diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index de76e721..f9414f6a 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -178,7 +178,7 @@ const endpoints: ApiController = { getPlaylistSongList: ndController.getPlaylistSongList, getRandomSongList: ssController.getRandomSongList, getServerInfo: ndController.getServerInfo, - getSimilarSongs: ssController.getSimilarSongs, + getSimilarSongs: ndController.getSimilarSongs, getSongDetail: ndController.getSongDetail, getSongList: ndController.getSongList, getStructuredLyrics: ssController.getStructuredLyrics, diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index ed9a538e..87b16456 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -115,6 +115,15 @@ export const contract = c.router({ 400: jfType._response.error, }, }, + getInstantMix: { + method: 'GET', + path: 'songs/:itemId/InstantMix', + query: jfType._parameters.similarSongs, + responses: { + 200: jfType._response.songList, + 400: jfType._response.error, + }, + }, getMusicFolderList: { method: 'GET', path: 'users/:userId/items', diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 31082b03..1065a98f 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -974,6 +974,8 @@ const getServerInfo = async (args: ServerInfoArgs): Promise => { const getSimilarSongs = async (args: SimilarSongsArgs): Promise => { const { apiClientProps, query } = args; + // Prefer getSimilarSongs, where possible. Fallback to InstantMix + // where no similar songs were found. const res = await jfApiClient(apiClientProps).getSimilarSongs({ params: { itemId: query.songId, @@ -985,11 +987,36 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise => { }, }); - if (res.status !== 200) { + if (res.status === 200 && res.body.Items.length) { + const results = res.body.Items.reduce((acc, song) => { + if (song.Id !== query.songId) { + acc.push(jfNormalize.song(song, apiClientProps.server, '')); + } + + return acc; + }, []); + + if (results.length > 0) { + return results; + } + } + + const mix = await jfApiClient(apiClientProps).getInstantMix({ + params: { + itemId: query.songId, + }, + query: { + Fields: 'Genres, DateCreated, MediaSources, ParentId', + Limit: query.count, + UserId: apiClientProps.server?.userId || undefined, + }, + }); + + if (mix.status !== 200) { throw new Error('Failed to get similar songs'); } - return res.body.Items.reduce((acc, song) => { + return mix.body.Items.reduce((acc, song) => { if (song.Id !== query.songId) { acc.push(jfNormalize.song(song, apiClientProps.server, '')); } diff --git a/src/renderer/api/navidrome.types.ts b/src/renderer/api/navidrome.types.ts index 8e276125..b9c8c688 100644 --- a/src/renderer/api/navidrome.types.ts +++ b/src/renderer/api/navidrome.types.ts @@ -242,6 +242,7 @@ export enum NDSongListSort { ID = 'id', PLAY_COUNT = 'playCount', PLAY_DATE = 'playDate', + RANDOM = 'random', RATING = 'rating', RECENTLY_ADDED = 'createdAt', TITLE = 'title', diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 15c0420e..b427ef97 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -47,10 +47,14 @@ import { genreListSortMap, ServerInfo, ServerInfoArgs, + SimilarSongsArgs, + Song, } from '../types'; import { hasFeature } from '/@/renderer/api/utils'; import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types'; import { SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-types'; +import { NDSongListSort } from '/@/renderer/api/navidrome.types'; +import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize'; const authenticate = async ( url: string, @@ -545,6 +549,58 @@ const getServerInfo = async (args: ServerInfoArgs): Promise => { return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! }; }; +const getSimilarSongs = async (args: SimilarSongsArgs): Promise => { + const { apiClientProps, query } = args; + + // Prefer getSimilarSongs (which queries last.fm) where available + // otherwise find other tracks by the same album artist + const res = await ssApiClient({ + ...apiClientProps, + silent: true, + }).getSimilarSongs({ + query: { + count: query.count, + id: query.songId, + }, + }); + + if (res.status === 200 && res.body.similarSongs?.song) { + const similar = res.body.similarSongs.song.reduce((acc, song) => { + if (song.id !== query.songId) { + acc.push(ssNormalize.song(song, apiClientProps.server, '')); + } + + return acc; + }, []); + + if (similar.length > 0) { + return similar; + } + } + + const fallback = await ndApiClient(apiClientProps).getSongList({ + query: { + _end: 50, + _order: 'ASC', + _sort: NDSongListSort.RANDOM, + _start: 0, + album_artist_id: query.albumArtistIds, + }, + }); + + if (fallback.status !== 200) { + throw new Error('Failed to get similar songs'); + } + + return fallback.body.data.reduce((acc, song) => { + if (song.id !== query.songId) { + acc.push(ndNormalize.song(song, apiClientProps.server, '')); + } + + return acc; + }, []); +}; + export const ndController = { addToPlaylist, authenticate, @@ -559,6 +615,7 @@ export const ndController = { getPlaylistList, getPlaylistSongList, getServerInfo, + getSimilarSongs, getSongDetail, getSongList, getUserList, diff --git a/src/renderer/api/navidrome/navidrome-normalize.ts b/src/renderer/api/navidrome/navidrome-normalize.ts index 9f69b9e1..386c6e34 100644 --- a/src/renderer/api/navidrome/navidrome-normalize.ts +++ b/src/renderer/api/navidrome/navidrome-normalize.ts @@ -81,7 +81,7 @@ const normalizeSong = ( const imagePlaceholderUrl = null; return { album: item.album, - albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }], + albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }], albumId: item.albumId, artistName: item.artist, artists: [{ id: item.artistId, imageUrl: null, name: item.artist }], diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index 467c7efc..7e36d8cb 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -131,7 +131,6 @@ axiosClient.defaults.paramsSerializer = (params) => { axiosClient.interceptors.response.use( (response) => { const data = response.data; - if (data['subsonic-response'].status !== 'ok') { // Suppress code related to non-linked lastfm or spotify from Navidrome if (data['subsonic-response'].error.code !== 0) { @@ -161,12 +160,24 @@ const parsePath = (fullPath: string) => { }; }; +const silentlyTransformResponse = (data: any) => { + const jsonBody = JSON.parse(data); + const status = jsonBody ? jsonBody['subsonic-response']?.status : undefined; + + if (status && status !== 'ok') { + jsonBody['subsonic-response'].error.code = 0; + } + + return jsonBody; +}; + export const ssApiClient = (args: { server: ServerListItem | null; signal?: AbortSignal; + silent?: boolean; url?: string; }) => { - const { server, url, signal } = args; + const { server, url, signal, silent } = args; return initClient(contract, { api: async ({ path, method, headers, body }) => { @@ -206,6 +217,8 @@ export const ssApiClient = (args: { ...params, }, signal, + // In cases where we have a fallback, don't notify the error + transformResponse: silent ? silentlyTransformResponse : undefined, url: `${baseUrl}/${api}`, }); diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 66301beb..b6acc9e7 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -469,7 +469,7 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise => { throw new Error('Failed to get similar songs'); } - if (!res.body.similarSongs) { + if (!res.body.similarSongs?.song) { return []; } diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index d6d3d957..96889aec 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -541,7 +541,7 @@ export const songListSortMap: SongListSortMap = { id: NDSongListSort.ID, name: NDSongListSort.TITLE, playCount: NDSongListSort.PLAY_COUNT, - random: undefined, + random: NDSongListSort.RANDOM, rating: NDSongListSort.RATING, recentlyAdded: NDSongListSort.RECENTLY_ADDED, recentlyPlayed: NDSongListSort.PLAY_DATE, @@ -1170,6 +1170,7 @@ export type StructuredLyric = { } & (StructuredUnsyncedLyric | StructuredSyncedLyric); export type SimilarSongsQuery = { + albumArtistIds: string[]; count?: number; songId: string; }; diff --git a/src/renderer/components/virtual-table/cells/combined-title-cell.tsx b/src/renderer/components/virtual-table/cells/combined-title-cell.tsx index bd7be633..d8761576 100644 --- a/src/renderer/components/virtual-table/cells/combined-title-cell.tsx +++ b/src/renderer/components/virtual-table/cells/combined-title-cell.tsx @@ -10,7 +10,6 @@ import styled from 'styled-components'; import type { AlbumArtist, Artist } from '/@/renderer/api/types'; import { Text } from '/@/renderer/components/text'; import { AppRoute } from '/@/renderer/router/routes'; -import { ServerType } from '/@/renderer/api/types'; import { Skeleton } from '/@/renderer/components/skeleton'; const CellContainer = styled(motion.div)<{ height: number }>` @@ -51,7 +50,7 @@ const StyledImage = styled(SimpleImg)` export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams) => { const artists = useMemo(() => { if (!value) return null; - return value?.type === ServerType.JELLYFIN ? value.artists : value.albumArtists; + return value.artists.length ? value.artists : value.albumArtists; }, [value]); if (value === undefined) { diff --git a/src/renderer/features/item-details/components/item-details-modal.tsx b/src/renderer/features/item-details/components/item-details-modal.tsx index 9126a2c3..6bc05386 100644 --- a/src/renderer/features/item-details/components/item-details-modal.tsx +++ b/src/renderer/features/item-details/components/item-details-modal.tsx @@ -159,6 +159,11 @@ const SongPropertyMapping: ItemDetailRow[] = [ { key: 'name', label: 'common.title' }, { key: 'path', label: 'common.path', render: SongPath }, { label: 'entity.albumArtist_one', render: formatArtists }, + { + key: 'artists', + label: 'entity.artist_other', + render: (song) => song.artists.map((artist) => artist.name).join(' ยท '), + }, { key: 'album', label: 'entity.album_one' }, { key: 'discNumber', label: 'common.disc' }, { key: 'trackNumber', label: 'common.trackNumber' }, @@ -229,6 +234,7 @@ export const ItemDetailsModal = ({ item }: ItemDetailsModalProps) => { {body} diff --git a/src/renderer/features/player/components/full-screen-player-image.tsx b/src/renderer/features/player/components/full-screen-player-image.tsx index 20ae0975..269517de 100644 --- a/src/renderer/features/player/components/full-screen-player-image.tsx +++ b/src/renderer/features/player/components/full-screen-player-image.tsx @@ -206,10 +206,7 @@ export const FullScreenPlayerImage = () => { justify="flex-start" p="1rem" > - + art.id), count, songId: song.id }, serverId: song?.serverId, }); diff --git a/src/renderer/features/similar-songs/queries/similar-song-queries.tsx b/src/renderer/features/similar-songs/queries/similar-song-queries.tsx index 0ba4516a..c09993f7 100644 --- a/src/renderer/features/similar-songs/queries/similar-song-queries.tsx +++ b/src/renderer/features/similar-songs/queries/similar-song-queries.tsx @@ -16,10 +16,14 @@ export const useSimilarSongs = (args: QueryHookArgs) => { return api.controller.getSimilarSongs({ apiClientProps: { server, signal }, - query: { count: query.count ?? 50, songId: query.songId }, + query: { + albumArtistIds: query.albumArtistIds, + count: query.count ?? 50, + songId: query.songId, + }, }); }, - queryKey: queryKeys.albumArtists.detail(server?.id || '', query), + queryKey: queryKeys.songs.similar(server?.id || '', query), ...options, }); }; diff --git a/src/renderer/features/songs/components/song-list-header-filters.tsx b/src/renderer/features/songs/components/song-list-header-filters.tsx index df3e64e9..a20830f7 100644 --- a/src/renderer/features/songs/components/song-list-header-filters.tsx +++ b/src/renderer/features/songs/components/song-list-header-filters.tsx @@ -139,6 +139,11 @@ const FILTERS = { name: i18n.t('filter.playCount', { postProcess: 'titleCase' }), value: SongListSort.PLAY_COUNT, }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.random', { postProcess: 'titleCase' }), + value: SongListSort.RANDOM, + }, { defaultOrder: SortOrder.DESC, name: i18n.t('filter.rating', { postProcess: 'titleCase' }),