feat: lidarr/Music support added
This commit is contained in:
@@ -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
13
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
918
seerr-api.yml
918
seerr-api.yml
File diff suppressed because it is too large
Load Diff
36
server/api/coverartarchive/index.ts
Normal file
36
server/api/coverartarchive/index.ts
Normal 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;
|
||||
24
server/api/coverartarchive/interfaces.ts
Normal file
24
server/api/coverartarchive/interfaces.ts
Normal 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
76
server/api/listenbrainz/index.ts
Normal file
76
server/api/listenbrainz/index.ts
Normal 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;
|
||||
31
server/api/listenbrainz/interfaces.ts
Normal file
31
server/api/listenbrainz/interfaces.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
207
server/api/musicbrainz/index.ts
Normal file
207
server/api/musicbrainz/index.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
return decoded;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MusicBrainz;
|
||||
126
server/api/musicbrainz/interfaces.ts
Normal file
126
server/api/musicbrainz/interfaces.ts
Normal 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;
|
||||
}[];
|
||||
}
|
||||
@@ -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=${
|
||||
|
||||
@@ -124,7 +124,7 @@ export interface PlexWatchlistItem {
|
||||
ratingKey: string;
|
||||
tmdbId: number;
|
||||
tvdbId?: number;
|
||||
type: 'movie' | 'show';
|
||||
type: 'movie' | 'show' | 'album';
|
||||
title: string;
|
||||
}
|
||||
|
||||
|
||||
525
server/api/servarr/lidarr.ts
Normal file
525
server/api/servarr/lidarr.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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)[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum MediaRequestStatus {
|
||||
export enum MediaType {
|
||||
MOVIE = 'movie',
|
||||
TV = 'tv',
|
||||
MUSIC = 'music',
|
||||
}
|
||||
|
||||
export enum MediaStatus {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)', {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface QuotaStatus {
|
||||
export interface QuotaResponse {
|
||||
movie: QuotaStatus;
|
||||
tv: QuotaStatus;
|
||||
music: QuotaStatus;
|
||||
}
|
||||
|
||||
export interface UserWatchDataResponse {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
123
server/lib/scanners/lidarr/index.ts
Normal file
123
server/lib/scanners/lidarr/index.ts
Normal 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();
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
125
server/migration/sqlite/1714310036946-AddMusicSupport.ts
Normal file
125
server/migration/sqlite/1714310036946-AddMusicSupport.ts
Normal 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
53
server/models/Artist.ts
Normal 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
130
server/models/Music.ts
Normal 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,
|
||||
});
|
||||
@@ -20,6 +20,7 @@ export interface PersonDetails {
|
||||
adult: boolean;
|
||||
imdbId?: string;
|
||||
homepage?: string;
|
||||
mbArtistId?: string;
|
||||
}
|
||||
|
||||
export interface PersonCredit {
|
||||
|
||||
@@ -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 || '',
|
||||
});
|
||||
|
||||
@@ -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
35
server/routes/caaproxy.ts
Normal 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;
|
||||
@@ -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) => {
|
||||
|
||||
35
server/routes/fanartproxy.ts
Normal file
35
server/routes/fanartproxy.ts
Normal 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
165
server/routes/group.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
39
server/routes/lidarrproxy.ts
Normal file
39
server/routes/lidarrproxy.ts
Normal 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;
|
||||
@@ -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
251
server/routes/music.ts
Normal 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;
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
135
server/routes/settings/lidarr.ts
Normal file
135
server/routes/settings/lidarr.ts
Normal 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;
|
||||
38
server/routes/tmdbproxy.ts
Normal file
38
server/routes/tmdbproxy.ts
Normal 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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
1
src/assets/services/lidarr.svg
Normal file
1
src/assets/services/lidarr.svg
Normal 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 |
137
src/components/AddedCard/index.tsx
Normal file
137
src/components/AddedCard/index.tsx
Normal 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;
|
||||
@@ -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(
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
112
src/components/Discover/DiscoverMusic/index.tsx
Normal file
112
src/components/Discover/DiscoverMusic/index.tsx
Normal 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;
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
95
src/components/GroupCard/index.tsx
Normal file
95
src/components/GroupCard/index.tsx
Normal 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;
|
||||
318
src/components/GroupDetails/index.tsx
Normal file
318
src/components/GroupDetails/index.tsx
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
76
src/components/MusicDetails/MusicArtistDiscography.tsx
Normal file
76
src/components/MusicDetails/MusicArtistDiscography.tsx
Normal 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;
|
||||
76
src/components/MusicDetails/MusicArtistSimilar.tsx
Normal file
76
src/components/MusicDetails/MusicArtistSimilar.tsx
Normal 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;
|
||||
622
src/components/MusicDetails/index.tsx
Normal file
622
src/components/MusicDetails/index.tsx
Normal 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;
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user