feat: lidarr/Music support added

This commit is contained in:
Pierre
2025-01-10 23:16:46 +01:00
committed by HiItsStolas
parent 539d49879d
commit adf56d63bc
153 changed files with 9372 additions and 860 deletions

View File

@@ -59,7 +59,8 @@
"date-fns": "2.29.3", "date-fns": "2.29.3",
"dayjs": "1.11.19", "dayjs": "1.11.19",
"dns-caching": "^0.2.7", "dns-caching": "^0.2.7",
"email-templates": "12.0.3", "dompurify": "^3.2.3",
"email-templates": "12.0.1",
"express": "4.21.2", "express": "4.21.2",
"express-openapi-validator": "4.13.8", "express-openapi-validator": "4.13.8",
"express-rate-limit": "6.7.0", "express-rate-limit": "6.7.0",

13
pnpm-lock.yaml generated
View File

@@ -86,6 +86,9 @@ importers:
dns-caching: dns-caching:
specifier: ^0.2.7 specifier: ^0.2.7
version: 0.2.7 version: 0.2.7
dompurify:
specifier: ^3.2.3
version: 3.2.3
email-templates: email-templates:
specifier: 12.0.3 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) 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==} resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
dompurify@3.2.3:
resolution: {integrity: sha512-U1U5Hzc2MO0oW3DF+G9qYN0aT7atAou4AgI0XjWz061nyBPbdxkfdhfy5uMgGn6+oLFCfn44ZGbdDqCzVmlOWA==}
domutils@2.8.0: domutils@2.8.0:
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
@@ -13615,6 +13621,9 @@ snapshots:
'@types/ua-parser-js@0.7.39': {} '@types/ua-parser-js@0.7.39': {}
'@types/trusted-types@2.0.7':
optional: true
'@types/unist@2.0.10': {} '@types/unist@2.0.10': {}
'@types/validator@13.15.10': {} '@types/validator@13.15.10': {}
@@ -15198,6 +15207,10 @@ snapshots:
dependencies: dependencies:
domelementtype: 2.3.0 domelementtype: 2.3.0
dompurify@3.2.3:
optionalDependencies:
'@types/trusted-types': 2.0.7
domutils@2.8.0: domutils@2.8.0:
dependencies: dependencies:
dom-serializer: 1.4.1 dom-serializer: 1.4.1

File diff suppressed because it is too large Load Diff

View File

@@ -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<CoverArtResponse> {
try {
const data = await this.get<CoverArtResponse>(
`/release-group/${id}`,
undefined,
43200
);
return data;
} catch (e) {
throw new Error(
`[CoverArtArchive] Failed to fetch cover art: ${e.message}`
);
}
}
}
export default CoverArtArchive;

View File

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

View File

