Add initial album artist detail route
This commit is contained in:
parent
55e2a9bf37
commit
9b8bcb05bd
21 changed files with 1000 additions and 27 deletions
|
@ -37,6 +37,8 @@ import type {
|
||||||
UserListArgs,
|
UserListArgs,
|
||||||
RawUserListResponse,
|
RawUserListResponse,
|
||||||
FavoriteArgs,
|
FavoriteArgs,
|
||||||
|
TopSongListArgs,
|
||||||
|
RawTopSongListResponse,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
||||||
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
||||||
|
@ -52,6 +54,7 @@ export type ControllerEndpoint = Partial<{
|
||||||
getAlbumDetail: (args: AlbumDetailArgs) => Promise<RawAlbumDetailResponse>;
|
getAlbumDetail: (args: AlbumDetailArgs) => Promise<RawAlbumDetailResponse>;
|
||||||
getAlbumList: (args: AlbumListArgs) => Promise<RawAlbumListResponse>;
|
getAlbumList: (args: AlbumListArgs) => Promise<RawAlbumListResponse>;
|
||||||
getArtistDetail: () => void;
|
getArtistDetail: () => void;
|
||||||
|
getArtistInfo: (args: any) => void;
|
||||||
getArtistList: (args: ArtistListArgs) => Promise<RawArtistListResponse>;
|
getArtistList: (args: ArtistListArgs) => Promise<RawArtistListResponse>;
|
||||||
getFavoritesList: () => void;
|
getFavoritesList: () => void;
|
||||||
getFolderItemList: () => void;
|
getFolderItemList: () => void;
|
||||||
|
@ -64,6 +67,7 @@ export type ControllerEndpoint = Partial<{
|
||||||
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<RawSongListResponse>;
|
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<RawSongListResponse>;
|
||||||
getSongDetail: (args: SongDetailArgs) => Promise<RawSongDetailResponse>;
|
getSongDetail: (args: SongDetailArgs) => Promise<RawSongDetailResponse>;
|
||||||
getSongList: (args: SongListArgs) => Promise<RawSongListResponse>;
|
getSongList: (args: SongListArgs) => Promise<RawSongListResponse>;
|
||||||
|
getTopSongs: (args: TopSongListArgs) => Promise<RawTopSongListResponse>;
|
||||||
getUserList: (args: UserListArgs) => Promise<RawUserListResponse>;
|
getUserList: (args: UserListArgs) => Promise<RawUserListResponse>;
|
||||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
|
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
|
||||||
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
|
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
|
||||||
|
@ -87,6 +91,7 @@ const endpoints: ApiController = {
|
||||||
getAlbumDetail: jellyfinApi.getAlbumDetail,
|
getAlbumDetail: jellyfinApi.getAlbumDetail,
|
||||||
getAlbumList: jellyfinApi.getAlbumList,
|
getAlbumList: jellyfinApi.getAlbumList,
|
||||||
getArtistDetail: undefined,
|
getArtistDetail: undefined,
|
||||||
|
getArtistInfo: undefined,
|
||||||
getArtistList: jellyfinApi.getArtistList,
|
getArtistList: jellyfinApi.getArtistList,
|
||||||
getFavoritesList: undefined,
|
getFavoritesList: undefined,
|
||||||
getFolderItemList: undefined,
|
getFolderItemList: undefined,
|
||||||
|
@ -99,6 +104,7 @@ const endpoints: ApiController = {
|
||||||
getPlaylistSongList: jellyfinApi.getPlaylistSongList,
|
getPlaylistSongList: jellyfinApi.getPlaylistSongList,
|
||||||
getSongDetail: undefined,
|
getSongDetail: undefined,
|
||||||
getSongList: jellyfinApi.getSongList,
|
getSongList: jellyfinApi.getSongList,
|
||||||
|
getTopSongs: undefined,
|
||||||
getUserList: undefined,
|
getUserList: undefined,
|
||||||
updatePlaylist: jellyfinApi.updatePlaylist,
|
updatePlaylist: jellyfinApi.updatePlaylist,
|
||||||
updateRating: undefined,
|
updateRating: undefined,
|
||||||
|
@ -114,6 +120,7 @@ const endpoints: ApiController = {
|
||||||
getAlbumDetail: navidromeApi.getAlbumDetail,
|
getAlbumDetail: navidromeApi.getAlbumDetail,
|
||||||
getAlbumList: navidromeApi.getAlbumList,
|
getAlbumList: navidromeApi.getAlbumList,
|
||||||
getArtistDetail: undefined,
|
getArtistDetail: undefined,
|
||||||
|
getArtistInfo: undefined,
|
||||||
getArtistList: undefined,
|
getArtistList: undefined,
|
||||||
getFavoritesList: undefined,
|
getFavoritesList: undefined,
|
||||||
getFolderItemList: undefined,
|
getFolderItemList: undefined,
|
||||||
|
@ -126,6 +133,7 @@ const endpoints: ApiController = {
|
||||||
getPlaylistSongList: navidromeApi.getPlaylistSongList,
|
getPlaylistSongList: navidromeApi.getPlaylistSongList,
|
||||||
getSongDetail: navidromeApi.getSongDetail,
|
getSongDetail: navidromeApi.getSongDetail,
|
||||||
getSongList: navidromeApi.getSongList,
|
getSongList: navidromeApi.getSongList,
|
||||||
|
getTopSongs: subsonicApi.getTopSongList,
|
||||||
getUserList: navidromeApi.getUserList,
|
getUserList: navidromeApi.getUserList,
|
||||||
updatePlaylist: navidromeApi.updatePlaylist,
|
updatePlaylist: navidromeApi.updatePlaylist,
|
||||||
updateRating: subsonicApi.updateRating,
|
updateRating: subsonicApi.updateRating,
|
||||||
|
@ -141,6 +149,7 @@ const endpoints: ApiController = {
|
||||||
getAlbumDetail: subsonicApi.getAlbumDetail,
|
getAlbumDetail: subsonicApi.getAlbumDetail,
|
||||||
getAlbumList: subsonicApi.getAlbumList,
|
getAlbumList: subsonicApi.getAlbumList,
|
||||||
getArtistDetail: undefined,
|
getArtistDetail: undefined,
|
||||||
|
getArtistInfo: undefined,
|
||||||
getArtistList: undefined,
|
getArtistList: undefined,
|
||||||
getFavoritesList: undefined,
|
getFavoritesList: undefined,
|
||||||
getFolderItemList: undefined,
|
getFolderItemList: undefined,
|
||||||
|
@ -152,6 +161,7 @@ const endpoints: ApiController = {
|
||||||
getPlaylistList: undefined,
|
getPlaylistList: undefined,
|
||||||
getSongDetail: undefined,
|
getSongDetail: undefined,
|
||||||
getSongList: undefined,
|
getSongList: undefined,
|
||||||
|
getTopSongs: subsonicApi.getTopSongList,
|
||||||
getUserList: undefined,
|
getUserList: undefined,
|
||||||
updatePlaylist: undefined,
|
updatePlaylist: undefined,
|
||||||
updateRating: undefined,
|
updateRating: undefined,
|
||||||
|
@ -255,6 +265,10 @@ const updateRating = async (args: RatingArgs) => {
|
||||||
return (apiController('updateRating') as ControllerEndpoint['updateRating'])?.(args);
|
return (apiController('updateRating') as ControllerEndpoint['updateRating'])?.(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getTopSongList = async (args: TopSongListArgs) => {
|
||||||
|
return (apiController('getTopSongs') as ControllerEndpoint['getTopSongs'])?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
export const controller = {
|
export const controller = {
|
||||||
createFavorite,
|
createFavorite,
|
||||||
createPlaylist,
|
createPlaylist,
|
||||||
|
@ -271,6 +285,7 @@ export const controller = {
|
||||||
getPlaylistList,
|
getPlaylistList,
|
||||||
getPlaylistSongList,
|
getPlaylistSongList,
|
||||||
getSongList,
|
getSongList,
|
||||||
|
getTopSongList,
|
||||||
getUserList,
|
getUserList,
|
||||||
updatePlaylist,
|
updatePlaylist,
|
||||||
updateRating,
|
updateRating,
|
||||||
|
|
|
@ -140,7 +140,7 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<JFAlbu
|
||||||
const { query, server, signal } = args;
|
const { query, server, signal } = args;
|
||||||
|
|
||||||
const searchParams = {
|
const searchParams = {
|
||||||
fields: 'Genres',
|
fields: 'Genres, Overview',
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = await api
|
const data = await api
|
||||||
|
@ -152,7 +152,16 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<JFAlbu
|
||||||
})
|
})
|
||||||
.json<JFAlbumArtistDetailResponse>();
|
.json<JFAlbumArtistDetailResponse>();
|
||||||
|
|
||||||
return data;
|
const similarArtists = await api
|
||||||
|
.get(`artists/${query.id}/similar`, {
|
||||||
|
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||||
|
prefixUrl: server?.url,
|
||||||
|
searchParams: parseSearchParams({ limit: 10 }),
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
.json<JFAlbumArtistListResponse>();
|
||||||
|
|
||||||
|
return { ...data, similarArtists: { items: similarArtists.Items } };
|
||||||
};
|
};
|
||||||
|
|
||||||
// const getAlbumArtistAlbums = () => {
|
// const getAlbumArtistAlbums = () => {
|
||||||
|
@ -642,10 +651,14 @@ const normalizeSong = (
|
||||||
): Song => {
|
): Song => {
|
||||||
return {
|
return {
|
||||||
album: item.Album,
|
album: item.Album,
|
||||||
albumArtists: item.AlbumArtists?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
albumArtists: item.AlbumArtists?.map((entry) => ({
|
||||||
|
id: entry.Id,
|
||||||
|
imageUrl: null,
|
||||||
|
name: entry.Name,
|
||||||
|
})),
|
||||||
albumId: item.AlbumId,
|
albumId: item.AlbumId,
|
||||||
artistName: item.ArtistItems[0]?.Name,
|
artistName: item.ArtistItems[0]?.Name,
|
||||||
artists: item.ArtistItems.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
artists: item.ArtistItems.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })),
|
||||||
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)),
|
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)),
|
||||||
bpm: null,
|
bpm: null,
|
||||||
channels: null,
|
channels: null,
|
||||||
|
@ -691,9 +704,10 @@ const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: numbe
|
||||||
albumArtists:
|
albumArtists:
|
||||||
item.AlbumArtists.map((entry) => ({
|
item.AlbumArtists.map((entry) => ({
|
||||||
id: entry.Id,
|
id: entry.Id,
|
||||||
|
imageUrl: null,
|
||||||
name: entry.Name,
|
name: entry.Name,
|
||||||
})) || [],
|
})) || [],
|
||||||
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })),
|
||||||
backdropImageUrl: null,
|
backdropImageUrl: null,
|
||||||
createdAt: item.DateCreated,
|
createdAt: item.DateCreated,
|
||||||
duration: item.RunTimeTicks / 10000,
|
duration: item.RunTimeTicks / 10000,
|
||||||
|
@ -747,6 +761,17 @@ const normalizeAlbumArtist = (
|
||||||
playCount: item.UserData.PlayCount,
|
playCount: item.UserData.PlayCount,
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
serverType: ServerType.JELLYFIN,
|
serverType: ServerType.JELLYFIN,
|
||||||
|
similarArtists: item.similarArtists?.items
|
||||||
|
?.filter((entry) => entry.Name !== 'Various Artists')
|
||||||
|
.map((entry) => ({
|
||||||
|
id: entry.Id,
|
||||||
|
imageUrl: getAlbumArtistCoverArtUrl({
|
||||||
|
baseUrl: server.url,
|
||||||
|
item: entry,
|
||||||
|
size: imageSize || 300,
|
||||||
|
}),
|
||||||
|
name: entry.Name,
|
||||||
|
})),
|
||||||
songCount: null,
|
songCount: null,
|
||||||
userFavorite: item.UserData.IsFavorite || false,
|
userFavorite: item.UserData.IsFavorite || false,
|
||||||
userRating: null,
|
userRating: null,
|
||||||
|
|
|
@ -173,6 +173,10 @@ export type JFAlbumArtist = {
|
||||||
PlaybackPositionTicks: number;
|
PlaybackPositionTicks: number;
|
||||||
Played: boolean;
|
Played: boolean;
|
||||||
};
|
};
|
||||||
|
} & {
|
||||||
|
similarArtists: {
|
||||||
|
items: JFAlbumArtist[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type JFArtist = {
|
export type JFArtist = {
|
||||||
|
|
|
@ -78,6 +78,7 @@ import { toast } from '/@/renderer/components/toast';
|
||||||
import { useAuthStore } from '/@/renderer/store';
|
import { useAuthStore } from '/@/renderer/store';
|
||||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||||
import { parseSearchParams } from '/@/renderer/utils';
|
import { parseSearchParams } from '/@/renderer/utils';
|
||||||
|
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
||||||
|
|
||||||
const api = ky.create({
|
const api = ky.create({
|
||||||
hooks: {
|
hooks: {
|
||||||
|
@ -183,6 +184,15 @@ const getGenreList = async (args: GenreListArgs): Promise<NDGenreList> => {
|
||||||
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<NDAlbumArtistDetail> => {
|
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<NDAlbumArtistDetail> => {
|
||||||
const { query, server, signal } = args;
|
const { query, server, signal } = args;
|
||||||
|
|
||||||
|
const artistInfo = await subsonicApi.getArtistInfo({
|
||||||
|
query: {
|
||||||
|
artistId: query.id,
|
||||||
|
limit: 15,
|
||||||
|
},
|
||||||
|
server,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
const data = await api
|
const data = await api
|
||||||
.get(`api/artist/${query.id}`, {
|
.get(`api/artist/${query.id}`, {
|
||||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||||
|
@ -191,7 +201,7 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<NDAlbu
|
||||||
})
|
})
|
||||||
.json<NDAlbumArtistDetailResponse>();
|
.json<NDAlbumArtistDetailResponse>();
|
||||||
|
|
||||||
return { ...data };
|
return { ...data, similarArtists: artistInfo.similarArtist };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<NDAlbumArtistList> => {
|
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<NDAlbumArtistList> => {
|
||||||
|
@ -510,10 +520,10 @@ const normalizeSong = (
|
||||||
|
|
||||||
return {
|
return {
|
||||||
album: item.album,
|
album: item.album,
|
||||||
albumArtists: [{ id: item.artistId, name: item.artist }],
|
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||||
albumId: item.albumId,
|
albumId: item.albumId,
|
||||||
artistName: item.artist,
|
artistName: item.artist,
|
||||||
artists: [{ id: item.artistId, name: item.artist }],
|
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||||
bitRate: item.bitRate,
|
bitRate: item.bitRate,
|
||||||
bpm: item.bpm ? item.bpm : null,
|
bpm: item.bpm ? item.bpm : null,
|
||||||
channels: item.channels ? item.channels : null,
|
channels: item.channels ? item.channels : null,
|
||||||
|
@ -559,8 +569,8 @@ const normalizeAlbum = (item: NDAlbum, server: ServerListItem, imageSize?: numbe
|
||||||
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
|
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
albumArtists: [{ id: item.albumArtistId, name: item.albumArtist }],
|
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
|
||||||
artists: [{ id: item.artistId, name: item.artist }],
|
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||||
backdropImageUrl: imageBackdropUrl,
|
backdropImageUrl: imageBackdropUrl,
|
||||||
createdAt: item.createdAt.split('T')[0],
|
createdAt: item.createdAt.split('T')[0],
|
||||||
duration: item.duration * 1000 || null,
|
duration: item.duration * 1000 || null,
|
||||||
|
@ -602,6 +612,12 @@ const normalizeAlbumArtist = (item: NDAlbumArtist, server: ServerListItem): Albu
|
||||||
playCount: item.playCount,
|
playCount: item.playCount,
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
serverType: ServerType.NAVIDROME,
|
serverType: ServerType.NAVIDROME,
|
||||||
|
similarArtists:
|
||||||
|
item.similarArtists?.map((artist) => ({
|
||||||
|
id: artist.id,
|
||||||
|
imageUrl: artist?.artistImageUrl || null,
|
||||||
|
name: artist.name,
|
||||||
|
})) || null,
|
||||||
songCount: item.songCount,
|
songCount: item.songCount,
|
||||||
userFavorite: item.starred,
|
userFavorite: item.starred,
|
||||||
userRating: item.rating,
|
userRating: item.rating,
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { SSArtistInfo } from '/@/renderer/api/subsonic.types';
|
||||||
|
|
||||||
export type NDAuthenticate = {
|
export type NDAuthenticate = {
|
||||||
id: string;
|
id: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
@ -126,6 +128,8 @@ export type NDAlbumArtist = {
|
||||||
songCount: number;
|
songCount: number;
|
||||||
starred: boolean;
|
starred: boolean;
|
||||||
starredAt: string;
|
starredAt: string;
|
||||||
|
} & {
|
||||||
|
similarArtists?: SSArtistInfo['similarArtist'];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NDAuthenticationResponse = NDAuthenticate;
|
export type NDAuthenticationResponse = NDAuthenticate;
|
||||||
|
|
|
@ -16,7 +16,8 @@ import type {
|
||||||
NDSong,
|
NDSong,
|
||||||
NDUser,
|
NDUser,
|
||||||
} from '/@/renderer/api/navidrome.types';
|
} from '/@/renderer/api/navidrome.types';
|
||||||
import { SSGenreList, SSMusicFolderList } from '/@/renderer/api/subsonic.types';
|
import { ssNormalize } from '/@/renderer/api/subsonic.api';
|
||||||
|
import { SSGenreList, SSMusicFolderList, SSSong } from '/@/renderer/api/subsonic.types';
|
||||||
import type {
|
import type {
|
||||||
Album,
|
Album,
|
||||||
AlbumArtist,
|
AlbumArtist,
|
||||||
|
@ -29,6 +30,7 @@ import type {
|
||||||
RawPlaylistDetailResponse,
|
RawPlaylistDetailResponse,
|
||||||
RawPlaylistListResponse,
|
RawPlaylistListResponse,
|
||||||
RawSongListResponse,
|
RawSongListResponse,
|
||||||
|
RawTopSongListResponse,
|
||||||
RawUserListResponse,
|
RawUserListResponse,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { ServerListItem } from '/@/renderer/types';
|
import { ServerListItem } from '/@/renderer/types';
|
||||||
|
@ -92,6 +94,25 @@ const songList = (data: RawSongListResponse | undefined, server: ServerListItem
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const topSongList = (data: RawTopSongListResponse | undefined, server: ServerListItem | null) => {
|
||||||
|
let songs;
|
||||||
|
|
||||||
|
switch (server?.type) {
|
||||||
|
case 'jellyfin':
|
||||||
|
break;
|
||||||
|
case 'navidrome':
|
||||||
|
songs = data?.items?.map((item) => ssNormalize.song(item as SSSong, server, ''));
|
||||||
|
break;
|
||||||
|
case 'subsonic':
|
||||||
|
songs = data?.items?.map((item) => ssNormalize.song(item as SSSong, server, ''));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: songs,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const musicFolderList = (
|
const musicFolderList = (
|
||||||
data: RawMusicFolderListResponse | undefined,
|
data: RawMusicFolderListResponse | undefined,
|
||||||
server: ServerListItem | null,
|
server: ServerListItem | null,
|
||||||
|
@ -265,5 +286,6 @@ export const normalize = {
|
||||||
playlistDetail,
|
playlistDetail,
|
||||||
playlistList,
|
playlistList,
|
||||||
songList,
|
songList,
|
||||||
|
topSongList,
|
||||||
userList,
|
userList,
|
||||||
};
|
};
|
||||||
|
|
|
@ -9,6 +9,7 @@ import type {
|
||||||
PlaylistSongListQuery,
|
PlaylistSongListQuery,
|
||||||
UserListQuery,
|
UserListQuery,
|
||||||
AlbumArtistDetailQuery,
|
AlbumArtistDetailQuery,
|
||||||
|
TopSongListQuery,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
|
@ -22,6 +23,10 @@ export const queryKeys = {
|
||||||
return [serverId, 'albumArtists', 'list'] as const;
|
return [serverId, 'albumArtists', 'list'] as const;
|
||||||
},
|
},
|
||||||
root: (serverId: string) => [serverId, 'albumArtists'] as const,
|
root: (serverId: string) => [serverId, 'albumArtists'] as const,
|
||||||
|
topSongs: (serverId: string, query?: TopSongListQuery) => {
|
||||||
|
if (query) return [serverId, 'albumArtists', 'topSongs', query] as const;
|
||||||
|
return [serverId, 'albumArtists', 'topSongs'] as const;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
albums: {
|
albums: {
|
||||||
detail: (serverId: string, query?: AlbumDetailQuery) =>
|
detail: (serverId: string, query?: AlbumDetailQuery) =>
|
||||||
|
|
|
@ -19,12 +19,20 @@ import type {
|
||||||
SSRatingParams,
|
SSRatingParams,
|
||||||
SSAlbumArtistDetailParams,
|
SSAlbumArtistDetailParams,
|
||||||
SSAlbumArtistListParams,
|
SSAlbumArtistListParams,
|
||||||
|
SSTopSongListParams,
|
||||||
|
SSTopSongListResponse,
|
||||||
|
SSArtistInfoParams,
|
||||||
|
SSArtistInfoResponse,
|
||||||
|
SSArtistInfo,
|
||||||
|
SSSong,
|
||||||
|
SSTopSongList,
|
||||||
} from '/@/renderer/api/subsonic.types';
|
} from '/@/renderer/api/subsonic.types';
|
||||||
import {
|
import {
|
||||||
AlbumArtistDetailArgs,
|
AlbumArtistDetailArgs,
|
||||||
AlbumArtistListArgs,
|
AlbumArtistListArgs,
|
||||||
AlbumDetailArgs,
|
AlbumDetailArgs,
|
||||||
AlbumListArgs,
|
AlbumListArgs,
|
||||||
|
ArtistInfoArgs,
|
||||||
AuthenticationResponse,
|
AuthenticationResponse,
|
||||||
FavoriteArgs,
|
FavoriteArgs,
|
||||||
FavoriteResponse,
|
FavoriteResponse,
|
||||||
|
@ -34,8 +42,12 @@ import {
|
||||||
RatingArgs,
|
RatingArgs,
|
||||||
RatingResponse,
|
RatingResponse,
|
||||||
ServerListItem,
|
ServerListItem,
|
||||||
|
ServerType,
|
||||||
|
Song,
|
||||||
|
TopSongListArgs,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { toast } from '/@/renderer/components/toast';
|
import { toast } from '/@/renderer/components/toast';
|
||||||
|
import { nanoid } from 'nanoid/non-secure';
|
||||||
|
|
||||||
const getCoverArtUrl = (args: {
|
const getCoverArtUrl = (args: {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
@ -50,7 +62,7 @@ const getCoverArtUrl = (args: {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
`${args.baseUrl}/getCoverArt.view` +
|
`${args.baseUrl}/rest/getCoverArt.view` +
|
||||||
`?id=${args.coverArtId}` +
|
`?id=${args.coverArtId}` +
|
||||||
`&${args.credential}` +
|
`&${args.credential}` +
|
||||||
'&v=1.13.0' +
|
'&v=1.13.0' +
|
||||||
|
@ -65,10 +77,13 @@ const api = ky.create({
|
||||||
async (_request, _options, response) => {
|
async (_request, _options, response) => {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data['subsonic-response'].status !== 'ok') {
|
if (data['subsonic-response'].status !== 'ok') {
|
||||||
toast.error({
|
// Suppress code related to non-linked lastfm or spotify from Navidrome
|
||||||
message: data['subsonic-response'].error.message,
|
if (data['subsonic-response'].error.code !== 0) {
|
||||||
title: 'Issue from Subsonic API',
|
toast.error({
|
||||||
});
|
message: data['subsonic-response'].error.message,
|
||||||
|
title: 'Issue from Subsonic API',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify(data['subsonic-response']), { status: 200 });
|
return new Response(JSON.stringify(data['subsonic-response']), { status: 200 });
|
||||||
|
@ -325,6 +340,118 @@ const updateRating = async (args: RatingArgs): Promise<RatingResponse> => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getTopSongList = async (args: TopSongListArgs): Promise<SSTopSongList> => {
|
||||||
|
const { signal, server, query } = args;
|
||||||
|
const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
|
const searchParams: SSTopSongListParams = {
|
||||||
|
artist: query.artist,
|
||||||
|
count: query.limit,
|
||||||
|
...defaultParams,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await api
|
||||||
|
.get('rest/getTopSongs.view', {
|
||||||
|
prefixUrl: server?.url,
|
||||||
|
searchParams: parseSearchParams(searchParams),
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
.json<SSTopSongListResponse>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: data?.topSongs?.song,
|
||||||
|
startIndex: 0,
|
||||||
|
totalRecordCount: data?.topSongs?.song?.length || 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArtistInfo = async (args: ArtistInfoArgs): Promise<SSArtistInfo> => {
|
||||||
|
const { signal, server, query } = args;
|
||||||
|
const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
|
const searchParams: SSArtistInfoParams = {
|
||||||
|
count: query.limit,
|
||||||
|
id: query.artistId,
|
||||||
|
...defaultParams,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await api
|
||||||
|
.get('rest/getArtistInfo2.view', {
|
||||||
|
prefixUrl: server?.url,
|
||||||
|
searchParams,
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
.json<SSArtistInfoResponse>();
|
||||||
|
|
||||||
|
return data.artistInfo2;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSong = (item: SSSong, server: ServerListItem, deviceId: string): Song => {
|
||||||
|
const imageUrl =
|
||||||
|
getCoverArtUrl({
|
||||||
|
baseUrl: server.url,
|
||||||
|
coverArtId: item.coverArt,
|
||||||
|
credential: server.credential,
|
||||||
|
size: 300,
|
||||||
|
}) || null;
|
||||||
|
|
||||||
|
const streamUrl = `${server.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
album: item.album,
|
||||||
|
albumArtists: [
|
||||||
|
{
|
||||||
|
id: item.artistId || '',
|
||||||
|
imageUrl: null,
|
||||||
|
name: item.artist,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
albumId: item.albumId,
|
||||||
|
artistName: item.artist,
|
||||||
|
artists: [
|
||||||
|
{
|
||||||
|
id: item.artistId || '',
|
||||||
|
imageUrl: null,
|
||||||
|
name: item.artist,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bitRate: item.bitRate,
|
||||||
|
bpm: null,
|
||||||
|
channels: null,
|
||||||
|
comment: null,
|
||||||
|
compilation: null,
|
||||||
|
container: item.contentType,
|
||||||
|
createdAt: item.created,
|
||||||
|
discNumber: item.discNumber || 1,
|
||||||
|
duration: item.duration,
|
||||||
|
genres: [
|
||||||
|
{
|
||||||
|
id: item.genre,
|
||||||
|
name: item.genre,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: item.id,
|
||||||
|
imagePlaceholderUrl: null,
|
||||||
|
imageUrl,
|
||||||
|
itemType: LibraryItem.SONG,
|
||||||
|
lastPlayedAt: null,
|
||||||
|
name: item.title,
|
||||||
|
path: item.path,
|
||||||
|
playCount: item?.playCount || 0,
|
||||||
|
releaseDate: null,
|
||||||
|
releaseYear: item.year ? String(item.year) : null,
|
||||||
|
serverId: server.id,
|
||||||
|
serverType: ServerType.SUBSONIC,
|
||||||
|
size: item.size,
|
||||||
|
streamUrl,
|
||||||
|
trackNumber: item.track,
|
||||||
|
uniqueId: nanoid(),
|
||||||
|
updatedAt: '',
|
||||||
|
userFavorite: item.starred || false,
|
||||||
|
userRating: item.userRating || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const subsonicApi = {
|
export const subsonicApi = {
|
||||||
authenticate,
|
authenticate,
|
||||||
createFavorite,
|
createFavorite,
|
||||||
|
@ -333,8 +460,14 @@ export const subsonicApi = {
|
||||||
getAlbumArtistList,
|
getAlbumArtistList,
|
||||||
getAlbumDetail,
|
getAlbumDetail,
|
||||||
getAlbumList,
|
getAlbumList,
|
||||||
|
getArtistInfo,
|
||||||
getCoverArtUrl,
|
getCoverArtUrl,
|
||||||
getGenreList,
|
getGenreList,
|
||||||
getMusicFolderList,
|
getMusicFolderList,
|
||||||
|
getTopSongList,
|
||||||
updateRating,
|
updateRating,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ssNormalize = {
|
||||||
|
song: normalizeSong,
|
||||||
|
};
|
||||||
|
|
|
@ -65,6 +65,12 @@ export type SSAlbumDetailResponse = {
|
||||||
album: SSAlbum;
|
album: SSAlbum;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SSArtistInfoParams = {
|
||||||
|
count?: number;
|
||||||
|
id: string;
|
||||||
|
includeNotPresent?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type SSArtistInfoResponse = {
|
export type SSArtistInfoResponse = {
|
||||||
artistInfo2: SSArtistInfo;
|
artistInfo2: SSArtistInfo;
|
||||||
};
|
};
|
||||||
|
@ -75,6 +81,13 @@ export type SSArtistInfo = {
|
||||||
lastFmUrl?: string;
|
lastFmUrl?: string;
|
||||||
mediumImageUrl?: string;
|
mediumImageUrl?: string;
|
||||||
musicBrainzId?: string;
|
musicBrainzId?: string;
|
||||||
|
similarArtist?: {
|
||||||
|
albumCount: string;
|
||||||
|
artistImageUrl?: string;
|
||||||
|
coverArt?: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
smallImageUrl?: string;
|
smallImageUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -186,3 +199,20 @@ export type SSRatingParams = {
|
||||||
export type SSRating = null;
|
export type SSRating = null;
|
||||||
|
|
||||||
export type SSRatingResponse = null;
|
export type SSRatingResponse = null;
|
||||||
|
|
||||||
|
export type SSTopSongListParams = {
|
||||||
|
artist: string;
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SSTopSongListResponse = {
|
||||||
|
topSongs: {
|
||||||
|
song: SSSong[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SSTopSongList = {
|
||||||
|
items: SSSong[];
|
||||||
|
startIndex: number;
|
||||||
|
totalRecordCount: number | null;
|
||||||
|
};
|
||||||
|
|
|
@ -43,6 +43,7 @@ import {
|
||||||
SSAlbumArtistDetail,
|
SSAlbumArtistDetail,
|
||||||
SSMusicFolderList,
|
SSMusicFolderList,
|
||||||
SSGenreList,
|
SSGenreList,
|
||||||
|
SSTopSongList,
|
||||||
} from '/@/renderer/api/subsonic.types';
|
} from '/@/renderer/api/subsonic.types';
|
||||||
|
|
||||||
export enum LibraryItem {
|
export enum LibraryItem {
|
||||||
|
@ -231,6 +232,7 @@ export type AlbumArtist = {
|
||||||
playCount: number | null;
|
playCount: number | null;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
serverType: ServerType;
|
serverType: ServerType;
|
||||||
|
similarArtists: RelatedArtist[] | null;
|
||||||
songCount: number | null;
|
songCount: number | null;
|
||||||
userFavorite: boolean;
|
userFavorite: boolean;
|
||||||
userRating: number | null;
|
userRating: number | null;
|
||||||
|
@ -256,6 +258,7 @@ export type Artist = {
|
||||||
|
|
||||||
export type RelatedArtist = {
|
export type RelatedArtist = {
|
||||||
id: string;
|
id: string;
|
||||||
|
imageUrl: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -959,3 +962,24 @@ export const userListSortMap: UserListSortMap = {
|
||||||
name: undefined,
|
name: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Top Songs List
|
||||||
|
export type RawTopSongListResponse = SSTopSongList | undefined;
|
||||||
|
|
||||||
|
export type TopSongListResponse = BasePaginatedResponse<Song[]>;
|
||||||
|
|
||||||
|
export type TopSongListQuery = {
|
||||||
|
artist: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopSongListArgs = { query: TopSongListQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
|
// Artist Info
|
||||||
|
export type ArtistInfoQuery = {
|
||||||
|
artistId: string;
|
||||||
|
limit: number;
|
||||||
|
musicFolderId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ArtistInfoArgs = { query: ArtistInfoQuery } & BaseEndpointArgs;
|
||||||
|
|
|
@ -0,0 +1,411 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
getColumnDefs,
|
||||||
|
GridCarousel,
|
||||||
|
Text,
|
||||||
|
TextTitle,
|
||||||
|
VirtualTable,
|
||||||
|
} from '/@/renderer/components';
|
||||||
|
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||||
|
import { Box, Group, Stack } from '@mantine/core';
|
||||||
|
import { RiArrowDownSLine, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||||
|
import { generatePath, useParams } from 'react-router';
|
||||||
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
|
import { useContainerQuery } from '/@/renderer/hooks';
|
||||||
|
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||||
|
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||||
|
import { Play, TableColumn } from '/@/renderer/types';
|
||||||
|
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||||
|
import {
|
||||||
|
PlayButton,
|
||||||
|
PLAY_TYPES,
|
||||||
|
useCreateFavorite,
|
||||||
|
useDeleteFavorite,
|
||||||
|
} from '/@/renderer/features/shared';
|
||||||
|
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||||
|
import {
|
||||||
|
AlbumListSort,
|
||||||
|
LibraryItem,
|
||||||
|
QueueSong,
|
||||||
|
ServerType,
|
||||||
|
SortOrder,
|
||||||
|
} from '/@/renderer/api/types';
|
||||||
|
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||||
|
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
|
||||||
|
import { useTopSongsList } from '/@/renderer/features/artists/queries/top-songs-list-query';
|
||||||
|
|
||||||
|
const ContentContainer = styled.div`
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3rem;
|
||||||
|
max-width: 1920px;
|
||||||
|
padding: 1rem 2rem 5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.ag-theme-alpine-dark {
|
||||||
|
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ag-header {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const AlbumArtistDetailContent = () => {
|
||||||
|
const { albumArtistId } = useParams() as { albumArtistId: string };
|
||||||
|
const cq = useContainerQuery();
|
||||||
|
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||||
|
const server = useCurrentServer();
|
||||||
|
const itemsPerPage = cq.isXl ? 9 : cq.isLg ? 7 : cq.isMd ? 5 : cq.isSm ? 4 : 3;
|
||||||
|
|
||||||
|
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
|
||||||
|
|
||||||
|
const recentAlbumsQuery = useAlbumList({
|
||||||
|
jfParams: server?.type === ServerType.JELLYFIN ? { artistIds: albumArtistId } : undefined,
|
||||||
|
limit: itemsPerPage,
|
||||||
|
ndParams:
|
||||||
|
server?.type === ServerType.NAVIDROME
|
||||||
|
? { artist_id: albumArtistId, compilation: false }
|
||||||
|
: undefined,
|
||||||
|
sortBy: AlbumListSort.RELEASE_DATE,
|
||||||
|
sortOrder: SortOrder.DESC,
|
||||||
|
startIndex: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const topSongsQuery = useTopSongsList(
|
||||||
|
{ artist: detailQuery?.data?.name || '' },
|
||||||
|
{ enabled: server?.type !== ServerType.JELLYFIN && !!detailQuery?.data?.name },
|
||||||
|
);
|
||||||
|
|
||||||
|
const topSongsColumnDefs: ColDef[] = useMemo(
|
||||||
|
() =>
|
||||||
|
getColumnDefs([
|
||||||
|
{ column: TableColumn.ROW_INDEX, width: 0 },
|
||||||
|
{ column: TableColumn.TITLE_COMBINED, width: 0 },
|
||||||
|
{ column: TableColumn.DURATION, width: 0 },
|
||||||
|
{ column: TableColumn.ALBUM, width: 0 },
|
||||||
|
{ column: TableColumn.YEAR, width: 0 },
|
||||||
|
{ column: TableColumn.PLAY_COUNT, width: 0 },
|
||||||
|
{ column: TableColumn.USER_FAVORITE, width: 0 },
|
||||||
|
]),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cardRows = {
|
||||||
|
album: [
|
||||||
|
{
|
||||||
|
property: 'name',
|
||||||
|
route: {
|
||||||
|
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||||
|
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
arrayProperty: 'name',
|
||||||
|
property: 'albumArtists',
|
||||||
|
route: {
|
||||||
|
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||||
|
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
albumArtist: [
|
||||||
|
{
|
||||||
|
property: 'name',
|
||||||
|
route: {
|
||||||
|
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||||
|
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const carousels = [
|
||||||
|
{
|
||||||
|
data: recentAlbumsQuery?.data?.items,
|
||||||
|
itemType: LibraryItem.ALBUM,
|
||||||
|
loading: recentAlbumsQuery?.isLoading || recentAlbumsQuery.isFetching,
|
||||||
|
pagination: {
|
||||||
|
itemsPerPage,
|
||||||
|
},
|
||||||
|
title: (
|
||||||
|
<>
|
||||||
|
<TextTitle
|
||||||
|
fw="bold"
|
||||||
|
order={3}
|
||||||
|
>
|
||||||
|
Recent albums
|
||||||
|
</TextTitle>
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
uppercase
|
||||||
|
component={Link}
|
||||||
|
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY, {
|
||||||
|
albumArtistId,
|
||||||
|
})}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
View discography
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
uniqueId: 'recentAlbums',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: detailQuery?.data?.similarArtists?.slice(0, itemsPerPage),
|
||||||
|
isHidden: !detailQuery?.data?.similarArtists,
|
||||||
|
itemType: LibraryItem.ALBUM_ARTIST,
|
||||||
|
loading: detailQuery?.isLoading || detailQuery.isFetching,
|
||||||
|
pagination: {
|
||||||
|
itemsPerPage,
|
||||||
|
},
|
||||||
|
title: (
|
||||||
|
<TextTitle
|
||||||
|
fw="bold"
|
||||||
|
order={3}
|
||||||
|
>
|
||||||
|
Related artists
|
||||||
|
</TextTitle>
|
||||||
|
),
|
||||||
|
uniqueId: 'similarArtists',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const playButtonBehavior = usePlayButtonBehavior();
|
||||||
|
|
||||||
|
const handlePlay = async (playType?: Play) => {
|
||||||
|
handlePlayQueueAdd?.({
|
||||||
|
byItemType: {
|
||||||
|
id: [albumArtistId],
|
||||||
|
type: LibraryItem.ALBUM_ARTIST,
|
||||||
|
},
|
||||||
|
play: playType || playButtonBehavior,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
|
||||||
|
|
||||||
|
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||||
|
if (!e.data) return;
|
||||||
|
handlePlayQueueAdd?.({
|
||||||
|
byData: [e.data],
|
||||||
|
play: playButtonBehavior,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFavoriteMutation = useCreateFavorite();
|
||||||
|
const deleteFavoriteMutation = useDeleteFavorite();
|
||||||
|
|
||||||
|
const handleFavorite = () => {
|
||||||
|
if (!detailQuery?.data) return;
|
||||||
|
|
||||||
|
if (detailQuery.data.userFavorite) {
|
||||||
|
deleteFavoriteMutation.mutate({
|
||||||
|
query: {
|
||||||
|
id: [detailQuery.data.id],
|
||||||
|
type: LibraryItem.ALBUM_ARTIST,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
createFavoriteMutation.mutate({
|
||||||
|
query: {
|
||||||
|
id: [detailQuery.data.id],
|
||||||
|
type: LibraryItem.ALBUM_ARTIST,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const topSongs = topSongsQuery?.data?.items?.slice(0, 10);
|
||||||
|
|
||||||
|
const showBiography =
|
||||||
|
detailQuery?.data?.biography !== undefined && detailQuery?.data?.biography !== null;
|
||||||
|
const showTopSongs = server?.type !== ServerType.JELLYFIN && topSongsQuery?.data?.items?.length;
|
||||||
|
const showGenres = detailQuery?.data?.genres?.length !== 0;
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
detailQuery?.isLoading ||
|
||||||
|
recentAlbumsQuery?.isLoading ||
|
||||||
|
(server?.type === ServerType.NAVIDROME && topSongsQuery?.isLoading);
|
||||||
|
|
||||||
|
if (isLoading) return <ContentContainer ref={cq.ref} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContentContainer ref={cq.ref}>
|
||||||
|
<Box component="section">
|
||||||
|
<Group spacing="lg">
|
||||||
|
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||||
|
<Group spacing="xs">
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
loading={createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={handleFavorite}
|
||||||
|
>
|
||||||
|
{detailQuery?.data?.userFavorite ? (
|
||||||
|
<RiHeartFill
|
||||||
|
color="red"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RiHeartLine size={20} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu position="bottom-start">
|
||||||
|
<DropdownMenu.Target>
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<RiMoreFill size={20} />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenu.Target>
|
||||||
|
<DropdownMenu.Dropdown>
|
||||||
|
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={`playtype-${type.play}`}
|
||||||
|
onClick={() => handlePlay(type.play)}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
<DropdownMenu.Divider />
|
||||||
|
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Dropdown>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
{showGenres && (
|
||||||
|
<Box component="section">
|
||||||
|
<Group>
|
||||||
|
{detailQuery?.data?.genres?.map((genre) => (
|
||||||
|
<Button
|
||||||
|
key={`genre-${genre.id}`}
|
||||||
|
compact
|
||||||
|
component={Link}
|
||||||
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
to={generatePath(`${AppRoute.LIBRARY_ALBUM_ARTISTS}?genre=${genre.id}`, {
|
||||||
|
albumArtistId,
|
||||||
|
})}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
{genre.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{showBiography ? (
|
||||||
|
<Box
|
||||||
|
component="section"
|
||||||
|
maw="1280px"
|
||||||
|
>
|
||||||
|
<TextTitle
|
||||||
|
fw="bold"
|
||||||
|
order={3}
|
||||||
|
>
|
||||||
|
About {detailQuery?.data?.name}
|
||||||
|
</TextTitle>
|
||||||
|
<Text
|
||||||
|
$secondary
|
||||||
|
component="p"
|
||||||
|
dangerouslySetInnerHTML={{ __html: detailQuery?.data?.biography || '' }}
|
||||||
|
sx={{ textAlign: 'justify' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
{showTopSongs && (
|
||||||
|
<Box component="section">
|
||||||
|
<Group
|
||||||
|
noWrap
|
||||||
|
position="apart"
|
||||||
|
>
|
||||||
|
<Group
|
||||||
|
noWrap
|
||||||
|
align="flex-end"
|
||||||
|
>
|
||||||
|
<TextTitle
|
||||||
|
fw="bold"
|
||||||
|
order={3}
|
||||||
|
>
|
||||||
|
Top Songs
|
||||||
|
</TextTitle>
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
uppercase
|
||||||
|
component={Link}
|
||||||
|
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS, {
|
||||||
|
albumArtistId,
|
||||||
|
})}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenu.Target>
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
uppercase
|
||||||
|
rightIcon={<RiArrowDownSLine size={20} />}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
Community
|
||||||
|
</Button>
|
||||||
|
</DropdownMenu.Target>
|
||||||
|
<DropdownMenu.Dropdown>
|
||||||
|
<DropdownMenu.Item>Community</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item>User</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Dropdown>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Group>
|
||||||
|
<VirtualTable
|
||||||
|
autoFitColumns
|
||||||
|
autoHeight
|
||||||
|
deselectOnClickOutside
|
||||||
|
suppressCellFocus
|
||||||
|
suppressHorizontalScroll
|
||||||
|
suppressLoadingOverlay
|
||||||
|
suppressRowDrag
|
||||||
|
columnDefs={topSongsColumnDefs}
|
||||||
|
enableCellChangeFlash={false}
|
||||||
|
getRowId={(data) => data.data.uniqueId}
|
||||||
|
rowData={topSongs}
|
||||||
|
rowHeight={60}
|
||||||
|
rowSelection="multiple"
|
||||||
|
onCellContextMenu={handleContextMenu}
|
||||||
|
onRowDoubleClicked={handleRowDoubleClick}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box component="section">
|
||||||
|
<Stack spacing="xl">
|
||||||
|
{carousels
|
||||||
|
.filter((c) => !c.isHidden)
|
||||||
|
.map((carousel) => (
|
||||||
|
<GridCarousel
|
||||||
|
key={`carousel-${carousel.uniqueId}`}
|
||||||
|
cardRows={cardRows[carousel.itemType as keyof typeof cardRows]}
|
||||||
|
containerWidth={cq.width}
|
||||||
|
data={carousel.data}
|
||||||
|
itemType={carousel.itemType}
|
||||||
|
loading={carousel.loading}
|
||||||
|
pagination={carousel.pagination}
|
||||||
|
uniqueId={carousel.uniqueId}
|
||||||
|
>
|
||||||
|
<GridCarousel.Title>{carousel.title}</GridCarousel.Title>
|
||||||
|
</GridCarousel>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</ContentContainer>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { Group, Stack } from '@mantine/core';
|
||||||
|
import { forwardRef, Fragment, Ref } from 'react';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
import { LibraryItem } from '/@/renderer/api/types';
|
||||||
|
import { Text } from '/@/renderer/components';
|
||||||
|
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
|
||||||
|
import { LibraryHeader } from '/@/renderer/features/shared';
|
||||||
|
import { useContainerQuery } from '/@/renderer/hooks';
|
||||||
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
|
import { formatDurationString } from '/@/renderer/utils';
|
||||||
|
|
||||||
|
interface AlbumArtistDetailHeaderProps {
|
||||||
|
background: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlbumArtistDetailHeader = forwardRef(
|
||||||
|
({ background }: AlbumArtistDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
|
||||||
|
const { albumArtistId } = useParams() as { albumArtistId: string };
|
||||||
|
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
|
||||||
|
const cq = useContainerQuery();
|
||||||
|
|
||||||
|
const metadataItems = [
|
||||||
|
{
|
||||||
|
id: 'albumCount',
|
||||||
|
secondary: false,
|
||||||
|
value: detailQuery?.data?.albumCount && `${detailQuery?.data?.albumCount} albums`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'songCount',
|
||||||
|
secondary: false,
|
||||||
|
value: detailQuery?.data?.songCount && `${detailQuery?.data?.songCount} songs`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'duration',
|
||||||
|
secondary: true,
|
||||||
|
value: detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('detailQuery?.data', detailQuery?.data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack ref={cq.ref}>
|
||||||
|
<LibraryHeader
|
||||||
|
ref={ref}
|
||||||
|
background={background}
|
||||||
|
imageUrl={detailQuery?.data?.imageUrl}
|
||||||
|
item={{ route: AppRoute.LIBRARY_ALBUM_ARTISTS, type: LibraryItem.ALBUM_ARTIST }}
|
||||||
|
title={detailQuery?.data?.name || ''}
|
||||||
|
>
|
||||||
|
<Stack mt="1rem">
|
||||||
|
<Group>
|
||||||
|
{metadataItems
|
||||||
|
.filter((i) => i.value)
|
||||||
|
.map((item, index) => (
|
||||||
|
<Fragment key={`item-${item.id}-${index}`}>
|
||||||
|
{index > 0 && <Text $noSelect>•</Text>}
|
||||||
|
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
<Group
|
||||||
|
sx={{
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
display: '-webkit-box',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</LibraryHeader>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import type { AlbumArtistDetailQuery, RawAlbumArtistDetailResponse } from '/@/renderer/api/types';
|
||||||
|
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||||
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
|
||||||
|
export const useAlbumArtistDetail = (query: AlbumArtistDetailQuery, options?: QueryOptions) => {
|
||||||
|
const server = useCurrentServer();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
enabled: !!server?.id && !!query.id,
|
||||||
|
queryFn: ({ signal }) => api.controller.getAlbumArtistDetail({ query, server, signal }),
|
||||||
|
queryKey: queryKeys.albumArtists.detail(server?.id || '', query),
|
||||||
|
select: useCallback(
|
||||||
|
(data: RawAlbumArtistDetailResponse | undefined) =>
|
||||||
|
api.normalize.albumArtistDetail(data, server),
|
||||||
|
[server],
|
||||||
|
),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
23
src/renderer/features/artists/queries/artist-info-query.ts
Normal file
23
src/renderer/features/artists/queries/artist-info-query.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import type { AlbumArtistDetailQuery, RawAlbumArtistDetailResponse } from '/@/renderer/api/types';
|
||||||
|
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||||
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
|
||||||
|
export const useAlbumArtistInfo = (query: AlbumArtistDetailQuery, options?: QueryOptions) => {
|
||||||
|
const server = useCurrentServer();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
enabled: !!server?.id && !!query.id,
|
||||||
|
queryFn: ({ signal }) => api.controller.getAlbumArtistDetail({ query, server, signal }),
|
||||||
|
queryKey: queryKeys.albumArtists.detail(server?.id || '', query),
|
||||||
|
select: useCallback(
|
||||||
|
(data: RawAlbumArtistDetailResponse | undefined) =>
|
||||||
|
api.normalize.albumArtistDetail(data, server),
|
||||||
|
[server],
|
||||||
|
),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import type { RawTopSongListResponse, TopSongListQuery } from '/@/renderer/api/types';
|
||||||
|
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||||
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
|
||||||
|
export const useTopSongsList = (query: TopSongListQuery, options?: QueryOptions) => {
|
||||||
|
const server = useCurrentServer();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
enabled: !!server?.id,
|
||||||
|
queryFn: ({ signal }) => api.controller.getTopSongList({ query, server, signal }),
|
||||||
|
queryKey: queryKeys.albumArtists.topSongs(server?.id || '', query),
|
||||||
|
select: useCallback(
|
||||||
|
(data: RawTopSongListResponse | undefined) => api.normalize.topSongList(data, server),
|
||||||
|
[server],
|
||||||
|
),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { NativeScrollArea } from '/@/renderer/components';
|
||||||
|
import { AnimatedPage, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
import { useFastAverageColor } from '/@/renderer/hooks';
|
||||||
|
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||||
|
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||||
|
import { LibraryItem } from '/@/renderer/api/types';
|
||||||
|
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
|
||||||
|
import { AlbumArtistDetailHeader } from '/@/renderer/features/artists/components/album-artist-detail-header';
|
||||||
|
import { AlbumArtistDetailContent } from '/@/renderer/features/artists/components/album-artist-detail-content';
|
||||||
|
|
||||||
|
const AlbumArtistDetailRoute = () => {
|
||||||
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||||
|
const headerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { albumArtistId } = useParams() as { albumArtistId: string };
|
||||||
|
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||||
|
const playButtonBehavior = usePlayButtonBehavior();
|
||||||
|
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
|
||||||
|
const background = useFastAverageColor(detailQuery.data?.imageUrl, !detailQuery.isLoading);
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
handlePlayQueueAdd?.({
|
||||||
|
byItemType: {
|
||||||
|
id: [albumArtistId],
|
||||||
|
type: LibraryItem.ALBUM_ARTIST,
|
||||||
|
},
|
||||||
|
play: playButtonBehavior,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (detailQuery.isLoading || !background) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedPage key={`album-artist-detail-${albumArtistId}`}>
|
||||||
|
<NativeScrollArea
|
||||||
|
ref={scrollAreaRef}
|
||||||
|
pageHeaderProps={{
|
||||||
|
backgroundColor: background,
|
||||||
|
children: (
|
||||||
|
<LibraryHeaderBar>
|
||||||
|
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
|
||||||
|
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
|
||||||
|
</LibraryHeaderBar>
|
||||||
|
),
|
||||||
|
target: headerRef,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlbumArtistDetailHeader
|
||||||
|
ref={headerRef}
|
||||||
|
background={background}
|
||||||
|
/>
|
||||||
|
<AlbumArtistDetailContent />
|
||||||
|
</NativeScrollArea>
|
||||||
|
</AnimatedPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AlbumArtistDetailRoute;
|
|
@ -1,10 +1,10 @@
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { HTTPError } from 'ky';
|
import { HTTPError } from 'ky';
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { JFAlbumDetail } from '/@/renderer/api/jellyfin.types';
|
import { JFAlbumArtistDetail, JFAlbumDetail } from '/@/renderer/api/jellyfin.types';
|
||||||
import { NDAlbumDetail } from '/@/renderer/api/navidrome.types';
|
import { NDAlbumArtistDetail, NDAlbumDetail } from '/@/renderer/api/navidrome.types';
|
||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
import { SSAlbumDetail } from '/@/renderer/api/subsonic.types';
|
import { SSAlbumArtistDetail, SSAlbumDetail } from '/@/renderer/api/subsonic.types';
|
||||||
import { FavoriteArgs, LibraryItem, RawFavoriteResponse, ServerType } from '/@/renderer/api/types';
|
import { FavoriteArgs, LibraryItem, RawFavoriteResponse, ServerType } from '/@/renderer/api/types';
|
||||||
import { MutationOptions } from '/@/renderer/lib/react-query';
|
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||||
import { useCurrentServer, useSetAlbumListItemDataById } from '/@/renderer/store';
|
import { useCurrentServer, useSetAlbumListItemDataById } from '/@/renderer/store';
|
||||||
|
@ -55,6 +55,40 @@ export const useCreateFavorite = (options?: MutationOptions) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We only need to set if we're already on the album detail page
|
||||||
|
if (variables.query.type === LibraryItem.ALBUM_ARTIST && variables.query.id.length === 1) {
|
||||||
|
const queryKey = queryKeys.albumArtists.detail(server?.id || '', {
|
||||||
|
id: variables.query.id[0],
|
||||||
|
});
|
||||||
|
const previous = queryClient.getQueryData<any>(queryKey);
|
||||||
|
|
||||||
|
if (previous) {
|
||||||
|
switch (server?.type) {
|
||||||
|
case ServerType.NAVIDROME:
|
||||||
|
queryClient.setQueryData<NDAlbumArtistDetail>(queryKey, {
|
||||||
|
...previous,
|
||||||
|
starred: true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case ServerType.SUBSONIC:
|
||||||
|
queryClient.setQueryData<SSAlbumArtistDetail>(queryKey, {
|
||||||
|
...previous,
|
||||||
|
starred: true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case ServerType.JELLYFIN:
|
||||||
|
queryClient.setQueryData<JFAlbumArtistDetail>(queryKey, {
|
||||||
|
...previous,
|
||||||
|
UserData: {
|
||||||
|
...previous.UserData,
|
||||||
|
IsFavorite: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
...options,
|
...options,
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { HTTPError } from 'ky';
|
import { HTTPError } from 'ky';
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { JFAlbumDetail } from '/@/renderer/api/jellyfin.types';
|
import { JFAlbumArtistDetail, JFAlbumDetail } from '/@/renderer/api/jellyfin.types';
|
||||||
import { NDAlbumDetail } from '/@/renderer/api/navidrome.types';
|
import { NDAlbumArtistDetail, NDAlbumDetail } from '/@/renderer/api/navidrome.types';
|
||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
import { SSAlbumDetail } from '/@/renderer/api/subsonic.types';
|
import { SSAlbumArtistDetail, SSAlbumDetail } from '/@/renderer/api/subsonic.types';
|
||||||
import { FavoriteArgs, LibraryItem, RawFavoriteResponse, ServerType } from '/@/renderer/api/types';
|
import { FavoriteArgs, LibraryItem, RawFavoriteResponse, ServerType } from '/@/renderer/api/types';
|
||||||
import { MutationOptions } from '/@/renderer/lib/react-query';
|
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||||
import { useCurrentServer, useSetAlbumListItemDataById } from '/@/renderer/store';
|
import { useCurrentServer, useSetAlbumListItemDataById } from '/@/renderer/store';
|
||||||
|
@ -55,6 +55,40 @@ export const useDeleteFavorite = (options?: MutationOptions) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We only need to set if we're already on the album detail page
|
||||||
|
if (variables.query.type === LibraryItem.ALBUM_ARTIST && variables.query.id.length === 1) {
|
||||||
|
const queryKey = queryKeys.albumArtists.detail(server?.id || '', {
|
||||||
|
id: variables.query.id[0],
|
||||||
|
});
|
||||||
|
const previous = queryClient.getQueryData<any>(queryKey);
|
||||||
|
|
||||||
|
if (previous) {
|
||||||
|
switch (server?.type) {
|
||||||
|
case ServerType.NAVIDROME:
|
||||||
|
queryClient.setQueryData<NDAlbumArtistDetail>(queryKey, {
|
||||||
|
...previous,
|
||||||
|
starred: false,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case ServerType.SUBSONIC:
|
||||||
|
queryClient.setQueryData<SSAlbumArtistDetail>(queryKey, {
|
||||||
|
...previous,
|
||||||
|
starred: false,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case ServerType.JELLYFIN:
|
||||||
|
queryClient.setQueryData<JFAlbumArtistDetail>(queryKey, {
|
||||||
|
...previous,
|
||||||
|
UserData: {
|
||||||
|
...previous.UserData,
|
||||||
|
IsFavorite: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
|
@ -234,9 +234,9 @@ export const Sidebar = () => {
|
||||||
Tracks
|
Tracks
|
||||||
</Group>
|
</Group>
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
<SidebarItem to={AppRoute.LIBRARY_ALBUMARTISTS}>
|
<SidebarItem to={AppRoute.LIBRARY_ALBUM_ARTISTS}>
|
||||||
<Group>
|
<Group>
|
||||||
{location.pathname === AppRoute.LIBRARY_ALBUMARTISTS ? (
|
{location.pathname === AppRoute.LIBRARY_ALBUM_ARTISTS ? (
|
||||||
<RiUserVoiceFill size="1.1em" />
|
<RiUserVoiceFill size="1.1em" />
|
||||||
) : (
|
) : (
|
||||||
<RiUserVoiceLine size="1.1em" />
|
<RiUserVoiceLine size="1.1em" />
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './queries/song-list-query';
|
|
@ -44,6 +44,10 @@ const AlbumArtistListRoute = lazy(
|
||||||
() => import('/@/renderer/features/artists/routes/album-artist-list-route'),
|
() => import('/@/renderer/features/artists/routes/album-artist-list-route'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const AlbumArtistDetailRoute = lazy(
|
||||||
|
() => import('/@/renderer/features/artists/routes/album-artist-detail-route'),
|
||||||
|
);
|
||||||
|
|
||||||
const AlbumDetailRoute = lazy(
|
const AlbumDetailRoute = lazy(
|
||||||
() => import('/@/renderer/features/albums/routes/album-detail-route'),
|
() => import('/@/renderer/features/albums/routes/album-detail-route'),
|
||||||
);
|
);
|
||||||
|
@ -108,10 +112,18 @@ export const AppRouter = () => {
|
||||||
path={AppRoute.PLAYLISTS_DETAIL_SONGS}
|
path={AppRoute.PLAYLISTS_DETAIL_SONGS}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
element={<AlbumArtistListRoute />}
|
|
||||||
errorElement={<RouteErrorBoundary />}
|
errorElement={<RouteErrorBoundary />}
|
||||||
path={AppRoute.LIBRARY_ALBUMARTISTS}
|
path={AppRoute.LIBRARY_ALBUM_ARTISTS}
|
||||||
/>
|
>
|
||||||
|
<Route
|
||||||
|
index
|
||||||
|
element={<AlbumArtistListRoute />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
element={<AlbumArtistDetailRoute />}
|
||||||
|
path={AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
element={<InvalidRoute />}
|
element={<InvalidRoute />}
|
||||||
path="*"
|
path="*"
|
||||||
|
|
Reference in a new issue