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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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