feat: lidarr/Music support added
This commit is contained in:
@@ -59,7 +59,8 @@
|
|||||||
"date-fns": "2.29.3",
|
"date-fns": "2.29.3",
|
||||||
"dayjs": "1.11.19",
|
"dayjs": "1.11.19",
|
||||||
"dns-caching": "^0.2.7",
|
"dns-caching": "^0.2.7",
|
||||||
"email-templates": "12.0.3",
|
"dompurify": "^3.2.3",
|
||||||
|
"email-templates": "12.0.1",
|
||||||
"express": "4.21.2",
|
"express": "4.21.2",
|
||||||
"express-openapi-validator": "4.13.8",
|
"express-openapi-validator": "4.13.8",
|
||||||
"express-rate-limit": "6.7.0",
|
"express-rate-limit": "6.7.0",
|
||||||
|
|||||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -86,6 +86,9 @@ importers:
|
|||||||
dns-caching:
|
dns-caching:
|
||||||
specifier: ^0.2.7
|
specifier: ^0.2.7
|
||||||
version: 0.2.7
|
version: 0.2.7
|
||||||
|
dompurify:
|
||||||
|
specifier: ^3.2.3
|
||||||
|
version: 3.2.3
|
||||||
email-templates:
|
email-templates:
|
||||||
specifier: 12.0.3
|
specifier: 12.0.3
|
||||||
version: 12.0.3(@babel/core@7.24.7)(encoding@0.1.13)(handlebars@4.7.8)(mustache@4.2.0)(pug@3.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(underscore@1.13.7)
|
version: 12.0.3(@babel/core@7.24.7)(encoding@0.1.13)(handlebars@4.7.8)(mustache@4.2.0)(pug@3.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(underscore@1.13.7)
|
||||||
@@ -4800,6 +4803,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
dompurify@3.2.3:
|
||||||
|
resolution: {integrity: sha512-U1U5Hzc2MO0oW3DF+G9qYN0aT7atAou4AgI0XjWz061nyBPbdxkfdhfy5uMgGn6+oLFCfn44ZGbdDqCzVmlOWA==}
|
||||||
|
|
||||||
domutils@2.8.0:
|
domutils@2.8.0:
|
||||||
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
|
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
|
||||||
|
|
||||||
@@ -13615,6 +13621,9 @@ snapshots:
|
|||||||
|
|
||||||
'@types/ua-parser-js@0.7.39': {}
|
'@types/ua-parser-js@0.7.39': {}
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@types/unist@2.0.10': {}
|
'@types/unist@2.0.10': {}
|
||||||
|
|
||||||
'@types/validator@13.15.10': {}
|
'@types/validator@13.15.10': {}
|
||||||
@@ -15198,6 +15207,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
|
|
||||||
|
dompurify@3.2.3:
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
domutils@2.8.0:
|
domutils@2.8.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
dom-serializer: 1.4.1
|
dom-serializer: 1.4.1
|
||||||
|
|||||||
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 {
|
export interface JellyfinLibrary {
|
||||||
type: 'show' | 'movie';
|
type: 'show' | 'movie' | 'music';
|
||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
agent: string;
|
agent: string;
|
||||||
@@ -66,7 +66,13 @@ export interface JellyfinLibraryItem {
|
|||||||
Name: string;
|
Name: string;
|
||||||
Id: string;
|
Id: string;
|
||||||
HasSubtitles: boolean;
|
HasSubtitles: boolean;
|
||||||
Type: 'Movie' | 'Episode' | 'Season' | 'Series';
|
Type:
|
||||||
|
| 'Movie'
|
||||||
|
| 'Episode'
|
||||||
|
| 'Season'
|
||||||
|
| 'Series'
|
||||||
|
| 'MusicAlbum'
|
||||||
|
| 'MusicArtist';
|
||||||
LocationType: 'FileSystem' | 'Offline' | 'Remote' | 'Virtual';
|
LocationType: 'FileSystem' | 'Offline' | 'Remote' | 'Virtual';
|
||||||
SeriesName?: string;
|
SeriesName?: string;
|
||||||
SeriesId?: string;
|
SeriesId?: string;
|
||||||
@@ -76,6 +82,8 @@ export interface JellyfinLibraryItem {
|
|||||||
IndexNumberEnd?: number;
|
IndexNumberEnd?: number;
|
||||||
ParentIndexNumber?: number;
|
ParentIndexNumber?: number;
|
||||||
MediaType: string;
|
MediaType: string;
|
||||||
|
AlbumId?: string;
|
||||||
|
ArtistId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JellyfinMediaStream {
|
export interface JellyfinMediaStream {
|
||||||
@@ -104,6 +112,8 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
|||||||
Imdb?: string;
|
Imdb?: string;
|
||||||
Tvdb?: string;
|
Tvdb?: string;
|
||||||
AniDB?: string;
|
AniDB?: string;
|
||||||
|
MusicBrainzReleaseGroup: string | undefined;
|
||||||
|
MusicBrainzArtistId?: string;
|
||||||
};
|
};
|
||||||
MediaSources?: JellyfinMediaSource[];
|
MediaSources?: JellyfinMediaSource[];
|
||||||
Width?: number;
|
Width?: number;
|
||||||
@@ -308,13 +318,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private mapLibraries(mediaFolders: JellyfinMediaFolder[]): JellyfinLibrary[] {
|
private mapLibraries(mediaFolders: JellyfinMediaFolder[]): JellyfinLibrary[] {
|
||||||
const excludedTypes = [
|
const excludedTypes = ['books', 'musicvideos', 'homevideos', 'boxsets'];
|
||||||
'music',
|
|
||||||
'books',
|
|
||||||
'musicvideos',
|
|
||||||
'homevideos',
|
|
||||||
'boxsets',
|
|
||||||
];
|
|
||||||
|
|
||||||
return mediaFolders
|
return mediaFolders
|
||||||
.filter((Item: JellyfinMediaFolder) => {
|
.filter((Item: JellyfinMediaFolder) => {
|
||||||
@@ -327,7 +331,12 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return <JellyfinLibrary>{
|
return <JellyfinLibrary>{
|
||||||
key: Item.Id,
|
key: Item.Id,
|
||||||
title: Item.Name,
|
title: Item.Name,
|
||||||
type: Item.CollectionType === 'movies' ? 'movie' : 'show',
|
type:
|
||||||
|
Item.CollectionType === 'movies'
|
||||||
|
? 'movie'
|
||||||
|
: Item.CollectionType === 'tvshows'
|
||||||
|
? 'show'
|
||||||
|
: 'music',
|
||||||
agent: 'jellyfin',
|
agent: 'jellyfin',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -336,7 +345,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
||||||
try {
|
try {
|
||||||
const libraryItemsResponse = await this.get<any>(
|
const libraryItemsResponse = await this.get<any>(
|
||||||
`/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
|
`/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,MusicAlbum,MusicArtist,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
|
||||||
);
|
);
|
||||||
|
|
||||||
return libraryItemsResponse.Items.filter(
|
return libraryItemsResponse.Items.filter(
|
||||||
|
|||||||
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?: {
|
Guid?: {
|
||||||
id: string;
|
id: string;
|
||||||
}[];
|
}[];
|
||||||
type: 'movie' | 'show' | 'season' | 'episode';
|
type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album' | 'track';
|
||||||
Media: Media[];
|
Media: Media[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ interface PlexLibraryResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PlexLibrary {
|
export interface PlexLibrary {
|
||||||
type: 'show' | 'movie';
|
type: 'show' | 'movie' | 'music';
|
||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
agent: string;
|
agent: string;
|
||||||
@@ -44,7 +44,7 @@ export interface PlexMetadata {
|
|||||||
ratingKey: string;
|
ratingKey: string;
|
||||||
parentRatingKey?: string;
|
parentRatingKey?: string;
|
||||||
guid: string;
|
guid: string;
|
||||||
type: 'movie' | 'show' | 'season';
|
type: 'movie' | 'show' | 'season' | 'artist' | 'album' | 'track';
|
||||||
title: string;
|
title: string;
|
||||||
Guid: {
|
Guid: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -152,7 +152,10 @@ class PlexAPI {
|
|||||||
const newLibraries: Library[] = libraries
|
const newLibraries: Library[] = libraries
|
||||||
// Remove libraries that are not movie or show
|
// Remove libraries that are not movie or show
|
||||||
.filter(
|
.filter(
|
||||||
(library) => library.type === 'movie' || library.type === 'show'
|
(library) =>
|
||||||
|
library.type === 'movie' ||
|
||||||
|
library.type === 'show' ||
|
||||||
|
library.type === 'music'
|
||||||
)
|
)
|
||||||
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
|
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
|
||||||
.filter((library) => library.agent !== 'com.plexapp.agents.none')
|
.filter((library) => library.agent !== 'com.plexapp.agents.none')
|
||||||
@@ -227,7 +230,7 @@ class PlexAPI {
|
|||||||
options: { addedAt: number } = {
|
options: { addedAt: number } = {
|
||||||
addedAt: Date.now() - 1000 * 60 * 60,
|
addedAt: Date.now() - 1000 * 60 * 60,
|
||||||
},
|
},
|
||||||
mediaType: 'movie' | 'show'
|
mediaType: 'movie' | 'show' | 'music'
|
||||||
): Promise<PlexLibraryItem[]> {
|
): Promise<PlexLibraryItem[]> {
|
||||||
const response = await this.plexClient.query<PlexLibraryResponse>({
|
const response = await this.plexClient.query<PlexLibraryResponse>({
|
||||||
uri: `/library/sections/${id}/all?type=${
|
uri: `/library/sections/${id}/all?type=${
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export interface PlexWatchlistItem {
|
|||||||
ratingKey: string;
|
ratingKey: string;
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
tvdbId?: number;
|
tvdbId?: number;
|
||||||
type: 'movie' | 'show';
|
type: 'movie' | 'show' | 'album';
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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,
|
TmdbNetwork,
|
||||||
TmdbPersonCombinedCredits,
|
TmdbPersonCombinedCredits,
|
||||||
TmdbPersonDetails,
|
TmdbPersonDetails,
|
||||||
|
TmdbPersonSearchResponse,
|
||||||
TmdbProductionCompany,
|
TmdbProductionCompany,
|
||||||
TmdbRegion,
|
TmdbRegion,
|
||||||
TmdbSearchMovieResponse,
|
TmdbSearchMovieResponse,
|
||||||
@@ -230,6 +231,31 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public async searchPerson({
|
||||||
|
query,
|
||||||
|
page = 1,
|
||||||
|
includeAdult = false,
|
||||||
|
language = 'en',
|
||||||
|
}: SearchOptions): Promise<TmdbPersonSearchResponse> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbPersonSearchResponse>('/search/person', {
|
||||||
|
query,
|
||||||
|
page: page.toString(),
|
||||||
|
include_adult: includeAdult ? 'true' : 'false',
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
page: 1,
|
||||||
|
results: [],
|
||||||
|
total_pages: 1,
|
||||||
|
total_results: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public getPerson = async ({
|
public getPerson = async ({
|
||||||
personId,
|
personId,
|
||||||
language = this.locale,
|
language = this.locale,
|
||||||
|
|||||||
@@ -469,3 +469,15 @@ export interface TmdbWatchProviderRegion {
|
|||||||
english_name: string;
|
english_name: string;
|
||||||
native_name: string;
|
native_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TmdbPersonSearchResponse extends TmdbPaginatedResponse {
|
||||||
|
results: TmdbPersonSearchResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmdbPersonSearchResult
|
||||||
|
extends Omit<TmdbPersonResult, 'known_for'> {
|
||||||
|
gender: number;
|
||||||
|
known_for_department: string;
|
||||||
|
original_name: string;
|
||||||
|
known_for: (TmdbMovieResult | TmdbTvResult)[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export enum DiscoverSliderType {
|
|||||||
RECENTLY_ADDED = 1,
|
RECENTLY_ADDED = 1,
|
||||||
RECENT_REQUESTS,
|
RECENT_REQUESTS,
|
||||||
PLEX_WATCHLIST,
|
PLEX_WATCHLIST,
|
||||||
|
POPULAR_ALBUMS,
|
||||||
TRENDING,
|
TRENDING,
|
||||||
POPULAR_MOVIES,
|
POPULAR_MOVIES,
|
||||||
MOVIE_GENRES,
|
MOVIE_GENRES,
|
||||||
@@ -50,51 +51,57 @@ export const defaultSliders: Partial<DiscoverSlider>[] = [
|
|||||||
order: 3,
|
order: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: DiscoverSliderType.POPULAR_MOVIES,
|
type: DiscoverSliderType.POPULAR_ALBUMS,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
order: 4,
|
order: 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: DiscoverSliderType.MOVIE_GENRES,
|
type: DiscoverSliderType.POPULAR_MOVIES,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
order: 5,
|
order: 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: DiscoverSliderType.UPCOMING_MOVIES,
|
type: DiscoverSliderType.MOVIE_GENRES,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
order: 6,
|
order: 6,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: DiscoverSliderType.STUDIOS,
|
type: DiscoverSliderType.UPCOMING_MOVIES,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
order: 7,
|
order: 7,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: DiscoverSliderType.POPULAR_TV,
|
type: DiscoverSliderType.STUDIOS,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
order: 8,
|
order: 8,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: DiscoverSliderType.TV_GENRES,
|
type: DiscoverSliderType.POPULAR_TV,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
order: 9,
|
order: 9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: DiscoverSliderType.UPCOMING_TV,
|
type: DiscoverSliderType.TV_GENRES,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
order: 10,
|
order: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: DiscoverSliderType.NETWORKS,
|
type: DiscoverSliderType.UPCOMING_TV,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
order: 11,
|
order: 11,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.NETWORKS,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 12,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ export enum IssueType {
|
|||||||
VIDEO = 1,
|
VIDEO = 1,
|
||||||
AUDIO = 2,
|
AUDIO = 2,
|
||||||
SUBTITLES = 3,
|
SUBTITLES = 3,
|
||||||
OTHER = 4,
|
LYRICS = 4,
|
||||||
|
OTHER = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum IssueStatus {
|
export enum IssueStatus {
|
||||||
@@ -14,5 +15,6 @@ export const IssueTypeName = {
|
|||||||
[IssueType.AUDIO]: 'Audio',
|
[IssueType.AUDIO]: 'Audio',
|
||||||
[IssueType.VIDEO]: 'Video',
|
[IssueType.VIDEO]: 'Video',
|
||||||
[IssueType.SUBTITLES]: 'Subtitle',
|
[IssueType.SUBTITLES]: 'Subtitle',
|
||||||
|
[IssueType.LYRICS]: 'Lyrics',
|
||||||
[IssueType.OTHER]: 'Other',
|
[IssueType.OTHER]: 'Other',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export enum MediaRequestStatus {
|
|||||||
export enum MediaType {
|
export enum MediaType {
|
||||||
MOVIE = 'movie',
|
MOVIE = 'movie',
|
||||||
TV = 'tv',
|
TV = 'tv',
|
||||||
|
MUSIC = 'music',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum MediaStatus {
|
export enum MediaStatus {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
|
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@Unique(['tmdbId'])
|
@Unique(['tmdbId', 'mbId'])
|
||||||
export class Blacklist implements BlacklistItem {
|
export class Blacklist implements BlacklistItem {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
public id: number;
|
public id: number;
|
||||||
@@ -29,9 +29,13 @@ export class Blacklist implements BlacklistItem {
|
|||||||
@Column({ nullable: true, type: 'varchar' })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
||||||
@Column()
|
@Column({ nullable: true })
|
||||||
@Index()
|
@Index()
|
||||||
public tmdbId: number;
|
public tmdbId?: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
@Index()
|
||||||
|
public mbId?: string;
|
||||||
|
|
||||||
@ManyToOne(() => User, (user) => user.id, {
|
@ManyToOne(() => User, (user) => user.id, {
|
||||||
eager: true,
|
eager: true,
|
||||||
@@ -62,6 +66,7 @@ export class Blacklist implements BlacklistItem {
|
|||||||
mediaType: MediaType;
|
mediaType: MediaType;
|
||||||
title?: ZodOptional<ZodString>['_output'];
|
title?: ZodOptional<ZodString>['_output'];
|
||||||
tmdbId: ZodNumber['_output'];
|
tmdbId: ZodNumber['_output'];
|
||||||
|
mbId?: ZodOptional<ZodString>['_output']
|
||||||
blacklistedTags?: string;
|
blacklistedTags?: string;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -74,9 +79,10 @@ export class Blacklist implements BlacklistItem {
|
|||||||
|
|
||||||
const mediaRepository = em.getRepository(Media);
|
const mediaRepository = em.getRepository(Media);
|
||||||
let media = await mediaRepository.findOne({
|
let media = await mediaRepository.findOne({
|
||||||
where: {
|
where:
|
||||||
tmdbId: blacklistRequest.tmdbId,
|
blacklistRequest.mediaType === 'music'
|
||||||
},
|
? { mbId: blacklistRequest.mbId }
|
||||||
|
: { tmdbId: blacklistRequest.tmdbId },
|
||||||
});
|
});
|
||||||
|
|
||||||
const blacklistRepository = em.getRepository(this);
|
const blacklistRepository = em.getRepository(this);
|
||||||
@@ -86,6 +92,7 @@ export class Blacklist implements BlacklistItem {
|
|||||||
if (!media) {
|
if (!media) {
|
||||||
media = new Media({
|
media = new Media({
|
||||||
tmdbId: blacklistRequest.tmdbId,
|
tmdbId: blacklistRequest.tmdbId,
|
||||||
|
mbId: blacklistRequest.mbId,
|
||||||
status: MediaStatus.BLACKLISTED,
|
status: MediaStatus.BLACKLISTED,
|
||||||
status4k: MediaStatus.BLACKLISTED,
|
status4k: MediaStatus.BLACKLISTED,
|
||||||
mediaType: blacklistRequest.mediaType,
|
mediaType: blacklistRequest.mediaType,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||||
import RadarrAPI from '@server/api/servarr/radarr';
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
@@ -29,33 +30,39 @@ import Season from './Season';
|
|||||||
class Media {
|
class Media {
|
||||||
public static async getRelatedMedia(
|
public static async getRelatedMedia(
|
||||||
user: User | undefined,
|
user: User | undefined,
|
||||||
tmdbIds: number | number[]
|
ids: (number | string)[]
|
||||||
): Promise<Media[]> {
|
): Promise<Media[]> {
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let finalIds: number[];
|
if (ids.length === 0) {
|
||||||
if (!Array.isArray(tmdbIds)) {
|
|
||||||
finalIds = [tmdbIds];
|
|
||||||
} else {
|
|
||||||
finalIds = tmdbIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (finalIds.length === 0) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const media = await mediaRepository
|
const tmdbIds = ids.filter((id): id is number => typeof id === 'number');
|
||||||
|
const mbIds = ids.filter((id): id is string => typeof id === 'string');
|
||||||
|
|
||||||
|
const queryBuilder = mediaRepository
|
||||||
.createQueryBuilder('media')
|
.createQueryBuilder('media')
|
||||||
.leftJoinAndSelect(
|
.leftJoinAndSelect(
|
||||||
'media.watchlists',
|
'media.watchlists',
|
||||||
'watchlist',
|
'watchlist',
|
||||||
'media.id= watchlist.media and watchlist.requestedBy = :userId',
|
'media.id = watchlist.media and watchlist.requestedBy = :userId',
|
||||||
{ userId: user?.id }
|
{ userId: user?.id }
|
||||||
) //,
|
);
|
||||||
.where(' media.tmdbId in (:...finalIds)', { finalIds })
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
|
if (tmdbIds.length > 0 && mbIds.length > 0) {
|
||||||
|
queryBuilder.where(
|
||||||
|
'(media.tmdbId IN (:...tmdbIds) OR media.mbId IN (:...mbIds))',
|
||||||
|
{ tmdbIds, mbIds }
|
||||||
|
);
|
||||||
|
} else if (tmdbIds.length > 0) {
|
||||||
|
queryBuilder.where('media.tmdbId IN (:...tmdbIds)', { tmdbIds });
|
||||||
|
} else if (mbIds.length > 0) {
|
||||||
|
queryBuilder.where('media.mbId IN (:...mbIds)', { mbIds });
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = await queryBuilder.getMany();
|
||||||
return media;
|
return media;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e.message);
|
logger.error(e.message);
|
||||||
@@ -64,14 +71,19 @@ class Media {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static async getMedia(
|
public static async getMedia(
|
||||||
id: number,
|
id: number | string,
|
||||||
mediaType: MediaType
|
mediaType: MediaType
|
||||||
): Promise<Media | undefined> {
|
): Promise<Media | undefined> {
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const whereClause =
|
||||||
|
typeof id === 'string'
|
||||||
|
? { mbId: id, mediaType }
|
||||||
|
: { tmdbId: id, mediaType };
|
||||||
|
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
where: { tmdbId: id, mediaType: mediaType },
|
where: whereClause,
|
||||||
relations: { requests: true, issues: true },
|
relations: { requests: true, issues: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,7 +100,7 @@ class Media {
|
|||||||
@Column({ type: 'varchar' })
|
@Column({ type: 'varchar' })
|
||||||
public mediaType: MediaType;
|
public mediaType: MediaType;
|
||||||
|
|
||||||
@Column()
|
@Column({ nullable: true })
|
||||||
@Index()
|
@Index()
|
||||||
public tmdbId: number;
|
public tmdbId: number;
|
||||||
|
|
||||||
@@ -100,6 +112,10 @@ class Media {
|
|||||||
@Index()
|
@Index()
|
||||||
public imdbId?: string;
|
public imdbId?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
@Index()
|
||||||
|
public mbId?: string;
|
||||||
|
|
||||||
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
||||||
public status: MediaStatus;
|
public status: MediaStatus;
|
||||||
|
|
||||||
@@ -319,6 +335,21 @@ class Media {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.mediaType === MediaType.MUSIC) {
|
||||||
|
if (this.serviceId !== null && this.externalServiceSlug !== null) {
|
||||||
|
const settings = getSettings();
|
||||||
|
const server = settings.lidarr.find(
|
||||||
|
(lidarr) => lidarr.id === this.serviceId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (server) {
|
||||||
|
this.serviceUrl = server.externalUrl
|
||||||
|
? `${server.externalUrl}/album/${this.externalServiceSlug}`
|
||||||
|
: LidarrAPI.buildUrl(server, `/album/${this.externalServiceSlug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterLoad()
|
@AfterLoad()
|
||||||
@@ -374,6 +405,20 @@ class Media {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.mediaType === MediaType.MUSIC) {
|
||||||
|
if (
|
||||||
|
this.externalServiceId !== undefined &&
|
||||||
|
this.externalServiceId !== null &&
|
||||||
|
this.serviceId !== undefined &&
|
||||||
|
this.serviceId !== null
|
||||||
|
) {
|
||||||
|
this.downloadStatus = downloadTracker.getMusicProgress(
|
||||||
|
this.serviceId,
|
||||||
|
this.externalServiceId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
|
import type { LidarrAlbumDetails } from '@server/api/servarr/lidarr';
|
||||||
|
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||||
|
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
|
||||||
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
|
import type {
|
||||||
|
AddSeriesOptions,
|
||||||
|
SonarrSeries,
|
||||||
|
} from '@server/api/servarr/sonarr';
|
||||||
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||||
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
import type {
|
||||||
|
TmdbMovieDetails,
|
||||||
|
TmdbTvDetails,
|
||||||
|
} from '@server/api/themoviedb/interfaces';
|
||||||
import {
|
import {
|
||||||
MediaRequestStatus,
|
MediaRequestStatus,
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
@@ -46,8 +58,12 @@ export class MediaRequest {
|
|||||||
requestBody: MediaRequestBody,
|
requestBody: MediaRequestBody,
|
||||||
user: User,
|
user: User,
|
||||||
options: MediaRequestOptions = {}
|
options: MediaRequestOptions = {}
|
||||||
): Promise<MediaRequest> {
|
): Promise<MediaRequest | undefined> {
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
const lidarr = new LidarrAPI({
|
||||||
|
apiKey: getSettings().lidarr[0].apiKey,
|
||||||
|
url: LidarrAPI.buildUrl(getSettings().lidarr[0], '/api/v1'),
|
||||||
|
});
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
@@ -109,22 +125,50 @@ export class MediaRequest {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
requestBody.mediaType === MediaType.MUSIC &&
|
||||||
|
!requestUser.hasPermission(
|
||||||
|
[Permission.REQUEST, Permission.REQUEST_MUSIC],
|
||||||
|
{
|
||||||
|
type: 'or',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new RequestPermissionError(
|
||||||
|
'You do not have permission to make music requests.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const quotas = await requestUser.getQuota();
|
const quotas = await requestUser.getQuota();
|
||||||
|
|
||||||
if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
|
if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
|
||||||
throw new QuotaRestrictedError('Movie Quota exceeded.');
|
throw new QuotaRestrictedError('Movie Quota exceeded.');
|
||||||
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
|
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
|
||||||
throw new QuotaRestrictedError('Series Quota exceeded.');
|
throw new QuotaRestrictedError('Series Quota exceeded.');
|
||||||
|
} else if (
|
||||||
|
requestBody.mediaType === MediaType.MUSIC &&
|
||||||
|
quotas.music.restricted
|
||||||
|
) {
|
||||||
|
throw new QuotaRestrictedError('Music Quota exceeded.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmdbMedia =
|
const tmdbMedia =
|
||||||
requestBody.mediaType === MediaType.MOVIE
|
requestBody.mediaType === MediaType.MOVIE
|
||||||
? await tmdb.getMovie({ movieId: requestBody.mediaId })
|
? await tmdb.getMovie({ movieId: requestBody.mediaId })
|
||||||
: await tmdb.getTvShow({ tvId: requestBody.mediaId });
|
: requestBody.mediaType === MediaType.TV
|
||||||
|
? await tmdb.getTvShow({ tvId: requestBody.mediaId })
|
||||||
|
: await lidarr.getAlbumByMusicBrainzId(requestBody.mediaId.toString());
|
||||||
|
|
||||||
let media = await mediaRepository.findOne({
|
let media = await mediaRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
tmdbId: requestBody.mediaId,
|
mbId:
|
||||||
|
requestBody.mediaType === MediaType.MUSIC
|
||||||
|
? requestBody.mediaId.toString()
|
||||||
|
: undefined,
|
||||||
|
tmdbId:
|
||||||
|
requestBody.mediaType !== MediaType.MUSIC
|
||||||
|
? requestBody.mediaId
|
||||||
|
: undefined,
|
||||||
mediaType: requestBody.mediaType,
|
mediaType: requestBody.mediaType,
|
||||||
},
|
},
|
||||||
relations: ['requests'],
|
relations: ['requests'],
|
||||||
@@ -132,16 +176,27 @@ export class MediaRequest {
|
|||||||
|
|
||||||
if (!media) {
|
if (!media) {
|
||||||
media = new Media({
|
media = new Media({
|
||||||
tmdbId: tmdbMedia.id,
|
mbId:
|
||||||
tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
|
requestBody.mediaType === MediaType.MUSIC
|
||||||
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
? requestBody.mediaId.toString()
|
||||||
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
: undefined,
|
||||||
|
tmdbId:
|
||||||
|
requestBody.mediaType !== MediaType.MUSIC
|
||||||
|
? requestBody.mediaId
|
||||||
|
: undefined,
|
||||||
mediaType: requestBody.mediaType,
|
mediaType: requestBody.mediaType,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (media.status === MediaStatus.BLACKLISTED) {
|
if (media.status === MediaStatus.BLACKLISTED) {
|
||||||
logger.warn('Request for media blocked due to being blacklisted', {
|
logger.warn('Request for media blocked due to being blacklisted', {
|
||||||
tmdbId: tmdbMedia.id,
|
mbId:
|
||||||
|
requestBody.mediaType === MediaType.MUSIC
|
||||||
|
? requestBody.mediaId
|
||||||
|
: undefined,
|
||||||
|
tmdbId:
|
||||||
|
requestBody.mediaType !== MediaType.MUSIC
|
||||||
|
? tmdbMedia.id
|
||||||
|
: undefined,
|
||||||
mediaType: requestBody.mediaType,
|
mediaType: requestBody.mediaType,
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
});
|
});
|
||||||
@@ -152,18 +207,20 @@ export class MediaRequest {
|
|||||||
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
|
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
|
||||||
media.status = MediaStatus.PENDING;
|
media.status = MediaStatus.PENDING;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) {
|
|
||||||
media.status4k = MediaStatus.PENDING;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await requestRepository
|
const existing = await requestRepository
|
||||||
.createQueryBuilder('request')
|
.createQueryBuilder('request')
|
||||||
.leftJoin('request.media', 'media')
|
.leftJoin('request.media', 'media')
|
||||||
.leftJoinAndSelect('request.requestedBy', 'user')
|
.leftJoinAndSelect('request.requestedBy', 'user')
|
||||||
.where('request.is4k = :is4k', { is4k: requestBody.is4k })
|
.where(
|
||||||
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
|
requestBody.mediaType === MediaType.MUSIC
|
||||||
|
? 'media.mbId = :mbId'
|
||||||
|
: 'media.tmdbId = :tmdbId',
|
||||||
|
requestBody.mediaType === MediaType.MUSIC
|
||||||
|
? { mbId: requestBody.mediaId }
|
||||||
|
: { tmdbId: tmdbMedia.id }
|
||||||
|
)
|
||||||
.andWhere('media.mediaType = :mediaType', {
|
.andWhere('media.mediaType = :mediaType', {
|
||||||
mediaType: requestBody.mediaType,
|
mediaType: requestBody.mediaType,
|
||||||
})
|
})
|
||||||
@@ -172,14 +229,12 @@ export class MediaRequest {
|
|||||||
if (existing && existing.length > 0) {
|
if (existing && existing.length > 0) {
|
||||||
// If there is an existing movie request that isn't declined, don't allow a new one.
|
// If there is an existing movie request that isn't declined, don't allow a new one.
|
||||||
if (
|
if (
|
||||||
requestBody.mediaType === MediaType.MOVIE &&
|
requestBody.mediaType === MediaType.MUSIC &&
|
||||||
existing[0].status !== MediaRequestStatus.DECLINED &&
|
existing[0].status !== MediaRequestStatus.DECLINED
|
||||||
existing[0].status !== MediaRequestStatus.COMPLETED
|
|
||||||
) {
|
) {
|
||||||
logger.warn('Duplicate request for media blocked', {
|
logger.warn('Duplicate request for media blocked', {
|
||||||
tmdbId: tmdbMedia.id,
|
mbId: requestBody.mediaId,
|
||||||
mediaType: requestBody.mediaType,
|
mediaType: requestBody.mediaType,
|
||||||
is4k: requestBody.is4k,
|
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -201,131 +256,116 @@ export class MediaRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply overrides if the user is not an admin or has the "advanced request" permission
|
const isTmdbMedia = (
|
||||||
|
media: LidarrAlbumDetails | TmdbMovieDetails | TmdbTvDetails
|
||||||
|
): media is TmdbMovieDetails | TmdbTvDetails => {
|
||||||
|
return 'original_language' in media && 'keywords' in media;
|
||||||
|
};
|
||||||
|
|
||||||
|
let prioritizedRule: OverrideRule | undefined;
|
||||||
|
|
||||||
const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], {
|
const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
});
|
});
|
||||||
|
|
||||||
let rootFolder = requestBody.rootFolder;
|
|
||||||
let profileId = requestBody.profileId;
|
|
||||||
let tags = requestBody.tags;
|
|
||||||
|
|
||||||
if (useOverrides) {
|
if (useOverrides) {
|
||||||
const defaultRadarrId = requestBody.is4k
|
if (requestBody.mediaType !== MediaType.MUSIC) {
|
||||||
? settings.radarr.findIndex((r) => r.is4k && r.isDefault)
|
const defaultRadarrId = requestBody.is4k
|
||||||
: settings.radarr.findIndex((r) => !r.is4k && r.isDefault);
|
? settings.radarr.findIndex((r) => r.is4k && r.isDefault)
|
||||||
const defaultSonarrId = requestBody.is4k
|
: settings.radarr.findIndex((r) => !r.is4k && r.isDefault);
|
||||||
? settings.sonarr.findIndex((s) => s.is4k && s.isDefault)
|
const defaultSonarrId = requestBody.is4k
|
||||||
: settings.sonarr.findIndex((s) => !s.is4k && s.isDefault);
|
? settings.sonarr.findIndex((s) => s.is4k && s.isDefault)
|
||||||
|
: settings.sonarr.findIndex((s) => !s.is4k && s.isDefault);
|
||||||
|
|
||||||
const overrideRuleRepository = getRepository(OverrideRule);
|
const overrideRuleRepository = getRepository(OverrideRule);
|
||||||
const overrideRules = await overrideRuleRepository.find({
|
const overrideRules = await overrideRuleRepository.find({
|
||||||
where:
|
where:
|
||||||
requestBody.mediaType === MediaType.MOVIE
|
requestBody.mediaType === MediaType.MOVIE
|
||||||
? { radarrServiceId: defaultRadarrId }
|
? { radarrServiceId: defaultRadarrId }
|
||||||
: { sonarrServiceId: defaultSonarrId },
|
: { sonarrServiceId: defaultSonarrId },
|
||||||
});
|
});
|
||||||
|
|
||||||
const appliedOverrideRules = overrideRules.filter((rule) => {
|
const appliedOverrideRules = overrideRules.filter((rule) => {
|
||||||
const hasAnimeKeyword =
|
if (isTmdbMedia(tmdbMedia)) {
|
||||||
'results' in tmdbMedia.keywords &&
|
if (
|
||||||
tmdbMedia.keywords.results.some(
|
rule.language &&
|
||||||
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
!rule.language
|
||||||
);
|
.split('|')
|
||||||
|
.some(
|
||||||
// Skip override rules if the media is an anime TV show as anime TV
|
(languageId) => languageId === tmdbMedia.original_language
|
||||||
// is handled by default and override rules do not explicitly include
|
)
|
||||||
// the anime keyword
|
) {
|
||||||
if (
|
return false;
|
||||||
requestBody.mediaType === MediaType.TV &&
|
|
||||||
hasAnimeKeyword &&
|
|
||||||
(!rule.keywords ||
|
|
||||||
!rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID))
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
rule.users &&
|
|
||||||
!rule.users
|
|
||||||
.split(',')
|
|
||||||
.some((userId) => Number(userId) === requestUser.id)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
rule.genre &&
|
|
||||||
!rule.genre
|
|
||||||
.split(',')
|
|
||||||
.some((genreId) =>
|
|
||||||
tmdbMedia.genres
|
|
||||||
.map((genre) => genre.id)
|
|
||||||
.includes(Number(genreId))
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
rule.language &&
|
|
||||||
!rule.language
|
|
||||||
.split('|')
|
|
||||||
.some((languageId) => languageId === tmdbMedia.original_language)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
rule.keywords &&
|
|
||||||
!rule.keywords.split(',').some((keywordId) => {
|
|
||||||
let keywordList: TmdbKeyword[] = [];
|
|
||||||
|
|
||||||
if ('keywords' in tmdbMedia.keywords) {
|
|
||||||
keywordList = tmdbMedia.keywords.keywords;
|
|
||||||
} else if ('results' in tmdbMedia.keywords) {
|
|
||||||
keywordList = tmdbMedia.keywords.results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return keywordList
|
if (rule.keywords) {
|
||||||
.map((keyword: TmdbKeyword) => keyword.id)
|
const keywordList =
|
||||||
.includes(Number(keywordId));
|
'results' in tmdbMedia.keywords
|
||||||
})
|
? tmdbMedia.keywords.results
|
||||||
) {
|
: 'keywords' in tmdbMedia.keywords
|
||||||
return false;
|
? tmdbMedia.keywords.keywords
|
||||||
}
|
: [];
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// hacky way to prioritize rules
|
if (
|
||||||
// TODO: make this better
|
!rule.keywords
|
||||||
const prioritizedRule = appliedOverrideRules.sort((a, b) => {
|
.split(',')
|
||||||
const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords'];
|
.some((keywordId) =>
|
||||||
|
keywordList.map((k) => k.id).includes(Number(keywordId))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const aSpecificity = keys.filter((key) => a[key] !== null).length;
|
const hasAnimeKeyword =
|
||||||
const bSpecificity = keys.filter((key) => b[key] !== null).length;
|
'results' in tmdbMedia.keywords &&
|
||||||
|
tmdbMedia.keywords.results.some(
|
||||||
|
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||||
|
);
|
||||||
|
|
||||||
// Take the rule with the most specific condition first
|
if (
|
||||||
return bSpecificity - aSpecificity;
|
requestBody.mediaType === MediaType.TV &&
|
||||||
})[0];
|
hasAnimeKeyword &&
|
||||||
|
(!rule.keywords ||
|
||||||
|
!rule.keywords
|
||||||
|
.split(',')
|
||||||
|
.map(Number)
|
||||||
|
.includes(ANIME_KEYWORD_ID))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (prioritizedRule) {
|
if (
|
||||||
if (prioritizedRule.rootFolder) {
|
rule.users &&
|
||||||
rootFolder = prioritizedRule.rootFolder;
|
!rule.users
|
||||||
}
|
.split(',')
|
||||||
if (prioritizedRule.profileId) {
|
.some((userId) => Number(userId) === requestUser.id)
|
||||||
profileId = prioritizedRule.profileId;
|
) {
|
||||||
}
|
return false;
|
||||||
if (prioritizedRule.tags) {
|
}
|
||||||
tags = [
|
|
||||||
...new Set([
|
|
||||||
...(tags || []),
|
|
||||||
...prioritizedRule.tags.split(',').map((tag) => Number(tag)),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('Override rule applied.', {
|
return true;
|
||||||
label: 'Media Request',
|
|
||||||
overrides: prioritizedRule,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
prioritizedRule = appliedOverrideRules.sort((a, b) => {
|
||||||
|
const keys: (keyof OverrideRule)[] = [
|
||||||
|
'genre',
|
||||||
|
'language',
|
||||||
|
'keywords',
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
keys.filter((key) => b[key] !== null).length -
|
||||||
|
keys.filter((key) => a[key] !== null).length
|
||||||
|
);
|
||||||
|
})[0];
|
||||||
|
|
||||||
|
if (prioritizedRule) {
|
||||||
|
logger.debug('Override rule applied.', {
|
||||||
|
label: 'Media Request',
|
||||||
|
overrides: prioritizedRule,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,28 +407,31 @@ export class MediaRequest {
|
|||||||
: undefined,
|
: undefined,
|
||||||
is4k: requestBody.is4k,
|
is4k: requestBody.is4k,
|
||||||
serverId: requestBody.serverId,
|
serverId: requestBody.serverId,
|
||||||
profileId: profileId,
|
profileId: prioritizedRule?.profileId ?? requestBody.profileId,
|
||||||
rootFolder: rootFolder,
|
rootFolder: prioritizedRule?.rootFolder ?? requestBody.rootFolder,
|
||||||
tags: tags,
|
tags: prioritizedRule?.tags
|
||||||
|
? [
|
||||||
|
...new Set([
|
||||||
|
...(requestBody.tags || []),
|
||||||
|
...prioritizedRule.tags.split(',').map(Number),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
: requestBody.tags,
|
||||||
isAutoRequest: options.isAutoRequest ?? false,
|
isAutoRequest: options.isAutoRequest ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await requestRepository.save(request);
|
await requestRepository.save(request);
|
||||||
return request;
|
return request;
|
||||||
} else {
|
} else if (requestBody.mediaType === MediaType.TV) {
|
||||||
const tmdbMediaShow = tmdbMedia as Awaited<
|
const tmdbMediaShow = tmdbMedia as Awaited<
|
||||||
ReturnType<typeof tmdb.getTvShow>
|
ReturnType<typeof tmdb.getTvShow>
|
||||||
>;
|
>;
|
||||||
let requestedSeasons =
|
const requestedSeasons =
|
||||||
requestBody.seasons === 'all'
|
requestBody.seasons === 'all'
|
||||||
? tmdbMediaShow.seasons
|
? tmdbMediaShow.seasons
|
||||||
.filter((season) => season.season_number !== 0)
|
.filter((season) => season.season_number !== 0)
|
||||||
.map((season) => season.season_number)
|
.map((season) => season.season_number)
|
||||||
: (requestBody.seasons as number[]);
|
: (requestBody.seasons as number[]);
|
||||||
if (!settings.main.enableSpecialEpisodes) {
|
|
||||||
requestedSeasons = requestedSeasons.filter((sn) => sn > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let existingSeasons: number[] = [];
|
let existingSeasons: number[] = [];
|
||||||
|
|
||||||
// We need to check existing requests on this title to make sure we don't double up on seasons that were
|
// We need to check existing requests on this title to make sure we don't double up on seasons that were
|
||||||
@@ -477,10 +520,10 @@ export class MediaRequest {
|
|||||||
: undefined,
|
: undefined,
|
||||||
is4k: requestBody.is4k,
|
is4k: requestBody.is4k,
|
||||||
serverId: requestBody.serverId,
|
serverId: requestBody.serverId,
|
||||||
profileId: profileId,
|
profileId: requestBody.profileId,
|
||||||
rootFolder: rootFolder,
|
rootFolder: requestBody.rootFolder,
|
||||||
languageProfileId: requestBody.languageProfileId,
|
languageProfileId: requestBody.languageProfileId,
|
||||||
tags: tags,
|
tags: requestBody.tags,
|
||||||
seasons: finalSeasons.map(
|
seasons: finalSeasons.map(
|
||||||
(sn) =>
|
(sn) =>
|
||||||
new SeasonRequest({
|
new SeasonRequest({
|
||||||
@@ -504,6 +547,42 @@ export class MediaRequest {
|
|||||||
isAutoRequest: options.isAutoRequest ?? false,
|
isAutoRequest: options.isAutoRequest ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await requestRepository.save(request);
|
||||||
|
return request;
|
||||||
|
} else {
|
||||||
|
await mediaRepository.save(media);
|
||||||
|
|
||||||
|
const request = new MediaRequest({
|
||||||
|
type: MediaType.MUSIC,
|
||||||
|
media,
|
||||||
|
requestedBy: requestUser,
|
||||||
|
status: user.hasPermission(
|
||||||
|
[
|
||||||
|
Permission.AUTO_APPROVE,
|
||||||
|
Permission.AUTO_APPROVE_MUSIC,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
|
)
|
||||||
|
? MediaRequestStatus.APPROVED
|
||||||
|
: MediaRequestStatus.PENDING,
|
||||||
|
modifiedBy: user.hasPermission(
|
||||||
|
[
|
||||||
|
Permission.AUTO_APPROVE,
|
||||||
|
Permission.AUTO_APPROVE_MUSIC,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
|
)
|
||||||
|
? user
|
||||||
|
: undefined,
|
||||||
|
serverId: requestBody.serverId,
|
||||||
|
profileId: requestBody.profileId,
|
||||||
|
rootFolder: requestBody.rootFolder,
|
||||||
|
tags: requestBody.tags,
|
||||||
|
isAutoRequest: options.isAutoRequest ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
await requestRepository.save(request);
|
await requestRepository.save(request);
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
@@ -715,6 +794,10 @@ export class MediaRequest {
|
|||||||
type: Notification
|
type: Notification
|
||||||
) {
|
) {
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
const lidarr = new LidarrAPI({
|
||||||
|
apiKey: getSettings().lidarr[0].apiKey,
|
||||||
|
url: LidarrAPI.buildUrl(getSettings().lidarr[0], '/api/v1'),
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mediaType = entity.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
const mediaType = entity.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
||||||
@@ -797,6 +880,34 @@ export class MediaRequest {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
} else if (this.type === MediaType.MUSIC) {
|
||||||
|
if (!media.mbId) {
|
||||||
|
throw new Error('MusicBrainz ID not found for media');
|
||||||
|
}
|
||||||
|
|
||||||
|
const album = await lidarr.getAlbumByMusicBrainzId(media.mbId);
|
||||||
|
|
||||||
|
const coverUrl = album.images?.find(
|
||||||
|
(img) => img.coverType === 'Cover'
|
||||||
|
)?.url;
|
||||||
|
|
||||||
|
notificationManager.sendNotification(type, {
|
||||||
|
media,
|
||||||
|
request: this,
|
||||||
|
notifyAdmin,
|
||||||
|
notifySystem,
|
||||||
|
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
||||||
|
event,
|
||||||
|
subject: `${album.title}${
|
||||||
|
album.releaseDate ? ` (${album.releaseDate.slice(0, 4)})` : ''
|
||||||
|
}`,
|
||||||
|
message: truncate(album.overview || '', {
|
||||||
|
length: 500,
|
||||||
|
separator: /\s/,
|
||||||
|
omission: '…',
|
||||||
|
}),
|
||||||
|
image: coverUrl,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Something went wrong sending media notification(s)', {
|
logger.error('Something went wrong sending media notification(s)', {
|
||||||
|
|||||||
@@ -124,6 +124,12 @@ export class User {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public tvQuotaDays?: number;
|
public tvQuotaDays?: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public musicQuotaLimit?: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public musicQuotaDays?: number;
|
||||||
|
|
||||||
@OneToOne(() => UserSettings, (settings) => settings.user, {
|
@OneToOne(() => UserSettings, (settings) => settings.user, {
|
||||||
cascade: true,
|
cascade: true,
|
||||||
eager: true,
|
eager: true,
|
||||||
@@ -334,6 +340,30 @@ export class User {
|
|||||||
).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0)
|
).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
const musicQuotaLimit = !canBypass
|
||||||
|
? this.musicQuotaLimit ?? defaultQuotas.music.quotaLimit
|
||||||
|
: 0;
|
||||||
|
const musicQuotaDays = this.musicQuotaDays ?? defaultQuotas.music.quotaDays;
|
||||||
|
|
||||||
|
// Count music requests made during quota period
|
||||||
|
const musicDate = new Date();
|
||||||
|
if (musicQuotaDays) {
|
||||||
|
musicDate.setDate(musicDate.getDate() - musicQuotaDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
const musicQuotaUsed = musicQuotaLimit
|
||||||
|
? await requestRepository.count({
|
||||||
|
where: {
|
||||||
|
requestedBy: {
|
||||||
|
id: this.id,
|
||||||
|
},
|
||||||
|
createdAt: AfterDate(musicDate),
|
||||||
|
type: MediaType.MUSIC,
|
||||||
|
status: Not(MediaRequestStatus.DECLINED),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
movie: {
|
movie: {
|
||||||
days: movieQuotaDays,
|
days: movieQuotaDays,
|
||||||
@@ -357,6 +387,18 @@ export class User {
|
|||||||
restricted:
|
restricted:
|
||||||
tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false,
|
tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false,
|
||||||
},
|
},
|
||||||
|
music: {
|
||||||
|
days: musicQuotaDays,
|
||||||
|
limit: musicQuotaLimit,
|
||||||
|
used: musicQuotaUsed,
|
||||||
|
remaining: musicQuotaLimit
|
||||||
|
? Math.max(0, musicQuotaLimit - musicQuotaUsed)
|
||||||
|
: undefined,
|
||||||
|
restricted:
|
||||||
|
musicQuotaLimit && musicQuotaLimit - musicQuotaUsed <= 0
|
||||||
|
? true
|
||||||
|
: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export class NotFoundError extends Error {
|
|||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@Unique('UNIQUE_USER_DB', ['tmdbId', 'requestedBy'])
|
@Unique('UNIQUE_USER_DB', ['tmdbId', 'requestedBy'])
|
||||||
|
@Unique('UNIQUE_USER_FOREIGN', ['mbId', 'requestedBy'])
|
||||||
export class Watchlist implements WatchlistItem {
|
export class Watchlist implements WatchlistItem {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id: number;
|
id: number;
|
||||||
@@ -39,9 +40,13 @@ export class Watchlist implements WatchlistItem {
|
|||||||
@Column({ type: 'varchar' })
|
@Column({ type: 'varchar' })
|
||||||
title = '';
|
title = '';
|
||||||
|
|
||||||
@Column()
|
@Column({ nullable: true })
|
||||||
@Index()
|
@Index()
|
||||||
public tmdbId: number;
|
public tmdbId?: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
@Index()
|
||||||
|
public mbId?: string;
|
||||||
|
|
||||||
@ManyToOne(() => User, (user) => user.watchlists, {
|
@ManyToOne(() => User, (user) => user.watchlists, {
|
||||||
eager: true,
|
eager: true,
|
||||||
@@ -52,6 +57,7 @@ export class Watchlist implements WatchlistItem {
|
|||||||
@ManyToOne(() => Media, (media) => media.watchlists, {
|
@ManyToOne(() => Media, (media) => media.watchlists, {
|
||||||
eager: true,
|
eager: true,
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
|
nullable: false,
|
||||||
})
|
})
|
||||||
public media: Media;
|
public media: Media;
|
||||||
|
|
||||||
@@ -77,7 +83,8 @@ export class Watchlist implements WatchlistItem {
|
|||||||
mediaType: MediaType;
|
mediaType: MediaType;
|
||||||
ratingKey?: ZodOptional<ZodString>['_output'];
|
ratingKey?: ZodOptional<ZodString>['_output'];
|
||||||
title?: ZodOptional<ZodString>['_output'];
|
title?: ZodOptional<ZodString>['_output'];
|
||||||
tmdbId: ZodNumber['_output'];
|
tmdbId?: ZodNumber['_output'];
|
||||||
|
mbId?: ZodOptional<ZodString>['_output'];
|
||||||
};
|
};
|
||||||
user: User;
|
user: User;
|
||||||
}): Promise<Watchlist> {
|
}): Promise<Watchlist> {
|
||||||
@@ -85,46 +92,88 @@ export class Watchlist implements WatchlistItem {
|
|||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
const tmdbMedia =
|
let media: Media | null;
|
||||||
watchlistRequest.mediaType === MediaType.MOVIE
|
|
||||||
? await tmdb.getMovie({ movieId: watchlistRequest.tmdbId })
|
|
||||||
: await tmdb.getTvShow({ tvId: watchlistRequest.tmdbId });
|
|
||||||
|
|
||||||
const existing = await watchlistRepository
|
if (watchlistRequest.mediaType === MediaType.MUSIC) {
|
||||||
.createQueryBuilder('watchlist')
|
if (!watchlistRequest.mbId) {
|
||||||
.leftJoinAndSelect('watchlist.requestedBy', 'user')
|
throw new Error('MusicBrainz ID is required for music media type');
|
||||||
.where('user.id = :userId', { userId: user.id })
|
}
|
||||||
.andWhere('watchlist.tmdbId = :tmdbId', {
|
|
||||||
tmdbId: watchlistRequest.tmdbId,
|
|
||||||
})
|
|
||||||
.andWhere('watchlist.mediaType = :mediaType', {
|
|
||||||
mediaType: watchlistRequest.mediaType,
|
|
||||||
})
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
if (existing && existing.length > 0) {
|
const existing = await watchlistRepository
|
||||||
logger.warn('Duplicate request for watchlist blocked', {
|
.createQueryBuilder('watchlist')
|
||||||
tmdbId: watchlistRequest.tmdbId,
|
.leftJoinAndSelect('watchlist.requestedBy', 'user')
|
||||||
mediaType: watchlistRequest.mediaType,
|
.where('user.id = :userId', { userId: user.id })
|
||||||
label: 'Watchlist',
|
.andWhere('watchlist.mbId = :mbId', { mbId: watchlistRequest.mbId })
|
||||||
|
.andWhere('watchlist.mediaType = :mediaType', {
|
||||||
|
mediaType: watchlistRequest.mediaType,
|
||||||
|
})
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
if (existing && existing.length > 0) {
|
||||||
|
logger.warn('Duplicate request for watchlist blocked', {
|
||||||
|
mbId: watchlistRequest.mbId,
|
||||||
|
mediaType: watchlistRequest.mediaType,
|
||||||
|
label: 'Watchlist',
|
||||||
|
});
|
||||||
|
throw new DuplicateWatchlistRequestError();
|
||||||
|
}
|
||||||
|
|
||||||
|
media = await mediaRepository.findOne({
|
||||||
|
where: { mbId: watchlistRequest.mbId, mediaType: MediaType.MUSIC },
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new DuplicateWatchlistRequestError();
|
if (!media) {
|
||||||
}
|
media = new Media({
|
||||||
|
mbId: watchlistRequest.mbId,
|
||||||
|
mediaType: MediaType.MUSIC,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For movies/TV, validate tmdbId exists
|
||||||
|
if (!watchlistRequest.tmdbId) {
|
||||||
|
throw new Error('TMDB ID is required for movie/TV media types');
|
||||||
|
}
|
||||||
|
|
||||||
let media = await mediaRepository.findOne({
|
const tmdbMedia =
|
||||||
where: {
|
watchlistRequest.mediaType === MediaType.MOVIE
|
||||||
tmdbId: watchlistRequest.tmdbId,
|
? await tmdb.getMovie({ movieId: watchlistRequest.tmdbId })
|
||||||
mediaType: watchlistRequest.mediaType,
|
: await tmdb.getTvShow({ tvId: watchlistRequest.tmdbId });
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!media) {
|
const existing = await watchlistRepository
|
||||||
media = new Media({
|
.createQueryBuilder('watchlist')
|
||||||
tmdbId: tmdbMedia.id,
|
.leftJoinAndSelect('watchlist.requestedBy', 'user')
|
||||||
tvdbId: tmdbMedia.external_ids.tvdb_id,
|
.where('user.id = :userId', { userId: user.id })
|
||||||
mediaType: watchlistRequest.mediaType,
|
.andWhere('watchlist.tmdbId = :tmdbId', {
|
||||||
|
tmdbId: watchlistRequest.tmdbId,
|
||||||
|
})
|
||||||
|
.andWhere('watchlist.mediaType = :mediaType', {
|
||||||
|
mediaType: watchlistRequest.mediaType,
|
||||||
|
})
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
if (existing && existing.length > 0) {
|
||||||
|
logger.warn('Duplicate request for watchlist blocked', {
|
||||||
|
tmdbId: watchlistRequest.tmdbId,
|
||||||
|
mediaType: watchlistRequest.mediaType,
|
||||||
|
label: 'Watchlist',
|
||||||
|
});
|
||||||
|
throw new DuplicateWatchlistRequestError();
|
||||||
|
}
|
||||||
|
|
||||||
|
media = await mediaRepository.findOne({
|
||||||
|
where: {
|
||||||
|
tmdbId: watchlistRequest.tmdbId,
|
||||||
|
mediaType: watchlistRequest.mediaType,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
media = new Media({
|
||||||
|
tmdbId: tmdbMedia.id,
|
||||||
|
tvdbId: tmdbMedia.external_ids.tvdb_id,
|
||||||
|
mediaType: watchlistRequest.mediaType,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const watchlist = new this({
|
const watchlist = new this({
|
||||||
@@ -139,14 +188,19 @@ export class Watchlist implements WatchlistItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static async deleteWatchlist(
|
public static async deleteWatchlist(
|
||||||
tmdbId: Watchlist['tmdbId'],
|
id: Watchlist['tmdbId'] | Watchlist['mbId'],
|
||||||
user: User
|
user: User
|
||||||
): Promise<Watchlist | null> {
|
): Promise<Watchlist | null> {
|
||||||
const watchlistRepository = getRepository(this);
|
const watchlistRepository = getRepository(this);
|
||||||
const watchlist = await watchlistRepository.findOneBy({
|
|
||||||
tmdbId,
|
// Check if the ID is a number (TMDB) or string (MusicBrainz)
|
||||||
requestedBy: { id: user.id },
|
const whereClause =
|
||||||
});
|
typeof id === 'number'
|
||||||
|
? { tmdbId: id, requestedBy: { id: user.id } }
|
||||||
|
: { mbId: id, requestedBy: { id: user.id } };
|
||||||
|
|
||||||
|
const watchlist = await watchlistRepository.findOneBy(whereClause);
|
||||||
|
|
||||||
if (!watchlist) {
|
if (!watchlist) {
|
||||||
throw new NotFoundError('not Found');
|
throw new NotFoundError('not Found');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ import logger from '@server/logger';
|
|||||||
import clearCookies from '@server/middleware/clearcookies';
|
import clearCookies from '@server/middleware/clearcookies';
|
||||||
import routes from '@server/routes';
|
import routes from '@server/routes';
|
||||||
import avatarproxy from '@server/routes/avatarproxy';
|
import avatarproxy from '@server/routes/avatarproxy';
|
||||||
import imageproxy from '@server/routes/imageproxy';
|
import caaproxy from '@server/routes/caaproxy';
|
||||||
|
import fanartproxy from '@server/routes/fanartproxy';
|
||||||
|
import lidarrproxy from '@server/routes/lidarrproxy';
|
||||||
|
import tmdbproxy from '@server/routes/tmdbproxy';
|
||||||
import { appDataPermissions } from '@server/utils/appDataVolume';
|
import { appDataPermissions } from '@server/utils/appDataVolume';
|
||||||
import { getAppVersion } from '@server/utils/appVersion';
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
import createCustomProxyAgent from '@server/utils/customProxyAgent';
|
import createCustomProxyAgent from '@server/utils/customProxyAgent';
|
||||||
@@ -235,8 +238,11 @@ app
|
|||||||
server.use('/api/v1', routes);
|
server.use('/api/v1', routes);
|
||||||
|
|
||||||
// Do not set cookies so CDNs can cache them
|
// Do not set cookies so CDNs can cache them
|
||||||
server.use('/imageproxy', clearCookies, imageproxy);
|
server.use('/tmdbproxy', clearCookies, tmdbproxy);
|
||||||
server.use('/avatarproxy', clearCookies, avatarproxy);
|
server.use('/avatarproxy', clearCookies, avatarproxy);
|
||||||
|
server.use('/caaproxy', clearCookies, caaproxy);
|
||||||
|
server.use('/lidarrproxy', clearCookies, lidarrproxy);
|
||||||
|
server.use('/fanartproxy', clearCookies, fanartproxy);
|
||||||
|
|
||||||
server.get('*', (req, res) => handle(req, res));
|
server.get('*', (req, res) => handle(req, res));
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import type { User } from '@server/entity/User';
|
|||||||
import type { PaginatedResponse } from '@server/interfaces/api/common';
|
import type { PaginatedResponse } from '@server/interfaces/api/common';
|
||||||
|
|
||||||
export interface BlacklistItem {
|
export interface BlacklistItem {
|
||||||
tmdbId: number;
|
tmdbId?: number;
|
||||||
mediaType: 'movie' | 'tv';
|
mbId?: string;
|
||||||
|
mediaType: 'movie' | 'tv' | 'music';
|
||||||
title?: string;
|
title?: string;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
user?: User;
|
user?: User;
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ export interface GenreSliderItem {
|
|||||||
export interface WatchlistItem {
|
export interface WatchlistItem {
|
||||||
id: number;
|
id: number;
|
||||||
ratingKey: string;
|
ratingKey: string;
|
||||||
tmdbId: number;
|
tmdbId?: number;
|
||||||
mediaType: 'movie' | 'tv';
|
mbId?: string;
|
||||||
|
mediaType: 'movie' | 'tv' | 'music';
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { LanguageProfile } from '@server/api/servarr/sonarr';
|
|||||||
export interface ServiceCommonServer {
|
export interface ServiceCommonServer {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
is4k: boolean;
|
is4k?: boolean;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
activeProfileId: number;
|
activeProfileId: number;
|
||||||
activeDirectory: string;
|
activeDirectory: string;
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export interface CacheItem {
|
|||||||
|
|
||||||
export interface CacheResponse {
|
export interface CacheResponse {
|
||||||
apiCaches: CacheItem[];
|
apiCaches: CacheItem[];
|
||||||
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
|
imageCache: Record<'tmdb' | 'avatar' | 'caa' | 'lidarr' | 'fanart', { size: number; imageCount: number }>;
|
||||||
dnsCache: {
|
dnsCache: {
|
||||||
stats: DnsStats | undefined;
|
stats: DnsStats | undefined;
|
||||||
entries: DnsEntries | undefined;
|
entries: DnsEntries | undefined;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface QuotaStatus {
|
|||||||
export interface QuotaResponse {
|
export interface QuotaResponse {
|
||||||
movie: QuotaStatus;
|
movie: QuotaStatus;
|
||||||
tv: QuotaStatus;
|
tv: QuotaStatus;
|
||||||
|
music: QuotaStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserWatchDataResponse {
|
export interface UserWatchDataResponse {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
export const watchlistCreate = z.object({
|
export const watchlistCreate = z.object({
|
||||||
ratingKey: z.coerce.string().optional(),
|
ratingKey: z.coerce.string().optional(),
|
||||||
tmdbId: z.coerce.number(),
|
tmdbId: z.coerce.number().optional(),
|
||||||
|
mbId: z.coerce.string().optional(),
|
||||||
mediaType: z.nativeEnum(MediaType),
|
mediaType: z.nativeEnum(MediaType),
|
||||||
title: z.coerce.string().optional(),
|
title: z.coerce.string().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
jellyfinFullScanner,
|
jellyfinFullScanner,
|
||||||
jellyfinRecentScanner,
|
jellyfinRecentScanner,
|
||||||
} from '@server/lib/scanners/jellyfin';
|
} from '@server/lib/scanners/jellyfin';
|
||||||
|
import { lidarrScanner } from '@server/lib/scanners/lidarr';
|
||||||
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
|
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
|
||||||
import { radarrScanner } from '@server/lib/scanners/radarr';
|
import { radarrScanner } from '@server/lib/scanners/radarr';
|
||||||
import { sonarrScanner } from '@server/lib/scanners/sonarr';
|
import { sonarrScanner } from '@server/lib/scanners/sonarr';
|
||||||
@@ -172,6 +173,21 @@ export const startJobs = (): void => {
|
|||||||
cancelFn: () => sonarrScanner.cancel(),
|
cancelFn: () => sonarrScanner.cancel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Run full lidarr scan every 24 hours
|
||||||
|
scheduledJobs.push({
|
||||||
|
id: 'lidarr-scan',
|
||||||
|
name: 'Lidarr Scan',
|
||||||
|
type: 'process',
|
||||||
|
interval: 'hours',
|
||||||
|
cronSchedule: jobs['lidarr-scan'].schedule,
|
||||||
|
job: schedule.scheduleJob(jobs['lidarr-scan'].schedule, () => {
|
||||||
|
logger.info('Starting scheduled job: lidarr Scan', { label: 'Jobs' });
|
||||||
|
lidarrScanner.run();
|
||||||
|
}),
|
||||||
|
running: () => lidarrScanner.status().running,
|
||||||
|
cancelFn: () => lidarrScanner.cancel(),
|
||||||
|
});
|
||||||
|
|
||||||
// Checks if media is still available in plex/sonarr/radarr libs
|
// Checks if media is still available in plex/sonarr/radarr libs
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'availability-sync',
|
id: 'availability-sync',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { JellyfinLibraryItem } from '@server/api/jellyfin';
|
|||||||
import JellyfinAPI from '@server/api/jellyfin';
|
import JellyfinAPI from '@server/api/jellyfin';
|
||||||
import type { PlexMetadata } from '@server/api/plexapi';
|
import type { PlexMetadata } from '@server/api/plexapi';
|
||||||
import PlexAPI from '@server/api/plexapi';
|
import PlexAPI from '@server/api/plexapi';
|
||||||
|
import LidarrAPI, { type LidarrAlbum } from '@server/api/servarr/lidarr';
|
||||||
import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr';
|
import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr';
|
||||||
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
|
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
|
||||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
@@ -12,7 +13,11 @@ import Media from '@server/entity/Media';
|
|||||||
import MediaRequest from '@server/entity/MediaRequest';
|
import MediaRequest from '@server/entity/MediaRequest';
|
||||||
import type Season from '@server/entity/Season';
|
import type Season from '@server/entity/Season';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
import type {
|
||||||
|
LidarrSettings,
|
||||||
|
RadarrSettings,
|
||||||
|
SonarrSettings,
|
||||||
|
} from '@server/lib/settings';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { getHostname } from '@server/utils/getHostname';
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
@@ -28,6 +33,7 @@ class AvailabilitySync {
|
|||||||
private sonarrSeasonsCache: Record<string, SonarrSeason[]>;
|
private sonarrSeasonsCache: Record<string, SonarrSeason[]>;
|
||||||
private radarrServers: RadarrSettings[];
|
private radarrServers: RadarrSettings[];
|
||||||
private sonarrServers: SonarrSettings[];
|
private sonarrServers: SonarrSettings[];
|
||||||
|
private lidarrServers: LidarrSettings[];
|
||||||
|
|
||||||
async run() {
|
async run() {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
@@ -38,6 +44,7 @@ class AvailabilitySync {
|
|||||||
this.sonarrSeasonsCache = {};
|
this.sonarrSeasonsCache = {};
|
||||||
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
|
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
|
||||||
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
|
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
|
||||||
|
this.lidarrServers = settings.lidarr.filter((server) => server.syncEnabled);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info(`Starting availability sync...`, {
|
logger.info(`Starting availability sync...`, {
|
||||||
@@ -451,6 +458,47 @@ class AvailabilitySync {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (media.mediaType === 'music') {
|
||||||
|
let musicExists = false;
|
||||||
|
|
||||||
|
const existsInLidarr = await this.mediaExistsInLidarr(media);
|
||||||
|
|
||||||
|
// Check media server existence (Plex/Jellyfin/Emby)
|
||||||
|
if (mediaServerType === MediaServerType.PLEX) {
|
||||||
|
const { existsInPlex } = await this.mediaExistsInPlex(media, false);
|
||||||
|
if (existsInPlex || existsInLidarr) {
|
||||||
|
musicExists = true;
|
||||||
|
logger.info(
|
||||||
|
`The album [Foreign ID ${media.mbId}] still exists. Preventing removal.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
|
mediaServerType === MediaServerType.EMBY
|
||||||
|
) {
|
||||||
|
const { existsInJellyfin } = await this.mediaExistsInJellyfin(
|
||||||
|
media,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
if (existsInJellyfin || existsInLidarr) {
|
||||||
|
musicExists = true;
|
||||||
|
logger.info(
|
||||||
|
`The album [Foreign ID ${media.mbId}] still exists. Preventing removal.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!musicExists && media.status === MediaStatus.AVAILABLE) {
|
||||||
|
await this.mediaUpdater(media, false, mediaServerType);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
logger.error('Failed to complete availability sync.', {
|
logger.error('Failed to complete availability sync.', {
|
||||||
@@ -558,11 +606,23 @@ class AvailabilitySync {
|
|||||||
? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId']
|
? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId']
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update log message to include music media type
|
||||||
logger.info(
|
logger.info(
|
||||||
`The ${is4k ? '4K' : 'non-4K'} ${
|
`The ${is4k ? '4K' : 'non-4K'} ${
|
||||||
media.mediaType === 'movie' ? 'movie' : 'show'
|
media.mediaType === 'movie'
|
||||||
} [TMDB ID ${media.tmdbId}] was not found in any ${
|
? 'movie'
|
||||||
media.mediaType === 'movie' ? 'Radarr' : 'Sonarr'
|
: media.mediaType === 'tv'
|
||||||
|
? 'show'
|
||||||
|
: 'album'
|
||||||
|
} [${media.mediaType === 'music' ? 'Foreign ID' : 'TMDB ID'} ${
|
||||||
|
media.mediaType === 'music' ? media.mbId : media.tmdbId
|
||||||
|
}] was not found in any ${
|
||||||
|
media.mediaType === 'movie'
|
||||||
|
? 'Radarr'
|
||||||
|
: media.mediaType === 'tv'
|
||||||
|
? 'Sonarr'
|
||||||
|
: 'Lidarr'
|
||||||
} and ${
|
} and ${
|
||||||
mediaServerType === MediaServerType.PLEX
|
mediaServerType === MediaServerType.PLEX
|
||||||
? 'plex'
|
? 'plex'
|
||||||
@@ -577,8 +637,14 @@ class AvailabilitySync {
|
|||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${
|
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${
|
||||||
media.mediaType === 'tv' ? 'show' : 'movie'
|
media.mediaType === 'movie'
|
||||||
} [TMDB ID ${media.tmdbId}].`,
|
? 'movie'
|
||||||
|
: media.mediaType === 'tv'
|
||||||
|
? 'show'
|
||||||
|
: 'album'
|
||||||
|
} [${media.mediaType === 'music' ? 'Foreign ID' : 'TMDB ID'} ${
|
||||||
|
media.mediaType === 'music' ? media.mbId : media.tmdbId
|
||||||
|
}].`,
|
||||||
{
|
{
|
||||||
errorMessage: ex.message,
|
errorMessage: ex.message,
|
||||||
label: 'Availability Sync',
|
label: 'Availability Sync',
|
||||||
@@ -838,6 +904,51 @@ class AvailabilitySync {
|
|||||||
return seasonExists;
|
return seasonExists;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async mediaExistsInLidarr(media: Media): Promise<boolean> {
|
||||||
|
let existsInLidarr = false;
|
||||||
|
|
||||||
|
// Check for availability in all configured Lidarr servers
|
||||||
|
// If any find the media, we will assume the media exists
|
||||||
|
for (const server of this.lidarrServers) {
|
||||||
|
const lidarrAPI = new LidarrAPI({
|
||||||
|
apiKey: server.apiKey,
|
||||||
|
url: LidarrAPI.buildUrl(server, '/api/v1'),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let lidarr: LidarrAlbum | undefined;
|
||||||
|
|
||||||
|
if (media.externalServiceId) {
|
||||||
|
lidarr = await lidarrAPI.getAlbum({
|
||||||
|
id: media.externalServiceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lidarr?.statistics &&
|
||||||
|
lidarr.statistics.totalTrackCount > 0 &&
|
||||||
|
lidarr.statistics.trackFileCount === lidarr.statistics.totalTrackCount
|
||||||
|
) {
|
||||||
|
existsInLidarr = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
if (!ex.message.includes('404')) {
|
||||||
|
existsInLidarr = true;
|
||||||
|
logger.debug(
|
||||||
|
`Failed to retrieve album [Foreign ID ${media.mbId}] from Lidarr.`,
|
||||||
|
{
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return existsInLidarr;
|
||||||
|
}
|
||||||
|
|
||||||
// Plex
|
// Plex
|
||||||
private async mediaExistsInPlex(
|
private async mediaExistsInPlex(
|
||||||
media: Media,
|
media: Media,
|
||||||
@@ -881,8 +992,14 @@ class AvailabilitySync {
|
|||||||
preventSeasonSearch = true;
|
preventSeasonSearch = true;
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
||||||
media.mediaType === 'tv' ? 'show' : 'movie'
|
media.mediaType === 'movie'
|
||||||
} [TMDB ID ${media.tmdbId}] from Plex.`,
|
? 'movie'
|
||||||
|
: media.mediaType === 'tv'
|
||||||
|
? 'show'
|
||||||
|
: 'album'
|
||||||
|
} [${media.mediaType === 'music' ? 'Foreign ID' : 'TMDB ID'} ${
|
||||||
|
media.mediaType === 'music' ? media.mbId : media.tmdbId
|
||||||
|
}] from Plex.`,
|
||||||
{
|
{
|
||||||
errorMessage: ex.message,
|
errorMessage: ex.message,
|
||||||
label: 'Availability Sync',
|
label: 'Availability Sync',
|
||||||
@@ -993,13 +1110,19 @@ class AvailabilitySync {
|
|||||||
existsInJellyfin = true;
|
existsInJellyfin = true;
|
||||||
}
|
}
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
if (!ex.message.includes('404' || '500')) {
|
if (!ex.message.includes('404') && !ex.message.includes('500')) {
|
||||||
existsInJellyfin = false;
|
existsInJellyfin = false;
|
||||||
preventSeasonSearch = true;
|
preventSeasonSearch = true;
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
||||||
media.mediaType === 'tv' ? 'show' : 'movie'
|
media.mediaType === 'movie'
|
||||||
} [TMDB ID ${media.tmdbId}] from Jellyfin.`,
|
? 'movie'
|
||||||
|
: media.mediaType === 'tv'
|
||||||
|
? 'show'
|
||||||
|
: 'album'
|
||||||
|
} [${media.mediaType === 'music' ? 'Foreign ID' : 'TMDB ID'} ${
|
||||||
|
media.mediaType === 'music' ? media.mbId : media.tmdbId
|
||||||
|
}] from Jellyfin.`,
|
||||||
{
|
{
|
||||||
errorMessage: ex.message,
|
errorMessage: ex.message,
|
||||||
label: 'AvailabilitySync',
|
label: 'AvailabilitySync',
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import NodeCache from 'node-cache';
|
|||||||
|
|
||||||
export type AvailableCacheIds =
|
export type AvailableCacheIds =
|
||||||
| 'tmdb'
|
| 'tmdb'
|
||||||
|
| 'musicbrainz'
|
||||||
|
| 'listenbrainz'
|
||||||
|
| 'covertartarchive'
|
||||||
| 'radarr'
|
| 'radarr'
|
||||||
| 'sonarr'
|
| 'sonarr'
|
||||||
|
| 'lidarr'
|
||||||
| 'rt'
|
| 'rt'
|
||||||
| 'imdb'
|
| 'imdb'
|
||||||
| 'github'
|
| 'github'
|
||||||
@@ -48,8 +52,21 @@ class CacheManager {
|
|||||||
stdTtl: 21600,
|
stdTtl: 21600,
|
||||||
checkPeriod: 60 * 30,
|
checkPeriod: 60 * 30,
|
||||||
}),
|
}),
|
||||||
|
musicbrainz: new Cache('musicbrainz', 'MusicBrainz API', {
|
||||||
|
stdTtl: 21600,
|
||||||
|
checkPeriod: 60 * 30,
|
||||||
|
}),
|
||||||
|
listenbrainz: new Cache('listenbrainz', 'ListenBrainz API', {
|
||||||
|
stdTtl: 21600,
|
||||||
|
checkPeriod: 60 * 30,
|
||||||
|
}),
|
||||||
|
covertartarchive: new Cache('covertartarchive', 'CovertArtArchive API', {
|
||||||
|
stdTtl: 21600,
|
||||||
|
checkPeriod: 60 * 30,
|
||||||
|
}),
|
||||||
radarr: new Cache('radarr', 'Radarr API'),
|
radarr: new Cache('radarr', 'Radarr API'),
|
||||||
sonarr: new Cache('sonarr', 'Sonarr API'),
|
sonarr: new Cache('sonarr', 'Sonarr API'),
|
||||||
|
lidarr: new Cache('lidarr', 'Lidarr API'),
|
||||||
rt: new Cache('rt', 'Rotten Tomatoes API', {
|
rt: new Cache('rt', 'Rotten Tomatoes API', {
|
||||||
stdTtl: 43200,
|
stdTtl: 43200,
|
||||||
checkPeriod: 60 * 30,
|
checkPeriod: 60 * 30,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||||
import RadarrAPI from '@server/api/servarr/radarr';
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
@@ -27,6 +28,7 @@ export interface DownloadingItem {
|
|||||||
class DownloadTracker {
|
class DownloadTracker {
|
||||||
private radarrServers: Record<number, DownloadingItem[]> = {};
|
private radarrServers: Record<number, DownloadingItem[]> = {};
|
||||||
private sonarrServers: Record<number, DownloadingItem[]> = {};
|
private sonarrServers: Record<number, DownloadingItem[]> = {};
|
||||||
|
private lidarrServers: Record<number, DownloadingItem[]> = {};
|
||||||
|
|
||||||
public getMovieProgress(
|
public getMovieProgress(
|
||||||
serverId: number,
|
serverId: number,
|
||||||
@@ -54,6 +56,19 @@ class DownloadTracker {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getMusicProgress(
|
||||||
|
serverId: number,
|
||||||
|
externalServiceId: number
|
||||||
|
): DownloadingItem[] {
|
||||||
|
if (!this.lidarrServers[serverId]) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.lidarrServers[serverId].filter(
|
||||||
|
(item) => item.externalId === externalServiceId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async resetDownloadTracker() {
|
public async resetDownloadTracker() {
|
||||||
this.radarrServers = {};
|
this.radarrServers = {};
|
||||||
this.sonarrServers = {};
|
this.sonarrServers = {};
|
||||||
@@ -62,6 +77,7 @@ class DownloadTracker {
|
|||||||
public updateDownloads() {
|
public updateDownloads() {
|
||||||
this.updateRadarrDownloads();
|
this.updateRadarrDownloads();
|
||||||
this.updateSonarrDownloads();
|
this.updateSonarrDownloads();
|
||||||
|
this.updateLidarrDownloads();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateRadarrDownloads() {
|
private async updateRadarrDownloads() {
|
||||||
@@ -220,6 +236,84 @@ class DownloadTracker {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async updateLidarrDownloads() {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
// Remove duplicate servers
|
||||||
|
const filteredServers = uniqWith(settings.lidarr, (lidarrA, lidarrB) => {
|
||||||
|
return (
|
||||||
|
lidarrA.hostname === lidarrB.hostname &&
|
||||||
|
lidarrA.port === lidarrB.port &&
|
||||||
|
lidarrA.baseUrl === lidarrB.baseUrl
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load downloads from Lidarr servers
|
||||||
|
Promise.all(
|
||||||
|
filteredServers.map(async (server) => {
|
||||||
|
if (server.syncEnabled) {
|
||||||
|
const lidarr = new LidarrAPI({
|
||||||
|
apiKey: server.apiKey,
|
||||||
|
url: LidarrAPI.buildUrl(server, '/api/v1'),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await lidarr.refreshMonitoredDownloads();
|
||||||
|
const queueItems = await lidarr.getQueue();
|
||||||
|
|
||||||
|
this.lidarrServers[server.id] = queueItems.map((item) => ({
|
||||||
|
externalId: item.albumId,
|
||||||
|
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
||||||
|
mediaType: MediaType.MUSIC,
|
||||||
|
size: item.size,
|
||||||
|
sizeLeft: item.sizeleft,
|
||||||
|
status: item.status,
|
||||||
|
timeLeft: item.timeleft,
|
||||||
|
title: item.title,
|
||||||
|
downloadId: item.downloadId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (queueItems.length > 0) {
|
||||||
|
logger.debug(
|
||||||
|
`Found ${queueItems.length} item(s) in progress on Lidarr server: ${server.name}`,
|
||||||
|
{ label: 'Download Tracker' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error(
|
||||||
|
`Unable to get queue from Lidarr server: ${server.name}`,
|
||||||
|
{
|
||||||
|
label: 'Download Tracker',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate this data to matching servers
|
||||||
|
const matchingServers = settings.lidarr.filter(
|
||||||
|
(ls) =>
|
||||||
|
ls.hostname === server.hostname &&
|
||||||
|
ls.port === server.port &&
|
||||||
|
ls.baseUrl === server.baseUrl &&
|
||||||
|
ls.id !== server.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingServers.length > 0) {
|
||||||
|
logger.debug(
|
||||||
|
`Matching download data to ${matchingServers.length} other Lidarr server(s)`,
|
||||||
|
{ label: 'Download Tracker' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
matchingServers.forEach((ms) => {
|
||||||
|
if (ms.syncEnabled) {
|
||||||
|
this.lidarrServers[ms.id] = this.lidarrServers[server.id];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadTracker = new DownloadTracker();
|
const downloadTracker = new DownloadTracker();
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ class ImageProxy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await promises.mkdir(dir, { recursive: true });
|
await promises.mkdir(dir, { recursive: true });
|
||||||
await promises.writeFile(filename, buffer);
|
await promises.writeFile(filename, new Uint8Array(buffer));
|
||||||
}
|
}
|
||||||
|
|
||||||
private getCacheKey(path: string) {
|
private getCacheKey(path: string) {
|
||||||
@@ -340,7 +340,9 @@ class ImageProxy {
|
|||||||
const hash = createHash('sha256');
|
const hash = createHash('sha256');
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (typeof item === 'number') hash.update(String(item));
|
if (typeof item === 'number') hash.update(String(item));
|
||||||
else {
|
else if (Buffer.isBuffer(item)) {
|
||||||
|
hash.update(item.toString());
|
||||||
|
} else {
|
||||||
hash.update(item);
|
hash.update(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,26 +9,29 @@ export enum Permission {
|
|||||||
AUTO_APPROVE = 128,
|
AUTO_APPROVE = 128,
|
||||||
AUTO_APPROVE_MOVIE = 256,
|
AUTO_APPROVE_MOVIE = 256,
|
||||||
AUTO_APPROVE_TV = 512,
|
AUTO_APPROVE_TV = 512,
|
||||||
REQUEST_4K = 1024,
|
AUTO_APPROVE_MUSIC = 1024,
|
||||||
REQUEST_4K_MOVIE = 2048,
|
REQUEST_4K = 2048,
|
||||||
REQUEST_4K_TV = 4096,
|
REQUEST_4K_MOVIE = 4096,
|
||||||
REQUEST_ADVANCED = 8192,
|
REQUEST_4K_TV = 8192,
|
||||||
REQUEST_VIEW = 16384,
|
REQUEST_ADVANCED = 16384,
|
||||||
AUTO_APPROVE_4K = 32768,
|
REQUEST_VIEW = 32768,
|
||||||
AUTO_APPROVE_4K_MOVIE = 65536,
|
AUTO_APPROVE_4K = 65536,
|
||||||
AUTO_APPROVE_4K_TV = 131072,
|
AUTO_APPROVE_4K_MOVIE = 131072,
|
||||||
REQUEST_MOVIE = 262144,
|
AUTO_APPROVE_4K_TV = 262144,
|
||||||
REQUEST_TV = 524288,
|
REQUEST_MOVIE = 524288,
|
||||||
MANAGE_ISSUES = 1048576,
|
REQUEST_TV = 1048576,
|
||||||
VIEW_ISSUES = 2097152,
|
REQUEST_MUSIC = 2097152,
|
||||||
CREATE_ISSUES = 4194304,
|
AUTO_REQUEST = 4194304,
|
||||||
AUTO_REQUEST = 8388608,
|
AUTO_REQUEST_MOVIE = 8388608,
|
||||||
AUTO_REQUEST_MOVIE = 16777216,
|
AUTO_REQUEST_TV = 16777216,
|
||||||
AUTO_REQUEST_TV = 33554432,
|
AUTO_REQUEST_MUSIC = 33554432,
|
||||||
RECENT_VIEW = 67108864,
|
MANAGE_ISSUES = 67108864,
|
||||||
WATCHLIST_VIEW = 134217728,
|
VIEW_ISSUES = 134217728,
|
||||||
MANAGE_BLACKLIST = 268435456,
|
CREATE_ISSUES = 268435456,
|
||||||
VIEW_BLACKLIST = 1073741824,
|
RECENT_VIEW = 536870912,
|
||||||
|
WATCHLIST_VIEW = 1073741824,
|
||||||
|
MANAGE_BLACKLIST = 2147483648,
|
||||||
|
VIEW_BLACKLIST = 4294967296,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PermissionCheckOptions {
|
export interface PermissionCheckOptions {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface MediaIds {
|
|||||||
imdbId?: string;
|
imdbId?: string;
|
||||||
tvdbId?: number;
|
tvdbId?: number;
|
||||||
isHama?: boolean;
|
isHama?: boolean;
|
||||||
|
mbId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProcessOptions {
|
interface ProcessOptions {
|
||||||
@@ -79,11 +80,24 @@ class BaseScanner<T> {
|
|||||||
this.updateRate = updateRate ?? UPDATE_RATE;
|
this.updateRate = updateRate ?? UPDATE_RATE;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getExisting(tmdbId: number, mediaType: MediaType) {
|
private async getExisting(
|
||||||
|
id: number | string,
|
||||||
|
mediaType: MediaType
|
||||||
|
): Promise<Media | null> {
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
|
|
||||||
|
const query: Record<string, any> = {
|
||||||
|
mediaType,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mediaType === MediaType.MUSIC) {
|
||||||
|
query.mbId = id.toString();
|
||||||
|
} else {
|
||||||
|
query.tmdbId = Number(id);
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await mediaRepository.findOne({
|
const existing = await mediaRepository.findOne({
|
||||||
where: { tmdbId: tmdbId, mediaType },
|
where: query,
|
||||||
});
|
});
|
||||||
|
|
||||||
return existing;
|
return existing;
|
||||||
@@ -526,6 +540,61 @@ class BaseScanner<T> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async processMusic(
|
||||||
|
mbId: string,
|
||||||
|
{
|
||||||
|
serviceId,
|
||||||
|
externalServiceId,
|
||||||
|
externalServiceSlug,
|
||||||
|
mediaAddedAt,
|
||||||
|
processing = false,
|
||||||
|
title = 'Unknown Title',
|
||||||
|
}: ProcessOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
|
||||||
|
await this.asyncLock.dispatch(mbId, async () => {
|
||||||
|
const existing = await mediaRepository.findOne({
|
||||||
|
where: { mbId, mediaType: MediaType.MUSIC },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const newMedia = new Media();
|
||||||
|
newMedia.mbId = mbId;
|
||||||
|
newMedia.status = processing
|
||||||
|
? MediaStatus.PROCESSING
|
||||||
|
: MediaStatus.AVAILABLE;
|
||||||
|
newMedia.mediaType = MediaType.MUSIC;
|
||||||
|
|
||||||
|
if (mediaAddedAt) {
|
||||||
|
newMedia.mediaAddedAt = mediaAddedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serviceId) {
|
||||||
|
newMedia.serviceId = serviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (externalServiceId) {
|
||||||
|
newMedia.externalServiceId = externalServiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (externalServiceSlug) {
|
||||||
|
newMedia.externalServiceSlug = externalServiceSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mediaRepository.save(newMedia);
|
||||||
|
this.log(`Saved new media: ${title}`);
|
||||||
|
} catch (err) {
|
||||||
|
this.log('Failed to save new media', 'error', {
|
||||||
|
title,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call startRun from child class whenever a run is starting to
|
* Call startRun from child class whenever a run is starting to
|
||||||
* ensure required values are set
|
* ensure required values are set
|
||||||
|
|||||||
@@ -691,6 +691,90 @@ class JellyfinScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async processMusic(jellyfinitem: JellyfinLibraryItem) {
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metadata = await this.jfClient.getItemData(jellyfinitem.Id);
|
||||||
|
const newMedia = new Media();
|
||||||
|
|
||||||
|
if (!metadata?.Id) {
|
||||||
|
logger.debug('No Id metadata for this title. Skipping', {
|
||||||
|
label: 'Jellyfin Sync',
|
||||||
|
ratingKey: jellyfinitem.Id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use MusicBrainzReleaseGroup as the foreign ID
|
||||||
|
newMedia.mbId = metadata.ProviderIds?.MusicBrainzReleaseGroup;
|
||||||
|
|
||||||
|
// Only proceed if we have a valid ID
|
||||||
|
if (!newMedia.mbId) {
|
||||||
|
this.log(
|
||||||
|
'No MusicBrainz Album ID found for this title. Skipping.',
|
||||||
|
'debug',
|
||||||
|
{
|
||||||
|
title: metadata.Name,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.asyncLock.dispatch(metadata.Id, async () => {
|
||||||
|
const existing = await mediaRepository.findOne({
|
||||||
|
where: { mbId: newMedia.mbId, mediaType: MediaType.MUSIC },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
let changedExisting = false;
|
||||||
|
|
||||||
|
if (existing.status !== MediaStatus.AVAILABLE) {
|
||||||
|
existing.status = MediaStatus.AVAILABLE;
|
||||||
|
existing.mediaAddedAt = new Date(metadata.DateCreated ?? '');
|
||||||
|
changedExisting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing.mediaAddedAt && !changedExisting) {
|
||||||
|
existing.mediaAddedAt = new Date(metadata.DateCreated ?? '');
|
||||||
|
changedExisting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.jellyfinMediaId !== metadata.Id) {
|
||||||
|
existing.jellyfinMediaId = metadata.Id;
|
||||||
|
changedExisting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedExisting) {
|
||||||
|
await mediaRepository.save(existing);
|
||||||
|
this.log(
|
||||||
|
`Request for ${metadata.Name} exists. New media set to AVAILABLE`,
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.log(`Album already exists: ${metadata.Name}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newMedia.status = MediaStatus.AVAILABLE;
|
||||||
|
newMedia.mediaType = MediaType.MUSIC;
|
||||||
|
newMedia.mediaAddedAt = new Date(metadata.DateCreated ?? '');
|
||||||
|
newMedia.jellyfinMediaId = metadata.Id;
|
||||||
|
await mediaRepository.save(newMedia);
|
||||||
|
this.log(`Saved new album: ${metadata.Name}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
this.log(
|
||||||
|
`Failed to process Jellyfin item, id: ${jellyfinitem.Id}`,
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
errorMessage: e.message,
|
||||||
|
jellyfinitem,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async processItems(slicedItems: JellyfinLibraryItem[]) {
|
private async processItems(slicedItems: JellyfinLibraryItem[]) {
|
||||||
this.processedAnidbSeason = new Map();
|
this.processedAnidbSeason = new Map();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -699,6 +783,8 @@ class JellyfinScanner {
|
|||||||
await this.processMovie(item);
|
await this.processMovie(item);
|
||||||
} else if (item.Type === 'Series') {
|
} else if (item.Type === 'Series') {
|
||||||
await this.processShow(item);
|
await this.processShow(item);
|
||||||
|
} else if (item.Type === 'MusicAlbum') {
|
||||||
|
await this.processMusic(item);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
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 TheMovieDb from '@server/api/themoviedb';
|
||||||
import type {
|
import type {
|
||||||
|
TmdbCollectionResult,
|
||||||
TmdbMovieDetails,
|
TmdbMovieDetails,
|
||||||
TmdbMovieResult,
|
TmdbMovieResult,
|
||||||
TmdbPersonDetails,
|
TmdbPersonDetails,
|
||||||
TmdbPersonResult,
|
TmdbPersonResult,
|
||||||
TmdbSearchMovieResponse,
|
TmdbSearchMovieResponse,
|
||||||
TmdbSearchMultiResponse,
|
|
||||||
TmdbSearchTvResponse,
|
TmdbSearchTvResponse,
|
||||||
TmdbTvDetails,
|
TmdbTvDetails,
|
||||||
TmdbTvResult,
|
TmdbTvResult,
|
||||||
@@ -21,6 +26,19 @@ import {
|
|||||||
isTvDetails,
|
isTvDetails,
|
||||||
} from '@server/utils/typeHelpers';
|
} from '@server/utils/typeHelpers';
|
||||||
|
|
||||||
|
export type CombinedSearchResponse = {
|
||||||
|
page: number;
|
||||||
|
total_pages: number;
|
||||||
|
total_results: number;
|
||||||
|
results: (
|
||||||
|
| MbArtistResult
|
||||||
|
| MbAlbumResult
|
||||||
|
| TmdbMovieResult
|
||||||
|
| TmdbTvResult
|
||||||
|
| TmdbPersonResult
|
||||||
|
| TmdbCollectionResult
|
||||||
|
)[];
|
||||||
|
};
|
||||||
interface SearchProvider {
|
interface SearchProvider {
|
||||||
pattern: RegExp;
|
pattern: RegExp;
|
||||||
search: ({
|
search: ({
|
||||||
@@ -31,7 +49,7 @@ interface SearchProvider {
|
|||||||
id: string;
|
id: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
query?: string;
|
query?: string;
|
||||||
}) => Promise<TmdbSearchMultiResponse>;
|
}) => Promise<CombinedSearchResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchProviders: SearchProvider[] = [];
|
const searchProviders: SearchProvider[] = [];
|
||||||
@@ -214,3 +232,50 @@ searchProviders.push({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
searchProviders.push({
|
||||||
|
pattern: new RegExp(/(?<=musicbrainz:)/),
|
||||||
|
search: async ({ query }) => {
|
||||||
|
const musicbrainz = new MusicBrainz();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await musicbrainz.searchMulti({
|
||||||
|
query: query || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const results: CombinedSearchResponse['results'] = response.map(
|
||||||
|
(result) => {
|
||||||
|
if (result.artist) {
|
||||||
|
return {
|
||||||
|
...result.artist,
|
||||||
|
media_type: 'artist',
|
||||||
|
} as MbArtistResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.album) {
|
||||||
|
return {
|
||||||
|
...result.album,
|
||||||
|
media_type: 'album',
|
||||||
|
} as MbAlbumResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Invalid search result type');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: 1,
|
||||||
|
total_pages: 1,
|
||||||
|
total_results: results.length,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
page: 1,
|
||||||
|
total_pages: 1,
|
||||||
|
total_results: 0,
|
||||||
|
results: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface Library {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
type: 'show' | 'movie';
|
type: 'show' | 'movie' | 'music';
|
||||||
lastScan?: number;
|
lastScan?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +83,17 @@ export interface RadarrSettings extends DVRSettings {
|
|||||||
minimumAvailability: string;
|
minimumAvailability: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LidarrSettings extends DVRSettings {
|
||||||
|
url: string;
|
||||||
|
apiKey: string;
|
||||||
|
activeProfileId: number;
|
||||||
|
activeDirectory: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
is4k: boolean;
|
||||||
|
tagRequests: boolean;
|
||||||
|
preventSearch: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SonarrSettings extends DVRSettings {
|
export interface SonarrSettings extends DVRSettings {
|
||||||
seriesType: 'standard' | 'daily' | 'anime';
|
seriesType: 'standard' | 'daily' | 'anime';
|
||||||
animeSeriesType: 'standard' | 'daily' | 'anime';
|
animeSeriesType: 'standard' | 'daily' | 'anime';
|
||||||
@@ -130,6 +141,7 @@ export interface MainSettings {
|
|||||||
defaultQuotas: {
|
defaultQuotas: {
|
||||||
movie: Quota;
|
movie: Quota;
|
||||||
tv: Quota;
|
tv: Quota;
|
||||||
|
music: Quota;
|
||||||
};
|
};
|
||||||
hideAvailable: boolean;
|
hideAvailable: boolean;
|
||||||
hideBlacklisted: boolean;
|
hideBlacklisted: boolean;
|
||||||
@@ -340,6 +352,7 @@ export type JobId =
|
|||||||
| 'plex-refresh-token'
|
| 'plex-refresh-token'
|
||||||
| 'radarr-scan'
|
| 'radarr-scan'
|
||||||
| 'sonarr-scan'
|
| 'sonarr-scan'
|
||||||
|
| 'lidarr-scan'
|
||||||
| 'download-sync'
|
| 'download-sync'
|
||||||
| 'download-sync-reset'
|
| 'download-sync-reset'
|
||||||
| 'jellyfin-recently-added-scan'
|
| 'jellyfin-recently-added-scan'
|
||||||
@@ -358,6 +371,7 @@ export interface AllSettings {
|
|||||||
tautulli: TautulliSettings;
|
tautulli: TautulliSettings;
|
||||||
radarr: RadarrSettings[];
|
radarr: RadarrSettings[];
|
||||||
sonarr: SonarrSettings[];
|
sonarr: SonarrSettings[];
|
||||||
|
lidarr: LidarrSettings[];
|
||||||
public: PublicSettings;
|
public: PublicSettings;
|
||||||
notifications: NotificationSettings;
|
notifications: NotificationSettings;
|
||||||
jobs: Record<JobId, JobSettings>;
|
jobs: Record<JobId, JobSettings>;
|
||||||
@@ -387,6 +401,7 @@ class Settings {
|
|||||||
defaultQuotas: {
|
defaultQuotas: {
|
||||||
movie: {},
|
movie: {},
|
||||||
tv: {},
|
tv: {},
|
||||||
|
music: {},
|
||||||
},
|
},
|
||||||
hideAvailable: false,
|
hideAvailable: false,
|
||||||
hideBlacklisted: false,
|
hideBlacklisted: false,
|
||||||
@@ -429,6 +444,7 @@ class Settings {
|
|||||||
anime: MetadataProviderType.TMDB,
|
anime: MetadataProviderType.TMDB,
|
||||||
},
|
},
|
||||||
radarr: [],
|
radarr: [],
|
||||||
|
lidarr: [],
|
||||||
sonarr: [],
|
sonarr: [],
|
||||||
public: {
|
public: {
|
||||||
initialized: false,
|
initialized: false,
|
||||||
@@ -552,6 +568,9 @@ class Settings {
|
|||||||
'sonarr-scan': {
|
'sonarr-scan': {
|
||||||
schedule: '0 30 4 * * *',
|
schedule: '0 30 4 * * *',
|
||||||
},
|
},
|
||||||
|
'lidarr-scan': {
|
||||||
|
schedule: '0 30 4 * * *',
|
||||||
|
},
|
||||||
'availability-sync': {
|
'availability-sync': {
|
||||||
schedule: '0 0 5 * * *',
|
schedule: '0 0 5 * * *',
|
||||||
},
|
},
|
||||||
@@ -649,6 +668,14 @@ class Settings {
|
|||||||
this.data.radarr = data;
|
this.data.radarr = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get lidarr(): LidarrSettings[] {
|
||||||
|
return this.data.lidarr;
|
||||||
|
}
|
||||||
|
|
||||||
|
set lidarr(data: LidarrSettings[]) {
|
||||||
|
this.data.lidarr = data;
|
||||||
|
}
|
||||||
|
|
||||||
get sonarr(): SonarrSettings[] {
|
get sonarr(): SonarrSettings[] {
|
||||||
return this.data.sonarr;
|
return this.data.sonarr;
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
adult: boolean;
|
||||||
imdbId?: string;
|
imdbId?: string;
|
||||||
homepage?: string;
|
homepage?: string;
|
||||||
|
mbArtistId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonCredit {
|
export interface PersonCredit {
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import type {
|
||||||
|
MbAlbumDetails,
|
||||||
|
MbAlbumResult,
|
||||||
|
MbArtistDetails,
|
||||||
|
MbArtistResult,
|
||||||
|
MbImage,
|
||||||
|
} from '@server/api/musicbrainz/interfaces';
|
||||||
import type {
|
import type {
|
||||||
TmdbCollectionResult,
|
TmdbCollectionResult,
|
||||||
TmdbMovieDetails,
|
TmdbMovieDetails,
|
||||||
@@ -9,10 +16,15 @@ import type {
|
|||||||
} from '@server/api/themoviedb/interfaces';
|
} from '@server/api/themoviedb/interfaces';
|
||||||
import { MediaType as MainMediaType } from '@server/constants/media';
|
import { MediaType as MainMediaType } from '@server/constants/media';
|
||||||
import type Media from '@server/entity/Media';
|
import type Media from '@server/entity/Media';
|
||||||
|
export type MediaType =
|
||||||
|
| 'tv'
|
||||||
|
| 'movie'
|
||||||
|
| 'person'
|
||||||
|
| 'collection'
|
||||||
|
| 'artist'
|
||||||
|
| 'album';
|
||||||
|
|
||||||
export type MediaType = 'tv' | 'movie' | 'person' | 'collection';
|
interface TmdbSearchResult {
|
||||||
|
|
||||||
interface SearchResult {
|
|
||||||
id: number;
|
id: number;
|
||||||
mediaType: MediaType;
|
mediaType: MediaType;
|
||||||
popularity: number;
|
popularity: number;
|
||||||
@@ -26,7 +38,14 @@ interface SearchResult {
|
|||||||
mediaInfo?: Media;
|
mediaInfo?: Media;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MovieResult extends SearchResult {
|
interface MbSearchResult {
|
||||||
|
id: string;
|
||||||
|
mediaType: MediaType;
|
||||||
|
score: number;
|
||||||
|
mediaInfo?: Media;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MovieResult extends TmdbSearchResult {
|
||||||
mediaType: 'movie';
|
mediaType: 'movie';
|
||||||
title: string;
|
title: string;
|
||||||
originalTitle: string;
|
originalTitle: string;
|
||||||
@@ -36,7 +55,7 @@ export interface MovieResult extends SearchResult {
|
|||||||
mediaInfo?: Media;
|
mediaInfo?: Media;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TvResult extends SearchResult {
|
export interface TvResult extends TmdbSearchResult {
|
||||||
mediaType: 'tv';
|
mediaType: 'tv';
|
||||||
name: string;
|
name: string;
|
||||||
originalName: string;
|
originalName: string;
|
||||||
@@ -66,7 +85,46 @@ export interface PersonResult {
|
|||||||
knownFor: (MovieResult | TvResult)[];
|
knownFor: (MovieResult | TvResult)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Results = MovieResult | TvResult | PersonResult | CollectionResult;
|
export interface ArtistResult extends MbSearchResult {
|
||||||
|
mediaType: 'artist';
|
||||||
|
artistname: string;
|
||||||
|
overview: string;
|
||||||
|
disambiguation: string;
|
||||||
|
type: 'Group' | 'Person';
|
||||||
|
status: string;
|
||||||
|
sortname: string;
|
||||||
|
genres: string[];
|
||||||
|
images: MbImage[];
|
||||||
|
artistimage?: string;
|
||||||
|
rating?: {
|
||||||
|
Count: number;
|
||||||
|
Value: number | null;
|
||||||
|
};
|
||||||
|
mediaInfo?: Media;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlbumResult extends MbSearchResult {
|
||||||
|
mediaType: 'album';
|
||||||
|
title: string;
|
||||||
|
artistid: string;
|
||||||
|
artistname?: string;
|
||||||
|
type: string;
|
||||||
|
releasedate: string;
|
||||||
|
disambiguation: string;
|
||||||
|
genres: string[];
|
||||||
|
images: MbImage[];
|
||||||
|
secondarytypes: string[];
|
||||||
|
mediaInfo?: Media;
|
||||||
|
overview?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Results =
|
||||||
|
| MovieResult
|
||||||
|
| TvResult
|
||||||
|
| PersonResult
|
||||||
|
| CollectionResult
|
||||||
|
| ArtistResult
|
||||||
|
| AlbumResult;
|
||||||
|
|
||||||
export const mapMovieResult = (
|
export const mapMovieResult = (
|
||||||
movieResult: TmdbMovieResult,
|
movieResult: TmdbMovieResult,
|
||||||
@@ -144,18 +202,131 @@ export const mapPersonResult = (
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mapSearchResults = (
|
export const mapArtistResult = (
|
||||||
|
artistResult: MbArtistResult,
|
||||||
|
media?: Media
|
||||||
|
): ArtistResult => ({
|
||||||
|
id: artistResult.id,
|
||||||
|
score: artistResult.score,
|
||||||
|
mediaType: 'artist',
|
||||||
|
artistname: artistResult.artistname,
|
||||||
|
overview: artistResult.overview,
|
||||||
|
disambiguation: artistResult.disambiguation,
|
||||||
|
type: artistResult.type,
|
||||||
|
status: artistResult.status,
|
||||||
|
sortname: artistResult.sortname,
|
||||||
|
genres: artistResult.genres,
|
||||||
|
images: artistResult.images,
|
||||||
|
rating: artistResult.rating,
|
||||||
|
mediaInfo: media,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mapAlbumResult = (
|
||||||
|
albumResult: MbAlbumResult,
|
||||||
|
media?: Media
|
||||||
|
): AlbumResult => ({
|
||||||
|
id: albumResult.id,
|
||||||
|
score: albumResult.score,
|
||||||
|
mediaType: 'album',
|
||||||
|
title: albumResult.title,
|
||||||
|
artistid: albumResult.artistid,
|
||||||
|
artistname: albumResult.artists?.[0]?.artistname,
|
||||||
|
type: albumResult.type,
|
||||||
|
releasedate: albumResult.releasedate,
|
||||||
|
disambiguation: albumResult.disambiguation,
|
||||||
|
genres: albumResult.genres,
|
||||||
|
images: albumResult.images,
|
||||||
|
secondarytypes: albumResult.secondarytypes,
|
||||||
|
mediaInfo: media,
|
||||||
|
overview: albumResult.artists?.[0]?.overview,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isTmdbMovie = (
|
||||||
|
result:
|
||||||
|
| TmdbMovieResult
|
||||||
|
| TmdbTvResult
|
||||||
|
| TmdbPersonResult
|
||||||
|
| TmdbCollectionResult
|
||||||
|
| MbArtistResult
|
||||||
|
| MbAlbumResult
|
||||||
|
): result is TmdbMovieResult => {
|
||||||
|
return result.media_type === 'movie';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTmdbTv = (
|
||||||
|
result:
|
||||||
|
| TmdbMovieResult
|
||||||
|
| TmdbTvResult
|
||||||
|
| TmdbPersonResult
|
||||||
|
| TmdbCollectionResult
|
||||||
|
| MbArtistResult
|
||||||
|
| MbAlbumResult
|
||||||
|
): result is TmdbTvResult => {
|
||||||
|
return result.media_type === 'tv';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTmdbPerson = (
|
||||||
|
result:
|
||||||
|
| TmdbMovieResult
|
||||||
|
| TmdbTvResult
|
||||||
|
| TmdbPersonResult
|
||||||
|
| TmdbCollectionResult
|
||||||
|
| MbArtistResult
|
||||||
|
| MbAlbumResult
|
||||||
|
): result is TmdbPersonResult => {
|
||||||
|
return result.media_type === 'person';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTmdbCollection = (
|
||||||
|
result:
|
||||||
|
| TmdbMovieResult
|
||||||
|
| TmdbTvResult
|
||||||
|
| TmdbPersonResult
|
||||||
|
| TmdbCollectionResult
|
||||||
|
| MbArtistResult
|
||||||
|
| MbAlbumResult
|
||||||
|
): result is TmdbCollectionResult => {
|
||||||
|
return result.media_type === 'collection';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLidarrArtist = (
|
||||||
|
result:
|
||||||
|
| TmdbMovieResult
|
||||||
|
| TmdbTvResult
|
||||||
|
| TmdbPersonResult
|
||||||
|
| TmdbCollectionResult
|
||||||
|
| MbArtistResult
|
||||||
|
| MbAlbumResult
|
||||||
|
): result is MbArtistResult => {
|
||||||
|
return result.media_type === 'artist';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLidarrAlbum = (
|
||||||
|
result:
|
||||||
|
| TmdbMovieResult
|
||||||
|
| TmdbTvResult
|
||||||
|
| TmdbPersonResult
|
||||||
|
| TmdbCollectionResult
|
||||||
|
| MbArtistResult
|
||||||
|
| MbAlbumResult
|
||||||
|
): result is MbAlbumResult => {
|
||||||
|
return result.media_type === 'album';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapSearchResults = async (
|
||||||
results: (
|
results: (
|
||||||
| TmdbMovieResult
|
| TmdbMovieResult
|
||||||
| TmdbTvResult
|
| TmdbTvResult
|
||||||
| TmdbPersonResult
|
| TmdbPersonResult
|
||||||
| TmdbCollectionResult
|
| TmdbCollectionResult
|
||||||
|
| MbArtistResult
|
||||||
|
| MbAlbumResult
|
||||||
)[],
|
)[],
|
||||||
media?: Media[]
|
media?: Media[]
|
||||||
): Results[] =>
|
): Promise<Results[]> =>
|
||||||
results.map((result) => {
|
Promise.all(
|
||||||
switch (result.media_type) {
|
results.map(async (result) => {
|
||||||
case 'movie':
|
if (isTmdbMovie(result)) {
|
||||||
return mapMovieResult(
|
return mapMovieResult(
|
||||||
result,
|
result,
|
||||||
media?.find(
|
media?.find(
|
||||||
@@ -163,7 +334,7 @@ export const mapSearchResults = (
|
|||||||
req.tmdbId === result.id && req.mediaType === MainMediaType.MOVIE
|
req.tmdbId === result.id && req.mediaType === MainMediaType.MOVIE
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
case 'tv':
|
} else if (isTmdbTv(result)) {
|
||||||
return mapTvResult(
|
return mapTvResult(
|
||||||
result,
|
result,
|
||||||
media?.find(
|
media?.find(
|
||||||
@@ -171,12 +342,25 @@ export const mapSearchResults = (
|
|||||||
req.tmdbId === result.id && req.mediaType === MainMediaType.TV
|
req.tmdbId === result.id && req.mediaType === MainMediaType.TV
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
case 'collection':
|
} else if (isTmdbPerson(result)) {
|
||||||
return mapCollectionResult(result);
|
|
||||||
default:
|
|
||||||
return mapPersonResult(result);
|
return mapPersonResult(result);
|
||||||
}
|
} else if (isTmdbCollection(result)) {
|
||||||
});
|
return mapCollectionResult(result);
|
||||||
|
} else if (isLidarrArtist(result)) {
|
||||||
|
return mapArtistResult(result);
|
||||||
|
} else if (isLidarrAlbum(result)) {
|
||||||
|
return mapAlbumResult(
|
||||||
|
result,
|
||||||
|
media?.find(
|
||||||
|
(req) =>
|
||||||
|
req.mbId === result.id && req.mediaType === MainMediaType.MUSIC
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unhandled result type: ${JSON.stringify(result)}`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export const mapMovieDetailsToResult = (
|
export const mapMovieDetailsToResult = (
|
||||||
movieDetails: TmdbMovieDetails
|
movieDetails: TmdbMovieDetails
|
||||||
@@ -228,3 +412,39 @@ export const mapPersonDetailsToResult = (
|
|||||||
profile_path: personDetails.profile_path,
|
profile_path: personDetails.profile_path,
|
||||||
known_for: [],
|
known_for: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const mapArtistDetailsToResult = (
|
||||||
|
artistDetails: MbArtistDetails
|
||||||
|
): MbArtistResult => ({
|
||||||
|
id: artistDetails.id,
|
||||||
|
score: 100, // Default score since we're mapping details
|
||||||
|
media_type: 'artist',
|
||||||
|
artistname: artistDetails.artistname,
|
||||||
|
overview: artistDetails.overview,
|
||||||
|
disambiguation: artistDetails.disambiguation,
|
||||||
|
type: artistDetails.type,
|
||||||
|
status: artistDetails.status,
|
||||||
|
sortname: artistDetails.sortname,
|
||||||
|
genres: artistDetails.genres,
|
||||||
|
images: artistDetails.images,
|
||||||
|
links: artistDetails.links,
|
||||||
|
rating: artistDetails.rating,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mapAlbumDetailsToResult = (
|
||||||
|
albumDetails: MbAlbumDetails
|
||||||
|
): MbAlbumResult => ({
|
||||||
|
id: albumDetails.id,
|
||||||
|
score: 100,
|
||||||
|
media_type: 'album',
|
||||||
|
title: albumDetails.title,
|
||||||
|
artistid: albumDetails.artistid,
|
||||||
|
artists: albumDetails.artists,
|
||||||
|
type: albumDetails.type,
|
||||||
|
releasedate: albumDetails.releasedate,
|
||||||
|
disambiguation: albumDetails.disambiguation,
|
||||||
|
genres: albumDetails.genres,
|
||||||
|
images: albumDetails.images,
|
||||||
|
secondarytypes: albumDetails.secondarytypes,
|
||||||
|
overview: albumDetails.overview || albumDetails.artists?.[0]?.overview || '',
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { z } from 'zod';
|
|||||||
const blacklistRoutes = Router();
|
const blacklistRoutes = Router();
|
||||||
|
|
||||||
export const blacklistAdd = z.object({
|
export const blacklistAdd = z.object({
|
||||||
tmdbId: z.coerce.number(),
|
tmdbId: z.coerce.number().optional(),
|
||||||
|
mbId: z.string().optional(),
|
||||||
mediaType: z.nativeEnum(MediaType),
|
mediaType: z.nativeEnum(MediaType),
|
||||||
title: z.coerce.string().optional(),
|
title: z.coerce.string().optional(),
|
||||||
user: z.coerce.number(),
|
user: z.coerce.number(),
|
||||||
@@ -90,10 +91,12 @@ blacklistRoutes.get(
|
|||||||
}),
|
}),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const blacklisteRepository = getRepository(Blacklist);
|
const blacklistRepository = getRepository(Blacklist);
|
||||||
|
|
||||||
const blacklistItem = await blacklisteRepository.findOneOrFail({
|
const blacklistItem = await blacklistRepository.findOneOrFail({
|
||||||
where: { tmdbId: Number(req.params.id) },
|
where: !isNaN(Number(req.params.id))
|
||||||
|
? { tmdbId: Number(req.params.id) }
|
||||||
|
: { mbId: req.params.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).send(blacklistItem);
|
return res.status(200).send(blacklistItem);
|
||||||
@@ -135,6 +138,7 @@ blacklistRoutes.post(
|
|||||||
default:
|
default:
|
||||||
logger.warn('Something wrong with data blacklist', {
|
logger.warn('Something wrong with data blacklist', {
|
||||||
tmdbId: req.body.tmdbId,
|
tmdbId: req.body.tmdbId,
|
||||||
|
mbId: req.body.mbId,
|
||||||
mediaType: req.body.mediaType,
|
mediaType: req.body.mediaType,
|
||||||
label: 'Blacklist',
|
label: 'Blacklist',
|
||||||
});
|
});
|
||||||
@@ -154,18 +158,22 @@ blacklistRoutes.delete(
|
|||||||
}),
|
}),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const blacklisteRepository = getRepository(Blacklist);
|
const blacklistRepository = getRepository(Blacklist);
|
||||||
|
|
||||||
const blacklistItem = await blacklisteRepository.findOneOrFail({
|
const blacklistItem = await blacklistRepository.findOneOrFail({
|
||||||
where: { tmdbId: Number(req.params.id) },
|
where: !isNaN(Number(req.params.id))
|
||||||
|
? { tmdbId: Number(req.params.id) }
|
||||||
|
: { mbId: req.params.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
await blacklisteRepository.remove(blacklistItem);
|
await blacklistRepository.remove(blacklistItem);
|
||||||
|
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
|
|
||||||
const mediaItem = await mediaRepository.findOneOrFail({
|
const mediaItem = await mediaRepository.findOneOrFail({
|
||||||
where: { tmdbId: Number(req.params.id) },
|
where: !isNaN(Number(req.params.id))
|
||||||
|
? { tmdbId: Number(req.params.id) }
|
||||||
|
: { mbId: req.params.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
await mediaRepository.remove(mediaItem);
|
await mediaRepository.remove(mediaItem);
|
||||||
|
|||||||
35
server/routes/caaproxy.ts
Normal file
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 PlexTvAPI from '@server/api/plextv';
|
||||||
import type { SortOptions } from '@server/api/themoviedb';
|
import type { SortOptions } from '@server/api/themoviedb';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
@@ -854,6 +856,131 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
discoverRoutes.get('/music', async (req, res, next) => {
|
||||||
|
const listenbrainz = new ListenBrainzAPI();
|
||||||
|
const musicbrainz = new MusicBrainz();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = Number(req.query.page) || 1;
|
||||||
|
const pageSize = 20;
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const sortBy = (req.query.sortBy as string) || 'listen_count.desc';
|
||||||
|
|
||||||
|
const data = await listenbrainz.getTopAlbums({
|
||||||
|
offset,
|
||||||
|
count: pageSize,
|
||||||
|
range: 'week',
|
||||||
|
});
|
||||||
|
|
||||||
|
const media = await Media.getRelatedMedia(
|
||||||
|
req.user,
|
||||||
|
data.payload.release_groups.map((album) => album.release_group_mbid)
|
||||||
|
);
|
||||||
|
|
||||||
|
const albumDetailsPromises = data.payload.release_groups.map(
|
||||||
|
async (album) => {
|
||||||
|
try {
|
||||||
|
const details = await musicbrainz.getAlbum({
|
||||||
|
albumId: album.release_group_mbid,
|
||||||
|
});
|
||||||
|
|
||||||
|
const images =
|
||||||
|
details.images?.length > 0
|
||||||
|
? details.images.filter((img) => img.CoverType === 'Cover')
|
||||||
|
: album.caa_id
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
CoverType: 'Cover',
|
||||||
|
Url: `https://coverartarchive.org/release/${album.caa_release_mbid}/front`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: album.release_group_mbid,
|
||||||
|
mediaType: 'album',
|
||||||
|
type: 'Album',
|
||||||
|
title: album.release_group_name,
|
||||||
|
artistname: album.artist_name,
|
||||||
|
artistId: album.artist_mbids[0],
|
||||||
|
releasedate: details.releasedate || '',
|
||||||
|
images,
|
||||||
|
mediaInfo: media?.find(
|
||||||
|
(med) => med.mbId === album.release_group_mbid
|
||||||
|
),
|
||||||
|
listenCount: album.listen_count,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
id: album.release_group_mbid,
|
||||||
|
mediaType: 'album',
|
||||||
|
type: 'Album',
|
||||||
|
title: album.release_group_name,
|
||||||
|
artistname: album.artist_name,
|
||||||
|
artistId: album.artist_mbids[0],
|
||||||
|
releasedate: '',
|
||||||
|
images: album.caa_id
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
CoverType: 'Cover',
|
||||||
|
Url: `https://coverartarchive.org/release/${album.caa_release_mbid}/front`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
mediaInfo: media?.find(
|
||||||
|
(med) => med.mbId === album.release_group_mbid
|
||||||
|
),
|
||||||
|
listenCount: album.listen_count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.all(albumDetailsPromises);
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'listen_count.asc':
|
||||||
|
results.sort((a, b) => a.listenCount - b.listenCount);
|
||||||
|
break;
|
||||||
|
case 'listen_count.desc':
|
||||||
|
results.sort((a, b) => b.listenCount - a.listenCount);
|
||||||
|
break;
|
||||||
|
case 'title.asc':
|
||||||
|
results.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
break;
|
||||||
|
case 'title.desc':
|
||||||
|
results.sort((a, b) => b.title.localeCompare(a.title));
|
||||||
|
break;
|
||||||
|
case 'release_date.asc':
|
||||||
|
results.sort((a, b) =>
|
||||||
|
(a.releasedate || '').localeCompare(b.releasedate || '')
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'release_date.desc':
|
||||||
|
results.sort((a, b) =>
|
||||||
|
(b.releasedate || '').localeCompare(a.releasedate || '')
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
page,
|
||||||
|
totalPages: Math.ceil(data.payload.count / pageSize),
|
||||||
|
totalResults: data.payload.count,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Something went wrong retrieving popular music', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to retrieve popular music.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
|
discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
|
||||||
'/watchlist',
|
'/watchlist',
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
|
|||||||
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 blacklistRoutes from './blacklist';
|
||||||
import collectionRoutes from './collection';
|
import collectionRoutes from './collection';
|
||||||
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
|
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
|
||||||
|
import groupRoutes from './group';
|
||||||
import issueRoutes from './issue';
|
import issueRoutes from './issue';
|
||||||
import issueCommentRoutes from './issueComment';
|
import issueCommentRoutes from './issueComment';
|
||||||
import mediaRoutes from './media';
|
import mediaRoutes from './media';
|
||||||
import movieRoutes from './movie';
|
import movieRoutes from './movie';
|
||||||
|
import musicRoutes from './music';
|
||||||
import personRoutes from './person';
|
import personRoutes from './person';
|
||||||
import requestRoutes from './request';
|
import requestRoutes from './request';
|
||||||
import searchRoutes from './search';
|
import searchRoutes from './search';
|
||||||
@@ -154,8 +156,10 @@ router.use('/watchlist', isAuthenticated(), watchlistRoutes);
|
|||||||
router.use('/blacklist', isAuthenticated(), blacklistRoutes);
|
router.use('/blacklist', isAuthenticated(), blacklistRoutes);
|
||||||
router.use('/movie', isAuthenticated(), movieRoutes);
|
router.use('/movie', isAuthenticated(), movieRoutes);
|
||||||
router.use('/tv', isAuthenticated(), tvRoutes);
|
router.use('/tv', isAuthenticated(), tvRoutes);
|
||||||
|
router.use('/music', isAuthenticated(), musicRoutes);
|
||||||
router.use('/media', isAuthenticated(), mediaRoutes);
|
router.use('/media', isAuthenticated(), mediaRoutes);
|
||||||
router.use('/person', isAuthenticated(), personRoutes);
|
router.use('/person', isAuthenticated(), personRoutes);
|
||||||
|
router.use('/group', isAuthenticated(), groupRoutes);
|
||||||
router.use('/collection', isAuthenticated(), collectionRoutes);
|
router.use('/collection', isAuthenticated(), collectionRoutes);
|
||||||
router.use('/service', isAuthenticated(), serviceRoutes);
|
router.use('/service', isAuthenticated(), serviceRoutes);
|
||||||
router.use('/issue', isAuthenticated(), issueRoutes);
|
router.use('/issue', isAuthenticated(), issueRoutes);
|
||||||
|
|||||||
@@ -173,6 +173,12 @@ issueRoutes.get('/count', async (req, res, next) => {
|
|||||||
})
|
})
|
||||||
.getCount();
|
.getCount();
|
||||||
|
|
||||||
|
const lyricsCount = await query
|
||||||
|
.where('issue.issueType = :issueType', {
|
||||||
|
issueType: IssueType.LYRICS,
|
||||||
|
})
|
||||||
|
.getCount();
|
||||||
|
|
||||||
const othersCount = await query
|
const othersCount = await query
|
||||||
.where('issue.issueType = :issueType', {
|
.where('issue.issueType = :issueType', {
|
||||||
issueType: IssueType.OTHER,
|
issueType: IssueType.OTHER,
|
||||||
@@ -196,6 +202,7 @@ issueRoutes.get('/count', async (req, res, next) => {
|
|||||||
video: videoCount,
|
video: videoCount,
|
||||||
audio: audioCount,
|
audio: audioCount,
|
||||||
subtitles: subtitlesCount,
|
subtitles: subtitlesCount,
|
||||||
|
lyrics: lyricsCount,
|
||||||
others: othersCount,
|
others: othersCount,
|
||||||
open: openCount,
|
open: openCount,
|
||||||
closed: closedCount,
|
closed: closedCount,
|
||||||
|
|||||||
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 RadarrAPI from '@server/api/servarr/radarr';
|
||||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
import TautulliAPI from '@server/api/tautulli';
|
import TautulliAPI from '@server/api/tautulli';
|
||||||
@@ -199,43 +200,52 @@ mediaRoutes.delete(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const is4k = String(req.query.is4k) === 'true';
|
const is4k = String(req.query.is4k) === 'true';
|
||||||
const isMovie = media.mediaType === MediaType.MOVIE;
|
|
||||||
|
|
||||||
let serviceSettings;
|
let serviceSettings;
|
||||||
if (isMovie) {
|
if (media.mediaType === MediaType.MOVIE) {
|
||||||
serviceSettings = settings.radarr.find(
|
serviceSettings = settings.radarr.find(
|
||||||
(radarr) => radarr.isDefault && radarr.is4k === is4k
|
(radarr) => radarr.isDefault && radarr.is4k === is4k
|
||||||
);
|
);
|
||||||
} else {
|
} else if(media.mediaType === MediaType.TV) {
|
||||||
serviceSettings = settings.sonarr.find(
|
serviceSettings = settings.sonarr.find(
|
||||||
(sonarr) => sonarr.isDefault && sonarr.is4k === is4k
|
(sonarr) => sonarr.isDefault && sonarr.is4k === is4k
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
serviceSettings = settings.lidarr.find(
|
||||||
|
(lidarr) => lidarr.isDefault);
|
||||||
}
|
}
|
||||||
|
|
||||||
const specificServiceId = is4k ? media.serviceId4k : media.serviceId;
|
|
||||||
if (
|
const specificServiceId = is4k ? media.serviceId4k : media.serviceId;
|
||||||
specificServiceId &&
|
if (
|
||||||
specificServiceId >= 0 &&
|
specificServiceId &&
|
||||||
serviceSettings?.id !== specificServiceId
|
specificServiceId >= 0 &&
|
||||||
) {
|
serviceSettings?.id !== specificServiceId
|
||||||
if (isMovie) {
|
) {
|
||||||
serviceSettings = settings.radarr.find(
|
if (media.mediaType === MediaType.MOVIE) {
|
||||||
(radarr) => radarr.id === specificServiceId
|
serviceSettings = settings.radarr.find(
|
||||||
);
|
(radarr) => radarr.id === specificServiceId
|
||||||
} else {
|
);
|
||||||
serviceSettings = settings.sonarr.find(
|
} else if (media.mediaType === MediaType.TV) {
|
||||||
(sonarr) => sonarr.id === specificServiceId
|
serviceSettings = settings.sonarr.find(
|
||||||
);
|
(sonarr) => sonarr.id === specificServiceId
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
serviceSettings = settings.lidarr.find(
|
||||||
|
(lidarr) => lidarr.id === media.serviceId
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if (!serviceSettings) {
|
||||||
if (!serviceSettings) {
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`There is no default ${
|
`There is no default ${
|
||||||
is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr'
|
media.mediaType === MediaType.MOVIE
|
||||||
}/ server configured. Did you set any of your ${
|
? 'Radarr'
|
||||||
is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr'
|
: media.mediaType === MediaType.TV
|
||||||
} servers as default?`,
|
? 'Sonarr'
|
||||||
|
: 'Lidarr'
|
||||||
|
} server configured.`,
|
||||||
{
|
{
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
mediaId: media.id,
|
mediaId: media.id,
|
||||||
@@ -245,31 +255,43 @@ mediaRoutes.delete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let service;
|
let service;
|
||||||
if (isMovie) {
|
if (media.mediaType === MediaType.MOVIE) {
|
||||||
service = new RadarrAPI({
|
service = new RadarrAPI({
|
||||||
apiKey: serviceSettings?.apiKey,
|
apiKey: serviceSettings.apiKey,
|
||||||
url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'),
|
url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'),
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
|
await (service as RadarrAPI).removeMovie(media.tmdbId);
|
||||||
|
} else if (media.mediaType === MediaType.TV) {
|
||||||
service = new SonarrAPI({
|
service = new SonarrAPI({
|
||||||
apiKey: serviceSettings?.apiKey,
|
apiKey: serviceSettings?.apiKey,
|
||||||
url: SonarrAPI.buildUrl(serviceSettings, '/api/v3'),
|
url: SonarrAPI.buildUrl(serviceSettings, '/api/v3'),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (isMovie) {
|
|
||||||
await (service as RadarrAPI).removeMovie(media.tmdbId);
|
|
||||||
} else {
|
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
|
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||||
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
||||||
|
|
||||||
if (!tvdbId) {
|
if (!tvdbId) {
|
||||||
throw new Error('TVDB ID not found');
|
throw new Error('TVDB ID not found');
|
||||||
}
|
}
|
||||||
await (service as SonarrAPI).removeSeries(tvdbId);
|
await (service as SonarrAPI).removeSeries(tvdbId);
|
||||||
|
} else if (media.mediaType == MediaType.MUSIC)
|
||||||
|
{
|
||||||
|
service = new LidarrAPI({
|
||||||
|
apiKey: serviceSettings.apiKey,
|
||||||
|
url: LidarrAPI.buildUrl(serviceSettings, '/api/v1'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.removeAlbum(
|
||||||
|
media.externalServiceId
|
||||||
|
? parseInt(media.externalServiceId.toString())
|
||||||
|
: 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Something went wrong fetching media in delete request', {
|
logger.error('Something went wrong fetching media in delete request', {
|
||||||
label: 'Media',
|
label: 'Media',
|
||||||
|
|||||||
251
server/routes/music.ts
Normal file
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 TheMovieDb from '@server/api/themoviedb';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
@@ -12,13 +14,48 @@ const personRoutes = Router();
|
|||||||
|
|
||||||
personRoutes.get('/:id', async (req, res, next) => {
|
personRoutes.get('/:id', async (req, res, next) => {
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
const musicBrainz = new MusicBrainz();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const person = await tmdb.getPerson({
|
const person = await tmdb.getPerson({
|
||||||
personId: Number(req.params.id),
|
personId: Number(req.params.id),
|
||||||
language: (req.query.language as string) ?? req.locale,
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
return res.status(200).json(mapPersonDetails(person));
|
|
||||||
|
let mbArtistId = null;
|
||||||
|
try {
|
||||||
|
const artists = await musicBrainz.searchArtist({
|
||||||
|
query: person.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchedArtist = artists.find((artist) => {
|
||||||
|
if (artist.type !== 'Person') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameMatches =
|
||||||
|
artist.artistname.toLowerCase() === person.name.toLowerCase();
|
||||||
|
const aliasMatches = artist.artistaliases?.some(
|
||||||
|
(alias) => alias.toLowerCase() === person.name.toLowerCase()
|
||||||
|
);
|
||||||
|
return nameMatches || aliasMatches;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchedArtist) {
|
||||||
|
mbArtistId = matchedArtist.id;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Failed to fetch music artist data', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
personName: person.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
...mapPersonDetails(person),
|
||||||
|
mbArtistId,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug('Something went wrong retrieving person', {
|
logger.debug('Something went wrong retrieving person', {
|
||||||
label: 'API',
|
label: 'API',
|
||||||
@@ -32,6 +69,148 @@ personRoutes.get('/:id', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
personRoutes.get('/:id/discography', async (req, res, next) => {
|
||||||
|
const musicBrainz = new MusicBrainz();
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
const coverArtArchive = new CoverArtArchive();
|
||||||
|
const artistId = req.query.artistId as string;
|
||||||
|
const type = req.query.type as string;
|
||||||
|
const page = Number(req.query.page) || 1;
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
if (!artistId) {
|
||||||
|
return next({
|
||||||
|
status: 400,
|
||||||
|
message: 'Artist ID is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const person = await tmdb.getPerson({
|
||||||
|
personId: Number(req.params.id),
|
||||||
|
language: (req.query.language as string) ?? req.locale,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!person.birthday) {
|
||||||
|
return res.status(200).json({
|
||||||
|
page: 1,
|
||||||
|
pageInfo: { total: 0, totalPages: 0 },
|
||||||
|
results: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const artistDetails = await musicBrainz.getArtist({
|
||||||
|
artistId: artistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mapArtistDetails } = await import('@server/models/Artist');
|
||||||
|
const mappedDetails = await mapArtistDetails(artistDetails);
|
||||||
|
|
||||||
|
if (!mappedDetails.Albums?.length) {
|
||||||
|
return res.status(200).json({
|
||||||
|
page: 1,
|
||||||
|
pageInfo: {
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
},
|
||||||
|
results: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let filteredAlbums = mappedDetails.Albums;
|
||||||
|
if (type) {
|
||||||
|
if (type === 'Other') {
|
||||||
|
filteredAlbums = mappedDetails.Albums.filter(
|
||||||
|
(album) => !['Album', 'Single', 'EP'].includes(album.type)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
filteredAlbums = mappedDetails.Albums.filter(
|
||||||
|
(album) => album.type === type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const albumPromises = filteredAlbums.map(async (album) => {
|
||||||
|
try {
|
||||||
|
const albumDetails = await musicBrainz.getAlbum({
|
||||||
|
albumId: album.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
let images = albumDetails.images;
|
||||||
|
|
||||||
|
if (!images || images.length === 0) {
|
||||||
|
try {
|
||||||
|
const coverArtData = await coverArtArchive.getCoverArt(album.id);
|
||||||
|
if (coverArtData.images?.length > 0) {
|
||||||
|
images = coverArtData.images.map((img) => ({
|
||||||
|
CoverType: img.front ? 'Cover' : 'Poster',
|
||||||
|
Url: img.image,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (coverArtError) {
|
||||||
|
// Silently handle cover art fetch errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...album,
|
||||||
|
images: images || [],
|
||||||
|
releasedate: albumDetails.releasedate || '',
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return album;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumsWithDetails = await Promise.all(albumPromises);
|
||||||
|
|
||||||
|
const sortedAlbums = albumsWithDetails.sort((a, b) => {
|
||||||
|
if (!a.releasedate && !b.releasedate) return 0;
|
||||||
|
if (!a.releasedate) return 1;
|
||||||
|
if (!b.releasedate) return -1;
|
||||||
|
return (
|
||||||
|
new Date(b.releasedate).getTime() - new Date(a.releasedate).getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalResults = sortedAlbums.length;
|
||||||
|
const totalPages = Math.ceil(totalResults / pageSize);
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const end = start + pageSize;
|
||||||
|
const paginatedAlbums = sortedAlbums.slice(start, end);
|
||||||
|
|
||||||
|
const media = await Media.getRelatedMedia(
|
||||||
|
req.user,
|
||||||
|
paginatedAlbums.map((album) => album.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = paginatedAlbums.map((album) => ({
|
||||||
|
...album,
|
||||||
|
mediaInfo: media?.find((med) => med.mbId === album.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
page,
|
||||||
|
pageInfo: {
|
||||||
|
total: totalResults,
|
||||||
|
totalPages,
|
||||||
|
},
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong retrieving discography', {
|
||||||
|
label: 'Person API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
personId: req.params.id,
|
||||||
|
artistId,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to retrieve discography.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
personRoutes.get('/:id/combined_credits', async (req, res, next) => {
|
personRoutes.get('/:id/combined_credits', async (req, res, next) => {
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||||
import RadarrAPI from '@server/api/servarr/radarr';
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
import {
|
import {
|
||||||
@@ -213,6 +214,21 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// get all quality profiles for every configured lidarr server
|
||||||
|
const lidarrServers = await Promise.all(
|
||||||
|
settings.lidarr.map(async (lidarrSetting) => {
|
||||||
|
const lidarr = new LidarrAPI({
|
||||||
|
apiKey: lidarrSetting.apiKey,
|
||||||
|
url: LidarrAPI.buildUrl(lidarrSetting, '/api/v1'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: lidarrSetting.id,
|
||||||
|
profiles: await lidarr.getProfiles().catch(() => undefined),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// add profile names to the media requests, with undefined if not found
|
// add profile names to the media requests, with undefined if not found
|
||||||
let mappedRequests = requests.map((r) => {
|
let mappedRequests = requests.map((r) => {
|
||||||
switch (r.type) {
|
switch (r.type) {
|
||||||
@@ -234,6 +250,14 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
?.profiles?.find((profile) => profile.id === r.profileId)?.name,
|
?.profiles?.find((profile) => profile.id === r.profileId)?.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case MediaType.MUSIC: {
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
profileName: lidarrServers
|
||||||
|
.find((serverr) => serverr.id === r.serverId)
|
||||||
|
?.profiles?.find((profile) => profile.id === r.profileId)?.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
import MusicBrainz from '@server/api/musicbrainz';
|
||||||
|
import type {
|
||||||
|
MbAlbumResult,
|
||||||
|
MbArtistResult,
|
||||||
|
} from '@server/api/musicbrainz/interfaces';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces';
|
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import { findSearchProvider } from '@server/lib/search';
|
import {
|
||||||
|
findSearchProvider,
|
||||||
|
type CombinedSearchResponse,
|
||||||
|
} from '@server/lib/search';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { mapSearchResults } from '@server/models/Search';
|
import { mapSearchResults } from '@server/models/Search';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
@@ -11,7 +18,8 @@ const searchRoutes = Router();
|
|||||||
searchRoutes.get('/', async (req, res, next) => {
|
searchRoutes.get('/', async (req, res, next) => {
|
||||||
const queryString = req.query.query as string;
|
const queryString = req.query.query as string;
|
||||||
const searchProvider = findSearchProvider(queryString.toLowerCase());
|
const searchProvider = findSearchProvider(queryString.toLowerCase());
|
||||||
let results: TmdbSearchMultiResponse;
|
let results: CombinedSearchResponse;
|
||||||
|
let combinedResults: CombinedSearchResponse['results'] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (searchProvider) {
|
if (searchProvider) {
|
||||||
@@ -25,24 +33,56 @@ searchRoutes.get('/', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
const tmdbResults = await tmdb.searchMulti({
|
||||||
results = await tmdb.searchMulti({
|
|
||||||
query: queryString,
|
query: queryString,
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: (req.query.language as string) ?? req.locale,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
combinedResults = [...tmdbResults.results];
|
||||||
|
|
||||||
|
const musicbrainz = new MusicBrainz();
|
||||||
|
const mbResults = await musicbrainz.searchMulti({ query: queryString });
|
||||||
|
|
||||||
|
if (mbResults.length > 0) {
|
||||||
|
const mbMappedResults = mbResults.map((result) => {
|
||||||
|
if (result.artist) {
|
||||||
|
return {
|
||||||
|
...result.artist,
|
||||||
|
media_type: 'artist',
|
||||||
|
} as MbArtistResult;
|
||||||
|
}
|
||||||
|
if (result.album) {
|
||||||
|
return {
|
||||||
|
...result.album,
|
||||||
|
media_type: 'album',
|
||||||
|
} as MbAlbumResult;
|
||||||
|
}
|
||||||
|
throw new Error('Invalid search result type');
|
||||||
|
});
|
||||||
|
|
||||||
|
combinedResults = [...combinedResults, ...mbMappedResults];
|
||||||
|
}
|
||||||
|
|
||||||
|
results = {
|
||||||
|
page: tmdbResults.page,
|
||||||
|
total_pages: tmdbResults.total_pages,
|
||||||
|
total_results: tmdbResults.total_results + mbResults.length,
|
||||||
|
results: combinedResults,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
req.user,
|
req.user,
|
||||||
results.results.map((result) => result.id)
|
results.results.map((result) => ('id' in result ? result.id : 0))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mappedResults = await mapSearchResults(results.results, media);
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
page: results.page,
|
page: results.page,
|
||||||
totalPages: results.total_pages,
|
totalPages: results.total_pages,
|
||||||
totalResults: results.total_results,
|
totalResults: results.total_results,
|
||||||
results: mapSearchResults(results.results, media),
|
results: mappedResults,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug('Something went wrong retrieving search results', {
|
logger.debug('Something went wrong retrieving search results', {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||||
import RadarrAPI from '@server/api/servarr/radarr';
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
@@ -213,4 +214,69 @@ serviceRoutes.get<{ tmdbId: string }>(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
serviceRoutes.get('/lidarr', async (req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
const filteredLidarrServers: ServiceCommonServer[] = settings.lidarr.map(
|
||||||
|
(lidarr) => ({
|
||||||
|
id: lidarr.id,
|
||||||
|
name: lidarr.name,
|
||||||
|
activeDirectory: lidarr.activeDirectory,
|
||||||
|
activeProfileId: lidarr.activeProfileId,
|
||||||
|
activeTags: lidarr.tags ?? [],
|
||||||
|
isDefault: lidarr.isDefault,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json(filteredLidarrServers);
|
||||||
|
});
|
||||||
|
|
||||||
|
serviceRoutes.get<{ id: string }>('/lidarr/:id', async (req, res, next) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
const lidarrSettings = settings.lidarr.find(
|
||||||
|
(lidarr) => lidarr.id === Number(req.params.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lidarrSettings) {
|
||||||
|
return next({
|
||||||
|
status: 404,
|
||||||
|
message: 'Lidarr server not found.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lidarr = new LidarrAPI({
|
||||||
|
apiKey: lidarrSettings.apiKey,
|
||||||
|
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [profiles, rootFolders, tags] = await Promise.all([
|
||||||
|
lidarr.getProfiles(),
|
||||||
|
lidarr.getRootFolders(),
|
||||||
|
lidarr.getTags(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
server: {
|
||||||
|
id: lidarrSettings.id,
|
||||||
|
name: lidarrSettings.name,
|
||||||
|
isDefault: lidarrSettings.isDefault,
|
||||||
|
activeDirectory: lidarrSettings.activeDirectory,
|
||||||
|
activeProfileId: lidarrSettings.activeProfileId,
|
||||||
|
},
|
||||||
|
profiles,
|
||||||
|
rootFolders: rootFolders.map((folder) => ({
|
||||||
|
id: folder.id,
|
||||||
|
path: folder.path,
|
||||||
|
freeSpace: folder.freeSpace,
|
||||||
|
totalSpace: folder.totalSpace,
|
||||||
|
})),
|
||||||
|
tags,
|
||||||
|
} as ServiceCommonServerWithDetails);
|
||||||
|
} catch (e) {
|
||||||
|
next({ status: 500, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default serviceRoutes;
|
export default serviceRoutes;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import path from 'path';
|
|||||||
import semver from 'semver';
|
import semver from 'semver';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
import metadataRoutes from './metadata';
|
import metadataRoutes from './metadata';
|
||||||
|
import lidarrRoutes from './lidarr';
|
||||||
import notificationRoutes from './notifications';
|
import notificationRoutes from './notifications';
|
||||||
import radarrRoutes from './radarr';
|
import radarrRoutes from './radarr';
|
||||||
import sonarrRoutes from './sonarr';
|
import sonarrRoutes from './sonarr';
|
||||||
@@ -49,6 +50,7 @@ const settingsRoutes = Router();
|
|||||||
settingsRoutes.use('/notifications', notificationRoutes);
|
settingsRoutes.use('/notifications', notificationRoutes);
|
||||||
settingsRoutes.use('/radarr', radarrRoutes);
|
settingsRoutes.use('/radarr', radarrRoutes);
|
||||||
settingsRoutes.use('/sonarr', sonarrRoutes);
|
settingsRoutes.use('/sonarr', sonarrRoutes);
|
||||||
|
settingsRoutes.use('/lidarr', lidarrRoutes);
|
||||||
settingsRoutes.use('/discover', discoverSettingRoutes);
|
settingsRoutes.use('/discover', discoverSettingRoutes);
|
||||||
settingsRoutes.use('/metadatas', metadataRoutes);
|
settingsRoutes.use('/metadatas', metadataRoutes);
|
||||||
|
|
||||||
@@ -758,6 +760,9 @@ settingsRoutes.get('/cache', async (_req, res) => {
|
|||||||
|
|
||||||
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
|
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
|
||||||
const avatarImageCache = await ImageProxy.getImageStats('avatar');
|
const avatarImageCache = await ImageProxy.getImageStats('avatar');
|
||||||
|
const caaImageCache = await ImageProxy.getImageStats('caa');
|
||||||
|
const lidarrImageCache = await ImageProxy.getImageStats('lidarr');
|
||||||
|
const fanartImageCache = await ImageProxy.getImageStats('fanart');
|
||||||
|
|
||||||
const stats: DnsStats | undefined = dnsCache?.getStats();
|
const stats: DnsStats | undefined = dnsCache?.getStats();
|
||||||
const entries: DnsEntries | undefined = dnsCache?.getCacheEntries();
|
const entries: DnsEntries | undefined = dnsCache?.getCacheEntries();
|
||||||
@@ -767,6 +772,9 @@ settingsRoutes.get('/cache', async (_req, res) => {
|
|||||||
imageCache: {
|
imageCache: {
|
||||||
tmdb: tmdbImageCache,
|
tmdb: tmdbImageCache,
|
||||||
avatar: avatarImageCache,
|
avatar: avatarImageCache,
|
||||||
|
caa: caaImageCache,
|
||||||
|
lidarr: lidarrImageCache,
|
||||||
|
fanart: fanartImageCache,
|
||||||
},
|
},
|
||||||
dnsCache: {
|
dnsCache: {
|
||||||
stats,
|
stats,
|
||||||
|
|||||||
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:
|
case QueryFailedError:
|
||||||
logger.warn('Something wrong with data watchlist', {
|
logger.warn('Something wrong with data watchlist', {
|
||||||
tmdbId: req.body.tmdbId,
|
tmdbId: req.body.tmdbId,
|
||||||
|
mbId: req.body.mbId,
|
||||||
mediaType: req.body.mediaType,
|
mediaType: req.body.mediaType,
|
||||||
label: 'Watchlist',
|
label: 'Watchlist',
|
||||||
});
|
});
|
||||||
@@ -49,7 +50,7 @@ watchlistRoutes.post<never, Watchlist, Watchlist>(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
watchlistRoutes.delete('/:tmdbId', async (req, res, next) => {
|
watchlistRoutes.delete('/:id', async (req, res, next) => {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return next({
|
return next({
|
||||||
status: 401,
|
status: 401,
|
||||||
@@ -57,7 +58,11 @@ watchlistRoutes.delete('/:tmdbId', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await Watchlist.deleteWatchlist(Number(req.params.tmdbId), req.user);
|
const id = isNaN(Number(req.params.id))
|
||||||
|
? req.params.id
|
||||||
|
: Number(req.params.id);
|
||||||
|
|
||||||
|
await Watchlist.deleteWatchlist(id, req.user);
|
||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof NotFoundError) {
|
if (e instanceof NotFoundError) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import { IssueType, IssueTypeName } from '@server/constants/issue';
|
import { IssueType, IssueTypeName } from '@server/constants/issue';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
@@ -7,6 +8,7 @@ import Media from '@server/entity/Media';
|
|||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import type { EntitySubscriberInterface, InsertEvent } from 'typeorm';
|
import type { EntitySubscriberInterface, InsertEvent } from 'typeorm';
|
||||||
@@ -21,8 +23,8 @@ export class IssueCommentSubscriber
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async sendIssueCommentNotification(entity: IssueComment) {
|
private async sendIssueCommentNotification(entity: IssueComment) {
|
||||||
let title: string;
|
let title = '';
|
||||||
let image: string;
|
let image = '';
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -48,13 +50,33 @@ export class IssueCommentSubscriber
|
|||||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||||
}`;
|
}`;
|
||||||
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
|
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
|
||||||
} else {
|
} else if (media.mediaType === MediaType.TV) {
|
||||||
const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId });
|
const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||||
|
|
||||||
title = `${tvshow.name}${
|
title = `${tvshow.name}${
|
||||||
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
|
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
|
||||||
}`;
|
}`;
|
||||||
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
|
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
|
||||||
|
} else if (media.mediaType === MediaType.MUSIC) {
|
||||||
|
const settings = getSettings();
|
||||||
|
if (!settings.lidarr[0]) {
|
||||||
|
throw new Error('No Lidarr server configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lidarr = new LidarrAPI({
|
||||||
|
apiKey: settings.lidarr[0].apiKey,
|
||||||
|
url: LidarrAPI.buildUrl(settings.lidarr[0], '/api/v1'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!media.mbId) {
|
||||||
|
throw new Error('MusicBrainz ID is undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
const album = await lidarr.getAlbumByMusicBrainzId(media.mbId);
|
||||||
|
const artist = await lidarr.getArtist({ id: album.artistId });
|
||||||
|
|
||||||
|
title = `${artist.artistName} - ${album.title}`;
|
||||||
|
image = album.images?.[0]?.url ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const [firstComment] = sortBy(issue.comments, 'id');
|
const [firstComment] = sortBy(issue.comments, 'id');
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue';
|
import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
import Issue from '@server/entity/Issue';
|
import Issue from '@server/entity/Issue';
|
||||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import type {
|
import type {
|
||||||
@@ -20,8 +22,8 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async sendIssueNotification(entity: Issue, type: Notification) {
|
private async sendIssueNotification(entity: Issue, type: Notification) {
|
||||||
let title: string;
|
let title = '';
|
||||||
let image: string;
|
let image = '';
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -32,13 +34,33 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
|
|||||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||||
}`;
|
}`;
|
||||||
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
|
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
|
||||||
} else {
|
} else if (entity.media.mediaType === MediaType.TV) {
|
||||||
const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
|
const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
|
||||||
|
|
||||||
title = `${tvshow.name}${
|
title = `${tvshow.name}${
|
||||||
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
|
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
|
||||||
}`;
|
}`;
|
||||||
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
|
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
|
||||||
|
} else if (entity.media.mediaType === MediaType.MUSIC) {
|
||||||
|
const settings = getSettings();
|
||||||
|
if (!settings.lidarr[0]) {
|
||||||
|
throw new Error('No Lidarr server configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lidarr = new LidarrAPI({
|
||||||
|
apiKey: settings.lidarr[0].apiKey,
|
||||||
|
url: LidarrAPI.buildUrl(settings.lidarr[0], '/api/v1'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!entity.media.mbId) {
|
||||||
|
throw new Error('MusicBrainz ID is undefined');
|
||||||
|
}
|
||||||
|
const album = await lidarr.getAlbumByMusicBrainzId(entity.media.mbId);
|
||||||
|
|
||||||
|
const artist = await lidarr.getArtist({ id: album.artistId });
|
||||||
|
|
||||||
|
title = `${artist.artistName} - ${album.title}`;
|
||||||
|
image = album.images?.[0]?.url ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const [firstComment] = sortBy(entity.comments, 'id');
|
const [firstComment] = sortBy(entity.comments, 'id');
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import type {
|
||||||
|
LidarrAlbumDetails,
|
||||||
|
LidarrAlbumResult,
|
||||||
|
LidarrArtistDetails,
|
||||||
|
LidarrArtistResult,
|
||||||
|
} from '@server/api/servarr/lidarr';
|
||||||
import type {
|
import type {
|
||||||
TmdbCollectionResult,
|
TmdbCollectionResult,
|
||||||
TmdbMovieDetails,
|
TmdbMovieDetails,
|
||||||
@@ -38,6 +44,18 @@ export const isCollection = (
|
|||||||
return (collection as TmdbCollectionResult).media_type === 'collection';
|
return (collection as TmdbCollectionResult).media_type === 'collection';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isAlbum = (
|
||||||
|
media: LidarrAlbumResult | LidarrArtistResult
|
||||||
|
): media is LidarrAlbumResult => {
|
||||||
|
return (media as LidarrAlbumResult).album?.albumType !== undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isArtist = (
|
||||||
|
media: LidarrAlbumResult | LidarrArtistResult
|
||||||
|
): media is LidarrArtistResult => {
|
||||||
|
return (media as LidarrArtistResult).artist?.artistType !== undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export const isMovieDetails = (
|
export const isMovieDetails = (
|
||||||
movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails
|
movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails
|
||||||
): movie is TmdbMovieDetails => {
|
): movie is TmdbMovieDetails => {
|
||||||
@@ -49,3 +67,15 @@ export const isTvDetails = (
|
|||||||
): tv is TmdbTvDetails => {
|
): tv is TmdbTvDetails => {
|
||||||
return (tv as TmdbTvDetails).number_of_seasons !== undefined;
|
return (tv as TmdbTvDetails).number_of_seasons !== undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isAlbumDetails = (
|
||||||
|
details: LidarrAlbumDetails | LidarrArtistDetails
|
||||||
|
): details is LidarrAlbumDetails => {
|
||||||
|
return (details as LidarrAlbumDetails).albumType !== undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isArtistDetails = (
|
||||||
|
details: LidarrAlbumDetails | LidarrArtistDetails
|
||||||
|
): details is LidarrArtistDetails => {
|
||||||
|
return (details as LidarrArtistDetails).artistType !== undefined;
|
||||||
|
};
|
||||||
|
|||||||
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,
|
BlacklistResultsResponse,
|
||||||
} from '@server/interfaces/api/blacklistInterfaces';
|
} from '@server/interfaces/api/blacklistInterfaces';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
|
import type { MusicDetails } from '@server/models/Music';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -59,6 +60,12 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
|||||||
return (movie as MovieDetails).title !== undefined;
|
return (movie as MovieDetails).title !== undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isMusic = (
|
||||||
|
media: MovieDetails | TvDetails | MusicDetails
|
||||||
|
): media is MusicDetails => {
|
||||||
|
return (media as MusicDetails).artistId !== undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const Blacklist = () => {
|
const Blacklist = () => {
|
||||||
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
||||||
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
|
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
|
||||||
@@ -277,12 +284,15 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
const { hasPermission } = useUser();
|
const { hasPermission } = useUser();
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
item.mediaType === 'movie'
|
item.mediaType === 'music'
|
||||||
|
? `/api/v1/music/${item.mbId}`
|
||||||
|
: item.mediaType === 'movie'
|
||||||
? `/api/v1/movie/${item.tmdbId}`
|
? `/api/v1/movie/${item.tmdbId}`
|
||||||
: `/api/v1/tv/${item.tmdbId}`;
|
: `/api/v1/tv/${item.tmdbId}`;
|
||||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
|
||||||
inView ? url : null
|
const { data: title, error } = useSWR<
|
||||||
);
|
MovieDetails | TvDetails | MusicDetails
|
||||||
|
>(inView ? url : null);
|
||||||
|
|
||||||
if (!title && !error) {
|
if (!title && !error) {
|
||||||
return (
|
return (
|
||||||
@@ -293,11 +303,15 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
|
const removeFromBlacklist = async (
|
||||||
|
tmdbId?: number,
|
||||||
|
mbId?: string,
|
||||||
|
title?: string
|
||||||
|
) => {
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/v1/blacklist/${tmdbId}`);
|
await axios.delete(`/api/v1/blacklist/${mbId ?? tmdbId}`);
|
||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
<span>
|
<span>
|
||||||
@@ -321,11 +335,24 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
|
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
|
||||||
{title && title.backdropPath && (
|
{title && (
|
||||||
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
type={isMusic(title) ? 'music' : 'tmdb'}
|
||||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
src={
|
||||||
|
isMusic(title)
|
||||||
|
? title.artist.images?.find((img) => img.CoverType === 'Fanart')
|
||||||
|
?.Url ||
|
||||||
|
title.artist.images?.find((img) => img.CoverType === 'Poster')
|
||||||
|
?.Url ||
|
||||||
|
title.images?.find(
|
||||||
|
(img) => img.CoverType.toLowerCase() === 'cover'
|
||||||
|
)?.Url ||
|
||||||
|
''
|
||||||
|
: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${
|
||||||
|
title.backdropPath ?? ''
|
||||||
|
}`
|
||||||
|
}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
fill
|
fill
|
||||||
@@ -343,43 +370,58 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
<div className="relative z-10 flex w-full items-center overflow-hidden pl-4 pr-4 sm:pr-0 xl:w-7/12 2xl:w-2/3">
|
<div className="relative z-10 flex w-full items-center overflow-hidden pl-4 pr-4 sm:pr-0 xl:w-7/12 2xl:w-2/3">
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
item.mediaType === 'movie'
|
item.mediaType === 'music'
|
||||||
|
? `/music/${item.mbId}`
|
||||||
|
: item.mediaType === 'movie'
|
||||||
? `/movie/${item.tmdbId}`
|
? `/movie/${item.tmdbId}`
|
||||||
: `/tv/${item.tmdbId}`
|
: `/tv/${item.tmdbId}`
|
||||||
}
|
}
|
||||||
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
|
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
|
||||||
>
|
>
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
type={title && isMusic(title) ? 'music' : 'tmdb'}
|
||||||
src={
|
src={
|
||||||
title?.posterPath
|
title
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
? isMusic(title)
|
||||||
|
? title.images?.find((image) => image.CoverType === 'Cover')
|
||||||
|
?.Url ?? '/images/seerr_poster_not_found.png'
|
||||||
|
: title.posterPath
|
||||||
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||||
|
: '/images/seerr_poster_not_found.png'
|
||||||
: '/images/seerr_poster_not_found.png'
|
: '/images/seerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
style={{ width: '100%', height: 'auto', objectFit: 'cover' }}
|
style={{ width: '100%', height: 'auto', objectFit: 'cover' }}
|
||||||
width={600}
|
width={600}
|
||||||
height={900}
|
height={title && isMusic(title) ? 600 : 900}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
<div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
||||||
<div className="pt-0.5 text-xs font-medium text-white sm:pt-1">
|
<div className="pt-0.5 text-xs font-medium text-white sm:pt-1">
|
||||||
{title &&
|
{title &&
|
||||||
(isMovie(title)
|
(isMusic(title)
|
||||||
? title.releaseDate
|
? title.releaseDate?.slice(0, 4)
|
||||||
: title.firstAirDate
|
: isMovie(title)
|
||||||
)?.slice(0, 4)}
|
? title.releaseDate?.slice(0, 4)
|
||||||
|
: title.firstAirDate?.slice(0, 4))}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
item.mediaType === 'movie'
|
item.mediaType === 'music'
|
||||||
|
? `/music/${item.mbId}`
|
||||||
|
: item.mediaType === 'movie'
|
||||||
? `/movie/${item.tmdbId}`
|
? `/movie/${item.tmdbId}`
|
||||||
: `/tv/${item.tmdbId}`
|
: `/tv/${item.tmdbId}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl">
|
<span className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl">
|
||||||
{title && (isMovie(title) ? title.title : title.name)}
|
{title &&
|
||||||
|
(isMusic(title)
|
||||||
|
? `${title.artist.artistName} - ${title.title}`
|
||||||
|
: isMovie(title)
|
||||||
|
? title.title
|
||||||
|
: title.name)}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -446,12 +488,18 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
{intl.formatMessage(globalMessages.movie)}
|
{intl.formatMessage(globalMessages.movie)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : item.mediaType === 'tv' ? (
|
||||||
<div className="pointer-events-none z-40 self-start rounded-full border border-purple-600 bg-purple-600 bg-opacity-80 shadow-md">
|
<div className="pointer-events-none z-40 self-start rounded-full border border-purple-600 bg-purple-600 bg-opacity-80 shadow-md">
|
||||||
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
|
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
|
||||||
{intl.formatMessage(globalMessages.tvshow)}
|
{intl.formatMessage(globalMessages.tvshow)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pointer-events-none z-40 self-start rounded-full border border-green-600 bg-green-600 bg-opacity-80 shadow-md">
|
||||||
|
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
|
||||||
|
{intl.formatMessage(globalMessages.music)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -462,7 +510,13 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
removeFromBlacklist(
|
removeFromBlacklist(
|
||||||
item.tmdbId,
|
item.tmdbId,
|
||||||
title && (isMovie(title) ? title.title : title.name)
|
item.mbId,
|
||||||
|
title &&
|
||||||
|
(isMusic(title)
|
||||||
|
? `${title.artist.artistName} - ${title.title}`
|
||||||
|
: isMovie(title)
|
||||||
|
? title.title
|
||||||
|
: title.name)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
confirmText={intl.formatMessage(
|
confirmText={intl.formatMessage(
|
||||||
|
|||||||
@@ -21,13 +21,15 @@ const messages = defineMessages('component.BlacklistBlock', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
interface BlacklistBlockProps {
|
interface BlacklistBlockProps {
|
||||||
tmdbId: number;
|
tmdbId?: number;
|
||||||
|
mbId?: string;
|
||||||
onUpdate?: () => void;
|
onUpdate?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlacklistBlock = ({
|
const BlacklistBlock = ({
|
||||||
tmdbId,
|
tmdbId,
|
||||||
|
mbId,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: BlacklistBlockProps) => {
|
}: BlacklistBlockProps) => {
|
||||||
@@ -35,13 +37,28 @@ const BlacklistBlock = ({
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
const { data } = useSWR<Blacklist>(`/api/v1/blacklist/${tmdbId}`);
|
|
||||||
|
|
||||||
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
|
const { data } = useSWR<Blacklist>(
|
||||||
|
mbId
|
||||||
|
? `/api/v1/blacklist/music/${mbId}`
|
||||||
|
: tmdbId
|
||||||
|
? `/api/v1/blacklist/${tmdbId}`
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeFromBlacklist = async (
|
||||||
|
tmdbId?: number,
|
||||||
|
mbId?: string,
|
||||||
|
title?: string
|
||||||
|
) => {
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.delete('/api/v1/blacklist/' + tmdbId);
|
const url = mbId
|
||||||
|
? `/api/v1/blacklist/music/${mbId}`
|
||||||
|
: `/api/v1/blacklist/${tmdbId}`;
|
||||||
|
|
||||||
|
await axios.delete(url);
|
||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
<span>
|
<span>
|
||||||
@@ -113,7 +130,9 @@ const BlacklistBlock = ({
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
buttonType="danger"
|
buttonType="danger"
|
||||||
onClick={() => removeFromBlacklist(data.tmdbId, data.title)}
|
onClick={() =>
|
||||||
|
removeFromBlacklist(data.tmdbId, data.mbId, data.title)
|
||||||
|
}
|
||||||
disabled={isUpdating}
|
disabled={isUpdating}
|
||||||
>
|
>
|
||||||
<TrashIcon className="icon-sm" />
|
<TrashIcon className="icon-sm" />
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ import globalMessages from '@app/i18n/globalMessages';
|
|||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
|
import type { MusicDetails } from '@server/models/Music';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
interface BlacklistModalProps {
|
interface BlacklistModalProps {
|
||||||
tmdbId: number;
|
tmdbId?: number;
|
||||||
type: 'movie' | 'tv' | 'collection';
|
mbId?: string;
|
||||||
|
type: 'movie' | 'tv' | 'collection' | 'music';
|
||||||
show: boolean;
|
show: boolean;
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
@@ -22,14 +24,24 @@ const messages = defineMessages('component.BlacklistModal', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isMovie = (
|
const isMovie = (
|
||||||
movie: MovieDetails | TvDetails | null
|
media: MovieDetails | TvDetails | MusicDetails | null
|
||||||
): movie is MovieDetails => {
|
): media is MovieDetails => {
|
||||||
if (!movie) return false;
|
if (!media) return false;
|
||||||
return (movie as MovieDetails).title !== undefined;
|
return (
|
||||||
|
(media as MovieDetails).title !== undefined && !('artistName' in media)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMusic = (
|
||||||
|
media: MovieDetails | TvDetails | MusicDetails | null
|
||||||
|
): media is MusicDetails => {
|
||||||
|
if (!media) return false;
|
||||||
|
return (media as MusicDetails).artistId !== undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BlacklistModal = ({
|
const BlacklistModal = ({
|
||||||
tmdbId,
|
tmdbId,
|
||||||
|
mbId,
|
||||||
type,
|
type,
|
||||||
show,
|
show,
|
||||||
onComplete,
|
onComplete,
|
||||||
@@ -37,7 +49,9 @@ const BlacklistModal = ({
|
|||||||
isUpdating,
|
isUpdating,
|
||||||
}: BlacklistModalProps) => {
|
}: BlacklistModalProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [data, setData] = useState<TvDetails | MovieDetails | null>(null);
|
const [data, setData] = useState<
|
||||||
|
MovieDetails | TvDetails | MusicDetails | null
|
||||||
|
>(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,13 +59,36 @@ const BlacklistModal = ({
|
|||||||
if (!show) return;
|
if (!show) return;
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
const response = await axios.get(`/api/v1/${type}/${tmdbId}`);
|
const response = await axios.get(`/api/v1/${type}/${type === 'music' ? mbId : tmdbId}`);
|
||||||
setData(response.data);
|
setData(response.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err);
|
setError(err);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [show, tmdbId, type]);
|
}, [show, tmdbId, mbId, type]);
|
||||||
|
|
||||||
|
const getTitle = () => {
|
||||||
|
if (isMusic(data)) {
|
||||||
|
return `${data.artist.artistName} - ${data.title}`;
|
||||||
|
}
|
||||||
|
return isMovie(data) ? data.title : data?.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMediaType = () => {
|
||||||
|
if (isMusic(data)) {
|
||||||
|
return intl.formatMessage(globalMessages.music);
|
||||||
|
}
|
||||||
|
return isMovie(data)
|
||||||
|
? intl.formatMessage(globalMessages.movie)
|
||||||
|
: intl.formatMessage(globalMessages.tvshow);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBackdrop = () => {
|
||||||
|
if (isMusic(data)) {
|
||||||
|
return data.artist.images?.find((img) => img.CoverType === 'Fanart')?.Url;
|
||||||
|
}
|
||||||
|
return `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
@@ -67,12 +104,10 @@ const BlacklistModal = ({
|
|||||||
<Modal
|
<Modal
|
||||||
loading={!data && !error}
|
loading={!data && !error}
|
||||||
backgroundClickable
|
backgroundClickable
|
||||||
title={`${intl.formatMessage(globalMessages.blacklist)} ${
|
title={`${intl.formatMessage(
|
||||||
isMovie(data)
|
globalMessages.blacklist
|
||||||
? intl.formatMessage(globalMessages.movie)
|
)} ${getMediaType()}`}
|
||||||
: intl.formatMessage(globalMessages.tvshow)
|
subTitle={getTitle()}
|
||||||
}`}
|
|
||||||
subTitle={`${isMovie(data) ? data.title : data?.name}`}
|
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onOk={onComplete}
|
onOk={onComplete}
|
||||||
okText={
|
okText={
|
||||||
@@ -82,7 +117,7 @@ const BlacklistModal = ({
|
|||||||
}
|
}
|
||||||
okButtonType="danger"
|
okButtonType="danger"
|
||||||
okDisabled={isUpdating}
|
okDisabled={isUpdating}
|
||||||
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
|
backdrop={getBackdrop()}
|
||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const imageLoader: ImageLoader = ({ src }) => src;
|
|||||||
|
|
||||||
export type CachedImageProps = ImageProps & {
|
export type CachedImageProps = ImageProps & {
|
||||||
src: string;
|
src: string;
|
||||||
type: 'tmdb' | 'avatar' | 'tvdb';
|
type: 'tmdb' | 'avatar' | 'tvdb' | 'music';
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,6 +35,15 @@ const CachedImage = ({ src, type, ...props }: CachedImageProps) => {
|
|||||||
} else if (type === 'avatar') {
|
} else if (type === 'avatar') {
|
||||||
// jellyfin avatar (if any)
|
// jellyfin avatar (if any)
|
||||||
imageUrl = src;
|
imageUrl = src;
|
||||||
|
} else if (type === 'music') {
|
||||||
|
// Handle CAA, Fanart and Lidarr images
|
||||||
|
imageUrl = /^https?:\/\/coverartarchive\.org\//.test(src)
|
||||||
|
? src.replace(/^https?:\/\/coverartarchive\.org\//, '/caaproxy/')
|
||||||
|
: /^https?:\/\/assets\.fanart\.tv\//.test(src)
|
||||||
|
? src.replace(/^https?:\/\/assets\.fanart\.tv\//, '/fanartproxy/')
|
||||||
|
: currentSettings.cacheImages
|
||||||
|
? src.replace(/^https:\/\/imagecache\.lidarr\.audio\//, '/lidarrproxy/')
|
||||||
|
: src;
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import AddedCard from '@app/components/AddedCard';
|
||||||
|
import GroupCard from '@app/components/GroupCard';
|
||||||
import PersonCard from '@app/components/PersonCard';
|
import PersonCard from '@app/components/PersonCard';
|
||||||
import TitleCard from '@app/components/TitleCard';
|
import TitleCard from '@app/components/TitleCard';
|
||||||
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import useVerticalScroll from '@app/hooks/useVerticalScroll';
|
import useVerticalScroll from '@app/hooks/useVerticalScroll';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
import type {
|
import type {
|
||||||
|
AlbumResult,
|
||||||
|
ArtistResult,
|
||||||
CollectionResult,
|
CollectionResult,
|
||||||
MovieResult,
|
MovieResult,
|
||||||
PersonResult,
|
PersonResult,
|
||||||
@@ -15,7 +18,14 @@ import type {
|
|||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
type ListViewProps = {
|
type ListViewProps = {
|
||||||
items?: (TvResult | MovieResult | PersonResult | CollectionResult)[];
|
items?: (
|
||||||
|
| TvResult
|
||||||
|
| MovieResult
|
||||||
|
| PersonResult
|
||||||
|
| CollectionResult
|
||||||
|
| ArtistResult
|
||||||
|
| AlbumResult
|
||||||
|
)[];
|
||||||
plexItems?: WatchlistItem[];
|
plexItems?: WatchlistItem[];
|
||||||
isEmpty?: boolean;
|
isEmpty?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
@@ -53,9 +63,10 @@ const ListView = ({
|
|||||||
{plexItems?.map((title, index) => {
|
{plexItems?.map((title, index) => {
|
||||||
return (
|
return (
|
||||||
<li key={`${title.ratingKey}-${index}`}>
|
<li key={`${title.ratingKey}-${index}`}>
|
||||||
<TmdbTitleCard
|
<AddedCard
|
||||||
id={title.tmdbId}
|
id={title.tmdbId ?? 0}
|
||||||
tmdbId={title.tmdbId}
|
tmdbId={title.tmdbId ?? 0}
|
||||||
|
mbId={title.mbId}
|
||||||
type={title.mediaType}
|
type={title.mediaType}
|
||||||
isAddedToWatchlist={true}
|
isAddedToWatchlist={true}
|
||||||
canExpand
|
canExpand
|
||||||
@@ -68,8 +79,8 @@ const ListView = ({
|
|||||||
?.filter((title) => {
|
?.filter((title) => {
|
||||||
if (!blacklistVisibility)
|
if (!blacklistVisibility)
|
||||||
return (
|
return (
|
||||||
(title as TvResult | MovieResult).mediaInfo?.status !==
|
(title as TvResult | MovieResult | AlbumResult).mediaInfo
|
||||||
MediaStatus.BLACKLISTED
|
?.status !== MediaStatus.BLACKLISTED
|
||||||
);
|
);
|
||||||
return title;
|
return title;
|
||||||
})
|
})
|
||||||
@@ -143,6 +154,53 @@ const ListView = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case 'album':
|
||||||
|
titleCard = (
|
||||||
|
<TitleCard
|
||||||
|
key={title.id}
|
||||||
|
id={title.id}
|
||||||
|
isAddedToWatchlist={
|
||||||
|
title.mediaInfo?.watchlists?.length ?? 0
|
||||||
|
}
|
||||||
|
image={
|
||||||
|
title.images?.find((image) => image.CoverType === 'Cover')
|
||||||
|
?.Url
|
||||||
|
}
|
||||||
|
status={title.mediaInfo?.status}
|
||||||
|
title={title.title}
|
||||||
|
artist={title.artistname}
|
||||||
|
type={title.type}
|
||||||
|
year={title.releasedate}
|
||||||
|
mediaType={title.mediaType}
|
||||||
|
inProgress={
|
||||||
|
(title.mediaInfo?.downloadStatus ?? []).length > 0
|
||||||
|
}
|
||||||
|
canExpand
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'artist':
|
||||||
|
return title.type === 'Group' ? (
|
||||||
|
<GroupCard
|
||||||
|
key={title.id}
|
||||||
|
groupId={title.id}
|
||||||
|
name={title.artistname}
|
||||||
|
image={
|
||||||
|
title.images.find((image) => image.CoverType === 'Poster')
|
||||||
|
?.Url ?? title.artistimage
|
||||||
|
}
|
||||||
|
canExpand
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PersonCard
|
||||||
|
key={title.id}
|
||||||
|
personId={title.id}
|
||||||
|
name={title.artistname}
|
||||||
|
mediaType="artist"
|
||||||
|
profilePath={title.artistimage}
|
||||||
|
canExpand
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <li key={`${title.id}-${index}`}>{titleCard}</li>;
|
return <li key={`${title.id}-${index}`}>{titleCard}</li>;
|
||||||
|
|||||||
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);
|
return intl.formatMessage(sliderTitles.plexwatchlist);
|
||||||
case DiscoverSliderType.TRENDING:
|
case DiscoverSliderType.TRENDING:
|
||||||
return intl.formatMessage(sliderTitles.trending);
|
return intl.formatMessage(sliderTitles.trending);
|
||||||
|
case DiscoverSliderType.POPULAR_ALBUMS:
|
||||||
|
return intl.formatMessage(sliderTitles.popularalbums);
|
||||||
case DiscoverSliderType.POPULAR_MOVIES:
|
case DiscoverSliderType.POPULAR_MOVIES:
|
||||||
return intl.formatMessage(sliderTitles.popularmovies);
|
return intl.formatMessage(sliderTitles.popularmovies);
|
||||||
case DiscoverSliderType.MOVIE_GENRES:
|
case DiscoverSliderType.MOVIE_GENRES:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import AddedCard from '@app/components/AddedCard';
|
||||||
import Slider from '@app/components/Slider';
|
import Slider from '@app/components/Slider';
|
||||||
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
||||||
@@ -62,10 +62,11 @@ const PlexWatchlistSlider = () => {
|
|||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
items={watchlistItems?.results.map((item) => (
|
items={watchlistItems?.results.map((item) => (
|
||||||
<TmdbTitleCard
|
<AddedCard
|
||||||
id={item.tmdbId}
|
id={item.mediaType === 'music' ? item.mbId : item.tmdbId}
|
||||||
key={`watchlist-slider-item-${item.ratingKey}`}
|
key={`watchlist-slider-item-${item.ratingKey}`}
|
||||||
tmdbId={item.tmdbId}
|
tmdbId={item.tmdbId}
|
||||||
|
mbId={item.mbId}
|
||||||
type={item.mediaType}
|
type={item.mediaType}
|
||||||
isAddedToWatchlist={true}
|
isAddedToWatchlist={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import AddedCard from '@app/components/AddedCard';
|
||||||
import Slider from '@app/components/Slider';
|
import Slider from '@app/components/Slider';
|
||||||
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
|
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
|
||||||
@@ -38,11 +38,12 @@ const RecentlyAddedSlider = () => {
|
|||||||
sliderKey="media"
|
sliderKey="media"
|
||||||
isLoading={!media}
|
isLoading={!media}
|
||||||
items={(media?.results ?? []).map((item) => (
|
items={(media?.results ?? []).map((item) => (
|
||||||
<TmdbTitleCard
|
<AddedCard
|
||||||
key={`media-slider-item-${item.id}`}
|
key={`media-slider-item-${item.id}`}
|
||||||
id={item.id}
|
id={item.id}
|
||||||
tmdbId={item.tmdbId}
|
tmdbId={item.tmdbId}
|
||||||
tvdbId={item.tvdbId}
|
tvdbId={item.tvdbId}
|
||||||
|
mbId={item.mbId}
|
||||||
type={item.mediaType}
|
type={item.mediaType}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const genreColorMap: Record<number, [string, string]> = {
|
|||||||
|
|
||||||
export const sliderTitles = defineMessages('components.Discover', {
|
export const sliderTitles = defineMessages('components.Discover', {
|
||||||
recentrequests: 'Recent Requests',
|
recentrequests: 'Recent Requests',
|
||||||
|
popularalbums: 'Popular Albums',
|
||||||
popularmovies: 'Popular Movies',
|
popularmovies: 'Popular Movies',
|
||||||
populartv: 'Popular Series',
|
populartv: 'Popular Series',
|
||||||
upcomingtv: 'Upcoming Series',
|
upcomingtv: 'Upcoming Series',
|
||||||
|
|||||||
@@ -219,6 +219,16 @@ const Discover = () => {
|
|||||||
case DiscoverSliderType.PLEX_WATCHLIST:
|
case DiscoverSliderType.PLEX_WATCHLIST:
|
||||||
sliderComponent = <PlexWatchlistSlider />;
|
sliderComponent = <PlexWatchlistSlider />;
|
||||||
break;
|
break;
|
||||||
|
case DiscoverSliderType.POPULAR_ALBUMS:
|
||||||
|
sliderComponent = (
|
||||||
|
<MediaSlider
|
||||||
|
sliderKey="popular-albums"
|
||||||
|
title={intl.formatMessage(sliderTitles.popularalbums)}
|
||||||
|
url="/api/v1/discover/music"
|
||||||
|
linkUrl="/discover/music"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
case DiscoverSliderType.TRENDING:
|
case DiscoverSliderType.TRENDING:
|
||||||
sliderComponent = (
|
sliderComponent = (
|
||||||
<MediaSlider
|
<MediaSlider
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { MediaType } from '@server/constants/media';
|
|||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
|
||||||
interface ExternalLinkBlockProps {
|
interface ExternalLinkBlockProps {
|
||||||
mediaType: 'movie' | 'tv';
|
mediaType: 'movie' | 'tv' | 'music';
|
||||||
tmdbId?: number;
|
tmdbId?: number;
|
||||||
tvdbId?: number;
|
tvdbId?: number;
|
||||||
imdbId?: string;
|
imdbId?: string;
|
||||||
|
|||||||
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 { MediaServerType } from '@server/constants/server';
|
||||||
import type Issue from '@server/entity/Issue';
|
import type Issue from '@server/entity/Issue';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
|
import type { MusicDetails } from '@server/models/Music';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
@@ -73,8 +74,19 @@ const messages = defineMessages('components.IssueDetails', {
|
|||||||
commentplaceholder: 'Add a comment…',
|
commentplaceholder: 'Add a comment…',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
const isMovie = (
|
||||||
return (movie as MovieDetails).title !== undefined;
|
media: MovieDetails | TvDetails | MusicDetails
|
||||||
|
): media is MovieDetails => {
|
||||||
|
return (
|
||||||
|
(media as MovieDetails).title !== undefined &&
|
||||||
|
(media as MovieDetails).releaseDate !== undefined
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMusic = (
|
||||||
|
media: MovieDetails | TvDetails | MusicDetails
|
||||||
|
): media is MusicDetails => {
|
||||||
|
return (media as MusicDetails).artist !== undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const IssueDetails = () => {
|
const IssueDetails = () => {
|
||||||
@@ -86,9 +98,13 @@ const IssueDetails = () => {
|
|||||||
const { data: issueData, mutate: revalidateIssue } = useSWR<Issue>(
|
const { data: issueData, mutate: revalidateIssue } = useSWR<Issue>(
|
||||||
`/api/v1/issue/${router.query.issueId}`
|
`/api/v1/issue/${router.query.issueId}`
|
||||||
);
|
);
|
||||||
const { data, error } = useSWR<MovieDetails | TvDetails>(
|
const { data, error } = useSWR<MovieDetails | TvDetails | MusicDetails>(
|
||||||
issueData?.media.tmdbId
|
issueData?.media.tmdbId || issueData?.media.mbId
|
||||||
? `/api/v1/${issueData.media.mediaType}/${issueData.media.tmdbId}`
|
? `/api/v1/${issueData.media.mediaType}/${
|
||||||
|
issueData.media.mediaType === MediaType.MUSIC
|
||||||
|
? issueData.media.mbId
|
||||||
|
: issueData.media.tmdbId
|
||||||
|
}`
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -175,8 +191,17 @@ const IssueDetails = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const title = isMovie(data) ? data.title : data.name;
|
const title = isMusic(data)
|
||||||
const releaseYear = isMovie(data) ? data.releaseDate : data.firstAirDate;
|
? `${data.artist.artistName} - ${data.title}`
|
||||||
|
: isMovie(data)
|
||||||
|
? data.title
|
||||||
|
: data.name;
|
||||||
|
|
||||||
|
const releaseYear = isMusic(data)
|
||||||
|
? data.releaseDate
|
||||||
|
: isMovie(data)
|
||||||
|
? data.releaseDate
|
||||||
|
: data.firstAirDate;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -206,12 +231,23 @@ const IssueDetails = () => {
|
|||||||
{intl.formatMessage(messages.deleteissueconfirm)}
|
{intl.formatMessage(messages.deleteissueconfirm)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</Transition>
|
</Transition>
|
||||||
{data.backdropPath && (
|
{((!isMusic(data) && data.backdropPath) || isMusic(data)) && (
|
||||||
<div className="media-page-bg-image">
|
<div className="media-page-bg-image">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
type={isMusic(data) ? 'music' : 'tmdb'}
|
||||||
alt=""
|
alt=""
|
||||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
src={
|
||||||
|
isMusic(data)
|
||||||
|
? data.artist.images?.find((img) => img.CoverType === 'Fanart')
|
||||||
|
?.Url ||
|
||||||
|
data.artist.images?.find((img) => img.CoverType === 'Poster')
|
||||||
|
?.Url ||
|
||||||
|
data.images?.find(
|
||||||
|
(img) => img.CoverType.toLowerCase() === 'cover'
|
||||||
|
)?.Url ||
|
||||||
|
'/images/overseerr_poster_not_found.png'
|
||||||
|
: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`
|
||||||
|
}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
fill
|
fill
|
||||||
priority
|
priority
|
||||||
@@ -228,9 +264,13 @@ const IssueDetails = () => {
|
|||||||
<div className="media-header">
|
<div className="media-header">
|
||||||
<div className="media-poster">
|
<div className="media-poster">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
type={isMusic(data) ? 'music' : 'tmdb'}
|
||||||
src={
|
src={
|
||||||
data.posterPath
|
isMusic(data)
|
||||||
|
? data.images?.find(
|
||||||
|
(img) => img.CoverType.toLowerCase() === 'cover'
|
||||||
|
)?.Url || '/images/overseerr_poster_not_found.png'
|
||||||
|
: data.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||||
: '/images/seerr_poster_not_found.png'
|
: '/images/seerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
@@ -258,8 +298,18 @@ const IssueDetails = () => {
|
|||||||
<h1>
|
<h1>
|
||||||
<Link
|
<Link
|
||||||
href={`/${
|
href={`/${
|
||||||
issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv'
|
issueData.media.mediaType === MediaType.MOVIE
|
||||||
}/${data.id}`}
|
? 'movie'
|
||||||
|
: issueData.media.mediaType === MediaType.TV
|
||||||
|
? 'tv'
|
||||||
|
: 'music'
|
||||||
|
}/${
|
||||||
|
issueData.media.mediaType === MediaType.MUSIC
|
||||||
|
? isMusic(data)
|
||||||
|
? data.mbId
|
||||||
|
: data.id
|
||||||
|
: data.id
|
||||||
|
}`}
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { IssueStatus } from '@server/constants/issue';
|
|||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
import type Issue from '@server/entity/Issue';
|
import type Issue from '@server/entity/Issue';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
|
import type { MusicDetails } from '@server/models/Music';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
@@ -30,8 +31,19 @@ const messages = defineMessages('components.IssueList.IssueItem', {
|
|||||||
descriptionpreview: 'Issue Description',
|
descriptionpreview: 'Issue Description',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
const isMovie = (
|
||||||
return (movie as MovieDetails).title !== undefined;
|
media: MovieDetails | TvDetails | MusicDetails
|
||||||
|
): media is MovieDetails => {
|
||||||
|
return (
|
||||||
|
(media as MovieDetails).title !== undefined &&
|
||||||
|
(media as MovieDetails).releaseDate !== undefined
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMusic = (
|
||||||
|
media: MovieDetails | TvDetails | MusicDetails
|
||||||
|
): media is MusicDetails => {
|
||||||
|
return (media as MusicDetails).artist !== undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IssueItemProps {
|
interface IssueItemProps {
|
||||||
@@ -45,12 +57,15 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
triggerOnce: true,
|
triggerOnce: true,
|
||||||
});
|
});
|
||||||
const url =
|
const url =
|
||||||
issue.media.mediaType === 'movie'
|
issue.media.mediaType === MediaType.MOVIE
|
||||||
? `/api/v1/movie/${issue.media.tmdbId}`
|
? `/api/v1/movie/${issue.media.tmdbId}`
|
||||||
: `/api/v1/tv/${issue.media.tmdbId}`;
|
: issue.media.mediaType === MediaType.TV
|
||||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
? `/api/v1/tv/${issue.media.tmdbId}`
|
||||||
inView ? url : null
|
: `/api/v1/music/${issue.media.mbId}`;
|
||||||
);
|
|
||||||
|
const { data: title, error } = useSWR<
|
||||||
|
MovieDetails | TvDetails | MusicDetails
|
||||||
|
>(inView ? url : null);
|
||||||
|
|
||||||
if (!title && !error) {
|
if (!title && !error) {
|
||||||
return (
|
return (
|
||||||
@@ -118,11 +133,18 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:flex-row">
|
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:flex-row">
|
||||||
{title.backdropPath && (
|
{((!isMusic(title) && title.backdropPath) || isMusic(title)) && (
|
||||||
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
type={isMusic(title) ? 'music' : 'tmdb'}
|
||||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
src={
|
||||||
|
isMusic(title)
|
||||||
|
? title.artist.images?.find((img) => img.CoverType === 'Fanart')
|
||||||
|
?.Url ?? '/images/overseerr_poster_not_found.png'
|
||||||
|
: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${
|
||||||
|
title.backdropPath ?? ''
|
||||||
|
}`
|
||||||
|
}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
fill
|
fill
|
||||||
@@ -142,14 +164,19 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
href={
|
href={
|
||||||
issue.media.mediaType === MediaType.MOVIE
|
issue.media.mediaType === MediaType.MOVIE
|
||||||
? `/movie/${issue.media.tmdbId}`
|
? `/movie/${issue.media.tmdbId}`
|
||||||
: `/tv/${issue.media.tmdbId}`
|
: issue.media.mediaType === MediaType.TV
|
||||||
|
? `/tv/${issue.media.tmdbId}`
|
||||||
|
: `/music/${issue.media.mbId}`
|
||||||
}
|
}
|
||||||
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
|
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
|
||||||
>
|
>
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
type={isMusic(title) ? 'music' : 'tmdb'}
|
||||||
src={
|
src={
|
||||||
title.posterPath
|
isMusic(title)
|
||||||
|
? title.images?.find((image) => image.CoverType === 'Cover')
|
||||||
|
?.Url ?? '/images/overseerr_poster_not_found.png'
|
||||||
|
: title.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||||
: '/images/seerr_poster_not_found.png'
|
: '/images/seerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
@@ -162,20 +189,28 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
<div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
||||||
<div className="pt-0.5 text-xs text-white sm:pt-1">
|
<div className="pt-0.5 text-xs text-white sm:pt-1">
|
||||||
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice(
|
{isMusic(title)
|
||||||
0,
|
? title.releaseDate?.slice(0, 4)
|
||||||
4
|
: (isMovie(title)
|
||||||
)}
|
? title.releaseDate
|
||||||
|
: title.firstAirDate
|
||||||
|
)?.slice(0, 4)}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
issue.media.mediaType === MediaType.MOVIE
|
issue.media.mediaType === MediaType.MOVIE
|
||||||
? `/movie/${issue.media.tmdbId}`
|
? `/movie/${issue.media.tmdbId}`
|
||||||
: `/tv/${issue.media.tmdbId}`
|
: issue.media.mediaType === MediaType.TV
|
||||||
|
? `/tv/${issue.media.tmdbId}`
|
||||||
|
: `/music/${issue.media.mbId}`
|
||||||
}
|
}
|
||||||
className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl"
|
className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl"
|
||||||
>
|
>
|
||||||
{isMovie(title) ? title.title : title.name}
|
{isMusic(title)
|
||||||
|
? `${title.artist.artistName} - ${title.title}`
|
||||||
|
: isMovie(title)
|
||||||
|
? title.title
|
||||||
|
: title.name}
|
||||||
</Link>
|
</Link>
|
||||||
{description && (
|
{description && (
|
||||||
<div className="mt-1 max-w-full">
|
<div className="mt-1 max-w-full">
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import Modal from '@app/components/Common/Modal';
|
import Modal from '@app/components/Common/Modal';
|
||||||
import { issueOptions } from '@app/components/IssueModal/constants';
|
import {
|
||||||
|
getIssueOptionsForMediaType,
|
||||||
|
issueOptions,
|
||||||
|
} from '@app/components/IssueModal/constants';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
@@ -10,6 +13,7 @@ import { ArrowRightCircleIcon } from '@heroicons/react/24/solid';
|
|||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
import type Issue from '@server/entity/Issue';
|
import type Issue from '@server/entity/Issue';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
|
import type { MusicDetails } from '@server/models/Music';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Formik } from 'formik';
|
import { Field, Formik } from 'formik';
|
||||||
@@ -39,8 +43,16 @@ const messages = defineMessages('components.IssueModal.CreateIssueModal', {
|
|||||||
submitissue: 'Submit Issue',
|
submitissue: 'Submit Issue',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
const isMovie = (
|
||||||
return (movie as MovieDetails).title !== undefined;
|
media: MovieDetails | TvDetails | MusicDetails
|
||||||
|
): media is MovieDetails => {
|
||||||
|
return (media as MovieDetails).title !== undefined && !('artist' in media);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMusic = (
|
||||||
|
media: MovieDetails | TvDetails | MusicDetails
|
||||||
|
): media is MusicDetails => {
|
||||||
|
return 'artist' in media;
|
||||||
};
|
};
|
||||||
|
|
||||||
const classNames = (...classes: string[]) => {
|
const classNames = (...classes: string[]) => {
|
||||||
@@ -48,8 +60,9 @@ const classNames = (...classes: string[]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface CreateIssueModalProps {
|
interface CreateIssueModalProps {
|
||||||
mediaType: 'movie' | 'tv';
|
mediaType: 'movie' | 'tv' | 'music';
|
||||||
tmdbId?: number;
|
tmdbId?: number;
|
||||||
|
mbId?: string;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,16 +70,21 @@ const CreateIssueModal = ({
|
|||||||
onCancel,
|
onCancel,
|
||||||
mediaType,
|
mediaType,
|
||||||
tmdbId,
|
tmdbId,
|
||||||
|
mbId,
|
||||||
}: CreateIssueModalProps) => {
|
}: CreateIssueModalProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const { hasPermission } = useUser();
|
const { hasPermission } = useUser();
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
const { data, error } = useSWR<MovieDetails | TvDetails>(
|
const { data, error } = useSWR<MovieDetails | TvDetails | MusicDetails>(
|
||||||
tmdbId ? `/api/v1/${mediaType}/${tmdbId}` : null
|
mediaType === 'music' && mbId
|
||||||
|
? `/api/v1/music/${mbId}`
|
||||||
|
: tmdbId
|
||||||
|
? `/api/v1/${mediaType}/${tmdbId}`
|
||||||
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!tmdbId) {
|
if (!tmdbId && !mbId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,10 +108,13 @@ const CreateIssueModal = ({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Filter issue options based on media type
|
||||||
|
const availableIssueOptions = getIssueOptionsForMediaType(mediaType);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
selectedIssue: issueOptions[0],
|
selectedIssue: availableIssueOptions[0],
|
||||||
message: '',
|
message: '',
|
||||||
problemSeason: availableSeasons.length === 1 ? availableSeasons[0] : 0,
|
problemSeason: availableSeasons.length === 1 ? availableSeasons[0] : 0,
|
||||||
problemEpisode: 0,
|
problemEpisode: 0,
|
||||||
@@ -115,7 +136,11 @@ const CreateIssueModal = ({
|
|||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
{intl.formatMessage(messages.toastSuccessCreate, {
|
{intl.formatMessage(messages.toastSuccessCreate, {
|
||||||
title: isMovie(data) ? data.title : data.name,
|
title: isMusic(data)
|
||||||
|
? `${data.artist.artistName} - ${data.title}`
|
||||||
|
: isMovie(data)
|
||||||
|
? data.title
|
||||||
|
: data.name,
|
||||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -152,12 +177,28 @@ const CreateIssueModal = ({
|
|||||||
backgroundClickable
|
backgroundClickable
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
title={intl.formatMessage(messages.reportissue)}
|
title={intl.formatMessage(messages.reportissue)}
|
||||||
subTitle={data && isMovie(data) ? data?.title : data?.name}
|
subTitle={
|
||||||
|
data &&
|
||||||
|
(isMusic(data)
|
||||||
|
? `${data.artist.artistName} - ${data.title}`
|
||||||
|
: isMovie(data)
|
||||||
|
? data.title
|
||||||
|
: data.name)
|
||||||
|
}
|
||||||
cancelText={intl.formatMessage(globalMessages.close)}
|
cancelText={intl.formatMessage(globalMessages.close)}
|
||||||
onOk={() => handleSubmit()}
|
onOk={() => handleSubmit()}
|
||||||
okText={intl.formatMessage(messages.submitissue)}
|
okText={intl.formatMessage(messages.submitissue)}
|
||||||
loading={!data && !error}
|
loading={!data && !error}
|
||||||
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
|
backdrop={
|
||||||
|
data
|
||||||
|
? isMusic(data)
|
||||||
|
? data.images?.find((image) => image.CoverType === 'Cover')
|
||||||
|
?.Url ?? '/images/overseerr_poster_not_found.png'
|
||||||
|
: data.backdropPath
|
||||||
|
? `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`
|
||||||
|
: '/images/overseerr_poster_not_found.png'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{mediaType === 'tv' && data && !isMovie(data) && (
|
{mediaType === 'tv' && data && !isMovie(data) && (
|
||||||
<>
|
<>
|
||||||
@@ -211,24 +252,25 @@ const CreateIssueModal = ({
|
|||||||
<option value={0}>
|
<option value={0}>
|
||||||
{intl.formatMessage(messages.allepisodes)}
|
{intl.formatMessage(messages.allepisodes)}
|
||||||
</option>
|
</option>
|
||||||
{[
|
{!isMusic(data) &&
|
||||||
...Array(
|
[
|
||||||
data.seasons.find(
|
...Array(
|
||||||
(season) =>
|
data.seasons.find(
|
||||||
Number(values.problemSeason) ===
|
(season) =>
|
||||||
season.seasonNumber
|
Number(values.problemSeason) ===
|
||||||
)?.episodeCount ?? 0
|
season.seasonNumber
|
||||||
),
|
)?.episodeCount ?? 0
|
||||||
].map((i, index) => (
|
),
|
||||||
<option
|
].map((i, index) => (
|
||||||
value={index + 1}
|
<option
|
||||||
key={`problem-episode-${index + 1}`}
|
value={index + 1}
|
||||||
>
|
key={`problem-episode-${index + 1}`}
|
||||||
{intl.formatMessage(messages.episode, {
|
>
|
||||||
episodeNumber: index + 1,
|
{intl.formatMessage(messages.episode, {
|
||||||
})}
|
episodeNumber: index + 1,
|
||||||
</option>
|
})}
|
||||||
))}
|
</option>
|
||||||
|
))}
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,7 +287,7 @@ const CreateIssueModal = ({
|
|||||||
Select an Issue
|
Select an Issue
|
||||||
</RadioGroup.Label>
|
</RadioGroup.Label>
|
||||||
<div className="-space-y-px overflow-hidden rounded-md bg-gray-800 bg-opacity-30">
|
<div className="-space-y-px overflow-hidden rounded-md bg-gray-800 bg-opacity-30">
|
||||||
{issueOptions.map((setting, index) => (
|
{availableIssueOptions.map((setting, index) => (
|
||||||
<RadioGroup.Option
|
<RadioGroup.Option
|
||||||
key={`issue-type-${setting.issueType}`}
|
key={`issue-type-${setting.issueType}`}
|
||||||
value={setting}
|
value={setting}
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ const messages = defineMessages('components.IssueModal', {
|
|||||||
issueAudio: 'Audio',
|
issueAudio: 'Audio',
|
||||||
issueVideo: 'Video',
|
issueVideo: 'Video',
|
||||||
issueSubtitles: 'Subtitle',
|
issueSubtitles: 'Subtitle',
|
||||||
|
issueLyrics: 'Lyrics',
|
||||||
issueOther: 'Other',
|
issueOther: 'Other',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface IssueOption {
|
interface IssueOption {
|
||||||
name: MessageDescriptor;
|
name: MessageDescriptor;
|
||||||
issueType: IssueType;
|
issueType: IssueType;
|
||||||
mediaType?: 'movie' | 'tv';
|
mediaType?: 'movie' | 'tv' | 'music';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const issueOptions: IssueOption[] = [
|
export const issueOptions: IssueOption[] = [
|
||||||
@@ -28,8 +29,31 @@ export const issueOptions: IssueOption[] = [
|
|||||||
name: messages.issueSubtitles,
|
name: messages.issueSubtitles,
|
||||||
issueType: IssueType.SUBTITLES,
|
issueType: IssueType.SUBTITLES,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: messages.issueLyrics,
|
||||||
|
issueType: IssueType.LYRICS,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: messages.issueOther,
|
name: messages.issueOther,
|
||||||
issueType: IssueType.OTHER,
|
issueType: IssueType.OTHER,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const getIssueOptionsForMediaType = (
|
||||||
|
mediaType: 'movie' | 'tv' | 'music'
|
||||||
|
): IssueOption[] => {
|
||||||
|
let options = [...issueOptions];
|
||||||
|
|
||||||
|
if (mediaType === 'movie' || mediaType === 'tv') {
|
||||||
|
options = options.filter((option) => option.issueType !== IssueType.LYRICS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaType === 'music') {
|
||||||
|
options = options.filter(
|
||||||
|
(option) =>
|
||||||
|
![IssueType.VIDEO, IssueType.SUBTITLES].includes(option.issueType)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ import { Transition } from '@headlessui/react';
|
|||||||
interface IssueModalProps {
|
interface IssueModalProps {
|
||||||
show?: boolean;
|
show?: boolean;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
mediaType: 'movie' | 'tv';
|
mediaType: 'movie' | 'tv' | 'music';
|
||||||
tmdbId: number;
|
tmdbId?: number;
|
||||||
|
mbId?: string;
|
||||||
issueId?: never;
|
issueId?: never;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
|
const IssueModal = ({
|
||||||
|
show,
|
||||||
|
mediaType,
|
||||||
|
onCancel,
|
||||||
|
tmdbId,
|
||||||
|
mbId,
|
||||||
|
}: IssueModalProps) => (
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
enter="transition-opacity duration-300"
|
enter="transition-opacity duration-300"
|
||||||
@@ -24,6 +31,7 @@ const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
|
|||||||
mediaType={mediaType}
|
mediaType={mediaType}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
tmdbId={tmdbId}
|
tmdbId={tmdbId}
|
||||||
|
mbId={mbId}
|
||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
EyeSlashIcon,
|
EyeSlashIcon,
|
||||||
FilmIcon,
|
FilmIcon,
|
||||||
|
MusicalNoteIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TvIcon,
|
TvIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
ExclamationTriangleIcon as FilledExclamationTriangleIcon,
|
ExclamationTriangleIcon as FilledExclamationTriangleIcon,
|
||||||
EyeSlashIcon as FilledEyeSlashIcon,
|
EyeSlashIcon as FilledEyeSlashIcon,
|
||||||
FilmIcon as FilledFilmIcon,
|
FilmIcon as FilledFilmIcon,
|
||||||
|
MusicalNoteIcon as FilledMusicalNoteIcon,
|
||||||
SparklesIcon as FilledSparklesIcon,
|
SparklesIcon as FilledSparklesIcon,
|
||||||
TvIcon as FilledTvIcon,
|
TvIcon as FilledTvIcon,
|
||||||
UsersIcon as FilledUsersIcon,
|
UsersIcon as FilledUsersIcon,
|
||||||
@@ -92,6 +94,13 @@ const MobileMenu = ({
|
|||||||
svgIconSelected: <FilledTvIcon className="h-6 w-6" />,
|
svgIconSelected: <FilledTvIcon className="h-6 w-6" />,
|
||||||
activeRegExp: /^\/discover\/tv$/,
|
activeRegExp: /^\/discover\/tv$/,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/discover/music',
|
||||||
|
content: intl.formatMessage(menuMessages.browsemusic),
|
||||||
|
svgIcon: <MusicalNoteIcon className="h-6 w-6" />,
|
||||||
|
svgIconSelected: <FilledMusicalNoteIcon className="h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/discover\/music$/,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/requests',
|
href: '/requests',
|
||||||
content: intl.formatMessage(menuMessages.requests),
|
content: intl.formatMessage(menuMessages.requests),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { MagnifyingGlassIcon } from '@heroicons/react/24/solid';
|
|||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages('components.Layout.SearchInput', {
|
const messages = defineMessages('components.Layout.SearchInput', {
|
||||||
searchPlaceholder: 'Search Movies & TV',
|
searchPlaceholder: 'Search Movies, TV & Music',
|
||||||
});
|
});
|
||||||
|
|
||||||
const SearchInput = () => {
|
const SearchInput = () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
EyeSlashIcon,
|
EyeSlashIcon,
|
||||||
FilmIcon,
|
FilmIcon,
|
||||||
|
MusicalNoteIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TvIcon,
|
TvIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
@@ -26,11 +27,13 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', {
|
|||||||
dashboard: 'Discover',
|
dashboard: 'Discover',
|
||||||
browsemovies: 'Movies',
|
browsemovies: 'Movies',
|
||||||
browsetv: 'Series',
|
browsetv: 'Series',
|
||||||
|
browsemusic: 'Music',
|
||||||
requests: 'Requests',
|
requests: 'Requests',
|
||||||
blacklist: 'Blacklist',
|
blacklist: 'Blacklist',
|
||||||
issues: 'Issues',
|
issues: 'Issues',
|
||||||
users: 'Users',
|
users: 'Users',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
|
music: 'Music',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -72,6 +75,12 @@ const SidebarLinks: SidebarLinkProps[] = [
|
|||||||
svgIcon: <TvIcon className="mr-3 h-6 w-6" />,
|
svgIcon: <TvIcon className="mr-3 h-6 w-6" />,
|
||||||
activeRegExp: /^\/discover\/tv$/,
|
activeRegExp: /^\/discover\/tv$/,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/discover/music',
|
||||||
|
messagesKey: 'music',
|
||||||
|
svgIcon: <MusicalNoteIcon className="mr-3 h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/discover\/music$/,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/requests',
|
href: '/requests',
|
||||||
messagesKey: 'requests',
|
messagesKey: 'requests',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const messages = defineMessages(
|
|||||||
{
|
{
|
||||||
movierequests: 'Movie Requests',
|
movierequests: 'Movie Requests',
|
||||||
seriesrequests: 'Series Requests',
|
seriesrequests: 'Series Requests',
|
||||||
|
musicrequests: 'Music Requests',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ const MiniQuotaDisplay = ({ userId }: MiniQuotaDisplayProps) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data && !error) {
|
if (data === undefined && !error) {
|
||||||
return <SmallLoadingSpinner />;
|
return <SmallLoadingSpinner />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +89,34 @@ const MiniQuotaDisplay = ({ userId }: MiniQuotaDisplayProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex basis-1/2 flex-col space-y-2">
|
||||||
|
<div className="text-sm text-gray-200">
|
||||||
|
{intl.formatMessage(messages.musicrequests)}
|
||||||
|
</div>
|
||||||
|
<div className="flex h-full items-center space-x-2 text-gray-200">
|
||||||
|
{data?.music.limit ?? 0 > 0 ? (
|
||||||
|
<>
|
||||||
|
<ProgressCircle
|
||||||
|
className="h-8 w-8"
|
||||||
|
progress={Math.round(
|
||||||
|
((data?.music.remaining ?? 0) /
|
||||||
|
(data?.music.limit ?? 1)) *
|
||||||
|
100
|
||||||
|
)}
|
||||||
|
useHeatLevel
|
||||||
|
/>
|
||||||
|
<span className="text-lg font-bold">
|
||||||
|
{data?.music.remaining} / {data?.music.limit}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Infinity className="w-7" />
|
||||||
|
<span className="font-bold">Unlimited</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -25,8 +25,13 @@ import {
|
|||||||
} from '@server/constants/media';
|
} from '@server/constants/media';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfaces';
|
import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfaces';
|
||||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
import type {
|
||||||
|
LidarrSettings,
|
||||||
|
RadarrSettings,
|
||||||
|
SonarrSettings,
|
||||||
|
} from '@server/lib/settings';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
|
import type { MusicDetails } from '@server/models/Music';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -65,8 +70,19 @@ const messages = defineMessages('components.ManageSlideOver', {
|
|||||||
tvshow: 'series',
|
tvshow: 'series',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
const isMovie = (
|
||||||
return (movie as MovieDetails).title !== undefined;
|
media: MovieDetails | TvDetails | MusicDetails
|
||||||
|
): media is MovieDetails => {
|
||||||
|
return (
|
||||||
|
(media as MovieDetails).title !== undefined &&
|
||||||
|
(media as MusicDetails).artist === undefined
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMusic = (
|
||||||
|
media: MovieDetails | TvDetails | MusicDetails
|
||||||
|
): media is MusicDetails => {
|
||||||
|
return (media as MusicDetails).artist !== undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ManageSlideOverProps {
|
interface ManageSlideOverProps {
|
||||||
@@ -86,13 +102,21 @@ interface ManageSlideOverTvProps extends ManageSlideOverProps {
|
|||||||
data: TvDetails;
|
data: TvDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ManageSlideOverMusicProps extends ManageSlideOverProps {
|
||||||
|
mediaType: 'music';
|
||||||
|
data: MusicDetails;
|
||||||
|
}
|
||||||
|
|
||||||
const ManageSlideOver = ({
|
const ManageSlideOver = ({
|
||||||
show,
|
show,
|
||||||
mediaType,
|
mediaType,
|
||||||
onClose,
|
onClose,
|
||||||
data,
|
data,
|
||||||
revalidate,
|
revalidate,
|
||||||
}: ManageSlideOverMovieProps | ManageSlideOverTvProps) => {
|
}:
|
||||||
|
| ManageSlideOverMovieProps
|
||||||
|
| ManageSlideOverTvProps
|
||||||
|
| ManageSlideOverMusicProps) => {
|
||||||
const { user: currentUser, hasPermission } = useUser();
|
const { user: currentUser, hasPermission } = useUser();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
@@ -109,6 +133,9 @@ const ManageSlideOver = ({
|
|||||||
const { data: sonarrData } = useSWR<SonarrSettings[]>(
|
const { data: sonarrData } = useSWR<SonarrSettings[]>(
|
||||||
hasPermission(Permission.ADMIN) ? '/api/v1/settings/sonarr' : null
|
hasPermission(Permission.ADMIN) ? '/api/v1/settings/sonarr' : null
|
||||||
);
|
);
|
||||||
|
const { data: lidarrData } = useSWR<LidarrSettings[]>(
|
||||||
|
hasPermission(Permission.ADMIN) ? '/api/v1/settings/lidarr' : null
|
||||||
|
);
|
||||||
|
|
||||||
const deleteMedia = async () => {
|
const deleteMedia = async () => {
|
||||||
if (data.mediaInfo) {
|
if (data.mediaInfo) {
|
||||||
@@ -138,6 +165,13 @@ const ManageSlideOver = ({
|
|||||||
radarr.isDefault && radarr.id === data.mediaInfo?.serviceId
|
radarr.isDefault && radarr.id === data.mediaInfo?.serviceId
|
||||||
) !== undefined
|
) !== undefined
|
||||||
);
|
);
|
||||||
|
} else if (data.mediaInfo.mediaType === MediaType.MUSIC) {
|
||||||
|
return (
|
||||||
|
lidarrData?.find(
|
||||||
|
(lidarr) =>
|
||||||
|
lidarr.isDefault && lidarr.id === data.mediaInfo?.serviceId
|
||||||
|
) !== undefined
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
sonarrData?.find(
|
sonarrData?.find(
|
||||||
@@ -215,11 +249,21 @@ const ManageSlideOver = ({
|
|||||||
show={show}
|
show={show}
|
||||||
title={intl.formatMessage(messages.manageModalTitle, {
|
title={intl.formatMessage(messages.manageModalTitle, {
|
||||||
mediaType: intl.formatMessage(
|
mediaType: intl.formatMessage(
|
||||||
mediaType === 'movie' ? globalMessages.movie : globalMessages.tvshow
|
mediaType === 'movie'
|
||||||
|
? globalMessages.movie
|
||||||
|
: mediaType === 'music'
|
||||||
|
? globalMessages.album
|
||||||
|
: globalMessages.tvshow
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
onClose={() => onClose()}
|
onClose={() => onClose()}
|
||||||
subText={isMovie(data) ? data.title : data.name}
|
subText={
|
||||||
|
isMovie(data)
|
||||||
|
? data.title
|
||||||
|
: isMusic(data)
|
||||||
|
? `${data.artist} - ${data.title}`
|
||||||
|
: data.name
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
|
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
|
||||||
@@ -429,7 +473,12 @@ const ManageSlideOver = ({
|
|||||||
<ServerIcon />
|
<ServerIcon />
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(messages.openarr, {
|
{intl.formatMessage(messages.openarr, {
|
||||||
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
arr:
|
||||||
|
mediaType === 'movie'
|
||||||
|
? 'Radarr'
|
||||||
|
: mediaType === 'music'
|
||||||
|
? 'Lidarr'
|
||||||
|
: 'Sonarr',
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -450,7 +499,12 @@ const ManageSlideOver = ({
|
|||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(messages.removearr, {
|
{intl.formatMessage(messages.removearr, {
|
||||||
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
arr:
|
||||||
|
mediaType === 'movie'
|
||||||
|
? 'Radarr'
|
||||||
|
: mediaType === 'music'
|
||||||
|
? 'Lidarr'
|
||||||
|
: 'Sonarr',
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</ConfirmButton>
|
</ConfirmButton>
|
||||||
@@ -650,9 +704,9 @@ const ManageSlideOver = ({
|
|||||||
<CheckCircleIcon />
|
<CheckCircleIcon />
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
mediaType === 'movie'
|
mediaType === 'tv'
|
||||||
? messages.markavailable
|
? messages.markallseasonsavailable
|
||||||
: messages.markallseasonsavailable
|
: messages.markavailable
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import GroupCard from '@app/components/GroupCard';
|
||||||
import ShowMoreCard from '@app/components/MediaSlider/ShowMoreCard';
|
import ShowMoreCard from '@app/components/MediaSlider/ShowMoreCard';
|
||||||
import PersonCard from '@app/components/PersonCard';
|
import PersonCard from '@app/components/PersonCard';
|
||||||
import Slider from '@app/components/Slider';
|
import Slider from '@app/components/Slider';
|
||||||
@@ -8,6 +9,8 @@ import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
|||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
import type {
|
import type {
|
||||||
|
AlbumResult,
|
||||||
|
ArtistResult,
|
||||||
MovieResult,
|
MovieResult,
|
||||||
PersonResult,
|
PersonResult,
|
||||||
TvResult,
|
TvResult,
|
||||||
@@ -20,7 +23,13 @@ interface MixedResult {
|
|||||||
page: number;
|
page: number;
|
||||||
totalResults: number;
|
totalResults: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
results: (TvResult | MovieResult | PersonResult)[];
|
results: (
|
||||||
|
| MovieResult
|
||||||
|
| TvResult
|
||||||
|
| PersonResult
|
||||||
|
| AlbumResult
|
||||||
|
| ArtistResult
|
||||||
|
)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MediaSliderProps {
|
interface MediaSliderProps {
|
||||||
@@ -62,7 +71,7 @@ const MediaSlider = ({
|
|||||||
|
|
||||||
let titles = (data ?? []).reduce(
|
let titles = (data ?? []).reduce(
|
||||||
(a, v) => [...a, ...v.results],
|
(a, v) => [...a, ...v.results],
|
||||||
[] as (MovieResult | TvResult | PersonResult)[]
|
[] as (MovieResult | TvResult | PersonResult | AlbumResult | ArtistResult)[]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (settings.currentSettings.hideAvailable) {
|
if (settings.currentSettings.hideAvailable) {
|
||||||
@@ -112,7 +121,7 @@ const MediaSlider = ({
|
|||||||
.filter((title) => {
|
.filter((title) => {
|
||||||
if (!blacklistVisibility)
|
if (!blacklistVisibility)
|
||||||
return (
|
return (
|
||||||
(title as TvResult | MovieResult).mediaInfo?.status !==
|
(title as TvResult | MovieResult | AlbumResult).mediaInfo?.status !==
|
||||||
MediaStatus.BLACKLISTED
|
MediaStatus.BLACKLISTED
|
||||||
);
|
);
|
||||||
return title;
|
return title;
|
||||||
@@ -159,6 +168,41 @@ const MediaSlider = ({
|
|||||||
profilePath={title.profilePath}
|
profilePath={title.profilePath}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case 'album':
|
||||||
|
return (
|
||||||
|
<TitleCard
|
||||||
|
key={title.id}
|
||||||
|
id={title.id}
|
||||||
|
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
||||||
|
image={
|
||||||
|
title.images?.find((image) => image.CoverType === 'Cover')?.Url
|
||||||
|
}
|
||||||
|
status={title.mediaInfo?.status}
|
||||||
|
title={title.title}
|
||||||
|
year={title.releasedate}
|
||||||
|
mediaType={title.mediaType}
|
||||||
|
artist={title.artistname}
|
||||||
|
type={title.type}
|
||||||
|
inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'artist':
|
||||||
|
return title.type === 'Group' ? (
|
||||||
|
<GroupCard
|
||||||
|
key={title.id}
|
||||||
|
groupId={title.id}
|
||||||
|
name={title.artistname}
|
||||||
|
image={title.artistimage}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PersonCard
|
||||||
|
key={title.id}
|
||||||
|
personId={title.id}
|
||||||
|
name={title.artistname}
|
||||||
|
mediaType="artist"
|
||||||
|
profilePath={title.artistimage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,7 +213,9 @@ const MediaSlider = ({
|
|||||||
posters={titles
|
posters={titles
|
||||||
.slice(20, 24)
|
.slice(20, 24)
|
||||||
.map((title) =>
|
.map((title) =>
|
||||||
title.mediaType !== 'person' ? title.posterPath : undefined
|
title.mediaType !== 'person'
|
||||||
|
? (title as MovieResult | TvResult).posterPath
|
||||||
|
: undefined
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1060,26 +1060,14 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!!streamingProviders.length && (
|
{!!streamingProviders.length && (
|
||||||
<div className="media-fact flex-col gap-1">
|
<div className="media-fact">
|
||||||
<span>{intl.formatMessage(messages.streamingproviders)}</span>
|
<span>{intl.formatMessage(messages.streamingproviders)}</span>
|
||||||
<span className="media-fact-value flex flex-row flex-wrap gap-5">
|
<span className="media-fact-value">
|
||||||
{streamingProviders.map((p) => {
|
{streamingProviders.map((p) => {
|
||||||
return (
|
return (
|
||||||
<Tooltip content={p.name}>
|
<span className="block" key={`provider-${p.id}`}>
|
||||||
<span
|
{p.name}
|
||||||
className="opacity-50 transition duration-300 hover:opacity-100"
|
</span>
|
||||||
key={`provider-${p.id}`}
|
|
||||||
>
|
|
||||||
<CachedImage
|
|
||||||
type="tmdb"
|
|
||||||
src={'https://image.tmdb.org/t/p/w45/' + p.logoPath}
|
|
||||||
alt={p.name}
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
className="rounded-md"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
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',
|
requestTv: 'Request Series',
|
||||||
requestTvDescription:
|
requestTvDescription:
|
||||||
'Grant permission to submit requests for non-4K series.',
|
'Grant permission to submit requests for non-4K series.',
|
||||||
|
requestMusic: 'Request Music',
|
||||||
|
requestMusicDescription:
|
||||||
|
'Grant permission to submit requests for music albums.',
|
||||||
autoapprove: 'Auto-Approve',
|
autoapprove: 'Auto-Approve',
|
||||||
autoapproveDescription:
|
autoapproveDescription:
|
||||||
'Grant automatic approval for all non-4K media requests.',
|
'Grant automatic approval for all non-4K media requests.',
|
||||||
@@ -34,6 +37,9 @@ export const messages = defineMessages('components.PermissionEdit', {
|
|||||||
autoapproveSeries: 'Auto-Approve Series',
|
autoapproveSeries: 'Auto-Approve Series',
|
||||||
autoapproveSeriesDescription:
|
autoapproveSeriesDescription:
|
||||||
'Grant automatic approval for non-4K series requests.',
|
'Grant automatic approval for non-4K series requests.',
|
||||||
|
autoapproveMusic: 'Auto-Approve Music',
|
||||||
|
autoapproveMusicDescription:
|
||||||
|
'Grant automatic approval for music album requests.',
|
||||||
autoapprove4k: 'Auto-Approve 4K',
|
autoapprove4k: 'Auto-Approve 4K',
|
||||||
autoapprove4kDescription:
|
autoapprove4kDescription:
|
||||||
'Grant automatic approval for all 4K media requests.',
|
'Grant automatic approval for all 4K media requests.',
|
||||||
@@ -62,6 +68,9 @@ export const messages = defineMessages('components.PermissionEdit', {
|
|||||||
autorequestSeries: 'Auto-Request Series',
|
autorequestSeries: 'Auto-Request Series',
|
||||||
autorequestSeriesDescription:
|
autorequestSeriesDescription:
|
||||||
'Grant permission to automatically submit requests for non-4K series via Plex Watchlist.',
|
'Grant permission to automatically submit requests for non-4K series via Plex Watchlist.',
|
||||||
|
autorequestMusic: 'Auto-Request Music',
|
||||||
|
autorequestMusicDescription:
|
||||||
|
'Grant permission to automatically submit requests for music via Plex Watchlist.',
|
||||||
viewrequests: 'View Requests',
|
viewrequests: 'View Requests',
|
||||||
viewrequestsDescription:
|
viewrequestsDescription:
|
||||||
'Grant permission to view media requests submitted by other users.',
|
'Grant permission to view media requests submitted by other users.',
|
||||||
@@ -182,6 +191,12 @@ export const PermissionEdit = ({
|
|||||||
description: intl.formatMessage(messages.requestTvDescription),
|
description: intl.formatMessage(messages.requestTvDescription),
|
||||||
permission: Permission.REQUEST_TV,
|
permission: Permission.REQUEST_TV,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'request-music',
|
||||||
|
name: intl.formatMessage(messages.requestMusic),
|
||||||
|
description: intl.formatMessage(messages.requestMusicDescription),
|
||||||
|
permission: Permission.REQUEST_MUSIC,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -219,6 +234,18 @@ export const PermissionEdit = ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'autoapprovemusic',
|
||||||
|
name: intl.formatMessage(messages.autoapproveMusic),
|
||||||
|
description: intl.formatMessage(messages.autoapproveMusicDescription),
|
||||||
|
permission: Permission.AUTO_APPROVE_MUSIC,
|
||||||
|
requires: [
|
||||||
|
{
|
||||||
|
permissions: [Permission.REQUEST, Permission.REQUEST_MUSIC],
|
||||||
|
type: 'or',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -256,6 +283,18 @@ export const PermissionEdit = ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'autorequestmusic',
|
||||||
|
name: intl.formatMessage(messages.autorequestMusic),
|
||||||
|
description: intl.formatMessage(messages.autorequestMusicDescription),
|
||||||
|
permission: Permission.AUTO_REQUEST_MUSIC,
|
||||||
|
requires: [
|
||||||
|
{
|
||||||
|
permissions: [Permission.REQUEST, Permission.REQUEST_MUSIC],
|
||||||
|
type: 'or',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import Link from 'next/link';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface PersonCardProps {
|
interface PersonCardProps {
|
||||||
personId: number;
|
personId: number | string;
|
||||||
name: string;
|
name: string;
|
||||||
subName?: string;
|
subName?: string;
|
||||||
profilePath?: string;
|
profilePath?: string;
|
||||||
canExpand?: boolean;
|
canExpand?: boolean;
|
||||||
|
mediaType?: 'person' | 'artist';
|
||||||
}
|
}
|
||||||
|
|
||||||
const PersonCard = ({
|
const PersonCard = ({
|
||||||
@@ -17,6 +18,7 @@ const PersonCard = ({
|
|||||||
subName,
|
subName,
|
||||||
profilePath,
|
profilePath,
|
||||||
canExpand = false,
|
canExpand = false,
|
||||||
|
mediaType = 'person',
|
||||||
}: PersonCardProps) => {
|
}: PersonCardProps) => {
|
||||||
const [isHovered, setHovered] = useState(false);
|
const [isHovered, setHovered] = useState(false);
|
||||||
|
|
||||||
@@ -51,8 +53,12 @@ const PersonCard = ({
|
|||||||
{profilePath ? (
|
{profilePath ? (
|
||||||
<div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700">
|
<div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
type={mediaType === 'person' ? 'tmdb' : 'music'}
|
||||||
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
|
src={
|
||||||
|
mediaType === 'person'
|
||||||
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`
|
||||||
|
: profilePath
|
||||||
|
}
|
||||||
alt=""
|
alt=""
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user