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",
"dayjs": "1.11.19",
"dns-caching": "^0.2.7",
"email-templates": "12.0.3",
"dompurify": "^3.2.3",
"email-templates": "12.0.1",
"express": "4.21.2",
"express-openapi-validator": "4.13.8",
"express-rate-limit": "6.7.0",

13
pnpm-lock.yaml generated
View File

@@ -86,6 +86,9 @@ importers:
dns-caching:
specifier: ^0.2.7
version: 0.2.7
dompurify:
specifier: ^3.2.3
version: 3.2.3
email-templates:
specifier: 12.0.3
version: 12.0.3(@babel/core@7.24.7)(encoding@0.1.13)(handlebars@4.7.8)(mustache@4.2.0)(pug@3.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(underscore@1.13.7)
@@ -4800,6 +4803,9 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dompurify@3.2.3:
resolution: {integrity: sha512-U1U5Hzc2MO0oW3DF+G9qYN0aT7atAou4AgI0XjWz061nyBPbdxkfdhfy5uMgGn6+oLFCfn44ZGbdDqCzVmlOWA==}
domutils@2.8.0:
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
@@ -13615,6 +13621,9 @@ snapshots:
'@types/ua-parser-js@0.7.39': {}
'@types/trusted-types@2.0.7':
optional: true
'@types/unist@2.0.10': {}
'@types/validator@13.15.10': {}
@@ -15198,6 +15207,10 @@ snapshots:
dependencies:
domelementtype: 2.3.0
dompurify@3.2.3:
optionalDependencies:
'@types/trusted-types': 2.0.7
domutils@2.8.0:
dependencies:
dom-serializer: 1.4.1

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

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

View File

@@ -124,7 +124,7 @@ export interface PlexWatchlistItem {
ratingKey: string;
tmdbId: number;
tvdbId?: number;
type: 'movie' | 'show';
type: 'movie' | 'show' | 'album';
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,
TmdbPersonCombinedCredits,
TmdbPersonDetails,
TmdbPersonSearchResponse,
TmdbProductionCompany,
TmdbRegion,
TmdbSearchMovieResponse,
@@ -230,6 +231,31 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
}
};
public async searchPerson({
query,
page = 1,
includeAdult = false,
language = 'en',
}: SearchOptions): Promise<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 ({
personId,
language = this.locale,

View File

@@ -469,3 +469,15 @@ export interface TmdbWatchProviderRegion {
english_name: string;
native_name: string;
}
export interface TmdbPersonSearchResponse extends TmdbPaginatedResponse {
results: TmdbPersonSearchResult[];
}
export interface TmdbPersonSearchResult
extends Omit<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,
RECENT_REQUESTS,
PLEX_WATCHLIST,
POPULAR_ALBUMS,
TRENDING,
POPULAR_MOVIES,
MOVIE_GENRES,
@@ -50,51 +51,57 @@ export const defaultSliders: Partial<DiscoverSlider>[] = [
order: 3,
},
{
type: DiscoverSliderType.POPULAR_MOVIES,
type: DiscoverSliderType.POPULAR_ALBUMS,
enabled: true,
isBuiltIn: true,
order: 4,
},
{
type: DiscoverSliderType.MOVIE_GENRES,
type: DiscoverSliderType.POPULAR_MOVIES,
enabled: true,
isBuiltIn: true,
order: 5,
},
{
type: DiscoverSliderType.UPCOMING_MOVIES,
type: DiscoverSliderType.MOVIE_GENRES,
enabled: true,
isBuiltIn: true,
order: 6,
},
{
type: DiscoverSliderType.STUDIOS,
type: DiscoverSliderType.UPCOMING_MOVIES,
enabled: true,
isBuiltIn: true,
order: 7,
},
{
type: DiscoverSliderType.POPULAR_TV,
type: DiscoverSliderType.STUDIOS,
enabled: true,
isBuiltIn: true,
order: 8,
},
{
type: DiscoverSliderType.TV_GENRES,
type: DiscoverSliderType.POPULAR_TV,
enabled: true,
isBuiltIn: true,
order: 9,
},
{
type: DiscoverSliderType.UPCOMING_TV,
type: DiscoverSliderType.TV_GENRES,
enabled: true,
isBuiltIn: true,
order: 10,
},
{
type: DiscoverSliderType.NETWORKS,
type: DiscoverSliderType.UPCOMING_TV,
enabled: true,
isBuiltIn: true,
order: 11,
},
{
type: DiscoverSliderType.NETWORKS,
enabled: true,
isBuiltIn: true,
order: 12,
},
];

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus, MediaType } from '@server/constants/media';
@@ -29,33 +30,39 @@ import Season from './Season';
class Media {
public static async getRelatedMedia(
user: User | undefined,
tmdbIds: number | number[]
ids: (number | string)[]
): Promise<Media[]> {
const mediaRepository = getRepository(Media);
try {
let finalIds: number[];
if (!Array.isArray(tmdbIds)) {
finalIds = [tmdbIds];
} else {
finalIds = tmdbIds;
}
if (finalIds.length === 0) {
if (ids.length === 0) {
return [];
}
const media = await mediaRepository
const tmdbIds = ids.filter((id): id is number => typeof id === 'number');
const mbIds = ids.filter((id): id is string => typeof id === 'string');
const queryBuilder = mediaRepository
.createQueryBuilder('media')
.leftJoinAndSelect(
'media.watchlists',
'watchlist',
'media.id= watchlist.media and watchlist.requestedBy = :userId',
'media.id = watchlist.media and watchlist.requestedBy = :userId',
{ userId: user?.id }
) //,
.where(' media.tmdbId in (:...finalIds)', { finalIds })
.getMany();
);
if (tmdbIds.length > 0 && mbIds.length > 0) {
queryBuilder.where(
'(media.tmdbId IN (:...tmdbIds) OR media.mbId IN (:...mbIds))',
{ tmdbIds, mbIds }
);
} else if (tmdbIds.length > 0) {
queryBuilder.where('media.tmdbId IN (:...tmdbIds)', { tmdbIds });
} else if (mbIds.length > 0) {
queryBuilder.where('media.mbId IN (:...mbIds)', { mbIds });
}
const media = await queryBuilder.getMany();
return media;
} catch (e) {
logger.error(e.message);
@@ -64,14 +71,19 @@ class Media {
}
public static async getMedia(
id: number,
id: number | string,
mediaType: MediaType
): Promise<Media | undefined> {
const mediaRepository = getRepository(Media);
try {
const whereClause =
typeof id === 'string'
? { mbId: id, mediaType }
: { tmdbId: id, mediaType };
const media = await mediaRepository.findOne({
where: { tmdbId: id, mediaType: mediaType },
where: whereClause,
relations: { requests: true, issues: true },
});
@@ -88,7 +100,7 @@ class Media {
@Column({ type: 'varchar' })
public mediaType: MediaType;
@Column()
@Column({ nullable: true })
@Index()
public tmdbId: number;
@@ -100,6 +112,10 @@ class Media {
@Index()
public imdbId?: string;
@Column({ nullable: true })
@Index()
public mbId?: string;
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
public status: MediaStatus;
@@ -319,6 +335,21 @@ class Media {
}
}
}
if (this.mediaType === MediaType.MUSIC) {
if (this.serviceId !== null && this.externalServiceSlug !== null) {
const settings = getSettings();
const server = settings.lidarr.find(
(lidarr) => lidarr.id === this.serviceId
);
if (server) {
this.serviceUrl = server.externalUrl
? `${server.externalUrl}/album/${this.externalServiceSlug}`
: LidarrAPI.buildUrl(server, `/album/${this.externalServiceSlug}`);
}
}
}
}
@AfterLoad()
@@ -374,6 +405,20 @@ class Media {
);
}
}
if (this.mediaType === MediaType.MUSIC) {
if (
this.externalServiceId !== undefined &&
this.externalServiceId !== null &&
this.serviceId !== undefined &&
this.serviceId !== null
) {
this.downloadStatus = downloadTracker.getMusicProgress(
this.serviceId,
this.externalServiceId
);
}
}
}
}

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

