improve similar items fallback, make ND album artist for song actually album artist, fix full screen race
This commit is contained in:
parent
2257e439a4
commit
14086ebc9c
15 changed files with 136 additions and 17 deletions
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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, ''));
|
||||
}
|
||||
|
|
|
@ -242,6 +242,7 @@ export enum NDSongListSort {
|
|||
ID = 'id',
|
||||
PLAY_COUNT = 'playCount',
|
||||
PLAY_DATE = 'playDate',
|
||||
RANDOM = 'random',
|
||||
RATING = 'rating',
|
||||
RECENTLY_ADDED = 'createdAt',
|
||||
TITLE = 'title',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 }],
|
||||
|
|
|
@ -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}`,
|
||||
});
|
||||
|
||||
|
|
|
@ -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 [];
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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' }),
|
||||
|
|
Reference in a new issue