diff --git a/package.json b/package.json index c4e4b990..893a3d15 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,8 @@ "date-fns": "2.29.3", "dayjs": "1.11.19", "dns-caching": "^0.2.7", - "email-templates": "12.0.3", + "dompurify": "^3.2.3", + "email-templates": "12.0.1", "express": "4.21.2", "express-openapi-validator": "4.13.8", "express-rate-limit": "6.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5b98a6b..f3c731a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: dns-caching: specifier: ^0.2.7 version: 0.2.7 + dompurify: + specifier: ^3.2.3 + version: 3.2.3 email-templates: specifier: 12.0.3 version: 12.0.3(@babel/core@7.24.7)(encoding@0.1.13)(handlebars@4.7.8)(mustache@4.2.0)(pug@3.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(underscore@1.13.7) @@ -4800,6 +4803,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.2.3: + resolution: {integrity: sha512-U1U5Hzc2MO0oW3DF+G9qYN0aT7atAou4AgI0XjWz061nyBPbdxkfdhfy5uMgGn6+oLFCfn44ZGbdDqCzVmlOWA==} + domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} @@ -13615,6 +13621,9 @@ snapshots: '@types/ua-parser-js@0.7.39': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.10': {} '@types/validator@13.15.10': {} @@ -15198,6 +15207,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.2.3: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@2.8.0: dependencies: dom-serializer: 1.4.1 diff --git a/seerr-api.yml b/seerr-api.yml index bf9d8827..fe5f3dc9 100644 --- a/seerr-api.yml +++ b/seerr-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. - name: watchlist description: Collection of media to watch later - name: blacklist @@ -695,6 +697,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: @@ -822,6 +879,92 @@ 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/AlbumResult' + gender: + type: string + area: + type: string + beginDate: + type: string + endDate: + type: string + tags: + type: array + items: + type: string + AlbumResult: + 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/AlbumResult' + artist: + type: array + items: + $ref: '#/components/schemas/ArtistResult' + tags: + type: array + items: + type: string + mediaInfo: + $ref: '#/components/schemas/MediaInfo' Genre: type: object properties: @@ -1296,6 +1439,8 @@ components: type: string example: '2020-09-12T10:00:27.000Z' readOnly: true + secondaryType: + type: string Cast: type: object properties: @@ -2947,6 +3092,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/{mbId}: + put: + summary: Update Lidarr instance + description: Updates an existing Lidarr instance with the provided values. + tags: + - settings + parameters: + - in: path + name: mbId + 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 mbId parameter. + tags: + - settings + parameters: + - in: path + name: mbId + 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/{mbId}/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: mbId + 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 @@ -5108,6 +5397,11 @@ paths: type: number example: 1 default: 1 + - in: query + name: type + schema: + type: string + enum: [movie, tv, music] responses: '200': description: Results @@ -5128,7 +5422,9 @@ paths: results: type: array items: - $ref: '#/components/schemas/Keyword' + oneOf: + - $ref: '#/components/schemas/Keyword' + - type: string /search/company: get: summary: Search for companies @@ -5977,6 +6273,59 @@ paths: name: type: string example: Genre Name + /discover/music: + get: + summary: Discover trending music albums + description: Returns a list of trending albums from ListenBrainz in a JSON object + tags: + - search + parameters: + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: range + schema: + type: string + enum: [week, month, year, all] + default: week + - in: query + name: sortBy + schema: + type: string + enum: + - listen_count.desc + - listen_count.asc + - release_date.desc + - release_date.asc + - title.asc + - title.desc + default: listen_count.desc + example: listen_count.desc + 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: + $ref: '#/components/schemas/AlbumResult' /discover/watchlist: get: summary: Get the Plex watchlist. @@ -6109,43 +6458,7 @@ paths: content: application/json: schema: - type: object - properties: - mediaType: - type: string - enum: [movie, tv] - example: movie - mediaId: - type: number - example: 123 - tvdbId: - type: number - example: 123 - seasons: - oneOf: - - type: array - items: - type: number - minimum: 0 - - type: string - enum: [all] - is4k: - type: boolean - example: false - serverId: - type: number - profileId: - type: number - rootFolder: - type: string - languageProfileId: - type: number - userId: - type: number - nullable: true - required: - - mediaType - - mediaId + $ref: '#/components/schemas/MainSettings' responses: '201': description: Succesfully created the request @@ -6229,7 +6542,7 @@ paths: properties: mediaType: type: string - enum: [movie, tv] + enum: [movie, tv, music] seasons: type: array items: @@ -6278,7 +6591,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: @@ -6786,6 +7099,483 @@ paths: $ref: '#/components/schemas/CreditCrew' id: type: number + /group/{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' + /group/{artistId}/discography: + get: + summary: Get artist discography + description: Returns the artist's discography with pagination and optional type filtering + tags: + - music + parameters: + - in: path + name: artistId + required: true + schema: + type: string + example: f59c5520-5f46-4d2c-b2c4-822eabf53419 + - in: query + name: page + schema: + type: number + default: 1 + example: 1 + - in: query + name: type + schema: + type: string + enum: [Album, Single, EP, Other] + description: Filter releases by type + - in: query + name: pageSize + schema: + type: number + default: 20 + example: 20 + responses: + '200': + description: Paginated artist discography + content: + application/json: + schema: + type: object + properties: + page: + type: number + pageInfo: + type: object + properties: + total: + type: number + totalPages: + type: number + results: + type: array + items: + type: object + properties: + id: + type: string + title: + type: string + type: + type: string + enum: [Album, Single, EP, Other] + releasedate: + type: string + images: + type: array + items: + type: object + properties: + CoverType: + type: string + Url: + type: string + '404': + description: Artist not found + '500': + description: Internal server error + /person/{personId}/discography: + get: + summary: Get person's musical discography + description: Returns the person's discography with pagination and optional type filtering + tags: + - music + parameters: + - in: path + name: personId + required: true + schema: + type: number + example: 287 + - in: query + name: artistId + required: true + schema: + type: string + example: 'b2fbd053-4380-412c-95d2-35c6da8f1011' + - in: query + name: type + schema: + type: string + enum: [Album, Single, EP, Other] + description: Filter releases by type + - in: query + name: page + schema: + type: number + default: 1 + example: 1 + - in: query + name: pageSize + schema: + type: number + default: 20 + example: 20 + responses: + '200': + description: Paginated person discography + content: + application/json: + schema: + type: object + properties: + page: + type: number + pageInfo: + type: object + properties: + total: + type: number + totalPages: + type: number + results: + type: array + items: + type: object + properties: + id: + type: string + title: + type: string + type: + type: string + enum: [Album, Single, EP, Other] + releasedate: + type: string + images: + type: array + items: + type: object + properties: + CoverType: + type: string + Url: + type: string + '404': + description: No music artist found for this person + '500': + description: Unable to retrieve artist discography + /music/{mbId}: + get: + summary: Get album details + description: Returns full album details in a JSON object. + tags: + - music + parameters: + - in: path + name: mbId + 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: Album details + content: + application/json: + schema: + $ref: '#/components/schemas/AlbumResult' + /music/{mbId}/wikipedia-extract: + get: + summary: Get artist Wikipedia extract + description: Returns Wikipedia extract for the artist of the specified album + tags: + - music + parameters: + - in: path + name: mbId + required: true + schema: + type: string + example: da04991d-8e8a-43be-9892-1e6342a77410 + - in: query + name: language + schema: + type: string + example: en + default: en + responses: + '200': + description: Artist Wikipedia extract + content: + application/json: + schema: + type: object + properties: + artistId: + type: string + artistName: + type: string + overview: + type: string + url: + type: string + '404': + description: Artist not found + content: + application/json: + schema: + type: object + properties: + message: + type: string + '500': + description: Internal server error + content: + application/json: + schema: + type: object + properties: + message: + type: string + /music/{mbId}/tracks: + get: + summary: Get album tracks + description: Returns tracks listing for the specified album + tags: + - music + parameters: + - in: path + name: mbId + required: true + schema: + type: string + example: d9390962-495f-466e-8674-e705e660ed8b + responses: + '200': + description: Album tracks + content: + application/json: + schema: + type: object + properties: + media: + type: array + items: + type: object + properties: + format: + type: string + track-count: + type: integer + position: + type: integer + tracks: + type: array + items: + type: object + properties: + id: + type: string + number: + type: string + title: + type: string + length: + type: integer + position: + type: integer + recording: + type: object + properties: + id: + type: string + title: + type: string + length: + type: integer + first-release-date: + type: string + video: + type: boolean + disambiguation: + type: string + '404': + description: Album not found + content: + application/json: + schema: + type: object + properties: + message: + type: string + '500': + description: Internal server error + content: + application/json: + schema: + type: object + properties: + message: + type: string + /music/{mbId}/discography: + get: + summary: Get artist discography + description: Returns a list of albums by the artist of the specified discography + tags: + - music + parameters: + - in: path + name: mbId + required: true + schema: + type: string + example: 87f17f8a-c0e2-406c-a149-8c8e311bf330 + - in: query + name: page + schema: + type: integer + minimum: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of artist albums + content: + application/json: + schema: + type: object + properties: + page: + type: integer + totalPages: + type: integer + totalResults: + type: integer + results: + type: array + items: + type: object + properties: + id: + type: integer + mediaType: + type: string + enum: [music] + title: + type: string + artistName: + type: string + posterPath: + type: string + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + '404': + description: Artist not found + content: + application/json: + schema: + type: object + properties: + message: + type: string + '500': + description: Internal server error + content: + application/json: + schema: + type: object + properties: + message: + type: string + /music/{mbId}/similar: + get: + tags: + - music + summary: Get similar artists for a music album + parameters: + - name: mbId + in: path + required: true + schema: + type: string + description: MusicBrainz ID of the album + - name: page + in: query + required: false + schema: + type: integer + default: 1 + description: Page number for pagination + responses: + '404': + description: Artist not found + content: + application/json: + schema: + type: object + properties: + message: + type: string + '500': + description: Internal server error + content: + application/json: + schema: + type: object + properties: + message: + type: string /media: get: summary: Get media @@ -6826,6 +7616,12 @@ paths: type: string enum: [added, modified, mediaAdded] default: added + - in: query + name: type + schema: + type: string + enum: [all, movie, tv, music, artist, album] + default: all responses: '200': description: Returned media @@ -7101,6 +7897,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/{mbId}: + 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: mbId + 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/server/api/coverartarchive/index.ts b/server/api/coverartarchive/index.ts new file mode 100644 index 00000000..4ced8c83 --- /dev/null +++ b/server/api/coverartarchive/index.ts @@ -0,0 +1,36 @@ +import ExternalAPI from '@server/api/externalapi'; +import cacheManager from '@server/lib/cache'; +import type { CoverArtResponse } from './interfaces'; + +class CoverArtArchive extends ExternalAPI { + constructor() { + super( + 'https://coverartarchive.org', + {}, + { + nodeCache: cacheManager.getCache('covertartarchive').data, + rateLimit: { + maxRPS: 50, + id: 'covertartarchive', + }, + } + ); + } + + public async getCoverArt(id: string): Promise { + try { + const data = await this.get( + `/release-group/${id}`, + undefined, + 43200 + ); + return data; + } catch (e) { + throw new Error( + `[CoverArtArchive] Failed to fetch cover art: ${e.message}` + ); + } + } +} + +export default CoverArtArchive; diff --git a/server/api/coverartarchive/interfaces.ts b/server/api/coverartarchive/interfaces.ts new file mode 100644 index 00000000..133a948b --- /dev/null +++ b/server/api/coverartarchive/interfaces.ts @@ -0,0 +1,24 @@ +interface CoverArtThumbnails { + 1200: string; + 250: string; + 500: string; + large: string; + small: string; +} + +interface CoverArtImage { + approved: boolean; + back: boolean; + comment: string; + edit: number; + front: boolean; + id: number; + image: string; + thumbnails: CoverArtThumbnails; + types: string[]; +} + +export interface CoverArtResponse { + images: CoverArtImage[]; + release: string; +} diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index c5f79258..1b2ba13f 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -56,7 +56,7 @@ interface JellyfinMediaFolder { } export interface JellyfinLibrary { - type: 'show' | 'movie'; + type: 'show' | 'movie' | 'music'; key: string; title: string; agent: string; @@ -66,7 +66,13 @@ export interface JellyfinLibraryItem { Name: string; Id: string; HasSubtitles: boolean; - Type: 'Movie' | 'Episode' | 'Season' | 'Series'; + Type: + | 'Movie' + | 'Episode' + | 'Season' + | 'Series' + | 'MusicAlbum' + | 'MusicArtist'; LocationType: 'FileSystem' | 'Offline' | 'Remote' | 'Virtual'; SeriesName?: string; SeriesId?: string; @@ -76,6 +82,8 @@ export interface JellyfinLibraryItem { IndexNumberEnd?: number; ParentIndexNumber?: number; MediaType: string; + AlbumId?: string; + ArtistId?: string; } export interface JellyfinMediaStream { @@ -104,6 +112,8 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem { Imdb?: string; Tvdb?: string; AniDB?: string; + MusicBrainzReleaseGroup: string | undefined; + MusicBrainzArtistId?: string; }; MediaSources?: JellyfinMediaSource[]; Width?: number; @@ -308,13 +318,7 @@ class JellyfinAPI extends ExternalAPI { } private mapLibraries(mediaFolders: JellyfinMediaFolder[]): JellyfinLibrary[] { - const excludedTypes = [ - 'music', - 'books', - 'musicvideos', - 'homevideos', - 'boxsets', - ]; + const excludedTypes = ['books', 'musicvideos', 'homevideos', 'boxsets']; return mediaFolders .filter((Item: JellyfinMediaFolder) => { @@ -327,7 +331,12 @@ class JellyfinAPI extends ExternalAPI { return { key: Item.Id, title: Item.Name, - type: Item.CollectionType === 'movies' ? 'movie' : 'show', + type: + Item.CollectionType === 'movies' + ? 'movie' + : Item.CollectionType === 'tvshows' + ? 'show' + : 'music', agent: 'jellyfin', }; }); @@ -336,7 +345,7 @@ class JellyfinAPI extends ExternalAPI { public async getLibraryContents(id: string): Promise { try { const libraryItemsResponse = await this.get( - `/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false` + `/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,MusicAlbum,MusicArtist,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false` ); return libraryItemsResponse.Items.filter( diff --git a/server/api/listenbrainz/index.ts b/server/api/listenbrainz/index.ts new file mode 100644 index 00000000..34d475ab --- /dev/null +++ b/server/api/listenbrainz/index.ts @@ -0,0 +1,76 @@ +import ExternalAPI from '@server/api/externalapi'; +import cacheManager from '@server/lib/cache'; +import type { + LbSimilarArtistResponse, + LbTopAlbumsResponse, +} from './interfaces'; + +class ListenBrainzAPI extends ExternalAPI { + constructor() { + super( + 'https://api.listenbrainz.org/1', + {}, + { + nodeCache: cacheManager.getCache('listenbrainz').data, + rateLimit: { + maxRPS: 50, + id: 'listenbrainz', + }, + } + ); + } + + public async getSimilarArtists( + artistMbid: string, + options: { + days?: number; + session?: number; + contribution?: number; + threshold?: number; + limit?: number; + skip?: number; + } = {} + ): Promise { + const { + days = 9000, + session = 300, + contribution = 5, + threshold = 15, + limit = 50, + skip = 30, + } = options; + + return this.getRolling( + '/similar-artists/json', + { + artist_mbids: artistMbid, + algorithm: `session_based_days_${days}_session_${session}_contribution_${contribution}_threshold_${threshold}_limit_${limit}_skip_${skip}`, + }, + 43200, + undefined, + 'https://labs.api.listenbrainz.org' + ); + } + + public async getTopAlbums({ + offset = 0, + range = 'week', + count = 20, + }: { + offset?: number; + range?: string; + count?: number; + }): Promise { + return this.get( + '/stats/sitewide/release-groups', + { + offset: offset.toString(), + range, + count: count.toString(), + }, + 43200 + ); + } +} + +export default ListenBrainzAPI; diff --git a/server/api/listenbrainz/interfaces.ts b/server/api/listenbrainz/interfaces.ts new file mode 100644 index 00000000..607ddb41 --- /dev/null +++ b/server/api/listenbrainz/interfaces.ts @@ -0,0 +1,31 @@ +export interface LbSimilarArtistResponse { + artist_mbid: string; + name: string; + comment: string; + type: string | null; + gender: string | null; + score: number; + reference_mbid: string; +} + +export interface LbReleaseGroup { + artist_mbids: string[]; + artist_name: string; + caa_id: number; + caa_release_mbid: string; + listen_count: number; + release_group_mbid: string; + release_group_name: string; +} + +export interface LbTopAlbumsResponse { + payload: { + count: number; + from_ts: number; + last_updated: number; + offset: number; + range: string; + release_groups: LbReleaseGroup[]; + to_ts: number; + }; +} diff --git a/server/api/musicbrainz/index.ts b/server/api/musicbrainz/index.ts new file mode 100644 index 00000000..5908c2f6 --- /dev/null +++ b/server/api/musicbrainz/index.ts @@ -0,0 +1,207 @@ +import ExternalAPI from '@server/api/externalapi'; +import cacheManager from '@server/lib/cache'; +import DOMPurify from 'dompurify'; +import type { + MbAlbumDetails, + MbArtistDetails, + MbLink, + MbSearchMultiResponse, +} from './interfaces'; + +class MusicBrainz extends ExternalAPI { + constructor() { + super( + 'https://api.lidarr.audio/api/v0.4', + {}, + { + nodeCache: cacheManager.getCache('musicbrainz').data, + rateLimit: { + maxRPS: 50, + id: 'musicbrainz', + }, + } + ); + } + + public searchMulti = async ({ + query, + }: { + query: string; + }): Promise => { + try { + const data = await this.get('/search', { + type: 'all', + query, + }); + + return data.filter( + (result) => !result.artist || result.artist.type === 'Group' + ); + } catch (e) { + return []; + } + }; + + public async searchArtist({ + query, + }: { + query: string; + }): Promise { + try { + const data = await this.get( + '/search', + { + type: 'artist', + query, + }, + 43200 + ); + + return data; + } catch (e) { + throw new Error(`[MusicBrainz] Failed to search artists: ${e.message}`); + } + } + + public async getAlbum({ + albumId, + }: { + albumId: string; + }): Promise { + try { + const data = await this.get( + `/album/${albumId}`, + {}, + 43200 + ); + + return data; + } catch (e) { + throw new Error( + `[MusicBrainz] Failed to fetch album details: ${e.message}` + ); + } + } + + public async getArtist({ + artistId, + }: { + artistId: string; + }): Promise { + try { + const artistData = await this.get( + `/artist/${artistId}`, + {}, + 43200 + ); + + return artistData; + } catch (e) { + throw new Error( + `[MusicBrainz] Failed to fetch artist details: ${e.message}` + ); + } + } + + public async getWikipediaExtract( + id: string, + language = 'en', + type: 'artist' | 'album' = 'album' + ): Promise { + try { + const data = + type === 'album' + ? await this.get(`/album/${id}`, { language }, 43200) + : await this.get( + `/artist/${id}`, + { language }, + 43200 + ); + + let targetLinks: MbLink[] | undefined; + if (type === 'album') { + const albumData = data as MbAlbumDetails; + const artistId = albumData.artists?.[0]?.id; + if (!artistId) return null; + + const artistData = await this.get( + `/artist/${artistId}`, + { language }, + 43200 + ); + targetLinks = artistData.links; + } else { + const artistData = data as MbArtistDetails; + targetLinks = artistData.links; + } + + const wikiLink = targetLinks?.find( + (l: MbLink) => l.type.toLowerCase() === 'wikidata' + )?.target; + + if (!wikiLink) return null; + + const wikiId = wikiLink.split('/').pop(); + if (!wikiId) return null; + + interface WikidataResponse { + entities: { + [key: string]: { + sitelinks?: { + [key: string]: { + title: string; + }; + }; + }; + }; + } + + interface WikipediaResponse { + query: { + pages: { + [key: string]: { + extract: string; + }; + }; + }; + } + + const wikiResponse = await fetch( + `https://www.wikidata.org/w/api.php?action=wbgetentities&props=sitelinks&ids=${wikiId}&format=json` + ); + const wikiData = (await wikiResponse.json()) as WikidataResponse; + + const wikipediaTitle = + wikiData.entities[wikiId]?.sitelinks?.[`${language}wiki`]?.title; + if (!wikipediaTitle) return null; + + const extractResponse = await fetch( + `https://${language}.wikipedia.org/w/api.php?action=query&prop=extracts&exintro&titles=${encodeURIComponent( + wikipediaTitle + )}&format=json&origin=*` + ); + const extractData = (await extractResponse.json()) as WikipediaResponse; + const extract = Object.values(extractData.query.pages)[0]?.extract; + + if (!extract) return null; + + const decoded = DOMPurify.sanitize(extract, { + ALLOWED_TAGS: [], // Strip all HTML tags + ALLOWED_ATTR: [], // Strip all attributes + }) + .trim() + .replace(/\s+/g, ' ') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + return decoded; + } catch (e) { + return null; + } + } +} + +export default MusicBrainz; diff --git a/server/api/musicbrainz/interfaces.ts b/server/api/musicbrainz/interfaces.ts new file mode 100644 index 00000000..18049c96 --- /dev/null +++ b/server/api/musicbrainz/interfaces.ts @@ -0,0 +1,126 @@ +interface MbMediaResult { + id: string; + score: number; +} + +export interface MbArtistResult extends MbMediaResult { + media_type: 'artist'; + artistname: string; + overview: string; + disambiguation: string; + type: 'Group' | 'Person'; + status: string; + sortname: string; + genres: string[]; + images: MbImage[]; + links: MbLink[]; + rating?: { + Count: number; + Value: number | null; + }; +} + +export interface MbAlbumResult extends MbMediaResult { + media_type: 'album'; + title: string; + artistid: string; + artists: MbArtistResult[]; + type: string; + releasedate: string; + disambiguation: string; + genres: string[]; + images: MbImage[]; + secondarytypes: string[]; + overview?: string; + releases?: { + id: string; + track_count?: number; + title?: string; + releasedate?: string; + }[]; +} + +export interface MbImage { + CoverType: 'Fanart' | 'Logo' | 'Poster' | 'Cover'; + Url: string; +} + +export interface MbLink { + target: string; + type: string; +} + +export interface MbSearchMultiResponse { + artist: MbArtistResult | null; + album: MbAlbumResult | null; + score: number; +} + +export interface MbArtistDetails extends MbArtistResult { + artistaliases: string[]; + oldids: string[]; + links: MbLink[]; + images: MbImage[]; + rating: { + Count: number; + Value: number | null; + }; + Albums?: Album[]; +} + +export interface MbAlbumDetails extends MbAlbumResult { + aliases: string[]; + artists: MbArtistResult[]; + releases: MbRelease[]; + rating: { + Count: number; + Value: number | null; + }; + overview: string; + secondaryTypes: string[]; +} + +interface MbRelease { + id: string; + title: string; + status: string; + releasedate: string; + country: string[]; + label: string[]; + track_count: number; + media: MbMedium[]; + tracks: MbTrack[]; + disambiguation: string; +} + +interface MbMedium { + Format: string; + Name: string; + Position: number; +} + +interface MbTrack { + id: string; + artistid: string; + trackname: string; + tracknumber: string; + trackposition: number; + mediumnumber: number; + durationms: number; + recordingid: string; + oldids: string[]; + oldrecordingids: string[]; +} + +interface Album { + Id: string; + OldIds: string[]; + ReleaseStatuses: string[]; + SecondaryTypes: string[]; + Title: string; + Type: string; + images?: { + CoverType: string; + Url: string; + }[]; +} diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index 03cdaf66..388f1033 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -16,7 +16,7 @@ export interface PlexLibraryItem { Guid?: { id: string; }[]; - type: 'movie' | 'show' | 'season' | 'episode'; + type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album' | 'track'; Media: Media[]; } @@ -28,7 +28,7 @@ interface PlexLibraryResponse { } export interface PlexLibrary { - type: 'show' | 'movie'; + type: 'show' | 'movie' | 'music'; key: string; title: string; agent: string; @@ -44,7 +44,7 @@ export interface PlexMetadata { ratingKey: string; parentRatingKey?: string; guid: string; - type: 'movie' | 'show' | 'season'; + type: 'movie' | 'show' | 'season' | 'artist' | 'album' | 'track'; title: string; Guid: { id: string; @@ -152,7 +152,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 === 'music' ) // Remove libraries that do not have a metadata agent set (usually personal video libraries) .filter((library) => library.agent !== 'com.plexapp.agents.none') @@ -227,7 +230,7 @@ class PlexAPI { options: { addedAt: number } = { addedAt: Date.now() - 1000 * 60 * 60, }, - mediaType: 'movie' | 'show' + mediaType: 'movie' | 'show' | 'music' ): Promise { const response = await this.plexClient.query({ uri: `/library/sections/${id}/all?type=${ diff --git a/server/api/plextv.ts b/server/api/plextv.ts index bfc75bc1..d72535b6 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -124,7 +124,7 @@ export interface PlexWatchlistItem { ratingKey: string; tmdbId: number; tvdbId?: number; - type: 'movie' | 'show'; + type: 'movie' | 'show' | 'album'; title: string; } diff --git a/server/api/servarr/lidarr.ts b/server/api/servarr/lidarr.ts new file mode 100644 index 00000000..76f97849 --- /dev/null +++ b/server/api/servarr/lidarr.ts @@ -0,0 +1,525 @@ +import logger from '@server/logger'; +import ServarrBase from './base'; + +interface LidarrMediaResult { + id: number; + mbId: string; + media_type: string; +} + +export interface LidarrArtistResult extends LidarrMediaResult { + artist: { + media_type: 'artist'; + artistName: string; + overview: string; + remotePoster?: string; + artistType: string; + genres: string[]; + }; +} + +export interface LidarrAlbumResult extends LidarrMediaResult { + album: { + media_type: 'music'; + title: string; + foreignAlbumId: string; + overview: string; + releaseDate: string; + albumType: string; + genres: string[]; + images: LidarrImage[]; + artist: { + artistName: string; + overview: string; + }; + }; +} + +export interface LidarrArtistDetails { + id: number; + foreignArtistId: string; + status: string; + ended: boolean; + artistName: string; + tadbId: number; + discogsId: number; + overview: string; + artistType: string; + disambiguation: string; + links: LidarrLink[]; + nextAlbum: LidarrAlbumResult | null; + lastAlbum: LidarrAlbumResult | null; + images: LidarrImage[]; + qualityProfileId: number; + profileId: number; + metadataProfileId: number; + monitored: boolean; + monitorNewItems: string; + genres: string[]; + tags: string[]; + added: string; + ratings: LidarrRating; + remotePoster?: string; +} + +export interface LidarrAlbumDetails { + id: number; + mbId: string; + foreignArtistId: string; + hasFile: boolean; + monitored: boolean; + title: string; + titleSlug: string; + path: string; + artistName: string; + disambiguation: string; + overview: string; + artistId: number; + foreignAlbumId: string; + anyReleaseOk: boolean; + profileId: number; + qualityProfileId: number; + duration: number; + isAvailable: boolean; + folderName: string; + metadataProfileId: number; + added: string; + albumType: string; + secondaryTypes: string[]; + mediumCount: number; + ratings: LidarrRating; + releaseDate: string; + releases: { + id: number; + albumId: number; + foreignReleaseId: string; + title: string; + status: string; + duration: number; + trackCount: number; + media: any[]; + mediumCount: number; + disambiguation: string; + country: any[]; + label: any[]; + format: string; + monitored: boolean; + }[]; + genres: string[]; + media: { + mediumNumber: number; + mediumName: string; + mediumFormat: string; + }[]; + artist: LidarrArtistDetails & { + artistName: string; + nextAlbum: any | null; + lastAlbum: any | null; + }; + images: LidarrImage[]; + links: { + url: string; + name: string; + }[]; + remoteCover?: string; +} + +export interface LidarrImage { + url: string; + coverType: string; +} + +export interface LidarrRating { + votes: number; + value: number; +} + +export interface LidarrLink { + url: string; + name: string; +} + +export interface LidarrRelease { + id: number; + albumId: number; + foreignReleaseId: string; + title: string; + status: string; + duration: number; + trackCount: number; + media: LidarrMedia[]; +} + +export interface LidarrMedia { + mediumNumber: number; + mediumFormat: string; + mediumName: string; +} + +export interface LidarrSearchResponse { + page: number; + total_results: number; + total_pages: number; + results: (LidarrArtistResult | LidarrAlbumResult)[]; +} + +export interface LidarrAlbumOptions { + [key: string]: unknown; + profileId: number; + mbId: string; + qualityProfileId: number; + rootFolderPath: string; + title: string; + monitored: boolean; + tags: string[]; + searchNow: boolean; + artistId: number; + artist: { + id: number; + foreignArtistId: string; + artistName: string; + qualityProfileId: number; + metadataProfileId: number; + rootFolderPath: string; + monitored: boolean; + monitorNewItems: string; + }; +} + +export interface LidarrArtistOptions { + [key: string]: unknown; + artistName: string; + qualityProfileId: number; + profileId: number; + rootFolderPath: string; + foreignArtistId: string; + monitored: boolean; + tags: number[]; + searchNow: boolean; + monitorNewItems: string; + monitor: string; + searchForMissingAlbums: boolean; +} + +export interface LidarrAlbum { + id: number; + mbId: string; + title: string; + monitored: boolean; + artistId: number; + foreignAlbumId: string; + titleSlug: string; + profileId: number; + duration: number; + albumType: string; + statistics: { + trackFileCount: number; + trackCount: number; + totalTrackCount: number; + sizeOnDisk: number; + percentOfTracks: number; + }; +} + +class LidarrAPI extends ServarrBase<{ albumId: number }> { + protected apiKey: string; + constructor({ url, apiKey }: { url: string; apiKey: string }) { + super({ url, apiKey, cacheName: 'lidarr', apiName: 'Lidarr' }); + this.apiKey = apiKey; + } + + public async getAlbums(): Promise { + try { + const data = await this.get('/album'); + return data; + } catch (e) { + throw new Error(`[Lidarr] Failed to retrieve albums: ${e.message}`); + } + } + + public async getArtist({ id }: { id: number }): Promise { + try { + const data = await this.get(`/artist/${id}`); + return data; + } catch (e) { + throw new Error(`[Lidarr] Failed to retrieve album: ${e.message}`); + } + } + + public async getAlbum({ id }: { id: number }): Promise { + try { + const data = await this.get(`/album/${id}`); + return data; + } catch (e) { + throw new Error(`[Lidarr] Failed to retrieve album: ${e.message}`); + } + } + + public async getAlbumByMusicBrainzId( + mbId: string + ): Promise { + try { + const data = await this.get('/album/lookup', { + term: `lidarr:${mbId}`, + }); + + if (!data[0]) { + throw new Error('Album not found'); + } + + return data[0]; + } catch (e) { + logger.error('Error retrieving album by foreign ID', { + label: 'Lidarr API', + errorMessage: e.message, + mbId: mbId, + }); + throw new Error('Album not found'); + } + } + + public async removeAlbum(albumId: number): Promise { + try { + await this.delete(`/album/${albumId}`, { + deleteFiles: 'true', + addImportExclusion: 'false', + }); + logger.info(`[Lidarr] Removed album ${albumId}`); + } catch (e) { + throw new Error(`[Lidarr] Failed to remove album: ${e.message}`); + } + } + + public async getArtistByMusicBrainzId( + mbId: string + ): Promise { + try { + const data = await this.get('/artist/lookup', { + term: `lidarr:${mbId}`, + }); + + if (!data[0]) { + throw new Error('Artist not found'); + } + + return data[0]; + } catch (e) { + logger.error('Error retrieving artist by foreign ID', { + label: 'Lidarr API', + errorMessage: e.message, + mbId: mbId, + }); + throw new Error('Artist not found'); + } + } + + public async addAlbum( + options: LidarrAlbumOptions + ): Promise { + try { + const data = await this.post('/album', options); + return data; + } catch (e) { + if (e.message.includes('This album has already been added')) { + logger.info('Album already exists in Lidarr, monitoring it in Lidarr', { + label: 'Lidarr', + albumTitle: options.title, + mbId: options.mbId, + }); + throw e; + } + + logger.error('Failed to add album to Lidarr', { + label: 'Lidarr', + options, + errorMessage: e.message, + }); + throw new Error(`[Lidarr] Failed to add album: ${e.message}`); + } + } + + public async addArtist( + options: LidarrArtistOptions + ): Promise { + try { + const data = await this.post('/artist', options); + return data; + } catch (e) { + logger.error('Failed to add artist to Lidarr', { + label: 'Lidarr', + options, + errorMessage: e.message, + }); + throw new Error(`[Lidarr] Failed to add artist: ${e.message}`); + } + } + + public async searchMulti(searchTerm: string): Promise { + try { + const data = await this.get< + { + foreignId: string; + artist?: { + artistName: string; + overview?: string; + remotePoster?: string; + artistType?: string; + genres: string[]; + foreignArtistId: string; + }; + album?: { + title: string; + foreignAlbumId: string; + overview?: string; + releaseDate?: string; + albumType: string; + genres: string[]; + images: LidarrImage[]; + artist: { + artistName: string; + overview?: string; + }; + remoteCover?: string; + }; + id: number; + }[] + >( + '/search', + { + term: searchTerm, + }, + undefined, + { + headers: { + 'X-Api-Key': this.apiKey, + }, + } + ); + + if (!data) { + throw new Error('No data received from Lidarr'); + } + + const results = data.map((item) => { + if (item.album) { + return { + id: item.id, + mbId: item.album.foreignAlbumId, + media_type: 'music' as const, + album: { + media_type: 'music' as const, + title: item.album.title, + foreignAlbumId: item.album.foreignAlbumId, + overview: item.album.overview || '', + releaseDate: item.album.releaseDate || '', + albumType: item.album.albumType, + genres: item.album.genres, + images: item.album.remoteCover + ? [ + { + url: item.album.remoteCover, + coverType: 'cover', + }, + ] + : item.album.images, + artist: { + artistName: item.album.artist.artistName, + overview: item.album.artist.overview || '', + }, + }, + } satisfies LidarrAlbumResult; + } + + if (item.artist) { + return { + id: item.id, + mbId: item.artist.foreignArtistId, + media_type: 'artist' as const, + artist: { + media_type: 'artist' as const, + artistName: item.artist.artistName, + overview: item.artist.overview || '', + remotePoster: item.artist.remotePoster, + artistType: item.artist.artistType || '', + genres: item.artist.genres, + }, + } satisfies LidarrArtistResult; + } + + throw new Error('Invalid search result type'); + }); + + return { + page: 1, + total_pages: 1, + total_results: results.length, + results, + }; + } catch (e) { + logger.error('Failed to search Lidarr', { + label: 'Lidarr API', + errorMessage: e.message, + }); + throw new Error(`[Lidarr] Failed to search: ${e.message}`); + } + } + + public async updateArtist( + artist: LidarrArtistDetails + ): Promise { + try { + const data = await this.put(`/artist/${artist.id}`, { + ...artist, + } as Record); + return data; + } catch (e) { + logger.error('Failed to update artist in Lidarr', { + label: 'Lidarr', + artistId: artist.id, + errorMessage: e.message, + }); + throw new Error(`[Lidarr] Failed to update artist: ${e.message}`); + } + } + + public async updateAlbum(album: LidarrAlbum): Promise { + try { + const data = await this.put(`/album/${album.id}`, { + ...album, + } as Record); + return data; + } catch (e) { + logger.error('Failed to update album in Lidarr', { + label: 'Lidarr', + albumId: album.id, + errorMessage: e.message, + }); + throw new Error(`[Lidarr] Failed to update album: ${e.message}`); + } + } + + public async searchAlbum(albumId: number): Promise { + logger.info('Executing album search command', { + label: 'Lidarr API', + albumId, + }); + + try { + await this.post('/command', { + name: 'AlbumSearch', + albumIds: [albumId], + }); + } catch (e) { + logger.error( + 'Something went wrong while executing Lidarr album search.', + { + label: 'Lidarr API', + errorMessage: e.message, + albumId, + } + ); + } + } +} + +export default LidarrAPI; diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 74ec5841..0509ea44 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -16,6 +16,7 @@ import type { TmdbNetwork, TmdbPersonCombinedCredits, TmdbPersonDetails, + TmdbPersonSearchResponse, TmdbProductionCompany, TmdbRegion, TmdbSearchMovieResponse, @@ -230,6 +231,31 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { } }; + public async searchPerson({ + query, + page = 1, + includeAdult = false, + language = 'en', + }: SearchOptions): Promise { + try { + const data = await this.get('/search/person', { + query, + page: page.toString(), + include_adult: includeAdult ? 'true' : 'false', + language, + }); + + return data; + } catch (e) { + return { + page: 1, + results: [], + total_pages: 1, + total_results: 0, + }; + } + } + public getPerson = async ({ personId, language = this.locale, diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index 65ba18f8..a39ed379 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -469,3 +469,15 @@ export interface TmdbWatchProviderRegion { english_name: string; native_name: string; } + +export interface TmdbPersonSearchResponse extends TmdbPaginatedResponse { + results: TmdbPersonSearchResult[]; +} + +export interface TmdbPersonSearchResult + extends Omit { + gender: number; + known_for_department: string; + original_name: string; + known_for: (TmdbMovieResult | TmdbTvResult)[]; +} diff --git a/server/constants/discover.ts b/server/constants/discover.ts index fda06822..15e8088e 100644 --- a/server/constants/discover.ts +++ b/server/constants/discover.ts @@ -4,6 +4,7 @@ export enum DiscoverSliderType { RECENTLY_ADDED = 1, RECENT_REQUESTS, PLEX_WATCHLIST, + POPULAR_ALBUMS, TRENDING, POPULAR_MOVIES, MOVIE_GENRES, @@ -50,51 +51,57 @@ export const defaultSliders: Partial[] = [ order: 3, }, { - type: DiscoverSliderType.POPULAR_MOVIES, + type: DiscoverSliderType.POPULAR_ALBUMS, enabled: true, isBuiltIn: true, order: 4, }, { - type: DiscoverSliderType.MOVIE_GENRES, + type: DiscoverSliderType.POPULAR_MOVIES, enabled: true, isBuiltIn: true, order: 5, }, { - type: DiscoverSliderType.UPCOMING_MOVIES, + type: DiscoverSliderType.MOVIE_GENRES, enabled: true, isBuiltIn: true, order: 6, }, { - type: DiscoverSliderType.STUDIOS, + type: DiscoverSliderType.UPCOMING_MOVIES, enabled: true, isBuiltIn: true, order: 7, }, { - type: DiscoverSliderType.POPULAR_TV, + type: DiscoverSliderType.STUDIOS, enabled: true, isBuiltIn: true, order: 8, }, { - type: DiscoverSliderType.TV_GENRES, + type: DiscoverSliderType.POPULAR_TV, enabled: true, isBuiltIn: true, order: 9, }, { - type: DiscoverSliderType.UPCOMING_TV, + type: DiscoverSliderType.TV_GENRES, enabled: true, isBuiltIn: true, order: 10, }, { - type: DiscoverSliderType.NETWORKS, + type: DiscoverSliderType.UPCOMING_TV, enabled: true, isBuiltIn: true, order: 11, }, + { + type: DiscoverSliderType.NETWORKS, + enabled: true, + isBuiltIn: true, + order: 12, + }, ]; diff --git a/server/constants/issue.ts b/server/constants/issue.ts index 2c9dcb69..9e320866 100644 --- a/server/constants/issue.ts +++ b/server/constants/issue.ts @@ -2,7 +2,8 @@ export enum IssueType { VIDEO = 1, AUDIO = 2, SUBTITLES = 3, - OTHER = 4, + LYRICS = 4, + OTHER = 5, } export enum IssueStatus { @@ -14,5 +15,6 @@ export const IssueTypeName = { [IssueType.AUDIO]: 'Audio', [IssueType.VIDEO]: 'Video', [IssueType.SUBTITLES]: 'Subtitle', + [IssueType.LYRICS]: 'Lyrics', [IssueType.OTHER]: 'Other', }; diff --git a/server/constants/media.ts b/server/constants/media.ts index 4bac7c03..cc83784b 100644 --- a/server/constants/media.ts +++ b/server/constants/media.ts @@ -9,6 +9,7 @@ export enum MediaRequestStatus { export enum MediaType { MOVIE = 'movie', TV = 'tv', + MUSIC = 'music', } export enum MediaStatus { diff --git a/server/entity/Blacklist.ts b/server/entity/Blacklist.ts index 64c44e50..20e1877f 100644 --- a/server/entity/Blacklist.ts +++ b/server/entity/Blacklist.ts @@ -18,7 +18,7 @@ import { import type { ZodNumber, ZodOptional, ZodString } from 'zod'; @Entity() -@Unique(['tmdbId']) +@Unique(['tmdbId', 'mbId']) export class Blacklist implements BlacklistItem { @PrimaryGeneratedColumn() public id: number; @@ -29,9 +29,13 @@ export class Blacklist implements BlacklistItem { @Column({ nullable: true, type: 'varchar' }) title?: string; - @Column() + @Column({ nullable: true }) @Index() - public tmdbId: number; + public tmdbId?: number; + + @Column({ nullable: true }) + @Index() + public mbId?: string; @ManyToOne(() => User, (user) => user.id, { eager: true, @@ -62,6 +66,7 @@ export class Blacklist implements BlacklistItem { mediaType: MediaType; title?: ZodOptional['_output']; tmdbId: ZodNumber['_output']; + mbId?: ZodOptional['_output'] blacklistedTags?: string; }; }, @@ -74,9 +79,10 @@ export class Blacklist implements BlacklistItem { const mediaRepository = em.getRepository(Media); let media = await mediaRepository.findOne({ - where: { - tmdbId: blacklistRequest.tmdbId, - }, + where: + blacklistRequest.mediaType === 'music' + ? { mbId: blacklistRequest.mbId } + : { tmdbId: blacklistRequest.tmdbId }, }); const blacklistRepository = em.getRepository(this); @@ -86,6 +92,7 @@ export class Blacklist implements BlacklistItem { if (!media) { media = new Media({ tmdbId: blacklistRequest.tmdbId, + mbId: blacklistRequest.mbId, status: MediaStatus.BLACKLISTED, status4k: MediaStatus.BLACKLISTED, mediaType: blacklistRequest.mediaType, diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 38726f70..34bb82d1 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'; @@ -29,33 +30,39 @@ import Season from './Season'; class Media { public static async getRelatedMedia( user: User | undefined, - tmdbIds: number | number[] + ids: (number | string)[] ): Promise { const mediaRepository = getRepository(Media); try { - let finalIds: number[]; - if (!Array.isArray(tmdbIds)) { - finalIds = [tmdbIds]; - } else { - finalIds = tmdbIds; - } - - if (finalIds.length === 0) { + if (ids.length === 0) { return []; } - const media = await mediaRepository + const tmdbIds = ids.filter((id): id is number => typeof id === 'number'); + const mbIds = ids.filter((id): id is string => typeof id === 'string'); + + const queryBuilder = mediaRepository .createQueryBuilder('media') .leftJoinAndSelect( 'media.watchlists', 'watchlist', - 'media.id= watchlist.media and watchlist.requestedBy = :userId', + 'media.id = watchlist.media and watchlist.requestedBy = :userId', { userId: user?.id } - ) //, - .where(' media.tmdbId in (:...finalIds)', { finalIds }) - .getMany(); + ); + if (tmdbIds.length > 0 && mbIds.length > 0) { + queryBuilder.where( + '(media.tmdbId IN (:...tmdbIds) OR media.mbId IN (:...mbIds))', + { tmdbIds, mbIds } + ); + } else if (tmdbIds.length > 0) { + queryBuilder.where('media.tmdbId IN (:...tmdbIds)', { tmdbIds }); + } else if (mbIds.length > 0) { + queryBuilder.where('media.mbId IN (:...mbIds)', { mbIds }); + } + + const media = await queryBuilder.getMany(); return media; } catch (e) { logger.error(e.message); @@ -64,14 +71,19 @@ class Media { } public static async getMedia( - id: number, + id: number | string, mediaType: MediaType ): Promise { const mediaRepository = getRepository(Media); try { + const whereClause = + typeof id === 'string' + ? { mbId: id, mediaType } + : { tmdbId: id, mediaType }; + const media = await mediaRepository.findOne({ - where: { tmdbId: id, mediaType: mediaType }, + where: whereClause, relations: { requests: true, issues: true }, }); @@ -88,7 +100,7 @@ class Media { @Column({ type: 'varchar' }) public mediaType: MediaType; - @Column() + @Column({ nullable: true }) @Index() public tmdbId: number; @@ -100,6 +112,10 @@ class Media { @Index() public imdbId?: string; + @Column({ nullable: true }) + @Index() + public mbId?: string; + @Column({ type: 'int', default: MediaStatus.UNKNOWN }) public status: MediaStatus; @@ -319,6 +335,21 @@ class Media { } } } + + if (this.mediaType === MediaType.MUSIC) { + if (this.serviceId !== null && this.externalServiceSlug !== null) { + const settings = getSettings(); + const server = settings.lidarr.find( + (lidarr) => lidarr.id === this.serviceId + ); + + if (server) { + this.serviceUrl = server.externalUrl + ? `${server.externalUrl}/album/${this.externalServiceSlug}` + : LidarrAPI.buildUrl(server, `/album/${this.externalServiceSlug}`); + } + } + } } @AfterLoad() @@ -374,6 +405,20 @@ class Media { ); } } + + if (this.mediaType === MediaType.MUSIC) { + if ( + this.externalServiceId !== undefined && + this.externalServiceId !== null && + this.serviceId !== undefined && + this.serviceId !== null + ) { + this.downloadStatus = downloadTracker.getMusicProgress( + this.serviceId, + this.externalServiceId + ); + } + } } } diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index cdfa17c3..7b22b623 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1,6 +1,18 @@ +import type { LidarrAlbumDetails } 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 { + AddSeriesOptions, + SonarrSeries, +} from '@server/api/servarr/sonarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; -import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; +import type { + TmdbMovieDetails, + TmdbTvDetails, +} from '@server/api/themoviedb/interfaces'; import { MediaRequestStatus, MediaStatus, @@ -46,8 +58,12 @@ export class MediaRequest { requestBody: MediaRequestBody, user: User, options: MediaRequestOptions = {} - ): Promise { + ): Promise { const tmdb = new TheMovieDb(); + const lidarr = new LidarrAPI({ + apiKey: getSettings().lidarr[0].apiKey, + url: LidarrAPI.buildUrl(getSettings().lidarr[0], '/api/v1'), + }); const mediaRepository = getRepository(Media); const requestRepository = getRepository(MediaRequest); const userRepository = getRepository(User); @@ -109,22 +125,50 @@ export class MediaRequest { ); } + 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(); if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) { 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 = requestBody.mediaType === MediaType.MOVIE ? await tmdb.getMovie({ movieId: requestBody.mediaId }) - : await tmdb.getTvShow({ tvId: requestBody.mediaId }); + : requestBody.mediaType === MediaType.TV + ? await tmdb.getTvShow({ tvId: requestBody.mediaId }) + : await lidarr.getAlbumByMusicBrainzId(requestBody.mediaId.toString()); let media = await mediaRepository.findOne({ where: { - tmdbId: requestBody.mediaId, + mbId: + requestBody.mediaType === MediaType.MUSIC + ? requestBody.mediaId.toString() + : undefined, + tmdbId: + requestBody.mediaType !== MediaType.MUSIC + ? requestBody.mediaId + : undefined, mediaType: requestBody.mediaType, }, relations: ['requests'], @@ -132,16 +176,27 @@ export class MediaRequest { 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, + mbId: + requestBody.mediaType === MediaType.MUSIC + ? requestBody.mediaId.toString() + : undefined, + tmdbId: + requestBody.mediaType !== MediaType.MUSIC + ? requestBody.mediaId + : undefined, mediaType: requestBody.mediaType, }); } else { if (media.status === MediaStatus.BLACKLISTED) { logger.warn('Request for media blocked due to being blacklisted', { - tmdbId: tmdbMedia.id, + mbId: + requestBody.mediaType === MediaType.MUSIC + ? requestBody.mediaId + : undefined, + tmdbId: + requestBody.mediaType !== MediaType.MUSIC + ? tmdbMedia.id + : undefined, mediaType: requestBody.mediaType, label: 'Media Request', }); @@ -152,18 +207,20 @@ export class MediaRequest { if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) { media.status = MediaStatus.PENDING; } - - if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) { - media.status4k = 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 }) + .where( + requestBody.mediaType === MediaType.MUSIC + ? 'media.mbId = :mbId' + : 'media.tmdbId = :tmdbId', + requestBody.mediaType === MediaType.MUSIC + ? { mbId: requestBody.mediaId } + : { tmdbId: tmdbMedia.id } + ) .andWhere('media.mediaType = :mediaType', { mediaType: requestBody.mediaType, }) @@ -172,14 +229,12 @@ export class MediaRequest { if (existing && existing.length > 0) { // If there is an existing movie request that isn't declined, don't allow a new one. if ( - requestBody.mediaType === MediaType.MOVIE && - existing[0].status !== MediaRequestStatus.DECLINED && - existing[0].status !== MediaRequestStatus.COMPLETED + requestBody.mediaType === MediaType.MUSIC && + existing[0].status !== MediaRequestStatus.DECLINED ) { logger.warn('Duplicate request for media blocked', { - tmdbId: tmdbMedia.id, + mbId: requestBody.mediaId, mediaType: requestBody.mediaType, - is4k: requestBody.is4k, label: 'Media Request', }); @@ -201,131 +256,116 @@ export class MediaRequest { } } - // Apply overrides if the user is not an admin or has the "advanced request" permission + const isTmdbMedia = ( + media: LidarrAlbumDetails | TmdbMovieDetails | TmdbTvDetails + ): media is TmdbMovieDetails | TmdbTvDetails => { + return 'original_language' in media && 'keywords' in media; + }; + + let prioritizedRule: OverrideRule | undefined; + const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], { type: 'or', }); - let rootFolder = requestBody.rootFolder; - let profileId = requestBody.profileId; - let tags = requestBody.tags; - if (useOverrides) { - const defaultRadarrId = requestBody.is4k - ? settings.radarr.findIndex((r) => r.is4k && r.isDefault) - : settings.radarr.findIndex((r) => !r.is4k && r.isDefault); - const defaultSonarrId = requestBody.is4k - ? settings.sonarr.findIndex((s) => s.is4k && s.isDefault) - : settings.sonarr.findIndex((s) => !s.is4k && s.isDefault); + if (requestBody.mediaType !== MediaType.MUSIC) { + const defaultRadarrId = requestBody.is4k + ? settings.radarr.findIndex((r) => r.is4k && r.isDefault) + : settings.radarr.findIndex((r) => !r.is4k && r.isDefault); + const defaultSonarrId = requestBody.is4k + ? settings.sonarr.findIndex((s) => s.is4k && s.isDefault) + : settings.sonarr.findIndex((s) => !s.is4k && s.isDefault); - const overrideRuleRepository = getRepository(OverrideRule); - const overrideRules = await overrideRuleRepository.find({ - where: - requestBody.mediaType === MediaType.MOVIE - ? { radarrServiceId: defaultRadarrId } - : { sonarrServiceId: defaultSonarrId }, - }); + const overrideRuleRepository = getRepository(OverrideRule); + const overrideRules = await overrideRuleRepository.find({ + where: + requestBody.mediaType === MediaType.MOVIE + ? { radarrServiceId: defaultRadarrId } + : { sonarrServiceId: defaultSonarrId }, + }); - const appliedOverrideRules = overrideRules.filter((rule) => { - const hasAnimeKeyword = - 'results' in tmdbMedia.keywords && - tmdbMedia.keywords.results.some( - (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID - ); - - // Skip override rules if the media is an anime TV show as anime TV - // is handled by default and override rules do not explicitly include - // the anime keyword - if ( - requestBody.mediaType === MediaType.TV && - hasAnimeKeyword && - (!rule.keywords || - !rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID)) - ) { - return false; - } - - if ( - rule.users && - !rule.users - .split(',') - .some((userId) => Number(userId) === requestUser.id) - ) { - return false; - } - if ( - rule.genre && - !rule.genre - .split(',') - .some((genreId) => - tmdbMedia.genres - .map((genre) => genre.id) - .includes(Number(genreId)) - ) - ) { - return false; - } - if ( - rule.language && - !rule.language - .split('|') - .some((languageId) => languageId === tmdbMedia.original_language) - ) { - return false; - } - if ( - rule.keywords && - !rule.keywords.split(',').some((keywordId) => { - let keywordList: TmdbKeyword[] = []; - - if ('keywords' in tmdbMedia.keywords) { - keywordList = tmdbMedia.keywords.keywords; - } else if ('results' in tmdbMedia.keywords) { - keywordList = tmdbMedia.keywords.results; + const appliedOverrideRules = overrideRules.filter((rule) => { + if (isTmdbMedia(tmdbMedia)) { + if ( + rule.language && + !rule.language + .split('|') + .some( + (languageId) => languageId === tmdbMedia.original_language + ) + ) { + return false; } - return keywordList - .map((keyword: TmdbKeyword) => keyword.id) - .includes(Number(keywordId)); - }) - ) { - return false; - } - return true; - }); + if (rule.keywords) { + const keywordList = + 'results' in tmdbMedia.keywords + ? tmdbMedia.keywords.results + : 'keywords' in tmdbMedia.keywords + ? tmdbMedia.keywords.keywords + : []; - // hacky way to prioritize rules - // TODO: make this better - const prioritizedRule = appliedOverrideRules.sort((a, b) => { - const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords']; + if ( + !rule.keywords + .split(',') + .some((keywordId) => + keywordList.map((k) => k.id).includes(Number(keywordId)) + ) + ) { + return false; + } + } - const aSpecificity = keys.filter((key) => a[key] !== null).length; - const bSpecificity = keys.filter((key) => b[key] !== null).length; + const hasAnimeKeyword = + 'results' in tmdbMedia.keywords && + tmdbMedia.keywords.results.some( + (keyword) => keyword.id === ANIME_KEYWORD_ID + ); - // Take the rule with the most specific condition first - return bSpecificity - aSpecificity; - })[0]; + if ( + requestBody.mediaType === MediaType.TV && + hasAnimeKeyword && + (!rule.keywords || + !rule.keywords + .split(',') + .map(Number) + .includes(ANIME_KEYWORD_ID)) + ) { + return false; + } + } - if (prioritizedRule) { - if (prioritizedRule.rootFolder) { - rootFolder = prioritizedRule.rootFolder; - } - if (prioritizedRule.profileId) { - profileId = prioritizedRule.profileId; - } - if (prioritizedRule.tags) { - tags = [ - ...new Set([ - ...(tags || []), - ...prioritizedRule.tags.split(',').map((tag) => Number(tag)), - ]), - ]; - } + if ( + rule.users && + !rule.users + .split(',') + .some((userId) => Number(userId) === requestUser.id) + ) { + return false; + } - logger.debug('Override rule applied.', { - label: 'Media Request', - overrides: prioritizedRule, + return true; }); + + prioritizedRule = appliedOverrideRules.sort((a, b) => { + const keys: (keyof OverrideRule)[] = [ + 'genre', + 'language', + 'keywords', + ]; + return ( + keys.filter((key) => b[key] !== null).length - + keys.filter((key) => a[key] !== null).length + ); + })[0]; + + if (prioritizedRule) { + logger.debug('Override rule applied.', { + label: 'Media Request', + overrides: prioritizedRule, + }); + } } } @@ -367,28 +407,31 @@ export class MediaRequest { : undefined, is4k: requestBody.is4k, serverId: requestBody.serverId, - profileId: profileId, - rootFolder: rootFolder, - tags: tags, + profileId: prioritizedRule?.profileId ?? requestBody.profileId, + rootFolder: prioritizedRule?.rootFolder ?? requestBody.rootFolder, + tags: prioritizedRule?.tags + ? [ + ...new Set([ + ...(requestBody.tags || []), + ...prioritizedRule.tags.split(',').map(Number), + ]), + ] + : requestBody.tags, isAutoRequest: options.isAutoRequest ?? false, }); await requestRepository.save(request); return request; - } else { + } else if (requestBody.mediaType === MediaType.TV) { const tmdbMediaShow = tmdbMedia as Awaited< ReturnType >; - let requestedSeasons = + const requestedSeasons = requestBody.seasons === 'all' ? tmdbMediaShow.seasons .filter((season) => season.season_number !== 0) .map((season) => season.season_number) : (requestBody.seasons as number[]); - if (!settings.main.enableSpecialEpisodes) { - requestedSeasons = requestedSeasons.filter((sn) => sn > 0); - } - let existingSeasons: number[] = []; // We need to check existing requests on this title to make sure we don't double up on seasons that were @@ -477,10 +520,10 @@ export class MediaRequest { : undefined, is4k: requestBody.is4k, serverId: requestBody.serverId, - profileId: profileId, - rootFolder: rootFolder, + profileId: requestBody.profileId, + rootFolder: requestBody.rootFolder, languageProfileId: requestBody.languageProfileId, - tags: tags, + tags: requestBody.tags, seasons: finalSeasons.map( (sn) => new SeasonRequest({ @@ -504,6 +547,42 @@ 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, + media, + requestedBy: requestUser, + 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; } @@ -715,6 +794,10 @@ export class MediaRequest { type: Notification ) { const tmdb = new TheMovieDb(); + const lidarr = new LidarrAPI({ + apiKey: getSettings().lidarr[0].apiKey, + url: LidarrAPI.buildUrl(getSettings().lidarr[0], '/api/v1'), + }); try { const mediaType = entity.type === MediaType.MOVIE ? 'Movie' : 'Series'; @@ -797,6 +880,34 @@ export class MediaRequest { }, ], }); + } else if (this.type === MediaType.MUSIC) { + if (!media.mbId) { + throw new Error('MusicBrainz ID not found for media'); + } + + const album = await lidarr.getAlbumByMusicBrainzId(media.mbId); + + const coverUrl = album.images?.find( + (img) => img.coverType === 'Cover' + )?.url; + + notificationManager.sendNotification(type, { + media, + request: this, + notifyAdmin, + notifySystem, + notifyUser: notifyAdmin ? undefined : this.requestedBy, + event, + subject: `${album.title}${ + album.releaseDate ? ` (${album.releaseDate.slice(0, 4)})` : '' + }`, + message: truncate(album.overview || '', { + length: 500, + separator: /\s/, + omission: '…', + }), + image: coverUrl, + }); } } catch (e) { logger.error('Something went wrong sending media notification(s)', { diff --git a/server/entity/User.ts b/server/entity/User.ts index 8a96f396..a4c77e19 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -124,6 +124,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, @@ -334,6 +340,30 @@ 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; + + // Count music requests made during quota period + 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, @@ -357,6 +387,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/entity/Watchlist.ts b/server/entity/Watchlist.ts index 10c26246..7a06dea8 100644 --- a/server/entity/Watchlist.ts +++ b/server/entity/Watchlist.ts @@ -26,6 +26,7 @@ export class NotFoundError extends Error { @Entity() @Unique('UNIQUE_USER_DB', ['tmdbId', 'requestedBy']) +@Unique('UNIQUE_USER_FOREIGN', ['mbId', 'requestedBy']) export class Watchlist implements WatchlistItem { @PrimaryGeneratedColumn() id: number; @@ -39,9 +40,13 @@ export class Watchlist implements WatchlistItem { @Column({ type: 'varchar' }) title = ''; - @Column() + @Column({ nullable: true }) @Index() - public tmdbId: number; + public tmdbId?: number; + + @Column({ nullable: true }) + @Index() + public mbId?: string; @ManyToOne(() => User, (user) => user.watchlists, { eager: true, @@ -52,6 +57,7 @@ export class Watchlist implements WatchlistItem { @ManyToOne(() => Media, (media) => media.watchlists, { eager: true, onDelete: 'CASCADE', + nullable: false, }) public media: Media; @@ -77,7 +83,8 @@ export class Watchlist implements WatchlistItem { mediaType: MediaType; ratingKey?: ZodOptional['_output']; title?: ZodOptional['_output']; - tmdbId: ZodNumber['_output']; + tmdbId?: ZodNumber['_output']; + mbId?: ZodOptional['_output']; }; user: User; }): Promise { @@ -85,46 +92,88 @@ export class Watchlist implements WatchlistItem { const mediaRepository = getRepository(Media); const tmdb = new TheMovieDb(); - const tmdbMedia = - watchlistRequest.mediaType === MediaType.MOVIE - ? await tmdb.getMovie({ movieId: watchlistRequest.tmdbId }) - : await tmdb.getTvShow({ tvId: watchlistRequest.tmdbId }); + let media: Media | null; - const existing = await watchlistRepository - .createQueryBuilder('watchlist') - .leftJoinAndSelect('watchlist.requestedBy', 'user') - .where('user.id = :userId', { userId: user.id }) - .andWhere('watchlist.tmdbId = :tmdbId', { - tmdbId: watchlistRequest.tmdbId, - }) - .andWhere('watchlist.mediaType = :mediaType', { - mediaType: watchlistRequest.mediaType, - }) - .getMany(); + if (watchlistRequest.mediaType === MediaType.MUSIC) { + if (!watchlistRequest.mbId) { + throw new Error('MusicBrainz ID is required for music media type'); + } - if (existing && existing.length > 0) { - logger.warn('Duplicate request for watchlist blocked', { - tmdbId: watchlistRequest.tmdbId, - mediaType: watchlistRequest.mediaType, - label: 'Watchlist', + const existing = await watchlistRepository + .createQueryBuilder('watchlist') + .leftJoinAndSelect('watchlist.requestedBy', 'user') + .where('user.id = :userId', { userId: user.id }) + .andWhere('watchlist.mbId = :mbId', { mbId: watchlistRequest.mbId }) + .andWhere('watchlist.mediaType = :mediaType', { + mediaType: watchlistRequest.mediaType, + }) + .getMany(); + + if (existing && existing.length > 0) { + logger.warn('Duplicate request for watchlist blocked', { + mbId: watchlistRequest.mbId, + mediaType: watchlistRequest.mediaType, + label: 'Watchlist', + }); + throw new DuplicateWatchlistRequestError(); + } + + media = await mediaRepository.findOne({ + where: { mbId: watchlistRequest.mbId, mediaType: MediaType.MUSIC }, }); - throw new DuplicateWatchlistRequestError(); - } + if (!media) { + media = new Media({ + mbId: watchlistRequest.mbId, + mediaType: MediaType.MUSIC, + }); + } + } else { + // For movies/TV, validate tmdbId exists + if (!watchlistRequest.tmdbId) { + throw new Error('TMDB ID is required for movie/TV media types'); + } - let media = await mediaRepository.findOne({ - where: { - tmdbId: watchlistRequest.tmdbId, - mediaType: watchlistRequest.mediaType, - }, - }); + const tmdbMedia = + watchlistRequest.mediaType === MediaType.MOVIE + ? await tmdb.getMovie({ movieId: watchlistRequest.tmdbId }) + : await tmdb.getTvShow({ tvId: watchlistRequest.tmdbId }); - if (!media) { - media = new Media({ - tmdbId: tmdbMedia.id, - tvdbId: tmdbMedia.external_ids.tvdb_id, - mediaType: watchlistRequest.mediaType, + const existing = await watchlistRepository + .createQueryBuilder('watchlist') + .leftJoinAndSelect('watchlist.requestedBy', 'user') + .where('user.id = :userId', { userId: user.id }) + .andWhere('watchlist.tmdbId = :tmdbId', { + tmdbId: watchlistRequest.tmdbId, + }) + .andWhere('watchlist.mediaType = :mediaType', { + mediaType: watchlistRequest.mediaType, + }) + .getMany(); + + if (existing && existing.length > 0) { + logger.warn('Duplicate request for watchlist blocked', { + tmdbId: watchlistRequest.tmdbId, + mediaType: watchlistRequest.mediaType, + label: 'Watchlist', + }); + throw new DuplicateWatchlistRequestError(); + } + + media = await mediaRepository.findOne({ + where: { + tmdbId: watchlistRequest.tmdbId, + mediaType: watchlistRequest.mediaType, + }, }); + + if (!media) { + media = new Media({ + tmdbId: tmdbMedia.id, + tvdbId: tmdbMedia.external_ids.tvdb_id, + mediaType: watchlistRequest.mediaType, + }); + } } const watchlist = new this({ @@ -139,14 +188,19 @@ export class Watchlist implements WatchlistItem { } public static async deleteWatchlist( - tmdbId: Watchlist['tmdbId'], + id: Watchlist['tmdbId'] | Watchlist['mbId'], user: User ): Promise { const watchlistRepository = getRepository(this); - const watchlist = await watchlistRepository.findOneBy({ - tmdbId, - requestedBy: { id: user.id }, - }); + + // Check if the ID is a number (TMDB) or string (MusicBrainz) + const whereClause = + typeof id === 'number' + ? { tmdbId: id, requestedBy: { id: user.id } } + : { mbId: id, requestedBy: { id: user.id } }; + + const watchlist = await watchlistRepository.findOneBy(whereClause); + if (!watchlist) { throw new NotFoundError('not Found'); } diff --git a/server/index.ts b/server/index.ts index 091180a7..0b2c7770 100644 --- a/server/index.ts +++ b/server/index.ts @@ -22,7 +22,10 @@ import logger from '@server/logger'; import clearCookies from '@server/middleware/clearcookies'; import routes from '@server/routes'; import avatarproxy from '@server/routes/avatarproxy'; -import imageproxy from '@server/routes/imageproxy'; +import caaproxy from '@server/routes/caaproxy'; +import fanartproxy from '@server/routes/fanartproxy'; +import lidarrproxy from '@server/routes/lidarrproxy'; +import tmdbproxy from '@server/routes/tmdbproxy'; import { appDataPermissions } from '@server/utils/appDataVolume'; import { getAppVersion } from '@server/utils/appVersion'; import createCustomProxyAgent from '@server/utils/customProxyAgent'; @@ -235,8 +238,11 @@ app server.use('/api/v1', routes); // Do not set cookies so CDNs can cache them - server.use('/imageproxy', clearCookies, imageproxy); + server.use('/tmdbproxy', clearCookies, tmdbproxy); server.use('/avatarproxy', clearCookies, avatarproxy); + server.use('/caaproxy', clearCookies, caaproxy); + server.use('/lidarrproxy', clearCookies, lidarrproxy); + server.use('/fanartproxy', clearCookies, fanartproxy); server.get('*', (req, res) => handle(req, res)); server.use( diff --git a/server/interfaces/api/blacklistInterfaces.ts b/server/interfaces/api/blacklistInterfaces.ts index 0cf4646e..405b57e5 100644 --- a/server/interfaces/api/blacklistInterfaces.ts +++ b/server/interfaces/api/blacklistInterfaces.ts @@ -2,8 +2,9 @@ import type { User } from '@server/entity/User'; import type { PaginatedResponse } from '@server/interfaces/api/common'; export interface BlacklistItem { - tmdbId: number; - mediaType: 'movie' | 'tv'; + tmdbId?: number; + mbId?: string; + mediaType: 'movie' | 'tv' | 'music'; title?: string; createdAt?: Date; user?: User; diff --git a/server/interfaces/api/discoverInterfaces.ts b/server/interfaces/api/discoverInterfaces.ts index 6738cbb5..d80ee0be 100644 --- a/server/interfaces/api/discoverInterfaces.ts +++ b/server/interfaces/api/discoverInterfaces.ts @@ -7,8 +7,9 @@ export interface GenreSliderItem { export interface WatchlistItem { id: number; ratingKey: string; - tmdbId: number; - mediaType: 'movie' | 'tv'; + tmdbId?: number; + mbId?: string; + mediaType: 'movie' | 'tv' | 'music'; title: string; } diff --git a/server/interfaces/api/serviceInterfaces.ts b/server/interfaces/api/serviceInterfaces.ts index 3b430b0b..94850a38 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; diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 5e058ecc..bca3597b 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -64,7 +64,7 @@ export interface CacheItem { export interface CacheResponse { apiCaches: CacheItem[]; - imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>; + imageCache: Record<'tmdb' | 'avatar' | 'caa' | 'lidarr' | 'fanart', { size: number; imageCount: number }>; dnsCache: { stats: DnsStats | undefined; entries: DnsEntries | undefined; 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/interfaces/api/watchlistCreate.ts b/server/interfaces/api/watchlistCreate.ts index 6cc6af3b..f11fa507 100644 --- a/server/interfaces/api/watchlistCreate.ts +++ b/server/interfaces/api/watchlistCreate.ts @@ -3,7 +3,8 @@ import { z } from 'zod'; export const watchlistCreate = z.object({ ratingKey: z.coerce.string().optional(), - tmdbId: z.coerce.number(), + tmdbId: z.coerce.number().optional(), + mbId: z.coerce.string().optional(), mediaType: z.nativeEnum(MediaType), title: z.coerce.string().optional(), }); diff --git a/server/job/schedule.ts b/server/job/schedule.ts index c740dbae..a9017c2f 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -8,6 +8,7 @@ import { jellyfinFullScanner, jellyfinRecentScanner, } from '@server/lib/scanners/jellyfin'; +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'; @@ -172,6 +173,21 @@ export const startJobs = (): void => { cancelFn: () => sonarrScanner.cancel(), }); + // 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 libs scheduledJobs.push({ id: 'availability-sync', diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 9bdf51c4..defc1a89 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -2,6 +2,7 @@ import type { JellyfinLibraryItem } from '@server/api/jellyfin'; import JellyfinAPI from '@server/api/jellyfin'; import type { PlexMetadata } from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi'; +import LidarrAPI, { type LidarrAlbum } from '@server/api/servarr/lidarr'; import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr'; import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; @@ -12,7 +13,11 @@ import Media from '@server/entity/Media'; import MediaRequest from '@server/entity/MediaRequest'; import type Season from '@server/entity/Season'; import { User } from '@server/entity/User'; -import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; +import type { + LidarrSettings, + RadarrSettings, + SonarrSettings, +} from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { getHostname } from '@server/utils/getHostname'; @@ -28,6 +33,7 @@ class AvailabilitySync { private sonarrSeasonsCache: Record; private radarrServers: RadarrSettings[]; private sonarrServers: SonarrSettings[]; + private lidarrServers: LidarrSettings[]; async run() { const settings = getSettings(); @@ -38,6 +44,7 @@ class AvailabilitySync { this.sonarrSeasonsCache = {}; this.radarrServers = settings.radarr.filter((server) => server.syncEnabled); this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled); + this.lidarrServers = settings.lidarr.filter((server) => server.syncEnabled); try { logger.info(`Starting availability sync...`, { @@ -451,6 +458,47 @@ class AvailabilitySync { ); } } + + if (media.mediaType === 'music') { + let musicExists = false; + + const existsInLidarr = await this.mediaExistsInLidarr(media); + + // Check media server existence (Plex/Jellyfin/Emby) + if (mediaServerType === MediaServerType.PLEX) { + const { existsInPlex } = await this.mediaExistsInPlex(media, false); + if (existsInPlex || existsInLidarr) { + musicExists = true; + logger.info( + `The album [Foreign ID ${media.mbId}] still exists. Preventing removal.`, + { + label: 'AvailabilitySync', + } + ); + } + } else if ( + mediaServerType === MediaServerType.JELLYFIN || + mediaServerType === MediaServerType.EMBY + ) { + const { existsInJellyfin } = await this.mediaExistsInJellyfin( + media, + false + ); + if (existsInJellyfin || existsInLidarr) { + musicExists = true; + logger.info( + `The album [Foreign ID ${media.mbId}] still exists. Preventing removal.`, + { + label: 'AvailabilitySync', + } + ); + } + } + + if (!musicExists && media.status === MediaStatus.AVAILABLE) { + await this.mediaUpdater(media, false, mediaServerType); + } + } } } catch (ex) { logger.error('Failed to complete availability sync.', { @@ -558,11 +606,23 @@ class AvailabilitySync { ? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] : null; } + + // Update log message to include music media type logger.info( `The ${is4k ? '4K' : 'non-4K'} ${ - media.mediaType === 'movie' ? 'movie' : 'show' - } [TMDB ID ${media.tmdbId}] was not found in any ${ - media.mediaType === 'movie' ? 'Radarr' : 'Sonarr' + media.mediaType === 'movie' + ? 'movie' + : media.mediaType === 'tv' + ? 'show' + : 'album' + } [${media.mediaType === 'music' ? 'Foreign ID' : 'TMDB ID'} ${ + media.mediaType === 'music' ? media.mbId : media.tmdbId + }] was not found in any ${ + media.mediaType === 'movie' + ? 'Radarr' + : media.mediaType === 'tv' + ? 'Sonarr' + : 'Lidarr' } and ${ mediaServerType === MediaServerType.PLEX ? 'plex' @@ -577,8 +637,14 @@ class AvailabilitySync { } catch (ex) { logger.debug( `Failure updating the ${is4k ? '4K' : 'non-4K'} ${ - media.mediaType === 'tv' ? 'show' : 'movie' - } [TMDB ID ${media.tmdbId}].`, + media.mediaType === 'movie' + ? 'movie' + : media.mediaType === 'tv' + ? 'show' + : 'album' + } [${media.mediaType === 'music' ? 'Foreign ID' : 'TMDB ID'} ${ + media.mediaType === 'music' ? media.mbId : media.tmdbId + }].`, { errorMessage: ex.message, label: 'Availability Sync', @@ -838,6 +904,51 @@ class AvailabilitySync { return seasonExists; } + private async mediaExistsInLidarr(media: Media): Promise { + let existsInLidarr = false; + + // Check for availability in all configured Lidarr servers + // If any find the media, we will assume the media exists + for (const server of this.lidarrServers) { + const lidarrAPI = new LidarrAPI({ + apiKey: server.apiKey, + url: LidarrAPI.buildUrl(server, '/api/v1'), + }); + + try { + let lidarr: LidarrAlbum | undefined; + + if (media.externalServiceId) { + lidarr = await lidarrAPI.getAlbum({ + id: media.externalServiceId, + }); + } + + if ( + lidarr?.statistics && + lidarr.statistics.totalTrackCount > 0 && + lidarr.statistics.trackFileCount === lidarr.statistics.totalTrackCount + ) { + existsInLidarr = true; + break; + } + } catch (ex) { + if (!ex.message.includes('404')) { + existsInLidarr = true; + logger.debug( + `Failed to retrieve album [Foreign ID ${media.mbId}] from Lidarr.`, + { + errorMessage: ex.message, + label: 'AvailabilitySync', + } + ); + } + } + } + + return existsInLidarr; + } + // Plex private async mediaExistsInPlex( media: Media, @@ -881,8 +992,14 @@ class AvailabilitySync { preventSeasonSearch = true; logger.debug( `Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${ - media.mediaType === 'tv' ? 'show' : 'movie' - } [TMDB ID ${media.tmdbId}] from Plex.`, + media.mediaType === 'movie' + ? 'movie' + : media.mediaType === 'tv' + ? 'show' + : 'album' + } [${media.mediaType === 'music' ? 'Foreign ID' : 'TMDB ID'} ${ + media.mediaType === 'music' ? media.mbId : media.tmdbId + }] from Plex.`, { errorMessage: ex.message, label: 'Availability Sync', @@ -993,13 +1110,19 @@ class AvailabilitySync { existsInJellyfin = true; } } catch (ex) { - if (!ex.message.includes('404' || '500')) { + if (!ex.message.includes('404') && !ex.message.includes('500')) { existsInJellyfin = false; preventSeasonSearch = true; logger.debug( `Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${ - media.mediaType === 'tv' ? 'show' : 'movie' - } [TMDB ID ${media.tmdbId}] from Jellyfin.`, + media.mediaType === 'movie' + ? 'movie' + : media.mediaType === 'tv' + ? 'show' + : 'album' + } [${media.mediaType === 'music' ? 'Foreign ID' : 'TMDB ID'} ${ + media.mediaType === 'music' ? media.mbId : media.tmdbId + }] from Jellyfin.`, { errorMessage: ex.message, label: 'AvailabilitySync', diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 64b5c79e..3a9c6684 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -2,8 +2,12 @@ import NodeCache from 'node-cache'; export type AvailableCacheIds = | 'tmdb' + | 'musicbrainz' + | 'listenbrainz' + | 'covertartarchive' | 'radarr' | 'sonarr' + | 'lidarr' | 'rt' | 'imdb' | 'github' @@ -48,8 +52,21 @@ class CacheManager { stdTtl: 21600, checkPeriod: 60 * 30, }), + musicbrainz: new Cache('musicbrainz', 'MusicBrainz API', { + stdTtl: 21600, + checkPeriod: 60 * 30, + }), + listenbrainz: new Cache('listenbrainz', 'ListenBrainz API', { + stdTtl: 21600, + checkPeriod: 60 * 30, + }), + covertartarchive: new Cache('covertartarchive', 'CovertArtArchive API', { + stdTtl: 21600, + checkPeriod: 60 * 30, + }), radarr: new Cache('radarr', 'Radarr API'), sonarr: new Cache('sonarr', 'Sonarr API'), + lidarr: new Cache('lidarr', 'Lidarr API'), rt: new Cache('rt', 'Rotten Tomatoes API', { stdTtl: 43200, checkPeriod: 60 * 30, diff --git a/server/lib/downloadtracker.ts b/server/lib/downloadtracker.ts index f160f6c8..e88cf96e 100644 --- a/server/lib/downloadtracker.ts +++ b/server/lib/downloadtracker.ts @@ -1,3 +1,4 @@ +import LidarrAPI from '@server/api/servarr/lidarr'; import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import { MediaType } from '@server/constants/media'; @@ -27,6 +28,7 @@ export interface DownloadingItem { class DownloadTracker { private radarrServers: Record = {}; private sonarrServers: Record = {}; + private lidarrServers: Record = {}; public getMovieProgress( serverId: number, @@ -54,6 +56,19 @@ class DownloadTracker { ); } + public getMusicProgress( + serverId: number, + externalServiceId: number + ): DownloadingItem[] { + if (!this.lidarrServers[serverId]) { + return []; + } + + return this.lidarrServers[serverId].filter( + (item) => item.externalId === externalServiceId + ); + } + public async resetDownloadTracker() { this.radarrServers = {}; this.sonarrServers = {}; @@ -62,6 +77,7 @@ class DownloadTracker { public updateDownloads() { this.updateRadarrDownloads(); this.updateSonarrDownloads(); + this.updateLidarrDownloads(); } private async updateRadarrDownloads() { @@ -220,6 +236,84 @@ class DownloadTracker { }) ); } + + private async updateLidarrDownloads() { + const settings = getSettings(); + + // Remove duplicate servers + const filteredServers = uniqWith(settings.lidarr, (lidarrA, lidarrB) => { + return ( + lidarrA.hostname === lidarrB.hostname && + lidarrA.port === lidarrB.port && + lidarrA.baseUrl === lidarrB.baseUrl + ); + }); + + // Load downloads from Lidarr servers + Promise.all( + filteredServers.map(async (server) => { + if (server.syncEnabled) { + const lidarr = new LidarrAPI({ + apiKey: server.apiKey, + url: LidarrAPI.buildUrl(server, '/api/v1'), + }); + + try { + await lidarr.refreshMonitoredDownloads(); + const queueItems = await lidarr.getQueue(); + + this.lidarrServers[server.id] = queueItems.map((item) => ({ + externalId: item.albumId, + estimatedCompletionTime: new Date(item.estimatedCompletionTime), + mediaType: MediaType.MUSIC, + size: item.size, + sizeLeft: item.sizeleft, + status: item.status, + timeLeft: item.timeleft, + title: item.title, + downloadId: item.downloadId, + })); + + if (queueItems.length > 0) { + logger.debug( + `Found ${queueItems.length} item(s) in progress on Lidarr server: ${server.name}`, + { label: 'Download Tracker' } + ); + } + } catch { + logger.error( + `Unable to get queue from Lidarr server: ${server.name}`, + { + label: 'Download Tracker', + } + ); + } + + // Duplicate this data to matching servers + const matchingServers = settings.lidarr.filter( + (ls) => + ls.hostname === server.hostname && + ls.port === server.port && + ls.baseUrl === server.baseUrl && + ls.id !== server.id + ); + + if (matchingServers.length > 0) { + logger.debug( + `Matching download data to ${matchingServers.length} other Lidarr server(s)`, + { label: 'Download Tracker' } + ); + } + + matchingServers.forEach((ms) => { + if (ms.syncEnabled) { + this.lidarrServers[ms.id] = this.lidarrServers[server.id]; + } + }); + } + }) + ); + } } const downloadTracker = new DownloadTracker(); diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts index 39b3e287..0c5b4977 100644 --- a/server/lib/imageproxy.ts +++ b/server/lib/imageproxy.ts @@ -329,7 +329,7 @@ class ImageProxy { }); await promises.mkdir(dir, { recursive: true }); - await promises.writeFile(filename, buffer); + await promises.writeFile(filename, new Uint8Array(buffer)); } private getCacheKey(path: string) { @@ -340,7 +340,9 @@ class ImageProxy { const hash = createHash('sha256'); for (const item of items) { if (typeof item === 'number') hash.update(String(item)); - else { + else if (Buffer.isBuffer(item)) { + hash.update(item.toString()); + } else { hash.update(item); } } diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index bc477169..342da1d7 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -9,26 +9,29 @@ 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, - MANAGE_BLACKLIST = 268435456, - VIEW_BLACKLIST = 1073741824, + AUTO_APPROVE_MUSIC = 1024, + REQUEST_4K = 2048, + REQUEST_4K_MOVIE = 4096, + REQUEST_4K_TV = 8192, + REQUEST_ADVANCED = 16384, + REQUEST_VIEW = 32768, + AUTO_APPROVE_4K = 65536, + AUTO_APPROVE_4K_MOVIE = 131072, + AUTO_APPROVE_4K_TV = 262144, + REQUEST_MOVIE = 524288, + REQUEST_TV = 1048576, + REQUEST_MUSIC = 2097152, + AUTO_REQUEST = 4194304, + AUTO_REQUEST_MOVIE = 8388608, + AUTO_REQUEST_TV = 16777216, + AUTO_REQUEST_MUSIC = 33554432, + MANAGE_ISSUES = 67108864, + VIEW_ISSUES = 134217728, + CREATE_ISSUES = 268435456, + RECENT_VIEW = 536870912, + WATCHLIST_VIEW = 1073741824, + MANAGE_BLACKLIST = 2147483648, + VIEW_BLACKLIST = 4294967296, } export interface PermissionCheckOptions { diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index b78ea811..9c94d434 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -28,6 +28,7 @@ export interface MediaIds { imdbId?: string; tvdbId?: number; isHama?: boolean; + mbId?: string; } interface ProcessOptions { @@ -79,11 +80,24 @@ class BaseScanner { this.updateRate = updateRate ?? UPDATE_RATE; } - private async getExisting(tmdbId: number, mediaType: MediaType) { + private async getExisting( + id: number | string, + mediaType: MediaType + ): Promise { const mediaRepository = getRepository(Media); + const query: Record = { + mediaType, + }; + + if (mediaType === MediaType.MUSIC) { + query.mbId = id.toString(); + } else { + query.tmdbId = Number(id); + } + const existing = await mediaRepository.findOne({ - where: { tmdbId: tmdbId, mediaType }, + where: query, }); return existing; @@ -526,6 +540,61 @@ class BaseScanner { }); } + protected async processMusic( + mbId: string, + { + serviceId, + externalServiceId, + externalServiceSlug, + mediaAddedAt, + processing = false, + title = 'Unknown Title', + }: ProcessOptions = {} + ): Promise { + const mediaRepository = getRepository(Media); + + await this.asyncLock.dispatch(mbId, async () => { + const existing = await mediaRepository.findOne({ + where: { mbId, mediaType: MediaType.MUSIC }, + }); + + if (!existing) { + const newMedia = new Media(); + newMedia.mbId = mbId; + newMedia.status = processing + ? MediaStatus.PROCESSING + : MediaStatus.AVAILABLE; + newMedia.mediaType = MediaType.MUSIC; + + if (mediaAddedAt) { + newMedia.mediaAddedAt = mediaAddedAt; + } + + if (serviceId) { + newMedia.serviceId = serviceId; + } + + if (externalServiceId) { + newMedia.externalServiceId = externalServiceId; + } + + if (externalServiceSlug) { + newMedia.externalServiceSlug = externalServiceSlug; + } + + try { + await mediaRepository.save(newMedia); + this.log(`Saved new media: ${title}`); + } catch (err) { + this.log('Failed to save new media', 'error', { + title, + error: err.message, + }); + } + } + }); + } + /** * Call startRun from child class whenever a run is starting to * ensure required values are set diff --git a/server/lib/scanners/jellyfin/index.ts b/server/lib/scanners/jellyfin/index.ts index d449ee7c..ffd0ccbf 100644 --- a/server/lib/scanners/jellyfin/index.ts +++ b/server/lib/scanners/jellyfin/index.ts @@ -691,6 +691,90 @@ class JellyfinScanner { } } + private async processMusic(jellyfinitem: JellyfinLibraryItem) { + const mediaRepository = getRepository(Media); + + try { + const metadata = await this.jfClient.getItemData(jellyfinitem.Id); + const newMedia = new Media(); + + if (!metadata?.Id) { + logger.debug('No Id metadata for this title. Skipping', { + label: 'Jellyfin Sync', + ratingKey: jellyfinitem.Id, + }); + return; + } + + // Use MusicBrainzReleaseGroup as the foreign ID + newMedia.mbId = metadata.ProviderIds?.MusicBrainzReleaseGroup; + + // Only proceed if we have a valid ID + if (!newMedia.mbId) { + this.log( + 'No MusicBrainz Album ID found for this title. Skipping.', + 'debug', + { + title: metadata.Name, + } + ); + return; + } + + await this.asyncLock.dispatch(metadata.Id, async () => { + const existing = await mediaRepository.findOne({ + where: { mbId: newMedia.mbId, mediaType: MediaType.MUSIC }, + }); + + if (existing) { + let changedExisting = false; + + if (existing.status !== MediaStatus.AVAILABLE) { + existing.status = MediaStatus.AVAILABLE; + existing.mediaAddedAt = new Date(metadata.DateCreated ?? ''); + changedExisting = true; + } + + if (!existing.mediaAddedAt && !changedExisting) { + existing.mediaAddedAt = new Date(metadata.DateCreated ?? ''); + changedExisting = true; + } + + if (existing.jellyfinMediaId !== metadata.Id) { + existing.jellyfinMediaId = metadata.Id; + changedExisting = true; + } + + if (changedExisting) { + await mediaRepository.save(existing); + this.log( + `Request for ${metadata.Name} exists. New media set to AVAILABLE`, + 'info' + ); + } else { + this.log(`Album already exists: ${metadata.Name}`); + } + } else { + newMedia.status = MediaStatus.AVAILABLE; + newMedia.mediaType = MediaType.MUSIC; + newMedia.mediaAddedAt = new Date(metadata.DateCreated ?? ''); + newMedia.jellyfinMediaId = metadata.Id; + await mediaRepository.save(newMedia); + this.log(`Saved new album: ${metadata.Name}`); + } + }); + } catch (e) { + this.log( + `Failed to process Jellyfin item, id: ${jellyfinitem.Id}`, + 'error', + { + errorMessage: e.message, + jellyfinitem, + } + ); + } + } + private async processItems(slicedItems: JellyfinLibraryItem[]) { this.processedAnidbSeason = new Map(); await Promise.all( @@ -699,6 +783,8 @@ class JellyfinScanner { await this.processMovie(item); } else if (item.Type === 'Series') { await this.processShow(item); + } else if (item.Type === 'MusicAlbum') { + await this.processMusic(item); } }) ); diff --git a/server/lib/scanners/lidarr/index.ts b/server/lib/scanners/lidarr/index.ts new file mode 100644 index 00000000..8367c5cd --- /dev/null +++ b/server/lib/scanners/lidarr/index.ts @@ -0,0 +1,123 @@ +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 { + // Filter out duplicate servers + 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 { + try { + if (!lidarrAlbum.monitored) { + this.log('Title is unmonitored. Skipping item.', 'debug', { + title: lidarrAlbum.title, + }); + return; + } + + const mbId = lidarrAlbum.foreignAlbumId; + + if (!mbId) { + this.log( + 'No MusicBrainz ID found for this title. Skipping item.', + 'debug', + { + title: lidarrAlbum.title, + } + ); + return; + } + + await this.processMusic(mbId, { + serviceId: this.currentServer.id, + externalServiceId: lidarrAlbum.id, + externalServiceSlug: lidarrAlbum.titleSlug, + title: lidarrAlbum.title, + processing: + lidarrAlbum.monitored && + (!lidarrAlbum.statistics || + lidarrAlbum.statistics.trackFileCount < + lidarrAlbum.statistics.totalTrackCount), + }); + } 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/search.ts b/server/lib/search.ts index be9ee3ae..3be208aa 100644 --- a/server/lib/search.ts +++ b/server/lib/search.ts @@ -1,11 +1,16 @@ +import MusicBrainz from '@server/api/musicbrainz'; +import type { + MbAlbumResult, + MbArtistResult, +} from '@server/api/musicbrainz/interfaces'; import TheMovieDb from '@server/api/themoviedb'; import type { + TmdbCollectionResult, TmdbMovieDetails, TmdbMovieResult, TmdbPersonDetails, TmdbPersonResult, TmdbSearchMovieResponse, - TmdbSearchMultiResponse, TmdbSearchTvResponse, TmdbTvDetails, TmdbTvResult, @@ -21,6 +26,19 @@ import { isTvDetails, } from '@server/utils/typeHelpers'; +export type CombinedSearchResponse = { + page: number; + total_pages: number; + total_results: number; + results: ( + | MbArtistResult + | MbAlbumResult + | TmdbMovieResult + | TmdbTvResult + | TmdbPersonResult + | TmdbCollectionResult + )[]; +}; interface SearchProvider { pattern: RegExp; search: ({ @@ -31,7 +49,7 @@ interface SearchProvider { id: string; language?: string; query?: string; - }) => Promise; + }) => Promise; } const searchProviders: SearchProvider[] = []; @@ -214,3 +232,50 @@ searchProviders.push({ }; }, }); + +searchProviders.push({ + pattern: new RegExp(/(?<=musicbrainz:)/), + search: async ({ query }) => { + const musicbrainz = new MusicBrainz(); + + try { + const response = await musicbrainz.searchMulti({ + query: query || '', + }); + + const results: CombinedSearchResponse['results'] = response.map( + (result) => { + if (result.artist) { + return { + ...result.artist, + media_type: 'artist', + } as MbArtistResult; + } + + if (result.album) { + return { + ...result.album, + media_type: 'album', + } as MbAlbumResult; + } + + throw new Error('Invalid search result type'); + } + ); + + return { + page: 1, + total_pages: 1, + total_results: results.length, + results, + }; + } catch (e) { + return { + page: 1, + total_pages: 1, + total_results: 0, + results: [], + }; + } + }, +}); diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index e37eccc9..806d9691 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -11,7 +11,7 @@ export interface Library { id: string; name: string; enabled: boolean; - type: 'show' | 'movie'; + type: 'show' | 'movie' | 'music'; lastScan?: number; } @@ -83,6 +83,17 @@ export interface RadarrSettings extends DVRSettings { minimumAvailability: string; } +export interface LidarrSettings extends DVRSettings { + url: string; + apiKey: string; + activeProfileId: number; + activeDirectory: string; + isDefault: boolean; + is4k: boolean; + tagRequests: boolean; + preventSearch: boolean; +} + export interface SonarrSettings extends DVRSettings { seriesType: 'standard' | 'daily' | 'anime'; animeSeriesType: 'standard' | 'daily' | 'anime'; @@ -130,6 +141,7 @@ export interface MainSettings { defaultQuotas: { movie: Quota; tv: Quota; + music: Quota; }; hideAvailable: boolean; hideBlacklisted: boolean; @@ -340,6 +352,7 @@ export type JobId = | 'plex-refresh-token' | 'radarr-scan' | 'sonarr-scan' + | 'lidarr-scan' | 'download-sync' | 'download-sync-reset' | 'jellyfin-recently-added-scan' @@ -358,6 +371,7 @@ export interface AllSettings { tautulli: TautulliSettings; radarr: RadarrSettings[]; sonarr: SonarrSettings[]; + lidarr: LidarrSettings[]; public: PublicSettings; notifications: NotificationSettings; jobs: Record; @@ -387,6 +401,7 @@ class Settings { defaultQuotas: { movie: {}, tv: {}, + music: {}, }, hideAvailable: false, hideBlacklisted: false, @@ -429,6 +444,7 @@ class Settings { anime: MetadataProviderType.TMDB, }, radarr: [], + lidarr: [], sonarr: [], public: { initialized: false, @@ -552,6 +568,9 @@ class Settings { 'sonarr-scan': { schedule: '0 30 4 * * *', }, + 'lidarr-scan': { + schedule: '0 30 4 * * *', + }, 'availability-sync': { schedule: '0 0 5 * * *', }, @@ -649,6 +668,14 @@ class Settings { this.data.radarr = data; } + get lidarr(): LidarrSettings[] { + return this.data.lidarr; + } + + set lidarr(data: LidarrSettings[]) { + this.data.lidarr = data; + } + get sonarr(): SonarrSettings[] { return this.data.sonarr; } diff --git a/server/migration/sqlite/1714310036946-AddMusicSupport.ts b/server/migration/sqlite/1714310036946-AddMusicSupport.ts new file mode 100644 index 00000000..e3371d16 --- /dev/null +++ b/server/migration/sqlite/1714310036946-AddMusicSupport.ts @@ -0,0 +1,125 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMusicSupport1714310036946 implements MigrationInterface { + name = 'AddMusicSupport1714310036946'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" ADD "musicQuotaLimit" integer`); + await queryRunner.query(`ALTER TABLE "user" ADD "musicQuotaDays" integer`); + + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + + await queryRunner.query( + `CREATE TABLE "temporary_media" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "mediaType" varchar NOT NULL, + "tmdbId" integer, + "tvdbId" integer, + "imdbId" varchar, + "mbId" varchar, + "status" integer NOT NULL DEFAULT (1), + "status4k" integer NOT NULL DEFAULT (1), + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), + "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + "mediaAddedAt" datetime, + "serviceId" integer, + "serviceId4k" integer, + "externalServiceId" integer, + "externalServiceId4k" integer, + "externalServiceSlug" varchar, + "externalServiceSlug4k" varchar, + "ratingKey" varchar, + "ratingKey4k" varchar, + "jellyfinMediaId" varchar, + "jellyfinMediaId4k" varchar + )` + ); + + await queryRunner.query( + `INSERT INTO "temporary_media"( + "id", "mediaType", "tmdbId", "tvdbId", "imdbId", + "status", "status4k", "createdAt", "updatedAt", + "lastSeasonChange", "mediaAddedAt", "serviceId", + "serviceId4k", "externalServiceId", "externalServiceId4k", + "externalServiceSlug", "externalServiceSlug4k", + "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" + ) SELECT + "id", "mediaType", "tmdbId", "tvdbId", "imdbId", + "status", "status4k", "createdAt", "updatedAt", + "lastSeasonChange", "mediaAddedAt", "serviceId", + "serviceId4k", "externalServiceId", "externalServiceId4k", + "externalServiceSlug", "externalServiceSlug4k", + "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" + FROM "media"` + ); + + await queryRunner.query(`DROP TABLE "media"`); + await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); + + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId")` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId")` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId")` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "musicQuotaLimit"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "musicQuotaDays"`); + + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + + await queryRunner.query( + `CREATE TABLE "temporary_media" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "mediaType" varchar NOT NULL, + "tmdbId" integer NOT NULL, + "tvdbId" integer, + "imdbId" varchar, + "status" integer NOT NULL DEFAULT (1), + "status4k" integer NOT NULL DEFAULT (1), + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), + "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + "mediaAddedAt" datetime, + "serviceId" integer, + "serviceId4k" integer, + "externalServiceId" integer, + "externalServiceId4k" integer, + "externalServiceSlug" varchar, + "externalServiceSlug4k" varchar, + "ratingKey" varchar, + "ratingKey4k" varchar, + "jellyfinMediaId" varchar, + "jellyfinMediaId4k" varchar + )` + ); + + await queryRunner.query( + `INSERT INTO "temporary_media" + SELECT * FROM "media" WHERE "mediaType" != 'music'` + ); + + await queryRunner.query(`DROP TABLE "media"`); + await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); + + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId")` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId")` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId")` + ); + } +} diff --git a/server/models/Artist.ts b/server/models/Artist.ts new file mode 100644 index 00000000..2abc0677 --- /dev/null +++ b/server/models/Artist.ts @@ -0,0 +1,53 @@ +import type { MbArtistDetails } from '@server/api/musicbrainz/interfaces'; +import type Media from '@server/entity/Media'; + +export interface ArtistDetailsType { + id: string; + name: string; + type: string; + overview: string; + disambiguation: string; + status: string; + genres: string[]; + images: { + CoverType: string; + Url: string; + }[]; + links: { + target: string; + type: string; + }[]; + Albums?: { + id: string; + title: string; + type: string; + releasedate: string; + images?: { + CoverType: string; + Url: string; + }[]; + mediaInfo?: Media; + onUserWatchlist?: boolean; + }[]; +} + +export const mapArtistDetails = ( + artist: MbArtistDetails +): ArtistDetailsType => ({ + id: artist.id, + name: artist.artistname, + type: artist.type, + overview: artist.overview, + disambiguation: artist.disambiguation, + status: artist.status, + genres: artist.genres, + images: artist.images, + links: artist.links, + Albums: artist.Albums?.map((album) => ({ + id: album.Id.toLowerCase(), + title: album.Title, + type: album.Type, + releasedate: '', + images: [], + })), +}); diff --git a/server/models/Music.ts b/server/models/Music.ts new file mode 100644 index 00000000..58e73e56 --- /dev/null +++ b/server/models/Music.ts @@ -0,0 +1,130 @@ +import type { + MbAlbumDetails, + MbImage, + MbLink, +} from '@server/api/musicbrainz/interfaces'; +import type Media from '@server/entity/Media'; + +export interface MusicDetails { + id: string; + mbId: string; + title: string; + titleSlug?: string; + overview: string; + artistId: string; + type: string; + releaseDate: string; + disambiguation: string; + genres: string[]; + secondaryTypes: string[]; + releases: { + id: string; + title: string; + status: string; + releaseDate: string; + trackCount: number; + country: string[]; + label: string[]; + media: { + format: string; + name: string; + position: number; + }[]; + tracks: { + id: string; + artistId: string; + trackName: string; + trackNumber: string; + trackPosition: number; + mediumNumber: number; + durationMs: number; + recordingId: string; + }[]; + disambiguation: string; + }[]; + artist: { + id: string; + artistName: string; + sortName: string; + type: 'Group' | 'Person'; + disambiguation: string; + overview: string; + genres: string[]; + status: string; + images: MbImage[]; + links: MbLink[]; + rating?: { + count: number; + value: number | null; + }; + }; + images: MbImage[]; + links: MbLink[]; + mediaInfo?: Media; + onUserWatchlist?: boolean; +} + +export const mapMusicDetails = ( + album: MbAlbumDetails, + media?: Media, + userWatchlist?: boolean +): MusicDetails => ({ + id: album.id, + mbId: album.id, + title: album.title, + titleSlug: album.title.toLowerCase().replace(/[^a-z0-9]+/g, '-'), + overview: album.overview, + artistId: album.artistid, + type: album.type, + releaseDate: album.releasedate, + disambiguation: album.disambiguation, + genres: album.genres, + secondaryTypes: album.secondaryTypes ?? [], + releases: album.releases.map((release) => ({ + id: release.id, + title: release.title, + status: release.status, + releaseDate: release.releasedate, + trackCount: release.track_count, + country: release.country, + label: release.label, + media: release.media.map((medium) => ({ + format: medium.Format, + name: medium.Name, + position: medium.Position, + })), + tracks: release.tracks.map((track) => ({ + id: track.id, + artistId: track.artistid, + trackName: track.trackname, + trackNumber: track.tracknumber, + trackPosition: track.trackposition, + mediumNumber: track.mediumnumber, + durationMs: track.durationms, + recordingId: track.recordingid, + })), + disambiguation: release.disambiguation, + })), + artist: { + id: album.artists[0].id, + artistName: album.artists[0].artistname, + sortName: album.artists[0].sortname, + type: album.artists[0].type, + disambiguation: album.artists[0].disambiguation, + overview: album.artists[0].overview, + genres: album.artists[0].genres, + status: album.artists[0].status, + images: album.artists[0].images, + links: album.artists[0].links, + rating: album.artists[0].rating + ? { + count: album.artists[0].rating.Count, + value: album.artists[0].rating.Value, + } + : undefined, + }, + images: album.images, + links: album.artists[0].links, + mediaInfo: media, + onUserWatchlist: userWatchlist, +}); diff --git a/server/models/Person.ts b/server/models/Person.ts index 998585ee..b2aeacf2 100644 --- a/server/models/Person.ts +++ b/server/models/Person.ts @@ -20,6 +20,7 @@ export interface PersonDetails { adult: boolean; imdbId?: string; homepage?: string; + mbArtistId?: string; } export interface PersonCredit { diff --git a/server/models/Search.ts b/server/models/Search.ts index 2193bbe1..b2cf171f 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -1,3 +1,10 @@ +import type { + MbAlbumDetails, + MbAlbumResult, + MbArtistDetails, + MbArtistResult, + MbImage, +} from '@server/api/musicbrainz/interfaces'; import type { TmdbCollectionResult, TmdbMovieDetails, @@ -9,10 +16,15 @@ import type { } 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' + | 'artist' + | 'album'; -export type MediaType = 'tv' | 'movie' | 'person' | 'collection'; - -interface SearchResult { +interface TmdbSearchResult { id: number; mediaType: MediaType; popularity: number; @@ -26,7 +38,14 @@ interface SearchResult { mediaInfo?: Media; } -export interface MovieResult extends SearchResult { +interface MbSearchResult { + id: string; + mediaType: MediaType; + score: number; + mediaInfo?: Media; +} + +export interface MovieResult extends TmdbSearchResult { mediaType: 'movie'; title: string; originalTitle: string; @@ -36,7 +55,7 @@ export interface MovieResult extends SearchResult { mediaInfo?: Media; } -export interface TvResult extends SearchResult { +export interface TvResult extends TmdbSearchResult { mediaType: 'tv'; name: string; originalName: string; @@ -66,7 +85,46 @@ export interface PersonResult { knownFor: (MovieResult | TvResult)[]; } -export type Results = MovieResult | TvResult | PersonResult | CollectionResult; +export interface ArtistResult extends MbSearchResult { + mediaType: 'artist'; + artistname: string; + overview: string; + disambiguation: string; + type: 'Group' | 'Person'; + status: string; + sortname: string; + genres: string[]; + images: MbImage[]; + artistimage?: string; + rating?: { + Count: number; + Value: number | null; + }; + mediaInfo?: Media; +} + +export interface AlbumResult extends MbSearchResult { + mediaType: 'album'; + title: string; + artistid: string; + artistname?: string; + type: string; + releasedate: string; + disambiguation: string; + genres: string[]; + images: MbImage[]; + secondarytypes: string[]; + mediaInfo?: Media; + overview?: string; +} + +export type Results = + | MovieResult + | TvResult + | PersonResult + | CollectionResult + | ArtistResult + | AlbumResult; export const mapMovieResult = ( movieResult: TmdbMovieResult, @@ -144,18 +202,131 @@ export const mapPersonResult = ( }), }); -export const mapSearchResults = ( +export const mapArtistResult = ( + artistResult: MbArtistResult, + media?: Media +): ArtistResult => ({ + id: artistResult.id, + score: artistResult.score, + mediaType: 'artist', + artistname: artistResult.artistname, + overview: artistResult.overview, + disambiguation: artistResult.disambiguation, + type: artistResult.type, + status: artistResult.status, + sortname: artistResult.sortname, + genres: artistResult.genres, + images: artistResult.images, + rating: artistResult.rating, + mediaInfo: media, +}); + +export const mapAlbumResult = ( + albumResult: MbAlbumResult, + media?: Media +): AlbumResult => ({ + id: albumResult.id, + score: albumResult.score, + mediaType: 'album', + title: albumResult.title, + artistid: albumResult.artistid, + artistname: albumResult.artists?.[0]?.artistname, + type: albumResult.type, + releasedate: albumResult.releasedate, + disambiguation: albumResult.disambiguation, + genres: albumResult.genres, + images: albumResult.images, + secondarytypes: albumResult.secondarytypes, + mediaInfo: media, + overview: albumResult.artists?.[0]?.overview, +}); + +const isTmdbMovie = ( + result: + | TmdbMovieResult + | TmdbTvResult + | TmdbPersonResult + | TmdbCollectionResult + | MbArtistResult + | MbAlbumResult +): result is TmdbMovieResult => { + return result.media_type === 'movie'; +}; + +const isTmdbTv = ( + result: + | TmdbMovieResult + | TmdbTvResult + | TmdbPersonResult + | TmdbCollectionResult + | MbArtistResult + | MbAlbumResult +): result is TmdbTvResult => { + return result.media_type === 'tv'; +}; + +const isTmdbPerson = ( + result: + | TmdbMovieResult + | TmdbTvResult + | TmdbPersonResult + | TmdbCollectionResult + | MbArtistResult + | MbAlbumResult +): result is TmdbPersonResult => { + return result.media_type === 'person'; +}; + +const isTmdbCollection = ( + result: + | TmdbMovieResult + | TmdbTvResult + | TmdbPersonResult + | TmdbCollectionResult + | MbArtistResult + | MbAlbumResult +): result is TmdbCollectionResult => { + return result.media_type === 'collection'; +}; + +const isLidarrArtist = ( + result: + | TmdbMovieResult + | TmdbTvResult + | TmdbPersonResult + | TmdbCollectionResult + | MbArtistResult + | MbAlbumResult +): result is MbArtistResult => { + return result.media_type === 'artist'; +}; + +const isLidarrAlbum = ( + result: + | TmdbMovieResult + | TmdbTvResult + | TmdbPersonResult + | TmdbCollectionResult + | MbArtistResult + | MbAlbumResult +): result is MbAlbumResult => { + return result.media_type === 'album'; +}; + +export const mapSearchResults = async ( results: ( | TmdbMovieResult | TmdbTvResult | TmdbPersonResult | TmdbCollectionResult + | MbArtistResult + | MbAlbumResult )[], media?: Media[] -): Results[] => - results.map((result) => { - switch (result.media_type) { - case 'movie': +): Promise => + Promise.all( + results.map(async (result) => { + if (isTmdbMovie(result)) { return mapMovieResult( result, media?.find( @@ -163,7 +334,7 @@ export const mapSearchResults = ( req.tmdbId === result.id && req.mediaType === MainMediaType.MOVIE ) ); - case 'tv': + } else if (isTmdbTv(result)) { return mapTvResult( result, media?.find( @@ -171,12 +342,25 @@ export const mapSearchResults = ( req.tmdbId === result.id && req.mediaType === MainMediaType.TV ) ); - case 'collection': - return mapCollectionResult(result); - default: + } else if (isTmdbPerson(result)) { return mapPersonResult(result); - } - }); + } else if (isTmdbCollection(result)) { + return mapCollectionResult(result); + } else if (isLidarrArtist(result)) { + return mapArtistResult(result); + } else if (isLidarrAlbum(result)) { + return mapAlbumResult( + result, + media?.find( + (req) => + req.mbId === result.id && req.mediaType === MainMediaType.MUSIC + ) + ); + } + + throw new Error(`Unhandled result type: ${JSON.stringify(result)}`); + }) + ); export const mapMovieDetailsToResult = ( movieDetails: TmdbMovieDetails @@ -228,3 +412,39 @@ export const mapPersonDetailsToResult = ( profile_path: personDetails.profile_path, known_for: [], }); + +export const mapArtistDetailsToResult = ( + artistDetails: MbArtistDetails +): MbArtistResult => ({ + id: artistDetails.id, + score: 100, // Default score since we're mapping details + media_type: 'artist', + artistname: artistDetails.artistname, + overview: artistDetails.overview, + disambiguation: artistDetails.disambiguation, + type: artistDetails.type, + status: artistDetails.status, + sortname: artistDetails.sortname, + genres: artistDetails.genres, + images: artistDetails.images, + links: artistDetails.links, + rating: artistDetails.rating, +}); + +export const mapAlbumDetailsToResult = ( + albumDetails: MbAlbumDetails +): MbAlbumResult => ({ + id: albumDetails.id, + score: 100, + media_type: 'album', + title: albumDetails.title, + artistid: albumDetails.artistid, + artists: albumDetails.artists, + type: albumDetails.type, + releasedate: albumDetails.releasedate, + disambiguation: albumDetails.disambiguation, + genres: albumDetails.genres, + images: albumDetails.images, + secondarytypes: albumDetails.secondarytypes, + overview: albumDetails.overview || albumDetails.artists?.[0]?.overview || '', +}); diff --git a/server/routes/blacklist.ts b/server/routes/blacklist.ts index e0540c48..9d9cabc0 100644 --- a/server/routes/blacklist.ts +++ b/server/routes/blacklist.ts @@ -13,7 +13,8 @@ import { z } from 'zod'; const blacklistRoutes = Router(); export const blacklistAdd = z.object({ - tmdbId: z.coerce.number(), + tmdbId: z.coerce.number().optional(), + mbId: z.string().optional(), mediaType: z.nativeEnum(MediaType), title: z.coerce.string().optional(), user: z.coerce.number(), @@ -90,10 +91,12 @@ blacklistRoutes.get( }), async (req, res, next) => { try { - const blacklisteRepository = getRepository(Blacklist); + const blacklistRepository = getRepository(Blacklist); - const blacklistItem = await blacklisteRepository.findOneOrFail({ - where: { tmdbId: Number(req.params.id) }, + const blacklistItem = await blacklistRepository.findOneOrFail({ + where: !isNaN(Number(req.params.id)) + ? { tmdbId: Number(req.params.id) } + : { mbId: req.params.id }, }); return res.status(200).send(blacklistItem); @@ -135,6 +138,7 @@ blacklistRoutes.post( default: logger.warn('Something wrong with data blacklist', { tmdbId: req.body.tmdbId, + mbId: req.body.mbId, mediaType: req.body.mediaType, label: 'Blacklist', }); @@ -154,18 +158,22 @@ blacklistRoutes.delete( }), async (req, res, next) => { try { - const blacklisteRepository = getRepository(Blacklist); + const blacklistRepository = getRepository(Blacklist); - const blacklistItem = await blacklisteRepository.findOneOrFail({ - where: { tmdbId: Number(req.params.id) }, + const blacklistItem = await blacklistRepository.findOneOrFail({ + where: !isNaN(Number(req.params.id)) + ? { tmdbId: Number(req.params.id) } + : { mbId: req.params.id }, }); - await blacklisteRepository.remove(blacklistItem); + await blacklistRepository.remove(blacklistItem); const mediaRepository = getRepository(Media); const mediaItem = await mediaRepository.findOneOrFail({ - where: { tmdbId: Number(req.params.id) }, + where: !isNaN(Number(req.params.id)) + ? { tmdbId: Number(req.params.id) } + : { mbId: req.params.id }, }); await mediaRepository.remove(mediaItem); diff --git a/server/routes/caaproxy.ts b/server/routes/caaproxy.ts new file mode 100644 index 00000000..4a1ecd9c --- /dev/null +++ b/server/routes/caaproxy.ts @@ -0,0 +1,35 @@ +import ImageProxy from '@server/lib/imageproxy'; +import logger from '@server/logger'; +import { Router } from 'express'; + +const router = Router(); +const caaImageProxy = new ImageProxy('caa', 'http://coverartarchive.org', { + rateLimitOptions: { + maxRPS: 50, + }, +}); + +router.get('/*', async (req, res) => { + const imagePath = req.path; + try { + const imageData = await caaImageProxy.getImage(imagePath); + + res.writeHead(200, { + 'Content-Type': `image/${imageData.meta.extension}`, + 'Content-Length': imageData.imageBuffer.length, + 'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`, + 'OS-Cache-Key': imageData.meta.cacheKey, + 'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT', + }); + + res.end(imageData.imageBuffer); + } catch (e) { + logger.error('Failed to proxy image', { + imagePath, + errorMessage: e.message, + }); + res.status(500).end(); + } +}); + +export default router; diff --git a/server/routes/discover.ts b/server/routes/discover.ts index c6dab52a..e4e893a0 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,3 +1,5 @@ +import ListenBrainzAPI from '@server/api/listenbrainz'; +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'; @@ -854,6 +856,131 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( } ); +discoverRoutes.get('/music', async (req, res, next) => { + const listenbrainz = new ListenBrainzAPI(); + const musicbrainz = new MusicBrainz(); + + try { + const page = Number(req.query.page) || 1; + const pageSize = 20; + const offset = (page - 1) * pageSize; + const sortBy = (req.query.sortBy as string) || 'listen_count.desc'; + + const data = await listenbrainz.getTopAlbums({ + offset, + count: pageSize, + range: 'week', + }); + + const media = await Media.getRelatedMedia( + req.user, + data.payload.release_groups.map((album) => album.release_group_mbid) + ); + + const albumDetailsPromises = data.payload.release_groups.map( + async (album) => { + try { + const details = await musicbrainz.getAlbum({ + albumId: album.release_group_mbid, + }); + + const images = + details.images?.length > 0 + ? details.images.filter((img) => img.CoverType === 'Cover') + : album.caa_id + ? [ + { + CoverType: 'Cover', + Url: `https://coverartarchive.org/release/${album.caa_release_mbid}/front`, + }, + ] + : []; + + return { + id: album.release_group_mbid, + mediaType: 'album', + type: 'Album', + title: album.release_group_name, + artistname: album.artist_name, + artistId: album.artist_mbids[0], + releasedate: details.releasedate || '', + images, + mediaInfo: media?.find( + (med) => med.mbId === album.release_group_mbid + ), + listenCount: album.listen_count, + }; + } catch (e) { + return { + id: album.release_group_mbid, + mediaType: 'album', + type: 'Album', + title: album.release_group_name, + artistname: album.artist_name, + artistId: album.artist_mbids[0], + releasedate: '', + images: album.caa_id + ? [ + { + CoverType: 'Cover', + Url: `https://coverartarchive.org/release/${album.caa_release_mbid}/front`, + }, + ] + : [], + mediaInfo: media?.find( + (med) => med.mbId === album.release_group_mbid + ), + listenCount: album.listen_count, + }; + } + } + ); + + const results = await Promise.all(albumDetailsPromises); + + switch (sortBy) { + case 'listen_count.asc': + results.sort((a, b) => a.listenCount - b.listenCount); + break; + case 'listen_count.desc': + results.sort((a, b) => b.listenCount - a.listenCount); + break; + case 'title.asc': + results.sort((a, b) => a.title.localeCompare(b.title)); + break; + case 'title.desc': + results.sort((a, b) => b.title.localeCompare(a.title)); + break; + case 'release_date.asc': + results.sort((a, b) => + (a.releasedate || '').localeCompare(b.releasedate || '') + ); + break; + case 'release_date.desc': + results.sort((a, b) => + (b.releasedate || '').localeCompare(a.releasedate || '') + ); + break; + } + + return res.status(200).json({ + page, + totalPages: Math.ceil(data.payload.count / pageSize), + totalResults: data.payload.count, + results, + }); + } catch (e) { + logger.debug('Something went wrong retrieving popular music', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve popular music.', + }); + } +}); + discoverRoutes.get, WatchlistResponse>( '/watchlist', async (req, res) => { diff --git a/server/routes/fanartproxy.ts b/server/routes/fanartproxy.ts new file mode 100644 index 00000000..4b6dd809 --- /dev/null +++ b/server/routes/fanartproxy.ts @@ -0,0 +1,35 @@ +import ImageProxy from '@server/lib/imageproxy'; +import logger from '@server/logger'; +import { Router } from 'express'; + +const router = Router(); +const fanartImageProxy = new ImageProxy('fanart', 'http://assets.fanart.tv/', { + rateLimitOptions: { + maxRPS: 50, + }, +}); + +router.get('/*', async (req, res) => { + const imagePath = req.path; + try { + const imageData = await fanartImageProxy.getImage(imagePath); + + res.writeHead(200, { + 'Content-Type': `image/${imageData.meta.extension}`, + 'Content-Length': imageData.imageBuffer.length, + 'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`, + 'OS-Cache-Key': imageData.meta.cacheKey, + 'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT', + }); + + res.end(imageData.imageBuffer); + } catch (e) { + logger.error('Failed to proxy image', { + imagePath, + errorMessage: e.message, + }); + res.status(500).end(); + } +}); + +export default router; diff --git a/server/routes/group.ts b/server/routes/group.ts new file mode 100644 index 00000000..a5f2c1f5 --- /dev/null +++ b/server/routes/group.ts @@ -0,0 +1,165 @@ +import CoverArtArchive from '@server/api/coverartarchive'; +import MusicBrainz from '@server/api/musicbrainz'; +import Media from '@server/entity/Media'; +import logger from '@server/logger'; +import { mapArtistDetails } from '@server/models/Artist'; +import { Router } from 'express'; + +const groupRoutes = Router(); + +groupRoutes.get('/:id', async (req, res, next) => { + const musicbrainz = new MusicBrainz(); + const locale = req.locale || 'en'; + + try { + const [artistDetails, wikipediaExtract] = await Promise.all([ + musicbrainz.getArtist({ + artistId: req.params.id, + }), + musicbrainz.getWikipediaExtract(req.params.id, locale, 'artist'), + ]); + + const mappedDetails = await mapArtistDetails(artistDetails); + + if (wikipediaExtract) { + mappedDetails.overview = wikipediaExtract; + } + + return res.status(200).json(mappedDetails); + } catch (e) { + logger.error('Something went wrong retrieving artist details', { + label: 'Group API', + errorMessage: e.message, + artistId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve artist details.', + }); + } +}); + +groupRoutes.get('/:id/discography', async (req, res, next) => { + const musicbrainz = new MusicBrainz(); + const coverArtArchive = new CoverArtArchive(); + + try { + const type = req.query.type as string; + const page = Number(req.query.page) || 1; + const pageSize = 20; + + const artistDetails = await musicbrainz.getArtist({ + artistId: req.params.id, + }); + + const mappedDetails = await mapArtistDetails(artistDetails); + + if (!mappedDetails.Albums?.length) { + return res.status(200).json({ + page: 1, + pageInfo: { + total: 0, + totalPages: 0, + }, + results: [], + }); + } + + let filteredAlbums = mappedDetails.Albums; + if (type) { + if (type === 'Other') { + filteredAlbums = mappedDetails.Albums.filter( + (album) => !['Album', 'Single', 'EP'].includes(album.type) + ); + } else { + filteredAlbums = mappedDetails.Albums.filter( + (album) => album.type === type + ); + } + } + + const albumsWithDetails = await Promise.all( + filteredAlbums.map(async (album) => { + try { + const albumDetails = await musicbrainz.getAlbum({ + albumId: album.id, + }); + + let images = albumDetails.images; + + if (!images?.length) { + try { + const coverArtData = await coverArtArchive.getCoverArt(album.id); + if (coverArtData.images?.length) { + images = coverArtData.images.map((img) => ({ + CoverType: img.front ? 'Cover' : 'Poster', + Url: img.image, + })); + } + } catch (e) { + // Handle cover art errors silently + } + } + + return { + ...album, + images: images || [], + releasedate: albumDetails.releasedate || '', + }; + } catch (e) { + return { + ...album, + images: [], + releasedate: '', + }; + } + }) + ); + + const sortedAlbums = albumsWithDetails.sort((a, b) => { + if (!a.releasedate && !b.releasedate) return 0; + if (!a.releasedate) return 1; + if (!b.releasedate) return -1; + return ( + new Date(b.releasedate).getTime() - new Date(a.releasedate).getTime() + ); + }); + + const totalResults = sortedAlbums.length; + const totalPages = Math.ceil(totalResults / pageSize); + const start = (page - 1) * pageSize; + const end = start + pageSize; + const paginatedAlbums = sortedAlbums.slice(start, end); + + const media = await Media.getRelatedMedia( + req.user, + paginatedAlbums.map((album) => album.id) + ); + + const results = paginatedAlbums.map((album) => ({ + ...album, + mediaInfo: media?.find((med) => med.mbId === album.id), + })); + + return res.status(200).json({ + page, + pageInfo: { + total: totalResults, + totalPages, + }, + results, + }); + } catch (e) { + logger.error('Something went wrong retrieving artist discography', { + label: 'Group API', + errorMessage: e.message, + artistId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve artist discography.', + }); + } +}); + +export default groupRoutes; diff --git a/server/routes/index.ts b/server/routes/index.ts index 6155f350..93d62177 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -31,10 +31,12 @@ import authRoutes from './auth'; import blacklistRoutes from './blacklist'; import collectionRoutes from './collection'; import discoverRoutes, { createTmdbWithRegionLanguage } from './discover'; +import groupRoutes from './group'; 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'; @@ -154,8 +156,10 @@ router.use('/watchlist', isAuthenticated(), watchlistRoutes); router.use('/blacklist', isAuthenticated(), blacklistRoutes); router.use('/movie', isAuthenticated(), movieRoutes); router.use('/tv', isAuthenticated(), tvRoutes); +router.use('/music', isAuthenticated(), musicRoutes); router.use('/media', isAuthenticated(), mediaRoutes); router.use('/person', isAuthenticated(), personRoutes); +router.use('/group', isAuthenticated(), groupRoutes); router.use('/collection', isAuthenticated(), collectionRoutes); router.use('/service', isAuthenticated(), serviceRoutes); router.use('/issue', isAuthenticated(), issueRoutes); diff --git a/server/routes/issue.ts b/server/routes/issue.ts index 5b0bbd98..cc543342 100644 --- a/server/routes/issue.ts +++ b/server/routes/issue.ts @@ -173,6 +173,12 @@ issueRoutes.get('/count', async (req, res, next) => { }) .getCount(); + const lyricsCount = await query + .where('issue.issueType = :issueType', { + issueType: IssueType.LYRICS, + }) + .getCount(); + const othersCount = await query .where('issue.issueType = :issueType', { issueType: IssueType.OTHER, @@ -196,6 +202,7 @@ issueRoutes.get('/count', async (req, res, next) => { video: videoCount, audio: audioCount, subtitles: subtitlesCount, + lyrics: lyricsCount, others: othersCount, open: openCount, closed: closedCount, diff --git a/server/routes/lidarrproxy.ts b/server/routes/lidarrproxy.ts new file mode 100644 index 00000000..f44966d0 --- /dev/null +++ b/server/routes/lidarrproxy.ts @@ -0,0 +1,39 @@ +import ImageProxy from '@server/lib/imageproxy'; +import logger from '@server/logger'; +import { Router } from 'express'; + +const router = Router(); +const lidarrImageProxy = new ImageProxy( + 'lidarr', + 'https://imagecache.lidarr.audio', + { + rateLimitOptions: { + maxRPS: 50, + }, + } +); + +router.get('/*', async (req, res) => { + const imagePath = req.path; + try { + const imageData = await lidarrImageProxy.getImage(imagePath); + + res.writeHead(200, { + 'Content-Type': `image/${imageData.meta.extension}`, + 'Content-Length': imageData.imageBuffer.length, + 'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`, + 'OS-Cache-Key': imageData.meta.cacheKey, + 'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT', + }); + + res.end(imageData.imageBuffer); + } catch (e) { + logger.error('Failed to proxy image', { + imagePath, + errorMessage: e.message, + }); + res.status(500).end(); + } +}); + +export default router; diff --git a/server/routes/media.ts b/server/routes/media.ts index 8f52efae..0f1716cc 100644 --- a/server/routes/media.ts +++ b/server/routes/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 TautulliAPI from '@server/api/tautulli'; @@ -199,43 +200,52 @@ mediaRoutes.delete( }); const is4k = String(req.query.is4k) === 'true'; - const isMovie = media.mediaType === MediaType.MOVIE; let serviceSettings; - if (isMovie) { + if (media.mediaType === MediaType.MOVIE) { serviceSettings = settings.radarr.find( (radarr) => radarr.isDefault && radarr.is4k === is4k ); - } else { + } else if(media.mediaType === MediaType.TV) { serviceSettings = settings.sonarr.find( (sonarr) => sonarr.isDefault && sonarr.is4k === is4k ); + } else { + serviceSettings = settings.lidarr.find( + (lidarr) => lidarr.isDefault); } - const specificServiceId = is4k ? media.serviceId4k : media.serviceId; - if ( - specificServiceId && - specificServiceId >= 0 && - serviceSettings?.id !== specificServiceId - ) { - if (isMovie) { - serviceSettings = settings.radarr.find( - (radarr) => radarr.id === specificServiceId - ); - } else { - serviceSettings = settings.sonarr.find( - (sonarr) => sonarr.id === specificServiceId - ); + + const specificServiceId = is4k ? media.serviceId4k : media.serviceId; + if ( + specificServiceId && + specificServiceId >= 0 && + serviceSettings?.id !== specificServiceId + ) { + if (media.mediaType === MediaType.MOVIE) { + serviceSettings = settings.radarr.find( + (radarr) => radarr.id === specificServiceId + ); + } else if (media.mediaType === MediaType.TV) { + serviceSettings = settings.sonarr.find( + (sonarr) => sonarr.id === specificServiceId + ); + } else { + serviceSettings = settings.lidarr.find( + (lidarr) => lidarr.id === media.serviceId + ) + } } - } - - if (!serviceSettings) { + + if (!serviceSettings) { logger.warn( `There is no default ${ - is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr' - }/ server configured. Did you set any of your ${ - is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr' - } servers as default?`, + media.mediaType === MediaType.MOVIE + ? 'Radarr' + : media.mediaType === MediaType.TV + ? 'Sonarr' + : 'Lidarr' + } server configured.`, { label: 'Media Request', mediaId: media.id, @@ -245,31 +255,43 @@ mediaRoutes.delete( } let service; - if (isMovie) { + if (media.mediaType === MediaType.MOVIE) { service = new RadarrAPI({ - apiKey: serviceSettings?.apiKey, + apiKey: serviceSettings.apiKey, url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'), }); - } else { + + await (service as RadarrAPI).removeMovie(media.tmdbId); + } else if (media.mediaType === MediaType.TV) { service = new SonarrAPI({ apiKey: serviceSettings?.apiKey, url: SonarrAPI.buildUrl(serviceSettings, '/api/v3'), }); - } - if (isMovie) { - await (service as RadarrAPI).removeMovie(media.tmdbId); - } else { const tmdb = new TheMovieDb(); const series = await tmdb.getTvShow({ tvId: media.tmdbId }); const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; + if (!tvdbId) { throw new Error('TVDB ID not found'); } await (service as SonarrAPI).removeSeries(tvdbId); + } else if (media.mediaType == MediaType.MUSIC) + { + service = new LidarrAPI({ + apiKey: serviceSettings.apiKey, + url: LidarrAPI.buildUrl(serviceSettings, '/api/v1'), + }); + + await service.removeAlbum( + media.externalServiceId + ? parseInt(media.externalServiceId.toString()) + : 0 + ); } return res.status(204).send(); + } catch (e) { logger.error('Something went wrong fetching media in delete request', { label: 'Media', diff --git a/server/routes/music.ts b/server/routes/music.ts new file mode 100644 index 00000000..4c7db786 --- /dev/null +++ b/server/routes/music.ts @@ -0,0 +1,251 @@ +import CoverArtArchive from '@server/api/coverartarchive'; +import ListenBrainzAPI from '@server/api/listenbrainz'; +import MusicBrainz from '@server/api/musicbrainz'; +import TheMovieDb from '@server/api/themoviedb'; +import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { Watchlist } from '@server/entity/Watchlist'; +import logger from '@server/logger'; +import { mapMusicDetails } from '@server/models/Music'; +import { Router } from 'express'; + +const musicRoutes = Router(); + +musicRoutes.get('/:id', async (req, res, next) => { + const musicbrainz = new MusicBrainz(); + const locale = req.locale || 'en'; + + try { + const [albumDetails, wikipediaExtract] = await Promise.all([ + musicbrainz.getAlbum({ + albumId: req.params.id, + }), + musicbrainz.getWikipediaExtract(req.params.id, locale), + ]); + + const [media, onUserWatchlist] = await Promise.all([ + getRepository(Media) + .findOne({ + where: { + mbId: req.params.id, + mediaType: MediaType.MUSIC, + }, + }) + .then((media) => media ?? undefined), + + getRepository(Watchlist).exist({ + where: { + mbId: req.params.id, + requestedBy: { + id: req.user?.id, + }, + }, + }), + ]); + + const mappedDetails = mapMusicDetails(albumDetails, media, onUserWatchlist); + + if (wikipediaExtract) { + mappedDetails.artist.overview = wikipediaExtract; + } + + return res.status(200).json(mappedDetails); + } catch (e) { + logger.error('Something went wrong retrieving album details', { + label: 'Music API', + errorMessage: e.message, + mbId: req.params.id, + }); + + return next({ + status: 500, + message: 'Unable to retrieve album details.', + }); + } +}); + +musicRoutes.get('/:id/discography', async (req, res, next) => { + const musicbrainz = new MusicBrainz(); + const coverArtArchive = new CoverArtArchive(); + + try { + const albumDetails = await musicbrainz.getAlbum({ + albumId: req.params.id, + }); + + if (!albumDetails.artists?.[0]?.id) { + throw new Error('No artist found for album'); + } + + const page = Number(req.query.page) || 1; + const pageSize = 20; + + const artistData = await musicbrainz.getArtist({ + artistId: albumDetails.artists[0].id, + }); + + const albums = + artistData.Albums?.map((album) => ({ + id: album.Id.toLowerCase(), + title: album.Title, + type: album.Type, + mediaType: 'album', + })) ?? []; + + const start = (page - 1) * pageSize; + const end = start + pageSize; + const paginatedAlbums = albums.slice(start, end); + + const albumDetailsPromises = paginatedAlbums.map(async (album) => { + try { + const details = await musicbrainz.getAlbum({ + albumId: album.id, + }); + + let images = details.images; + + // Try to get cover art if no images found + if (!images || images.length === 0) { + try { + const coverArtData = await coverArtArchive.getCoverArt(album.id); + if (coverArtData.images?.length > 0) { + images = coverArtData.images.map((img) => ({ + CoverType: img.front ? 'Cover' : 'Poster', + Url: img.image, + })); + } + } catch (coverArtError) { + // Fallback silently + } + } + + return { + ...album, + images, + releasedate: details.releasedate, + }; + } catch (e) { + return album; + } + }); + + const albumsWithDetails = await Promise.all(albumDetailsPromises); + + const media = await Media.getRelatedMedia( + req.user, + albumsWithDetails.map((album) => album.id) + ); + + const resultsWithMedia = albumsWithDetails.map((album) => ({ + ...album, + mediaInfo: media?.find((med) => med.mbId === album.id), + })); + + return res.status(200).json({ + page, + totalPages: Math.ceil(albums.length / pageSize), + totalResults: albums.length, + results: resultsWithMedia, + }); + } catch (e) { + logger.error('Something went wrong retrieving artist discography', { + label: 'Music API', + errorMessage: e.message, + albumId: req.params.id, + }); + + return next({ + status: 500, + message: 'Unable to retrieve artist discography.', + }); + } +}); + +musicRoutes.get('/:id/similar', async (req, res, next) => { + const musicbrainz = new MusicBrainz(); + const listenbrainz = new ListenBrainzAPI(); + const tmdb = new TheMovieDb(); + + try { + const albumDetails = await musicbrainz.getAlbum({ + albumId: req.params.id, + }); + + if (!albumDetails.artists?.[0]?.id) { + throw new Error('No artist found for album'); + } + + const page = Number(req.query.page) || 1; + const pageSize = 20; + + const similarArtists = await listenbrainz.getSimilarArtists( + albumDetails.artists[0].id + ); + + const start = (page - 1) * pageSize; + const end = start + pageSize; + const paginatedArtists = similarArtists.slice(start, end); + + const artistDetailsPromises = paginatedArtists.map(async (artist) => { + try { + let tmdbId = null; + if (artist.type === 'Person') { + const searchResults = await tmdb.searchPerson({ + query: artist.name, + page: 1, + }); + + const match = searchResults.results.find( + (result) => result.name.toLowerCase() === artist.name.toLowerCase() + ); + if (match) { + tmdbId = match.id; + } + } + + const details = await musicbrainz.getArtist({ + artistId: artist.artist_mbid, + }); + + return { + id: tmdbId || artist.artist_mbid, + mediaType: 'artist' as const, + artistname: artist.name, + type: artist.type || 'Person', + overview: artist.comment, + score: artist.score, + images: details.images || [], + artistimage: details.images?.find((img) => img.CoverType === 'Poster') + ?.Url, + }; + } catch (e) { + return null; + } + }); + + const artistDetails = (await Promise.all(artistDetailsPromises)).filter( + (artist): artist is NonNullable => artist !== null + ); + + return res.status(200).json({ + page, + totalPages: Math.ceil(similarArtists.length / pageSize), + totalResults: similarArtists.length, + results: artistDetails, + }); + } catch (e) { + logger.error('Something went wrong retrieving similar artists', { + label: 'Music API', + errorMessage: e.message, + albumId: req.params.id, + }); + + return next({ + status: 500, + message: 'Unable to retrieve similar artists.', + }); + } +}); + +export default musicRoutes; diff --git a/server/routes/person.ts b/server/routes/person.ts index 7462328c..6594481f 100644 --- a/server/routes/person.ts +++ b/server/routes/person.ts @@ -1,3 +1,5 @@ +import CoverArtArchive from '@server/api/coverartarchive'; +import MusicBrainz from '@server/api/musicbrainz'; import TheMovieDb from '@server/api/themoviedb'; import Media from '@server/entity/Media'; import logger from '@server/logger'; @@ -12,13 +14,48 @@ const personRoutes = Router(); personRoutes.get('/:id', async (req, res, next) => { const tmdb = new TheMovieDb(); + const musicBrainz = new MusicBrainz(); try { const person = await tmdb.getPerson({ personId: Number(req.params.id), language: (req.query.language as string) ?? req.locale, }); - return res.status(200).json(mapPersonDetails(person)); + + let mbArtistId = null; + try { + const artists = await musicBrainz.searchArtist({ + query: person.name, + }); + + const matchedArtist = artists.find((artist) => { + if (artist.type !== 'Person') { + return false; + } + + const nameMatches = + artist.artistname.toLowerCase() === person.name.toLowerCase(); + const aliasMatches = artist.artistaliases?.some( + (alias) => alias.toLowerCase() === person.name.toLowerCase() + ); + return nameMatches || aliasMatches; + }); + + if (matchedArtist) { + mbArtistId = matchedArtist.id; + } + } catch (e) { + logger.debug('Failed to fetch music artist data', { + label: 'API', + errorMessage: e.message, + personName: person.name, + }); + } + + return res.status(200).json({ + ...mapPersonDetails(person), + mbArtistId, + }); } catch (e) { logger.debug('Something went wrong retrieving person', { label: 'API', @@ -32,6 +69,148 @@ personRoutes.get('/:id', async (req, res, next) => { } }); +personRoutes.get('/:id/discography', async (req, res, next) => { + const musicBrainz = new MusicBrainz(); + const tmdb = new TheMovieDb(); + const coverArtArchive = new CoverArtArchive(); + const artistId = req.query.artistId as string; + const type = req.query.type as string; + const page = Number(req.query.page) || 1; + const pageSize = 20; + + if (!artistId) { + return next({ + status: 400, + message: 'Artist ID is required', + }); + } + + const person = await tmdb.getPerson({ + personId: Number(req.params.id), + language: (req.query.language as string) ?? req.locale, + }); + + if (!person.birthday) { + return res.status(200).json({ + page: 1, + pageInfo: { total: 0, totalPages: 0 }, + results: [], + }); + } + + try { + const artistDetails = await musicBrainz.getArtist({ + artistId: artistId, + }); + + const { mapArtistDetails } = await import('@server/models/Artist'); + const mappedDetails = await mapArtistDetails(artistDetails); + + if (!mappedDetails.Albums?.length) { + return res.status(200).json({ + page: 1, + pageInfo: { + total: 0, + totalPages: 0, + }, + results: [], + }); + } + + let filteredAlbums = mappedDetails.Albums; + if (type) { + if (type === 'Other') { + filteredAlbums = mappedDetails.Albums.filter( + (album) => !['Album', 'Single', 'EP'].includes(album.type) + ); + } else { + filteredAlbums = mappedDetails.Albums.filter( + (album) => album.type === type + ); + } + } + + const albumPromises = filteredAlbums.map(async (album) => { + try { + const albumDetails = await musicBrainz.getAlbum({ + albumId: album.id, + }); + + let images = albumDetails.images; + + if (!images || images.length === 0) { + try { + const coverArtData = await coverArtArchive.getCoverArt(album.id); + if (coverArtData.images?.length > 0) { + images = coverArtData.images.map((img) => ({ + CoverType: img.front ? 'Cover' : 'Poster', + Url: img.image, + })); + } + } catch (coverArtError) { + // Silently handle cover art fetch errors + } + } + + return { + ...album, + images: images || [], + releasedate: albumDetails.releasedate || '', + }; + } catch (e) { + return album; + } + }); + + const albumsWithDetails = await Promise.all(albumPromises); + + const sortedAlbums = albumsWithDetails.sort((a, b) => { + if (!a.releasedate && !b.releasedate) return 0; + if (!a.releasedate) return 1; + if (!b.releasedate) return -1; + return ( + new Date(b.releasedate).getTime() - new Date(a.releasedate).getTime() + ); + }); + + const totalResults = sortedAlbums.length; + const totalPages = Math.ceil(totalResults / pageSize); + const start = (page - 1) * pageSize; + const end = start + pageSize; + const paginatedAlbums = sortedAlbums.slice(start, end); + + const media = await Media.getRelatedMedia( + req.user, + paginatedAlbums.map((album) => album.id) + ); + + const results = paginatedAlbums.map((album) => ({ + ...album, + mediaInfo: media?.find((med) => med.mbId === album.id), + })); + + return res.status(200).json({ + page, + pageInfo: { + total: totalResults, + totalPages, + }, + results, + }); + } catch (e) { + logger.error('Something went wrong retrieving discography', { + label: 'Person API', + errorMessage: e.message, + personId: req.params.id, + artistId, + }); + return next({ + status: 500, + message: 'Unable to retrieve discography.', + }); + } +}); + personRoutes.get('/:id/combined_credits', async (req, res, next) => { const tmdb = new TheMovieDb(); diff --git a/server/routes/request.ts b/server/routes/request.ts index a142a6c0..35d59c04 100644 --- a/server/routes/request.ts +++ b/server/routes/request.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 { @@ -213,6 +214,21 @@ requestRoutes.get, RequestResultsResponse>( }) ); + // get all quality profiles for every configured lidarr server + const lidarrServers = await Promise.all( + settings.lidarr.map(async (lidarrSetting) => { + const lidarr = new LidarrAPI({ + apiKey: lidarrSetting.apiKey, + url: LidarrAPI.buildUrl(lidarrSetting, '/api/v1'), + }); + + return { + id: lidarrSetting.id, + profiles: await lidarr.getProfiles().catch(() => undefined), + }; + }) + ); + // add profile names to the media requests, with undefined if not found let mappedRequests = requests.map((r) => { switch (r.type) { @@ -234,6 +250,14 @@ requestRoutes.get, RequestResultsResponse>( ?.profiles?.find((profile) => profile.id === r.profileId)?.name, }; } + case MediaType.MUSIC: { + return { + ...r, + profileName: lidarrServers + .find((serverr) => serverr.id === r.serverId) + ?.profiles?.find((profile) => profile.id === r.profileId)?.name, + }; + } } }); diff --git a/server/routes/search.ts b/server/routes/search.ts index ee2fd9eb..9e530406 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,7 +1,14 @@ +import MusicBrainz from '@server/api/musicbrainz'; +import type { + MbAlbumResult, + MbArtistResult, +} from '@server/api/musicbrainz/interfaces'; 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 { + findSearchProvider, + type CombinedSearchResponse, +} from '@server/lib/search'; import logger from '@server/logger'; import { mapSearchResults } from '@server/models/Search'; import { Router } from 'express'; @@ -11,7 +18,8 @@ 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: CombinedSearchResponse; + let combinedResults: CombinedSearchResponse['results'] = []; try { if (searchProvider) { @@ -25,24 +33,56 @@ searchRoutes.get('/', async (req, res, next) => { }); } else { const tmdb = new TheMovieDb(); - - results = await tmdb.searchMulti({ + const tmdbResults = await tmdb.searchMulti({ query: queryString, page: Number(req.query.page), - language: (req.query.language as string) ?? req.locale, }); + + combinedResults = [...tmdbResults.results]; + + const musicbrainz = new MusicBrainz(); + const mbResults = await musicbrainz.searchMulti({ query: queryString }); + + if (mbResults.length > 0) { + const mbMappedResults = mbResults.map((result) => { + if (result.artist) { + return { + ...result.artist, + media_type: 'artist', + } as MbArtistResult; + } + if (result.album) { + return { + ...result.album, + media_type: 'album', + } as MbAlbumResult; + } + throw new Error('Invalid search result type'); + }); + + combinedResults = [...combinedResults, ...mbMappedResults]; + } + + results = { + page: tmdbResults.page, + total_pages: tmdbResults.total_pages, + total_results: tmdbResults.total_results + mbResults.length, + results: combinedResults, + }; } const media = await Media.getRelatedMedia( req.user, - results.results.map((result) => result.id) + results.results.map((result) => ('id' in result ? result.id : 0)) ); + 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', { diff --git a/server/routes/service.ts b/server/routes/service.ts index 8f6c92b0..d51d9b50 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'; @@ -213,4 +214,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, + activeDirectory: lidarr.activeDirectory, + activeProfileId: lidarr.activeProfileId, + activeTags: lidarr.tags ?? [], + isDefault: lidarr.isDefault, + }) + ); + + return res.status(200).json(filteredLidarrServers); +}); + +serviceRoutes.get<{ id: string }>('/lidarr/:id', async (req, res, next) => { + const settings = getSettings(); + + const lidarrSettings = settings.lidarr.find( + (lidarr) => lidarr.id === Number(req.params.id) + ); + + if (!lidarrSettings) { + return next({ + status: 404, + message: 'Lidarr server not found.', + }); + } + + const lidarr = new LidarrAPI({ + apiKey: lidarrSettings.apiKey, + url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'), + }); + + try { + const [profiles, rootFolders, tags] = await Promise.all([ + lidarr.getProfiles(), + lidarr.getRootFolders(), + lidarr.getTags(), + ]); + + return res.status(200).json({ + server: { + id: lidarrSettings.id, + name: lidarrSettings.name, + isDefault: lidarrSettings.isDefault, + activeDirectory: lidarrSettings.activeDirectory, + activeProfileId: lidarrSettings.activeProfileId, + }, + profiles, + rootFolders: rootFolders.map((folder) => ({ + id: folder.id, + path: folder.path, + freeSpace: folder.freeSpace, + totalSpace: folder.totalSpace, + })), + tags, + } as ServiceCommonServerWithDetails); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + export default serviceRoutes; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 68353968..64b905f2 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -40,6 +40,7 @@ import path from 'path'; import semver from 'semver'; import { URL } from 'url'; import metadataRoutes from './metadata'; +import lidarrRoutes from './lidarr'; import notificationRoutes from './notifications'; import radarrRoutes from './radarr'; import sonarrRoutes from './sonarr'; @@ -49,6 +50,7 @@ const settingsRoutes = Router(); settingsRoutes.use('/notifications', notificationRoutes); settingsRoutes.use('/radarr', radarrRoutes); settingsRoutes.use('/sonarr', sonarrRoutes); +settingsRoutes.use('/lidarr', lidarrRoutes); settingsRoutes.use('/discover', discoverSettingRoutes); settingsRoutes.use('/metadatas', metadataRoutes); @@ -758,6 +760,9 @@ settingsRoutes.get('/cache', async (_req, res) => { const tmdbImageCache = await ImageProxy.getImageStats('tmdb'); const avatarImageCache = await ImageProxy.getImageStats('avatar'); + const caaImageCache = await ImageProxy.getImageStats('caa'); + const lidarrImageCache = await ImageProxy.getImageStats('lidarr'); + const fanartImageCache = await ImageProxy.getImageStats('fanart'); const stats: DnsStats | undefined = dnsCache?.getStats(); const entries: DnsEntries | undefined = dnsCache?.getCacheEntries(); @@ -767,6 +772,9 @@ settingsRoutes.get('/cache', async (_req, res) => { imageCache: { tmdb: tmdbImageCache, avatar: avatarImageCache, + caa: caaImageCache, + lidarr: lidarrImageCache, + fanart: fanartImageCache, }, dnsCache: { stats, 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/routes/tmdbproxy.ts b/server/routes/tmdbproxy.ts new file mode 100644 index 00000000..df4b4ffe --- /dev/null +++ b/server/routes/tmdbproxy.ts @@ -0,0 +1,38 @@ +import ImageProxy from '@server/lib/imageproxy'; +import logger from '@server/logger'; +import { Router } from 'express'; + +const router = Router(); +const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', { + rateLimitOptions: { + maxRPS: 50, + }, +}); + +/** + * Image Proxy + */ +router.get('/*', async (req, res) => { + const imagePath = req.path.replace('/image', ''); + try { + const imageData = await tmdbImageProxy.getImage(imagePath); + + res.writeHead(200, { + 'Content-Type': `image/${imageData.meta.extension}`, + 'Content-Length': imageData.imageBuffer.length, + 'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`, + 'OS-Cache-Key': imageData.meta.cacheKey, + 'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT', + }); + + res.end(imageData.imageBuffer); + } catch (e) { + logger.error('Failed to proxy image', { + imagePath, + errorMessage: e.message, + }); + res.status(500).send(); + } +}); + +export default router; diff --git a/server/routes/watchlist.ts b/server/routes/watchlist.ts index bbb44da0..5a1cc18a 100644 --- a/server/routes/watchlist.ts +++ b/server/routes/watchlist.ts @@ -36,6 +36,7 @@ watchlistRoutes.post( case QueryFailedError: logger.warn('Something wrong with data watchlist', { tmdbId: req.body.tmdbId, + mbId: req.body.mbId, mediaType: req.body.mediaType, label: 'Watchlist', }); @@ -49,7 +50,7 @@ watchlistRoutes.post( } ); -watchlistRoutes.delete('/:tmdbId', async (req, res, next) => { +watchlistRoutes.delete('/:id', async (req, res, next) => { if (!req.user) { return next({ status: 401, @@ -57,7 +58,11 @@ watchlistRoutes.delete('/:tmdbId', async (req, res, next) => { }); } try { - await Watchlist.deleteWatchlist(Number(req.params.tmdbId), req.user); + const id = isNaN(Number(req.params.id)) + ? req.params.id + : Number(req.params.id); + + await Watchlist.deleteWatchlist(id, req.user); return res.status(204).send(); } catch (e) { if (e instanceof NotFoundError) { diff --git a/server/subscriber/IssueCommentSubscriber.ts b/server/subscriber/IssueCommentSubscriber.ts index 71db981d..9a73275a 100644 --- a/server/subscriber/IssueCommentSubscriber.ts +++ b/server/subscriber/IssueCommentSubscriber.ts @@ -1,3 +1,4 @@ +import LidarrAPI from '@server/api/servarr/lidarr'; import TheMovieDb from '@server/api/themoviedb'; import { IssueType, IssueTypeName } from '@server/constants/issue'; import { MediaType } from '@server/constants/media'; @@ -7,6 +8,7 @@ import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { sortBy } from 'lodash'; import type { EntitySubscriberInterface, InsertEvent } from 'typeorm'; @@ -21,8 +23,8 @@ export class IssueCommentSubscriber } private async sendIssueCommentNotification(entity: IssueComment) { - let title: string; - let image: string; + let title = ''; + let image = ''; const tmdb = new TheMovieDb(); try { @@ -48,13 +50,33 @@ export class IssueCommentSubscriber movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' }`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`; - } else { + } else if (media.mediaType === MediaType.TV) { const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId }); title = `${tvshow.name}${ tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : '' }`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`; + } else if (media.mediaType === MediaType.MUSIC) { + const settings = getSettings(); + if (!settings.lidarr[0]) { + throw new Error('No Lidarr server configured'); + } + + const lidarr = new LidarrAPI({ + apiKey: settings.lidarr[0].apiKey, + url: LidarrAPI.buildUrl(settings.lidarr[0], '/api/v1'), + }); + + if (!media.mbId) { + throw new Error('MusicBrainz ID is undefined'); + } + + const album = await lidarr.getAlbumByMusicBrainzId(media.mbId); + const artist = await lidarr.getArtist({ id: album.artistId }); + + title = `${artist.artistName} - ${album.title}`; + image = album.images?.[0]?.url ?? ''; } const [firstComment] = sortBy(issue.comments, 'id'); diff --git a/server/subscriber/IssueSubscriber.ts b/server/subscriber/IssueSubscriber.ts index d54523cf..b117b4db 100644 --- a/server/subscriber/IssueSubscriber.ts +++ b/server/subscriber/IssueSubscriber.ts @@ -1,9 +1,11 @@ +import LidarrAPI from '@server/api/servarr/lidarr'; import TheMovieDb from '@server/api/themoviedb'; import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue'; import { MediaType } from '@server/constants/media'; import Issue from '@server/entity/Issue'; import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { sortBy } from 'lodash'; import type { @@ -20,8 +22,8 @@ export class IssueSubscriber implements EntitySubscriberInterface { } private async sendIssueNotification(entity: Issue, type: Notification) { - let title: string; - let image: string; + let title = ''; + let image = ''; const tmdb = new TheMovieDb(); try { @@ -32,13 +34,33 @@ export class IssueSubscriber implements EntitySubscriberInterface { movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' }`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`; - } else { + } else if (entity.media.mediaType === MediaType.TV) { const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId }); title = `${tvshow.name}${ tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : '' }`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`; + } else if (entity.media.mediaType === MediaType.MUSIC) { + const settings = getSettings(); + if (!settings.lidarr[0]) { + throw new Error('No Lidarr server configured'); + } + + const lidarr = new LidarrAPI({ + apiKey: settings.lidarr[0].apiKey, + url: LidarrAPI.buildUrl(settings.lidarr[0], '/api/v1'), + }); + + if (!entity.media.mbId) { + throw new Error('MusicBrainz ID is undefined'); + } + const album = await lidarr.getAlbumByMusicBrainzId(entity.media.mbId); + + const artist = await lidarr.getArtist({ id: album.artistId }); + + title = `${artist.artistName} - ${album.title}`; + image = album.images?.[0]?.url ?? ''; } const [firstComment] = sortBy(entity.comments, 'id'); diff --git a/server/utils/typeHelpers.ts b/server/utils/typeHelpers.ts index 548378ff..f3789339 100644 --- a/server/utils/typeHelpers.ts +++ b/server/utils/typeHelpers.ts @@ -1,3 +1,9 @@ +import type { + LidarrAlbumDetails, + LidarrAlbumResult, + LidarrArtistDetails, + LidarrArtistResult, +} from '@server/api/servarr/lidarr'; import type { TmdbCollectionResult, TmdbMovieDetails, @@ -38,6 +44,18 @@ export const isCollection = ( return (collection as TmdbCollectionResult).media_type === 'collection'; }; +export const isAlbum = ( + media: LidarrAlbumResult | LidarrArtistResult +): media is LidarrAlbumResult => { + return (media as LidarrAlbumResult).album?.albumType !== undefined; +}; + +export const isArtist = ( + media: LidarrAlbumResult | LidarrArtistResult +): media is LidarrArtistResult => { + return (media as LidarrArtistResult).artist?.artistType !== undefined; +}; + export const isMovieDetails = ( movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails ): movie is TmdbMovieDetails => { @@ -49,3 +67,15 @@ export const isTvDetails = ( ): tv is TmdbTvDetails => { return (tv as TmdbTvDetails).number_of_seasons !== undefined; }; + +export const isAlbumDetails = ( + details: LidarrAlbumDetails | LidarrArtistDetails +): details is LidarrAlbumDetails => { + return (details as LidarrAlbumDetails).albumType !== undefined; +}; + +export const isArtistDetails = ( + details: LidarrAlbumDetails | LidarrArtistDetails +): details is LidarrArtistDetails => { + return (details as LidarrArtistDetails).artistType !== undefined; +}; 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/AddedCard/index.tsx b/src/components/AddedCard/index.tsx new file mode 100644 index 00000000..a083dbe5 --- /dev/null +++ b/src/components/AddedCard/index.tsx @@ -0,0 +1,137 @@ +import TitleCard from '@app/components/TitleCard'; +import { Permission, useUser } from '@app/hooks/useUser'; +import type { MovieDetails } from '@server/models/Movie'; +import type { MusicDetails } from '@server/models/Music'; +import type { TvDetails } from '@server/models/Tv'; +import { useInView } from 'react-intersection-observer'; +import useSWR from 'swr'; + +export interface AddedCardProps { + id?: number | string; + tmdbId?: number; + tvdbId?: number; + mbId?: string; + type: 'movie' | 'tv' | 'music'; + canExpand?: boolean; + isAddedToWatchlist?: boolean; + mutateParent?: () => void; +} + +const isMovie = ( + media: MovieDetails | TvDetails | MusicDetails +): media is MovieDetails => { + return (media as MovieDetails).title !== undefined; +}; + +const isMusic = ( + media: MovieDetails | TvDetails | MusicDetails +): media is MusicDetails => { + return (media as MusicDetails).artistId !== undefined; +}; + +const AddedCard = ({ + id, + tmdbId, + tvdbId, + mbId, + type, + canExpand, + isAddedToWatchlist = false, + mutateParent, +}: AddedCardProps) => { + const { hasPermission } = useUser(); + + const { ref, inView } = useInView({ + triggerOnce: true, + }); + + const url = + type === 'music' + ? `/api/v1/music/${mbId}` + : type === 'movie' + ? `/api/v1/movie/${tmdbId}` + : `/api/v1/tv/${tmdbId}`; + + const { data: title, error } = useSWR< + MovieDetails | TvDetails | MusicDetails + >(inView ? url : null); + + if (!title && !error) { + return ( +
+ +
+ ); + } + + if (!title) { + return hasPermission(Permission.ADMIN) && id ? ( + + ) : null; + } + + if (isMusic(title)) { + return ( + image.CoverType === 'Cover')?.Url} + status={title.mediaInfo?.status} + title={title.title} + artist={title.artist.artistName} + type={title.type} + year={title.releaseDate} + mediaType={'album'} + canExpand={canExpand} + mutateParent={mutateParent} + /> + ); + } + + return isMovie(title) ? ( + + ) : ( + + ); +}; + +export default AddedCard; diff --git a/src/components/Blacklist/index.tsx b/src/components/Blacklist/index.tsx index 32bea3b9..615f5d02 100644 --- a/src/components/Blacklist/index.tsx +++ b/src/components/Blacklist/index.tsx @@ -24,6 +24,7 @@ import type { BlacklistResultsResponse, } from '@server/interfaces/api/blacklistInterfaces'; import type { MovieDetails } from '@server/models/Movie'; +import type { MusicDetails } from '@server/models/Music'; import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import Link from 'next/link'; @@ -59,6 +60,12 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { return (movie as MovieDetails).title !== undefined; }; +const isMusic = ( + media: MovieDetails | TvDetails | MusicDetails +): media is MusicDetails => { + return (media as MusicDetails).artistId !== undefined; +}; + const Blacklist = () => { const [currentPageSize, setCurrentPageSize] = useState(10); const [searchFilter, debouncedSearchFilter, setSearchFilter] = @@ -277,12 +284,15 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { const { hasPermission } = useUser(); const url = - item.mediaType === 'movie' + item.mediaType === 'music' + ? `/api/v1/music/${item.mbId}` + : item.mediaType === 'movie' ? `/api/v1/movie/${item.tmdbId}` : `/api/v1/tv/${item.tmdbId}`; - const { data: title, error } = useSWR( - inView ? url : null - ); + + const { data: title, error } = useSWR< + MovieDetails | TvDetails | MusicDetails + >(inView ? url : null); if (!title && !error) { return ( @@ -293,11 +303,15 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { ); } - const removeFromBlacklist = async (tmdbId: number, title?: string) => { + const removeFromBlacklist = async ( + tmdbId?: number, + mbId?: string, + title?: string + ) => { setIsUpdating(true); try { - await axios.delete(`/api/v1/blacklist/${tmdbId}`); + await axios.delete(`/api/v1/blacklist/${mbId ?? tmdbId}`); addToast( @@ -321,11 +335,24 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { return (
- {title && title.backdropPath && ( + {title && (
img.CoverType === 'Fanart') + ?.Url || + title.artist.images?.find((img) => img.CoverType === 'Poster') + ?.Url || + title.images?.find( + (img) => img.CoverType.toLowerCase() === 'cover' + )?.Url || + '' + : `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${ + title.backdropPath ?? '' + }` + } alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} fill @@ -343,43 +370,58 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
image.CoverType === 'Cover') + ?.Url ?? '/images/seerr_poster_not_found.png' + : title.posterPath + ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` + : '/images/seerr_poster_not_found.png' : '/images/seerr_poster_not_found.png' } alt="" sizes="100vw" style={{ width: '100%', height: 'auto', objectFit: 'cover' }} width={600} - height={900} + height={title && isMusic(title) ? 600 : 900} />
{title && - (isMovie(title) - ? title.releaseDate - : title.firstAirDate - )?.slice(0, 4)} + (isMusic(title) + ? title.releaseDate?.slice(0, 4) + : isMovie(title) + ? title.releaseDate?.slice(0, 4) + : title.firstAirDate?.slice(0, 4))}
- {title && (isMovie(title) ? title.title : title.name)} + {title && + (isMusic(title) + ? `${title.artist.artistName} - ${title.title}` + : isMovie(title) + ? title.title + : title.name)}
@@ -446,12 +488,18 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { {intl.formatMessage(globalMessages.movie)}
- ) : ( + ) : item.mediaType === 'tv' ? (
{intl.formatMessage(globalMessages.tvshow)}
+ ) : ( +
+
+ {intl.formatMessage(globalMessages.music)} +
+
)}
@@ -462,7 +510,13 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { onClick={() => removeFromBlacklist( item.tmdbId, - title && (isMovie(title) ? title.title : title.name) + item.mbId, + title && + (isMusic(title) + ? `${title.artist.artistName} - ${title.title}` + : isMovie(title) + ? title.title + : title.name) ) } confirmText={intl.formatMessage( diff --git a/src/components/BlacklistBlock/index.tsx b/src/components/BlacklistBlock/index.tsx index 1e8f1fb6..24d225d3 100644 --- a/src/components/BlacklistBlock/index.tsx +++ b/src/components/BlacklistBlock/index.tsx @@ -21,13 +21,15 @@ const messages = defineMessages('component.BlacklistBlock', { }); interface BlacklistBlockProps { - tmdbId: number; + tmdbId?: number; + mbId?: string; onUpdate?: () => void; onDelete?: () => void; } const BlacklistBlock = ({ tmdbId, + mbId, onUpdate, onDelete, }: BlacklistBlockProps) => { @@ -35,13 +37,28 @@ const BlacklistBlock = ({ const intl = useIntl(); const [isUpdating, setIsUpdating] = useState(false); const { addToast } = useToasts(); - const { data } = useSWR(`/api/v1/blacklist/${tmdbId}`); - const removeFromBlacklist = async (tmdbId: number, title?: string) => { + const { data } = useSWR( + mbId + ? `/api/v1/blacklist/music/${mbId}` + : tmdbId + ? `/api/v1/blacklist/${tmdbId}` + : null + ); + + const removeFromBlacklist = async ( + tmdbId?: number, + mbId?: string, + title?: string + ) => { setIsUpdating(true); try { - await axios.delete('/api/v1/blacklist/' + tmdbId); + const url = mbId + ? `/api/v1/blacklist/music/${mbId}` + : `/api/v1/blacklist/${tmdbId}`; + + await axios.delete(url); addToast( @@ -113,7 +130,9 @@ const BlacklistBlock = ({ > + + )} + + ); + }; + + return ( + <> + +
+ page.results) ?? []), + ...(singlesData?.flatMap((page) => page.results) ?? []), + ...(epsData?.flatMap((page) => page.results) ?? []), + ...(otherData?.flatMap((page) => page.results) ?? []), + ] + .filter((album) => album.images?.[0]?.Url) + .map((album) => album.images[0].Url) + .slice(0, 6)} + /> +
+
+ {data.images?.[0]?.Url && ( +
+ img.CoverType === 'Poster')?.Url ?? + data.images[0]?.Url + } + alt="" + style={{ width: '100%', height: '100%', objectFit: 'cover' }} + fill + /> +
+ )} +
+

{data.name}

+
+
{groupAttributes.join(' | ')}
+
+ {data.overview && ( +
+
setShowBio((show) => !show)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Space') { + setShowBio((show) => !show); + } + }} + role="button" + tabIndex={0} + > + + } + > +

{data.overview}

+
+
+
+ )} +
+
+ + {renderAlbumSection( + intl.formatMessage(messages.albums), + albumData ? albumData.flatMap((page) => page.results) : [], + isLoadingAlbums ?? false, + (albumData?.[0]?.results.length === 0 || + (albumData && + albumData[albumData.length - 1]?.results.length < 20)) ?? + false, + () => setAlbumSize(albumSize + 1) + )} + {renderAlbumSection( + intl.formatMessage(messages.singles), + singlesData ? singlesData.flatMap((page) => page.results) : [], + isLoadingSingles ?? false, + (singlesData?.[0]?.results.length === 0 || + (singlesData && + singlesData[singlesData.length - 1]?.results.length < 20)) ?? + false, + () => setSinglesSize(singlesSize + 1) + )} + {renderAlbumSection( + intl.formatMessage(messages.eps), + epsData ? epsData.flatMap((page) => page.results) : [], + isLoadingEps, + (epsData?.[0]?.results.length === 0 || + (epsData && epsData[epsData.length - 1]?.results.length < 20)) ?? + false, + () => setEpsSize(epsSize + 1) + )} + {renderAlbumSection( + intl.formatMessage(messages.other), + otherData ? otherData.flatMap((page) => page.results) : [], + isLoadingOther, + (otherData?.[0]?.results.length === 0 || + (otherData && + otherData[otherData.length - 1]?.results.length < 20)) ?? + false, + () => setOtherSize(otherSize + 1) + )} + + ); +}; + +export default GroupDetails; diff --git a/src/components/IssueDetails/index.tsx b/src/components/IssueDetails/index.tsx index fb15d049..8ff59668 100644 --- a/src/components/IssueDetails/index.tsx +++ b/src/components/IssueDetails/index.tsx @@ -26,6 +26,7 @@ import { MediaType } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import type Issue from '@server/entity/Issue'; import type { MovieDetails } from '@server/models/Movie'; +import type { MusicDetails } from '@server/models/Music'; import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import { Field, Form, Formik } from 'formik'; @@ -73,8 +74,19 @@ const messages = defineMessages('components.IssueDetails', { commentplaceholder: 'Add a comment…', }); -const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { - return (movie as MovieDetails).title !== undefined; +const isMovie = ( + media: MovieDetails | TvDetails | MusicDetails +): media is MovieDetails => { + return ( + (media as MovieDetails).title !== undefined && + (media as MovieDetails).releaseDate !== undefined + ); +}; + +const isMusic = ( + media: MovieDetails | TvDetails | MusicDetails +): media is MusicDetails => { + return (media as MusicDetails).artist !== undefined; }; const IssueDetails = () => { @@ -86,9 +98,13 @@ const IssueDetails = () => { const { data: issueData, mutate: revalidateIssue } = useSWR( `/api/v1/issue/${router.query.issueId}` ); - const { data, error } = useSWR( - issueData?.media.tmdbId - ? `/api/v1/${issueData.media.mediaType}/${issueData.media.tmdbId}` + const { data, error } = useSWR( + issueData?.media.tmdbId || issueData?.media.mbId + ? `/api/v1/${issueData.media.mediaType}/${ + issueData.media.mediaType === MediaType.MUSIC + ? issueData.media.mbId + : issueData.media.tmdbId + }` : null ); @@ -175,8 +191,17 @@ const IssueDetails = () => { } }; - const title = isMovie(data) ? data.title : data.name; - const releaseYear = isMovie(data) ? data.releaseDate : data.firstAirDate; + const title = isMusic(data) + ? `${data.artist.artistName} - ${data.title}` + : isMovie(data) + ? data.title + : data.name; + + const releaseYear = isMusic(data) + ? data.releaseDate + : isMovie(data) + ? data.releaseDate + : data.firstAirDate; return (
{ {intl.formatMessage(messages.deleteissueconfirm)} - {data.backdropPath && ( + {((!isMusic(data) && data.backdropPath) || isMusic(data)) && (
img.CoverType === 'Fanart') + ?.Url || + data.artist.images?.find((img) => img.CoverType === 'Poster') + ?.Url || + data.images?.find( + (img) => img.CoverType.toLowerCase() === 'cover' + )?.Url || + '/images/overseerr_poster_not_found.png' + : `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}` + } style={{ width: '100%', height: '100%', objectFit: 'cover' }} fill priority @@ -228,9 +264,13 @@ const IssueDetails = () => {
img.CoverType.toLowerCase() === 'cover' + )?.Url || '/images/overseerr_poster_not_found.png' + : data.posterPath ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}` : '/images/seerr_poster_not_found.png' } @@ -258,8 +298,18 @@ const IssueDetails = () => {

{title} diff --git a/src/components/IssueList/IssueItem/index.tsx b/src/components/IssueList/IssueItem/index.tsx index 184735b3..7f0a5851 100644 --- a/src/components/IssueList/IssueItem/index.tsx +++ b/src/components/IssueList/IssueItem/index.tsx @@ -11,6 +11,7 @@ import { IssueStatus } from '@server/constants/issue'; import { MediaType } from '@server/constants/media'; import type Issue from '@server/entity/Issue'; import type { MovieDetails } from '@server/models/Movie'; +import type { MusicDetails } from '@server/models/Music'; import type { TvDetails } from '@server/models/Tv'; import Link from 'next/link'; import { useInView } from 'react-intersection-observer'; @@ -30,8 +31,19 @@ const messages = defineMessages('components.IssueList.IssueItem', { descriptionpreview: 'Issue Description', }); -const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { - return (movie as MovieDetails).title !== undefined; +const isMovie = ( + media: MovieDetails | TvDetails | MusicDetails +): media is MovieDetails => { + return ( + (media as MovieDetails).title !== undefined && + (media as MovieDetails).releaseDate !== undefined + ); +}; + +const isMusic = ( + media: MovieDetails | TvDetails | MusicDetails +): media is MusicDetails => { + return (media as MusicDetails).artist !== undefined; }; interface IssueItemProps { @@ -45,12 +57,15 @@ const IssueItem = ({ issue }: IssueItemProps) => { triggerOnce: true, }); const url = - issue.media.mediaType === 'movie' + issue.media.mediaType === MediaType.MOVIE ? `/api/v1/movie/${issue.media.tmdbId}` - : `/api/v1/tv/${issue.media.tmdbId}`; - const { data: title, error } = useSWR( - inView ? url : null - ); + : issue.media.mediaType === MediaType.TV + ? `/api/v1/tv/${issue.media.tmdbId}` + : `/api/v1/music/${issue.media.mbId}`; + + const { data: title, error } = useSWR< + MovieDetails | TvDetails | MusicDetails + >(inView ? url : null); if (!title && !error) { return ( @@ -118,11 +133,18 @@ const IssueItem = ({ issue }: IssueItemProps) => { return (
- {title.backdropPath && ( + {((!isMusic(title) && title.backdropPath) || isMusic(title)) && (
img.CoverType === 'Fanart') + ?.Url ?? '/images/overseerr_poster_not_found.png' + : `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${ + title.backdropPath ?? '' + }` + } alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} fill @@ -142,14 +164,19 @@ const IssueItem = ({ issue }: IssueItemProps) => { href={ issue.media.mediaType === MediaType.MOVIE ? `/movie/${issue.media.tmdbId}` - : `/tv/${issue.media.tmdbId}` + : issue.media.mediaType === MediaType.TV + ? `/tv/${issue.media.tmdbId}` + : `/music/${issue.media.mbId}` } className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105" > image.CoverType === 'Cover') + ?.Url ?? '/images/overseerr_poster_not_found.png' + : title.posterPath ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` : '/images/seerr_poster_not_found.png' } @@ -162,20 +189,28 @@ const IssueItem = ({ issue }: IssueItemProps) => {
- {(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice( - 0, - 4 - )} + {isMusic(title) + ? title.releaseDate?.slice(0, 4) + : (isMovie(title) + ? title.releaseDate + : title.firstAirDate + )?.slice(0, 4)}
- {isMovie(title) ? title.title : title.name} + {isMusic(title) + ? `${title.artist.artistName} - ${title.title}` + : isMovie(title) + ? title.title + : title.name} {description && (
diff --git a/src/components/IssueModal/CreateIssueModal/index.tsx b/src/components/IssueModal/CreateIssueModal/index.tsx index db6e0ded..51236eaa 100644 --- a/src/components/IssueModal/CreateIssueModal/index.tsx +++ b/src/components/IssueModal/CreateIssueModal/index.tsx @@ -1,6 +1,9 @@ import Button from '@app/components/Common/Button'; import Modal from '@app/components/Common/Modal'; -import { issueOptions } from '@app/components/IssueModal/constants'; +import { + getIssueOptionsForMediaType, + issueOptions, +} from '@app/components/IssueModal/constants'; import useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; @@ -10,6 +13,7 @@ import { ArrowRightCircleIcon } from '@heroicons/react/24/solid'; import { MediaStatus } from '@server/constants/media'; import type Issue from '@server/entity/Issue'; import type { MovieDetails } from '@server/models/Movie'; +import type { MusicDetails } from '@server/models/Music'; import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import { Field, Formik } from 'formik'; @@ -39,8 +43,16 @@ const messages = defineMessages('components.IssueModal.CreateIssueModal', { submitissue: 'Submit Issue', }); -const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { - return (movie as MovieDetails).title !== undefined; +const isMovie = ( + media: MovieDetails | TvDetails | MusicDetails +): media is MovieDetails => { + return (media as MovieDetails).title !== undefined && !('artist' in media); +}; + +const isMusic = ( + media: MovieDetails | TvDetails | MusicDetails +): media is MusicDetails => { + return 'artist' in media; }; const classNames = (...classes: string[]) => { @@ -48,8 +60,9 @@ const classNames = (...classes: string[]) => { }; interface CreateIssueModalProps { - mediaType: 'movie' | 'tv'; + mediaType: 'movie' | 'tv' | 'music'; tmdbId?: number; + mbId?: string; onCancel?: () => void; } @@ -57,16 +70,21 @@ const CreateIssueModal = ({ onCancel, mediaType, tmdbId, + mbId, }: CreateIssueModalProps) => { const intl = useIntl(); const settings = useSettings(); const { hasPermission } = useUser(); const { addToast } = useToasts(); - const { data, error } = useSWR( - tmdbId ? `/api/v1/${mediaType}/${tmdbId}` : null + const { data, error } = useSWR( + mediaType === 'music' && mbId + ? `/api/v1/music/${mbId}` + : tmdbId + ? `/api/v1/${mediaType}/${tmdbId}` + : null ); - if (!tmdbId) { + if (!tmdbId && !mbId) { return null; } @@ -90,10 +108,13 @@ const CreateIssueModal = ({ ), }); + // Filter issue options based on media type + const availableIssueOptions = getIssueOptionsForMediaType(mediaType); + return (
{intl.formatMessage(messages.toastSuccessCreate, { - title: isMovie(data) ? data.title : data.name, + title: isMusic(data) + ? `${data.artist.artistName} - ${data.title}` + : isMovie(data) + ? data.title + : data.name, strong: (msg: React.ReactNode) => {msg}, })}
@@ -152,12 +177,28 @@ const CreateIssueModal = ({ backgroundClickable onCancel={onCancel} title={intl.formatMessage(messages.reportissue)} - subTitle={data && isMovie(data) ? data?.title : data?.name} + subTitle={ + data && + (isMusic(data) + ? `${data.artist.artistName} - ${data.title}` + : isMovie(data) + ? data.title + : data.name) + } cancelText={intl.formatMessage(globalMessages.close)} onOk={() => handleSubmit()} okText={intl.formatMessage(messages.submitissue)} loading={!data && !error} - backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`} + backdrop={ + data + ? isMusic(data) + ? data.images?.find((image) => image.CoverType === 'Cover') + ?.Url ?? '/images/overseerr_poster_not_found.png' + : data.backdropPath + ? `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}` + : '/images/overseerr_poster_not_found.png' + : undefined + } > {mediaType === 'tv' && data && !isMovie(data) && ( <> @@ -211,24 +252,25 @@ const CreateIssueModal = ({ - {[ - ...Array( - data.seasons.find( - (season) => - Number(values.problemSeason) === - season.seasonNumber - )?.episodeCount ?? 0 - ), - ].map((i, index) => ( - - ))} + {!isMusic(data) && + [ + ...Array( + data.seasons.find( + (season) => + Number(values.problemSeason) === + season.seasonNumber + )?.episodeCount ?? 0 + ), + ].map((i, index) => ( + + ))}
@@ -245,7 +287,7 @@ const CreateIssueModal = ({ Select an Issue
- {issueOptions.map((setting, index) => ( + {availableIssueOptions.map((setting, index) => ( { + let options = [...issueOptions]; + + if (mediaType === 'movie' || mediaType === 'tv') { + options = options.filter((option) => option.issueType !== IssueType.LYRICS); + } + + if (mediaType === 'music') { + options = options.filter( + (option) => + ![IssueType.VIDEO, IssueType.SUBTITLES].includes(option.issueType) + ); + } + + return options; +}; diff --git a/src/components/IssueModal/index.tsx b/src/components/IssueModal/index.tsx index bf7e923a..61bc6c46 100644 --- a/src/components/IssueModal/index.tsx +++ b/src/components/IssueModal/index.tsx @@ -4,12 +4,19 @@ import { Transition } from '@headlessui/react'; interface IssueModalProps { show?: boolean; onCancel: () => void; - mediaType: 'movie' | 'tv'; - tmdbId: number; + mediaType: 'movie' | 'tv' | 'music'; + tmdbId?: number; + mbId?: string; issueId?: never; } -const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => ( +const IssueModal = ({ + show, + mediaType, + onCancel, + tmdbId, + mbId, +}: IssueModalProps) => ( ( mediaType={mediaType} onCancel={onCancel} tmdbId={tmdbId} + mbId={mbId} /> ); diff --git a/src/components/Layout/MobileMenu/index.tsx b/src/components/Layout/MobileMenu/index.tsx index 09cec4a0..dad63ca2 100644 --- a/src/components/Layout/MobileMenu/index.tsx +++ b/src/components/Layout/MobileMenu/index.tsx @@ -10,6 +10,7 @@ import { ExclamationTriangleIcon, EyeSlashIcon, FilmIcon, + MusicalNoteIcon, SparklesIcon, TvIcon, UsersIcon, @@ -20,6 +21,7 @@ import { ExclamationTriangleIcon as FilledExclamationTriangleIcon, EyeSlashIcon as FilledEyeSlashIcon, FilmIcon as FilledFilmIcon, + MusicalNoteIcon as FilledMusicalNoteIcon, SparklesIcon as FilledSparklesIcon, TvIcon as FilledTvIcon, UsersIcon as FilledUsersIcon, @@ -92,6 +94,13 @@ const MobileMenu = ({ svgIconSelected: , activeRegExp: /^\/discover\/tv$/, }, + { + href: '/discover/music', + content: intl.formatMessage(menuMessages.browsemusic), + svgIcon: , + svgIconSelected: , + activeRegExp: /^\/discover\/music$/, + }, { href: '/requests', content: intl.formatMessage(menuMessages.requests), diff --git a/src/components/Layout/SearchInput/index.tsx b/src/components/Layout/SearchInput/index.tsx index fa8eb304..ac5b7b2f 100644 --- a/src/components/Layout/SearchInput/index.tsx +++ b/src/components/Layout/SearchInput/index.tsx @@ -5,7 +5,7 @@ import { MagnifyingGlassIcon } from '@heroicons/react/24/solid'; import { useIntl } from 'react-intl'; const messages = defineMessages('components.Layout.SearchInput', { - searchPlaceholder: 'Search Movies & TV', + searchPlaceholder: 'Search Movies, TV & Music', }); const SearchInput = () => { diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index d578bef8..8ad559f7 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -11,6 +11,7 @@ import { ExclamationTriangleIcon, EyeSlashIcon, FilmIcon, + MusicalNoteIcon, SparklesIcon, TvIcon, UsersIcon, @@ -26,11 +27,13 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', { dashboard: 'Discover', browsemovies: 'Movies', browsetv: 'Series', + browsemusic: 'Music', requests: 'Requests', blacklist: 'Blacklist', issues: 'Issues', users: 'Users', settings: 'Settings', + music: 'Music', }); interface SidebarProps { @@ -72,6 +75,12 @@ const SidebarLinks: SidebarLinkProps[] = [ svgIcon: , activeRegExp: /^\/discover\/tv$/, }, + { + href: '/discover/music', + messagesKey: 'music', + svgIcon: , + activeRegExp: /^\/discover\/music$/, + }, { href: '/requests', messagesKey: 'requests', diff --git a/src/components/Layout/UserDropdown/MiniQuotaDisplay/index.tsx b/src/components/Layout/UserDropdown/MiniQuotaDisplay/index.tsx index 2f5814f4..54f80c7e 100644 --- a/src/components/Layout/UserDropdown/MiniQuotaDisplay/index.tsx +++ b/src/components/Layout/UserDropdown/MiniQuotaDisplay/index.tsx @@ -11,6 +11,7 @@ const messages = defineMessages( { movierequests: 'Movie Requests', seriesrequests: 'Series Requests', + musicrequests: 'Music Requests', } ); @@ -26,7 +27,7 @@ const MiniQuotaDisplay = ({ userId }: MiniQuotaDisplayProps) => { return null; } - if (!data && !error) { + if (data === undefined && !error) { return ; } @@ -88,6 +89,34 @@ const MiniQuotaDisplay = ({ userId }: MiniQuotaDisplayProps) => { )}
+
+
+ {intl.formatMessage(messages.musicrequests)} +
+
+ {data?.music.limit ?? 0 > 0 ? ( + <> + + + {data?.music.remaining} / {data?.music.limit} + + + ) : ( + <> + + Unlimited + + )} +
+
)} diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index 7a2c1047..ce0f8426 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -25,8 +25,13 @@ import { } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfaces'; -import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; +import type { + LidarrSettings, + RadarrSettings, + SonarrSettings, +} from '@server/lib/settings'; import type { MovieDetails } from '@server/models/Movie'; +import type { MusicDetails } from '@server/models/Music'; import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import Link from 'next/link'; @@ -65,8 +70,19 @@ const messages = defineMessages('components.ManageSlideOver', { tvshow: 'series', }); -const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { - return (movie as MovieDetails).title !== undefined; +const isMovie = ( + media: MovieDetails | TvDetails | MusicDetails +): media is MovieDetails => { + return ( + (media as MovieDetails).title !== undefined && + (media as MusicDetails).artist === undefined + ); +}; + +const isMusic = ( + media: MovieDetails | TvDetails | MusicDetails +): media is MusicDetails => { + return (media as MusicDetails).artist !== undefined; }; interface ManageSlideOverProps { @@ -86,13 +102,21 @@ interface ManageSlideOverTvProps extends ManageSlideOverProps { data: TvDetails; } +interface ManageSlideOverMusicProps extends ManageSlideOverProps { + mediaType: 'music'; + data: MusicDetails; +} + const ManageSlideOver = ({ show, mediaType, onClose, data, revalidate, -}: ManageSlideOverMovieProps | ManageSlideOverTvProps) => { +}: + | ManageSlideOverMovieProps + | ManageSlideOverTvProps + | ManageSlideOverMusicProps) => { const { user: currentUser, hasPermission } = useUser(); const intl = useIntl(); const settings = useSettings(); @@ -109,6 +133,9 @@ const ManageSlideOver = ({ const { data: sonarrData } = useSWR( hasPermission(Permission.ADMIN) ? '/api/v1/settings/sonarr' : null ); + const { data: lidarrData } = useSWR( + hasPermission(Permission.ADMIN) ? '/api/v1/settings/lidarr' : null + ); const deleteMedia = async () => { if (data.mediaInfo) { @@ -138,6 +165,13 @@ const ManageSlideOver = ({ radarr.isDefault && radarr.id === data.mediaInfo?.serviceId ) !== undefined ); + } else if (data.mediaInfo.mediaType === MediaType.MUSIC) { + return ( + lidarrData?.find( + (lidarr) => + lidarr.isDefault && lidarr.id === data.mediaInfo?.serviceId + ) !== undefined + ); } else { return ( sonarrData?.find( @@ -215,11 +249,21 @@ const ManageSlideOver = ({ show={show} title={intl.formatMessage(messages.manageModalTitle, { mediaType: intl.formatMessage( - mediaType === 'movie' ? globalMessages.movie : globalMessages.tvshow + mediaType === 'movie' + ? globalMessages.movie + : mediaType === 'music' + ? globalMessages.album + : globalMessages.tvshow ), })} onClose={() => onClose()} - subText={isMovie(data) ? data.title : data.name} + subText={ + isMovie(data) + ? data.title + : isMusic(data) + ? `${data.artist} - ${data.title}` + : data.name + } >
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 || @@ -429,7 +473,12 @@ const ManageSlideOver = ({ {intl.formatMessage(messages.openarr, { - arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + arr: + mediaType === 'movie' + ? 'Radarr' + : mediaType === 'music' + ? 'Lidarr' + : 'Sonarr', })} @@ -450,7 +499,12 @@ const ManageSlideOver = ({ {intl.formatMessage(messages.removearr, { - arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + arr: + mediaType === 'movie' + ? 'Radarr' + : mediaType === 'music' + ? 'Lidarr' + : 'Sonarr', })} @@ -650,9 +704,9 @@ const ManageSlideOver = ({ {intl.formatMessage( - mediaType === 'movie' - ? messages.markavailable - : messages.markallseasonsavailable + mediaType === 'tv' + ? messages.markallseasonsavailable + : messages.markavailable )} diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index b3042b86..f0737f51 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -1,3 +1,4 @@ +import GroupCard from '@app/components/GroupCard'; import ShowMoreCard from '@app/components/MediaSlider/ShowMoreCard'; import PersonCard from '@app/components/PersonCard'; import Slider from '@app/components/Slider'; @@ -8,6 +9,8 @@ import { ArrowRightCircleIcon } from '@heroicons/react/24/outline'; import { MediaStatus } from '@server/constants/media'; import { Permission } from '@server/lib/permissions'; import type { + AlbumResult, + ArtistResult, MovieResult, PersonResult, TvResult, @@ -20,7 +23,13 @@ interface MixedResult { page: number; totalResults: number; totalPages: number; - results: (TvResult | MovieResult | PersonResult)[]; + results: ( + | MovieResult + | TvResult + | PersonResult + | AlbumResult + | ArtistResult + )[]; } interface MediaSliderProps { @@ -62,7 +71,7 @@ const MediaSlider = ({ let titles = (data ?? []).reduce( (a, v) => [...a, ...v.results], - [] as (MovieResult | TvResult | PersonResult)[] + [] as (MovieResult | TvResult | PersonResult | AlbumResult | ArtistResult)[] ); if (settings.currentSettings.hideAvailable) { @@ -112,7 +121,7 @@ const MediaSlider = ({ .filter((title) => { if (!blacklistVisibility) return ( - (title as TvResult | MovieResult).mediaInfo?.status !== + (title as TvResult | MovieResult | AlbumResult).mediaInfo?.status !== MediaStatus.BLACKLISTED ); return title; @@ -159,6 +168,41 @@ const MediaSlider = ({ profilePath={title.profilePath} /> ); + case 'album': + return ( + image.CoverType === 'Cover')?.Url + } + status={title.mediaInfo?.status} + title={title.title} + year={title.releasedate} + mediaType={title.mediaType} + artist={title.artistname} + type={title.type} + inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0} + /> + ); + case 'artist': + return title.type === 'Group' ? ( + + ) : ( + + ); } }); @@ -169,7 +213,9 @@ const MediaSlider = ({ posters={titles .slice(20, 24) .map((title) => - title.mediaType !== 'person' ? title.posterPath : undefined + title.mediaType !== 'person' + ? (title as MovieResult | TvResult).posterPath + : undefined )} /> ); diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 85574fe9..b842b46f 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -1060,26 +1060,14 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
)} {!!streamingProviders.length && ( -
+
{intl.formatMessage(messages.streamingproviders)} - + {streamingProviders.map((p) => { return ( - - - - - + + {p.name} + ); })} diff --git a/src/components/MusicDetails/MusicArtistDiscography.tsx b/src/components/MusicDetails/MusicArtistDiscography.tsx new file mode 100644 index 00000000..74d9874a --- /dev/null +++ b/src/components/MusicDetails/MusicArtistDiscography.tsx @@ -0,0 +1,76 @@ +import Header from '@app/components/Common/Header'; +import ListView from '@app/components/Common/ListView'; +import PageTitle from '@app/components/Common/PageTitle'; +import useDiscover from '@app/hooks/useDiscover'; +import Error from '@app/pages/_error'; +import defineMessages from '@app/utils/defineMessages'; +import type { MusicDetails } from '@server/models/Music'; +import type { AlbumResult } from '@server/models/Search'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useIntl } from 'react-intl'; +import useSWR from 'swr'; + +const messages = defineMessages('components.MusicDetails', { + artistalbums: "Artist's Discography", +}); + +const MusicArtistDiscography = () => { + const intl = useIntl(); + const router = useRouter(); + const { data: musicData } = useSWR( + `/api/v1/music/${router.query.musicId}` + ); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover( + `/api/v1/music/${router.query.musicId}/discography` + ); + + if (error) { + return ; + } + + return ( + <> + +
+
+ {musicData?.artist.artistName} + + } + > + {intl.formatMessage(messages.artistalbums)} +
+
+ 0) + } + onScrollBottom={fetchMore} + /> + + ); +}; + +export default MusicArtistDiscography; diff --git a/src/components/MusicDetails/MusicArtistSimilar.tsx b/src/components/MusicDetails/MusicArtistSimilar.tsx new file mode 100644 index 00000000..933e2d32 --- /dev/null +++ b/src/components/MusicDetails/MusicArtistSimilar.tsx @@ -0,0 +1,76 @@ +import Header from '@app/components/Common/Header'; +import ListView from '@app/components/Common/ListView'; +import PageTitle from '@app/components/Common/PageTitle'; +import useDiscover from '@app/hooks/useDiscover'; +import Error from '@app/pages/_error'; +import defineMessages from '@app/utils/defineMessages'; +import type { MusicDetails } from '@server/models/Music'; +import type { ArtistResult } from '@server/models/Search'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useIntl } from 'react-intl'; +import useSWR from 'swr'; + +const messages = defineMessages('components.MusicDetails', { + similarArtists: 'Similar Artists', +}); + +const MusicArtistSimilar = () => { + const intl = useIntl(); + const router = useRouter(); + const { data: musicData } = useSWR( + `/api/v1/music/${router.query.musicId}` + ); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover( + `/api/v1/music/${router.query.musicId}/similar` + ); + + if (error) { + return ; + } + + return ( + <> + +
+
+ {musicData?.artist.artistName} + + } + > + {intl.formatMessage(messages.similarArtists)} +
+
+ 0) + } + onScrollBottom={fetchMore} + /> + + ); +}; + +export default MusicArtistSimilar; diff --git a/src/components/MusicDetails/index.tsx b/src/components/MusicDetails/index.tsx new file mode 100644 index 00000000..ef23f3ef --- /dev/null +++ b/src/components/MusicDetails/index.tsx @@ -0,0 +1,622 @@ +import Spinner from '@app/assets/spinner.svg'; +import BlacklistModal from '@app/components/BlacklistModal'; +import Button from '@app/components/Common/Button'; +import CachedImage from '@app/components/Common/CachedImage'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +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 ManageSlideOver from '@app/components/ManageSlideOver'; +import MediaSlider from '@app/components/MediaSlider'; +import RequestButton from '@app/components/RequestButton'; +import StatusBadge from '@app/components/StatusBadge'; +import useDeepLinks from '@app/hooks/useDeepLinks'; +import useSettings from '@app/hooks/useSettings'; +import { Permission, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import ErrorPage from '@app/pages/_error'; +import defineMessages from '@app/utils/defineMessages'; +import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper'; +import { + CogIcon, + ExclamationTriangleIcon, + EyeSlashIcon, + PlayIcon, +} from '@heroicons/react/24/outline'; +import { MinusCircleIcon, StarIcon } from '@heroicons/react/24/solid'; +import { IssueStatus } from '@server/constants/issue'; +import { MediaStatus, MediaType } from '@server/constants/media'; +import { MediaServerType } from '@server/constants/server'; +import type { MusicDetails as MusicDetailsType } from '@server/models/Music'; +import { useRouter } from 'next/router'; +import { useCallback, useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; + +const messages = defineMessages('components.MusicDetails', { + biography: 'Biography', + runtime: '{minutes} minutes', + album: 'Album', + releasedate: 'Release Date', + play: 'Play on {mediaServerName}', + reportissue: 'Report an Issue', + managemusic: 'Manage Music', + biographyunavailable: 'Biography unavailable.', + trackstitle: 'Tracks', + tracksunavailable: 'No tracks available.', + watchlistSuccess: '{title} added to watchlist successfully!', + watchlistDeleted: + '{title} removed from watchlist successfully!', + watchlistError: 'Something went wrong try again.', + removefromwatchlist: 'Remove From Watchlist', + addtowatchlist: 'Add To Watchlist', + status: 'Status', + label: 'Label', + artisttype: 'Artist Type', + artiststatus: 'Artist Status', + discography: "{artistName}'s discography", + similarArtists: 'Similar Artists', +}); + +interface MusicDetailsProps { + music?: MusicDetailsType; +} + +const MusicDetails = ({ music }: MusicDetailsProps) => { + const settings = useSettings(); + const { user, hasPermission } = useUser(); + const router = useRouter(); + const intl = useIntl(); + const [showManager, setShowManager] = useState(false); + const [showIssueModal, setShowIssueModal] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [toggleWatchlist, setToggleWatchlist] = useState( + !music?.onUserWatchlist + ); + const [isBlacklistUpdating, setIsBlacklistUpdating] = + useState(false); + const [showBlacklistModal, setShowBlacklistModal] = useState(false); + const { addToast } = useToasts(); + + const { + data, + error, + mutate: revalidate, + } = useSWR(`/api/v1/music/${router.query.musicId}`, { + fallbackData: music, + refreshInterval: refreshIntervalHelper( + { + downloadStatus: music?.mediaInfo?.downloadStatus, + downloadStatus4k: undefined, + }, + 15000 + ), + }); + + useEffect(() => { + setShowManager(router.query.manage == '1' ? true : false); + }, [router.query.manage]); + + const closeBlacklistModal = useCallback( + () => setShowBlacklistModal(false), + [] + ); + + const { mediaUrl: plexUrl } = useDeepLinks({ + mediaUrl: data?.mediaInfo?.mediaUrl, + mediaUrl4k: data?.mediaInfo?.mediaUrl4k, + iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl, + iOSPlexUrl4k: data?.mediaInfo?.iOSPlexUrl4k, + }); + + if (!data && !error) { + return ; + } + + if (!data) { + return ; + } + + const mediaLinks: PlayButtonLink[] = []; + + if ( + plexUrl && + hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], { + type: 'or', + }) + ) { + mediaLinks.push({ + text: getAvalaibleMediaServerName(), + url: plexUrl, + svg: , + }); + } + + const formatDuration = (milliseconds: number): string => { + if (!milliseconds) return ''; + + const totalMinutes = Math.floor(milliseconds / 1000 / 60); + + return `${totalMinutes} Minute${totalMinutes > 1 ? 's' : ''}`; + }; + + function getAvalaibleMediaServerName() { + if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) { + return intl.formatMessage(messages.play, { mediaServerName: 'Emby' }); + } + + if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) { + return intl.formatMessage(messages.play, { mediaServerName: 'Plex' }); + } + + return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' }); + } + + const onClickWatchlistBtn = async (): Promise => { + setIsUpdating(true); + + const res = await fetch('/api/v1/watchlist', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + mbId: music?.id, + mediaType: MediaType.MUSIC, + title: music?.title, + }), + }); + + if (!res.ok) { + addToast(intl.formatMessage(messages.watchlistError), { + appearance: 'error', + autoDismiss: true, + }); + + setIsUpdating(false); + return; + } + + const data = await res.json(); + + if (data) { + addToast( + + {intl.formatMessage(messages.watchlistSuccess, { + title: music?.title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } + + setIsUpdating(false); + setToggleWatchlist((prevState) => !prevState); + }; + + const onClickDeleteWatchlistBtn = async (): Promise => { + setIsUpdating(true); + try { + const res = await fetch(`/api/v1/watchlist/${music?.id}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error(); + + if (res.status === 204) { + addToast( + + {intl.formatMessage(messages.watchlistDeleted, { + title: music?.title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'info', autoDismiss: true } + ); + } + } catch (e) { + addToast(intl.formatMessage(messages.watchlistError), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsUpdating(false); + setToggleWatchlist((prevState) => !prevState); + } + }; + + const onClickHideItemBtn = async (): Promise => { + setIsBlacklistUpdating(true); + + const res = await fetch('/api/v1/blacklist', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + mbId: music?.id, + mediaType: 'music', + title: music?.title, + user: user?.id, + }), + }); + + if (res.status === 201) { + addToast( + + {intl.formatMessage(globalMessages.blacklistSuccess, { + title: music?.title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + + revalidate(); + } else if (res.status === 412) { + addToast( + + {intl.formatMessage(globalMessages.blacklistDuplicateError, { + title: music?.title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'info', autoDismiss: true } + ); + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + setIsBlacklistUpdating(false); + closeBlacklistModal(); + }; + + const showHideButton = hasPermission([Permission.MANAGE_BLACKLIST], { + type: 'or', + }); + + const totalDurationMs = data.releases?.[0]?.tracks?.reduce( + (sum, track) => sum + (track.durationMs || 0), + 0 + ); + + const truncateOverview = (text: string): string => { + const maxLength = 800; + if (!text || text.length <= maxLength) return text; + + const truncated = text.substring(0, maxLength); + return truncated.substring(0, truncated.lastIndexOf('.') + 1); + }; + + return ( +
+
+ img.CoverType === 'Fanart') + ?.Url || + data.artist.images?.find((img) => img.CoverType === 'Poster') + ?.Url || + data.images?.find((img) => img.CoverType.toLowerCase() === 'cover') + ?.Url || + '/images/overseerr_poster_not_found.png' + } + style={{ width: '100%', height: '100%', objectFit: 'cover' }} + fill + priority + /> +
+
+ + setShowIssueModal(false)} + show={showIssueModal} + mediaType="music" + mbId={data.id} + /> + { + setShowManager(false); + router.push({ + pathname: router.pathname, + query: { musicId: router.query.musicId }, + }); + }} + revalidate={() => revalidate()} + show={showManager} + /> + +
+
+ img.CoverType.toLowerCase() === 'cover' + )?.Url || '/images/overseerr_poster_not_found.png' + } + alt="" + sizes="100vw" + style={{ width: '100%', height: 'auto' }} + width={600} + height={600} + priority + /> +
+
+
+ 0} + mbId={data.mediaInfo?.mbId} + mediaType="music" + serviceUrl={data.mediaInfo?.serviceUrl} + /> +
+

+ {data.title} - {data.artist.artistName}{' '} + {data.releaseDate && ( + + ({new Date(data.releaseDate).getFullYear()}) + + )} +

+ + {[ + {data.type}, + totalDurationMs ? formatDuration(totalDurationMs) : null, + data.genres.length > 0 ? data.genres.join(', ') : null, + ] + .filter(Boolean) + .map((t, k) => {t}) + .reduce((prev, curr) => ( + <> + {prev} + | + {curr} + + ))} + +
+
+ {showHideButton && + data?.mediaInfo?.status !== MediaStatus.PROCESSING && + data?.mediaInfo?.status !== MediaStatus.AVAILABLE && + data?.mediaInfo?.status !== MediaStatus.PENDING && + data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && ( + + + + )} + {data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && ( + <> + {toggleWatchlist ? ( + + + + ) : ( + + + + )} + + )} + + revalidate()} + /> + {data.mediaInfo?.status === MediaStatus.AVAILABLE && + hasPermission( + [Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES], + { + type: 'or', + } + ) && ( + + + + )} + {hasPermission(Permission.MANAGE_REQUESTS) && + data.mediaInfo && + (data.mediaInfo.jellyfinMediaId || + data.mediaInfo.jellyfinMediaId4k || + data.mediaInfo.status !== MediaStatus.UNKNOWN || + data.mediaInfo.status4k !== MediaStatus.UNKNOWN) && ( + + + + )} +
+
+
+
+

{intl.formatMessage(messages.biography)}

+

+ {data.artist.overview + ? truncateOverview(data.artist.overview) + : intl.formatMessage(messages.biographyunavailable)} +

+

{intl.formatMessage(messages.trackstitle)}

+ {data.releases?.[0]?.tracks?.length > 0 ? ( +
+ {data.releases[0].tracks.map((track, index) => ( +
+
+ {index + 1} + + {track.trackName} + + + {Math.floor((track.durationMs ?? 0) / 1000 / 60)}: + {String( + Math.floor(((track.durationMs ?? 0) / 1000) % 60) + ).padStart(2, '0')} + +
+
+ ))} +
+ ) : ( +
+ {intl.formatMessage(messages.tracksunavailable)} +
+ )} +
+
+
+ {data.releases?.[0]?.status && ( +
+ {intl.formatMessage(globalMessages.status)} + + {data.releases[0].status} + +
+ )} + {data.releases?.[0]?.label?.length > 0 && ( +
+ {intl.formatMessage(messages.label)} + + {data.releases[0].label.map((label) => ( + + {label} + + ))} + +
+ )} + {data.artist.type && ( +
+ {intl.formatMessage(messages.artisttype)} + {data.artist.type} +
+ )} + {data.artist.status && ( +
+ {intl.formatMessage(messages.artiststatus)} + + {data.artist.status.charAt(0).toUpperCase() + + data.artist.status.slice(1)} + +
+ )} +
+
+
+ + +
+
+ ); +}; + +export default MusicDetails; diff --git a/src/components/PermissionEdit/index.tsx b/src/components/PermissionEdit/index.tsx index 5a861de8..1542b059 100644 --- a/src/components/PermissionEdit/index.tsx +++ b/src/components/PermissionEdit/index.tsx @@ -25,6 +25,9 @@ export const messages = defineMessages('components.PermissionEdit', { requestTv: 'Request Series', requestTvDescription: 'Grant permission to submit requests for non-4K series.', + requestMusic: 'Request Music', + requestMusicDescription: + 'Grant permission to submit requests for music albums.', autoapprove: 'Auto-Approve', autoapproveDescription: 'Grant automatic approval for all non-4K media requests.', @@ -34,6 +37,9 @@ export const messages = defineMessages('components.PermissionEdit', { autoapproveSeries: 'Auto-Approve Series', autoapproveSeriesDescription: 'Grant automatic approval for non-4K series requests.', + autoapproveMusic: 'Auto-Approve Music', + autoapproveMusicDescription: + 'Grant automatic approval for music album requests.', autoapprove4k: 'Auto-Approve 4K', autoapprove4kDescription: 'Grant automatic approval for all 4K media requests.', @@ -62,6 +68,9 @@ export const messages = defineMessages('components.PermissionEdit', { autorequestSeries: 'Auto-Request Series', autorequestSeriesDescription: 'Grant permission to automatically submit requests for non-4K series via Plex Watchlist.', + autorequestMusic: 'Auto-Request Music', + autorequestMusicDescription: + 'Grant permission to automatically submit requests for music via Plex Watchlist.', viewrequests: 'View Requests', viewrequestsDescription: 'Grant permission to view media requests submitted by other users.', @@ -182,6 +191,12 @@ export const PermissionEdit = ({ description: intl.formatMessage(messages.requestTvDescription), permission: Permission.REQUEST_TV, }, + { + id: 'request-music', + name: intl.formatMessage(messages.requestMusic), + description: intl.formatMessage(messages.requestMusicDescription), + permission: Permission.REQUEST_MUSIC, + }, ], }, { @@ -219,6 +234,18 @@ export const PermissionEdit = ({ }, ], }, + { + id: 'autoapprovemusic', + name: intl.formatMessage(messages.autoapproveMusic), + description: intl.formatMessage(messages.autoapproveMusicDescription), + permission: Permission.AUTO_APPROVE_MUSIC, + requires: [ + { + permissions: [Permission.REQUEST, Permission.REQUEST_MUSIC], + type: 'or', + }, + ], + }, ], }, { @@ -256,6 +283,18 @@ export const PermissionEdit = ({ }, ], }, + { + id: 'autorequestmusic', + name: intl.formatMessage(messages.autorequestMusic), + description: intl.formatMessage(messages.autorequestMusicDescription), + permission: Permission.AUTO_REQUEST_MUSIC, + requires: [ + { + permissions: [Permission.REQUEST, Permission.REQUEST_MUSIC], + type: 'or', + }, + ], + }, ], }, { diff --git a/src/components/PersonCard/index.tsx b/src/components/PersonCard/index.tsx index 4d79a469..458438a6 100644 --- a/src/components/PersonCard/index.tsx +++ b/src/components/PersonCard/index.tsx @@ -4,11 +4,12 @@ import Link from 'next/link'; import { useState } from 'react'; interface PersonCardProps { - personId: number; + personId: number | string; name: string; subName?: string; profilePath?: string; canExpand?: boolean; + mediaType?: 'person' | 'artist'; } const PersonCard = ({ @@ -17,6 +18,7 @@ const PersonCard = ({ subName, profilePath, canExpand = false, + mediaType = 'person', }: PersonCardProps) => { const [isHovered, setHovered] = useState(false); @@ -51,8 +53,12 @@ const PersonCard = ({ {profilePath ? (
{ `/api/v1/person/${router.query.personId}/combined_credits` ); + const { + data: albumData, + size: albumSize, + setSize: setAlbumSize, + isValidating: isLoadingAlbums, + } = useSWRInfinite( + (index) => + data?.mbArtistId + ? `/api/v1/person/${router.query.personId}/discography?page=${ + index + 1 + }&type=Album&artistId=${data.mbArtistId}` + : null, + { revalidateFirstPage: false } + ); + + const { + data: singlesData, + size: singlesSize, + setSize: setSinglesSize, + isValidating: isLoadingSingles, + } = useSWRInfinite( + (index) => + data?.mbArtistId + ? `/api/v1/person/${router.query.personId}/discography?page=${ + index + 1 + }&type=Single&artistId=${data.mbArtistId}` + : null, + { revalidateFirstPage: false } + ); + + const { + data: epsData, + size: epsSize, + setSize: setEpsSize, + isValidating: isLoadingEps, + } = useSWRInfinite( + (index) => + data?.mbArtistId + ? `/api/v1/person/${router.query.personId}/discography?page=${ + index + 1 + }&type=EP&artistId=${data.mbArtistId}` + : null, + { revalidateFirstPage: false } + ); + + const { + data: otherData, + size: otherSize, + setSize: setOtherSize, + isValidating: isLoadingOther, + } = useSWRInfinite( + (index) => + data?.mbArtistId + ? `/api/v1/person/${router.query.personId}/discography?page=${ + index + 1 + }&type=Other&artistId=${data.mbArtistId}` + : null, + { revalidateFirstPage: false } + ); + const sortedCast = useMemo(() => { const filtered = (combinedCredits?.cast ?? []).filter( (media) => @@ -237,6 +329,89 @@ const PersonDetails = () => { ); + const albumsList = albumData ? albumData.flatMap((page) => page.results) : []; + const isReachingEndAlbums = + albumData?.[0]?.results.length === 0 || + (albumData && albumData[albumData.length - 1]?.results.length < 20); + + const singlesList = singlesData + ? singlesData.flatMap((page) => page.results) + : []; + const isReachingEndSingles = + singlesData?.[0]?.results.length === 0 || + (singlesData && singlesData[singlesData.length - 1]?.results.length < 20); + + const epsList = epsData ? epsData.flatMap((page) => page.results) : []; + const isReachingEndEps = + epsData?.[0]?.results.length === 0 || + (epsData && epsData[epsData.length - 1]?.results.length < 20); + + const otherList = otherData ? otherData.flatMap((page) => page.results) : []; + const isReachingEndOther = + otherData?.[0]?.results.length === 0 || + (otherData && otherData[otherData.length - 1]?.results.length < 20); + + const renderAlbumSection = ( + title: string, + albums: Album[], + isLoading: boolean, + isReachingEnd: boolean, + onLoadMore: () => void + ) => { + if (!albums?.length && !isLoading) return null; + + return ( + <> +
+
+ {title} +
+
+
    + {albums?.map((album) => ( +
  • + 0} + canExpand + /> +
  • + ))} + {isLoading && + [...Array(20)].map((_, index) => ( +
  • + +
  • + ))} +
+ {!isReachingEnd && ( +
+ +
+ )} + + ); + }; + return ( <> @@ -315,6 +490,38 @@ const PersonDetails = () => { )}
+ {data.mbArtistId && ( + <> + {renderAlbumSection( + intl.formatMessage(messages.albums), + albumsList, + isLoadingAlbums ?? false, + isReachingEndAlbums ?? false, + () => setAlbumSize(albumSize + 1) + )} + {renderAlbumSection( + intl.formatMessage(messages.singles), + singlesList, + isLoadingSingles ?? false, + isReachingEndSingles ?? false, + () => setSinglesSize(singlesSize + 1) + )} + {renderAlbumSection( + intl.formatMessage(messages.eps), + epsList, + isLoadingEps ?? false, + isReachingEndEps ?? false, + () => setEpsSize(epsSize + 1) + )} + {renderAlbumSection( + intl.formatMessage(messages.otherReleases), + otherList, + isLoadingOther ?? false, + isReachingEndOther ?? false, + () => setOtherSize(otherSize + 1) + )} + + )} {data.knownForDepartment === 'Acting' ? [cast, crew] : [crew, cast]} {isLoading && } diff --git a/src/components/QuotaSelector/index.tsx b/src/components/QuotaSelector/index.tsx index 0f0454e2..47c6c8b7 100644 --- a/src/components/QuotaSelector/index.tsx +++ b/src/components/QuotaSelector/index.tsx @@ -7,14 +7,17 @@ const messages = defineMessages('components.QuotaSelector', { '{quotaLimit} {movies} per {quotaDays} {days}', tvRequests: '{quotaLimit} {seasons} per {quotaDays} {days}', + musicRequests: + '{quotaLimit} {albums} per {quotaDays} {days}', movies: '{count, plural, one {movie} other {movies}}', seasons: '{count, plural, one {season} other {seasons}}', + albums: '{count, plural, one {album} other {albums}}', days: '{count, plural, one {day} other {days}}', unlimited: 'Unlimited', }); interface QuotaSelectorProps { - mediaType: 'movie' | 'tv'; + mediaType: 'movie' | 'tv' | 'music'; defaultDays?: number; defaultLimit?: number; dayOverride?: number; @@ -50,54 +53,74 @@ const QuotaSelector = ({ onChange(limitFieldName, quotaLimit); }, [limitFieldName, onChange, quotaLimit]); + const getQuotaMessage = () => { + switch (mediaType) { + case 'movie': + return messages.movieRequests; + case 'tv': + return messages.tvRequests; + case 'music': + return messages.musicRequests; + default: + return messages.movieRequests; + } + }; + + const getUnitMessage = (count: number) => { + switch (mediaType) { + case 'movie': + return intl.formatMessage(messages.movies, { count }); + case 'tv': + return intl.formatMessage(messages.seasons, { count }); + case 'music': + return intl.formatMessage(messages.albums, { count }); + default: + return intl.formatMessage(messages.movies, { count }); + } + }; + return (
- {intl.formatMessage( - mediaType === 'movie' ? messages.movieRequests : messages.tvRequests, - { - quotaLimit: ( - setQuotaLimit(Number(e.target.value))} + disabled={isDisabled} + > + + {[...Array(100)].map((_item, i) => ( + - {[...Array(100)].map((_item, i) => ( - - ))} - - ), - quotaDays: ( - - ), - movies: intl.formatMessage(messages.movies, { count: quotaLimit }), - seasons: intl.formatMessage(messages.seasons, { count: quotaLimit }), - days: intl.formatMessage(messages.days, { count: quotaDays }), - quotaUnits: function quotaUnits(msg) { - return ( - - {msg} - - ); - }, - } - )} + ))} + + ), + quotaDays: ( + + ), + movies: getUnitMessage(quotaLimit), + seasons: getUnitMessage(quotaLimit), + albums: getUnitMessage(quotaLimit), + days: intl.formatMessage(messages.days, { count: quotaDays }), + quotaUnits: (msg: React.ReactNode) => ( + + {msg} + + ), + })}
); }; diff --git a/src/components/RequestButton/index.tsx b/src/components/RequestButton/index.tsx index 71d97b9a..105f2f25 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -45,10 +45,11 @@ interface ButtonOption { } interface RequestButtonProps { - mediaType: 'movie' | 'tv'; + mediaType: 'movie' | 'tv' | 'music'; onUpdate: () => void; - tmdbId: number; + tmdbId?: number; media?: Media; + mbId?: string; isShowComplete?: boolean; is4kShowComplete?: boolean; } @@ -58,6 +59,7 @@ const RequestButton = ({ onUpdate, media, mediaType, + mbId, isShowComplete = false, is4kShowComplete = false, }: RequestButtonProps) => { @@ -69,12 +71,15 @@ const RequestButton = ({ const [editRequest, setEditRequest] = useState(false); // All pending requests - const activeRequests = media?.requests.filter( - (request) => request.status === MediaRequestStatus.PENDING && !request.is4k - ); - const active4kRequests = media?.requests.filter( - (request) => request.status === MediaRequestStatus.PENDING && request.is4k - ); + const activeRequests = + media?.requests?.filter( + (request) => + request.status === MediaRequestStatus.PENDING && !request.is4k + ) ?? []; + const active4kRequests = + media?.requests?.filter( + (request) => request.status === MediaRequestStatus.PENDING && request.is4k + ) ?? []; // Current user's pending request, or the first pending request const activeRequest = useMemo(() => { @@ -276,6 +281,8 @@ const RequestButton = ({ Permission.REQUEST, mediaType === 'movie' ? Permission.REQUEST_MOVIE + : mediaType === 'music' + ? Permission.REQUEST_MUSIC : Permission.REQUEST_TV, ], { type: 'or' } @@ -369,6 +376,7 @@ 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 95b2bfaf..b4e4949c 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -22,6 +22,7 @@ import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import type { MediaRequest } from '@server/entity/MediaRequest'; import type { NonFunctionProperties } from '@server/interfaces/api/common'; import type { MovieDetails } from '@server/models/Movie'; +import type { MusicDetails } from '@server/models/Music'; import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import Link from 'next/link'; @@ -45,10 +46,18 @@ const messages = defineMessages('components.RequestCard', { unknowntitle: 'Unknown Title', }); -const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { +const isMovie = ( + movie: MovieDetails | TvDetails | MusicDetails +): movie is MovieDetails => { return (movie as MovieDetails).title !== undefined; }; +const isAlbum = ( + media: MovieDetails | TvDetails | MusicDetails +): media is MusicDetails => { + return (media as MusicDetails).artistId !== undefined; +}; + const RequestCardPlaceholder = () => { return (
@@ -231,7 +240,9 @@ 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.mbId}`; const { data: title, error } = useSWR( inView ? `${url}` : null @@ -335,43 +346,68 @@ 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 && ( -
- -
-
- )} +
+ img.CoverType === 'Fanart') + ?.Url || + title.artist.images?.find((img) => img.CoverType === 'Poster') + ?.Url || + title.images?.find( + (img) => img.CoverType.toLowerCase() === 'cover' + )?.Url || + '' + : `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${ + title.backdropPath ?? '' + }` + } + style={{ width: '100%', height: '100%', objectFit: 'cover' }} + fill + /> +
+
- {(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice( - 0, - 4 + {(isMovie(title) + ? title.releaseDate + : isAlbum(title) + ? title.releaseDate + : title.firstAirDate + )?.slice(0, 4)} + {isAlbum(title) && ( + <> + - + {title.artist.artistName} + )}
- {isMovie(title) ? title.title : title.name} + {isMovie(title) + ? title.title + : isAlbum(title) + ? title.title + : title.name} {hasPermission( [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], @@ -608,23 +644,46 @@ 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.mbId}` } - className="w-20 flex-shrink-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-md shadow-sm transition duration-300 hover:scale-105 hover:shadow-md sm:w-28" + className={`w-20 flex-shrink-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-md shadow-sm transition duration-300 hover:scale-105 hover:shadow-md sm:w-28`} > - +
+ image.CoverType === 'Cover') + ?.Url ?? '/images/overseerr_poster_not_found.png' + : title.posterPath + ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` + : '/images/seerr_poster_not_found.png' + } + alt="" + sizes="100vw" + style={{ + width: '100%', + height: 'auto', + margin: request.type === 'music' ? 'auto' : undefined, + position: request.type === 'music' ? 'absolute' : undefined, + top: request.type === 'music' ? '50%' : undefined, + left: request.type === 'music' ? '50%' : undefined, + transform: + request.type === 'music' + ? 'translate(-50%, -50%)' + : undefined, + borderRadius: request.type === 'music' ? '0.375rem' : undefined, + }} + width={600} + height={request.type === 'music' ? 600 : 900} + /> +
diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 83480040..784a2bb3 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -22,6 +22,7 @@ import type { MediaRequest } from '@server/entity/MediaRequest'; import type { NonFunctionProperties } from '@server/interfaces/api/common'; import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces'; import type { MovieDetails } from '@server/models/Movie'; +import type { MusicDetails } from '@server/models/Music'; import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import Link from 'next/link'; @@ -44,13 +45,25 @@ const messages = defineMessages('components.RequestList.RequestItem', { cancelRequest: 'Cancel Request', tmdbid: 'TMDB ID', tvdbid: 'TheTVDB ID', + mbid: 'MusicBrainz ID', unknowntitle: 'Unknown Title', removearr: 'Remove from {arr}', profileName: 'Profile', }); -const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { - return (movie as MovieDetails).title !== undefined; +const isMovie = ( + media: MovieDetails | TvDetails | MusicDetails +): media is MovieDetails => { + return ( + (media as MovieDetails).title !== undefined && + !(media as MusicDetails).artist + ); +}; + +const isMusic = ( + media: MovieDetails | TvDetails | MusicDetails +): media is MusicDetails => { + return (media as MusicDetails).artistId !== undefined; }; interface RequestItemErrorProps { @@ -88,28 +101,42 @@ const RequestItemError = ({ requestData?.type ? requestData?.type === 'movie' ? globalMessages.movie - : globalMessages.tvshow + : requestData?.type === 'tv' + ? globalMessages.tvshow + : globalMessages.music : globalMessages.request ), })}
{requestData && hasPermission(Permission.MANAGE_REQUESTS) && ( <> -
- - {intl.formatMessage(messages.tmdbid)} - - - {requestData.media.tmdbId} - -
- {requestData.media.tvdbId && ( + {requestData.type !== 'music' && ( +
+ + {intl.formatMessage(messages.tmdbid)} + + + {requestData.media.tmdbId} + +
+ )} + {requestData.media.tvdbId && requestData.type === 'tv' && (
{intl.formatMessage(messages.tvdbid)} - {requestData?.media.tvdbId} + {requestData.media.tvdbId} + +
+ )} + {requestData.media.mbId && requestData.type === 'music' && ( +
+ + {intl.formatMessage(messages.mbid)} + + + {requestData.media.mbId}
)} @@ -304,12 +331,14 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { const { user, hasPermission } = useUser(); const [showEditModal, setShowEditModal] = useState(false); const url = - request.type === 'movie' + request.type === 'music' + ? `/api/v1/music/${request.media.mbId}` + : request.type === 'movie' ? `/api/v1/movie/${request.media.tmdbId}` : `/api/v1/tv/${request.media.tmdbId}`; - const { data: title, error } = useSWR( - inView ? url : null - ); + const { data: title, error } = useSWR< + MovieDetails | TvDetails | MusicDetails + >(inView ? url : null); const { data: requestData, mutate: revalidate } = useSWR< NonFunctionProperties >(`/api/v1/request/${request.id}`, { @@ -397,6 +426,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { { }} />
- {title.backdropPath && ( -
- -
-
- )} +
+ img.CoverType === 'Fanart') + ?.Url || + title.artist.images?.find((img) => img.CoverType === 'Poster') + ?.Url || + title.images?.find( + (img) => img.CoverType.toLowerCase() === 'cover' + )?.Url || + '' + : `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${ + title.backdropPath ?? '' + }` + } + alt="" + style={{ width: '100%', height: '100%', objectFit: 'cover' }} + fill + /> +
+
image.CoverType === 'Cover') + ?.Url ?? '/images/overseerr_poster_not_found.png' + : title.posterPath ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` : '/images/seerr_poster_not_found.png' } @@ -446,53 +492,63 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { sizes="100vw" style={{ width: '100%', height: 'auto', objectFit: 'cover' }} width={600} - height={900} + height={isMusic(title) ? 600 : 900} />
- {(isMovie(title) + {(isMusic(title) + ? title.releaseDate + : isMovie(title) ? title.releaseDate : title.firstAirDate )?.slice(0, 4)}
- {isMovie(title) ? title.title : title.name} + {isMusic(title) + ? `${title.artist.artistName} - ${title.title}` + : isMovie(title) + ? title.title + : title.name} - {!isMovie(title) && request.seasons.length > 0 && ( -
- - {intl.formatMessage(messages.seasons, { - seasonCount: - (settings.currentSettings.enableSpecialEpisodes - ? title.seasons.length - : title.seasons.filter( - (season) => season.seasonNumber !== 0 - ).length) === request.seasons.length - ? 0 - : request.seasons.length, - })} - -
- {request.seasons.map((season) => ( - - - {season.seasonNumber === 0 - ? intl.formatMessage(globalMessages.specials) - : season.seasonNumber} - - - ))} + {!isMovie(title) && + !isMusic(title) && + request.seasons.length > 0 && ( +
+ + {intl.formatMessage(messages.seasons, { + seasonCount: + (settings.currentSettings.enableSpecialEpisodes + ? title.seasons.length + : title.seasons.filter( + (season) => season.seasonNumber !== 0 + ).length) === request.seasons.length + ? 0 + : request.seasons.length, + })} + +
+ {request.seasons.map((season) => ( + + + {season.seasonNumber === 0 + ? intl.formatMessage(globalMessages.specials) + : season.seasonNumber} + + + ))} +
-
- )} + )}
@@ -530,7 +586,13 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { requestData.is4k ? 'downloadStatus4k' : 'downloadStatus' ] } - title={isMovie(title) ? title.title : title.name} + title={ + isMovie(title) + ? title.title + : isMusic(title) + ? title.title + : title.name + } inProgress={ ( requestData.media[ @@ -708,7 +770,12 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { {intl.formatMessage(messages.removearr, { - arr: request.type === 'movie' ? 'Radarr' : 'Sonarr', + arr: + request.type === 'music' + ? 'Lidarr' + : request.type === 'movie' + ? 'Radarr' + : 'Sonarr', })} diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx index ad11db82..8aebb5af 100644 --- a/src/components/RequestModal/AdvancedRequester/index.tsx +++ b/src/components/RequestModal/AdvancedRequester/index.tsx @@ -50,7 +50,7 @@ export type RequestOverrides = { }; interface AdvancedRequesterProps { - type: 'movie' | 'tv'; + type: 'movie' | 'tv' | 'music'; is4k: boolean; isAnime?: boolean; defaultOverrides?: RequestOverrides; @@ -69,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 === 'tv' ? 'sonarr' : 'lidarr' + }`, { refreshInterval: 0, refreshWhenHidden: false, @@ -101,7 +103,7 @@ const AdvancedRequester = ({ useSWR( selectedServer !== null ? `/api/v1/service/${ - type === 'movie' ? 'radarr' : 'sonarr' + type === 'movie' ? 'radarr' : type === 'tv' ? 'sonarr' : 'lidarr' }/${selectedServer}` : null, { @@ -135,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/CollectionRequestModal.tsx b/src/components/RequestModal/CollectionRequestModal.tsx index 23508f51..ac9cea0d 100644 --- a/src/components/RequestModal/CollectionRequestModal.tsx +++ b/src/components/RequestModal/CollectionRequestModal.tsx @@ -32,7 +32,7 @@ const messages = defineMessages('components.RequestModal', { }); interface RequestModalProps extends React.HTMLAttributes { - tmdbId: number; + tmdbId?: number; is4k?: boolean; onCancel?: () => void; onComplete?: (newStatus: MediaStatus) => void; diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index 134a937f..10592f09 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -38,7 +38,7 @@ const messages = defineMessages('components.RequestModal', { }); interface RequestModalProps extends React.HTMLAttributes { - tmdbId: number; + tmdbId?: number; is4k?: boolean; editRequest?: NonFunctionProperties; onCancel?: () => void; diff --git a/src/components/RequestModal/MusicRequestModal.tsx b/src/components/RequestModal/MusicRequestModal.tsx new file mode 100644 index 00000000..7dc97fa9 --- /dev/null +++ b/src/components/RequestModal/MusicRequestModal.tsx @@ -0,0 +1,365 @@ +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 defineMessages from '@app/utils/defineMessages'; +import { MediaStatus } from '@server/constants/media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; +import type { NonFunctionProperties } from '@server/interfaces/api/common'; +import type { QuotaResponse } from '@server/interfaces/api/userInterfaces'; +import { Permission } from '@server/lib/permissions'; +import type { MusicDetails } from '@server/models/Music'; +import { useCallback, useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR, { mutate } from 'swr'; + +const messages = defineMessages('components.RequestModal', { + requestadmin: 'This request will be approved automatically.', + requestSuccess: '{title} requested successfully!', + requestCancel: 'Request for {title} canceled.', + requestmusictitle: 'Request Music', + edit: 'Edit Request', + approve: 'Approve Request', + cancel: 'Cancel Request', + pendingrequest: 'Pending Album 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; + onCancel?: () => void; + onComplete?: (newStatus: MediaStatus) => void; + onUpdating?: (isUpdating: boolean) => void; + editRequest?: NonFunctionProperties; +} + +const MusicRequestModal = ({ + mbId, + onCancel, + onComplete, + onUpdating, + editRequest, +}: RequestModalProps) => { + const [isUpdating, setIsUpdating] = useState(false); + const [requestOverrides, setRequestOverrides] = + useState(null); + const { addToast } = useToasts(); + const { data, error } = useSWR(`/api/v1/music/${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 res = await fetch('/api/v1/request', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + mediaId: data?.mbId, + mediaType: 'music', + ...overrideParams, + }), + }); + if (!res.ok) throw new Error(); + const mediaRequest: MediaRequest = await res.json(); + mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + + if (mediaRequest) { + if (onComplete) { + onComplete( + hasPermission(Permission.AUTO_APPROVE) + ? 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 res = await fetch(`/api/v1/request/${editRequest?.id}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error(); + + mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + + if (res.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 { + const res = await fetch(`/api/v1/request/${editRequest?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + mediaType: 'music', + serverId: requestOverrides?.server, + profileId: requestOverrides?.profile, + rootFolder: requestOverrides?.folder, + userId: requestOverrides?.user?.id, + tags: requestOverrides?.tags, + }), + }); + if (!res.ok) throw new Error(); + + if (alsoApproveRequest) { + const res = await fetch(`/api/v1/request/${editRequest?.id}/approve`, { + method: 'POST', + }); + if (!res.ok) throw new Error(); + } + 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 ( + img.CoverType === 'Fanart')?.Url + } + onOk={() => + hasPermission(Permission.MANAGE_REQUESTS) + ? updateRequest(true) + : hasPermission(Permission.REQUEST_ADVANCED) + ? updateRequest() + : cancelRequest() + } + okDisabled={isUpdating} + okText={ + hasPermission(Permission.MANAGE_REQUESTS) + ? intl.formatMessage(messages.approve) + : hasPermission(Permission.REQUEST_ADVANCED) + ? intl.formatMessage(messages.edit) + : intl.formatMessage(messages.cancel) + } + okButtonType={ + hasPermission(Permission.MANAGE_REQUESTS) + ? 'success' + : hasPermission(Permission.REQUEST_ADVANCED) + ? 'primary' + : 'danger' + } + onSecondary={ + isOwner && + hasPermission( + [Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS], + { type: 'or' } + ) + ? () => cancelRequest() + : undefined + } + secondaryDisabled={isUpdating} + secondaryText={ + isOwner && + hasPermission( + [Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS], + { type: 'or' } + ) + ? intl.formatMessage(messages.cancel) + : undefined + } + secondaryButtonType="danger" + cancelText={intl.formatMessage(globalMessages.close)} + > + {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], + { type: 'or' } + ); + + return ( + img.CoverType === 'Fanart')?.Url || + data?.artist?.images?.find((img) => img.CoverType === 'Poster')?.Url || + data?.images?.find((img) => img.CoverType.toLowerCase() === 'cover') + ?.Url + } + > + {hasAutoApprove && !quota?.music?.restricted && ( +
+ +
+ )} + {(quota?.music?.limit ?? 0) > 0 && ( + + )} + {(hasPermission(Permission.REQUEST_ADVANCED) || + hasPermission(Permission.MANAGE_REQUESTS)) && ( + { + setRequestOverrides(overrides); + }} + /> + )} +
+ ); +}; + +export default MusicRequestModal; diff --git a/src/components/RequestModal/QuotaDisplay/index.tsx b/src/components/RequestModal/QuotaDisplay/index.tsx index 99ffaab0..d4baad77 100644 --- a/src/components/RequestModal/QuotaDisplay/index.tsx +++ b/src/components/RequestModal/QuotaDisplay/index.tsx @@ -11,6 +11,7 @@ const messages = defineMessages('components.RequestModal.QuotaDisplay', { '{remaining, plural, =0 {No} other {#}} {type} {remaining, plural, one {request} other {requests}} remaining', movielimit: '{limit, plural, one {movie} other {movies}}', seasonlimit: '{limit, plural, one {season} other {seasons}}', + musiclimit: '{limit, plural, one {album} other {albums}}', allowedRequests: 'You are allowed to request {limit} {type} every {days} days.', allowedRequestsUser: @@ -21,6 +22,7 @@ const messages = defineMessages('components.RequestModal.QuotaDisplay', { "You can view a summary of this user's request limits on their profile page.", movie: 'movie', season: 'season', + album: 'album', notenoughseasonrequests: 'Not enough season requests remaining', requiredquota: 'You need to have at least {seasons} {seasons, plural, one {season request} other {season requests}} remaining in order to submit a request for this series.', @@ -30,7 +32,7 @@ const messages = defineMessages('components.RequestModal.QuotaDisplay', { interface QuotaDisplayProps { quota?: QuotaStatus; - mediaType: 'movie' | 'tv'; + mediaType: 'movie' | 'tv' | 'music'; userOverride?: number | null; remaining?: number; overLimit?: number; @@ -118,7 +120,9 @@ const QuotaDisplay = ({ type: intl.formatMessage( mediaType === 'movie' ? messages.movielimit - : messages.seasonlimit, + : mediaType === 'tv' + ? messages.seasonlimit + : messages.musiclimit, { limit: quota?.limit } ), strong: (msg: React.ReactNode) => {msg}, diff --git a/src/components/RequestModal/SearchByNameModal/index.tsx b/src/components/RequestModal/SearchByNameModal/index.tsx index 0b2f5b17..79e030bd 100644 --- a/src/components/RequestModal/SearchByNameModal/index.tsx +++ b/src/components/RequestModal/SearchByNameModal/index.tsx @@ -20,7 +20,7 @@ interface SearchByNameModalProps { closeModal: () => void; modalTitle: string; modalSubTitle: string; - tmdbId: number; + tmdbId?: number; backdrop?: string; } diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 5d2249de..09d0f091 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -53,7 +53,7 @@ const messages = defineMessages('components.RequestModal', { }); interface RequestModalProps extends React.HTMLAttributes { - tmdbId: number; + tmdbId?: number; onCancel?: () => void; onComplete?: (newStatus: MediaStatus) => void; onUpdating?: (isUpdating: boolean) => void; diff --git a/src/components/RequestModal/index.tsx b/src/components/RequestModal/index.tsx index 19874179..1783a039 100644 --- a/src/components/RequestModal/index.tsx +++ b/src/components/RequestModal/index.tsx @@ -1,5 +1,6 @@ import CollectionRequestModal from '@app/components/RequestModal/CollectionRequestModal'; import MovieRequestModal from '@app/components/RequestModal/MovieRequestModal'; +import MusicRequestModal from '@app/components/RequestModal/MusicRequestModal'; import TvRequestModal from '@app/components/RequestModal/TvRequestModal'; import { Transition } from '@headlessui/react'; import type { MediaStatus } from '@server/constants/media'; @@ -8,8 +9,9 @@ import type { NonFunctionProperties } from '@server/interfaces/api/common'; interface RequestModalProps { show: boolean; - type: 'movie' | 'tv' | 'collection'; - tmdbId: number; + type: 'movie' | 'tv' | 'collection' | 'music'; + tmdbId?: number; + mbId?: string; is4k?: boolean; editRequest?: NonFunctionProperties; onComplete?: (newStatus: MediaStatus) => void; @@ -21,6 +23,7 @@ const RequestModal = ({ type, show, tmdbId, + mbId, is4k, editRequest, onComplete, @@ -56,6 +59,14 @@ const RequestModal = ({ is4k={is4k} editRequest={editRequest} /> + ) : type === 'music' ? ( + ) : ( 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 fetch('/api/v1/settings/lidarr/test', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + hostname, + apiKey, + port: Number(port), + baseUrl, + useSsl, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data: TestResponse = await response.json(); + + setIsValidated(true); + setTestResponse(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, + }; + + const response = await fetch( + !lidarr + ? '/api/v1/settings/lidarr' + : `/api/v1/settings/lidarr/${lidarr.id}`, + { + method: !lidarr ? 'POST' : 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(submission), + } + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + 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 5355f4d3..388b266e 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -85,6 +85,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', @@ -741,6 +742,37 @@ const SettingsJobs = () => { {formatBytes(cacheData?.imageCache.avatar.size ?? 0)} + + Cover Art Archive (caa) + + {intl.formatNumber(cacheData?.imageCache.caa.imageCount ?? 0)} + + + {formatBytes(cacheData?.imageCache.caa.size ?? 0)} + + + + Lidarr Images (lidarr) + + {intl.formatNumber( + cacheData?.imageCache.lidarr.imageCount ?? 0 + )} + + + {formatBytes(cacheData?.imageCache.lidarr.size ?? 0)} + + + + Fanart.tv (fanart) + + {intl.formatNumber( + cacheData?.imageCache.fanart.imageCount ?? 0 + )} + + + {formatBytes(cacheData?.imageCache.fanart.size ?? 0)} + +
diff --git a/src/components/Settings/SettingsServices.tsx b/src/components/Settings/SettingsServices.tsx index be39f744..8c9f0074 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,6 +7,7 @@ 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 OverrideRuleModal from '@app/components/Settings/OverrideRule/OverrideRuleModal'; import OverrideRuleTiles from '@app/components/Settings/OverrideRule/OverrideRuleTiles'; import RadarrModal from '@app/components/Settings/RadarrModal'; @@ -16,7 +18,11 @@ import { Transition } from '@headlessui/react'; import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid'; import type OverrideRule from '@server/entity/OverrideRule'; import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces'; -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 { useIntl } from 'react-intl'; @@ -26,8 +32,11 @@ const messages = defineMessages('components.Settings', { 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', @@ -37,6 +46,7 @@ const messages = defineMessages('components.Settings', { 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: @@ -45,6 +55,7 @@ const messages = defineMessages('components.Settings', { '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', overrideRules: 'Override Rules', overrideRulesDescription: @@ -62,6 +73,7 @@ interface ServerInstanceProps { externalUrl?: string; profileName: string; isSonarr?: boolean; + isLidarr?: boolean; onEdit: () => void; onDelete: () => void; } @@ -102,6 +114,7 @@ const ServerInstance = ({ isDefault = false, isSSL = false, isSonarr = false, + isLidarr = false, externalUrl, onEdit, onDelete, @@ -172,6 +185,8 @@ const ServerInstance = ({ > {isSonarr ? ( + ) : isLidarr ? ( + ) : ( )} @@ -215,6 +230,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 { data: rules, mutate: revalidate } = useSWR('/api/v1/overrideRule'); const [editRadarrModal, setEditRadarrModal] = useState<{ @@ -231,9 +251,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, @@ -271,7 +298,7 @@ const SettingsServices = () => { {intl.formatMessage(messages.radarrsettings)}

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

@@ -304,6 +331,28 @@ const SettingsServices = () => { }} /> )} + {editLidarrModal.open && ( + setEditLidarrModal({ open: false, lidarr: null })} + onSave={() => { + revalidateLidarr(); + mutate('/api/v1/settings/public'); + setEditLidarrModal({ open: false, lidarr: null }); + }} + /> + )} + {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)} @@ -416,7 +469,7 @@ const SettingsServices = () => { {intl.formatMessage(messages.sonarrsettings)}

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

@@ -499,6 +552,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', + }) + } + /> + ))} +
  • +
    + +
    +
  • +
+ + )} +

{intl.formatMessage(messages.overrideRules)} diff --git a/src/components/Settings/SettingsUsers/index.tsx b/src/components/Settings/SettingsUsers/index.tsx index 7dad9f94..b92c117e 100644 --- a/src/components/Settings/SettingsUsers/index.tsx +++ b/src/components/Settings/SettingsUsers/index.tsx @@ -37,6 +37,7 @@ const messages = defineMessages('components.Settings.SettingsUsers', { 'Allow {mediaServerName} users to sign in without first being imported', movieRequestLimitLabel: 'Global Movie Request Limit', tvRequestLimitLabel: 'Global Series Request Limit', + musicRequestLimitLabel: 'Global Music Request Limit', defaultPermissions: 'Default Permissions', defaultPermissionsTip: 'Initial permissions assigned to new users', }); @@ -111,6 +112,8 @@ const SettingsUsers = () => { movieQuotaDays: data?.defaultQuotas.movie.quotaDays ?? 7, tvQuotaLimit: data?.defaultQuotas.tv.quotaLimit ?? 0, tvQuotaDays: data?.defaultQuotas.tv.quotaDays ?? 7, + musicQuotaLimit: data?.defaultQuotas.music.quotaLimit ?? 0, + musicQuotaDays: data?.defaultQuotas.music.quotaDays ?? 7, defaultPermissions: data?.defaultPermissions ?? 0, }} validationSchema={schema} @@ -130,6 +133,10 @@ const SettingsUsers = () => { quotaLimit: values.tvQuotaLimit, quotaDays: values.tvQuotaDays, }, + music: { + quotaLimit: values.musicQuotaLimit, + quotaDays: values.musicQuotaDays, + }, }, defaultPermissions: values.defaultPermissions, }); @@ -258,6 +265,21 @@ const SettingsUsers = () => { />

+
+ +
+ +
+
{ @@ -59,7 +61,9 @@ const StatusBadge = ({ mediaType && plexUrl && hasPermission( - is4k + mediaType === 'music' + ? [Permission.REQUEST, Permission.REQUEST_MUSIC] + : is4k ? [ Permission.REQUEST_4K, mediaType === 'movie' @@ -91,17 +95,28 @@ const StatusBadge = ({ : 'Jellyfin', }); } else if (hasPermission(Permission.MANAGE_REQUESTS)) { - if (mediaType && tmdbId) { - mediaLink = `/${mediaType}/${tmdbId}?manage=1`; + if (mediaType && (tmdbId || mbId)) { + mediaLink = `/${mediaType}/${ + mediaType === 'music' ? mbId : tmdbId + }?manage=1`; mediaLinkDescription = intl.formatMessage(messages.managemedia, { mediaType: intl.formatMessage( - mediaType === 'movie' ? globalMessages.movie : globalMessages.tvshow + mediaType === 'movie' + ? globalMessages.movie + : mediaType === 'tv' + ? globalMessages.tvshow + : globalMessages.album ), }); } else if (hasPermission(Permission.ADMIN) && serviceUrl) { mediaLink = serviceUrl; mediaLinkDescription = intl.formatMessage(messages.openinarr, { - arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + arr: + mediaType === 'movie' + ? 'Radarr' + : mediaType === 'tv' + ? 'Sonarr' + : 'Lidarr', }); } } diff --git a/src/components/TitleCard/ErrorCard.tsx b/src/components/TitleCard/ErrorCard.tsx index 6c386b8c..ee75bc81 100644 --- a/src/components/TitleCard/ErrorCard.tsx +++ b/src/components/TitleCard/ErrorCard.tsx @@ -7,10 +7,11 @@ import { useIntl } from 'react-intl'; import { mutate } from 'swr'; interface ErrorCardProps { - id: number; - tmdbId: number; + id?: number | string; + tmdbId?: number; tvdbId?: number; - type: 'movie' | 'tv'; + mbId?: string; + type: 'movie' | 'tv' | 'music'; canExpand?: boolean; } @@ -18,10 +19,18 @@ const messages = defineMessages('components.TitleCard', { mediaerror: '{mediaType} Not Found', tmdbid: 'TMDB ID', tvdbid: 'TheTVDB ID', + mbId: 'MusicBrainz ID', cleardata: 'Clear Data', }); -const ErrorCard = ({ id, tmdbId, tvdbId, type, canExpand }: ErrorCardProps) => { +const ErrorCard = ({ + id, + tmdbId, + tvdbId, + mbId, + type, + canExpand, +}: ErrorCardProps) => { const intl = useIntl(); const deleteMedia = async () => { @@ -45,13 +54,19 @@ const ErrorCard = ({ id, tmdbId, tvdbId, type, canExpand }: ErrorCardProps) => {
{type === 'movie' ? intl.formatMessage(globalMessages.movie) - : intl.formatMessage(globalMessages.tvshow)} + : type === 'tv' + ? intl.formatMessage(globalMessages.tvshow) + : intl.formatMessage(globalMessages.music)}
@@ -92,18 +107,25 @@ const ErrorCard = ({ id, tmdbId, tvdbId, type, canExpand }: ErrorCardProps) => { wordBreak: 'break-word', }} > -
- - {intl.formatMessage(messages.tmdbid)} - - {tmdbId} -
- {!!tvdbId && ( -
- - {intl.formatMessage(messages.tvdbid)} - - {tvdbId} + {type === 'music' ? ( +
+ + {intl.formatMessage(messages.mbId)}: + {' '} + {mbId} +
+ ) : ( +
+ + {intl.formatMessage(messages.tmdbid)}: + {' '} + {tmdbId} + {tvdbId && ( + <> +
+ TVDb ID: {tvdbId} + + )}
)}
diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 0faf8eb0..223994f8 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -31,11 +31,13 @@ import { useToasts } from 'react-toast-notifications'; import { mutate } from 'swr'; interface TitleCardProps { - id: number; + id?: number | string; image?: string; summary?: string; year?: string; title: string; + artist?: string; + type?: string; userScore?: number; mediaType: MediaType; status?: MediaStatus; @@ -61,6 +63,8 @@ const TitleCard = ({ summary, year, title, + artist, + type, status, mediaType, isAddedToWatchlist = false, @@ -109,11 +113,13 @@ const TitleCard = ({ const onClickWatchlistBtn = async (): Promise => { setIsUpdating(true); try { - const response = await axios.post('/api/v1/watchlist', { - tmdbId: id, - mediaType, + const requestBody = { + mediaType: mediaType === 'album' ? 'music' : mediaType, title, - }); + ...(mediaType === 'album' ? { mbId: id } : { tmdbId: Number(id) }), + }; + + const response = await axios.post('/api/v1/watchlist', requestBody); mutate('/api/v1/discover/watchlist'); if (response.data) { addToast( @@ -175,8 +181,8 @@ const TitleCard = ({ if (topNode) { try { await axios.post('/api/v1/blacklist', { - tmdbId: id, - mediaType, + ...(mediaType === 'album' ? { mbId: id } : { tmdbId: id }), + mediaType: mediaType === 'album' ? 'music' : mediaType, title, user: user?.id, }); @@ -258,9 +264,13 @@ const TitleCard = ({ const showRequestButton = hasPermission( [ Permission.REQUEST, - mediaType === 'movie' || mediaType === 'collection' - ? Permission.REQUEST_MOVIE - : Permission.REQUEST_TV, + ...(mediaType === 'movie' || mediaType === 'collection' + ? [Permission.REQUEST_MOVIE] + : mediaType === 'tv' + ? [Permission.REQUEST_TV] + : mediaType === 'album' + ? [Permission.REQUEST_MUSIC] + : []), ], { type: 'or' } ); @@ -276,27 +286,33 @@ const TitleCard = ({ ref={cardRef} >
- + {mediaType === 'album' ? ( +
+
+ +
+
+
+ {title} +
+ {artist && ( +
+ {artist} +
+ )} + {type && ( +
+ {type} +
+ )} +
+
+ ) : ( + + )}
@@ -451,7 +518,9 @@ const TitleCard = ({
{
)} {!!streamingProviders.length && ( -
+
{intl.formatMessage(messages.streamingproviders)} - + {streamingProviders.map((p) => { return ( - - - - - + + {p.name} + ); })} diff --git a/src/components/UserProfile/index.tsx b/src/components/UserProfile/index.tsx index 8d5abd2c..a3177fd6 100644 --- a/src/components/UserProfile/index.tsx +++ b/src/components/UserProfile/index.tsx @@ -1,10 +1,10 @@ +import AddedCard from '@app/components/AddedCard'; import ImageFader from '@app/components/Common/ImageFader'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; import ProgressCircle from '@app/components/Common/ProgressCircle'; import RequestCard from '@app/components/RequestCard'; import Slider from '@app/components/Slider'; -import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard'; import ProfileHeader from '@app/components/UserProfile/ProfileHeader'; import { Permission, UserType, useUser } from '@app/hooks/useUser'; import Error from '@app/pages/_error'; @@ -17,6 +17,7 @@ import type { UserWatchDataResponse, } from '@server/interfaces/api/userInterfaces'; import type { MovieDetails } from '@server/models/Movie'; +import type { MusicDetails } from '@server/models/Music'; import type { TvDetails } from '@server/models/Tv'; import Link from 'next/link'; import { useRouter } from 'next/router'; @@ -33,6 +34,7 @@ const messages = defineMessages('components.UserProfile', { pastdays: '{type} (past {days} days)', movierequests: 'Movie Requests', seriesrequest: 'Series Requests', + musicrequests: 'Music Requests', recentlywatched: 'Recently Watched', plexwatchlist: 'Plex Watchlist', localWatchlist: "{username}'s Watchlist", @@ -40,7 +42,7 @@ const messages = defineMessages('components.UserProfile', { 'Media added to your Plex Watchlist will appear here.', }); -type MediaTitle = MovieDetails | TvDetails; +type MediaTitle = MovieDetails | TvDetails | MusicDetails; const UserProfile = () => { const intl = useIntl(); @@ -135,11 +137,41 @@ const UserProfile = () => { key={user.id} isDarker backgroundImages={Object.values(availableTitles) - .filter((media) => media.backdropPath) - .map( - (media) => - `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}` - ) + .filter((media) => { + if ('backdropPath' in media) { + return media.backdropPath; + } + if ('artist' in media) { + return ( + media.artist.images?.find( + (img) => img.CoverType === 'Fanart' + )?.Url || + media.artist.images?.find( + (img) => img.CoverType === 'Poster' + )?.Url || + media.images?.find( + (img) => img.CoverType.toLowerCase() === 'cover' + )?.Url + ); + } + return false; + }) + .map((media) => { + if ('backdropPath' in media) { + return `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}`; + } + if ('artist' in media) { + const fanart = media.artist.images?.find( + (img) => img.CoverType === 'Fanart' + ); + const cover = media.artist.images?.find( + (img) => img.CoverType === 'Cover' + ); + return fanart?.Url || cover?.Url || ''; + } + return ''; + }) + .filter(Boolean) .slice(0, 6)} />
@@ -152,7 +184,7 @@ const UserProfile = () => { { type: 'and' } )) && (
-
+
{intl.formatMessage(messages.totalrequests)} @@ -282,6 +314,61 @@ const UserProfile = () => { )}
+
+
+ {quota.music?.limit + ? intl.formatMessage(messages.pastdays, { + type: intl.formatMessage(messages.musicrequests), + days: quota?.music.days, + }) + : intl.formatMessage(messages.musicrequests)} +
+
+ {quota.music?.limit ? ( + <> + +
+ {intl.formatMessage(messages.requestsperdays, { + limit: ( + + {intl.formatMessage(messages.limit, { + remaining: quota.music.remaining, + limit: quota.music.limit, + })} + + ), + })} +
+ + ) : ( + + {intl.formatMessage(messages.unlimited)} + + )} +
+
)} @@ -365,10 +452,11 @@ const UserProfile = () => { ), })} items={watchlistItems?.results.map((item) => ( - ))} @@ -389,8 +477,8 @@ const UserProfile = () => { ( - ( + - (i.mediaType === 'movie' || i.mediaType === 'tv') && + (i.mediaType === 'movie' || + i.mediaType === 'tv' || + i.mediaType === 'music') && i.mediaInfo?.status !== MediaStatus.AVAILABLE && i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE ); diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts index 54fd8986..b1870c24 100644 --- a/src/i18n/globalMessages.ts +++ b/src/i18n/globalMessages.ts @@ -21,6 +21,10 @@ const globalMessages = defineMessages('i18n', { collection: 'Collection', tvshow: 'Series', tvshows: 'Series', + music: 'Music', + musics: 'Musics', + album: 'Album', + albums: 'Albums', cancel: 'Cancel', canceling: 'Canceling…', approve: 'Approve', diff --git a/src/i18n/locale/ar.json b/src/i18n/locale/ar.json index 957351c3..043f10d5 100644 --- a/src/i18n/locale/ar.json +++ b/src/i18n/locale/ar.json @@ -656,7 +656,7 @@ "components.Settings.plexlibraries": "مكتبات بليكس", "components.Settings.noDefaultServer": "على الأقل {serverType} سيرفر واحد يجب أن يكون معدا كإفتراضي لإتاحة تنفيذ طلبات الـ {mediaType}.", "components.Settings.plexsettings": "إعدادات بليكس", - "components.Settings.serviceSettingsDescription": "قم بإعداد {serverType} بالإسفل.تستطيع الاتصال بأكثر من سيرفر {serverType} ,ولكن إثنان فقط يمكن إعدادهما كإفتراضيين (واحد لجودة الفور كي والأخر لغير جودة الفور كي). أصحاب الصلاحيات الإدارية بإمكانهم تجاوز السيرفر المستخدم قبل تأكيدهم لأي طلب محتوى جديد.", + "components.Settings.videoServiceSettingsDescription": "قم بإعداد {serverType} بالإسفل.تستطيع الاتصال بأكثر من سيرفر {serverType} ,ولكن إثنان فقط يمكن إعدادهما كإفتراضيين (واحد لجودة الفور كي والأخر لغير جودة الفور كي). أصحاب الصلاحيات الإدارية بإمكانهم تجاوز السيرفر المستخدم قبل تأكيدهم لأي طلب محتوى جديد.", "components.Settings.serverpresetManualMessage": "ضبط يدوي", "components.Settings.sonarrsettings": "إعدادات سونار", "components.Settings.tautulliSettingsDescription": "بشكل إختياري أضبط إعدادات سيرفرك الخاص بـ Tautulli.أوفرسيرر سيقوم بجلب بيانات سجل المشاهدة لمحتوى بليكس من Tautulli.", diff --git a/src/i18n/locale/bg.json b/src/i18n/locale/bg.json index 72252025..08ba31c0 100644 --- a/src/i18n/locale/bg.json +++ b/src/i18n/locale/bg.json @@ -854,7 +854,7 @@ "components.Settings.hostname": "Име на хост или IP адрес", "components.Settings.SonarrModal.tagRequestsInfo": "Автоматично добавяне на допълнителен етикет с потребителското ID и показваното име на заявителя", "components.TvDetails.Season.noepisodes": "Няма наличен списък с епизоди.", - "components.Settings.serviceSettingsDescription": "Конфигурирайте вашия {serverType} сървър(и) по-долу. Можете да свържете множество {serverType} сървъри, но само два от тях могат да бъдат маркирани като стандартни (един не-4K и един 4K). Администраторите могат да заменят сървъра, използван за обработка на нови заявки преди одобрението.", + "components.Settings.videoServiceSettingsDescription": "Конфигурирайте вашия {serverType} сървър(и) по-долу. Можете да свържете множество {serverType} сървъри, но само два от тях могат да бъдат маркирани като стандартни (един не-4K и един 4K). Администраторите могат да заменят сървъра, използван за обработка на нови заявки преди одобрението.", "components.TvDetails.seasonstitle": "Сезони", "i18n.available": "Наличен", "components.Settings.menuGeneralSettings": "Общ", diff --git a/src/i18n/locale/ca.json b/src/i18n/locale/ca.json index 223f38b8..f7297e1e 100644 --- a/src/i18n/locale/ca.json +++ b/src/i18n/locale/ca.json @@ -683,7 +683,7 @@ "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingssaved": "La configuració de les notificacions de Discord s'ha desat correctament!", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingsfailed": "No s'ha pogut desar la configuració de les notificacions de Discord.", "components.UserList.autogeneratepasswordTip": "Envieu a l'usuari una contrasenya generada per servidor", - "components.Settings.serviceSettingsDescription": "Configureu els vostres servidors {serverType} a continuació. Podeu connectar diversos servidors {serverType}, però només dos es poden marcar com a valors predeterminats (un no 4K i un 4K). Els administradors poden substituir el servidor utilitzat per processar noves sol·licituds abans de l’aprovació.", + "components.Settings.videoServiceSettingsDescription": "Configureu els vostres servidors {serverType} a continuació. Podeu connectar diversos servidors {serverType}, però només dos es poden marcar com a valors predeterminats (un no 4K i un 4K). Els administradors poden substituir el servidor utilitzat per processar noves sol·licituds abans de l’aprovació.", "components.Settings.serverSecure": "segur", "components.Settings.noDefaultServer": "Cal marcar com a mínim un servidor {serverType} com a predeterminat perquè es processin les sol·licituds de {mediaType}.", "components.Settings.noDefaultNon4kServer": "Si només teniu un servidor únic {serverType} per a contingut no 4K i 4K (o si només descarregueu contingut 4K), el vostre servidor {serverType} NO deuria marcar-se com a servidor 4K.", diff --git a/src/i18n/locale/cs.json b/src/i18n/locale/cs.json index efe0f7eb..11411400 100644 --- a/src/i18n/locale/cs.json +++ b/src/i18n/locale/cs.json @@ -914,7 +914,7 @@ "components.UserList.importfromplexerror": "Při importu uživatelů systému Plex se něco pokazilo.", "components.Settings.Notifications.validationSmtpHostRequired": "Musíte zadat platný název hostitele nebo IP adresu", "components.Settings.RadarrModal.validationProfileRequired": "Je třeba vybrat profil kvality", - "components.Settings.serviceSettingsDescription": "Níže nakonfigurujte server(y) {serverType}. Můžete připojit více serverů {serverType}, ale pouze dva z nich mohou být označeny jako výchozí (jeden ne-4K a jeden 4K). Správci mohou před schválením změnit server používaný ke zpracování nových požadavků.", + "components.Settings.videoServiceSettingsDescription": "Níže nakonfigurujte server(y) {serverType}. Můžete připojit více serverů {serverType}, ale pouze dva z nich mohou být označeny jako výchozí (jeden ne-4K a jeden 4K). Správci mohou před schválením změnit server používaný ke zpracování nových požadavků.", "components.Settings.RadarrModal.testFirstTags": "Testovací připojení k načítání značek", "components.UserList.validationpasswordminchars": "Heslo je příliš krátké; mělo by mít minimálně 8 znaků", "components.UserProfile.pastdays": "{type} (posledních {days} dnů)", diff --git a/src/i18n/locale/da.json b/src/i18n/locale/da.json index b98474fa..03335a19 100644 --- a/src/i18n/locale/da.json +++ b/src/i18n/locale/da.json @@ -763,7 +763,7 @@ "components.UserList.deleteconfirm": "Er du sikker på at du vil slette denne bruger? Alle deres forespørgselsdata vil blive slettet permanent.", "components.Settings.serverpresetManualMessage": "Manuel konfiguration", "components.Settings.serverpresetRefreshing": "Henter servere…", - "components.Settings.serviceSettingsDescription": "Konfigurér dine {serverType}server(e) nedenfor. Du kan forbinde til flere forskellige {serverType}servere men kun to af dem kan markeres som standard (én ikke-4K og én 4K). Administratorer kan ændre på serveren der bruges til at behandle nye forespørgsler inden godkendelse.", + "components.Settings.videoServiceSettingsDescription": "Konfigurér dine {serverType}server(e) nedenfor. Du kan forbinde til flere forskellige {serverType}servere men kun to af dem kan markeres som standard (én ikke-4K og én 4K). Administratorer kan ændre på serveren der bruges til at behandle nye forespørgsler inden godkendelse.", "components.Settings.settingUpPlexDescription": "For at sætte Plex op skal du enten indtaste oplysningerne manuelt eller vælge en server som hentes fra plex.tv. Klik på knappen til højre for rullemenuen for at hente en liste af tilgængelige servere.", "components.Settings.toastPlexConnectingFailure": "Kunne ikke forbinde til Plex.", "components.Settings.toastPlexConnectingSuccess": "Plex forbindelse er etableret!", diff --git a/src/i18n/locale/el.json b/src/i18n/locale/el.json index 62402cac..9411e532 100644 --- a/src/i18n/locale/el.json +++ b/src/i18n/locale/el.json @@ -636,7 +636,7 @@ "components.Settings.sonarrsettings": "Ρυθμίσεις Sonarr", "components.Settings.settingUpPlexDescription": "Για να ρυθμίσεις το Plex, μπορείς είτε να εισαγάγεις τα στοιχεία χειροκίνητα είτε να επιλέξεις έναν διακομιστή που ανακτήθηκε από το plex.tv. Πάτα το κουμπί στα δεξιά του αναπτυσσόμενου μενού για να ανακτήσεις τη λίστα με τους διαθέσιμους διακομιστές.", "components.Settings.services": "Υπηρεσίες", - "components.Settings.serviceSettingsDescription": "Διαμόρφωσε τον {serverType} διακομιστή(-ές) παρακάτω . Μπορείτε να συνδέσεις πολλούς {serverType} διακομιστές, αλλά μόνο δύο από αυτούς μπορούν να χαρακτηριστούν ως προεπιλεγμένοι (ένας μη-4K και ένας 4K). Οι διαχειριστές έχουν τη δυνατότητα να παρακάμψουν τον διακομιστή που χρησιμοποιείται για την επεξεργασία νέων αιτημάτων πριν από την έγκριση.", + "components.Settings.videoServiceSettingsDescription": "Διαμόρφωσε τον {serverType} διακομιστή(-ές) παρακάτω . Μπορείτε να συνδέσεις πολλούς {serverType} διακομιστές, αλλά μόνο δύο από αυτούς μπορούν να χαρακτηριστούν ως προεπιλεγμένοι (ένας μη-4K και ένας 4K). Οι διαχειριστές έχουν τη δυνατότητα να παρακάμψουν τον διακομιστή που χρησιμοποιείται για την επεξεργασία νέων αιτημάτων πριν από την έγκριση.", "components.Settings.serverpresetRefreshing": "Ανάκτηση διακομιστών…", "components.Settings.serverpresetManualMessage": "Χειροκίνητη διαμόρφωση", "components.Settings.serverpresetLoad": "Πάτα το κουμπί για να φορτώσει τους διαθέσιμους διακομιστές", diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 34963834..0628ada8 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -41,6 +41,13 @@ "components.Discover.CreateSlider.starttyping": "Starting typing to search.", "components.Discover.CreateSlider.validationDatarequired": "You must provide a data value.", "components.Discover.CreateSlider.validationTitlerequired": "You must provide a title.", + "components.Discover.DiscoverMusic.discovermusics": "Music", + "components.Discover.DiscoverMusic.sortPopularityDesc": "Most Listened", + "components.Discover.DiscoverMusic.sortPopularityAsc": "Least Listened", + "components.Discover.DiscoverMusic.sortReleaseDateDesc": "Newest First", + "components.Discover.DiscoverMusic.sortReleaseDateAsc": "Oldest First", + "components.Discover.DiscoverMusic.sortTitleAsc": "Title (A-Z)", + "components.Discover.DiscoverMusic.sortTitleDesc": "Title (Z-A)", "components.Discover.DiscoverMovieGenre.genreMovies": "{genre} Movies", "components.Discover.DiscoverMovieKeyword.keywordMovies": "{keywordTitle} Movies", "components.Discover.DiscoverMovieLanguage.languageMovies": "{language} Movies", @@ -113,6 +120,7 @@ "components.Discover.moviegenres": "Movie Genres", "components.Discover.networks": "Networks", "components.Discover.plexwatchlist": "Your Watchlist", + "components.Discover.popularalbums": "Popular Albums", "components.Discover.popularmovies": "Popular Movies", "components.Discover.populartv": "Popular Series", "components.Discover.recentlyAdded": "Recently Added", @@ -214,11 +222,13 @@ "components.IssueModal.issueOther": "Other", "components.IssueModal.issueSubtitles": "Subtitle", "components.IssueModal.issueVideo": "Video", + "components.IssueModal.issueLyrics": "Lyrics", "components.LanguageSelector.languageServerDefault": "Default ({language})", "components.LanguageSelector.originalLanguageDefault": "All Languages", "components.Layout.LanguagePicker.displaylanguage": "Display Language", - "components.Layout.SearchInput.searchPlaceholder": "Search Movies & TV", + "components.Layout.SearchInput.searchPlaceholder": "Search Movies, TV & Music", "components.Layout.Sidebar.blacklist": "Blacklist", + "components.Layout.Sidebar.browsemusic": "Music", "components.Layout.Sidebar.browsemovies": "Movies", "components.Layout.Sidebar.browsetv": "Series", "components.Layout.Sidebar.dashboard": "Discover", @@ -312,6 +322,36 @@ "components.MetadataSelector.selectMetdataProvider": "Select a metadata provider", "components.MetadataSelector.tmdbLabel": "The Movie Database (TMDB)", "components.MetadataSelector.tvdbLabel": "TheTVDB", + "components.GroupDetails.type": "Type: {type}", + "components.GroupDetails.genres": "Genres: {genres}", + "components.GroupDetails.albums": "Albums", + "components.GroupDetails.singles": "Singles", + "components.GroupDetails.eps": "EPs", + "components.GroupDetails.other": "Other", + "components.GroupDetails.overview": "Overview", + "components.GroupDetails.status": "Status: {status}", + "components.GroupDetails.loadmore": "Load More", + "components.MusicDetails.biography": "Biography", + "components.MusicDetails.runtime": "{minutes} minutes", + "components.MusicDetails.album": "Album", + "components.MusicDetails.releasedate": "Release Date", + "components.MusicDetails.play": "Play on {mediaServerName}", + "components.MusicDetails.reportissue": "Report an Issue", + "components.MusicDetails.managemusic": "Manage Music", + "components.MusicDetails.biographyunavailable": "Biography unavailable.", + "components.MusicDetails.trackstitle": "Tracks", + "components.MusicDetails.tracksunavailable": "No tracks available.", + "components.MusicDetails.watchlistSuccess": "{title} added to watchlist successfully!", + "components.MusicDetails.watchlistDeleted": "{title} removed from watchlist successfully!", + "components.MusicDetails.watchlistError": "Something went wrong try again.", + "components.MusicDetails.removefromwatchlist": "Remove From Watchlist", + "components.MusicDetails.addtowatchlist": "Add To Watchlist", + "components.MusicDetails.status": "Status", + "components.MusicDetails.label": "Label", + "components.MusicDetails.artisttype": "Artist Type", + "components.MusicDetails.artiststatus": "Artist Status", + "components.MusicDetails.discography": "{artistName}'s discography", + "components.MusicDetails.similarArtists": "Similar Artists", "components.MovieDetails.MovieCast.fullcast": "Full Cast", "components.MovieDetails.MovieCrew.fullcrew": "Full Crew", "components.MovieDetails.addtowatchlist": "Add To Watchlist", @@ -406,11 +446,13 @@ "components.PermissionEdit.autoapproveSeries": "Auto-Approve Series", "components.PermissionEdit.autoapproveSeriesDescription": "Grant automatic approval for non-4K series requests.", "components.PermissionEdit.autorequest": "Auto-Request", - "components.PermissionEdit.autorequestDescription": "Grant permission to automatically submit requests for non-4K media via Plex Watchlist.", + "components.PermissionEdit.autorequestDescription": "Grant permission to automatically submit requests for non-4K media via Watchlist.", + "components.PermissionEdit.autorequestMusic": "Auto-Request Music", + "components.PermissionEdit.autorequestMusicDescription": "Grant permission to automatically submit requests for music via Watchlist", "components.PermissionEdit.autorequestMovies": "Auto-Request Movies", - "components.PermissionEdit.autorequestMoviesDescription": "Grant permission to automatically submit requests for non-4K movies via Plex Watchlist.", + "components.PermissionEdit.autorequestMoviesDescription": "Grant permission to automatically submit requests for non-4K movies via Watchlist.", "components.PermissionEdit.autorequestSeries": "Auto-Request Series", - "components.PermissionEdit.autorequestSeriesDescription": "Grant permission to automatically submit requests for non-4K series via Plex Watchlist.", + "components.PermissionEdit.autorequestSeriesDescription": "Grant permission to automatically submit requests for non-4K series via Watchlist.", "components.PermissionEdit.blacklistedItems": "Blacklist media.", "components.PermissionEdit.blacklistedItemsDescription": "Grant permission to blacklist media.", "components.PermissionEdit.createissues": "Report Issues", @@ -451,6 +493,11 @@ "components.PersonDetails.birthdate": "Born {birthdate}", "components.PersonDetails.crewmember": "Crew", "components.PersonDetails.lifespan": "{birthdate} – {deathdate}", + "components.PersonDetails.albums": "Albums", + "components.PersonDetails.singles": "Singles", + "components.PersonDetails.eps": "EPs", + "components.PersonDetails.otherReleases": "Other", + "components.PersonDetails.loadmore": "Load More", "components.QuotaSelector.days": "{count, plural, one {day} other {days}}", "components.QuotaSelector.movieRequests": "{quotaLimit} {movies} per {quotaDays} {days}", "components.QuotaSelector.movies": "{count, plural, one {movie} other {movies}}", @@ -933,6 +980,7 @@ "components.Settings.SettingsJobsCache.plex-watchlist-sync": "Plex Watchlist Sync", "components.Settings.SettingsJobsCache.process": "Process", "components.Settings.SettingsJobsCache.process-blacklisted-tags": "Process Blacklisted Tags", + "components.Settings.SettingsJobsCache.lidarr-scan": "Lidarr Scan", "components.Settings.SettingsJobsCache.radarr-scan": "Radarr Scan", "components.Settings.SettingsJobsCache.runnow": "Run Now", "components.Settings.SettingsJobsCache.size": "Size", @@ -1032,6 +1080,7 @@ "components.Settings.SettingsUsers.loginMethodsTip": "Configure login methods for users.", "components.Settings.SettingsUsers.mediaServerLogin": "Enable {mediaServerName} Sign-In", "components.Settings.SettingsUsers.mediaServerLoginTip": "Allow users to sign in using their {mediaServerName} account", + "components.Settings.SettingsUsers.musicRequestLimitLabel": "Global Music Request Limit", "components.Settings.SettingsUsers.movieRequestLimitLabel": "Global Movie Request Limit", "components.Settings.SettingsUsers.newPlexLogin": "Enable New {mediaServerName} Sign-In", "components.Settings.SettingsUsers.newPlexLoginTip": "Allow {mediaServerName} users to sign in without first being imported", @@ -1189,6 +1238,7 @@ "components.Settings.plexsettingsDescription": "Configure the settings for your Plex server. Seerr scans your Plex libraries to determine content availability.", "components.Settings.port": "Port", "components.Settings.providerStatus": "Metadata Provider Status", + "components.Settings.lidarrsettings": "Lidarr Settings", "components.Settings.radarrsettings": "Radarr Settings", "components.Settings.restartrequiredTooltip": "Seerr must be restarted for changes to this setting to take effect", "components.Settings.save": "Save Changes", @@ -1205,7 +1255,8 @@ "components.Settings.serverpresetLoad": "Press the button to load available servers", "components.Settings.serverpresetManualMessage": "Manual configuration", "components.Settings.serverpresetRefreshing": "Retrieving servers…", - "components.Settings.serviceSettingsDescription": "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.", + "components.Settings.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.", + "components.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.", "components.Settings.services": "Services", "components.Settings.settingUpPlexDescription": "To set up Plex, you can either enter the details manually or select a server retrieved from plex.tv. Press the button to the right of the dropdown to fetch the list of available servers.", "components.Settings.settings": "Settings", diff --git a/src/i18n/locale/es.json b/src/i18n/locale/es.json index ed087d8a..aa529ac8 100644 --- a/src/i18n/locale/es.json +++ b/src/i18n/locale/es.json @@ -658,7 +658,7 @@ "components.UserProfile.UserSettings.UserNotificationSettings.email": "Email", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingssaved": "¡Los ajustes de notificaciones de Discord se han guardado correctamente!", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingsfailed": "No se han podido guardar los ajustes de notificaciones de Discord.", - "components.Settings.serviceSettingsDescription": "Configura tu(s) servidor(es) {serverType} a continuación. Puedes conectar a múltiples servidores de {serverType}, pero solo dos de ellos pueden ser marcados como por defecto (uno no-4k y otro 4k). Los administradores podrán modificar el servidor usado al procesar nuevas solicitudes antes de su aprobación.", + "components.Settings.videoServiceSettingsDescription": "Configura tu(s) servidor(es) {serverType} a continuación. Puedes conectar a múltiples servidores de {serverType}, pero solo dos de ellos pueden ser marcados como por defecto (uno no-4k y otro 4k). Los administradores podrán modificar el servidor usado al procesar nuevas solicitudes antes de su aprobación.", "components.Settings.mediaTypeMovie": "película", "components.Settings.SonarrModal.testFirstTags": "Probar conexión para cargar etiquetas", "components.Settings.SonarrModal.tags": "Etiquetas", diff --git a/src/i18n/locale/es_MX.json b/src/i18n/locale/es_MX.json index b93490c6..8cfa458c 100644 --- a/src/i18n/locale/es_MX.json +++ b/src/i18n/locale/es_MX.json @@ -658,7 +658,7 @@ "components.UserProfile.UserSettings.UserNotificationSettings.email": "Email", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingssaved": "¡Los ajustes de notificaciones de Discord se han guardado correctamente!", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingsfailed": "No se han podido guardar los ajustes de notificaciones de Discord.", - "components.Settings.serviceSettingsDescription": "Configura tu(s) servidor(es) {serverType} a continuación. Puedes conectar a múltiples servidores de {serverType}, pero solo dos de ellos pueden ser marcados como por defecto (uno no-4k y otro 4k). Los administradores podrán modificar el servidor usado al procesar nuevas solicitudes antes de su aprobación.", + "components.Settings.videoServiceSettingsDescription": "Configura tu(s) servidor(es) {serverType} a continuación. Puedes conectar a múltiples servidores de {serverType}, pero solo dos de ellos pueden ser marcados como por defecto (uno no-4k y otro 4k). Los administradores podrán modificar el servidor usado al procesar nuevas solicitudes antes de su aprobación.", "components.Settings.mediaTypeMovie": "película", "components.Settings.SonarrModal.testFirstTags": "Probar conexión para cargar etiquetas", "components.Settings.SonarrModal.tags": "Etiquetas", diff --git a/src/i18n/locale/fr.json b/src/i18n/locale/fr.json index 99ed0e00..97ec432b 100644 --- a/src/i18n/locale/fr.json +++ b/src/i18n/locale/fr.json @@ -765,7 +765,7 @@ "components.UserProfile.UserSettings.UserNotificationSettings.pgpPublicKey": "Clé Publique PGP", "components.UserProfile.UserSettings.UserNotificationSettings.email": "Email", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingsfailed": "Les paramètres de notification Discord n’ont pas pu être enregistrés.", - "components.Settings.serviceSettingsDescription": "Configurez votre serveur {serverType} ci-dessous. Vous pouvez connecter plusieurs serveurs {serverType}, mais seulement deux d’entre eux peuvent être marqués par défaut (un non-4K et un 4K). Les administrateurs peuvent modifier le serveur utilisé pour traiter les nouvelles demandes avant la validation.", + "components.Settings.videoServiceSettingsDescription": "Configurez votre serveur {serverType} ci-dessous. Vous pouvez connecter plusieurs serveurs {serverType}, mais seulement deux d’entre eux peuvent être marqués par défaut (un non-4K et un 4K). Les administrateurs peuvent modifier le serveur utilisé pour traiter les nouvelles demandes avant la validation.", "components.Settings.mediaTypeSeries": "séries", "components.Settings.mediaTypeMovie": "film", "components.Settings.SettingsAbout.uptodate": "À jour", diff --git a/src/i18n/locale/hr.json b/src/i18n/locale/hr.json index f17ec31d..09b6c53b 100644 --- a/src/i18n/locale/hr.json +++ b/src/i18n/locale/hr.json @@ -378,7 +378,7 @@ "components.Settings.serverSecure": "sigurno", "components.Settings.serverpreset": "Poslužitelj", "components.Settings.serverpresetRefreshing": "Dohvaćanje poslužitelja …", - "components.Settings.serviceSettingsDescription": "Dolje donfiguriraj svoje {serverType} poslužitelje. Možeš povezati više {serverType} poslužitelja, ali samo dva od njih mogu biti označena kao zadana (jedan ne-4K i jedan 4K). Administratori mogu promijeniti poslužitelj koji se koristi za obradu novih zahtjeva prije odobrenja.", + "components.Settings.videoServiceSettingsDescription": "Dolje donfiguriraj svoje {serverType} poslužitelje. Možeš povezati više {serverType} poslužitelja, ali samo dva od njih mogu biti označena kao zadana (jedan ne-4K i jedan 4K). Administratori mogu promijeniti poslužitelj koji se koristi za obradu novih zahtjeva prije odobrenja.", "components.Settings.serverpresetLoad": "Pritisni gumb za učitavanje dostupnih polsužitelja", "components.Settings.serverpresetManualMessage": "Ručna konfiguracija", "components.Settings.services": "Usluge", diff --git a/src/i18n/locale/hu.json b/src/i18n/locale/hu.json index 5be406a6..d7ea6d2a 100644 --- a/src/i18n/locale/hu.json +++ b/src/i18n/locale/hu.json @@ -297,7 +297,7 @@ "components.Settings.sonarrsettings": "Sonarr beállítások", "components.Settings.settingUpPlexDescription": "A Plex beállításához megadhatja kézzel az adatokat, vagy kiválaszthat egy plex.tv-ről elérhető szervert. Nyomja meg a legördülő menü jobb oldalán lévő gombot az elérhető szerverek listájának lekérdezéséhez.", "components.Settings.services": "Szolgáltatások", - "components.Settings.serviceSettingsDescription": "Az alábbiakban konfigurálja a {serverType} szerver(eke)t. Több {serverType} szervert is csatlakoztathat, de ezek közül csak kettő jelölhető meg alapértelmezettként (egy nem 4K és egy 4K). A rendszergazdák felülbírálhatják az új kérések feldolgozásához használt szervert a jóváhagyás előtt.", + "components.Settings.videoServiceSettingsDescription": "Az alábbiakban konfigurálja a {serverType} szerver(eke)t. Több {serverType} szervert is csatlakoztathat, de ezek közül csak kettő jelölhető meg alapértelmezettként (egy nem 4K és egy 4K). A rendszergazdák felülbírálhatják az új kérések feldolgozásához használt szervert a jóváhagyás előtt.", "components.Settings.serverpresetRefreshing": "Szerverek lekérése…", "components.Settings.serverpresetManualMessage": "Kézi beállítás", "components.Settings.serverpresetLoad": "Nyomja meg a gombot az elérhető szerverek betöltéséhez", diff --git a/src/i18n/locale/it.json b/src/i18n/locale/it.json index 4af30cb1..a7f0568d 100644 --- a/src/i18n/locale/it.json +++ b/src/i18n/locale/it.json @@ -667,7 +667,7 @@ "components.RequestModal.AdvancedRequester.notagoptions": "Nessun tag.", "components.Layout.VersionStatus.outofdate": "Non aggiornato", "components.Layout.VersionStatus.commitsbehind": "{commitsBehind} {commitsBehind, plural, one {versione} other {versioni}} indietro", - "components.Settings.serviceSettingsDescription": "Configura i tuoi server {serverType} qui sotto. Puoi collegare più server {serverType}, ma solo due possono essere contrassegnati come predefiniti (uno non-4K e uno 4K). Gli amministratori possono selezionare il server usato per elaborare le nuove richieste prima dell'approvazione.", + "components.Settings.videoServiceSettingsDescription": "Configura i tuoi server {serverType} qui sotto. Puoi collegare più server {serverType}, ma solo due possono essere contrassegnati come predefiniti (uno non-4K e uno 4K). Gli amministratori possono selezionare il server usato per elaborare le nuove richieste prima dell'approvazione.", "components.Settings.noDefaultServer": "Almeno un server {serverType} deve essere contrassegnato come predefinito affinché le richieste {mediaType} possano essere processate.", "components.Settings.noDefaultNon4kServer": "Se hai solo un singolo server {serverType} per contenuti non-4K e 4K (o se scarichi solo contenuti 4K), il tuo server {serverType} NON dovrebbe essere designato come server 4K.", "components.Settings.mediaTypeSeries": "serie", diff --git a/src/i18n/locale/ko.json b/src/i18n/locale/ko.json index fede8d64..3180af2c 100644 --- a/src/i18n/locale/ko.json +++ b/src/i18n/locale/ko.json @@ -1093,7 +1093,7 @@ "components.Settings.SonarrModal.loadinglanguageprofiles": "언어 프로필 불러오는 중…", "components.Settings.SonarrModal.validationBaseUrlLeadingSlash": "기본 URL 앞에는 슬래시가 있어야 합니다", "components.Settings.SonarrModal.validationNameRequired": "서버 이름을 입력해야 합니다", - "components.Settings.serviceSettingsDescription": "아래에서 {serverType} 서버(들)를 구성하세요. 여러 개의 {serverType} 서버를 연결할 수 있지만, 기본 설정으로는 두 개만 지정할 수 있습니다 (비-4K 한 대와 4K 한 대). 관리자는 승인 이전에 새로운 요청을 처리하는 데 사용할 서버를 재지정할 수 있습니다.", + "components.Settings.videoServiceSettingsDescription": "아래에서 {serverType} 서버(들)를 구성하세요. 여러 개의 {serverType} 서버를 연결할 수 있지만, 기본 설정으로는 두 개만 지정할 수 있습니다 (비-4K 한 대와 4K 한 대). 관리자는 승인 이전에 새로운 요청을 처리하는 데 사용할 서버를 재지정할 수 있습니다.", "components.StatusBadge.managemedia": "{mediaType} 관리", "components.Settings.SonarrModal.animeTags": "애니메이션 태그", "components.Settings.SonarrModal.hostname": "호스트 네임 또는 IP 주소", diff --git a/src/i18n/locale/nb_NO.json b/src/i18n/locale/nb_NO.json index 1da54838..de5c3c14 100644 --- a/src/i18n/locale/nb_NO.json +++ b/src/i18n/locale/nb_NO.json @@ -929,7 +929,7 @@ "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON payload tilbakestilt!", "components.Settings.Notifications.NotificationsPushover.userTokenTip": "Din 30-tegns bruker- eller gruppe-nøkkel", "components.Settings.SettingsJobsCache.cachevsize": "Verdistørrelse", - "components.Settings.serviceSettingsDescription": "Konfigurer dine {serverType}tjener(e) nedenfor. Du kan koble til flere forskellige {serverType}tjenere men kun to av dem kan markeres som standard (en som ikke er 4K og en 4K). Administratorer kan endre hvilken tjener som brukes før godkjennelse av nye forespørsler.", + "components.Settings.videoServiceSettingsDescription": "Konfigurer dine {serverType}tjener(e) nedenfor. Du kan koble til flere forskellige {serverType}tjenere men kun to av dem kan markeres som standard (en som ikke er 4K og en 4K). Administratorer kan endre hvilken tjener som brukes før godkjennelse av nye forespørsler.", "components.ManageSlideOver.manageModalClearMediaWarning": "* Dette vil slette all data for denne tittelen uten mulighet for å bli gjennopprettet, det inkluderer alle forespørsler, avvik osv. Hvis denne tittelen finnes i ditt {mediaServerName} bibliotek vil medieinformasjon bli opprettet på nytt under neste skanning.", "components.Settings.Notifications.NotificationsWebhook.authheader": "Autorisasjonshode", "components.Settings.SettingsJobsCache.cacheksize": "Nøkkelstørrelse", diff --git a/src/i18n/locale/pl.json b/src/i18n/locale/pl.json index 70ed0d8d..42d9021e 100644 --- a/src/i18n/locale/pl.json +++ b/src/i18n/locale/pl.json @@ -874,7 +874,7 @@ "components.Settings.sonarrsettings": "Ustawienia Sonarr", "components.Settings.ssl": "Protokół SSL", "components.Settings.toastPlexConnectingSuccess": "Połączenie Plex nawiązane pomyślnie!", - "components.Settings.serviceSettingsDescription": "Skonfiguruj poniżej swój serwer(y) {serverType}. Możesz podłączyć wiele serwerów {serverType}, ale tylko dwa z nich mogą być oznaczone jako domyślne (jeden nie-4K i jeden 4K). Administratorzy mogą zmienić serwer używany do przetwarzania nowych żądań przed zatwierdzeniem.", + "components.Settings.videoServiceSettingsDescription": "Skonfiguruj poniżej swój serwer(y) {serverType}. Możesz podłączyć wiele serwerów {serverType}, ale tylko dwa z nich mogą być oznaczone jako domyślne (jeden nie-4K i jeden 4K). Administratorzy mogą zmienić serwer używany do przetwarzania nowych żądań przed zatwierdzeniem.", "components.Settings.toastPlexRefreshSuccess": "Lista serwerów Plex została pobrana pomyślnie!", "components.Settings.startscan": "Rozpocznij skanowanie", "components.Setup.finish": "Zakończ konfigurację", diff --git a/src/i18n/locale/pt_BR.json b/src/i18n/locale/pt_BR.json index e6dc5d0f..a31dc96f 100644 --- a/src/i18n/locale/pt_BR.json +++ b/src/i18n/locale/pt_BR.json @@ -669,7 +669,7 @@ "components.UserProfile.UserSettings.UserPasswordChange.noPasswordSet": "Esse usuário ainda não possui uma senha definida. Defina uma senha abaixo para habilitar autenticação como \"usuário local.\"", "components.UserProfile.UserSettings.UserNotificationSettings.validationPgpPublicKey": "Você deve prover uma chave pública PGP válida", "components.UserProfile.UserSettings.UserNotificationSettings.pgpPublicKeyTip": "Criptografa mensagens de e-mail usando OpenPGP", - "components.Settings.serviceSettingsDescription": "Configure seu(s) servidor(es) {serverType} abaixo. Você pode se conectar à múltiplos servidores {serverType}, mas apenas dois podem ser marcados como padrão (um não 4K e outro 4K). Administradores podem sobrescrever o servidor usado antes de aprovar as novas solicitações.", + "components.Settings.videoServiceSettingsDescription": "Configure seu(s) servidor(es) {serverType} abaixo. Você pode se conectar à múltiplos servidores {serverType}, mas apenas dois podem ser marcados como padrão (um não 4K e outro 4K). Administradores podem sobrescrever o servidor usado antes de aprovar as novas solicitações.", "components.Settings.noDefaultServer": "Ao menos um servidor {serverType} deve ser marcado como padrão para que as solicitações de {mediaType} sejam processadas.", "components.UserProfile.UserSettings.UserNotificationSettings.telegramsettingssaved": "Configurações de notificação via Telegram salvas com sucesso!", "components.UserProfile.UserSettings.UserNotificationSettings.telegramsettingsfailed": "Falha ao salvar configurações de notificação via Telegram.", diff --git a/src/i18n/locale/pt_PT.json b/src/i18n/locale/pt_PT.json index 3851d11d..960b7d1a 100644 --- a/src/i18n/locale/pt_PT.json +++ b/src/i18n/locale/pt_PT.json @@ -692,7 +692,7 @@ "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingssaved": "Definições de notificação Discord gravadas com sucesso!", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingsfailed": "Falha ao gravar as definições de notificação Discord.", "components.RequestList.RequestItem.cancelRequest": "Cancelar Pedido", - "components.Settings.serviceSettingsDescription": "Configure o seu(s) servidor(es) {serverType} abaixo. Pode ligar vários servidores {serverType}, mas apenas dois deles podem ser marcados como predefinidos (um não 4K e um 4K). Os administradores podem mudar o servidor usado para processar novos pedidos antes da aprovação.", + "components.Settings.videoServiceSettingsDescription": "Configure o seu(s) servidor(es) {serverType} abaixo. Pode ligar vários servidores {serverType}, mas apenas dois deles podem ser marcados como predefinidos (um não 4K e um 4K). Os administradores podem mudar o servidor usado para processar novos pedidos antes da aprovação.", "components.Settings.noDefaultServer": "Pelo menos um servidor {serverType} deve ser marcado como predefinido para que os pedidos de {mediaType} sejam processados.", "components.Settings.noDefaultNon4kServer": "Se tiver apenas um único servidor {serverType} para conteúdo não 4K e 4K (ou se apenas transfere conteúdo 4K), o seu servidor {serverType} NÃO deve ser designado como um servidor 4K.", "components.UserProfile.UserSettings.UserPasswordChange.noPasswordSetOwnAccount": "Atualmente, sua conta não tem uma palavra-passe definida. Configure uma palavra-passe abaixo para permitir o inicio de sessão como um \"utilizador local\" usando o seu e-mail.", diff --git a/src/i18n/locale/ru.json b/src/i18n/locale/ru.json index e8f1b27c..87059e68 100644 --- a/src/i18n/locale/ru.json +++ b/src/i18n/locale/ru.json @@ -759,7 +759,7 @@ "components.Settings.toastPlexConnecting": "Попытка подключения к Plex…", "components.Settings.settingUpPlexDescription": "Чтобы настроить Plex, вы можете либо ввести данные вручную, либо выбрать сервер, полученный со страницы plex.tv. Нажмите кнопку справа от выпадающего списка, чтобы получить список доступных серверов.", "components.Settings.services": "Службы", - "components.Settings.serviceSettingsDescription": "Настройте сервер(ы) {serverType} ниже. Вы можете подключить несколько серверов {serverType}, но только два из них могут быть помечены как серверы по умолчанию (один не 4К и один 4К). Администраторы могут переопределить сервер для обработки новых запросов до их одобрения.", + "components.Settings.videoServiceSettingsDescription": "Настройте сервер(ы) {serverType} ниже. Вы можете подключить несколько серверов {serverType}, но только два из них могут быть помечены как серверы по умолчанию (один не 4К и один 4К). Администраторы могут переопределить сервер для обработки новых запросов до их одобрения.", "components.Settings.serverpresetLoad": "Нажмите кнопку, чтобы загрузить список доступных серверов", "components.Settings.serverSecure": "защищённый", "components.Settings.serverRemote": "удалённый", diff --git a/src/i18n/locale/sq.json b/src/i18n/locale/sq.json index c33895ac..ea1675e1 100644 --- a/src/i18n/locale/sq.json +++ b/src/i18n/locale/sq.json @@ -932,7 +932,7 @@ "components.Settings.SonarrModal.syncEnabled": "Aktivo skanimin", "components.Settings.SonarrModal.testFirstTags": "Testo lidhjen për të ngarkuar etiketat", "components.Settings.SonarrModal.validationRootFolderRequired": "Duhet të zgjedhësh një dosje rrënjë", - "components.Settings.serviceSettingsDescription": "Konfiguro serverin tënd {serverType} më poshtë. Ju mund të lidhni shumë servera {serverType}, por vetëm dy prej tyre mund të shënohen si të prezgjedhur (një jo-4K dhe një 4K). Administratorët janë në gjendje të kapërcejnë serverin e përdorur për të përpunuar kërkesa të reja përpara miratimit.", + "components.Settings.videoServiceSettingsDescription": "Konfiguro serverin tënd {serverType} më poshtë. Ju mund të lidhni shumë servera {serverType}, por vetëm dy prej tyre mund të shënohen si të prezgjedhur (një jo-4K dhe një 4K). Administratorët janë në gjendje të kapërcejnë serverin e përdorur për të përpunuar kërkesa të reja përpara miratimit.", "components.Settings.validationHostnameRequired": "Ju duhet të siguroni një emër të vlefshëm host ose adrese IP", "components.UserProfile.UserSettings.UserPasswordChange.nopermissionDescription": "Ju nuk keni leje për të modifikuar fjalëkalimin e këtij përdoruesi.", "components.UserProfile.UserSettings.UserPasswordChange.toastSettingsFailureVerifyCurrent": "Diçka shkoi keq duke ruajtur fjalëkalimin. A u fut fjalëkalimi në mënyrë korrekte?", diff --git a/src/i18n/locale/tr.json b/src/i18n/locale/tr.json index 5fa53430..edf61b60 100644 --- a/src/i18n/locale/tr.json +++ b/src/i18n/locale/tr.json @@ -962,7 +962,7 @@ "components.Settings.manualscanDescription": "Normalde, bu yalnızca her 24 saatte bir çalıştırılır. Jellyseerr, Plex sunucunuzun son eklenenlerini sık sık kontrol edecektir. Plex'i ilk kez yapılandırıyorsanız, tek seferlik tam manuel kütüphane taraması önerilir!", "components.Settings.manualscanJellyfin": "Kütüphaneleri Elle Tara", "components.Settings.menuJobs": "İşlemler & Önbellek", - "components.Settings.serviceSettingsDescription": "{serverType} sunucu(lar)ınızı aşağıdan yapılandırın. Birden fazla {serverType} sunucusuna bağlanabilirsiniz, ancak sadece iki tanesi varsayılan olarak tanımlanabilir (bir adet 4K ve bir adet 4K dışı). Yöneticiler sunucuları her daim mutlak bir biçimde yönetebilirler.", + "components.Settings.videoServiceSettingsDescription": "{serverType} sunucu(lar)ınızı aşağıdan yapılandırın. Birden fazla {serverType} sunucusuna bağlanabilirsiniz, ancak sadece iki tanesi varsayılan olarak tanımlanabilir (bir adet 4K ve bir adet 4K dışı). Yöneticiler sunucuları her daim mutlak bir biçimde yönetebilirler.", "components.Settings.services": "Servisler", "components.Settings.settingUpPlexDescription": "Plex'i kurmak için, ayrıntıları manuel olarak girebilir veya plex.tv adresinden alınan bir sunucuyu seçebilirsiniz. Kullanılabilir sunucuların listesini almak için açılır menünün sağındaki düğmeyi kullanın.", "components.Settings.ssl": "SSL", diff --git a/src/i18n/locale/uk.json b/src/i18n/locale/uk.json index c7abc6c6..e6a522b0 100644 --- a/src/i18n/locale/uk.json +++ b/src/i18n/locale/uk.json @@ -790,7 +790,7 @@ "components.Settings.serverpresetLoad": "Натисніть кнопку, щоб завантажити список доступних серверів", "components.Settings.serverpresetManualMessage": "Ручне налаштування", "components.Settings.serverpresetRefreshing": "Отримання списку серверів…", - "components.Settings.serviceSettingsDescription": "Налаштуйте сервер(и) {serverType} нижче. Ви можете підключити кілька серверів {serverType}, але тільки два з них можуть бути позначені як сервери за промовчанням (один не 4К і один 4К). Адміністратори можуть перевизначити сервер для обробки нових запитів до їх схвалення.", + "components.Settings.videoServiceSettingsDescription": "Налаштуйте сервер(и) {serverType} нижче. Ви можете підключити кілька серверів {serverType}, але тільки два з них можуть бути позначені як сервери за промовчанням (один не 4К і один 4К). Адміністратори можуть перевизначити сервер для обробки нових запитів до їх схвалення.", "components.Settings.services": "Служби", "components.Settings.settingUpPlexDescription": "Щоб налаштувати Plex, ви можете або ввести дані вручну, або вибрати сервер, отриманий зі сторінки plex.tv. Натисніть кнопку праворуч від випадаючого списку, щоб отримати список доступних серверів .", "components.Settings.sonarrsettings": "Налаштування Sonarr", diff --git a/src/i18n/locale/zh_Hans.json b/src/i18n/locale/zh_Hans.json index adf57ae4..4e923a26 100644 --- a/src/i18n/locale/zh_Hans.json +++ b/src/i18n/locale/zh_Hans.json @@ -83,7 +83,7 @@ "components.Settings.sonarrsettings": "Sonarr 设置", "components.Settings.settingUpPlexDescription": "你可以手动输入你的 Plex 服务器资料,或从 plex.tv 返回的设置做选择以及自动配置。请点下拉式选单右边的按钮获取服务器列表。", "components.Settings.services": "服务器", - "components.Settings.serviceSettingsDescription": "关于 {serverType} 服务器的设置。{serverType} 服务器数没有最大值限制,但你只能指定兩个服务器为默认(一个非 4K、一个 4K)。", + "components.Settings.videoServiceSettingsDescription": "关于 {serverType} 服务器的设置。{serverType} 服务器数没有最大值限制,但你只能指定兩个服务器为默认(一个非 4K、一个 4K)。", "components.Settings.serverpresetRefreshing": "载入中…", "components.Settings.serverpresetManualMessage": "手动设定", "components.Settings.serverpresetLoad": "请点右边的按钮", diff --git a/src/i18n/locale/zh_Hant.json b/src/i18n/locale/zh_Hant.json index 9540a6bf..4a8d886a 100644 --- a/src/i18n/locale/zh_Hant.json +++ b/src/i18n/locale/zh_Hant.json @@ -693,7 +693,7 @@ "components.Settings.SettingsAbout.uptodate": "最新", "components.Settings.noDefaultNon4kServer": "如果您只有一個 {serverType} 伺服器,請勿把它設定為 4K 伺服器。", "components.Settings.noDefaultServer": "您必須至少指定一個預設 {serverType} 伺服器,才能處理{mediaType}請求。", - "components.Settings.serviceSettingsDescription": "關於 {serverType} 伺服器的設定。{serverType} 伺服器數沒有最大值限制,但您只能指定兩個預設伺服器(一個非 4K、一個 4K)。", + "components.Settings.videoServiceSettingsDescription": "關於 {serverType} 伺服器的設定。{serverType} 伺服器數沒有最大值限制,但您只能指定兩個預設伺服器(一個非 4K、一個 4K)。", "components.Settings.mediaTypeSeries": "影集", "components.Settings.mediaTypeMovie": "電影", "components.UserProfile.UserSettings.UserPasswordChange.noPasswordSet": "此使用者的帳戶目前沒有設密碼。若在以下設定密碼,此使用者就能使用「本地登入」。", diff --git a/src/pages/discover/music/index.tsx b/src/pages/discover/music/index.tsx new file mode 100644 index 00000000..f34241ce --- /dev/null +++ b/src/pages/discover/music/index.tsx @@ -0,0 +1,8 @@ +import DiscoverMusic from '@app/components/Discover/DiscoverMusic'; +import type { NextPage } from 'next'; + +const DiscoverMusicPage: NextPage = () => { + return ; +}; + +export default DiscoverMusicPage; diff --git a/src/pages/group/[groupId]/index.tsx b/src/pages/group/[groupId]/index.tsx new file mode 100644 index 00000000..f340857f --- /dev/null +++ b/src/pages/group/[groupId]/index.tsx @@ -0,0 +1,8 @@ +import GroupDetails from '@app/components/GroupDetails'; +import type { NextPage } from 'next'; + +const GroupPage: NextPage = () => { + return ; +}; + +export default GroupPage; diff --git a/src/pages/music/[musicId]/discography.tsx b/src/pages/music/[musicId]/discography.tsx new file mode 100644 index 00000000..3d52b087 --- /dev/null +++ b/src/pages/music/[musicId]/discography.tsx @@ -0,0 +1,8 @@ +import MusicArtistDiscography from '@app/components/MusicDetails/MusicArtistDiscography'; +import type { NextPage } from 'next'; + +const MusicArtistDiscographyPage: NextPage = () => { + return ; +}; + +export default MusicArtistDiscographyPage; diff --git a/src/pages/music/[musicId]/index.tsx b/src/pages/music/[musicId]/index.tsx new file mode 100644 index 00000000..74d98711 --- /dev/null +++ b/src/pages/music/[musicId]/index.tsx @@ -0,0 +1,36 @@ +import MusicDetails from '@app/components/MusicDetails'; +import type { MusicDetails as MusicDetailsType } from '@server/models/Music'; +import type { GetServerSideProps, NextPage } from 'next'; + +interface MusicPageProps { + music?: MusicDetailsType; +} + +const MusicPage: NextPage = ({ music }) => { + return ; +}; + +export const getServerSideProps: GetServerSideProps = async ( + ctx +) => { + const res = await fetch( + `http://localhost:${process.env.PORT || 5055}/api/v1/music/${ + ctx.query.musicId + }`, + { + headers: ctx.req?.headers?.cookie + ? { cookie: ctx.req.headers.cookie } + : undefined, + } + ); + if (!res.ok) throw new Error(); + const music: MusicDetailsType = await res.json(); + + return { + props: { + music, + }, + }; +}; + +export default MusicPage; diff --git a/src/pages/music/[musicId]/similar.tsx b/src/pages/music/[musicId]/similar.tsx new file mode 100644 index 00000000..8daa46b4 --- /dev/null +++ b/src/pages/music/[musicId]/similar.tsx @@ -0,0 +1,8 @@ +import MusicArtistSimilar from '@app/components/MusicDetails/MusicArtistSimilar'; +import type { NextPage } from 'next'; + +const MusicArtistSimilarPage: NextPage = () => { + return ; +}; + +export default MusicArtistSimilarPage;