@@ -56,7 +56,7 @@ interface JellyfinMediaFolder {
} }
export interface JellyfinLibrary { export interface JellyfinLibrary {
type: 'show' | 'movie'; type: 'show' | 'movie' | 'music';
key: string; key: string;
title: string; title: string;
agent: string; agent: string;
@@ -66,7 +66,13 @@ export interface JellyfinLibraryItem {
Name: string; Name: string;
Id: string; Id: string;
HasSubtitles: boolean; HasSubtitles: boolean;
Type: 'Movie' | 'Episode' | 'Season' | 'Series'; Type:
| 'Movie'
| 'Episode'
| 'Season'
| 'Series'
| 'MusicAlbum'
| 'MusicArtist';
LocationType: 'FileSystem' | 'Offline' | 'Remote' | 'Virtual'; LocationType: 'FileSystem' | 'Offline' | 'Remote' | 'Virtual';
SeriesName?: string; SeriesName?: string;
SeriesId?: string; SeriesId?: string;
@@ -76,6 +82,8 @@ export interface JellyfinLibraryItem {
IndexNumberEnd?: number; IndexNumberEnd?: number;
ParentIndexNumber?: number; ParentIndexNumber?: number;
MediaType: string; MediaType: string;
AlbumId?: string;
ArtistId?: string;
} }
export interface JellyfinMediaStream { export interface JellyfinMediaStream {
@@ -104,6 +112,8 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
Imdb?: string; Imdb?: string;
Tvdb?: string; Tvdb?: string;
AniDB?: string; AniDB?: string;
MusicBrainzReleaseGroup: string | undefined;
MusicBrainzArtistId?: string;
}; };
MediaSources?: JellyfinMediaSource[]; MediaSources?: JellyfinMediaSource[];
Width?: number; Width?: number;
@@ -308,13 +318,7 @@ class JellyfinAPI extends ExternalAPI {
} }
private mapLibraries(mediaFolders: JellyfinMediaFolder[]): JellyfinLibrary[] { private mapLibraries(mediaFolders: JellyfinMediaFolder[]): JellyfinLibrary[] {
const excludedTypes = [ const excludedTypes = ['books', 'musicvideos', 'homevideos', 'boxsets'];
'music',
'books',
'musicvideos',
'homevideos',
'boxsets',
];
return mediaFolders return mediaFolders
.filter((Item: JellyfinMediaFolder) => { .filter((Item: JellyfinMediaFolder) => {
@@ -327,7 +331,12 @@ class JellyfinAPI extends ExternalAPI {
return <JellyfinLibrary>{ return <JellyfinLibrary>{
key: Item.Id, key: Item.Id,
title: Item.Name, title: Item.Name,
type: Item.CollectionType === 'movies' ? 'movie' : 'show', type:
Item.CollectionType === 'movies'
? 'movie'
: Item.CollectionType === 'tvshows'
? 'show'
: 'music',
agent: 'jellyfin', agent: 'jellyfin',
}; };
}); });
@@ -336,7 +345,7 @@ class JellyfinAPI extends ExternalAPI {
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> { public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
try { try {
const libraryItemsResponse = await this.get<any>( const libraryItemsResponse = await this.get<any>(
`/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( return libraryItemsResponse.Items.filter(

View File

@@ -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<LbSimilarArtistResponse[]> {
const {
days = 9000,
session = 300,
contribution = 5,
threshold = 15,
limit = 50,
skip = 30,
} = options;
return this.getRolling<LbSimilarArtistResponse[]>(
'/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<LbTopAlbumsResponse> {
return this.get<LbTopAlbumsResponse>(
'/stats/sitewide/release-groups',
{
offset: offset.toString(),
range,
count: count.toString(),
},
43200
);
}
}
export default ListenBrainzAPI;

View File

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

View File

@@ -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<MbSearchMultiResponse[]> => {
try {
const data = await this.get<MbSearchMultiResponse[]>('/search', {
type: 'all',
query,
});
return data.filter(
(result) => !result.artist || result.artist.type === 'Group'
);
} catch (e) {
return [];
}
};
public async searchArtist({
query,
}: {
query: string;
}): Promise<MbArtistDetails[]> {
try {
const data = await this.get<MbArtistDetails[]>(
'/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<MbAlbumDetails> {
try {
const data = await this.get<MbAlbumDetails>(
`/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<MbArtistDetails> {
try {
const artistData = await this.get<MbArtistDetails>(
`/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<string | null> {
try {
const data =
type === 'album'
? await this.get<MbAlbumDetails>(`/album/${id}`, { language }, 43200)
: await this.get<MbArtistDetails>(
`/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<MbArtistDetails>(
`/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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
return decoded;
} catch (e) {
return null;
}
}
}
export default MusicBrainz;

View File

@@ -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;
}[];
}

View File

@@ -16,7 +16,7 @@ export interface PlexLibraryItem {
Guid?: { Guid?: {
id: string; id: string;
}[]; }[];
type: 'movie' | 'show' | 'season' | 'episode'; type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album' | 'track';
Media: Media[]; Media: Media[];
} }
@@ -28,7 +28,7 @@ interface PlexLibraryResponse {
} }
export interface PlexLibrary { export interface PlexLibrary {
type: 'show' | 'movie'; type: 'show' | 'movie' | 'music';
key: string; key: string;
title: string; title: string;
agent: string; agent: string;
@@ -44,7 +44,7 @@ export interface PlexMetadata {
ratingKey: string; ratingKey: string;
parentRatingKey?: string; parentRatingKey?: string;
guid: string; guid: string;
type: 'movie' | 'show' | 'season'; type: 'movie' | 'show' | 'season' | 'artist' | 'album' | 'track';
title: string; title: string;
Guid: { Guid: {
id: string; id: string;
@@ -152,7 +152,10 @@ class PlexAPI {
const newLibraries: Library[] = libraries const newLibraries: Library[] = libraries
// Remove libraries that are not movie or show // Remove libraries that are not movie or show
.filter( .filter(
(library) => library.type === 'movie' || library.type === 'show' (library) =>
library.type === 'movie' ||
library.type === 'show' ||
library.type === 'music'
) )
// Remove libraries that do not have a metadata agent set (usually personal video libraries) // Remove libraries that do not have a metadata agent set (usually personal video libraries)
.filter((library) => library.agent !== 'com.plexapp.agents.none') .filter((library) => library.agent !== 'com.plexapp.agents.none')
@@ -227,7 +230,7 @@ class PlexAPI {
options: { addedAt: number } = { options: { addedAt: number } = {
addedAt: Date.now() - 1000 * 60 * 60, addedAt: Date.now() - 1000 * 60 * 60,
}, },
mediaType: 'movie' | 'show' mediaType: 'movie' | 'show' | 'music'
): Promise<PlexLibraryItem[]> { ): Promise<PlexLibraryItem[]> {
const response = await this.plexClient.query<PlexLibraryResponse>({ const response = await this.plexClient.query<PlexLibraryResponse>({
uri: `/library/sections/${id}/all?type=${ uri: `/library/sections/${id}/all?type=${

View File

@@ -124,7 +124,7 @@ export interface PlexWatchlistItem {
ratingKey: string; ratingKey: string;
tmdbId: number; tmdbId: number;
tvdbId?: number; tvdbId?: number;
type: 'movie' | 'show'; type: 'movie' | 'show' | 'album';
title: string; title: string;
} }

View File

@@ -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<LidarrAlbum[]> {
try {
const data = await this.get<LidarrAlbum[]>('/album');
return data;
} catch (e) {
throw new Error(`[Lidarr] Failed to retrieve albums: ${e.message}`);
}
}
public async getArtist({ id }: { id: number }): Promise<LidarrArtistDetails> {
try {
const data = await this.get<LidarrArtistDetails>(`/artist/${id}`);
return data;
} catch (e) {
throw new Error(`[Lidarr] Failed to retrieve album: ${e.message}`);
}
}
public async getAlbum({ id }: { id: number }): Promise<LidarrAlbum> {
try {
const data = await this.get<LidarrAlbum>(`/album/${id}`);
return data;
} catch (e) {
throw new Error(`[Lidarr] Failed to retrieve album: ${e.message}`);
}
}
public async getAlbumByMusicBrainzId(
mbId: string
): Promise<LidarrAlbumDetails> {
try {
const data = await this.get<LidarrAlbumDetails[]>('/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<void> {
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<LidarrArtistDetails> {
try {
const data = await this.get<LidarrArtistDetails[]>('/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<LidarrAlbumDetails> {
try {
const data = await this.post<LidarrAlbumDetails>('/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<LidarrArtistDetails> {
try {
const data = await this.post<LidarrArtistDetails>('/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<LidarrSearchResponse> {
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<LidarrArtistDetails> {
try {
const data = await this.put<LidarrArtistDetails>(`/artist/${artist.id}`, {
...artist,
} as Record<string, unknown>);
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<LidarrAlbumDetails> {
try {
const data = await this.put<LidarrAlbumDetails>(`/album/${album.id}`, {
...album,
} as Record<string, unknown>);
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<void> {
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;

View File

@@ -16,6 +16,7 @@ import type {
TmdbNetwork, TmdbNetwork,
TmdbPersonCombinedCredits, TmdbPersonCombinedCredits,
TmdbPersonDetails, TmdbPersonDetails,
TmdbPersonSearchResponse,
TmdbProductionCompany, TmdbProductionCompany,
TmdbRegion, TmdbRegion,
TmdbSearchMovieResponse, TmdbSearchMovieResponse,
@@ -230,6 +231,31 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
} }
}; };
public async searchPerson({
query,
page = 1,
includeAdult = false,
language = 'en',
}: SearchOptions): Promise<TmdbPersonSearchResponse> {
try {
const data = await this.get<TmdbPersonSearchResponse>('/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 ({ public getPerson = async ({
personId, personId,
language = this.locale, language = this.locale,

View File

@@ -469,3 +469,15 @@ export interface TmdbWatchProviderRegion {
english_name: string; english_name: string;
native_name: string; native_name: string;
} }
export interface TmdbPersonSearchResponse extends TmdbPaginatedResponse {
results: TmdbPersonSearchResult[];
}
export interface TmdbPersonSearchResult
extends Omit<TmdbPersonResult, 'known_for'> {
gender: number;
known_for_department: string;
original_name: string;
known_for: (TmdbMovieResult | TmdbTvResult)[];
}

View File

@@ -4,6 +4,7 @@ export enum DiscoverSliderType {
RECENTLY_ADDED = 1, RECENTLY_ADDED = 1,
RECENT_REQUESTS, RECENT_REQUESTS,
PLEX_WATCHLIST, PLEX_WATCHLIST,
POPULAR_ALBUMS,
TRENDING, TRENDING,
POPULAR_MOVIES, POPULAR_MOVIES,
MOVIE_GENRES, MOVIE_GENRES,
@@ -50,51 +51,57 @@ export const defaultSliders: Partial<DiscoverSlider>[] = [
order: 3, order: 3,
}, },
{ {
type: DiscoverSliderType.POPULAR_MOVIES, type: DiscoverSliderType.POPULAR_ALBUMS,
enabled: true, enabled: true,
isBuiltIn: true, isBuiltIn: true,
order: 4, order: 4,
}, },
{ {
type: DiscoverSliderType.MOVIE_GENRES, type: DiscoverSliderType.POPULAR_MOVIES,
enabled: true, enabled: true,
isBuiltIn: true, isBuiltIn: true,
order: 5, order: 5,
}, },
{ {
type: DiscoverSliderType.UPCOMING_MOVIES, type: DiscoverSliderType.MOVIE_GENRES,
enabled: true, enabled: true,
isBuiltIn: true, isBuiltIn: true,
order: 6, order: 6,
}, },
{ {
type: DiscoverSliderType.STUDIOS, type: DiscoverSliderType.UPCOMING_MOVIES,
enabled: true, enabled: true,
isBuiltIn: true, isBuiltIn: true,
order: 7, order: 7,
}, },
{ {
type: DiscoverSliderType.POPULAR_TV, type: DiscoverSliderType.STUDIOS,
enabled: true, enabled: true,
isBuiltIn: true, isBuiltIn: true,
order: 8, order: 8,
}, },
{ {
type: DiscoverSliderType.TV_GENRES, type: DiscoverSliderType.POPULAR_TV,
enabled: true, enabled: true,
isBuiltIn: true, isBuiltIn: true,
order: 9, order: 9,
}, },
{ {
type: DiscoverSliderType.UPCOMING_TV, type: DiscoverSliderType.TV_GENRES,
enabled: true, enabled: true,
isBuiltIn: true, isBuiltIn: true,
order: 10, order: 10,
}, },
{ {
type: DiscoverSliderType.NETWORKS, type: DiscoverSliderType.UPCOMING_TV,
enabled: true, enabled: true,
isBuiltIn: true, isBuiltIn: true,
order: 11, order: 11,
}, },
{
type: DiscoverSliderType.NETWORKS,
enabled: true,
isBuiltIn: true,
order: 12,
},
]; ];

View File

@@ -2,7 +2,8 @@ export enum IssueType {
VIDEO = 1, VIDEO = 1,
AUDIO = 2, AUDIO = 2,
SUBTITLES = 3, SUBTITLES = 3,
OTHER = 4, LYRICS = 4,
OTHER = 5,
} }
export enum IssueStatus { export enum IssueStatus {
@@ -14,5 +15,6 @@ export const IssueTypeName = {
[IssueType.AUDIO]: 'Audio', [IssueType.AUDIO]: 'Audio',
[IssueType.VIDEO]: 'Video', [IssueType.VIDEO]: 'Video',
[IssueType.SUBTITLES]: 'Subtitle', [IssueType.SUBTITLES]: 'Subtitle',
[IssueType.LYRICS]: 'Lyrics',
[IssueType.OTHER]: 'Other', [IssueType.OTHER]: 'Other',
}; };

View File

@@ -9,6 +9,7 @@ export enum MediaRequestStatus {
export enum MediaType { export enum MediaType {
MOVIE = 'movie', MOVIE = 'movie',
TV = 'tv', TV = 'tv',
MUSIC = 'music',
} }
export enum MediaStatus { export enum MediaStatus {

View File

@@ -18,7 +18,7 @@ import {
import type { ZodNumber, ZodOptional, ZodString } from 'zod'; import type { ZodNumber, ZodOptional, ZodString } from 'zod';
@Entity() @Entity()
@Unique(['tmdbId']) @Unique(['tmdbId', 'mbId'])
export class Blacklist implements BlacklistItem { export class Blacklist implements BlacklistItem {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
public id: number; public id: number;
@@ -29,9 +29,13 @@ export class Blacklist implements BlacklistItem {
@Column({ nullable: true, type: 'varchar' }) @Column({ nullable: true, type: 'varchar' })
title?: string; title?: string;
@Column() @Column({ nullable: true })
@Index() @Index()
public tmdbId: number; public tmdbId?: number;
@Column({ nullable: true })
@Index()
public mbId?: string;
@ManyToOne(() => User, (user) => user.id, { @ManyToOne(() => User, (user) => user.id, {
eager: true, eager: true,
@@ -62,6 +66,7 @@ export class Blacklist implements BlacklistItem {
mediaType: MediaType; mediaType: MediaType;
title?: ZodOptional<ZodString>['_output']; title?: ZodOptional<ZodString>['_output'];
tmdbId: ZodNumber['_output']; tmdbId: ZodNumber['_output'];
mbId?: ZodOptional<ZodString>['_output']
blacklistedTags?: string; blacklistedTags?: string;
}; };
}, },
@@ -74,9 +79,10 @@ export class Blacklist implements BlacklistItem {
const mediaRepository = em.getRepository(Media); const mediaRepository = em.getRepository(Media);
let media = await mediaRepository.findOne({ let media = await mediaRepository.findOne({
where: { where:
tmdbId: blacklistRequest.tmdbId, blacklistRequest.mediaType === 'music'
}, ? { mbId: blacklistRequest.mbId }
: { tmdbId: blacklistRequest.tmdbId },
}); });
const blacklistRepository = em.getRepository(this); const blacklistRepository = em.getRepository(this);
@@ -86,6 +92,7 @@ export class Blacklist implements BlacklistItem {
if (!media) { if (!media) {
media = new Media({ media = new Media({
tmdbId: blacklistRequest.tmdbId, tmdbId: blacklistRequest.tmdbId,
mbId: blacklistRequest.mbId,
status: MediaStatus.BLACKLISTED, status: MediaStatus.BLACKLISTED,
status4k: MediaStatus.BLACKLISTED, status4k: MediaStatus.BLACKLISTED,
mediaType: blacklistRequest.mediaType, mediaType: blacklistRequest.mediaType,

View File

@@ -1,3 +1,4 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import RadarrAPI from '@server/api/servarr/radarr'; import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaStatus, MediaType } from '@server/constants/media';
@@ -29,33 +30,39 @@ import Season from './Season';
class Media { class Media {
public static async getRelatedMedia( public static async getRelatedMedia(
user: User | undefined, user: User | undefined,
tmdbIds: number | number[] ids: (number | string)[]
): Promise<Media[]> { ): Promise<Media[]> {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
try { try {
let finalIds: number[]; if (ids.length === 0) {
if (!Array.isArray(tmdbIds)) {
finalIds = [tmdbIds];
} else {
finalIds = tmdbIds;
}
if (finalIds.length === 0) {
return []; 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') .createQueryBuilder('media')
.leftJoinAndSelect( .leftJoinAndSelect(
'media.watchlists', 'media.watchlists',
'watchlist', 'watchlist',
'media.id= watchlist.media and watchlist.requestedBy = :userId', 'media.id = watchlist.media and watchlist.requestedBy = :userId',
{ userId: user?.id } { 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; return media;
} catch (e) { } catch (e) {
logger.error(e.message); logger.error(e.message);
@@ -64,14 +71,19 @@ class Media {
} }
public static async getMedia( public static async getMedia(
id: number, id: number | string,
mediaType: MediaType mediaType: MediaType
): Promise<Media | undefined> { ): Promise<Media | undefined> {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
try { try {
const whereClause =
typeof id === 'string'
? { mbId: id, mediaType }
: { tmdbId: id, mediaType };
const media = await mediaRepository.findOne({ const media = await mediaRepository.findOne({
where: { tmdbId: id, mediaType: mediaType }, where: whereClause,
relations: { requests: true, issues: true }, relations: { requests: true, issues: true },
}); });
@@ -88,7 +100,7 @@ class Media {
@Column({ type: 'varchar' }) @Column({ type: 'varchar' })
public mediaType: MediaType; public mediaType: MediaType;
@Column() @Column({ nullable: true })
@Index() @Index()
public tmdbId: number; public tmdbId: number;
@@ -100,6 +112,10 @@ class Media {
@Index() @Index()
public imdbId?: string; public imdbId?: string;
@Column({ nullable: true })
@Index()
public mbId?: string;
@Column({ type: 'int', default: MediaStatus.UNKNOWN }) @Column({ type: 'int', default: MediaStatus.UNKNOWN })
public status: MediaStatus; 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() @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
);
}
}
} }
} }

View File

@@ -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 TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import type {
TmdbMovieDetails,
TmdbTvDetails,
} from '@server/api/themoviedb/interfaces';
import { import {
MediaRequestStatus, MediaRequestStatus,
MediaStatus, MediaStatus,
@@ -46,8 +58,12 @@ export class MediaRequest {
requestBody: MediaRequestBody, requestBody: MediaRequestBody,
user: User, user: User,
options: MediaRequestOptions = {} options: MediaRequestOptions = {}
): Promise<MediaRequest> { ): Promise<MediaRequest | undefined> {
const tmdb = new TheMovieDb(); 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 mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest); const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User); 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(); const quotas = await requestUser.getQuota();
if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) { if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
throw new QuotaRestrictedError('Movie Quota exceeded.'); throw new QuotaRestrictedError('Movie Quota exceeded.');
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) { } else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
throw new QuotaRestrictedError('Series Quota exceeded.'); throw new QuotaRestrictedError('Series Quota exceeded.');
} else if (
requestBody.mediaType === MediaType.MUSIC &&
quotas.music.restricted
) {
throw new QuotaRestrictedError('Music Quota exceeded.');
} }
const tmdbMedia = const tmdbMedia =
requestBody.mediaType === MediaType.MOVIE requestBody.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: requestBody.mediaId }) ? 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({ let media = await mediaRepository.findOne({
where: { where: {
tmdbId: requestBody.mediaId, mbId:
requestBody.mediaType === MediaType.MUSIC
? requestBody.mediaId.toString()
: undefined,
tmdbId:
requestBody.mediaType !== MediaType.MUSIC
? requestBody.mediaId
: undefined,
mediaType: requestBody.mediaType, mediaType: requestBody.mediaType,
}, },
relations: ['requests'], relations: ['requests'],
@@ -132,16 +176,27 @@ export class MediaRequest {
if (!media) { if (!media) {
media = new Media({ media = new Media({
tmdbId: tmdbMedia.id, mbId:
tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id, requestBody.mediaType === MediaType.MUSIC
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, ? requestBody.mediaId.toString()
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, : undefined,
tmdbId:
requestBody.mediaType !== MediaType.MUSIC
? requestBody.mediaId
: undefined,
mediaType: requestBody.mediaType, mediaType: requestBody.mediaType,
}); });
} else { } else {
if (media.status === MediaStatus.BLACKLISTED) { if (media.status === MediaStatus.BLACKLISTED) {
logger.warn('Request for media blocked due to being 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, mediaType: requestBody.mediaType,
label: 'Media Request', label: 'Media Request',
}); });
@@ -152,18 +207,20 @@ export class MediaRequest {
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) { if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
media.status = MediaStatus.PENDING; media.status = MediaStatus.PENDING;
} }
if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) {
media.status4k = MediaStatus.PENDING;
}
} }
const existing = await requestRepository const existing = await requestRepository
.createQueryBuilder('request') .createQueryBuilder('request')
.leftJoin('request.media', 'media') .leftJoin('request.media', 'media')
.leftJoinAndSelect('request.requestedBy', 'user') .leftJoinAndSelect('request.requestedBy', 'user')
.where('request.is4k = :is4k', { is4k: requestBody.is4k }) .where(
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id }) requestBody.mediaType === MediaType.MUSIC
? 'media.mbId = :mbId'
: 'media.tmdbId = :tmdbId',
requestBody.mediaType === MediaType.MUSIC
? { mbId: requestBody.mediaId }
: { tmdbId: tmdbMedia.id }
)
.andWhere('media.mediaType = :mediaType', { .andWhere('media.mediaType = :mediaType', {
mediaType: requestBody.mediaType, mediaType: requestBody.mediaType,
}) })
@@ -172,14 +229,12 @@ export class MediaRequest {
if (existing && existing.length > 0) { if (existing && existing.length > 0) {
// If there is an existing movie request that isn't declined, don't allow a new one. // If there is an existing movie request that isn't declined, don't allow a new one.
if ( if (
requestBody.mediaType === MediaType.MOVIE && requestBody.mediaType === MediaType.MUSIC &&
existing[0].status !== MediaRequestStatus.DECLINED && existing[0].status !== MediaRequestStatus.DECLINED
existing[0].status !== MediaRequestStatus.COMPLETED
) { ) {
logger.warn('Duplicate request for media blocked', { logger.warn('Duplicate request for media blocked', {
tmdbId: tmdbMedia.id, mbId: requestBody.mediaId,
mediaType: requestBody.mediaType, mediaType: requestBody.mediaType,
is4k: requestBody.is4k,
label: 'Media Request', 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], { const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], {
type: 'or', type: 'or',
}); });
let rootFolder = requestBody.rootFolder;
let profileId = requestBody.profileId;
let tags = requestBody.tags;
if (useOverrides) { if (useOverrides) {
const defaultRadarrId = requestBody.is4k if (requestBody.mediaType !== MediaType.MUSIC) {
? settings.radarr.findIndex((r) => r.is4k && r.isDefault) 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.radarr.findIndex((r) => !r.is4k && r.isDefault);
? settings.sonarr.findIndex((s) => s.is4k && s.isDefault) const defaultSonarrId = requestBody.is4k
: settings.sonarr.findIndex((s) => !s.is4k && s.isDefault); ? settings.sonarr.findIndex((s) => s.is4k && s.isDefault)
: settings.sonarr.findIndex((s) => !s.is4k && s.isDefault);
const overrideRuleRepository = getRepository(OverrideRule); const overrideRuleRepository = getRepository(OverrideRule);
const overrideRules = await overrideRuleRepository.find({ const overrideRules = await overrideRuleRepository.find({
where: where:
requestBody.mediaType === MediaType.MOVIE requestBody.mediaType === MediaType.MOVIE
? { radarrServiceId: defaultRadarrId } ? { radarrServiceId: defaultRadarrId }
: { sonarrServiceId: defaultSonarrId }, : { sonarrServiceId: defaultSonarrId },
}); });
const appliedOverrideRules = overrideRules.filter((rule) => { const appliedOverrideRules = overrideRules.filter((rule) => {
const hasAnimeKeyword = if (isTmdbMedia(tmdbMedia)) {
'results' in tmdbMedia.keywords && if (
tmdbMedia.keywords.results.some( rule.language &&
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID !rule.language
); .split('|')
.some(
// Skip override rules if the media is an anime TV show as anime TV (languageId) => languageId === tmdbMedia.original_language
// is handled by default and override rules do not explicitly include )
// the anime keyword ) {
if ( return false;
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;
} }
return keywordList if (rule.keywords) {
.map((keyword: TmdbKeyword) => keyword.id) const keywordList =
.includes(Number(keywordId)); 'results' in tmdbMedia.keywords
}) ? tmdbMedia.keywords.results
) { : 'keywords' in tmdbMedia.keywords
return false; ? tmdbMedia.keywords.keywords
} : [];
return true;
});
// hacky way to prioritize rules if (
// TODO: make this better !rule.keywords
const prioritizedRule = appliedOverrideRules.sort((a, b) => { .split(',')
const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords']; .some((keywordId) =>
keywordList.map((k) => k.id).includes(Number(keywordId))
)
) {
return false;
}
}
const aSpecificity = keys.filter((key) => a[key] !== null).length; const hasAnimeKeyword =
const bSpecificity = keys.filter((key) => b[key] !== null).length; 'results' in tmdbMedia.keywords &&
tmdbMedia.keywords.results.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID
);
// Take the rule with the most specific condition first if (
return bSpecificity - aSpecificity; requestBody.mediaType === MediaType.TV &&
})[0]; hasAnimeKeyword &&
(!rule.keywords ||
!rule.keywords
.split(',')
.map(Number)
.includes(ANIME_KEYWORD_ID))
) {
return false;
}
}
if (prioritizedRule) { if (
if (prioritizedRule.rootFolder) { rule.users &&
rootFolder = prioritizedRule.rootFolder; !rule.users
} .split(',')
if (prioritizedRule.profileId) { .some((userId) => Number(userId) === requestUser.id)
profileId = prioritizedRule.profileId; ) {
} return false;
if (prioritizedRule.tags) { }
tags = [
...new Set([
...(tags || []),
...prioritizedRule.tags.split(',').map((tag) => Number(tag)),
]),
];
}
logger.debug('Override rule applied.', { return true;
label: 'Media Request',
overrides: prioritizedRule,
}); });
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, : undefined,
is4k: requestBody.is4k, is4k: requestBody.is4k,
serverId: requestBody.serverId, serverId: requestBody.serverId,
profileId: profileId, profileId: prioritizedRule?.profileId ?? requestBody.profileId,
rootFolder: rootFolder, rootFolder: prioritizedRule?.rootFolder ?? requestBody.rootFolder,
tags: tags, tags: prioritizedRule?.tags
? [
...new Set([
...(requestBody.tags || []),
...prioritizedRule.tags.split(',').map(Number),
]),
]
: requestBody.tags,
isAutoRequest: options.isAutoRequest ?? false, isAutoRequest: options.isAutoRequest ?? false,
}); });
await requestRepository.save(request); await requestRepository.save(request);
return request; return request;
} else { } else if (requestBody.mediaType === MediaType.TV) {
const tmdbMediaShow = tmdbMedia as Awaited< const tmdbMediaShow = tmdbMedia as Awaited<
ReturnType<typeof tmdb.getTvShow> ReturnType<typeof tmdb.getTvShow>
>; >;
let requestedSeasons = const requestedSeasons =
requestBody.seasons === 'all' requestBody.seasons === 'all'
? tmdbMediaShow.seasons ? tmdbMediaShow.seasons
.filter((season) => season.season_number !== 0) .filter((season) => season.season_number !== 0)
.map((season) => season.season_number) .map((season) => season.season_number)
: (requestBody.seasons as number[]); : (requestBody.seasons as number[]);
if (!settings.main.enableSpecialEpisodes) {
requestedSeasons = requestedSeasons.filter((sn) => sn > 0);
}
let existingSeasons: number[] = []; let existingSeasons: number[] = [];
// We need to check existing requests on this title to make sure we don't double up on seasons that were // We need to check existing requests on this title to make sure we don't double up on seasons that were
@@ -477,10 +520,10 @@ export class MediaRequest {
: undefined, : undefined,
is4k: requestBody.is4k, is4k: requestBody.is4k,
serverId: requestBody.serverId, serverId: requestBody.serverId,
profileId: profileId, profileId: requestBody.profileId,
rootFolder: rootFolder, rootFolder: requestBody.rootFolder,
languageProfileId: requestBody.languageProfileId, languageProfileId: requestBody.languageProfileId,
tags: tags, tags: requestBody.tags,
seasons: finalSeasons.map( seasons: finalSeasons.map(
(sn) => (sn) =>
new SeasonRequest({ new SeasonRequest({
@@ -504,6 +547,42 @@ export class MediaRequest {
isAutoRequest: options.isAutoRequest ?? false, isAutoRequest: options.isAutoRequest ?? false,
}); });
await requestRepository.save(request);
return request;
} else {
await mediaRepository.save(media);
const request = new MediaRequest({
type: MediaType.MUSIC,
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); await requestRepository.save(request);
return request; return request;
} }
@@ -715,6 +794,10 @@ export class MediaRequest {
type: Notification type: Notification
) { ) {
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
const lidarr = new LidarrAPI({
apiKey: getSettings().lidarr[0].apiKey,
url: LidarrAPI.buildUrl(getSettings().lidarr[0], '/api/v1'),
});
try { try {
const mediaType = entity.type === MediaType.MOVIE ? 'Movie' : 'Series'; 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) { } catch (e) {
logger.error('Something went wrong sending media notification(s)', { logger.error('Something went wrong sending media notification(s)', {

View File

@@ -124,6 +124,12 @@ export class User {
@Column({ nullable: true }) @Column({ nullable: true })
public tvQuotaDays?: number; public tvQuotaDays?: number;
@Column({ nullable: true })
public musicQuotaLimit?: number;
@Column({ nullable: true })
public musicQuotaDays?: number;
@OneToOne(() => UserSettings, (settings) => settings.user, { @OneToOne(() => UserSettings, (settings) => settings.user, {
cascade: true, cascade: true,
eager: true, eager: true,
@@ -334,6 +340,30 @@ export class User {
).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0) ).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0)
: 0; : 0;
const musicQuotaLimit = !canBypass
? this.musicQuotaLimit ?? defaultQuotas.music.quotaLimit
: 0;
const musicQuotaDays = this.musicQuotaDays ?? defaultQuotas.music.quotaDays;
// 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 { return {
movie: { movie: {
days: movieQuotaDays, days: movieQuotaDays,
@@ -357,6 +387,18 @@ export class User {
restricted: restricted:
tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false, tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false,
}, },
music: {
days: musicQuotaDays,
limit: musicQuotaLimit,
used: musicQuotaUsed,
remaining: musicQuotaLimit
? Math.max(0, musicQuotaLimit - musicQuotaUsed)
: undefined,
restricted:
musicQuotaLimit && musicQuotaLimit - musicQuotaUsed <= 0
? true
: false,
},
}; };
} }
} }

View File

@@ -26,6 +26,7 @@ export class NotFoundError extends Error {
@Entity() @Entity()
@Unique('UNIQUE_USER_DB', ['tmdbId', 'requestedBy']) @Unique('UNIQUE_USER_DB', ['tmdbId', 'requestedBy'])
@Unique('UNIQUE_USER_FOREIGN', ['mbId', 'requestedBy'])
export class Watchlist implements WatchlistItem { export class Watchlist implements WatchlistItem {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;
@@ -39,9 +40,13 @@ export class Watchlist implements WatchlistItem {
@Column({ type: 'varchar' }) @Column({ type: 'varchar' })
title = ''; title = '';
@Column() @Column({ nullable: true })
@Index() @Index()
public tmdbId: number; public tmdbId?: number;
@Column({ nullable: true })
@Index()
public mbId?: string;
@ManyToOne(() => User, (user) => user.watchlists, { @ManyToOne(() => User, (user) => user.watchlists, {
eager: true, eager: true,
@@ -52,6 +57,7 @@ export class Watchlist implements WatchlistItem {
@ManyToOne(() => Media, (media) => media.watchlists, { @ManyToOne(() => Media, (media) => media.watchlists, {
eager: true, eager: true,
onDelete: 'CASCADE', onDelete: 'CASCADE',
nullable: false,
}) })
public media: Media; public media: Media;
@@ -77,7 +83,8 @@ export class Watchlist implements WatchlistItem {
mediaType: MediaType; mediaType: MediaType;
ratingKey?: ZodOptional<ZodString>['_output']; ratingKey?: ZodOptional<ZodString>['_output'];
title?: ZodOptional<ZodString>['_output']; title?: ZodOptional<ZodString>['_output'];
tmdbId: ZodNumber['_output']; tmdbId?: ZodNumber['_output'];
mbId?: ZodOptional<ZodString>['_output'];
}; };
user: User; user: User;
}): Promise<Watchlist> { }): Promise<Watchlist> {
@@ -85,46 +92,88 @@ export class Watchlist implements WatchlistItem {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
const tmdbMedia = let media: Media | null;
watchlistRequest.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: watchlistRequest.tmdbId })
: await tmdb.getTvShow({ tvId: watchlistRequest.tmdbId });
const existing = await watchlistRepository if (watchlistRequest.mediaType === MediaType.MUSIC) {
.createQueryBuilder('watchlist') if (!watchlistRequest.mbId) {
.leftJoinAndSelect('watchlist.requestedBy', 'user') throw new Error('MusicBrainz ID is required for music media type');
.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) { const existing = await watchlistRepository
logger.warn('Duplicate request for watchlist blocked', { .createQueryBuilder('watchlist')
tmdbId: watchlistRequest.tmdbId, .leftJoinAndSelect('watchlist.requestedBy', 'user')
mediaType: watchlistRequest.mediaType, .where('user.id = :userId', { userId: user.id })
label: 'Watchlist', .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({ const tmdbMedia =
where: { watchlistRequest.mediaType === MediaType.MOVIE
tmdbId: watchlistRequest.tmdbId, ? await tmdb.getMovie({ movieId: watchlistRequest.tmdbId })
mediaType: watchlistRequest.mediaType, : await tmdb.getTvShow({ tvId: watchlistRequest.tmdbId });
},
});
if (!media) { const existing = await watchlistRepository
media = new Media({ .createQueryBuilder('watchlist')
tmdbId: tmdbMedia.id, .leftJoinAndSelect('watchlist.requestedBy', 'user')
tvdbId: tmdbMedia.external_ids.tvdb_id, .where('user.id = :userId', { userId: user.id })
mediaType: watchlistRequest.mediaType, .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({ const watchlist = new this({
@@ -139,14 +188,19 @@ export class Watchlist implements WatchlistItem {
} }
public static async deleteWatchlist( public static async deleteWatchlist(
tmdbId: Watchlist['tmdbId'], id: Watchlist['tmdbId'] | Watchlist['mbId'],
user: User user: User
): Promise<Watchlist | null> { ): Promise<Watchlist | null> {
const watchlistRepository = getRepository(this); const watchlistRepository = getRepository(this);
const watchlist = await watchlistRepository.findOneBy({
tmdbId, // Check if the ID is a number (TMDB) or string (MusicBrainz)
requestedBy: { id: user.id }, 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) { if (!watchlist) {
throw new NotFoundError('not Found'); throw new NotFoundError('not Found');
} }

View File

@@ -22,7 +22,10 @@ import logger from '@server/logger';
import clearCookies from '@server/middleware/clearcookies'; import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes'; import routes from '@server/routes';
import avatarproxy from '@server/routes/avatarproxy'; 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 { appDataPermissions } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion'; import { getAppVersion } from '@server/utils/appVersion';
import createCustomProxyAgent from '@server/utils/customProxyAgent'; import createCustomProxyAgent from '@server/utils/customProxyAgent';
@@ -235,8 +238,11 @@ app
server.use('/api/v1', routes); server.use('/api/v1', routes);
// Do not set cookies so CDNs can cache them // 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('/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.get('*', (req, res) => handle(req, res));
server.use( server.use(

View File

@@ -2,8 +2,9 @@ import type { User } from '@server/entity/User';
import type { PaginatedResponse } from '@server/interfaces/api/common'; import type { PaginatedResponse } from '@server/interfaces/api/common';
export interface BlacklistItem { export interface BlacklistItem {
tmdbId: number; tmdbId?: number;
mediaType: 'movie' | 'tv'; mbId?: string;
mediaType: 'movie' | 'tv' | 'music';
title?: string; title?: string;
createdAt?: Date; createdAt?: Date;
user?: User; user?: User;

View File

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

View File

@@ -4,7 +4,7 @@ import type { LanguageProfile } from '@server/api/servarr/sonarr';
export interface ServiceCommonServer { export interface ServiceCommonServer {
id: number; id: number;
name: string; name: string;
is4k: boolean; is4k?: boolean;
isDefault: boolean; isDefault: boolean;
activeProfileId: number; activeProfileId: number;
activeDirectory: string; activeDirectory: string;

View File

@@ -64,7 +64,7 @@ export interface CacheItem {
export interface CacheResponse { export interface CacheResponse {
apiCaches: CacheItem[]; apiCaches: CacheItem[];
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>; imageCache: Record<'tmdb' | 'avatar' | 'caa' | 'lidarr' | 'fanart', { size: number; imageCount: number }>;
dnsCache: { dnsCache: {
stats: DnsStats | undefined; stats: DnsStats | undefined;
entries: DnsEntries | undefined; entries: DnsEntries | undefined;

View File

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

View File

@@ -3,7 +3,8 @@ import { z } from 'zod';
export const watchlistCreate = z.object({ export const watchlistCreate = z.object({
ratingKey: z.coerce.string().optional(), ratingKey: z.coerce.string().optional(),
tmdbId: z.coerce.number(), tmdbId: z.coerce.number().optional(),
mbId: z.coerce.string().optional(),
mediaType: z.nativeEnum(MediaType), mediaType: z.nativeEnum(MediaType),
title: z.coerce.string().optional(), title: z.coerce.string().optional(),
}); });

View File

@@ -8,6 +8,7 @@ import {
jellyfinFullScanner, jellyfinFullScanner,
jellyfinRecentScanner, jellyfinRecentScanner,
} from '@server/lib/scanners/jellyfin'; } from '@server/lib/scanners/jellyfin';
import { lidarrScanner } from '@server/lib/scanners/lidarr';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex'; import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
import { radarrScanner } from '@server/lib/scanners/radarr'; import { radarrScanner } from '@server/lib/scanners/radarr';
import { sonarrScanner } from '@server/lib/scanners/sonarr'; import { sonarrScanner } from '@server/lib/scanners/sonarr';
@@ -172,6 +173,21 @@ export const startJobs = (): void => {
cancelFn: () => sonarrScanner.cancel(), 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 // Checks if media is still available in plex/sonarr/radarr libs
scheduledJobs.push({ scheduledJobs.push({
id: 'availability-sync', id: 'availability-sync',

View File

@@ -2,6 +2,7 @@ import type { JellyfinLibraryItem } from '@server/api/jellyfin';
import JellyfinAPI from '@server/api/jellyfin'; import JellyfinAPI from '@server/api/jellyfin';
import type { PlexMetadata } from '@server/api/plexapi'; import type { PlexMetadata } from '@server/api/plexapi';
import PlexAPI 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 RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr';
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr'; import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI 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 MediaRequest from '@server/entity/MediaRequest';
import type Season from '@server/entity/Season'; import type Season from '@server/entity/Season';
import { User } from '@server/entity/User'; 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 { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { getHostname } from '@server/utils/getHostname'; import { getHostname } from '@server/utils/getHostname';
@@ -28,6 +33,7 @@ class AvailabilitySync {
private sonarrSeasonsCache: Record<string, SonarrSeason[]>; private sonarrSeasonsCache: Record<string, SonarrSeason[]>;
private radarrServers: RadarrSettings[]; private radarrServers: RadarrSettings[];
private sonarrServers: SonarrSettings[]; private sonarrServers: SonarrSettings[];
private lidarrServers: LidarrSettings[];
async run() { async run() {
const settings = getSettings(); const settings = getSettings();
@@ -38,6 +44,7 @@ class AvailabilitySync {
this.sonarrSeasonsCache = {}; this.sonarrSeasonsCache = {};
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled); this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled); this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
this.lidarrServers = settings.lidarr.filter((server) => server.syncEnabled);
try { try {
logger.info(`Starting availability sync...`, { 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) { } catch (ex) {
logger.error('Failed to complete availability sync.', { logger.error('Failed to complete availability sync.', {
@@ -558,11 +606,23 @@ class AvailabilitySync {
? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] ? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId']
: null; : null;
} }
// Update log message to include music media type
logger.info( logger.info(
`The ${is4k ? '4K' : 'non-4K'} ${ `The ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'movie' ? 'movie' : 'show' media.mediaType === 'movie'
} [TMDB ID ${media.tmdbId}] was not found in any ${ ? 'movie'
media.mediaType === 'movie' ? 'Radarr' : 'Sonarr' : 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 ${ } and ${
mediaServerType === MediaServerType.PLEX mediaServerType === MediaServerType.PLEX
? 'plex' ? 'plex'
@@ -577,8 +637,14 @@ class AvailabilitySync {
} catch (ex) { } catch (ex) {
logger.debug( logger.debug(
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${ `Failure updating the ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'tv' ? 'show' : 'movie' media.mediaType === 'movie'
} [TMDB ID ${media.tmdbId}].`, ? 'movie'
: media.mediaType === 'tv'
? 'show'
: 'album'
} [${media.mediaType === 'music' ? 'Foreign ID' : 'TMDB ID'} ${
media.mediaType === 'music' ? media.mbId : media.tmdbId
}].`,
{ {
errorMessage: ex.message, errorMessage: ex.message,
label: 'Availability Sync', label: 'Availability Sync',
@@ -838,6 +904,51 @@ class AvailabilitySync {
return seasonExists; return seasonExists;
} }
private async mediaExistsInLidarr(media: Media): Promise<boolean> {
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 // Plex
private async mediaExistsInPlex( private async mediaExistsInPlex(
media: Media, media: Media,
@@ -881,8 +992,14 @@ class AvailabilitySync {
preventSeasonSearch = true; preventSeasonSearch = true;
logger.debug( logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${ `Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'tv' ? 'show' : 'movie' media.mediaType === 'movie'
} [TMDB ID ${media.tmdbId}] from Plex.`, ? '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, errorMessage: ex.message,
label: 'Availability Sync', label: 'Availability Sync',
@@ -993,13 +1110,19 @@ class AvailabilitySync {
existsInJellyfin = true; existsInJellyfin = true;
} }
} catch (ex) { } catch (ex) {
if (!ex.message.includes('404' || '500')) { if (!ex.message.includes('404') && !ex.message.includes('500')) {
existsInJellyfin = false; existsInJellyfin = false;
preventSeasonSearch = true; preventSeasonSearch = true;
logger.debug( logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${ `Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'tv' ? 'show' : 'movie' media.mediaType === 'movie'
} [TMDB ID ${media.tmdbId}] from Jellyfin.`, ? '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, errorMessage: ex.message,
label: 'AvailabilitySync', label: 'AvailabilitySync',

View File

@@ -2,8 +2,12 @@ import NodeCache from 'node-cache';
export type AvailableCacheIds = export type AvailableCacheIds =
| 'tmdb' | 'tmdb'
| 'musicbrainz'
| 'listenbrainz'
| 'covertartarchive'
| 'radarr' | 'radarr'
| 'sonarr' | 'sonarr'
| 'lidarr'
| 'rt' | 'rt'
| 'imdb' | 'imdb'
| 'github' | 'github'
@@ -48,8 +52,21 @@ class CacheManager {
stdTtl: 21600, stdTtl: 21600,
checkPeriod: 60 * 30, 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'), radarr: new Cache('radarr', 'Radarr API'),
sonarr: new Cache('sonarr', 'Sonarr API'), sonarr: new Cache('sonarr', 'Sonarr API'),
lidarr: new Cache('lidarr', 'Lidarr API'),
rt: new Cache('rt', 'Rotten Tomatoes API', { rt: new Cache('rt', 'Rotten Tomatoes API', {
stdTtl: 43200, stdTtl: 43200,
checkPeriod: 60 * 30, checkPeriod: 60 * 30,

View File

@@ -1,3 +1,4 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import RadarrAPI from '@server/api/servarr/radarr'; import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
@@ -27,6 +28,7 @@ export interface DownloadingItem {
class DownloadTracker { class DownloadTracker {
private radarrServers: Record<number, DownloadingItem[]> = {}; private radarrServers: Record<number, DownloadingItem[]> = {};
private sonarrServers: Record<number, DownloadingItem[]> = {}; private sonarrServers: Record<number, DownloadingItem[]> = {};
private lidarrServers: Record<number, DownloadingItem[]> = {};
public getMovieProgress( public getMovieProgress(
serverId: number, serverId: number,
@@ -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() { public async resetDownloadTracker() {
this.radarrServers = {}; this.radarrServers = {};
this.sonarrServers = {}; this.sonarrServers = {};
@@ -62,6 +77,7 @@ class DownloadTracker {
public updateDownloads() { public updateDownloads() {
this.updateRadarrDownloads(); this.updateRadarrDownloads();
this.updateSonarrDownloads(); this.updateSonarrDownloads();
this.updateLidarrDownloads();
} }
private async updateRadarrDownloads() { 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(); const downloadTracker = new DownloadTracker();

View File

@@ -329,7 +329,7 @@ class ImageProxy {
}); });
await promises.mkdir(dir, { recursive: true }); await promises.mkdir(dir, { recursive: true });
await promises.writeFile(filename, buffer); await promises.writeFile(filename, new Uint8Array(buffer));
} }
private getCacheKey(path: string) { private getCacheKey(path: string) {
@@ -340,7 +340,9 @@ class ImageProxy {
const hash = createHash('sha256'); const hash = createHash('sha256');
for (const item of items) { for (const item of items) {
if (typeof item === 'number') hash.update(String(item)); if (typeof item === 'number') hash.update(String(item));
else { else if (Buffer.isBuffer(item)) {
hash.update(item.toString());
} else {
hash.update(item); hash.update(item);
} }
} }

View File

@@ -9,26 +9,29 @@ export enum Permission {
AUTO_APPROVE = 128, AUTO_APPROVE = 128,
AUTO_APPROVE_MOVIE = 256, AUTO_APPROVE_MOVIE = 256,
AUTO_APPROVE_TV = 512, AUTO_APPROVE_TV = 512,
REQUEST_4K = 1024, AUTO_APPROVE_MUSIC = 1024,
REQUEST_4K_MOVIE = 2048, REQUEST_4K = 2048,
REQUEST_4K_TV = 4096, REQUEST_4K_MOVIE = 4096,
REQUEST_ADVANCED = 8192, REQUEST_4K_TV = 8192,
REQUEST_VIEW = 16384, REQUEST_ADVANCED = 16384,
AUTO_APPROVE_4K = 32768, REQUEST_VIEW = 32768,
AUTO_APPROVE_4K_MOVIE = 65536, AUTO_APPROVE_4K = 65536,
AUTO_APPROVE_4K_TV = 131072, AUTO_APPROVE_4K_MOVIE = 131072,
REQUEST_MOVIE = 262144, AUTO_APPROVE_4K_TV = 262144,
REQUEST_TV = 524288, REQUEST_MOVIE = 524288,
MANAGE_ISSUES = 1048576, REQUEST_TV = 1048576,
VIEW_ISSUES = 2097152, REQUEST_MUSIC = 2097152,
CREATE_ISSUES = 4194304, AUTO_REQUEST = 4194304,
AUTO_REQUEST = 8388608, AUTO_REQUEST_MOVIE = 8388608,
AUTO_REQUEST_MOVIE = 16777216, AUTO_REQUEST_TV = 16777216,
AUTO_REQUEST_TV = 33554432, AUTO_REQUEST_MUSIC = 33554432,
RECENT_VIEW = 67108864, MANAGE_ISSUES = 67108864,
WATCHLIST_VIEW = 134217728, VIEW_ISSUES = 134217728,
MANAGE_BLACKLIST = 268435456, CREATE_ISSUES = 268435456,
VIEW_BLACKLIST = 1073741824, RECENT_VIEW = 536870912,
WATCHLIST_VIEW = 1073741824,
MANAGE_BLACKLIST = 2147483648,
VIEW_BLACKLIST = 4294967296,
} }
export interface PermissionCheckOptions { export interface PermissionCheckOptions {

View File

@@ -28,6 +28,7 @@ export interface MediaIds {
imdbId?: string; imdbId?: string;
tvdbId?: number; tvdbId?: number;
isHama?: boolean; isHama?: boolean;
mbId?: string;
} }
interface ProcessOptions { interface ProcessOptions {
@@ -79,11 +80,24 @@ class BaseScanner<T> {
this.updateRate = updateRate ?? UPDATE_RATE; this.updateRate = updateRate ?? UPDATE_RATE;
} }
private async getExisting(tmdbId: number, mediaType: MediaType) { private async getExisting(
id: number | string,
mediaType: MediaType
): Promise<Media | null> {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
const query: Record<string, any> = {
mediaType,
};
if (mediaType === MediaType.MUSIC) {
query.mbId = id.toString();
} else {
query.tmdbId = Number(id);
}
const existing = await mediaRepository.findOne({ const existing = await mediaRepository.findOne({
where: { tmdbId: tmdbId, mediaType }, where: query,
}); });
return existing; return existing;
@@ -526,6 +540,61 @@ class BaseScanner<T> {
}); });
} }
protected async processMusic(
mbId: string,
{
serviceId,
externalServiceId,
externalServiceSlug,
mediaAddedAt,
processing = false,
title = 'Unknown Title',
}: ProcessOptions = {}
): Promise<void> {
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 * Call startRun from child class whenever a run is starting to
* ensure required values are set * ensure required values are set

View File

@@ -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[]) { private async processItems(slicedItems: JellyfinLibraryItem[]) {
this.processedAnidbSeason = new Map(); this.processedAnidbSeason = new Map();
await Promise.all( await Promise.all(
@@ -699,6 +783,8 @@ class JellyfinScanner {
await this.processMovie(item); await this.processMovie(item);
} else if (item.Type === 'Series') { } else if (item.Type === 'Series') {
await this.processShow(item); await this.processShow(item);
} else if (item.Type === 'MusicAlbum') {
await this.processMusic(item);
} }
}) })
); );

View File

@@ -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<LidarrAlbum>
implements RunnableScanner<SyncStatus>
{
private servers: LidarrSettings[];
private currentServer: LidarrSettings;
private lidarrApi: LidarrAPI;
constructor() {
super('Lidarr Scan', { bundleSize: 50 });
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentServer: this.currentServer,
servers: this.servers,
};
}
public async run(): Promise<void> {
const settings = getSettings();
const sessionId = this.startRun();
try {
// 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<void> {
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();

View File

@@ -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 TheMovieDb from '@server/api/themoviedb';
import type { import type {
TmdbCollectionResult,
TmdbMovieDetails, TmdbMovieDetails,
TmdbMovieResult, TmdbMovieResult,
TmdbPersonDetails, TmdbPersonDetails,
TmdbPersonResult, TmdbPersonResult,
TmdbSearchMovieResponse, TmdbSearchMovieResponse,
TmdbSearchMultiResponse,
TmdbSearchTvResponse, TmdbSearchTvResponse,
TmdbTvDetails, TmdbTvDetails,
TmdbTvResult, TmdbTvResult,
@@ -21,6 +26,19 @@ import {
isTvDetails, isTvDetails,
} from '@server/utils/typeHelpers'; } from '@server/utils/typeHelpers';
export type CombinedSearchResponse = {
page: number;
total_pages: number;
total_results: number;
results: (
| MbArtistResult
| MbAlbumResult
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
)[];
};
interface SearchProvider { interface SearchProvider {
pattern: RegExp; pattern: RegExp;
search: ({ search: ({
@@ -31,7 +49,7 @@ interface SearchProvider {
id: string; id: string;
language?: string; language?: string;
query?: string; query?: string;
}) => Promise<TmdbSearchMultiResponse>; }) => Promise<CombinedSearchResponse>;
} }
const searchProviders: SearchProvider[] = []; 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: [],
};
}
},
});

View File

@@ -11,7 +11,7 @@ export interface Library {
id: string; id: string;
name: string; name: string;
enabled: boolean; enabled: boolean;
type: 'show' | 'movie'; type: 'show' | 'movie' | 'music';
lastScan?: number; lastScan?: number;
} }
@@ -83,6 +83,17 @@ export interface RadarrSettings extends DVRSettings {
minimumAvailability: string; 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 { export interface SonarrSettings extends DVRSettings {
seriesType: 'standard' | 'daily' | 'anime'; seriesType: 'standard' | 'daily' | 'anime';
animeSeriesType: 'standard' | 'daily' | 'anime'; animeSeriesType: 'standard' | 'daily' | 'anime';
@@ -130,6 +141,7 @@ export interface MainSettings {
defaultQuotas: { defaultQuotas: {
movie: Quota; movie: Quota;
tv: Quota; tv: Quota;
music: Quota;
}; };
hideAvailable: boolean; hideAvailable: boolean;
hideBlacklisted: boolean; hideBlacklisted: boolean;
@@ -340,6 +352,7 @@ export type JobId =
| 'plex-refresh-token' | 'plex-refresh-token'
| 'radarr-scan' | 'radarr-scan'
| 'sonarr-scan' | 'sonarr-scan'
| 'lidarr-scan'
| 'download-sync' | 'download-sync'
| 'download-sync-reset' | 'download-sync-reset'
| 'jellyfin-recently-added-scan' | 'jellyfin-recently-added-scan'
@@ -358,6 +371,7 @@ export interface AllSettings {
tautulli: TautulliSettings; tautulli: TautulliSettings;
radarr: RadarrSettings[]; radarr: RadarrSettings[];
sonarr: SonarrSettings[]; sonarr: SonarrSettings[];
lidarr: LidarrSettings[];
public: PublicSettings; public: PublicSettings;
notifications: NotificationSettings; notifications: NotificationSettings;
jobs: Record<JobId, JobSettings>; jobs: Record<JobId, JobSettings>;
@@ -387,6 +401,7 @@ class Settings {
defaultQuotas: { defaultQuotas: {
movie: {}, movie: {},
tv: {}, tv: {},
music: {},
}, },
hideAvailable: false, hideAvailable: false,
hideBlacklisted: false, hideBlacklisted: false,
@@ -429,6 +444,7 @@ class Settings {
anime: MetadataProviderType.TMDB, anime: MetadataProviderType.TMDB,
}, },
radarr: [], radarr: [],
lidarr: [],
sonarr: [], sonarr: [],
public: { public: {
initialized: false, initialized: false,
@@ -552,6 +568,9 @@ class Settings {
'sonarr-scan': { 'sonarr-scan': {
schedule: '0 30 4 * * *', schedule: '0 30 4 * * *',
}, },
'lidarr-scan': {
schedule: '0 30 4 * * *',
},
'availability-sync': { 'availability-sync': {
schedule: '0 0 5 * * *', schedule: '0 0 5 * * *',
}, },
@@ -649,6 +668,14 @@ class Settings {
this.data.radarr = data; this.data.radarr = data;
} }
get lidarr(): LidarrSettings[] {
return this.data.lidarr;
}
set lidarr(data: LidarrSettings[]) {
this.data.lidarr = data;
}
get sonarr(): SonarrSettings[] { get sonarr(): SonarrSettings[] {
return this.data.sonarr; return this.data.sonarr;
} }

View File

@@ -0,0 +1,125 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMusicSupport1714310036946 implements MigrationInterface {
name = 'AddMusicSupport1714310036946';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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")`
);
}
}

53
server/models/Artist.ts Normal file
View File

@@ -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: [],
})),
});

130
server/models/Music.ts Normal file
View File

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

View File

@@ -20,6 +20,7 @@ export interface PersonDetails {
adult: boolean; adult: boolean;
imdbId?: string; imdbId?: string;
homepage?: string; homepage?: string;
mbArtistId?: string;
} }
export interface PersonCredit { export interface PersonCredit {

View File

@@ -1,3 +1,10 @@
import type {
MbAlbumDetails,
MbAlbumResult,
MbArtistDetails,
MbArtistResult,
MbImage,
} from '@server/api/musicbrainz/interfaces';
import type { import type {
TmdbCollectionResult, TmdbCollectionResult,
TmdbMovieDetails, TmdbMovieDetails,
@@ -9,10 +16,15 @@ import type {
} from '@server/api/themoviedb/interfaces'; } from '@server/api/themoviedb/interfaces';
import { MediaType as MainMediaType } from '@server/constants/media'; import { MediaType as MainMediaType } from '@server/constants/media';
import type Media from '@server/entity/Media'; import type Media from '@server/entity/Media';
export type MediaType =
| 'tv'
| 'movie'
| 'person'
| 'collection'
| 'artist'
| 'album';
export type MediaType = 'tv' | 'movie' | 'person' | 'collection'; interface TmdbSearchResult {
interface SearchResult {
id: number; id: number;
mediaType: MediaType; mediaType: MediaType;
popularity: number; popularity: number;
@@ -26,7 +38,14 @@ interface SearchResult {
mediaInfo?: Media; mediaInfo?: Media;
} }
export interface MovieResult extends SearchResult { interface MbSearchResult {
id: string;
mediaType: MediaType;
score: number;
mediaInfo?: Media;
}
export interface MovieResult extends TmdbSearchResult {
mediaType: 'movie'; mediaType: 'movie';
title: string; title: string;
originalTitle: string; originalTitle: string;
@@ -36,7 +55,7 @@ export interface MovieResult extends SearchResult {
mediaInfo?: Media; mediaInfo?: Media;
} }
export interface TvResult extends SearchResult { export interface TvResult extends TmdbSearchResult {
mediaType: 'tv'; mediaType: 'tv';
name: string; name: string;
originalName: string; originalName: string;
@@ -66,7 +85,46 @@ export interface PersonResult {
knownFor: (MovieResult | TvResult)[]; 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 = ( export const mapMovieResult = (
movieResult: TmdbMovieResult, 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: ( results: (
| TmdbMovieResult | TmdbMovieResult
| TmdbTvResult | TmdbTvResult
| TmdbPersonResult | TmdbPersonResult
| TmdbCollectionResult | TmdbCollectionResult
| MbArtistResult
| MbAlbumResult
)[], )[],
media?: Media[] media?: Media[]
): Results[] => ): Promise<Results[]> =>
results.map((result) => { Promise.all(
switch (result.media_type) { results.map(async (result) => {
case 'movie': if (isTmdbMovie(result)) {
return mapMovieResult( return mapMovieResult(
result, result,
media?.find( media?.find(
@@ -163,7 +334,7 @@ export const mapSearchResults = (
req.tmdbId === result.id && req.mediaType === MainMediaType.MOVIE req.tmdbId === result.id && req.mediaType === MainMediaType.MOVIE
) )
); );
case 'tv': } else if (isTmdbTv(result)) {
return mapTvResult( return mapTvResult(
result, result,
media?.find( media?.find(
@@ -171,12 +342,25 @@ export const mapSearchResults = (
req.tmdbId === result.id && req.mediaType === MainMediaType.TV req.tmdbId === result.id && req.mediaType === MainMediaType.TV
) )
); );
case 'collection': } else if (isTmdbPerson(result)) {
return mapCollectionResult(result);
default:
return mapPersonResult(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 = ( export const mapMovieDetailsToResult = (
movieDetails: TmdbMovieDetails movieDetails: TmdbMovieDetails
@@ -228,3 +412,39 @@ export const mapPersonDetailsToResult = (
profile_path: personDetails.profile_path, profile_path: personDetails.profile_path,
known_for: [], 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 || '',
});

View File

@@ -13,7 +13,8 @@ import { z } from 'zod';
const blacklistRoutes = Router(); const blacklistRoutes = Router();
export const blacklistAdd = z.object({ export const blacklistAdd = z.object({
tmdbId: z.coerce.number(), tmdbId: z.coerce.number().optional(),
mbId: z.string().optional(),
mediaType: z.nativeEnum(MediaType), mediaType: z.nativeEnum(MediaType),
title: z.coerce.string().optional(), title: z.coerce.string().optional(),
user: z.coerce.number(), user: z.coerce.number(),
@@ -90,10 +91,12 @@ blacklistRoutes.get(
}), }),
async (req, res, next) => { async (req, res, next) => {
try { try {
const blacklisteRepository = getRepository(Blacklist); const blacklistRepository = getRepository(Blacklist);
const blacklistItem = await blacklisteRepository.findOneOrFail({ const blacklistItem = await blacklistRepository.findOneOrFail({
where: { tmdbId: Number(req.params.id) }, where: !isNaN(Number(req.params.id))
? { tmdbId: Number(req.params.id) }
: { mbId: req.params.id },
}); });
return res.status(200).send(blacklistItem); return res.status(200).send(blacklistItem);
@@ -135,6 +138,7 @@ blacklistRoutes.post(
default: default:
logger.warn('Something wrong with data blacklist', { logger.warn('Something wrong with data blacklist', {
tmdbId: req.body.tmdbId, tmdbId: req.body.tmdbId,
mbId: req.body.mbId,
mediaType: req.body.mediaType, mediaType: req.body.mediaType,
label: 'Blacklist', label: 'Blacklist',
}); });
@@ -154,18 +158,22 @@ blacklistRoutes.delete(
}), }),
async (req, res, next) => { async (req, res, next) => {
try { try {
const blacklisteRepository = getRepository(Blacklist); const blacklistRepository = getRepository(Blacklist);
const blacklistItem = await blacklisteRepository.findOneOrFail({ const blacklistItem = await blacklistRepository.findOneOrFail({
where: { tmdbId: Number(req.params.id) }, 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 mediaRepository = getRepository(Media);
const mediaItem = await mediaRepository.findOneOrFail({ 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); await mediaRepository.remove(mediaItem);

35
server/routes/caaproxy.ts Normal file
View File

@@ -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;

View File

@@ -1,3 +1,5 @@
import ListenBrainzAPI from '@server/api/listenbrainz';
import MusicBrainz from '@server/api/musicbrainz';
import PlexTvAPI from '@server/api/plextv'; import PlexTvAPI from '@server/api/plextv';
import type { SortOptions } from '@server/api/themoviedb'; import type { SortOptions } from '@server/api/themoviedb';
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb';
@@ -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<Record<string, unknown>, WatchlistResponse>( discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
'/watchlist', '/watchlist',
async (req, res) => { async (req, res) => {

View File

@@ -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;

165
server/routes/group.ts Normal file
View File

@@ -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;

View File

@@ -31,10 +31,12 @@ import authRoutes from './auth';
import blacklistRoutes from './blacklist'; import blacklistRoutes from './blacklist';
import collectionRoutes from './collection'; import collectionRoutes from './collection';
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover'; import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
import groupRoutes from './group';
import issueRoutes from './issue'; import issueRoutes from './issue';
import issueCommentRoutes from './issueComment'; import issueCommentRoutes from './issueComment';
import mediaRoutes from './media'; import mediaRoutes from './media';
import movieRoutes from './movie'; import movieRoutes from './movie';
import musicRoutes from './music';
import personRoutes from './person'; import personRoutes from './person';
import requestRoutes from './request'; import requestRoutes from './request';
import searchRoutes from './search'; import searchRoutes from './search';
@@ -154,8 +156,10 @@ router.use('/watchlist', isAuthenticated(), watchlistRoutes);
router.use('/blacklist', isAuthenticated(), blacklistRoutes); router.use('/blacklist', isAuthenticated(), blacklistRoutes);
router.use('/movie', isAuthenticated(), movieRoutes); router.use('/movie', isAuthenticated(), movieRoutes);
router.use('/tv', isAuthenticated(), tvRoutes); router.use('/tv', isAuthenticated(), tvRoutes);
router.use('/music', isAuthenticated(), musicRoutes);
router.use('/media', isAuthenticated(), mediaRoutes); router.use('/media', isAuthenticated(), mediaRoutes);
router.use('/person', isAuthenticated(), personRoutes); router.use('/person', isAuthenticated(), personRoutes);
router.use('/group', isAuthenticated(), groupRoutes);
router.use('/collection', isAuthenticated(), collectionRoutes); router.use('/collection', isAuthenticated(), collectionRoutes);
router.use('/service', isAuthenticated(), serviceRoutes); router.use('/service', isAuthenticated(), serviceRoutes);
router.use('/issue', isAuthenticated(), issueRoutes); router.use('/issue', isAuthenticated(), issueRoutes);

View File

@@ -173,6 +173,12 @@ issueRoutes.get('/count', async (req, res, next) => {
}) })
.getCount(); .getCount();
const lyricsCount = await query
.where('issue.issueType = :issueType', {
issueType: IssueType.LYRICS,
})
.getCount();
const othersCount = await query const othersCount = await query
.where('issue.issueType = :issueType', { .where('issue.issueType = :issueType', {
issueType: IssueType.OTHER, issueType: IssueType.OTHER,
@@ -196,6 +202,7 @@ issueRoutes.get('/count', async (req, res, next) => {
video: videoCount, video: videoCount,
audio: audioCount, audio: audioCount,
subtitles: subtitlesCount, subtitles: subtitlesCount,
lyrics: lyricsCount,
others: othersCount, others: othersCount,
open: openCount, open: openCount,
closed: closedCount, closed: closedCount,

View File

@@ -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;

View File

@@ -1,3 +1,4 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import RadarrAPI from '@server/api/servarr/radarr'; import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr';
import TautulliAPI from '@server/api/tautulli'; import TautulliAPI from '@server/api/tautulli';
@@ -199,43 +200,52 @@ mediaRoutes.delete(
}); });
const is4k = String(req.query.is4k) === 'true'; const is4k = String(req.query.is4k) === 'true';
const isMovie = media.mediaType === MediaType.MOVIE;
let serviceSettings; let serviceSettings;
if (isMovie) { if (media.mediaType === MediaType.MOVIE) {
serviceSettings = settings.radarr.find( serviceSettings = settings.radarr.find(
(radarr) => radarr.isDefault && radarr.is4k === is4k (radarr) => radarr.isDefault && radarr.is4k === is4k
); );
} else { } else if(media.mediaType === MediaType.TV) {
serviceSettings = settings.sonarr.find( serviceSettings = settings.sonarr.find(
(sonarr) => sonarr.isDefault && sonarr.is4k === is4k (sonarr) => sonarr.isDefault && sonarr.is4k === is4k
); );
} else {
serviceSettings = settings.lidarr.find(
(lidarr) => lidarr.isDefault);
} }
const specificServiceId = is4k ? media.serviceId4k : media.serviceId;
if ( const specificServiceId = is4k ? media.serviceId4k : media.serviceId;
specificServiceId && if (
specificServiceId >= 0 && specificServiceId &&
serviceSettings?.id !== specificServiceId specificServiceId >= 0 &&
) { serviceSettings?.id !== specificServiceId
if (isMovie) { ) {
serviceSettings = settings.radarr.find( if (media.mediaType === MediaType.MOVIE) {
(radarr) => radarr.id === specificServiceId serviceSettings = settings.radarr.find(
); (radarr) => radarr.id === specificServiceId
} else { );
serviceSettings = settings.sonarr.find( } else if (media.mediaType === MediaType.TV) {
(sonarr) => sonarr.id === specificServiceId serviceSettings = settings.sonarr.find(
); (sonarr) => sonarr.id === specificServiceId
);
} else {
serviceSettings = settings.lidarr.find(
(lidarr) => lidarr.id === media.serviceId
)
}
} }
}
if (!serviceSettings) {
if (!serviceSettings) {
logger.warn( logger.warn(
`There is no default ${ `There is no default ${
is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr' media.mediaType === MediaType.MOVIE
}/ server configured. Did you set any of your ${ ? 'Radarr'
is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr' : media.mediaType === MediaType.TV
} servers as default?`, ? 'Sonarr'
: 'Lidarr'
} server configured.`,
{ {
label: 'Media Request', label: 'Media Request',
mediaId: media.id, mediaId: media.id,
@@ -245,31 +255,43 @@ mediaRoutes.delete(
} }
let service; let service;
if (isMovie) { if (media.mediaType === MediaType.MOVIE) {
service = new RadarrAPI({ service = new RadarrAPI({
apiKey: serviceSettings?.apiKey, apiKey: serviceSettings.apiKey,
url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'), url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'),
}); });
} else {
await (service as RadarrAPI).removeMovie(media.tmdbId);
} else if (media.mediaType === MediaType.TV) {
service = new SonarrAPI({ service = new SonarrAPI({
apiKey: serviceSettings?.apiKey, apiKey: serviceSettings?.apiKey,
url: SonarrAPI.buildUrl(serviceSettings, '/api/v3'), url: SonarrAPI.buildUrl(serviceSettings, '/api/v3'),
}); });
}
if (isMovie) {
await (service as RadarrAPI).removeMovie(media.tmdbId);
} else {
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
const series = await tmdb.getTvShow({ tvId: media.tmdbId }); const series = await tmdb.getTvShow({ tvId: media.tmdbId });
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
if (!tvdbId) { if (!tvdbId) {
throw new Error('TVDB ID not found'); throw new Error('TVDB ID not found');
} }
await (service as SonarrAPI).removeSeries(tvdbId); 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(); return res.status(204).send();
} catch (e) { } catch (e) {
logger.error('Something went wrong fetching media in delete request', { logger.error('Something went wrong fetching media in delete request', {
label: 'Media', label: 'Media',

251
server/routes/music.ts Normal file
View File

@@ -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<typeof artist> => 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;

View File

@@ -1,3 +1,5 @@
import CoverArtArchive from '@server/api/coverartarchive';
import MusicBrainz from '@server/api/musicbrainz';
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import logger from '@server/logger'; import logger from '@server/logger';
@@ -12,13 +14,48 @@ const personRoutes = Router();
personRoutes.get('/:id', async (req, res, next) => { personRoutes.get('/:id', async (req, res, next) => {
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
const musicBrainz = new MusicBrainz();
try { try {
const person = await tmdb.getPerson({ const person = await tmdb.getPerson({
personId: Number(req.params.id), personId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale, 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) { } catch (e) {
logger.debug('Something went wrong retrieving person', { logger.debug('Something went wrong retrieving person', {
label: 'API', 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) => { personRoutes.get('/:id/combined_credits', async (req, res, next) => {
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();

View File

@@ -1,3 +1,4 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import RadarrAPI from '@server/api/servarr/radarr'; import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr';
import { import {
@@ -213,6 +214,21 @@ requestRoutes.get<Record<string, unknown>, 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 // add profile names to the media requests, with undefined if not found
let mappedRequests = requests.map((r) => { let mappedRequests = requests.map((r) => {
switch (r.type) { switch (r.type) {
@@ -234,6 +250,14 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
?.profiles?.find((profile) => profile.id === r.profileId)?.name, ?.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,
};
}
} }
}); });

View File

@@ -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 TheMovieDb from '@server/api/themoviedb';
import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import { findSearchProvider } from '@server/lib/search'; import {
findSearchProvider,
type CombinedSearchResponse,
} from '@server/lib/search';
import logger from '@server/logger'; import logger from '@server/logger';
import { mapSearchResults } from '@server/models/Search'; import { mapSearchResults } from '@server/models/Search';
import { Router } from 'express'; import { Router } from 'express';
@@ -11,7 +18,8 @@ const searchRoutes = Router();
searchRoutes.get('/', async (req, res, next) => { searchRoutes.get('/', async (req, res, next) => {
const queryString = req.query.query as string; const queryString = req.query.query as string;
const searchProvider = findSearchProvider(queryString.toLowerCase()); const searchProvider = findSearchProvider(queryString.toLowerCase());
let results: TmdbSearchMultiResponse; let results: CombinedSearchResponse;
let combinedResults: CombinedSearchResponse['results'] = [];
try { try {
if (searchProvider) { if (searchProvider) {
@@ -25,24 +33,56 @@ searchRoutes.get('/', async (req, res, next) => {
}); });
} else { } else {
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
const tmdbResults = await tmdb.searchMulti({
results = await tmdb.searchMulti({
query: queryString, query: queryString,
page: Number(req.query.page), page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
}); });
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( const media = await Media.getRelatedMedia(
req.user, 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({ return res.status(200).json({
page: results.page, page: results.page,
totalPages: results.total_pages, totalPages: results.total_pages,
totalResults: results.total_results, totalResults: results.total_results,
results: mapSearchResults(results.results, media), results: mappedResults,
}); });
} catch (e) { } catch (e) {
logger.debug('Something went wrong retrieving search results', { logger.debug('Something went wrong retrieving search results', {

View File

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

View File

@@ -40,6 +40,7 @@ import path from 'path';
import semver from 'semver'; import semver from 'semver';
import { URL } from 'url'; import { URL } from 'url';
import metadataRoutes from './metadata'; import metadataRoutes from './metadata';
import lidarrRoutes from './lidarr';
import notificationRoutes from './notifications'; import notificationRoutes from './notifications';
import radarrRoutes from './radarr'; import radarrRoutes from './radarr';
import sonarrRoutes from './sonarr'; import sonarrRoutes from './sonarr';
@@ -49,6 +50,7 @@ const settingsRoutes = Router();
settingsRoutes.use('/notifications', notificationRoutes); settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes); settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes); settingsRoutes.use('/sonarr', sonarrRoutes);
settingsRoutes.use('/lidarr', lidarrRoutes);
settingsRoutes.use('/discover', discoverSettingRoutes); settingsRoutes.use('/discover', discoverSettingRoutes);
settingsRoutes.use('/metadatas', metadataRoutes); settingsRoutes.use('/metadatas', metadataRoutes);
@@ -758,6 +760,9 @@ settingsRoutes.get('/cache', async (_req, res) => {
const tmdbImageCache = await ImageProxy.getImageStats('tmdb'); const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
const avatarImageCache = await ImageProxy.getImageStats('avatar'); 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 stats: DnsStats | undefined = dnsCache?.getStats();
const entries: DnsEntries | undefined = dnsCache?.getCacheEntries(); const entries: DnsEntries | undefined = dnsCache?.getCacheEntries();
@@ -767,6 +772,9 @@ settingsRoutes.get('/cache', async (_req, res) => {
imageCache: { imageCache: {
tmdb: tmdbImageCache, tmdb: tmdbImageCache,
avatar: avatarImageCache, avatar: avatarImageCache,
caa: caaImageCache,
lidarr: lidarrImageCache,
fanart: fanartImageCache,
}, },
dnsCache: { dnsCache: {
stats, stats,

View File

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

View File

@@ -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;

View File

@@ -36,6 +36,7 @@ watchlistRoutes.post<never, Watchlist, Watchlist>(
case QueryFailedError: case QueryFailedError:
logger.warn('Something wrong with data watchlist', { logger.warn('Something wrong with data watchlist', {
tmdbId: req.body.tmdbId, tmdbId: req.body.tmdbId,
mbId: req.body.mbId,
mediaType: req.body.mediaType, mediaType: req.body.mediaType,
label: 'Watchlist', label: 'Watchlist',
}); });
@@ -49,7 +50,7 @@ watchlistRoutes.post<never, Watchlist, Watchlist>(
} }
); );
watchlistRoutes.delete('/:tmdbId', async (req, res, next) => { watchlistRoutes.delete('/:id', async (req, res, next) => {
if (!req.user) { if (!req.user) {
return next({ return next({
status: 401, status: 401,
@@ -57,7 +58,11 @@ watchlistRoutes.delete('/:tmdbId', async (req, res, next) => {
}); });
} }
try { 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(); return res.status(204).send();
} catch (e) { } catch (e) {
if (e instanceof NotFoundError) { if (e instanceof NotFoundError) {

View File

@@ -1,3 +1,4 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb';
import { IssueType, IssueTypeName } from '@server/constants/issue'; import { IssueType, IssueTypeName } from '@server/constants/issue';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
@@ -7,6 +8,7 @@ import Media from '@server/entity/Media';
import { User } from '@server/entity/User'; import { User } from '@server/entity/User';
import notificationManager, { Notification } from '@server/lib/notifications'; import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import type { EntitySubscriberInterface, InsertEvent } from 'typeorm'; import type { EntitySubscriberInterface, InsertEvent } from 'typeorm';
@@ -21,8 +23,8 @@ export class IssueCommentSubscriber
} }
private async sendIssueCommentNotification(entity: IssueComment) { private async sendIssueCommentNotification(entity: IssueComment) {
let title: string; let title = '';
let image: string; let image = '';
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
try { try {
@@ -48,13 +50,33 @@ export class IssueCommentSubscriber
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`; }`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
} else { } else if (media.mediaType === MediaType.TV) {
const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId }); const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId });
title = `${tvshow.name}${ title = `${tvshow.name}${
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : '' tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
}`; }`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`; 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'); const [firstComment] = sortBy(issue.comments, 'id');

View File

@@ -1,9 +1,11 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb';
import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue'; import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
import Issue from '@server/entity/Issue'; import Issue from '@server/entity/Issue';
import notificationManager, { Notification } from '@server/lib/notifications'; import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import type { import type {
@@ -20,8 +22,8 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
} }
private async sendIssueNotification(entity: Issue, type: Notification) { private async sendIssueNotification(entity: Issue, type: Notification) {
let title: string; let title = '';
let image: string; let image = '';
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
try { try {
@@ -32,13 +34,33 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`; }`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
} else { } else if (entity.media.mediaType === MediaType.TV) {
const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId }); const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
title = `${tvshow.name}${ title = `${tvshow.name}${
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : '' tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
}`; }`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`; 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'); const [firstComment] = sortBy(entity.comments, 'id');

View File

@@ -1,3 +1,9 @@
import type {
LidarrAlbumDetails,
LidarrAlbumResult,
LidarrArtistDetails,
LidarrArtistResult,
} from '@server/api/servarr/lidarr';
import type { import type {
TmdbCollectionResult, TmdbCollectionResult,
TmdbMovieDetails, TmdbMovieDetails,
@@ -38,6 +44,18 @@ export const isCollection = (
return (collection as TmdbCollectionResult).media_type === 'collection'; 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 = ( export const isMovieDetails = (
movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails
): movie is TmdbMovieDetails => { ): movie is TmdbMovieDetails => {
@@ -49,3 +67,15 @@ export const isTvDetails = (
): tv is TmdbTvDetails => { ): tv is TmdbTvDetails => {
return (tv as TmdbTvDetails).number_of_seasons !== undefined; 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;
};

View File

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

After

Width:  |  Height:  |  Size: 897 B

View File

@@ -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 (
<div ref={ref}>
<TitleCard.Placeholder canExpand={canExpand} />
</div>
);
}
if (!title) {
return hasPermission(Permission.ADMIN) && id ? (
<TitleCard.ErrorCard
id={id}
tmdbId={tmdbId}
tvdbId={tvdbId}
mbId={mbId}
type={type}
/>
) : null;
}
if (isMusic(title)) {
return (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={
title.mediaInfo?.watchlists?.length || isAddedToWatchlist
}
image={title.images?.find((image) => 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) ? (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={
title.mediaInfo?.watchlists?.length || isAddedToWatchlist
}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={'movie'}
canExpand={canExpand}
mutateParent={mutateParent}
/>
) : (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={
title.mediaInfo?.watchlists?.length || isAddedToWatchlist
}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
title={title.name}
userScore={title.voteAverage}
year={title.firstAirDate}
mediaType={'tv'}
canExpand={canExpand}
mutateParent={mutateParent}
/>
);
};
export default AddedCard;

View File

@@ -24,6 +24,7 @@ import type {
BlacklistResultsResponse, BlacklistResultsResponse,
} from '@server/interfaces/api/blacklistInterfaces'; } from '@server/interfaces/api/blacklistInterfaces';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { MusicDetails } from '@server/models/Music';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios'; import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
@@ -59,6 +60,12 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined; return (movie as MovieDetails).title !== undefined;
}; };
const isMusic = (
media: MovieDetails | TvDetails | MusicDetails
): media is MusicDetails => {
return (media as MusicDetails).artistId !== undefined;
};
const Blacklist = () => { const Blacklist = () => {
const [currentPageSize, setCurrentPageSize] = useState<number>(10); const [currentPageSize, setCurrentPageSize] = useState<number>(10);
const [searchFilter, debouncedSearchFilter, setSearchFilter] = const [searchFilter, debouncedSearchFilter, setSearchFilter] =
@@ -277,12 +284,15 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
const { hasPermission } = useUser(); const { hasPermission } = useUser();
const url = const url =
item.mediaType === 'movie' item.mediaType === 'music'
? `/api/v1/music/${item.mbId}`
: item.mediaType === 'movie'
? `/api/v1/movie/${item.tmdbId}` ? `/api/v1/movie/${item.tmdbId}`
: `/api/v1/tv/${item.tmdbId}`; : `/api/v1/tv/${item.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? url : null const { data: title, error } = useSWR<
); MovieDetails | TvDetails | MusicDetails
>(inView ? url : null);
if (!title && !error) { if (!title && !error) {
return ( 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); setIsUpdating(true);
try { try {
await axios.delete(`/api/v1/blacklist/${tmdbId}`); await axios.delete(`/api/v1/blacklist/${mbId ?? tmdbId}`);
addToast( addToast(
<span> <span>
@@ -321,11 +335,24 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
return ( return (
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row"> <div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
{title && title.backdropPath && ( {title && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3"> <div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage <CachedImage
type="tmdb" type={isMusic(title) ? 'music' : 'tmdb'}
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`} src={
isMusic(title)
? title.artist.images?.find((img) => 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="" alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
fill fill
@@ -343,43 +370,58 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
<div className="relative z-10 flex w-full items-center overflow-hidden pl-4 pr-4 sm:pr-0 xl:w-7/12 2xl:w-2/3"> <div className="relative z-10 flex w-full items-center overflow-hidden pl-4 pr-4 sm:pr-0 xl:w-7/12 2xl:w-2/3">
<Link <Link
href={ href={
item.mediaType === 'movie' item.mediaType === 'music'
? `/music/${item.mbId}`
: item.mediaType === 'movie'
? `/movie/${item.tmdbId}` ? `/movie/${item.tmdbId}`
: `/tv/${item.tmdbId}` : `/tv/${item.tmdbId}`
} }
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105" className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
> >
<CachedImage <CachedImage
type="tmdb" type={title && isMusic(title) ? 'music' : 'tmdb'}
src={ src={
title?.posterPath title
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` ? isMusic(title)
? title.images?.find((image) => 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' : '/images/seerr_poster_not_found.png'
} }
alt="" alt=""
sizes="100vw" sizes="100vw"
style={{ width: '100%', height: 'auto', objectFit: 'cover' }} style={{ width: '100%', height: 'auto', objectFit: 'cover' }}
width={600} width={600}
height={900} height={title && isMusic(title) ? 600 : 900}
/> />
</Link> </Link>
<div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4"> <div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div className="pt-0.5 text-xs font-medium text-white sm:pt-1"> <div className="pt-0.5 text-xs font-medium text-white sm:pt-1">
{title && {title &&
(isMovie(title) (isMusic(title)
? title.releaseDate ? title.releaseDate?.slice(0, 4)
: title.firstAirDate : isMovie(title)
)?.slice(0, 4)} ? title.releaseDate?.slice(0, 4)
: title.firstAirDate?.slice(0, 4))}
</div> </div>
<Link <Link
href={ href={
item.mediaType === 'movie' item.mediaType === 'music'
? `/music/${item.mbId}`
: item.mediaType === 'movie'
? `/movie/${item.tmdbId}` ? `/movie/${item.tmdbId}`
: `/tv/${item.tmdbId}` : `/tv/${item.tmdbId}`
} }
> >
<span className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl"> <span className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl">
{title && (isMovie(title) ? title.title : title.name)} {title &&
(isMusic(title)
? `${title.artist.artistName} - ${title.title}`
: isMovie(title)
? title.title
: title.name)}
</span> </span>
</Link> </Link>
</div> </div>
@@ -446,12 +488,18 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
{intl.formatMessage(globalMessages.movie)} {intl.formatMessage(globalMessages.movie)}
</div> </div>
</div> </div>
) : ( ) : item.mediaType === 'tv' ? (
<div className="pointer-events-none z-40 self-start rounded-full border border-purple-600 bg-purple-600 bg-opacity-80 shadow-md"> <div className="pointer-events-none z-40 self-start rounded-full border border-purple-600 bg-purple-600 bg-opacity-80 shadow-md">
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5"> <div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
{intl.formatMessage(globalMessages.tvshow)} {intl.formatMessage(globalMessages.tvshow)}
</div> </div>
</div> </div>
) : (
<div className="pointer-events-none z-40 self-start rounded-full border border-green-600 bg-green-600 bg-opacity-80 shadow-md">
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
{intl.formatMessage(globalMessages.music)}
</div>
</div>
)} )}
</div> </div>
</div> </div>
@@ -462,7 +510,13 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
onClick={() => onClick={() =>
removeFromBlacklist( removeFromBlacklist(
item.tmdbId, 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( confirmText={intl.formatMessage(

View File

@@ -21,13 +21,15 @@ const messages = defineMessages('component.BlacklistBlock', {
}); });
interface BlacklistBlockProps { interface BlacklistBlockProps {
tmdbId: number; tmdbId?: number;
mbId?: string;
onUpdate?: () => void; onUpdate?: () => void;
onDelete?: () => void; onDelete?: () => void;
} }
const BlacklistBlock = ({ const BlacklistBlock = ({
tmdbId, tmdbId,
mbId,
onUpdate, onUpdate,
onDelete, onDelete,
}: BlacklistBlockProps) => { }: BlacklistBlockProps) => {
@@ -35,13 +37,28 @@ const BlacklistBlock = ({
const intl = useIntl(); const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const { addToast } = useToasts(); const { addToast } = useToasts();
const { data } = useSWR<Blacklist>(`/api/v1/blacklist/${tmdbId}`);
const removeFromBlacklist = async (tmdbId: number, title?: string) => { const { data } = useSWR<Blacklist>(
mbId
? `/api/v1/blacklist/music/${mbId}`
: tmdbId
? `/api/v1/blacklist/${tmdbId}`
: null
);
const removeFromBlacklist = async (
tmdbId?: number,
mbId?: string,
title?: string
) => {
setIsUpdating(true); setIsUpdating(true);
try { 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( addToast(
<span> <span>
@@ -113,7 +130,9 @@ const BlacklistBlock = ({
> >
<Button <Button
buttonType="danger" buttonType="danger"
onClick={() => removeFromBlacklist(data.tmdbId, data.title)} onClick={() =>
removeFromBlacklist(data.tmdbId, data.mbId, data.title)
}
disabled={isUpdating} disabled={isUpdating}
> >
<TrashIcon className="icon-sm" /> <TrashIcon className="icon-sm" />

View File

@@ -3,14 +3,16 @@ import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { MusicDetails } from '@server/models/Music';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios'; import axios from 'axios';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
interface BlacklistModalProps { interface BlacklistModalProps {
tmdbId: number; tmdbId?: number;
type: 'movie' | 'tv' | 'collection'; mbId?: string;
type: 'movie' | 'tv' | 'collection' | 'music';
show: boolean; show: boolean;
onComplete?: () => void; onComplete?: () => void;
onCancel?: () => void; onCancel?: () => void;
@@ -22,14 +24,24 @@ const messages = defineMessages('component.BlacklistModal', {
}); });
const isMovie = ( const isMovie = (
movie: MovieDetails | TvDetails | null media: MovieDetails | TvDetails | MusicDetails | null
): movie is MovieDetails => { ): media is MovieDetails => {
if (!movie) return false; if (!media) return false;
return (movie as MovieDetails).title !== undefined; return (
(media as MovieDetails).title !== undefined && !('artistName' in media)
);
};
const isMusic = (
media: MovieDetails | TvDetails | MusicDetails | null
): media is MusicDetails => {
if (!media) return false;
return (media as MusicDetails).artistId !== undefined;
}; };
const BlacklistModal = ({ const BlacklistModal = ({
tmdbId, tmdbId,
mbId,
type, type,
show, show,
onComplete, onComplete,
@@ -37,7 +49,9 @@ const BlacklistModal = ({
isUpdating, isUpdating,
}: BlacklistModalProps) => { }: BlacklistModalProps) => {
const intl = useIntl(); const intl = useIntl();
const [data, setData] = useState<TvDetails | MovieDetails | null>(null); const [data, setData] = useState<
MovieDetails | TvDetails | MusicDetails | null
>(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
useEffect(() => { useEffect(() => {
@@ -45,13 +59,36 @@ const BlacklistModal = ({
if (!show) return; if (!show) return;
try { try {
setError(null); setError(null);
const response = await axios.get(`/api/v1/${type}/${tmdbId}`); const response = await axios.get(`/api/v1/${type}/${type === 'music' ? mbId : tmdbId}`);
setData(response.data); setData(response.data);
} catch (err) { } catch (err) {
setError(err); setError(err);
} }
})(); })();
}, [show, tmdbId, type]); }, [show, tmdbId, mbId, type]);
const getTitle = () => {
if (isMusic(data)) {
return `${data.artist.artistName} - ${data.title}`;
}
return isMovie(data) ? data.title : data?.name;
};
const getMediaType = () => {
if (isMusic(data)) {
return intl.formatMessage(globalMessages.music);
}
return isMovie(data)
? intl.formatMessage(globalMessages.movie)
: intl.formatMessage(globalMessages.tvshow);
};
const getBackdrop = () => {
if (isMusic(data)) {
return data.artist.images?.find((img) => img.CoverType === 'Fanart')?.Url;
}
return `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`;
};
return ( return (
<Transition <Transition
@@ -67,12 +104,10 @@ const BlacklistModal = ({
<Modal <Modal
loading={!data && !error} loading={!data && !error}
backgroundClickable backgroundClickable
title={`${intl.formatMessage(globalMessages.blacklist)} ${ title={`${intl.formatMessage(
isMovie(data) globalMessages.blacklist
? intl.formatMessage(globalMessages.movie) )} ${getMediaType()}`}
: intl.formatMessage(globalMessages.tvshow) subTitle={getTitle()}
}`}
subTitle={`${isMovie(data) ? data.title : data?.name}`}
onCancel={onCancel} onCancel={onCancel}
onOk={onComplete} onOk={onComplete}
okText={ okText={
@@ -82,7 +117,7 @@ const BlacklistModal = ({
} }
okButtonType="danger" okButtonType="danger"
okDisabled={isUpdating} okDisabled={isUpdating}
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`} backdrop={getBackdrop()}
/> />
</Transition> </Transition>
); );

View File

@@ -6,7 +6,7 @@ const imageLoader: ImageLoader = ({ src }) => src;
export type CachedImageProps = ImageProps & { export type CachedImageProps = ImageProps & {
src: string; src: string;
type: 'tmdb' | 'avatar' | 'tvdb'; type: 'tmdb' | 'avatar' | 'tvdb' | 'music';
}; };
/** /**
@@ -35,6 +35,15 @@ const CachedImage = ({ src, type, ...props }: CachedImageProps) => {
} else if (type === 'avatar') { } else if (type === 'avatar') {
// jellyfin avatar (if any) // jellyfin avatar (if any)
imageUrl = src; imageUrl = src;
} else if (type === 'music') {
// Handle CAA, Fanart and Lidarr images
imageUrl = /^https?:\/\/coverartarchive\.org\//.test(src)
? src.replace(/^https?:\/\/coverartarchive\.org\//, '/caaproxy/')
: /^https?:\/\/assets\.fanart\.tv\//.test(src)
? src.replace(/^https?:\/\/assets\.fanart\.tv\//, '/fanartproxy/')
: currentSettings.cacheImages
? src.replace(/^https:\/\/imagecache\.lidarr\.audio\//, '/lidarrproxy/')
: src;
} else { } else {
return null; return null;
} }

View File

@@ -1,12 +1,15 @@
import AddedCard from '@app/components/AddedCard';
import GroupCard from '@app/components/GroupCard';
import PersonCard from '@app/components/PersonCard'; import PersonCard from '@app/components/PersonCard';
import TitleCard from '@app/components/TitleCard'; import TitleCard from '@app/components/TitleCard';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import useVerticalScroll from '@app/hooks/useVerticalScroll'; import useVerticalScroll from '@app/hooks/useVerticalScroll';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import { MediaStatus } from '@server/constants/media'; import { MediaStatus } from '@server/constants/media';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import type { import type {
AlbumResult,
ArtistResult,
CollectionResult, CollectionResult,
MovieResult, MovieResult,
PersonResult, PersonResult,
@@ -15,7 +18,14 @@ import type {
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
type ListViewProps = { type ListViewProps = {
items?: (TvResult | MovieResult | PersonResult | CollectionResult)[]; items?: (
| TvResult
| MovieResult
| PersonResult
| CollectionResult
| ArtistResult
| AlbumResult
)[];
plexItems?: WatchlistItem[]; plexItems?: WatchlistItem[];
isEmpty?: boolean; isEmpty?: boolean;
isLoading?: boolean; isLoading?: boolean;
@@ -53,9 +63,10 @@ const ListView = ({
{plexItems?.map((title, index) => { {plexItems?.map((title, index) => {
return ( return (
<li key={`${title.ratingKey}-${index}`}> <li key={`${title.ratingKey}-${index}`}>
<TmdbTitleCard <AddedCard
id={title.tmdbId} id={title.tmdbId ?? 0}
tmdbId={title.tmdbId} tmdbId={title.tmdbId ?? 0}
mbId={title.mbId}
type={title.mediaType} type={title.mediaType}
isAddedToWatchlist={true} isAddedToWatchlist={true}
canExpand canExpand
@@ -68,8 +79,8 @@ const ListView = ({
?.filter((title) => { ?.filter((title) => {
if (!blacklistVisibility) if (!blacklistVisibility)
return ( return (
(title as TvResult | MovieResult).mediaInfo?.status !== (title as TvResult | MovieResult | AlbumResult).mediaInfo
MediaStatus.BLACKLISTED ?.status !== MediaStatus.BLACKLISTED
); );
return title; return title;
}) })
@@ -143,6 +154,53 @@ const ListView = ({
/> />
); );
break; break;
case 'album':
titleCard = (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={
title.mediaInfo?.watchlists?.length ?? 0
}
image={
title.images?.find((image) => image.CoverType === 'Cover')
?.Url
}
status={title.mediaInfo?.status}
title={title.title}
artist={title.artistname}
type={title.type}
year={title.releasedate}
mediaType={title.mediaType}
inProgress={
(title.mediaInfo?.downloadStatus ?? []).length > 0
}
canExpand
/>
);
break;
case 'artist':
return title.type === 'Group' ? (
<GroupCard
key={title.id}
groupId={title.id}
name={title.artistname}
image={
title.images.find((image) => image.CoverType === 'Poster')
?.Url ?? title.artistimage
}
canExpand
/>
) : (
<PersonCard
key={title.id}
personId={title.id}
name={title.artistname}
mediaType="artist"
profilePath={title.artistimage}
canExpand
/>
);
} }
return <li key={`${title.id}-${index}`}>{titleCard}</li>; return <li key={`${title.id}-${index}`}>{titleCard}</li>;

View File

@@ -0,0 +1,112 @@
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import type { FilterOptions } from '@app/components/Discover/constants';
import { prepareFilterValues } from '@app/components/Discover/constants';
import useDiscover from '@app/hooks/useDiscover';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import { BarsArrowDownIcon } from '@heroicons/react/24/solid';
import type { AlbumResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useIntl } from 'react-intl';
const messages = defineMessages('components.Discover.DiscoverMusic', {
discovermusics: 'Music',
sortPopularityDesc: 'Most Listened',
sortPopularityAsc: 'Least Listened',
sortReleaseDateDesc: 'Newest First',
sortReleaseDateAsc: 'Oldest First',
sortTitleAsc: 'Title (A-Z)',
sortTitleDesc: 'Title (Z-A)',
});
const SortOptions = {
PopularityDesc: 'listen_count.desc',
PopularityAsc: 'listen_count.asc',
ReleaseDateDesc: 'release_date.desc',
ReleaseDateAsc: 'release_date.asc',
TitleAsc: 'title.asc',
TitleDesc: 'title.desc',
} as const;
const DiscoverMusic = () => {
const intl = useIntl();
const router = useRouter();
const updateQueryParams = useUpdateQueryParams({});
const preparedFilters = prepareFilterValues(router.query);
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<AlbumResult & { id: number }, unknown, FilterOptions>( // Add intersection type to ensure id is number
'/api/v1/discover/music',
preparedFilters
);
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discovermusics);
return (
<>
<PageTitle title={title} />
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
<Header>{title}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
<BarsArrowDownIcon className="h-6 w-6" />
</span>
<select
id="sortBy"
name="sortBy"
className="rounded-r-only"
value={preparedFilters.sortBy}
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
>
<option value={SortOptions.PopularityDesc}>
{intl.formatMessage(messages.sortPopularityDesc)}
</option>
<option value={SortOptions.PopularityAsc}>
{intl.formatMessage(messages.sortPopularityAsc)}
</option>
<option value={SortOptions.ReleaseDateDesc}>
{intl.formatMessage(messages.sortReleaseDateDesc)}
</option>
<option value={SortOptions.ReleaseDateAsc}>
{intl.formatMessage(messages.sortReleaseDateAsc)}
</option>
<option value={SortOptions.TitleAsc}>
{intl.formatMessage(messages.sortTitleAsc)}
</option>
<option value={SortOptions.TitleDesc}>
{intl.formatMessage(messages.sortTitleDesc)}
</option>
</select>
</div>
</div>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverMusic;

View File

@@ -135,6 +135,8 @@ const DiscoverSliderEdit = ({
return intl.formatMessage(sliderTitles.plexwatchlist); return intl.formatMessage(sliderTitles.plexwatchlist);
case DiscoverSliderType.TRENDING: case DiscoverSliderType.TRENDING:
return intl.formatMessage(sliderTitles.trending); return intl.formatMessage(sliderTitles.trending);
case DiscoverSliderType.POPULAR_ALBUMS:
return intl.formatMessage(sliderTitles.popularalbums);
case DiscoverSliderType.POPULAR_MOVIES: case DiscoverSliderType.POPULAR_MOVIES:
return intl.formatMessage(sliderTitles.popularmovies); return intl.formatMessage(sliderTitles.popularmovies);
case DiscoverSliderType.MOVIE_GENRES: case DiscoverSliderType.MOVIE_GENRES:

View File

@@ -1,5 +1,5 @@
import AddedCard from '@app/components/AddedCard';
import Slider from '@app/components/Slider'; import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline'; import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
@@ -62,10 +62,11 @@ const PlexWatchlistSlider = () => {
), ),
})} })}
items={watchlistItems?.results.map((item) => ( items={watchlistItems?.results.map((item) => (
<TmdbTitleCard <AddedCard
id={item.tmdbId} id={item.mediaType === 'music' ? item.mbId : item.tmdbId}
key={`watchlist-slider-item-${item.ratingKey}`} key={`watchlist-slider-item-${item.ratingKey}`}
tmdbId={item.tmdbId} tmdbId={item.tmdbId}
mbId={item.mbId}
type={item.mediaType} type={item.mediaType}
isAddedToWatchlist={true} isAddedToWatchlist={true}
/> />

View File

@@ -1,5 +1,5 @@
import AddedCard from '@app/components/AddedCard';
import Slider from '@app/components/Slider'; import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces'; import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
@@ -38,11 +38,12 @@ const RecentlyAddedSlider = () => {
sliderKey="media" sliderKey="media"
isLoading={!media} isLoading={!media}
items={(media?.results ?? []).map((item) => ( items={(media?.results ?? []).map((item) => (
<TmdbTitleCard <AddedCard
key={`media-slider-item-${item.id}`} key={`media-slider-item-${item.id}`}
id={item.id} id={item.id}
tmdbId={item.tmdbId} tmdbId={item.tmdbId}
tvdbId={item.tvdbId} tvdbId={item.tvdbId}
mbId={item.mbId}
type={item.mediaType} type={item.mediaType}
/> />
))} ))}

View File

@@ -68,6 +68,7 @@ export const genreColorMap: Record<number, [string, string]> = {
export const sliderTitles = defineMessages('components.Discover', { export const sliderTitles = defineMessages('components.Discover', {
recentrequests: 'Recent Requests', recentrequests: 'Recent Requests',
popularalbums: 'Popular Albums',
popularmovies: 'Popular Movies', popularmovies: 'Popular Movies',
populartv: 'Popular Series', populartv: 'Popular Series',
upcomingtv: 'Upcoming Series', upcomingtv: 'Upcoming Series',

View File

@@ -219,6 +219,16 @@ const Discover = () => {
case DiscoverSliderType.PLEX_WATCHLIST: case DiscoverSliderType.PLEX_WATCHLIST:
sliderComponent = <PlexWatchlistSlider />; sliderComponent = <PlexWatchlistSlider />;
break; break;
case DiscoverSliderType.POPULAR_ALBUMS:
sliderComponent = (
<MediaSlider
sliderKey="popular-albums"
title={intl.formatMessage(sliderTitles.popularalbums)}
url="/api/v1/discover/music"
linkUrl="/discover/music"
/>
);
break;
case DiscoverSliderType.TRENDING: case DiscoverSliderType.TRENDING:
sliderComponent = ( sliderComponent = (
<MediaSlider <MediaSlider

View File

@@ -13,7 +13,7 @@ import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
interface ExternalLinkBlockProps { interface ExternalLinkBlockProps {
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv' | 'music';
tmdbId?: number; tmdbId?: number;
tvdbId?: number; tvdbId?: number;
imdbId?: string; imdbId?: string;

View File

@@ -0,0 +1,95 @@
import CachedImage from '@app/components/Common/CachedImage';
import { UserCircleIcon } from '@heroicons/react/24/solid';
import Link from 'next/link';
import { useState } from 'react';
interface GroupCardProps {
groupId: string;
name: string;
subName?: string;
image?: string;
canExpand?: boolean;
}
const GroupCard = ({
groupId,
name,
subName,
image,
canExpand = false,
}: GroupCardProps) => {
const [isHovered, setHovered] = useState(false);
return (
<Link
href={`/group/${groupId}`}
className={canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'}
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => setHovered(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setHovered(true);
}
}}
role="link"
tabIndex={0}
>
<div
className={`relative ${
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'
} transform-gpu cursor-pointer rounded-xl text-white shadow ring-1 transition duration-150 ease-in-out ${
isHovered
? 'scale-105 bg-gray-700 ring-gray-500'
: 'scale-100 bg-gray-800 ring-gray-700'
}`}
>
<div style={{ paddingBottom: '150%' }}>
<div className="absolute inset-0 flex h-full w-full flex-col items-center p-2">
<div className="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
{image ? (
<div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700">
<CachedImage
type="music"
src={image}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
fill
/>
</div>
) : (
<UserCircleIcon className="h-full" />
)}
</div>
<div className="w-full truncate text-center font-bold">{name}</div>
{subName && (
<div
className="overflow-hidden whitespace-normal text-center text-sm text-gray-300"
style={{
WebkitLineClamp: 2,
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
}}
>
{subName}
</div>
)}
<div
className={`absolute bottom-0 left-0 right-0 h-12 rounded-b-xl bg-gradient-to-t ${
isHovered ? 'from-gray-800' : 'from-gray-900'
}`}
/>
</div>
</div>
</div>
</Link>
);
};
export default GroupCard;

View File

@@ -0,0 +1,318 @@
import Ellipsis from '@app/assets/ellipsis.svg';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import ImageFader from '@app/components/Common/ImageFader';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import TitleCard from '@app/components/TitleCard';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { ArtistDetailsType } from '@server/models/Artist';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import TruncateMarkup from 'react-truncate-markup';
import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
interface Album {
id: string;
title: string;
type: string;
releasedate: string;
images: { Url: string }[];
mediaInfo?: {
status?: number;
downloadStatus?: unknown[];
watchlists?: unknown[];
};
}
interface DiscographyResponse {
page: number;
pageInfo: {
total: number;
totalPages: number;
};
results: Album[];
}
const messages = defineMessages('components.GroupDetails', {
type: 'Type: {type}',
genres: 'Genres: {genres}',
albums: 'Albums',
singles: 'Singles',
eps: 'EPs',
other: 'Other',
overview: 'Overview',
status: 'Status: {status}',
loadmore: 'Load More',
});
const GroupDetails = () => {
const intl = useIntl();
const router = useRouter();
const [showBio, setShowBio] = useState(false);
const {
data: albumData,
size: albumSize,
setSize: setAlbumSize,
isValidating: isLoadingAlbums,
} = useSWRInfinite<DiscographyResponse>(
(index) =>
`/api/v1/group/${router.query.groupId}/discography?page=${
index + 1
}&type=Album`,
{ revalidateFirstPage: false }
);
const {
data: singlesData,
size: singlesSize,
setSize: setSinglesSize,
isValidating: isLoadingSingles,
} = useSWRInfinite<DiscographyResponse>(
(index) =>
`/api/v1/group/${router.query.groupId}/discography?page=${
index + 1
}&type=Single`,
{ revalidateFirstPage: false }
);
const {
data: epsData,
size: epsSize,
setSize: setEpsSize,
isValidating: isLoadingEps,
} = useSWRInfinite<DiscographyResponse>(
(index) =>
`/api/v1/group/${router.query.groupId}/discography?page=${
index + 1
}&type=EP`,
{ revalidateFirstPage: false }
);
const {
data: otherData,
size: otherSize,
setSize: setOtherSize,
isValidating: isLoadingOther,
} = useSWRInfinite<DiscographyResponse>(
(index) =>
`/api/v1/group/${router.query.groupId}/discography?page=${
index + 1
}&type=Other`,
{ revalidateFirstPage: false }
);
const { data, error } = useSWR<ArtistDetailsType>(
`/api/v1/group/${router.query.groupId}`
);
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
return <Error statusCode={404} />;
}
const groupAttributes: string[] = [];
if (data.type) {
groupAttributes.push(
intl.formatMessage(messages.type, {
type: data.type,
})
);
}
if (data.genres?.length > 0) {
groupAttributes.push(
intl.formatMessage(messages.genres, {
genres: data.genres.join(', '),
})
);
}
if (data.status) {
const capitalizeFirstLetter = (str: string) =>
str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
groupAttributes.push(
intl.formatMessage(messages.status, {
status: capitalizeFirstLetter(data.status),
})
);
}
const renderAlbumSection = (
title: string,
albums: Album[],
isLoading: boolean,
isReachingEnd: boolean,
onLoadMore: () => void
) => {
if (!albums?.length && !isLoading) return null;
return (
<>
<div className="slider-header">
<div className="slider-title">
<span>{title}</span>
</div>
</div>
<ul className="cards-vertical">
{albums?.map((album) => (
<li key={`album-${album.id}`}>
<TitleCard
id={album.id}
isAddedToWatchlist={album.mediaInfo?.watchlists?.length ?? 0}
title={album.title}
image={album.images?.[0]?.Url}
year={album.releasedate}
type={album.type}
mediaType="album"
status={album.mediaInfo?.status}
inProgress={(album.mediaInfo?.downloadStatus ?? []).length > 0}
canExpand
/>
</li>
))}
{isLoading &&
[...Array(20)].map((_, index) => (
<li key={`placeholder-${index}`}>
<TitleCard.Placeholder canExpand />
</li>
))}
</ul>
{!isReachingEnd && (
<div className="mt-4 flex justify-center">
<Button
onClick={onLoadMore}
disabled={isLoading}
className="flex h-9 w-32 items-center justify-center"
>
{isLoading ? (
<div className="h-5 w-5">
<LoadingSpinner />
</div>
) : (
intl.formatMessage(messages.loadmore)
)}
</Button>
</div>
)}
</>
);
};
return (
<>
<PageTitle title={data.name} />
<div className="absolute top-0 left-0 right-0 z-0 h-96">
<ImageFader
isDarker
backgroundImages={[
...(albumData?.flatMap((page) => 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)}
/>
</div>
<div className="relative z-10 mt-4 mb-8 flex flex-col items-center lg:flex-row lg:items-start">
{data.images?.[0]?.Url && (
<div className="relative mb-6 mr-0 h-36 w-36 flex-shrink-0 overflow-hidden rounded-full ring-1 ring-gray-700 lg:mb-0 lg:mr-6 lg:h-44 lg:w-44">
<CachedImage
type="music"
src={
data.images.find((img) => img.CoverType === 'Poster')?.Url ??
data.images[0]?.Url
}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
fill
/>
</div>
)}
<div className="text-center text-gray-300 lg:text-left">
<h1 className="text-3xl text-white lg:text-4xl">{data.name}</h1>
<div className="mt-1 mb-2 space-y-1 text-xs text-white sm:text-sm lg:text-base">
<div>{groupAttributes.join(' | ')}</div>
</div>
{data.overview && (
<div className="relative text-left">
<div
className="group outline-none ring-0"
onClick={() => setShowBio((show) => !show)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
setShowBio((show) => !show);
}
}}
role="button"
tabIndex={0}
>
<TruncateMarkup
lines={showBio ? 200 : 6}
ellipsis={
<Ellipsis className="relative -top-0.5 ml-2 inline-block opacity-70 transition duration-300 group-hover:opacity-100" />
}
>
<p className="pt-2 text-sm lg:text-base">{data.overview}</p>
</TruncateMarkup>
</div>
</div>
)}
</div>
</div>
{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;

View File

@@ -26,6 +26,7 @@ import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import type Issue from '@server/entity/Issue'; import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { MusicDetails } from '@server/models/Music';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios'; import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
@@ -73,8 +74,19 @@ const messages = defineMessages('components.IssueDetails', {
commentplaceholder: 'Add a comment…', commentplaceholder: 'Add a comment…',
}); });
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { const isMovie = (
return (movie as MovieDetails).title !== undefined; 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 = () => { const IssueDetails = () => {
@@ -86,9 +98,13 @@ const IssueDetails = () => {
const { data: issueData, mutate: revalidateIssue } = useSWR<Issue>( const { data: issueData, mutate: revalidateIssue } = useSWR<Issue>(
`/api/v1/issue/${router.query.issueId}` `/api/v1/issue/${router.query.issueId}`
); );
const { data, error } = useSWR<MovieDetails | TvDetails>( const { data, error } = useSWR<MovieDetails | TvDetails | MusicDetails>(
issueData?.media.tmdbId issueData?.media.tmdbId || issueData?.media.mbId
? `/api/v1/${issueData.media.mediaType}/${issueData.media.tmdbId}` ? `/api/v1/${issueData.media.mediaType}/${
issueData.media.mediaType === MediaType.MUSIC
? issueData.media.mbId
: issueData.media.tmdbId
}`
: null : null
); );
@@ -175,8 +191,17 @@ const IssueDetails = () => {
} }
}; };
const title = isMovie(data) ? data.title : data.name; const title = isMusic(data)
const releaseYear = isMovie(data) ? data.releaseDate : data.firstAirDate; ? `${data.artist.artistName} - ${data.title}`
: isMovie(data)
? data.title
: data.name;
const releaseYear = isMusic(data)
? data.releaseDate
: isMovie(data)
? data.releaseDate
: data.firstAirDate;
return ( return (
<div <div
@@ -206,12 +231,23 @@ const IssueDetails = () => {
{intl.formatMessage(messages.deleteissueconfirm)} {intl.formatMessage(messages.deleteissueconfirm)}
</Modal> </Modal>
</Transition> </Transition>
{data.backdropPath && ( {((!isMusic(data) && data.backdropPath) || isMusic(data)) && (
<div className="media-page-bg-image"> <div className="media-page-bg-image">
<CachedImage <CachedImage
type="tmdb" type={isMusic(data) ? 'music' : 'tmdb'}
alt="" alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`} src={
isMusic(data)
? data.artist.images?.find((img) => 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' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
fill fill
priority priority
@@ -228,9 +264,13 @@ const IssueDetails = () => {
<div className="media-header"> <div className="media-header">
<div className="media-poster"> <div className="media-poster">
<CachedImage <CachedImage
type="tmdb" type={isMusic(data) ? 'music' : 'tmdb'}
src={ src={
data.posterPath isMusic(data)
? data.images?.find(
(img) => 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}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
: '/images/seerr_poster_not_found.png' : '/images/seerr_poster_not_found.png'
} }
@@ -258,8 +298,18 @@ const IssueDetails = () => {
<h1> <h1>
<Link <Link
href={`/${ href={`/${
issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv' issueData.media.mediaType === MediaType.MOVIE
}/${data.id}`} ? 'movie'
: issueData.media.mediaType === MediaType.TV
? 'tv'
: 'music'
}/${
issueData.media.mediaType === MediaType.MUSIC
? isMusic(data)
? data.mbId
: data.id
: data.id
}`}
className="hover:underline" className="hover:underline"
> >
{title} {title}

View File

@@ -11,6 +11,7 @@ import { IssueStatus } from '@server/constants/issue';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
import type Issue from '@server/entity/Issue'; import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { MusicDetails } from '@server/models/Music';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import Link from 'next/link'; import Link from 'next/link';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
@@ -30,8 +31,19 @@ const messages = defineMessages('components.IssueList.IssueItem', {
descriptionpreview: 'Issue Description', descriptionpreview: 'Issue Description',
}); });
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { const isMovie = (
return (movie as MovieDetails).title !== undefined; 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 { interface IssueItemProps {
@@ -45,12 +57,15 @@ const IssueItem = ({ issue }: IssueItemProps) => {
triggerOnce: true, triggerOnce: true,
}); });
const url = const url =
issue.media.mediaType === 'movie' issue.media.mediaType === MediaType.MOVIE
? `/api/v1/movie/${issue.media.tmdbId}` ? `/api/v1/movie/${issue.media.tmdbId}`
: `/api/v1/tv/${issue.media.tmdbId}`; : issue.media.mediaType === MediaType.TV
const { data: title, error } = useSWR<MovieDetails | TvDetails>( ? `/api/v1/tv/${issue.media.tmdbId}`
inView ? url : null : `/api/v1/music/${issue.media.mbId}`;
);
const { data: title, error } = useSWR<
MovieDetails | TvDetails | MusicDetails
>(inView ? url : null);
if (!title && !error) { if (!title && !error) {
return ( return (
@@ -118,11 +133,18 @@ const IssueItem = ({ issue }: IssueItemProps) => {
return ( return (
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:flex-row"> <div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:flex-row">
{title.backdropPath && ( {((!isMusic(title) && title.backdropPath) || isMusic(title)) && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3"> <div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage <CachedImage
type="tmdb" type={isMusic(title) ? 'music' : 'tmdb'}
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`} src={
isMusic(title)
? title.artist.images?.find((img) => img.CoverType === 'Fanart')
?.Url ?? '/images/overseerr_poster_not_found.png'
: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${
title.backdropPath ?? ''
}`
}
alt="" alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
fill fill
@@ -142,14 +164,19 @@ const IssueItem = ({ issue }: IssueItemProps) => {
href={ href={
issue.media.mediaType === MediaType.MOVIE issue.media.mediaType === MediaType.MOVIE
? `/movie/${issue.media.tmdbId}` ? `/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" className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
> >
<CachedImage <CachedImage
type="tmdb" type={isMusic(title) ? 'music' : 'tmdb'}
src={ src={
title.posterPath isMusic(title)
? title.images?.find((image) => image.CoverType === 'Cover')
?.Url ?? '/images/overseerr_poster_not_found.png'
: title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/seerr_poster_not_found.png' : '/images/seerr_poster_not_found.png'
} }
@@ -162,20 +189,28 @@ const IssueItem = ({ issue }: IssueItemProps) => {
</Link> </Link>
<div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4"> <div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div className="pt-0.5 text-xs text-white sm:pt-1"> <div className="pt-0.5 text-xs text-white sm:pt-1">
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice( {isMusic(title)
0, ? title.releaseDate?.slice(0, 4)
4 : (isMovie(title)
)} ? title.releaseDate
: title.firstAirDate
)?.slice(0, 4)}
</div> </div>
<Link <Link
href={ href={
issue.media.mediaType === MediaType.MOVIE issue.media.mediaType === MediaType.MOVIE
? `/movie/${issue.media.tmdbId}` ? `/movie/${issue.media.tmdbId}`
: `/tv/${issue.media.tmdbId}` : issue.media.mediaType === MediaType.TV
? `/tv/${issue.media.tmdbId}`
: `/music/${issue.media.mbId}`
} }
className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl" className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl"
> >
{isMovie(title) ? title.title : title.name} {isMusic(title)
? `${title.artist.artistName} - ${title.title}`
: isMovie(title)
? title.title
: title.name}
</Link> </Link>
{description && ( {description && (
<div className="mt-1 max-w-full"> <div className="mt-1 max-w-full">

View File

@@ -1,6 +1,9 @@
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import Modal from '@app/components/Common/Modal'; 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 useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
@@ -10,6 +13,7 @@ import { ArrowRightCircleIcon } from '@heroicons/react/24/solid';
import { MediaStatus } from '@server/constants/media'; import { MediaStatus } from '@server/constants/media';
import type Issue from '@server/entity/Issue'; import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { MusicDetails } from '@server/models/Music';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios'; import axios from 'axios';
import { Field, Formik } from 'formik'; import { Field, Formik } from 'formik';
@@ -39,8 +43,16 @@ const messages = defineMessages('components.IssueModal.CreateIssueModal', {
submitissue: 'Submit Issue', submitissue: 'Submit Issue',
}); });
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { const isMovie = (
return (movie as MovieDetails).title !== undefined; 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[]) => { const classNames = (...classes: string[]) => {
@@ -48,8 +60,9 @@ const classNames = (...classes: string[]) => {
}; };
interface CreateIssueModalProps { interface CreateIssueModalProps {
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv' | 'music';
tmdbId?: number; tmdbId?: number;
mbId?: string;
onCancel?: () => void; onCancel?: () => void;
} }
@@ -57,16 +70,21 @@ const CreateIssueModal = ({
onCancel, onCancel,
mediaType, mediaType,
tmdbId, tmdbId,
mbId,
}: CreateIssueModalProps) => { }: CreateIssueModalProps) => {
const intl = useIntl(); const intl = useIntl();
const settings = useSettings(); const settings = useSettings();
const { hasPermission } = useUser(); const { hasPermission } = useUser();
const { addToast } = useToasts(); const { addToast } = useToasts();
const { data, error } = useSWR<MovieDetails | TvDetails>( const { data, error } = useSWR<MovieDetails | TvDetails | MusicDetails>(
tmdbId ? `/api/v1/${mediaType}/${tmdbId}` : null mediaType === 'music' && mbId
? `/api/v1/music/${mbId}`
: tmdbId
? `/api/v1/${mediaType}/${tmdbId}`
: null
); );
if (!tmdbId) { if (!tmdbId && !mbId) {
return null; return null;
} }
@@ -90,10 +108,13 @@ const CreateIssueModal = ({
), ),
}); });
// Filter issue options based on media type
const availableIssueOptions = getIssueOptionsForMediaType(mediaType);
return ( return (
<Formik <Formik
initialValues={{ initialValues={{
selectedIssue: issueOptions[0], selectedIssue: availableIssueOptions[0],
message: '', message: '',
problemSeason: availableSeasons.length === 1 ? availableSeasons[0] : 0, problemSeason: availableSeasons.length === 1 ? availableSeasons[0] : 0,
problemEpisode: 0, problemEpisode: 0,
@@ -115,7 +136,11 @@ const CreateIssueModal = ({
<> <>
<div> <div>
{intl.formatMessage(messages.toastSuccessCreate, { {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) => <strong>{msg}</strong>, strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})} })}
</div> </div>
@@ -152,12 +177,28 @@ const CreateIssueModal = ({
backgroundClickable backgroundClickable
onCancel={onCancel} onCancel={onCancel}
title={intl.formatMessage(messages.reportissue)} 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)} cancelText={intl.formatMessage(globalMessages.close)}
onOk={() => handleSubmit()} onOk={() => handleSubmit()}
okText={intl.formatMessage(messages.submitissue)} okText={intl.formatMessage(messages.submitissue)}
loading={!data && !error} 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) && ( {mediaType === 'tv' && data && !isMovie(data) && (
<> <>
@@ -211,24 +252,25 @@ const CreateIssueModal = ({
<option value={0}> <option value={0}>
{intl.formatMessage(messages.allepisodes)} {intl.formatMessage(messages.allepisodes)}
</option> </option>
{[ {!isMusic(data) &&
...Array( [
data.seasons.find( ...Array(
(season) => data.seasons.find(
Number(values.problemSeason) === (season) =>
season.seasonNumber Number(values.problemSeason) ===
)?.episodeCount ?? 0 season.seasonNumber
), )?.episodeCount ?? 0
].map((i, index) => ( ),
<option ].map((i, index) => (
value={index + 1} <option
key={`problem-episode-${index + 1}`} value={index + 1}
> key={`problem-episode-${index + 1}`}
{intl.formatMessage(messages.episode, { >
episodeNumber: index + 1, {intl.formatMessage(messages.episode, {
})} episodeNumber: index + 1,
</option> })}
))} </option>
))}
</Field> </Field>
</div> </div>
</div> </div>
@@ -245,7 +287,7 @@ const CreateIssueModal = ({
Select an Issue Select an Issue
</RadioGroup.Label> </RadioGroup.Label>
<div className="-space-y-px overflow-hidden rounded-md bg-gray-800 bg-opacity-30"> <div className="-space-y-px overflow-hidden rounded-md bg-gray-800 bg-opacity-30">
{issueOptions.map((setting, index) => ( {availableIssueOptions.map((setting, index) => (
<RadioGroup.Option <RadioGroup.Option
key={`issue-type-${setting.issueType}`} key={`issue-type-${setting.issueType}`}
value={setting} value={setting}

View File

@@ -6,13 +6,14 @@ const messages = defineMessages('components.IssueModal', {
issueAudio: 'Audio', issueAudio: 'Audio',
issueVideo: 'Video', issueVideo: 'Video',
issueSubtitles: 'Subtitle', issueSubtitles: 'Subtitle',
issueLyrics: 'Lyrics',
issueOther: 'Other', issueOther: 'Other',
}); });
interface IssueOption { interface IssueOption {
name: MessageDescriptor; name: MessageDescriptor;
issueType: IssueType; issueType: IssueType;
mediaType?: 'movie' | 'tv'; mediaType?: 'movie' | 'tv' | 'music';
} }
export const issueOptions: IssueOption[] = [ export const issueOptions: IssueOption[] = [
@@ -28,8 +29,31 @@ export const issueOptions: IssueOption[] = [
name: messages.issueSubtitles, name: messages.issueSubtitles,
issueType: IssueType.SUBTITLES, issueType: IssueType.SUBTITLES,
}, },
{
name: messages.issueLyrics,
issueType: IssueType.LYRICS,
},
{ {
name: messages.issueOther, name: messages.issueOther,
issueType: IssueType.OTHER, issueType: IssueType.OTHER,
}, },
]; ];
export const getIssueOptionsForMediaType = (
mediaType: 'movie' | 'tv' | 'music'
): IssueOption[] => {
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;
};

View File

@@ -4,12 +4,19 @@ import { Transition } from '@headlessui/react';
interface IssueModalProps { interface IssueModalProps {
show?: boolean; show?: boolean;
onCancel: () => void; onCancel: () => void;
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv' | 'music';
tmdbId: number; tmdbId?: number;
mbId?: string;
issueId?: never; issueId?: never;
} }
const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => ( const IssueModal = ({
show,
mediaType,
onCancel,
tmdbId,
mbId,
}: IssueModalProps) => (
<Transition <Transition
as="div" as="div"
enter="transition-opacity duration-300" enter="transition-opacity duration-300"
@@ -24,6 +31,7 @@ const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
mediaType={mediaType} mediaType={mediaType}
onCancel={onCancel} onCancel={onCancel}
tmdbId={tmdbId} tmdbId={tmdbId}
mbId={mbId}
/> />
</Transition> </Transition>
); );

View File

@@ -10,6 +10,7 @@ import {
ExclamationTriangleIcon, ExclamationTriangleIcon,
EyeSlashIcon, EyeSlashIcon,
FilmIcon, FilmIcon,
MusicalNoteIcon,
SparklesIcon, SparklesIcon,
TvIcon, TvIcon,
UsersIcon, UsersIcon,
@@ -20,6 +21,7 @@ import {
ExclamationTriangleIcon as FilledExclamationTriangleIcon, ExclamationTriangleIcon as FilledExclamationTriangleIcon,
EyeSlashIcon as FilledEyeSlashIcon, EyeSlashIcon as FilledEyeSlashIcon,
FilmIcon as FilledFilmIcon, FilmIcon as FilledFilmIcon,
MusicalNoteIcon as FilledMusicalNoteIcon,
SparklesIcon as FilledSparklesIcon, SparklesIcon as FilledSparklesIcon,
TvIcon as FilledTvIcon, TvIcon as FilledTvIcon,
UsersIcon as FilledUsersIcon, UsersIcon as FilledUsersIcon,
@@ -92,6 +94,13 @@ const MobileMenu = ({
svgIconSelected: <FilledTvIcon className="h-6 w-6" />, svgIconSelected: <FilledTvIcon className="h-6 w-6" />,
activeRegExp: /^\/discover\/tv$/, activeRegExp: /^\/discover\/tv$/,
}, },
{
href: '/discover/music',
content: intl.formatMessage(menuMessages.browsemusic),
svgIcon: <MusicalNoteIcon className="h-6 w-6" />,
svgIconSelected: <FilledMusicalNoteIcon className="h-6 w-6" />,
activeRegExp: /^\/discover\/music$/,
},
{ {
href: '/requests', href: '/requests',
content: intl.formatMessage(menuMessages.requests), content: intl.formatMessage(menuMessages.requests),

View File

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

View File

@@ -11,6 +11,7 @@ import {
ExclamationTriangleIcon, ExclamationTriangleIcon,
EyeSlashIcon, EyeSlashIcon,
FilmIcon, FilmIcon,
MusicalNoteIcon,
SparklesIcon, SparklesIcon,
TvIcon, TvIcon,
UsersIcon, UsersIcon,
@@ -26,11 +27,13 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', {
dashboard: 'Discover', dashboard: 'Discover',
browsemovies: 'Movies', browsemovies: 'Movies',
browsetv: 'Series', browsetv: 'Series',
browsemusic: 'Music',
requests: 'Requests', requests: 'Requests',
blacklist: 'Blacklist', blacklist: 'Blacklist',
issues: 'Issues', issues: 'Issues',
users: 'Users', users: 'Users',
settings: 'Settings', settings: 'Settings',
music: 'Music',
}); });
interface SidebarProps { interface SidebarProps {
@@ -72,6 +75,12 @@ const SidebarLinks: SidebarLinkProps[] = [
svgIcon: <TvIcon className="mr-3 h-6 w-6" />, svgIcon: <TvIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/discover\/tv$/, activeRegExp: /^\/discover\/tv$/,
}, },
{
href: '/discover/music',
messagesKey: 'music',
svgIcon: <MusicalNoteIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/discover\/music$/,
},
{ {
href: '/requests', href: '/requests',
messagesKey: 'requests', messagesKey: 'requests',

View File

@@ -11,6 +11,7 @@ const messages = defineMessages(
{ {
movierequests: 'Movie Requests', movierequests: 'Movie Requests',
seriesrequests: 'Series Requests', seriesrequests: 'Series Requests',
musicrequests: 'Music Requests',
} }
); );
@@ -26,7 +27,7 @@ const MiniQuotaDisplay = ({ userId }: MiniQuotaDisplayProps) => {
return null; return null;
} }
if (!data && !error) { if (data === undefined && !error) {
return <SmallLoadingSpinner />; return <SmallLoadingSpinner />;
} }
@@ -88,6 +89,34 @@ const MiniQuotaDisplay = ({ userId }: MiniQuotaDisplayProps) => {
)} )}
</div> </div>
</div> </div>
<div className="flex basis-1/2 flex-col space-y-2">
<div className="text-sm text-gray-200">
{intl.formatMessage(messages.musicrequests)}
</div>
<div className="flex h-full items-center space-x-2 text-gray-200">
{data?.music.limit ?? 0 > 0 ? (
<>
<ProgressCircle
className="h-8 w-8"
progress={Math.round(
((data?.music.remaining ?? 0) /
(data?.music.limit ?? 1)) *
100
)}
useHeatLevel
/>
<span className="text-lg font-bold">
{data?.music.remaining} / {data?.music.limit}
</span>
</>
) : (
<>
<Infinity className="w-7" />
<span className="font-bold">Unlimited</span>
</>
)}
</div>
</div>
</div> </div>
)} )}
</> </>

View File

@@ -25,8 +25,13 @@ import {
} from '@server/constants/media'; } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfaces'; 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 { MovieDetails } from '@server/models/Movie';
import type { MusicDetails } from '@server/models/Music';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios'; import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
@@ -65,8 +70,19 @@ const messages = defineMessages('components.ManageSlideOver', {
tvshow: 'series', tvshow: 'series',
}); });
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { const isMovie = (
return (movie as MovieDetails).title !== undefined; 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 { interface ManageSlideOverProps {
@@ -86,13 +102,21 @@ interface ManageSlideOverTvProps extends ManageSlideOverProps {
data: TvDetails; data: TvDetails;
} }
interface ManageSlideOverMusicProps extends ManageSlideOverProps {
mediaType: 'music';
data: MusicDetails;
}
const ManageSlideOver = ({ const ManageSlideOver = ({
show, show,
mediaType, mediaType,
onClose, onClose,
data, data,
revalidate, revalidate,
}: ManageSlideOverMovieProps | ManageSlideOverTvProps) => { }:
| ManageSlideOverMovieProps
| ManageSlideOverTvProps
| ManageSlideOverMusicProps) => {
const { user: currentUser, hasPermission } = useUser(); const { user: currentUser, hasPermission } = useUser();
const intl = useIntl(); const intl = useIntl();
const settings = useSettings(); const settings = useSettings();
@@ -109,6 +133,9 @@ const ManageSlideOver = ({
const { data: sonarrData } = useSWR<SonarrSettings[]>( const { data: sonarrData } = useSWR<SonarrSettings[]>(
hasPermission(Permission.ADMIN) ? '/api/v1/settings/sonarr' : null hasPermission(Permission.ADMIN) ? '/api/v1/settings/sonarr' : null
); );
const { data: lidarrData } = useSWR<LidarrSettings[]>(
hasPermission(Permission.ADMIN) ? '/api/v1/settings/lidarr' : null
);
const deleteMedia = async () => { const deleteMedia = async () => {
if (data.mediaInfo) { if (data.mediaInfo) {
@@ -138,6 +165,13 @@ const ManageSlideOver = ({
radarr.isDefault && radarr.id === data.mediaInfo?.serviceId radarr.isDefault && radarr.id === data.mediaInfo?.serviceId
) !== undefined ) !== undefined
); );
} else if (data.mediaInfo.mediaType === MediaType.MUSIC) {
return (
lidarrData?.find(
(lidarr) =>
lidarr.isDefault && lidarr.id === data.mediaInfo?.serviceId
) !== undefined
);
} else { } else {
return ( return (
sonarrData?.find( sonarrData?.find(
@@ -215,11 +249,21 @@ const ManageSlideOver = ({
show={show} show={show}
title={intl.formatMessage(messages.manageModalTitle, { title={intl.formatMessage(messages.manageModalTitle, {
mediaType: intl.formatMessage( mediaType: intl.formatMessage(
mediaType === 'movie' ? globalMessages.movie : globalMessages.tvshow mediaType === 'movie'
? globalMessages.movie
: mediaType === 'music'
? globalMessages.album
: globalMessages.tvshow
), ),
})} })}
onClose={() => onClose()} onClose={() => onClose()}
subText={isMovie(data) ? data.title : data.name} subText={
isMovie(data)
? data.title
: isMusic(data)
? `${data.artist} - ${data.title}`
: data.name
}
> >
<div className="space-y-6"> <div className="space-y-6">
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 || {((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
@@ -429,7 +473,12 @@ const ManageSlideOver = ({
<ServerIcon /> <ServerIcon />
<span> <span>
{intl.formatMessage(messages.openarr, { {intl.formatMessage(messages.openarr, {
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', arr:
mediaType === 'movie'
? 'Radarr'
: mediaType === 'music'
? 'Lidarr'
: 'Sonarr',
})} })}
</span> </span>
</Button> </Button>
@@ -450,7 +499,12 @@ const ManageSlideOver = ({
<TrashIcon /> <TrashIcon />
<span> <span>
{intl.formatMessage(messages.removearr, { {intl.formatMessage(messages.removearr, {
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', arr:
mediaType === 'movie'
? 'Radarr'
: mediaType === 'music'
? 'Lidarr'
: 'Sonarr',
})} })}
</span> </span>
</ConfirmButton> </ConfirmButton>
@@ -650,9 +704,9 @@ const ManageSlideOver = ({
<CheckCircleIcon /> <CheckCircleIcon />
<span> <span>
{intl.formatMessage( {intl.formatMessage(
mediaType === 'movie' mediaType === 'tv'
? messages.markavailable ? messages.markallseasonsavailable
: messages.markallseasonsavailable : messages.markavailable
)} )}
</span> </span>
</Button> </Button>

View File

@@ -1,3 +1,4 @@
import GroupCard from '@app/components/GroupCard';
import ShowMoreCard from '@app/components/MediaSlider/ShowMoreCard'; import ShowMoreCard from '@app/components/MediaSlider/ShowMoreCard';
import PersonCard from '@app/components/PersonCard'; import PersonCard from '@app/components/PersonCard';
import Slider from '@app/components/Slider'; import Slider from '@app/components/Slider';
@@ -8,6 +9,8 @@ import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import { MediaStatus } from '@server/constants/media'; import { MediaStatus } from '@server/constants/media';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import type { import type {
AlbumResult,
ArtistResult,
MovieResult, MovieResult,
PersonResult, PersonResult,
TvResult, TvResult,
@@ -20,7 +23,13 @@ interface MixedResult {
page: number; page: number;
totalResults: number; totalResults: number;
totalPages: number; totalPages: number;
results: (TvResult | MovieResult | PersonResult)[]; results: (
| MovieResult
| TvResult
| PersonResult
| AlbumResult
| ArtistResult
)[];
} }
interface MediaSliderProps { interface MediaSliderProps {
@@ -62,7 +71,7 @@ const MediaSlider = ({
let titles = (data ?? []).reduce( let titles = (data ?? []).reduce(
(a, v) => [...a, ...v.results], (a, v) => [...a, ...v.results],
[] as (MovieResult | TvResult | PersonResult)[] [] as (MovieResult | TvResult | PersonResult | AlbumResult | ArtistResult)[]
); );
if (settings.currentSettings.hideAvailable) { if (settings.currentSettings.hideAvailable) {
@@ -112,7 +121,7 @@ const MediaSlider = ({
.filter((title) => { .filter((title) => {
if (!blacklistVisibility) if (!blacklistVisibility)
return ( return (
(title as TvResult | MovieResult).mediaInfo?.status !== (title as TvResult | MovieResult | AlbumResult).mediaInfo?.status !==
MediaStatus.BLACKLISTED MediaStatus.BLACKLISTED
); );
return title; return title;
@@ -159,6 +168,41 @@ const MediaSlider = ({
profilePath={title.profilePath} profilePath={title.profilePath}
/> />
); );
case 'album':
return (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
image={
title.images?.find((image) => 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' ? (
<GroupCard
key={title.id}
groupId={title.id}
name={title.artistname}
image={title.artistimage}
/>
) : (
<PersonCard
key={title.id}
personId={title.id}
name={title.artistname}
mediaType="artist"
profilePath={title.artistimage}
/>
);
} }
}); });
@@ -169,7 +213,9 @@ const MediaSlider = ({
posters={titles posters={titles
.slice(20, 24) .slice(20, 24)
.map((title) => .map((title) =>
title.mediaType !== 'person' ? title.posterPath : undefined title.mediaType !== 'person'
? (title as MovieResult | TvResult).posterPath
: undefined
)} )}
/> />
); );

View File

@@ -1060,26 +1060,14 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
</div> </div>
)} )}
{!!streamingProviders.length && ( {!!streamingProviders.length && (
<div className="media-fact flex-col gap-1"> <div className="media-fact">
<span>{intl.formatMessage(messages.streamingproviders)}</span> <span>{intl.formatMessage(messages.streamingproviders)}</span>
<span className="media-fact-value flex flex-row flex-wrap gap-5"> <span className="media-fact-value">
{streamingProviders.map((p) => { {streamingProviders.map((p) => {
return ( return (
<Tooltip content={p.name}> <span className="block" key={`provider-${p.id}`}>
<span {p.name}
className="opacity-50 transition duration-300 hover:opacity-100" </span>
key={`provider-${p.id}`}
>
<CachedImage
type="tmdb"
src={'https://image.tmdb.org/t/p/w45/' + p.logoPath}
alt={p.name}
width={32}
height={32}
className="rounded-md"
/>
</span>
</Tooltip>
); );
})} })}
</span> </span>

View File

@@ -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<MusicDetails>(
`/api/v1/music/${router.query.musicId}`
);
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<AlbumResult & { id: number }>(
`/api/v1/music/${router.query.musicId}/discography`
);
if (error) {
return <Error statusCode={500} />;
}
return (
<>
<PageTitle
title={[
intl.formatMessage(messages.artistalbums),
musicData?.artist.artistName,
]}
/>
<div className="mt-1 mb-5">
<Header
subtext={
<Link
href={`/music/${musicData?.mbId}`}
className="hover:underline"
>
{musicData?.artist.artistName}
</Link>
}
>
{intl.formatMessage(messages.artistalbums)}
</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isReachingEnd={isReachingEnd}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
onScrollBottom={fetchMore}
/>
</>
);
};
export default MusicArtistDiscography;

View File

@@ -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<MusicDetails>(
`/api/v1/music/${router.query.musicId}`
);
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<ArtistResult & { id: number }>(
`/api/v1/music/${router.query.musicId}/similar`
);
if (error) {
return <Error statusCode={500} />;
}
return (
<>
<PageTitle
title={[
intl.formatMessage(messages.similarArtists),
musicData?.artist.artistName,
]}
/>
<div className="mt-1 mb-5">
<Header
subtext={
<Link
href={`/music/${musicData?.mbId}`}
className="hover:underline"
>
{musicData?.artist.artistName}
</Link>
}
>
{intl.formatMessage(messages.similarArtists)}
</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isReachingEnd={isReachingEnd}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
onScrollBottom={fetchMore}
/>
</>
);
};
export default MusicArtistSimilar;

View File

@@ -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: '<strong>{title}</strong> added to watchlist successfully!',
watchlistDeleted:
'<strong>{title}</strong> 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<boolean>(false);
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
!music?.onUserWatchlist
);
const [isBlacklistUpdating, setIsBlacklistUpdating] =
useState<boolean>(false);
const [showBlacklistModal, setShowBlacklistModal] = useState(false);
const { addToast } = useToasts();
const {
data,
error,
mutate: revalidate,
} = useSWR<MusicDetailsType>(`/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 <LoadingSpinner />;
}
if (!data) {
return <ErrorPage statusCode={404} />;
}
const mediaLinks: PlayButtonLink[] = [];
if (
plexUrl &&
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
})
) {
mediaLinks.push({
text: getAvalaibleMediaServerName(),
url: plexUrl,
svg: <PlayIcon />,
});
}
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<void> => {
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(
<span>
{intl.formatMessage(messages.watchlistSuccess, {
title: music?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
}
setIsUpdating(false);
setToggleWatchlist((prevState) => !prevState);
};
const onClickDeleteWatchlistBtn = async (): Promise<void> => {
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(
<span>
{intl.formatMessage(messages.watchlistDeleted, {
title: music?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ 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<void> => {
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(
<span>
{intl.formatMessage(globalMessages.blacklistSuccess, {
title: music?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
revalidate();
} else if (res.status === 412) {
addToast(
<span>
{intl.formatMessage(globalMessages.blacklistDuplicateError, {
title: music?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ 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 (
<div
className="media-page"
style={{
height: 493,
}}
>
<div className="media-page-bg-image">
<CachedImage
type="music"
alt=""
src={
data.artist.images?.find((img) => 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
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
}}
/>
</div>
<PageTitle title={`${data.title} - ${data.artist.artistName}`} />
<IssueModal
onCancel={() => setShowIssueModal(false)}
show={showIssueModal}
mediaType="music"
mbId={data.id}
/>
<ManageSlideOver
data={data}
mediaType="music"
onClose={() => {
setShowManager(false);
router.push({
pathname: router.pathname,
query: { musicId: router.query.musicId },
});
}}
revalidate={() => revalidate()}
show={showManager}
/>
<BlacklistModal
mbId={data.mbId}
type="music"
show={showBlacklistModal}
onCancel={closeBlacklistModal}
onComplete={onClickHideItemBtn}
isUpdating={isBlacklistUpdating}
/>
<div className="media-header">
<div className="media-poster">
<CachedImage
type="music"
src={
data.images?.find(
(img) => img.CoverType.toLowerCase() === 'cover'
)?.Url || '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"
style={{ width: '100%', height: 'auto' }}
width={600}
height={600}
priority
/>
</div>
<div className="media-title">
<div className="media-status">
<StatusBadge
status={data.mediaInfo?.status}
downloadItem={data.mediaInfo?.downloadStatus}
title={data.title}
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
mbId={data.mediaInfo?.mbId}
mediaType="music"
serviceUrl={data.mediaInfo?.serviceUrl}
/>
</div>
<h1 data-testid="media-title">
{data.title} - {data.artist.artistName}{' '}
{data.releaseDate && (
<span className="media-year">
({new Date(data.releaseDate).getFullYear()})
</span>
)}
</h1>
<span className="media-attributes">
{[
<span className="rounded-md border p-0.5 py-0">{data.type}</span>,
totalDurationMs ? formatDuration(totalDurationMs) : null,
data.genres.length > 0 ? data.genres.join(', ') : null,
]
.filter(Boolean)
.map((t, k) => <span key={k}>{t}</span>)
.reduce((prev, curr) => (
<>
{prev}
<span>|</span>
{curr}
</>
))}
</span>
</div>
<div className="media-actions">
{showHideButton &&
data?.mediaInfo?.status !== MediaStatus.PROCESSING &&
data?.mediaInfo?.status !== MediaStatus.AVAILABLE &&
data?.mediaInfo?.status !== MediaStatus.PENDING &&
data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && (
<Tooltip
content={intl.formatMessage(globalMessages.addToBlacklist)}
>
<Button
buttonType="ghost"
className="z-40 mr-2"
buttonSize="md"
onClick={() => setShowBlacklistModal(true)}
>
<EyeSlashIcon className="h-3" />
</Button>
</Tooltip>
)}
{data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && (
<>
{toggleWatchlist ? (
<Tooltip content={intl.formatMessage(messages.addtowatchlist)}>
<Button
buttonType="ghost"
className="z-40 mr-2"
buttonSize="md"
onClick={onClickWatchlistBtn}
>
{isUpdating ? (
<Spinner className="h-3" />
) : (
<StarIcon className="h-3 text-amber-300" />
)}
</Button>
</Tooltip>
) : (
<Tooltip
content={intl.formatMessage(messages.removefromwatchlist)}
>
<Button
className="z-40 mr-2"
buttonSize="md"
onClick={onClickDeleteWatchlistBtn}
>
{isUpdating ? (
<Spinner className="h-3" />
) : (
<MinusCircleIcon className="h-3" />
)}
</Button>
</Tooltip>
)}
</>
)}
<PlayButton links={mediaLinks} />
<RequestButton
mediaType="music"
media={data.mediaInfo}
mbId={data.mbId}
onUpdate={() => revalidate()}
/>
{data.mediaInfo?.status === MediaStatus.AVAILABLE &&
hasPermission(
[Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES],
{
type: 'or',
}
) && (
<Tooltip content={intl.formatMessage(messages.reportissue)}>
<Button
buttonType="warning"
onClick={() => setShowIssueModal(true)}
className="ml-2 first:ml-0"
>
<ExclamationTriangleIcon />
</Button>
</Tooltip>
)}
{hasPermission(Permission.MANAGE_REQUESTS) &&
data.mediaInfo &&
(data.mediaInfo.jellyfinMediaId ||
data.mediaInfo.jellyfinMediaId4k ||
data.mediaInfo.status !== MediaStatus.UNKNOWN ||
data.mediaInfo.status4k !== MediaStatus.UNKNOWN) && (
<Tooltip content={intl.formatMessage(messages.managemusic)}>
<Button
buttonType="ghost"
onClick={() => setShowManager(true)}
className="relative ml-2 first:ml-0"
>
<CogIcon className="!mr-0" />
{hasPermission(
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
{
type: 'or',
}
) &&
(data.mediaInfo?.issues ?? []).filter(
(issue) => issue.status === IssueStatus.OPEN
).length > 0 && (
<>
<div className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-red-600" />
<div className="absolute -right-1 -top-1 h-3 w-3 animate-ping rounded-full bg-red-600" />
</>
)}
</Button>
</Tooltip>
)}
</div>
</div>
<div className="media-overview">
<div className="media-overview-left">
<h2>{intl.formatMessage(messages.biography)}</h2>
<p>
{data.artist.overview
? truncateOverview(data.artist.overview)
: intl.formatMessage(messages.biographyunavailable)}
</p>
<h2 className="py-4">{intl.formatMessage(messages.trackstitle)}</h2>
{data.releases?.[0]?.tracks?.length > 0 ? (
<div className="divide-y divide-gray-700 rounded-lg border border-gray-700">
{data.releases[0].tracks.map((track, index) => (
<div
key={track.id ?? index}
className="flex items-center justify-between px-4 py-2 text-sm transition duration-150 hover:bg-gray-700"
>
<div className="flex flex-1 items-center space-x-4">
<span className="w-8 text-gray-500">{index + 1}</span>
<span className="flex-1 truncate text-gray-300">
{track.trackName}
</span>
<span className="text-right text-gray-500">
{Math.floor((track.durationMs ?? 0) / 1000 / 60)}:
{String(
Math.floor(((track.durationMs ?? 0) / 1000) % 60)
).padStart(2, '0')}
</span>
</div>
</div>
))}
</div>
) : (
<div className="text-gray-400">
{intl.formatMessage(messages.tracksunavailable)}
</div>
)}
</div>
<div className="media-overview-right">
<div className="media-facts">
{data.releases?.[0]?.status && (
<div className="media-fact">
<span>{intl.formatMessage(globalMessages.status)}</span>
<span className="media-fact-value">
{data.releases[0].status}
</span>
</div>
)}
{data.releases?.[0]?.label?.length > 0 && (
<div className="media-fact">
<span>{intl.formatMessage(messages.label)}</span>
<span className="media-fact-value">
{data.releases[0].label.map((label) => (
<span key={label} className="block">
{label}
</span>
))}
</span>
</div>
)}
{data.artist.type && (
<div className="media-fact">
<span>{intl.formatMessage(messages.artisttype)}</span>
<span className="media-fact-value">{data.artist.type}</span>
</div>
)}
{data.artist.status && (
<div className="media-fact">
<span>{intl.formatMessage(messages.artiststatus)}</span>
<span className="media-fact-value">
{data.artist.status.charAt(0).toUpperCase() +
data.artist.status.slice(1)}
</span>
</div>
)}
</div>
</div>
</div>
<MediaSlider
sliderKey="artist-discography"
title={intl.formatMessage(messages.discography, {
artistName: data?.artist.artistName ?? '',
})}
url={`/api/v1/music/${router.query.musicId}/discography`}
linkUrl={`/music/${data.id}/discography`}
hideWhenEmpty
/>
<MediaSlider
sliderKey="similar-artists"
title={intl.formatMessage(messages.similarArtists)}
url={`/api/v1/music/${router.query.musicId}/similar`}
linkUrl={`/music/${data.id}/similar`}
hideWhenEmpty
/>
<div className="extra-bottom-space relative" />
</div>
);
};
export default MusicDetails;

View File

@@ -25,6 +25,9 @@ export const messages = defineMessages('components.PermissionEdit', {
requestTv: 'Request Series', requestTv: 'Request Series',
requestTvDescription: requestTvDescription:
'Grant permission to submit requests for non-4K series.', 'Grant permission to submit requests for non-4K series.',
requestMusic: 'Request Music',
requestMusicDescription:
'Grant permission to submit requests for music albums.',
autoapprove: 'Auto-Approve', autoapprove: 'Auto-Approve',
autoapproveDescription: autoapproveDescription:
'Grant automatic approval for all non-4K media requests.', 'Grant automatic approval for all non-4K media requests.',
@@ -34,6 +37,9 @@ export const messages = defineMessages('components.PermissionEdit', {
autoapproveSeries: 'Auto-Approve Series', autoapproveSeries: 'Auto-Approve Series',
autoapproveSeriesDescription: autoapproveSeriesDescription:
'Grant automatic approval for non-4K series requests.', 'Grant automatic approval for non-4K series requests.',
autoapproveMusic: 'Auto-Approve Music',
autoapproveMusicDescription:
'Grant automatic approval for music album requests.',
autoapprove4k: 'Auto-Approve 4K', autoapprove4k: 'Auto-Approve 4K',
autoapprove4kDescription: autoapprove4kDescription:
'Grant automatic approval for all 4K media requests.', 'Grant automatic approval for all 4K media requests.',
@@ -62,6 +68,9 @@ export const messages = defineMessages('components.PermissionEdit', {
autorequestSeries: 'Auto-Request Series', autorequestSeries: 'Auto-Request Series',
autorequestSeriesDescription: autorequestSeriesDescription:
'Grant permission to automatically submit requests for non-4K series via Plex Watchlist.', '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', viewrequests: 'View Requests',
viewrequestsDescription: viewrequestsDescription:
'Grant permission to view media requests submitted by other users.', 'Grant permission to view media requests submitted by other users.',
@@ -182,6 +191,12 @@ export const PermissionEdit = ({
description: intl.formatMessage(messages.requestTvDescription), description: intl.formatMessage(messages.requestTvDescription),
permission: Permission.REQUEST_TV, 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',
},
],
},
], ],
}, },
{ {

View File

@@ -4,11 +4,12 @@ import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
interface PersonCardProps { interface PersonCardProps {
personId: number; personId: number | string;
name: string; name: string;
subName?: string; subName?: string;
profilePath?: string; profilePath?: string;
canExpand?: boolean; canExpand?: boolean;
mediaType?: 'person' | 'artist';
} }
const PersonCard = ({ const PersonCard = ({
@@ -17,6 +18,7 @@ const PersonCard = ({
subName, subName,
profilePath, profilePath,
canExpand = false, canExpand = false,
mediaType = 'person',
}: PersonCardProps) => { }: PersonCardProps) => {
const [isHovered, setHovered] = useState(false); const [isHovered, setHovered] = useState(false);
@@ -51,8 +53,12 @@ const PersonCard = ({
{profilePath ? ( {profilePath ? (
<div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700"> <div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700">
<CachedImage <CachedImage
type="tmdb" type={mediaType === 'person' ? 'tmdb' : 'music'}
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`} src={
mediaType === 'person'
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`
: profilePath
}
alt="" alt=""
style={{ style={{
width: '100%', width: '100%',

Some files were not shown because too many files have changed in this diff Show More