diff --git a/server/routes/music.ts b/server/routes/music.ts index a638a479..28aefcb5 100644 --- a/server/routes/music.ts +++ b/server/routes/music.ts @@ -203,15 +203,9 @@ musicRoutes.get('/:id', async (req, res, next) => { musicRoutes.get('/:id/artist', async (req, res, next) => { try { const listenbrainzApi = new ListenBrainzAPI(); - const personMapper = new TmdbPersonMapper(); const theAudioDb = new TheAudioDb(); - const metadataAlbumRepository = getRepository(MetadataAlbum); const metadataArtistRepository = getRepository(MetadataArtist); - const page = Number(req.query.page) || 1; - const pageSize = Number(req.query.pageSize) || 20; - const isSlider = req.query.slider === 'true'; - const albumData = await listenbrainzApi.getAlbum(req.params.id); const artistData = albumData?.release_group_metadata?.artist?.artists?.[0]; const artistType = artistData?.type; @@ -242,6 +236,72 @@ musicRoutes.get('/:id/artist', async (req, res, next) => { return res.status(404).json({ status: 404, message: 'Artist not found' }); } + const [artistImagesResult] = await Promise.allSettled([ + !cachedTheAudioDb && + !metadataArtist?.tadbThumb && + !metadataArtist?.tadbCover + ? theAudioDb.getArtistImages(artistData.artist_mbid) + : Promise.resolve(null), + ]); + + const artistImages = + artistImagesResult.status === 'fulfilled' + ? artistImagesResult.value + : null; + + return res.status(200).json({ + artist: { + ...artistDetails, + artistThumb: + cachedTheAudioDb?.artistThumb ?? + metadataArtist?.tadbThumb ?? + artistImages?.artistThumb ?? + null, + artistBackdrop: + cachedTheAudioDb?.artistBackground ?? + metadataArtist?.tadbCover ?? + artistImages?.artistBackground ?? + null, + }, + }); + } catch (error) { + logger.error('Something went wrong retrieving artist details', { + label: 'Music API', + errorMessage: error.message, + artistId: req.params.id, + }); + return next({ status: 500, message: 'Unable to retrieve artist details.' }); + } +}); + +musicRoutes.get('/:id/artist-discography', async (req, res, next) => { + try { + const listenbrainzApi = new ListenBrainzAPI(); + const metadataAlbumRepository = getRepository(MetadataAlbum); + + const page = Number(req.query.page) || 1; + const pageSize = Number(req.query.pageSize) || 20; + const isSlider = req.query.slider === 'true'; + + const albumData = await listenbrainzApi.getAlbum(req.params.id); + const artistData = albumData?.release_group_metadata?.artist?.artists?.[0]; + const artistType = artistData?.type; + + if (!artistData?.artist_mbid || artistType === 'Other') { + return res.status(404).json({ + status: 404, + message: 'Artist details not available for this type', + }); + } + + const artistDetails = await listenbrainzApi.getArtist( + artistData.artist_mbid + ); + + if (!artistDetails) { + return res.status(404).json({ status: 404, message: 'Artist not found' }); + } + const totalReleaseGroups = artistDetails.releaseGroups.length; const paginatedReleaseGroups = isSlider || page === 1 @@ -252,32 +312,130 @@ musicRoutes.get('/:id/artist', async (req, res, next) => { ); const releaseGroupIds = paginatedReleaseGroups.map((rg) => rg.mbid); - const similarArtistIds = - artistDetails.similarArtists?.artists?.map((a) => a.artist_mbid) ?? []; const mediaResponses = await Promise.allSettled([ Media.getRelatedMedia(req.user, releaseGroupIds), metadataAlbumRepository.find({ where: { mbAlbumId: In(releaseGroupIds) }, }), - similarArtistIds.length > 0 - ? metadataArtistRepository.find({ - where: { mbArtistId: In(similarArtistIds) }, - }) - : Promise.resolve([]), ]); const relatedMedia = mediaResponses[0].status === 'fulfilled' ? mediaResponses[0].value : []; const albumMetadata = mediaResponses[1].status === 'fulfilled' ? mediaResponses[1].value : []; - const similarArtistMetadata = - mediaResponses[2].status === 'fulfilled' ? mediaResponses[2].value : []; const albumMetadataMap = new Map( albumMetadata.map((metadata) => [metadata.mbAlbumId, metadata]) ); + const relatedMediaMap = new Map( + relatedMedia.map((media) => [media.mbId, media]) + ); + + const transformedReleaseGroups = paginatedReleaseGroups.map( + (releaseGroup) => { + const metadata = albumMetadataMap.get(releaseGroup.mbid); + return { + id: releaseGroup.mbid, + mediaType: 'album', + title: releaseGroup.name, + 'first-release-date': releaseGroup.date, + 'artist-credit': [{ name: releaseGroup.artist_credit_name }], + 'primary-type': releaseGroup.type || 'Other', + posterPath: metadata?.caaUrl ?? null, + needsCoverArt: !metadata?.caaUrl, + mediaInfo: relatedMediaMap.get(releaseGroup.mbid), + }; + } + ); + + return res.status(200).json({ + releaseGroups: transformedReleaseGroups, + pagination: { + page, + pageSize, + totalItems: totalReleaseGroups, + totalPages: Math.ceil(totalReleaseGroups / pageSize), + }, + }); + } catch (error) { + logger.error('Something went wrong retrieving artist discography', { + label: 'Music API', + errorMessage: error.message, + artistId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve artist discography.', + }); + } +}); + +musicRoutes.get('/:id/artist-similar', async (req, res, next) => { + try { + const listenbrainzApi = new ListenBrainzAPI(); + const personMapper = new TmdbPersonMapper(); + const theAudioDb = new TheAudioDb(); + const metadataArtistRepository = getRepository(MetadataArtist); + + const page = Number(req.query.page) || 1; + const pageSize = Number(req.query.pageSize) || 20; + + const albumData = await listenbrainzApi.getAlbum(req.params.id); + const artistData = albumData?.release_group_metadata?.artist?.artists?.[0]; + const artistType = artistData?.type; + + if (!artistData?.artist_mbid || artistType === 'Other') { + return res.status(404).json({ + status: 404, + message: 'Artist details not available for this type', + }); + } + + const artistDetails = await listenbrainzApi.getArtist( + artistData.artist_mbid + ); + + if (!artistDetails) { + return res.status(404).json({ status: 404, message: 'Artist not found' }); + } + + const allSimilarArtists = + artistDetails.similarArtists?.artists?.sort( + (a, b) => b.score - a.score + ) ?? []; + + const totalResults = allSimilarArtists.length; + const totalPages = Math.ceil(totalResults / pageSize); + + const paginatedSimilarArtists = allSimilarArtists.slice( + (page - 1) * pageSize, + page * pageSize + ); + + const similarArtistIds = paginatedSimilarArtists.map((a) => a.artist_mbid); + + if (similarArtistIds.length === 0) { + return res.status(200).json({ + page, + totalPages, + totalResults, + results: [], + }); + } + + const [similarArtistMetadataResult] = await Promise.allSettled([ + metadataArtistRepository.find({ + where: { mbArtistId: In(similarArtistIds) }, + }), + ]); + + const similarArtistMetadata = + similarArtistMetadataResult.status === 'fulfilled' + ? similarArtistMetadataResult.value + : []; + const similarArtistMetadataMap = new Map( similarArtistMetadata.map((metadata) => [metadata.mbArtistId, metadata]) ); @@ -288,8 +446,8 @@ musicRoutes.get('/:id/artist', async (req, res, next) => { }); const personArtists = - artistDetails.similarArtists?.artists - ?.filter((artist) => artist.type === 'Person') + paginatedSimilarArtists + .filter((artist) => artist.type === 'Person') .filter((artist) => { const metadata = similarArtistMetadataMap.get(artist.artist_mbid); return !metadata?.tmdbPersonId; @@ -315,11 +473,6 @@ musicRoutes.get('/:id/artist', async (req, res, next) => { }) ) : Promise.resolve(similarArtistMetadata), - !cachedTheAudioDb && - !metadataArtist?.tadbThumb && - !metadataArtist?.tadbCover - ? theAudioDb.getArtistImages(artistData.artist_mbid) - : Promise.resolve(null), ]); const artistImageResults = @@ -328,14 +481,6 @@ musicRoutes.get('/:id/artist', async (req, res, next) => { artistResponses[1].status === 'fulfilled' ? artistResponses[1].value : similarArtistMetadata; - const artistImagesResult = - artistResponses[2].status === 'fulfilled' - ? artistResponses[2].value - : null; - - const relatedMediaMap = new Map( - relatedMedia.map((media) => [media.mbId, media]) - ); const finalArtistMetadataMap = new Map( (updatedArtistMetadata || similarArtistMetadata).map((metadata) => [ @@ -344,80 +489,46 @@ musicRoutes.get('/:id/artist', async (req, res, next) => { ]) ); - const transformedReleaseGroups = paginatedReleaseGroups.map( - (releaseGroup) => { - const metadata = albumMetadataMap.get(releaseGroup.mbid); - return { - id: releaseGroup.mbid, - mediaType: 'album', - title: releaseGroup.name, - 'first-release-date': releaseGroup.date, - 'artist-credit': [{ name: releaseGroup.artist_credit_name }], - 'primary-type': releaseGroup.type || 'Other', - posterPath: metadata?.caaUrl ?? null, - needsCoverArt: !metadata?.caaUrl, - mediaInfo: relatedMediaMap.get(releaseGroup.mbid), - }; - } - ); + const transformedSimilarArtists = paginatedSimilarArtists.map((artist) => { + const metadata = finalArtistMetadataMap.get(artist.artist_mbid); + const artistImageResult = + artistImageResults[ + artist.artist_mbid as keyof typeof artistImageResults + ]; - const transformedSimilarArtists = - artistDetails.similarArtists?.artists?.map((artist) => { - const metadata = finalArtistMetadataMap.get(artist.artist_mbid); - const artistImageResult = - artistImageResults[ - artist.artist_mbid as keyof typeof artistImageResults - ]; + const artistThumb = + metadata?.tadbThumb || (artistImageResult?.artistThumb ?? null); - const artistThumb = - metadata?.tadbThumb || (artistImageResult?.artistThumb ?? null); - - const artistBackground = - metadata?.tadbCover || (artistImageResult?.artistBackground ?? null); - - return { - ...artist, - artistThumb: metadata?.tmdbThumb ?? artistThumb, - artistBackground: artistBackground, - tmdbPersonId: metadata?.tmdbPersonId - ? Number(metadata.tmdbPersonId) - : null, - }; - }) ?? []; + return { + id: artist.artist_mbid, + mediaType: 'artist', + name: artist.name, + type: artist.type as 'Group' | 'Person', + artistThumb: metadata?.tmdbThumb ?? artistThumb, + score: artist.score, + tmdbPersonId: metadata?.tmdbPersonId + ? Number(metadata.tmdbPersonId) + : null, + 'sort-name': artist.name, + }; + }); return res.status(200).json({ - artist: { - ...artistDetails, - artistThumb: - cachedTheAudioDb?.artistThumb ?? - metadataArtist?.tadbThumb ?? - artistImagesResult?.artistThumb ?? - null, - artistBackdrop: - cachedTheAudioDb?.artistBackground ?? - metadataArtist?.tadbCover ?? - artistImagesResult?.artistBackground ?? - null, - similarArtists: { - ...artistDetails.similarArtists, - artists: transformedSimilarArtists, - }, - releaseGroups: transformedReleaseGroups, - pagination: { - page, - pageSize, - totalItems: totalReleaseGroups, - totalPages: Math.ceil(totalReleaseGroups / pageSize), - }, - }, + page, + totalPages, + totalResults, + results: transformedSimilarArtists, }); } catch (error) { - logger.error('Something went wrong retrieving artist details', { + logger.error('Something went wrong retrieving similar artists', { label: 'Music API', errorMessage: error.message, artistId: req.params.id, }); - return next({ status: 500, message: 'Unable to retrieve artist details.' }); + return next({ + status: 500, + message: 'Unable to retrieve similar artists.', + }); } }); diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index e6e37052..8566b96a 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -16,7 +16,7 @@ import type { TvResult, } from '@server/models/Search'; import Link from 'next/link'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import useSWRInfinite from 'swr/infinite'; interface MixedResult { @@ -34,7 +34,7 @@ interface MixedResult { interface MediaSliderProps { title: string; - url?: string; + url: string; linkUrl?: string; sliderKey: string; hideWhenEmpty?: boolean; @@ -58,20 +58,13 @@ const MediaSlider = ({ sliderKey, hideWhenEmpty = false, onNewTitles, - items: passedItems, totalItems, }: MediaSliderProps) => { const settings = useSettings(); const { hasPermission } = useUser(); - const [titles, setTitles] = useState< - (MovieResult | TvResult | PersonResult | AlbumResult | ArtistResult)[] - >([]); const { data, error, setSize, size } = useSWRInfinite( (pageIndex: number, previousPageData: MixedResult | null) => { - if ( - !url || - (previousPageData && pageIndex + 1 > previousPageData.totalPages) - ) { + if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { return null; } @@ -85,33 +78,19 @@ const MediaSlider = ({ } ); - useEffect(() => { - const newTitles = - passedItems ?? - (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as ( - | MovieResult - | TvResult - | PersonResult - | AlbumResult - | ArtistResult - )[] - ); + let titles = (data ?? []).reduce( + (a, v) => [...a, ...v.results], + [] as (MovieResult | TvResult | PersonResult | AlbumResult | ArtistResult)[] + ); - if (settings.currentSettings.hideAvailable) { - setTitles( - newTitles.filter( - (i) => - (i.mediaType === 'movie' || i.mediaType === 'tv') && - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ) - ); - } else { - setTitles(newTitles); - } - }, [data, passedItems, settings.currentSettings.hideAvailable]); + if (settings.currentSettings.hideAvailable) { + titles = titles.filter( + (i) => + (i.mediaType === 'movie' || i.mediaType === 'tv') && + i.mediaInfo?.status !== MediaStatus.AVAILABLE && + i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + } if (settings.currentSettings.hideBlacklisted) { titles = titles.filter( @@ -123,7 +102,6 @@ const MediaSlider = ({ useEffect(() => { if ( - !passedItems && titles.length < 24 && size < 5 && (data?.[0]?.totalResults ?? 0) > size * 20 @@ -136,14 +114,9 @@ const MediaSlider = ({ // at all for our purposes. onNewTitles(titles.length); } - }, [titles, setSize, size, data, onNewTitles, passedItems]); + }, [titles, setSize, size, data, onNewTitles]); - if ( - hideWhenEmpty && - (!passedItems - ? (data?.[0].results ?? []).length === 0 - : titles.length === 0) - ) { + if (hideWhenEmpty && (data?.[0].results ?? []).length === 0) { return null; } @@ -271,7 +244,7 @@ const MediaSlider = ({ diff --git a/src/components/MusicDetails/index.tsx b/src/components/MusicDetails/index.tsx index 449a03c0..2369fad3 100644 --- a/src/components/MusicDetails/index.tsx +++ b/src/components/MusicDetails/index.tsx @@ -32,7 +32,7 @@ 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 type { AlbumResult, ArtistResult } from '@server/models/Search'; +import type { AlbumResult } from '@server/models/Search'; import axios from 'axios'; import 'country-flag-icons/3x2/flags.css'; import Link from 'next/link'; @@ -76,16 +76,6 @@ interface MusicDetailsProps { interface ArtistDetails { artist: { releaseGroups: AlbumResult[]; - similarArtists: { - artists: { - tmdbPersonId: number; - artist_mbid: string; - name: string; - type: string; - artistThumb: string; - score: number; - }[]; - }; pagination?: { totalItems: number; }; @@ -766,7 +756,7 @@ const MusicDetails = ({ music }: MusicDetailsProps) => { title={intl.formatMessage(messages.discography, { artistName: data?.artist.name.split(/[&,]|\sfeat\./)[0].trim() ?? '', })} - items={artistData?.artist.releaseGroups} + url={`/api/v1/music/${data.id}/artist-discography`} totalItems={artistData?.artist.pagination?.totalItems} linkUrl={`/music/${data.id}/discography`} hideWhenEmpty @@ -775,20 +765,7 @@ const MusicDetails = ({ music }: MusicDetailsProps) => { ({ - id: artist.artist_mbid, - mediaType: 'artist', - name: artist.name, - type: artist.type as 'Group' | 'Person', - artistThumb: artist.artistThumb, - score: artist.score, - tmdbPersonId: artist.tmdbPersonId, - 'sort-name': artist.name, - }) - ) ?? [] - } + url={`/api/v1/music/${data.id}/artist-similar`} linkUrl={`/music/${data.id}/similar`} hideWhenEmpty />