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,
|
getPlaylistSongList: ndController.getPlaylistSongList,
|
||||||
getRandomSongList: ssController.getRandomSongList,
|
getRandomSongList: ssController.getRandomSongList,
|
||||||
getServerInfo: ndController.getServerInfo,
|
getServerInfo: ndController.getServerInfo,
|
||||||
getSimilarSongs: ssController.getSimilarSongs,
|
getSimilarSongs: ndController.getSimilarSongs,
|
||||||
getSongDetail: ndController.getSongDetail,
|
getSongDetail: ndController.getSongDetail,
|
||||||
getSongList: ndController.getSongList,
|
getSongList: ndController.getSongList,
|
||||||
getStructuredLyrics: ssController.getStructuredLyrics,
|
getStructuredLyrics: ssController.getStructuredLyrics,
|
||||||
|
|
|
@ -115,6 +115,15 @@ export const contract = c.router({
|
||||||
400: jfType._response.error,
|
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: {
|
getMusicFolderList: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'users/:userId/items',
|
path: 'users/:userId/items',
|
||||||
|
|
|
@ -974,6 +974,8 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
|
||||||
const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
|
const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
// Prefer getSimilarSongs, where possible. Fallback to InstantMix
|
||||||
|
// where no similar songs were found.
|
||||||
const res = await jfApiClient(apiClientProps).getSimilarSongs({
|
const res = await jfApiClient(apiClientProps).getSimilarSongs({
|
||||||
params: {
|
params: {
|
||||||
itemId: query.songId,
|
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');
|
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) {
|
if (song.Id !== query.songId) {
|
||||||
acc.push(jfNormalize.song(song, apiClientProps.server, ''));
|
acc.push(jfNormalize.song(song, apiClientProps.server, ''));
|
||||||
}
|
}
|
||||||
|
|
|
@ -242,6 +242,7 @@ export enum NDSongListSort {
|
||||||
ID = 'id',
|
ID = 'id',
|
||||||
PLAY_COUNT = 'playCount',
|
PLAY_COUNT = 'playCount',
|
||||||
PLAY_DATE = 'playDate',
|
PLAY_DATE = 'playDate',
|
||||||
|
RANDOM = 'random',
|
||||||
RATING = 'rating',
|
RATING = 'rating',
|
||||||
RECENTLY_ADDED = 'createdAt',
|
RECENTLY_ADDED = 'createdAt',
|
||||||
TITLE = 'title',
|
TITLE = 'title',
|
||||||
|
|
|
@ -47,10 +47,14 @@ import {
|
||||||
genreListSortMap,
|
genreListSortMap,
|
||||||
ServerInfo,
|
ServerInfo,
|
||||||
ServerInfoArgs,
|
ServerInfoArgs,
|
||||||
|
SimilarSongsArgs,
|
||||||
|
Song,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { hasFeature } from '/@/renderer/api/utils';
|
import { hasFeature } from '/@/renderer/api/utils';
|
||||||
import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types';
|
import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types';
|
||||||
import { SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-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 (
|
const authenticate = async (
|
||||||
url: string,
|
url: string,
|
||||||
|
@ -545,6 +549,58 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
|
||||||
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! };
|
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 = {
|
export const ndController = {
|
||||||
addToPlaylist,
|
addToPlaylist,
|
||||||
authenticate,
|
authenticate,
|
||||||
|
@ -559,6 +615,7 @@ export const ndController = {
|
||||||
getPlaylistList,
|
getPlaylistList,
|
||||||
getPlaylistSongList,
|
getPlaylistSongList,
|
||||||
getServerInfo,
|
getServerInfo,
|
||||||
|
getSimilarSongs,
|
||||||
getSongDetail,
|
getSongDetail,
|
||||||
getSongList,
|
getSongList,
|
||||||
getUserList,
|
getUserList,
|
||||||
|
|
|
@ -81,7 +81,7 @@ const normalizeSong = (
|
||||||
const imagePlaceholderUrl = null;
|
const imagePlaceholderUrl = null;
|
||||||
return {
|
return {
|
||||||
album: item.album,
|
album: item.album,
|
||||||
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
|
||||||
albumId: item.albumId,
|
albumId: item.albumId,
|
||||||
artistName: item.artist,
|
artistName: item.artist,
|
||||||
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||||
|
|
|
@ -131,7 +131,6 @@ axiosClient.defaults.paramsSerializer = (params) => {
|
||||||
axiosClient.interceptors.response.use(
|
axiosClient.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
if (data['subsonic-response'].status !== 'ok') {
|
if (data['subsonic-response'].status !== 'ok') {
|
||||||
// Suppress code related to non-linked lastfm or spotify from Navidrome
|
// Suppress code related to non-linked lastfm or spotify from Navidrome
|
||||||
if (data['subsonic-response'].error.code !== 0) {
|
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: {
|
export const ssApiClient = (args: {
|
||||||
server: ServerListItem | null;
|
server: ServerListItem | null;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
silent?: boolean;
|
||||||
url?: string;
|
url?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { server, url, signal } = args;
|
const { server, url, signal, silent } = args;
|
||||||
|
|
||||||
return initClient(contract, {
|
return initClient(contract, {
|
||||||
api: async ({ path, method, headers, body }) => {
|
api: async ({ path, method, headers, body }) => {
|
||||||
|
@ -206,6 +217,8 @@ export const ssApiClient = (args: {
|
||||||
...params,
|
...params,
|
||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
|
// In cases where we have a fallback, don't notify the error
|
||||||
|
transformResponse: silent ? silentlyTransformResponse : undefined,
|
||||||
url: `${baseUrl}/${api}`,
|
url: `${baseUrl}/${api}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -469,7 +469,7 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
|
||||||
throw new Error('Failed to get similar songs');
|
throw new Error('Failed to get similar songs');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.body.similarSongs) {
|
if (!res.body.similarSongs?.song) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -541,7 +541,7 @@ export const songListSortMap: SongListSortMap = {
|
||||||
id: NDSongListSort.ID,
|
id: NDSongListSort.ID,
|
||||||
name: NDSongListSort.TITLE,
|
name: NDSongListSort.TITLE,
|
||||||
playCount: NDSongListSort.PLAY_COUNT,
|
playCount: NDSongListSort.PLAY_COUNT,
|
||||||
random: undefined,
|
random: NDSongListSort.RANDOM,
|
||||||
rating: NDSongListSort.RATING,
|
rating: NDSongListSort.RATING,
|
||||||
recentlyAdded: NDSongListSort.RECENTLY_ADDED,
|
recentlyAdded: NDSongListSort.RECENTLY_ADDED,
|
||||||
recentlyPlayed: NDSongListSort.PLAY_DATE,
|
recentlyPlayed: NDSongListSort.PLAY_DATE,
|
||||||
|
@ -1170,6 +1170,7 @@ export type StructuredLyric = {
|
||||||
} & (StructuredUnsyncedLyric | StructuredSyncedLyric);
|
} & (StructuredUnsyncedLyric | StructuredSyncedLyric);
|
||||||
|
|
||||||
export type SimilarSongsQuery = {
|
export type SimilarSongsQuery = {
|
||||||
|
albumArtistIds: string[];
|
||||||
count?: number;
|
count?: number;
|
||||||
songId: string;
|
songId: string;
|
||||||
};
|
};
|
||||||
|
|
|
@ -10,7 +10,6 @@ import styled from 'styled-components';
|
||||||
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
|
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
|
||||||
import { Text } from '/@/renderer/components/text';
|
import { Text } from '/@/renderer/components/text';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
import { ServerType } from '/@/renderer/api/types';
|
|
||||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||||
|
|
||||||
const CellContainer = styled(motion.div)<{ height: number }>`
|
const CellContainer = styled(motion.div)<{ height: number }>`
|
||||||
|
@ -51,7 +50,7 @@ const StyledImage = styled(SimpleImg)`
|
||||||
export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams) => {
|
export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams) => {
|
||||||
const artists = useMemo(() => {
|
const artists = useMemo(() => {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
return value?.type === ServerType.JELLYFIN ? value.artists : value.albumArtists;
|
return value.artists.length ? value.artists : value.albumArtists;
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
|
|
|
@ -159,6 +159,11 @@ const SongPropertyMapping: ItemDetailRow<Song>[] = [
|
||||||
{ key: 'name', label: 'common.title' },
|
{ key: 'name', label: 'common.title' },
|
||||||
{ key: 'path', label: 'common.path', render: SongPath },
|
{ key: 'path', label: 'common.path', render: SongPath },
|
||||||
{ label: 'entity.albumArtist_one', render: formatArtists },
|
{ 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: 'album', label: 'entity.album_one' },
|
||||||
{ key: 'discNumber', label: 'common.disc' },
|
{ key: 'discNumber', label: 'common.disc' },
|
||||||
{ key: 'trackNumber', label: 'common.trackNumber' },
|
{ key: 'trackNumber', label: 'common.trackNumber' },
|
||||||
|
@ -229,6 +234,7 @@ export const ItemDetailsModal = ({ item }: ItemDetailsModalProps) => {
|
||||||
<Table
|
<Table
|
||||||
highlightOnHover
|
highlightOnHover
|
||||||
horizontalSpacing="sm"
|
horizontalSpacing="sm"
|
||||||
|
sx={{ userSelect: 'text' }}
|
||||||
verticalSpacing="sm"
|
verticalSpacing="sm"
|
||||||
>
|
>
|
||||||
<tbody>{body}</tbody>
|
<tbody>{body}</tbody>
|
||||||
|
|
|
@ -206,10 +206,7 @@ export const FullScreenPlayerImage = () => {
|
||||||
justify="flex-start"
|
justify="flex-start"
|
||||||
p="1rem"
|
p="1rem"
|
||||||
>
|
>
|
||||||
<ImageContainer
|
<ImageContainer ref={mainImageRef}>
|
||||||
ref={mainImageRef}
|
|
||||||
onLoad={updateImageSize}
|
|
||||||
>
|
|
||||||
<AnimatePresence
|
<AnimatePresence
|
||||||
initial={false}
|
initial={false}
|
||||||
mode="sync"
|
mode="sync"
|
||||||
|
|
|
@ -30,7 +30,7 @@ export const SimilarSongsList = ({ count, fullScreen, song }: SimilarSongsListPr
|
||||||
cacheTime: 1000 * 60 * 2,
|
cacheTime: 1000 * 60 * 2,
|
||||||
staleTime: 1000 * 60 * 1,
|
staleTime: 1000 * 60 * 1,
|
||||||
},
|
},
|
||||||
query: { count, songId: song.id },
|
query: { albumArtistIds: song.albumArtists.map((art) => art.id), count, songId: song.id },
|
||||||
serverId: song?.serverId,
|
serverId: song?.serverId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -16,10 +16,14 @@ export const useSimilarSongs = (args: QueryHookArgs<SimilarSongsQuery>) => {
|
||||||
|
|
||||||
return api.controller.getSimilarSongs({
|
return api.controller.getSimilarSongs({
|
||||||
apiClientProps: { server, signal },
|
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,
|
...options,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -139,6 +139,11 @@ const FILTERS = {
|
||||||
name: i18n.t('filter.playCount', { postProcess: 'titleCase' }),
|
name: i18n.t('filter.playCount', { postProcess: 'titleCase' }),
|
||||||
value: SongListSort.PLAY_COUNT,
|
value: SongListSort.PLAY_COUNT,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
defaultOrder: SortOrder.ASC,
|
||||||
|
name: i18n.t('filter.random', { postProcess: 'titleCase' }),
|
||||||
|
value: SongListSort.RANDOM,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
defaultOrder: SortOrder.DESC,
|
defaultOrder: SortOrder.DESC,
|
||||||
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
|
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
|
||||||
|
|
Reference in a new issue