View File

@@ -124,6 +124,12 @@ export class User {
@Column({ nullable: true })
public tvQuotaDays?: number;
@Column({ nullable: true })
public musicQuotaLimit?: number;
@Column({ nullable: true })
public musicQuotaDays?: number;
@OneToOne(() => UserSettings, (settings) => settings.user, {
cascade: true,
eager: true,
@@ -334,6 +340,30 @@ export class User {
).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0)
: 0;
const musicQuotaLimit = !canBypass
? this.musicQuotaLimit ?? defaultQuotas.music.quotaLimit
: 0;
const musicQuotaDays = this.musicQuotaDays ?? defaultQuotas.music.quotaDays;
// Count music requests made during quota period
const musicDate = new Date();
if (musicQuotaDays) {
musicDate.setDate(musicDate.getDate() - musicQuotaDays);
}
const musicQuotaUsed = musicQuotaLimit
? await requestRepository.count({
where: {
requestedBy: {
id: this.id,
},
createdAt: AfterDate(musicDate),
type: MediaType.MUSIC,
status: Not(MediaRequestStatus.DECLINED),
},
})
: 0;
return {
movie: {
days: movieQuotaDays,
@@ -357,6 +387,18 @@ export class User {
restricted:
tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false,
},
music: {
days: musicQuotaDays,
limit: musicQuotaLimit,
used: musicQuotaUsed,
remaining: musicQuotaLimit
? Math.max(0, musicQuotaLimit - musicQuotaUsed)
: undefined,
restricted:
musicQuotaLimit && musicQuotaLimit - musicQuotaUsed <= 0
? true
: false,
},
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
jellyfinFullScanner,
jellyfinRecentScanner,
} from '@server/lib/scanners/jellyfin';
import { lidarrScanner } from '@server/lib/scanners/lidarr';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
import { radarrScanner } from '@server/lib/scanners/radarr';
import { sonarrScanner } from '@server/lib/scanners/sonarr';
@@ -172,6 +173,21 @@ export const startJobs = (): void => {
cancelFn: () => sonarrScanner.cancel(),
});
// Run full lidarr scan every 24 hours
scheduledJobs.push({
id: 'lidarr-scan',
name: 'Lidarr Scan',
type: 'process',
interval: 'hours',
cronSchedule: jobs['lidarr-scan'].schedule,
job: schedule.scheduleJob(jobs['lidarr-scan'].schedule, () => {
logger.info('Starting scheduled job: lidarr Scan', { label: 'Jobs' });
lidarrScanner.run();
}),
running: () => lidarrScanner.status().running,
cancelFn: () => lidarrScanner.cancel(),
});
// Checks if media is still available in plex/sonarr/radarr libs
scheduledJobs.push({
id: 'availability-sync',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ export interface MediaIds {
imdbId?: string;
tvdbId?: number;
isHama?: boolean;
mbId?: string;
}
interface ProcessOptions {
@@ -79,11 +80,24 @@ class BaseScanner<T> {
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 query: Record<string, any> = {
mediaType,
};
if (mediaType === MediaType.MUSIC) {
query.mbId = id.toString();
} else {
query.tmdbId = Number(id);
}
const existing = await mediaRepository.findOne({
where: { tmdbId: tmdbId, mediaType },
where: query,
});
return existing;
@@ -526,6 +540,61 @@ class BaseScanner<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
* 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[]) {
this.processedAnidbSeason = new Map();
await Promise.all(
@@ -699,6 +783,8 @@ class JellyfinScanner {
await this.processMovie(item);
} else if (item.Type === 'Series') {
await this.processShow(item);
} else if (item.Type === 'MusicAlbum') {
await this.processMusic(item);
}
})
);

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 type {
TmdbCollectionResult,
TmdbMovieDetails,
TmdbMovieResult,
TmdbPersonDetails,
TmdbPersonResult,
TmdbSearchMovieResponse,
TmdbSearchMultiResponse,
TmdbSearchTvResponse,
TmdbTvDetails,
TmdbTvResult,
@@ -21,6 +26,19 @@ import {
isTvDetails,
} from '@server/utils/typeHelpers';
export type CombinedSearchResponse = {
page: number;
total_pages: number;
total_results: number;
results: (
| MbArtistResult
| MbAlbumResult
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
)[];
};
interface SearchProvider {
pattern: RegExp;
search: ({
@@ -31,7 +49,7 @@ interface SearchProvider {
id: string;
language?: string;
query?: string;
}) => Promise<TmdbSearchMultiResponse>;
}) => Promise<CombinedSearchResponse>;
}
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;
name: string;
enabled: boolean;
type: 'show' | 'movie';
type: 'show' | 'movie' | 'music';
lastScan?: number;
}
@@ -83,6 +83,17 @@ export interface RadarrSettings extends DVRSettings {
minimumAvailability: string;
}
export interface LidarrSettings extends DVRSettings {
url: string;
apiKey: string;
activeProfileId: number;
activeDirectory: string;
isDefault: boolean;
is4k: boolean;
tagRequests: boolean;
preventSearch: boolean;
}
export interface SonarrSettings extends DVRSettings {
seriesType: 'standard' | 'daily' | 'anime';
animeSeriesType: 'standard' | 'daily' | 'anime';
@@ -130,6 +141,7 @@ export interface MainSettings {
defaultQuotas: {
movie: Quota;
tv: Quota;
music: Quota;
};
hideAvailable: boolean;
hideBlacklisted: boolean;
@@ -340,6 +352,7 @@ export type JobId =
| 'plex-refresh-token'
| 'radarr-scan'
| 'sonarr-scan'
| 'lidarr-scan'
| 'download-sync'
| 'download-sync-reset'
| 'jellyfin-recently-added-scan'
@@ -358,6 +371,7 @@ export interface AllSettings {
tautulli: TautulliSettings;
radarr: RadarrSettings[];
sonarr: SonarrSettings[];
lidarr: LidarrSettings[];
public: PublicSettings;
notifications: NotificationSettings;
jobs: Record<JobId, JobSettings>;
@@ -387,6 +401,7 @@ class Settings {
defaultQuotas: {
movie: {},
tv: {},
music: {},
},
hideAvailable: false,
hideBlacklisted: false,
@@ -429,6 +444,7 @@ class Settings {
anime: MetadataProviderType.TMDB,
},
radarr: [],
lidarr: [],
sonarr: [],
public: {
initialized: false,
@@ -552,6 +568,9 @@ class Settings {
'sonarr-scan': {
schedule: '0 30 4 * * *',
},
'lidarr-scan': {
schedule: '0 30 4 * * *',
},
'availability-sync': {
schedule: '0 0 5 * * *',
},
@@ -649,6 +668,14 @@ class Settings {
this.data.radarr = data;
}
get lidarr(): LidarrSettings[] {
return this.data.lidarr;
}
set lidarr(data: LidarrSettings[]) {
this.data.lidarr = data;
}
get sonarr(): SonarrSettings[] {
return this.data.sonarr;
}

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;
imdbId?: string;
homepage?: string;
mbArtistId?: string;
}
export interface PersonCredit {

View File

@@ -1,3 +1,10 @@
import type {
MbAlbumDetails,
MbAlbumResult,
MbArtistDetails,
MbArtistResult,
MbImage,
} from '@server/api/musicbrainz/interfaces';
import type {
TmdbCollectionResult,
TmdbMovieDetails,
@@ -9,10 +16,15 @@ import type {
} from '@server/api/themoviedb/interfaces';
import { MediaType as MainMediaType } from '@server/constants/media';
import type Media from '@server/entity/Media';
export type MediaType =
| 'tv'
| 'movie'
| 'person'
| 'collection'
| 'artist'
| 'album';
export type MediaType = 'tv' | 'movie' | 'person' | 'collection';
interface SearchResult {
interface TmdbSearchResult {
id: number;
mediaType: MediaType;
popularity: number;
@@ -26,7 +38,14 @@ interface SearchResult {
mediaInfo?: Media;
}
export interface MovieResult extends SearchResult {
interface MbSearchResult {
id: string;
mediaType: MediaType;
score: number;
mediaInfo?: Media;
}
export interface MovieResult extends TmdbSearchResult {
mediaType: 'movie';
title: string;
originalTitle: string;
@@ -36,7 +55,7 @@ export interface MovieResult extends SearchResult {
mediaInfo?: Media;
}
export interface TvResult extends SearchResult {
export interface TvResult extends TmdbSearchResult {
mediaType: 'tv';
name: string;
originalName: string;
@@ -66,7 +85,46 @@ export interface PersonResult {
knownFor: (MovieResult | TvResult)[];
}
export type Results = MovieResult | TvResult | PersonResult | CollectionResult;
export interface ArtistResult extends MbSearchResult {
mediaType: 'artist';
artistname: string;
overview: string;
disambiguation: string;
type: 'Group' | 'Person';
status: string;
sortname: string;
genres: string[];
images: MbImage[];
artistimage?: string;
rating?: {
Count: number;
Value: number | null;
};
mediaInfo?: Media;
}
export interface AlbumResult extends MbSearchResult {
mediaType: 'album';
title: string;
artistid: string;
artistname?: string;
type: string;
releasedate: string;
disambiguation: string;
genres: string[];
images: MbImage[];
secondarytypes: string[];
mediaInfo?: Media;
overview?: string;
}
export type Results =
| MovieResult
| TvResult
| PersonResult
| CollectionResult
| ArtistResult
| AlbumResult;
export const mapMovieResult = (
movieResult: TmdbMovieResult,
@@ -144,18 +202,131 @@ export const mapPersonResult = (
}),
});
export const mapSearchResults = (
export const mapArtistResult = (
artistResult: MbArtistResult,
media?: Media
): ArtistResult => ({
id: artistResult.id,
score: artistResult.score,
mediaType: 'artist',
artistname: artistResult.artistname,
overview: artistResult.overview,
disambiguation: artistResult.disambiguation,
type: artistResult.type,
status: artistResult.status,
sortname: artistResult.sortname,
genres: artistResult.genres,
images: artistResult.images,
rating: artistResult.rating,
mediaInfo: media,
});
export const mapAlbumResult = (
albumResult: MbAlbumResult,
media?: Media
): AlbumResult => ({
id: albumResult.id,
score: albumResult.score,
mediaType: 'album',
title: albumResult.title,
artistid: albumResult.artistid,
artistname: albumResult.artists?.[0]?.artistname,
type: albumResult.type,
releasedate: albumResult.releasedate,
disambiguation: albumResult.disambiguation,
genres: albumResult.genres,
images: albumResult.images,
secondarytypes: albumResult.secondarytypes,
mediaInfo: media,
overview: albumResult.artists?.[0]?.overview,
});
const isTmdbMovie = (
result:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
| MbArtistResult
| MbAlbumResult
): result is TmdbMovieResult => {
return result.media_type === 'movie';
};
const isTmdbTv = (
result:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
| MbArtistResult
| MbAlbumResult
): result is TmdbTvResult => {
return result.media_type === 'tv';
};
const isTmdbPerson = (
result:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
| MbArtistResult
| MbAlbumResult
): result is TmdbPersonResult => {
return result.media_type === 'person';
};
const isTmdbCollection = (
result:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
| MbArtistResult
| MbAlbumResult
): result is TmdbCollectionResult => {
return result.media_type === 'collection';
};
const isLidarrArtist = (
result:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
| MbArtistResult
| MbAlbumResult
): result is MbArtistResult => {
return result.media_type === 'artist';
};
const isLidarrAlbum = (
result:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
| MbArtistResult
| MbAlbumResult
): result is MbAlbumResult => {
return result.media_type === 'album';
};
export const mapSearchResults = async (
results: (
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
| MbArtistResult
| MbAlbumResult
)[],
media?: Media[]
): Results[] =>
results.map((result) => {
switch (result.media_type) {
case 'movie':
): Promise<Results[]> =>
Promise.all(
results.map(async (result) => {
if (isTmdbMovie(result)) {
return mapMovieResult(
result,
media?.find(
@@ -163,7 +334,7 @@ export const mapSearchResults = (
req.tmdbId === result.id && req.mediaType === MainMediaType.MOVIE
)
);
case 'tv':
} else if (isTmdbTv(result)) {
return mapTvResult(
result,
media?.find(
@@ -171,12 +342,25 @@ export const mapSearchResults = (
req.tmdbId === result.id && req.mediaType === MainMediaType.TV
)
);
case 'collection':
return mapCollectionResult(result);
default:
} else if (isTmdbPerson(result)) {
return mapPersonResult(result);
}
});
} else if (isTmdbCollection(result)) {
return mapCollectionResult(result);
} else if (isLidarrArtist(result)) {
return mapArtistResult(result);
} else if (isLidarrAlbum(result)) {
return mapAlbumResult(
result,
media?.find(
(req) =>
req.mbId === result.id && req.mediaType === MainMediaType.MUSIC
)
);
}
throw new Error(`Unhandled result type: ${JSON.stringify(result)}`);
})
);
export const mapMovieDetailsToResult = (
movieDetails: TmdbMovieDetails
@@ -228,3 +412,39 @@ export const mapPersonDetailsToResult = (
profile_path: personDetails.profile_path,
known_for: [],
});
export const mapArtistDetailsToResult = (
artistDetails: MbArtistDetails
): MbArtistResult => ({
id: artistDetails.id,
score: 100, // Default score since we're mapping details
media_type: 'artist',
artistname: artistDetails.artistname,
overview: artistDetails.overview,
disambiguation: artistDetails.disambiguation,
type: artistDetails.type,
status: artistDetails.status,
sortname: artistDetails.sortname,
genres: artistDetails.genres,
images: artistDetails.images,
links: artistDetails.links,
rating: artistDetails.rating,
});
export const mapAlbumDetailsToResult = (
albumDetails: MbAlbumDetails
): MbAlbumResult => ({
id: albumDetails.id,
score: 100,
media_type: 'album',
title: albumDetails.title,
artistid: albumDetails.artistid,
artists: albumDetails.artists,
type: albumDetails.type,
releasedate: albumDetails.releasedate,
disambiguation: albumDetails.disambiguation,
genres: albumDetails.genres,
images: albumDetails.images,
secondarytypes: albumDetails.secondarytypes,
overview: albumDetails.overview || albumDetails.artists?.[0]?.overview || '',
});

View File

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

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 type { SortOptions } from '@server/api/themoviedb';
import TheMovieDb from '@server/api/themoviedb';
@@ -854,6 +856,131 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
}
);
discoverRoutes.get('/music', async (req, res, next) => {
const listenbrainz = new ListenBrainzAPI();
const musicbrainz = new MusicBrainz();
try {
const page = Number(req.query.page) || 1;
const pageSize = 20;
const offset = (page - 1) * pageSize;
const sortBy = (req.query.sortBy as string) || 'listen_count.desc';
const data = await listenbrainz.getTopAlbums({
offset,
count: pageSize,
range: 'week',
});
const media = await Media.getRelatedMedia(
req.user,
data.payload.release_groups.map((album) => album.release_group_mbid)
);
const albumDetailsPromises = data.payload.release_groups.map(
async (album) => {
try {
const details = await musicbrainz.getAlbum({
albumId: album.release_group_mbid,
});
const images =
details.images?.length > 0
? details.images.filter((img) => img.CoverType === 'Cover')
: album.caa_id
? [
{
CoverType: 'Cover',
Url: `https://coverartarchive.org/release/${album.caa_release_mbid}/front`,
},
]
: [];
return {
id: album.release_group_mbid,
mediaType: 'album',
type: 'Album',
title: album.release_group_name,
artistname: album.artist_name,
artistId: album.artist_mbids[0],
releasedate: details.releasedate || '',
images,
mediaInfo: media?.find(
(med) => med.mbId === album.release_group_mbid
),
listenCount: album.listen_count,
};
} catch (e) {
return {
id: album.release_group_mbid,
mediaType: 'album',
type: 'Album',
title: album.release_group_name,
artistname: album.artist_name,
artistId: album.artist_mbids[0],
releasedate: '',
images: album.caa_id
? [
{
CoverType: 'Cover',
Url: `https://coverartarchive.org/release/${album.caa_release_mbid}/front`,
},
]
: [],
mediaInfo: media?.find(
(med) => med.mbId === album.release_group_mbid
),
listenCount: album.listen_count,
};
}
}
);
const results = await Promise.all(albumDetailsPromises);
switch (sortBy) {
case 'listen_count.asc':
results.sort((a, b) => a.listenCount - b.listenCount);
break;
case 'listen_count.desc':
results.sort((a, b) => b.listenCount - a.listenCount);
break;
case 'title.asc':
results.sort((a, b) => a.title.localeCompare(b.title));
break;
case 'title.desc':
results.sort((a, b) => b.title.localeCompare(a.title));
break;
case 'release_date.asc':
results.sort((a, b) =>
(a.releasedate || '').localeCompare(b.releasedate || '')
);
break;
case 'release_date.desc':
results.sort((a, b) =>
(b.releasedate || '').localeCompare(a.releasedate || '')
);
break;
}
return res.status(200).json({
page,
totalPages: Math.ceil(data.payload.count / pageSize),
totalResults: data.payload.count,
results,
});
} catch (e) {
logger.debug('Something went wrong retrieving popular music', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve popular music.',
});
}
});
discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
'/watchlist',
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 collectionRoutes from './collection';
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
import groupRoutes from './group';
import issueRoutes from './issue';
import issueCommentRoutes from './issueComment';
import mediaRoutes from './media';
import movieRoutes from './movie';
import musicRoutes from './music';
import personRoutes from './person';
import requestRoutes from './request';
import searchRoutes from './search';
@@ -154,8 +156,10 @@ router.use('/watchlist', isAuthenticated(), watchlistRoutes);
router.use('/blacklist', isAuthenticated(), blacklistRoutes);
router.use('/movie', isAuthenticated(), movieRoutes);
router.use('/tv', isAuthenticated(), tvRoutes);
router.use('/music', isAuthenticated(), musicRoutes);
router.use('/media', isAuthenticated(), mediaRoutes);
router.use('/person', isAuthenticated(), personRoutes);
router.use('/group', isAuthenticated(), groupRoutes);
router.use('/collection', isAuthenticated(), collectionRoutes);
router.use('/service', isAuthenticated(), serviceRoutes);
router.use('/issue', isAuthenticated(), issueRoutes);

View File

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

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

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 Media from '@server/entity/Media';
import logger from '@server/logger';
@@ -12,13 +14,48 @@ const personRoutes = Router();
personRoutes.get('/:id', async (req, res, next) => {
const tmdb = new TheMovieDb();
const musicBrainz = new MusicBrainz();
try {
const person = await tmdb.getPerson({
personId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale,
});
return res.status(200).json(mapPersonDetails(person));
let mbArtistId = null;
try {
const artists = await musicBrainz.searchArtist({
query: person.name,
});
const matchedArtist = artists.find((artist) => {
if (artist.type !== 'Person') {
return false;
}
const nameMatches =
artist.artistname.toLowerCase() === person.name.toLowerCase();
const aliasMatches = artist.artistaliases?.some(
(alias) => alias.toLowerCase() === person.name.toLowerCase()
);
return nameMatches || aliasMatches;
});
if (matchedArtist) {
mbArtistId = matchedArtist.id;
}
} catch (e) {
logger.debug('Failed to fetch music artist data', {
label: 'API',
errorMessage: e.message,
personName: person.name,
});
}
return res.status(200).json({
...mapPersonDetails(person),
mbArtistId,
});
} catch (e) {
logger.debug('Something went wrong retrieving person', {
label: 'API',
@@ -32,6 +69,148 @@ personRoutes.get('/:id', async (req, res, next) => {
}
});
personRoutes.get('/:id/discography', async (req, res, next) => {
const musicBrainz = new MusicBrainz();
const tmdb = new TheMovieDb();
const coverArtArchive = new CoverArtArchive();
const artistId = req.query.artistId as string;
const type = req.query.type as string;
const page = Number(req.query.page) || 1;
const pageSize = 20;
if (!artistId) {
return next({
status: 400,
message: 'Artist ID is required',
});
}
const person = await tmdb.getPerson({
personId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale,
});
if (!person.birthday) {
return res.status(200).json({
page: 1,
pageInfo: { total: 0, totalPages: 0 },
results: [],
});
}
try {
const artistDetails = await musicBrainz.getArtist({
artistId: artistId,
});
const { mapArtistDetails } = await import('@server/models/Artist');
const mappedDetails = await mapArtistDetails(artistDetails);
if (!mappedDetails.Albums?.length) {
return res.status(200).json({
page: 1,
pageInfo: {
total: 0,
totalPages: 0,
},
results: [],
});
}
let filteredAlbums = mappedDetails.Albums;
if (type) {
if (type === 'Other') {
filteredAlbums = mappedDetails.Albums.filter(
(album) => !['Album', 'Single', 'EP'].includes(album.type)
);
} else {
filteredAlbums = mappedDetails.Albums.filter(
(album) => album.type === type
);
}
}
const albumPromises = filteredAlbums.map(async (album) => {
try {
const albumDetails = await musicBrainz.getAlbum({
albumId: album.id,
});
let images = albumDetails.images;
if (!images || images.length === 0) {
try {
const coverArtData = await coverArtArchive.getCoverArt(album.id);
if (coverArtData.images?.length > 0) {
images = coverArtData.images.map((img) => ({
CoverType: img.front ? 'Cover' : 'Poster',
Url: img.image,
}));
}
} catch (coverArtError) {
// Silently handle cover art fetch errors
}
}
return {
...album,
images: images || [],
releasedate: albumDetails.releasedate || '',
};
} catch (e) {
return album;
}
});
const albumsWithDetails = await Promise.all(albumPromises);
const sortedAlbums = albumsWithDetails.sort((a, b) => {
if (!a.releasedate && !b.releasedate) return 0;
if (!a.releasedate) return 1;
if (!b.releasedate) return -1;
return (
new Date(b.releasedate).getTime() - new Date(a.releasedate).getTime()
);
});
const totalResults = sortedAlbums.length;
const totalPages = Math.ceil(totalResults / pageSize);
const start = (page - 1) * pageSize;
const end = start + pageSize;
const paginatedAlbums = sortedAlbums.slice(start, end);
const media = await Media.getRelatedMedia(
req.user,
paginatedAlbums.map((album) => album.id)
);
const results = paginatedAlbums.map((album) => ({
...album,
mediaInfo: media?.find((med) => med.mbId === album.id),
}));
return res.status(200).json({
page,
pageInfo: {
total: totalResults,
totalPages,
},
results,
});
} catch (e) {
logger.error('Something went wrong retrieving discography', {
label: 'Person API',
errorMessage: e.message,
personId: req.params.id,
artistId,
});
return next({
status: 500,
message: 'Unable to retrieve discography.',
});
}
});
personRoutes.get('/:id/combined_credits', async (req, res, next) => {
const tmdb = new TheMovieDb();

View File

@@ -1,3 +1,4 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import {
@@ -213,6 +214,21 @@ requestRoutes.get<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
let mappedRequests = requests.map((r) => {
switch (r.type) {
@@ -234,6 +250,14 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
?.profiles?.find((profile) => profile.id === r.profileId)?.name,
};
}
case MediaType.MUSIC: {
return {
...r,
profileName: lidarrServers
.find((serverr) => serverr.id === r.serverId)
?.profiles?.find((profile) => profile.id === r.profileId)?.name,
};
}
}
});

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

View File

@@ -1,3 +1,4 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
@@ -213,4 +214,69 @@ serviceRoutes.get<{ tmdbId: string }>(
}
);
serviceRoutes.get('/lidarr', async (req, res) => {
const settings = getSettings();
const filteredLidarrServers: ServiceCommonServer[] = settings.lidarr.map(
(lidarr) => ({
id: lidarr.id,
name: lidarr.name,
activeDirectory: lidarr.activeDirectory,
activeProfileId: lidarr.activeProfileId,
activeTags: lidarr.tags ?? [],
isDefault: lidarr.isDefault,
})
);
return res.status(200).json(filteredLidarrServers);
});
serviceRoutes.get<{ id: string }>('/lidarr/:id', async (req, res, next) => {
const settings = getSettings();
const lidarrSettings = settings.lidarr.find(
(lidarr) => lidarr.id === Number(req.params.id)
);
if (!lidarrSettings) {
return next({
status: 404,
message: 'Lidarr server not found.',
});
}
const lidarr = new LidarrAPI({
apiKey: lidarrSettings.apiKey,
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
});
try {
const [profiles, rootFolders, tags] = await Promise.all([
lidarr.getProfiles(),
lidarr.getRootFolders(),
lidarr.getTags(),
]);
return res.status(200).json({
server: {
id: lidarrSettings.id,
name: lidarrSettings.name,
isDefault: lidarrSettings.isDefault,
activeDirectory: lidarrSettings.activeDirectory,
activeProfileId: lidarrSettings.activeProfileId,
},
profiles,
rootFolders: rootFolders.map((folder) => ({
id: folder.id,
path: folder.path,
freeSpace: folder.freeSpace,
totalSpace: folder.totalSpace,
})),
tags,
} as ServiceCommonServerWithDetails);
} catch (e) {
next({ status: 500, message: e.message });
}
});
export default serviceRoutes;

View File

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

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

View File

@@ -1,3 +1,4 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import TheMovieDb from '@server/api/themoviedb';
import { IssueType, IssueTypeName } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
@@ -7,6 +8,7 @@ import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { sortBy } from 'lodash';
import type { EntitySubscriberInterface, InsertEvent } from 'typeorm';
@@ -21,8 +23,8 @@ export class IssueCommentSubscriber
}
private async sendIssueCommentNotification(entity: IssueComment) {
let title: string;
let image: string;
let title = '';
let image = '';
const tmdb = new TheMovieDb();
try {
@@ -48,13 +50,33 @@ export class IssueCommentSubscriber
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
} else {
} else if (media.mediaType === MediaType.TV) {
const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId });
title = `${tvshow.name}${
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
}`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
} else if (media.mediaType === MediaType.MUSIC) {
const settings = getSettings();
if (!settings.lidarr[0]) {
throw new Error('No Lidarr server configured');
}
const lidarr = new LidarrAPI({
apiKey: settings.lidarr[0].apiKey,
url: LidarrAPI.buildUrl(settings.lidarr[0], '/api/v1'),
});
if (!media.mbId) {
throw new Error('MusicBrainz ID is undefined');
}
const album = await lidarr.getAlbumByMusicBrainzId(media.mbId);
const artist = await lidarr.getArtist({ id: album.artistId });
title = `${artist.artistName} - ${album.title}`;
image = album.images?.[0]?.url ?? '';
}
const [firstComment] = sortBy(issue.comments, 'id');

View File

@@ -1,9 +1,11 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import TheMovieDb from '@server/api/themoviedb';
import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import Issue from '@server/entity/Issue';
import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { sortBy } from 'lodash';
import type {
@@ -20,8 +22,8 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
}
private async sendIssueNotification(entity: Issue, type: Notification) {
let title: string;
let image: string;
let title = '';
let image = '';
const tmdb = new TheMovieDb();
try {
@@ -32,13 +34,33 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
} else {
} else if (entity.media.mediaType === MediaType.TV) {
const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
title = `${tvshow.name}${
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
}`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
} else if (entity.media.mediaType === MediaType.MUSIC) {
const settings = getSettings();
if (!settings.lidarr[0]) {
throw new Error('No Lidarr server configured');
}
const lidarr = new LidarrAPI({
apiKey: settings.lidarr[0].apiKey,
url: LidarrAPI.buildUrl(settings.lidarr[0], '/api/v1'),
});
if (!entity.media.mbId) {
throw new Error('MusicBrainz ID is undefined');
}
const album = await lidarr.getAlbumByMusicBrainzId(entity.media.mbId);
const artist = await lidarr.getArtist({ id: album.artistId });
title = `${artist.artistName} - ${album.title}`;
image = album.images?.[0]?.url ?? '';
}
const [firstComment] = sortBy(entity.comments, 'id');

View File

@@ -1,3 +1,9 @@
import type {
LidarrAlbumDetails,
LidarrAlbumResult,
LidarrArtistDetails,
LidarrArtistResult,
} from '@server/api/servarr/lidarr';
import type {
TmdbCollectionResult,
TmdbMovieDetails,
@@ -38,6 +44,18 @@ export const isCollection = (
return (collection as TmdbCollectionResult).media_type === 'collection';
};
export const isAlbum = (
media: LidarrAlbumResult | LidarrArtistResult
): media is LidarrAlbumResult => {
return (media as LidarrAlbumResult).album?.albumType !== undefined;
};
export const isArtist = (
media: LidarrAlbumResult | LidarrArtistResult
): media is LidarrArtistResult => {
return (media as LidarrArtistResult).artist?.artistType !== undefined;
};
export const isMovieDetails = (
movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails
): movie is TmdbMovieDetails => {
@@ -49,3 +67,15 @@ export const isTvDetails = (
): tv is TmdbTvDetails => {
return (tv as TmdbTvDetails).number_of_seasons !== undefined;
};
export const isAlbumDetails = (
details: LidarrAlbumDetails | LidarrArtistDetails
): details is LidarrAlbumDetails => {
return (details as LidarrAlbumDetails).albumType !== undefined;
};
export const isArtistDetails = (
details: LidarrAlbumDetails | LidarrArtistDetails
): details is LidarrArtistDetails => {
return (details as LidarrArtistDetails).artistType !== undefined;
};

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,
} from '@server/interfaces/api/blacklistInterfaces';
import type { MovieDetails } from '@server/models/Movie';
import type { MusicDetails } from '@server/models/Music';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import Link from 'next/link';
@@ -59,6 +60,12 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
const isMusic = (
media: MovieDetails | TvDetails | MusicDetails
): media is MusicDetails => {
return (media as MusicDetails).artistId !== undefined;
};
const Blacklist = () => {
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
@@ -277,12 +284,15 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
const { hasPermission } = useUser();
const url =
item.mediaType === 'movie'
item.mediaType === 'music'
? `/api/v1/music/${item.mbId}`
: item.mediaType === 'movie'
? `/api/v1/movie/${item.tmdbId}`
: `/api/v1/tv/${item.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? url : null
);
const { data: title, error } = useSWR<
MovieDetails | TvDetails | MusicDetails
>(inView ? url : null);
if (!title && !error) {
return (
@@ -293,11 +303,15 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
);
}
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
const removeFromBlacklist = async (
tmdbId?: number,
mbId?: string,
title?: string
) => {
setIsUpdating(true);
try {
await axios.delete(`/api/v1/blacklist/${tmdbId}`);
await axios.delete(`/api/v1/blacklist/${mbId ?? tmdbId}`);
addToast(
<span>
@@ -321,11 +335,24 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
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">
{title && title.backdropPath && (
{title && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
type={isMusic(title) ? 'music' : 'tmdb'}
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=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
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">
<Link
href={
item.mediaType === 'movie'
item.mediaType === 'music'
? `/music/${item.mbId}`
: item.mediaType === 'movie'
? `/movie/${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"
>
<CachedImage
type="tmdb"
type={title && isMusic(title) ? 'music' : 'tmdb'}
src={
title?.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
title
? 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'
}
alt=""
sizes="100vw"
style={{ width: '100%', height: 'auto', objectFit: 'cover' }}
width={600}
height={900}
height={title && isMusic(title) ? 600 : 900}
/>
</Link>
<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">
{title &&
(isMovie(title)
? title.releaseDate
: title.firstAirDate
)?.slice(0, 4)}
(isMusic(title)
? title.releaseDate?.slice(0, 4)
: isMovie(title)
? title.releaseDate?.slice(0, 4)
: title.firstAirDate?.slice(0, 4))}
</div>
<Link
href={
item.mediaType === 'movie'
item.mediaType === 'music'
? `/music/${item.mbId}`
: item.mediaType === 'movie'
? `/movie/${item.tmdbId}`
: `/tv/${item.tmdbId}`
}
>
<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>
</Link>
</div>
@@ -446,12 +488,18 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
{intl.formatMessage(globalMessages.movie)}
</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="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)}
</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>
@@ -462,7 +510,13 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
onClick={() =>
removeFromBlacklist(
item.tmdbId,
title && (isMovie(title) ? title.title : title.name)
item.mbId,
title &&
(isMusic(title)
? `${title.artist.artistName} - ${title.title}`
: isMovie(title)
? title.title
: title.name)
)
}
confirmText={intl.formatMessage(

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ const imageLoader: ImageLoader = ({ src }) => src;
export type CachedImageProps = ImageProps & {
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') {
// jellyfin avatar (if any)
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 {
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 TitleCard from '@app/components/TitleCard';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, useUser } from '@app/hooks/useUser';
import useVerticalScroll from '@app/hooks/useVerticalScroll';
import globalMessages from '@app/i18n/globalMessages';
import { MediaStatus } from '@server/constants/media';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import type {
AlbumResult,
ArtistResult,
CollectionResult,
MovieResult,
PersonResult,
@@ -15,7 +18,14 @@ import type {
import { useIntl } from 'react-intl';
type ListViewProps = {
items?: (TvResult | MovieResult | PersonResult | CollectionResult)[];
items?: (
| TvResult
| MovieResult
| PersonResult
| CollectionResult
| ArtistResult
| AlbumResult
)[];
plexItems?: WatchlistItem[];
isEmpty?: boolean;
isLoading?: boolean;
@@ -53,9 +63,10 @@ const ListView = ({
{plexItems?.map((title, index) => {
return (
<li key={`${title.ratingKey}-${index}`}>
<TmdbTitleCard
id={title.tmdbId}
tmdbId={title.tmdbId}
<AddedCard
id={title.tmdbId ?? 0}
tmdbId={title.tmdbId ?? 0}
mbId={title.mbId}
type={title.mediaType}
isAddedToWatchlist={true}
canExpand
@@ -68,8 +79,8 @@ const ListView = ({
?.filter((title) => {
if (!blacklistVisibility)
return (
(title as TvResult | MovieResult).mediaInfo?.status !==
MediaStatus.BLACKLISTED
(title as TvResult | MovieResult | AlbumResult).mediaInfo
?.status !== MediaStatus.BLACKLISTED
);
return title;
})
@@ -143,6 +154,53 @@ const ListView = ({
/>
);
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>;

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);
case DiscoverSliderType.TRENDING:
return intl.formatMessage(sliderTitles.trending);
case DiscoverSliderType.POPULAR_ALBUMS:
return intl.formatMessage(sliderTitles.popularalbums);
case DiscoverSliderType.POPULAR_MOVIES:
return intl.formatMessage(sliderTitles.popularmovies);
case DiscoverSliderType.MOVIE_GENRES:

View File

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

View File

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

View File

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

View File

@@ -219,6 +219,16 @@ const Discover = () => {
case DiscoverSliderType.PLEX_WATCHLIST:
sliderComponent = <PlexWatchlistSlider />;
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:
sliderComponent = (
<MediaSlider

View File

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

View File

@@ -11,6 +11,7 @@ import { IssueStatus } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie';
import type { MusicDetails } from '@server/models/Music';
import type { TvDetails } from '@server/models/Tv';
import Link from 'next/link';
import { useInView } from 'react-intersection-observer';
@@ -30,8 +31,19 @@ const messages = defineMessages('components.IssueList.IssueItem', {
descriptionpreview: 'Issue Description',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
const isMovie = (
media: MovieDetails | TvDetails | MusicDetails
): media is MovieDetails => {
return (
(media as MovieDetails).title !== undefined &&
(media as MovieDetails).releaseDate !== undefined
);
};
const isMusic = (
media: MovieDetails | TvDetails | MusicDetails
): media is MusicDetails => {
return (media as MusicDetails).artist !== undefined;
};
interface IssueItemProps {
@@ -45,12 +57,15 @@ const IssueItem = ({ issue }: IssueItemProps) => {
triggerOnce: true,
});
const url =
issue.media.mediaType === 'movie'
issue.media.mediaType === MediaType.MOVIE
? `/api/v1/movie/${issue.media.tmdbId}`
: `/api/v1/tv/${issue.media.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? url : null
);
: issue.media.mediaType === MediaType.TV
? `/api/v1/tv/${issue.media.tmdbId}`
: `/api/v1/music/${issue.media.mbId}`;
const { data: title, error } = useSWR<
MovieDetails | TvDetails | MusicDetails
>(inView ? url : null);
if (!title && !error) {
return (
@@ -118,11 +133,18 @@ const IssueItem = ({ issue }: IssueItemProps) => {
return (
<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">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
type={isMusic(title) ? 'music' : 'tmdb'}
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=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
fill
@@ -142,14 +164,19 @@ const IssueItem = ({ issue }: IssueItemProps) => {
href={
issue.media.mediaType === MediaType.MOVIE
? `/movie/${issue.media.tmdbId}`
: `/tv/${issue.media.tmdbId}`
: issue.media.mediaType === MediaType.TV
? `/tv/${issue.media.tmdbId}`
: `/music/${issue.media.mbId}`
}
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
>
<CachedImage
type="tmdb"
type={isMusic(title) ? 'music' : 'tmdb'}
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}`
: '/images/seerr_poster_not_found.png'
}
@@ -162,20 +189,28 @@ const IssueItem = ({ issue }: IssueItemProps) => {
</Link>
<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">
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice(
0,
4
)}
{isMusic(title)
? title.releaseDate?.slice(0, 4)
: (isMovie(title)
? title.releaseDate
: title.firstAirDate
)?.slice(0, 4)}
</div>
<Link
href={
issue.media.mediaType === MediaType.MOVIE
? `/movie/${issue.media.tmdbId}`
: `/tv/${issue.media.tmdbId}`
: issue.media.mediaType === MediaType.TV
? `/tv/${issue.media.tmdbId}`
: `/music/${issue.media.mbId}`
}
className="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>
{description && (
<div className="mt-1 max-w-full">

View File

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

View File

@@ -6,13 +6,14 @@ const messages = defineMessages('components.IssueModal', {
issueAudio: 'Audio',
issueVideo: 'Video',
issueSubtitles: 'Subtitle',
issueLyrics: 'Lyrics',
issueOther: 'Other',
});
interface IssueOption {
name: MessageDescriptor;
issueType: IssueType;
mediaType?: 'movie' | 'tv';
mediaType?: 'movie' | 'tv' | 'music';
}
export const issueOptions: IssueOption[] = [
@@ -28,8 +29,31 @@ export const issueOptions: IssueOption[] = [
name: messages.issueSubtitles,
issueType: IssueType.SUBTITLES,
},
{
name: messages.issueLyrics,
issueType: IssueType.LYRICS,
},
{
name: messages.issueOther,
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 {
show?: boolean;
onCancel: () => void;
mediaType: 'movie' | 'tv';
tmdbId: number;
mediaType: 'movie' | 'tv' | 'music';
tmdbId?: number;
mbId?: string;
issueId?: never;
}
const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
const IssueModal = ({
show,
mediaType,
onCancel,
tmdbId,
mbId,
}: IssueModalProps) => (
<Transition
as="div"
enter="transition-opacity duration-300"
@@ -24,6 +31,7 @@ const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
mediaType={mediaType}
onCancel={onCancel}
tmdbId={tmdbId}
mbId={mbId}
/>
</Transition>
);

View File

@@ -10,6 +10,7 @@ import {
ExclamationTriangleIcon,
EyeSlashIcon,
FilmIcon,
MusicalNoteIcon,
SparklesIcon,
TvIcon,
UsersIcon,
@@ -20,6 +21,7 @@ import {
ExclamationTriangleIcon as FilledExclamationTriangleIcon,
EyeSlashIcon as FilledEyeSlashIcon,
FilmIcon as FilledFilmIcon,
MusicalNoteIcon as FilledMusicalNoteIcon,
SparklesIcon as FilledSparklesIcon,
TvIcon as FilledTvIcon,
UsersIcon as FilledUsersIcon,
@@ -92,6 +94,13 @@ const MobileMenu = ({
svgIconSelected: <FilledTvIcon className="h-6 w-6" />,
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',
content: intl.formatMessage(menuMessages.requests),

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ const messages = defineMessages(
{
movierequests: 'Movie Requests',
seriesrequests: 'Series Requests',
musicrequests: 'Music Requests',
}
);
@@ -26,7 +27,7 @@ const MiniQuotaDisplay = ({ userId }: MiniQuotaDisplayProps) => {
return null;
}
if (!data && !error) {
if (data === undefined && !error) {
return <SmallLoadingSpinner />;
}
@@ -88,6 +89,34 @@ const MiniQuotaDisplay = ({ userId }: MiniQuotaDisplayProps) => {
)}
</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>
)}
</>

View File

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

View File

@@ -1,3 +1,4 @@
import GroupCard from '@app/components/GroupCard';
import ShowMoreCard from '@app/components/MediaSlider/ShowMoreCard';
import PersonCard from '@app/components/PersonCard';
import Slider from '@app/components/Slider';
@@ -8,6 +9,8 @@ import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import { MediaStatus } from '@server/constants/media';
import { Permission } from '@server/lib/permissions';
import type {
AlbumResult,
ArtistResult,
MovieResult,
PersonResult,
TvResult,
@@ -20,7 +23,13 @@ interface MixedResult {
page: number;
totalResults: number;
totalPages: number;
results: (TvResult | MovieResult | PersonResult)[];
results: (
| MovieResult
| TvResult
| PersonResult
| AlbumResult
| ArtistResult
)[];
}
interface MediaSliderProps {
@@ -62,7 +71,7 @@ const MediaSlider = ({
let titles = (data ?? []).reduce(
(a, v) => [...a, ...v.results],
[] as (MovieResult | TvResult | PersonResult)[]
[] as (MovieResult | TvResult | PersonResult | AlbumResult | ArtistResult)[]
);
if (settings.currentSettings.hideAvailable) {
@@ -112,7 +121,7 @@ const MediaSlider = ({
.filter((title) => {
if (!blacklistVisibility)
return (
(title as TvResult | MovieResult).mediaInfo?.status !==
(title as TvResult | MovieResult | AlbumResult).mediaInfo?.status !==
MediaStatus.BLACKLISTED
);
return title;
@@ -159,6 +168,41 @@ const MediaSlider = ({
profilePath={title.profilePath}
/>
);
case 'album':
return (
<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
.slice(20, 24)
.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>
)}
{!!streamingProviders.length && (
<div className="media-fact flex-col gap-1">
<div className="media-fact">
<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) => {
return (
<Tooltip content={p.name}>
<span
className="opacity-50 transition duration-300 hover:opacity-100"
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 className="block" key={`provider-${p.id}`}>
{p.name}
</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',
requestTvDescription:
'Grant permission to submit requests for non-4K series.',
requestMusic: 'Request Music',
requestMusicDescription:
'Grant permission to submit requests for music albums.',
autoapprove: 'Auto-Approve',
autoapproveDescription:
'Grant automatic approval for all non-4K media requests.',
@@ -34,6 +37,9 @@ export const messages = defineMessages('components.PermissionEdit', {
autoapproveSeries: 'Auto-Approve Series',
autoapproveSeriesDescription:
'Grant automatic approval for non-4K series requests.',
autoapproveMusic: 'Auto-Approve Music',
autoapproveMusicDescription:
'Grant automatic approval for music album requests.',
autoapprove4k: 'Auto-Approve 4K',
autoapprove4kDescription:
'Grant automatic approval for all 4K media requests.',
@@ -62,6 +68,9 @@ export const messages = defineMessages('components.PermissionEdit', {
autorequestSeries: 'Auto-Request Series',
autorequestSeriesDescription:
'Grant permission to automatically submit requests for non-4K series via Plex Watchlist.',
autorequestMusic: 'Auto-Request Music',
autorequestMusicDescription:
'Grant permission to automatically submit requests for music via Plex Watchlist.',
viewrequests: 'View Requests',
viewrequestsDescription:
'Grant permission to view media requests submitted by other users.',
@@ -182,6 +191,12 @@ export const PermissionEdit = ({
description: intl.formatMessage(messages.requestTvDescription),
permission: Permission.REQUEST_TV,
},
{
id: 'request-music',
name: intl.formatMessage(messages.requestMusic),
description: intl.formatMessage(messages.requestMusicDescription),
permission: Permission.REQUEST_MUSIC,
},
],
},
{
@@ -219,6 +234,18 @@ export const PermissionEdit = ({
},
],
},
{
id: 'autoapprovemusic',
name: intl.formatMessage(messages.autoapproveMusic),
description: intl.formatMessage(messages.autoapproveMusicDescription),
permission: Permission.AUTO_APPROVE_MUSIC,
requires: [
{
permissions: [Permission.REQUEST, Permission.REQUEST_MUSIC],
type: 'or',
},
],
},
],
},
{
@@ -256,6 +283,18 @@ export const PermissionEdit = ({
},
],
},
{
id: 'autorequestmusic',
name: intl.formatMessage(messages.autorequestMusic),
description: intl.formatMessage(messages.autorequestMusicDescription),
permission: Permission.AUTO_REQUEST_MUSIC,
requires: [
{
permissions: [Permission.REQUEST, Permission.REQUEST_MUSIC],
type: 'or',
},
],
},
],
},
{

View File

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

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