improve similar items fallback, make ND album artist for song actually album artist, fix full screen race

This commit is contained in:
Kendall Garner 2024-04-08 08:49:55 -07:00
parent 2257e439a4
commit 14086ebc9c
No known key found for this signature in database
GPG key ID: 18D2767419676C87
15 changed files with 136 additions and 17 deletions

View file

@ -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,

View file

@ -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',

View file

@ -974,6 +974,8 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
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<Song[]> => {
},
});
if (res.status !== 200) {
if (res.status === 200 && res.body.Items.length) {
const results = res.body.Items.reduce<Song[]>((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<Song[]>((acc, song) => {
return mix.body.Items.reduce<Song[]>((acc, song) => {
if (song.Id !== query.songId) {
acc.push(jfNormalize.song(song, apiClientProps.server, ''));
}

View file

@ -242,6 +242,7 @@ export enum NDSongListSort {
ID = 'id',
PLAY_COUNT = 'playCount',
PLAY_DATE = 'playDate',
RANDOM = 'random',
RATING = 'rating',
RECENTLY_ADDED = 'createdAt',
TITLE = 'title',

View file

@ -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<ServerInfo> => {
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! };
};
const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
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<Song[]>((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<Song[]>((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,

View file

@ -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 }],

View file

@ -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}`,
});

View file

@ -469,7 +469,7 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
throw new Error('Failed to get similar songs');
}
if (!res.body.similarSongs) {
if (!res.body.similarSongs?.song) {
return [];
}

View file

@ -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;
};

View file

@ -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) {

View file

@ -159,6 +159,11 @@ const SongPropertyMapping: ItemDetailRow<Song>[] = [
{ 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) => {
<Table
highlightOnHover
horizontalSpacing="sm"
sx={{ userSelect: 'text' }}
verticalSpacing="sm"
>
<tbody>{body}</tbody>

View file

@ -206,10 +206,7 @@ export const FullScreenPlayerImage = () => {
justify="flex-start"
p="1rem"
>
<ImageContainer
ref={mainImageRef}
onLoad={updateImageSize}
>
<ImageContainer ref={mainImageRef}>
<AnimatePresence
initial={false}
mode="sync"

View file

@ -30,7 +30,7 @@ export const SimilarSongsList = ({ count, fullScreen, song }: SimilarSongsListPr
cacheTime: 1000 * 60 * 2,
staleTime: 1000 * 60 * 1,
},
query: { count, songId: song.id },
query: { albumArtistIds: song.albumArtists.map((art) => art.id), count, songId: song.id },
serverId: song?.serverId,
});

View file

@ -16,10 +16,14 @@ export const useSimilarSongs = (args: QueryHookArgs<SimilarSongsQuery>) => {
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,
});
};

View file

@ -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' }),