Added separate music section in discover

pull/3800/merge^2
Anatole Sot 5 months ago
parent d47e70870a
commit 8941fef04d

@ -455,73 +455,144 @@ class MusicBrainz extends BaseNodeBrainz {
}
};
public getArtist = (artistId: string): mbArtist => {
public getArtist = (artistId: string): Promise<mbArtist> => {
try {
const rawData = this.artist(artistId, {
inc: 'tags+recordings+releases+release-groups+works',
return new Promise<mbArtist>((resolve, reject) => {
this.artist(
artistId,
{
inc: 'tags+recordings+releases+release-groups+works',
},
(error, data) => {
if (error) {
reject(error);
} else {
const results = convertArtist(data as Artist);
resolve(results);
}
}
);
});
const artist: mbArtist = convertArtist(rawData);
return artist;
} catch (e) {
throw new Error(
`[MusicBrainz] Failed to fetch artist details: ${e.message}`
);
logger.error('Failed to get artist', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbArtist>((resolve) => resolve({} as mbArtist));
}
};
public getRecording = (recordingId: string): mbRecording => {
public getRecording = (recordingId: string): Promise<mbRecording> => {
try {
const rawData = this.recording(recordingId, {
inc: 'tags+artists+releases',
return new Promise<mbRecording>((resolve, reject) => {
this.recording(
recordingId,
{
inc: 'tags+artists+releases',
},
(error, data) => {
if (error) {
reject(error);
} else {
const results = convertRecording(data as Recording);
resolve(results);
}
}
);
});
const recording: mbRecording = convertRecording(rawData);
return recording;
} catch (e) {
throw new Error(
`[MusicBrainz] Failed to fetch recording details: ${e.message}`
);
logger.error('Failed to get recording', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbRecording>((resolve) => resolve({} as mbRecording));
}
};
public getReleaseGroup(releaseGroupId: string): mbReleaseGroup {
public getReleaseGroup = (
releaseGroupId: string
): Promise<mbReleaseGroup> => {
try {
const rawData = this.releaseGroup(releaseGroupId, {
inc: 'tags+artists+releases',
return new Promise<mbReleaseGroup>((resolve, reject) => {
this.releaseGroup(
releaseGroupId,
{
inc: 'tags+artists+releases',
},
(error, data) => {
if (error) {
reject(error);
} else {
const results = convertReleaseGroup(data as Group);
resolve(results);
}
}
);
});
const releaseGroup: mbReleaseGroup = convertReleaseGroup(rawData);
return releaseGroup;
} catch (e) {
throw new Error(
`[MusicBrainz] Failed to fetch release group details: ${e.message}`
logger.error('Failed to get release-group', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbReleaseGroup>((resolve) =>
resolve({} as mbReleaseGroup)
);
}
}
};
public getRelease(releaseId: string): mbRelease {
public getRelease = (releaseId: string): Promise<mbRelease> => {
try {
const rawData = this.release(releaseId, {
inc: 'tags+artists+recordings',
return new Promise<mbRelease>((resolve, reject) => {
this.release(
releaseId,
{
inc: 'tags+artists+recordings',
},
(error, data) => {
if (error) {
reject(error);
} else {
const results = convertRelease(data as Release);
resolve(results);
}
}
);
});
const release: mbRelease = convertRelease(rawData);
return release;
} catch (e) {
throw new Error(
`[MusicBrainz] Failed to fetch release details: ${e.message}`
);
logger.error('Failed to get release', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbRelease>((resolve) => resolve({} as mbRelease));
}
}
};
public getWork(workId: string): mbWork {
public getWork = (workId: string): Promise<mbWork> => {
try {
const rawData = this.work(workId, { inc: 'tags+artist-rels' });
const work: mbWork = convertWork(rawData);
return work;
return new Promise<mbWork>((resolve, reject) => {
this.work(
workId,
{
inc: 'tags+artist-rels',
},
(error, data) => {
if (error) {
reject(error);
} else {
const results = convertWork(data as Work);
resolve(results);
}
}
);
});
} catch (e) {
throw new Error(
`[MusicBrainz] Failed to fetch work details: ${e.message}`
);
logger.error('Failed to get work', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbWork>((resolve) => resolve({} as mbWork));
}
}
};
}
export default MusicBrainz;

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

@ -110,10 +110,14 @@ interface MetadataResponse {
MediaContainer: {
Metadata: {
ratingKey: string;
type: 'movie' | 'show';
type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album';
title: string;
Guid: {
id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`;
id:
| `imdb://tt${number}`
| `tmdb://${number}`
| `tvdb://${number}`
| `mbid://${string}`;
}[];
}[];
};
@ -121,9 +125,10 @@ interface MetadataResponse {
export interface PlexWatchlistItem {
ratingKey: string;
tmdbId: number;
tmdbId?: number;
tvdbId?: number;
type: 'movie' | 'show' | 'music';
musicBrainzId?: string;
type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album';
title: string;
}
@ -299,6 +304,9 @@ class PlexTvAPI extends ExternalAPI {
const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb')
);
const musicBrainzString = metadata.Guid.find((guid) =>
guid.id.startsWith('mbid')
);
return {
ratingKey: metadata.ratingKey,
@ -308,6 +316,9 @@ class PlexTvAPI extends ExternalAPI {
tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1])
: undefined,
musicBrainzId: musicBrainzString
? musicBrainzString.id.split('//')[1]
: undefined,
title: metadata.title,
type: metadata.type,
};
@ -315,7 +326,11 @@ class PlexTvAPI extends ExternalAPI {
)
);
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
const filteredList = watchlistDetails.filter((detail) =>
['movie', 'show'].includes(detail.type)
? detail.tmdbId
: detail.musicBrainzId
);
return {
offset,

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

@ -286,16 +286,20 @@ class AvailabilitySync {
id: media.id,
})
.andWhere(
`(request.is4k = :is4k AND media.${
is4k ? 'status4k' : 'status'
} IN (:...mediaStatus))`,
{
mediaStatus: [
MediaStatus.AVAILABLE,
MediaStatus.PARTIALLY_AVAILABLE,
],
is4k: is4k,
}
['show', 'movie'].includes(media.mediaType)
? `(request.is4k = :is4k AND media.${
is4k ? 'status4k' : 'status'
} IN (:...mediaStatus))`
: '',
['show', 'movie'].includes(media.mediaType)
? {
mediaStatus: [
MediaStatus.AVAILABLE,
MediaStatus.PARTIALLY_AVAILABLE,
],
is4k: is4k,
}
: {}
)
.getMany();

@ -24,7 +24,8 @@ export interface RunnableScanner<T> {
}
export interface MediaIds {
tmdbId: number;
tmdbId?: number;
mbId?: string;
imdbId?: string;
tvdbId?: number;
isHama?: boolean;
@ -79,13 +80,19 @@ class BaseScanner<T> {
this.updateRate = updateRate ?? UPDATE_RATE;
}
private async getExisting(tmdbId: number, mediaType: MediaType) {
private async getExisting(id: number | string, mediaType: MediaType) {
const mediaRepository = getRepository(Media);
const existing = await mediaRepository.findOne({
where: { tmdbId: tmdbId, mediaType },
});
let existing: Media | null;
if (mediaType === MediaType.MOVIE || mediaType === MediaType.TV) {
existing = await mediaRepository.findOne({
where: { tmdbId: id as number, mediaType },
});
} else {
existing = await mediaRepository.findOne({
where: { mbId: id as string, mediaType },
});
}
return existing;
}
@ -110,8 +117,8 @@ class BaseScanner<T> {
if (existing) {
let changedExisting = false;
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
existing[is4k ? 'status4k' : 'status'] = processing
if (existing['status'] !== MediaStatus.AVAILABLE) {
existing['status'] = processing
? MediaStatus.PROCESSING
: MediaStatus.AVAILABLE;
if (mediaAddedAt) {
@ -125,29 +132,21 @@ class BaseScanner<T> {
changedExisting = true;
}
if (
ratingKey &&
existing[is4k ? 'ratingKey4k' : 'ratingKey'] !== ratingKey
) {
existing[is4k ? 'ratingKey4k' : 'ratingKey'] = ratingKey;
if (ratingKey && existing['ratingKey'] !== ratingKey) {
existing['ratingKey'] = ratingKey;
changedExisting = true;
}
if (
serviceId !== undefined &&
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
) {
existing[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
if (serviceId !== undefined && existing['serviceId'] !== serviceId) {
existing['serviceId'] = serviceId;
changedExisting = true;
}
if (
externalServiceId !== undefined &&
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !==
externalServiceId
existing['externalServiceId'] !== externalServiceId
) {
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
externalServiceId;
existing['externalServiceId'] = externalServiceId;
changedExisting = true;
}
@ -384,12 +383,11 @@ class BaseScanner<T> {
}
if (serviceId !== undefined) {
media[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
media['serviceId'] = serviceId;
}
if (externalServiceId !== undefined) {
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
externalServiceId;
media['externalServiceId'] = externalServiceId;
}
if (externalServiceSlug !== undefined) {
@ -505,6 +503,93 @@ class BaseScanner<T> {
});
}
protected async processArtist(
mbId: string,
{
mediaAddedAt,
ratingKey,
serviceId,
externalServiceId,
processing = false,
title = 'Unknown Title',
}: ProcessOptions = {}
): Promise<void> {
const mediaRepository = getRepository(Media);
await this.asyncLock.dispatch(mbId, async () => {
const existing = await this.getExisting(mbId, MediaType.MUSIC);
if (existing) {
let changedExisting = false;
if (existing['status'] !== MediaStatus.AVAILABLE) {
existing['status'] = processing
? MediaStatus.PROCESSING
: MediaStatus.AVAILABLE;
if (mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
}
changedExisting = true;
}
if (!changedExisting && !existing.mediaAddedAt && mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
changedExisting = true;
}
if (ratingKey && existing['ratingKey'] !== ratingKey) {
existing['ratingKey'] = ratingKey;
changedExisting = true;
}
if (serviceId !== undefined && existing['serviceId'] !== serviceId) {
existing['serviceId'] = serviceId;
changedExisting = true;
}
if (
externalServiceId !== undefined &&
existing['externalServiceId'] !== externalServiceId
) {
existing['externalServiceId'] = externalServiceId;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
`Media for ${title} exists. Changes were detected and the title will be updated.`,
'info'
);
} else {
this.log(`Title already exists and no changes detected for ${title}`);
}
} else {
const newMedia = new Media();
newMedia.mbId = mbId;
newMedia.status = !processing
? MediaStatus.AVAILABLE
: processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MUSIC;
newMedia.serviceId = serviceId;
newMedia.externalServiceId = externalServiceId;
if (mediaAddedAt) {
newMedia.mediaAddedAt = mediaAddedAt;
}
if (ratingKey) {
newMedia.ratingKey = ratingKey;
}
await mediaRepository.save(newMedia);
this.log(`Saved new media: ${title}`);
}
});
}
/**
* Call startRun from child class whenever a run is starting to
* ensure required values are set

@ -19,6 +19,7 @@ import { uniqWith } from 'lodash';
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
const mbRegex = new RegExp(/mbid:\/\/([0-9a-f-]+)/);
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
const plexRegex = new RegExp(/plex:\/\//);
// Hama agent uses ASS naming, see details here:
@ -209,6 +210,8 @@ class PlexScanner
plexitem.type === 'season'
) {
await this.processPlexShow(plexitem);
} else if (plexitem.type === 'artist') {
await this.processPlexArtist(plexitem);
}
} catch (e) {
this.log('Failed to process Plex media', 'error', {
@ -224,13 +227,18 @@ class PlexScanner
const has4k = plexitem.Media.some(
(media) => media.videoResolution === '4k'
);
await this.processMovie(mediaIds.tmdbId, {
is4k: has4k && this.enable4kMovie,
mediaAddedAt: new Date(plexitem.addedAt * 1000),
ratingKey: plexitem.ratingKey,
title: plexitem.title,
});
if (mediaIds.tmdbId) {
await this.processMovie(mediaIds.tmdbId, {
is4k: has4k && this.enable4kMovie,
mediaAddedAt: new Date(plexitem.addedAt * 1000),
ratingKey: plexitem.ratingKey,
title: plexitem.title,
});
} else {
this.log('No TMDB ID found for movie', 'warn', {
title: plexitem.title,
});
}
}
private async processPlexMovieByTmdbId(
@ -273,7 +281,9 @@ class PlexScanner
await this.processHamaSpecials(metadata, mediaIds.tvdbId);
}
const tvShow = await this.tmdb.getTvShow({ tvId: mediaIds.tmdbId });
const tvShow = await this.tmdb.getTvShow({
tvId: mediaIds.tmdbId as number,
});
const seasons = tvShow.seasons;
const processableSeasons: ProcessableSeason[] = [];
@ -322,7 +332,7 @@ class PlexScanner
if (mediaIds.tvdbId) {
await this.processShow(
mediaIds.tmdbId,
mediaIds.tmdbId as number,
mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id,
processableSeasons,
{
@ -334,6 +344,21 @@ class PlexScanner
}
}
private async processPlexArtist(plexitem: PlexLibraryItem) {
const mediaIds = await this.getMediaIds(plexitem);
if (mediaIds.mbId) {
await this.processArtist(mediaIds.mbId, {
mediaAddedAt: new Date(plexitem.addedAt * 1000),
ratingKey: plexitem.ratingKey,
title: plexitem.title,
});
} else {
this.log('No MusicBrainz ID found for artist', 'warn', {
title: plexitem.title,
});
}
}
private async getMediaIds(plexitem: PlexLibraryItem): Promise<MediaIds> {
let mediaIds: Partial<MediaIds> = {};
// Check if item is using new plex movie/tv agent
@ -372,6 +397,8 @@ class PlexScanner
} else if (ref.id.match(tvdbRegex)) {
const tvdbMatch = ref.id.match(tvdbRegex)?.[1];
mediaIds.tvdbId = Number(tvdbMatch);
} else if (ref.id.match(mbRegex)) {
mediaIds.mbId = ref.id.match(mbRegex)?.[1] ?? undefined;
}
});
@ -487,10 +514,16 @@ class PlexScanner
}
}
}
// Check for MusicBrainz
} else if (plexitem.guid.match(mbRegex)) {
const mbMatch = plexitem.guid.match(mbRegex);
if (mbMatch) {
mediaIds.mbId = mbMatch[1];
}
}
if (!mediaIds.tmdbId) {
throw new Error('Unable to find TMDB ID');
if (!mediaIds.tmdbId && !mediaIds.mbId) {
throw new Error('Unable to find either a TMDB ID or a MB ID');
}
// We check above if we have the TMDB ID, so we can safely assert the type below

@ -9,7 +9,7 @@ export interface Library {
id: string;
name: string;
enabled: boolean;
type: 'show' | 'movie';
type: 'show' | 'movie' | 'artist';
lastScan?: number;
}

@ -10,6 +10,11 @@ import {
RequestPermissionError,
} from '@server/entity/MediaRequest';
import { User } from '@server/entity/User';
import type {
MusicRequestBody,
TvRequestBody,
VideoRequestBody,
} from '@server/interfaces/api/requestInterfaces';
import logger from '@server/logger';
import { Permission } from './permissions';
@ -66,7 +71,8 @@ class WatchlistSync {
const response = await plexTvApi.getWatchlist({ size: 200 });
const mediaItems = await Media.getRelatedMedia(
response.items.map((i) => i.tmdbId)
response.items.map((i) => i.tmdbId) as number[],
response.items.map((i) => i.musicBrainzId) as string[]
);
const unavailableItems = response.items.filter(
@ -114,13 +120,17 @@ class WatchlistSync {
await MediaRequest.request(
{
mediaId: mediaItem.tmdbId,
mediaId: mediaItem.tmdbId ?? mediaItem.musicBrainzId,
mediaType:
mediaItem.type === 'show' ? MediaType.TV : MediaType.MOVIE,
mediaItem.type === 'show'
? MediaType.TV
: mediaItem.type === 'movie'
? MediaType.MOVIE
: MediaType.MUSIC,
seasons: mediaItem.type === 'show' ? 'all' : undefined,
tvdbId: mediaItem.tvdbId,
is4k: false,
},
tvdbId: mediaItem.tvdbId ?? undefined,
is4k: ['movie', 'show'].includes(mediaItem.type) ? false : false,
} as MusicRequestBody | TvRequestBody | VideoRequestBody,
user,
{ isAutoRequest: true }
);

@ -847,6 +847,7 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
title: item.title,
mediaType: item.type === 'show' ? 'tv' : 'movie',
tmdbId: item.tmdbId,
musicBrainzId: item.musicBrainzId,
})),
});
}

@ -29,11 +29,31 @@ declare module 'nodebrainz' {
}
export default class BaseNodeBrainz {
constructor(options: { userAgent: string });
artist(artistId: string, { inc }: { inc: string }): Artist;
recording(recordingId: string, { inc }: { inc: string }): Recording;
release(releaseId: string, { inc }: { inc: string }): Release;
releaseGroup(releaseGroupId: string, { inc }: { inc: string }): Group;
work(workId: string, { inc }: { inc: string }): Work;
artist(
artistId: string,
{ inc }: { inc: string },
callback: (err: Error, data: Artist) => void
): Promise<Artist>;
recording(
recordingId: string,
{ inc }: { inc: string },
callback: (err: Error, data: Recording) => void
): Promise<Recording>;
release(
releaseId: string,
{ inc }: { inc: string },
callback: (err: Error, data: Release) => void
): Promise<Release>;
releaseGroup(
releaseGroupId: string,
{ inc }: { inc: string },
callback: (err: Error, data: Group) => void
): Promise<Group>;
work(
workId: string,
{ inc }: { inc: string },
callback: (err: Error, data: Work) => void
): Promise<Work>;
search(
type: string,
search: SearchOptions,

@ -1,12 +1,16 @@
import Slider from '@app/components/Slider';
import TitleCard from '@app/components/TitleCard';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, useUser } from '@app/hooks/useUser';
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
//import MusicBrainz from '@server/api/musicbrainz';
const messages = defineMessages({
recentlyAdded: 'Recently Added',
recentlyAddedMusic: 'Recently Added Music',
});
const RecentlyAddedSlider = () => {
@ -26,6 +30,21 @@ const RecentlyAddedSlider = () => {
return null;
}
const videoMedias = (media?.results ?? []).filter((item) => ["movie", "tv"].includes(item.mediaType))
const musicMedias = (media?.results ?? []).filter((item) => !["movie", "tv"].includes(item.mediaType))
//const musicBrainz = new MusicBrainz();
//const artistNames = musicMedias.map(async (item) => {return item.mbId ? (await musicBrainz.getArtist(item.mbId)).name: "Unknown"});
const musicItems = musicMedias.map((item) => (
<TitleCard
key={`media-slider-item-${item.id}`}
id={item.id}
title={"Unknown"}
mediaType={item.mediaType as 'music'}
/>
));
return (
<>
<div className="slider-header">
@ -36,16 +55,27 @@ const RecentlyAddedSlider = () => {
<Slider
sliderKey="media"
isLoading={!media}
items={(media?.results ?? []).map((item) => (
<TmdbTitleCard
items={videoMedias.map((item) => (
<TmdbTitleCard
key={`media-slider-item-${item.id}`}
id={item.id}
tmdbId={item.tmdbId}
tmdbId={item.tmdbId as number}
tvdbId={item.tvdbId}
type={item.mediaType}
type={item.mediaType as 'movie' | 'tv'}
/>
))}
/>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.recentlyAddedMusic)}</span>
</div>
</div>
<Slider
sliderKey="media"
isLoading={!media}
items={musicItems}
/>
</>
);
};

Loading…
Cancel
Save