Add initial album artist detail route

This commit is contained in:
jeffvli 2023-01-12 18:43:25 -08:00
parent 55e2a9bf37
commit 9b8bcb05bd
21 changed files with 1000 additions and 27 deletions

View file

@ -37,6 +37,8 @@ import type {
UserListArgs,
RawUserListResponse,
FavoriteArgs,
TopSongListArgs,
RawTopSongListResponse,
} from '/@/renderer/api/types';
import { subsonicApi } from '/@/renderer/api/subsonic.api';
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
@ -52,6 +54,7 @@ export type ControllerEndpoint = Partial<{
getAlbumDetail: (args: AlbumDetailArgs) => Promise<RawAlbumDetailResponse>;
getAlbumList: (args: AlbumListArgs) => Promise<RawAlbumListResponse>;
getArtistDetail: () => void;
getArtistInfo: (args: any) => void;
getArtistList: (args: ArtistListArgs) => Promise<RawArtistListResponse>;
getFavoritesList: () => void;
getFolderItemList: () => void;
@ -64,6 +67,7 @@ export type ControllerEndpoint = Partial<{
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<RawSongListResponse>;
getSongDetail: (args: SongDetailArgs) => Promise<RawSongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<RawSongListResponse>;
getTopSongs: (args: TopSongListArgs) => Promise<RawTopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<RawUserListResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
@ -87,6 +91,7 @@ const endpoints: ApiController = {
getAlbumDetail: jellyfinApi.getAlbumDetail,
getAlbumList: jellyfinApi.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: jellyfinApi.getArtistList,
getFavoritesList: undefined,
getFolderItemList: undefined,
@ -99,6 +104,7 @@ const endpoints: ApiController = {
getPlaylistSongList: jellyfinApi.getPlaylistSongList,
getSongDetail: undefined,
getSongList: jellyfinApi.getSongList,
getTopSongs: undefined,
getUserList: undefined,
updatePlaylist: jellyfinApi.updatePlaylist,
updateRating: undefined,
@ -114,6 +120,7 @@ const endpoints: ApiController = {
getAlbumDetail: navidromeApi.getAlbumDetail,
getAlbumList: navidromeApi.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
@ -126,6 +133,7 @@ const endpoints: ApiController = {
getPlaylistSongList: navidromeApi.getPlaylistSongList,
getSongDetail: navidromeApi.getSongDetail,
getSongList: navidromeApi.getSongList,
getTopSongs: subsonicApi.getTopSongList,
getUserList: navidromeApi.getUserList,
updatePlaylist: navidromeApi.updatePlaylist,
updateRating: subsonicApi.updateRating,
@ -141,6 +149,7 @@ const endpoints: ApiController = {
getAlbumDetail: subsonicApi.getAlbumDetail,
getAlbumList: subsonicApi.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
@ -152,6 +161,7 @@ const endpoints: ApiController = {
getPlaylistList: undefined,
getSongDetail: undefined,
getSongList: undefined,
getTopSongs: subsonicApi.getTopSongList,
getUserList: undefined,
updatePlaylist: undefined,
updateRating: undefined,
@ -255,6 +265,10 @@ const updateRating = async (args: RatingArgs) => {
return (apiController('updateRating') as ControllerEndpoint['updateRating'])?.(args);
};
const getTopSongList = async (args: TopSongListArgs) => {
return (apiController('getTopSongs') as ControllerEndpoint['getTopSongs'])?.(args);
};
export const controller = {
createFavorite,
createPlaylist,
@ -271,6 +285,7 @@ export const controller = {
getPlaylistList,
getPlaylistSongList,
getSongList,
getTopSongList,
getUserList,
updatePlaylist,
updateRating,

View file

@ -140,7 +140,7 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<JFAlbu
const { query, server, signal } = args;
const searchParams = {
fields: 'Genres',
fields: 'Genres, Overview',
};
const data = await api
@ -152,7 +152,16 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<JFAlbu
})
.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 = () => {
@ -642,10 +651,14 @@ const normalizeSong = (
): Song => {
return {
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,
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)),
bpm: null,
channels: null,
@ -691,9 +704,10 @@ const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: numbe
albumArtists:
item.AlbumArtists.map((entry) => ({
id: entry.Id,
imageUrl: null,
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,
createdAt: item.DateCreated,
duration: item.RunTimeTicks / 10000,
@ -747,6 +761,17 @@ const normalizeAlbumArtist = (
playCount: item.UserData.PlayCount,
serverId: server.id,
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,
userFavorite: item.UserData.IsFavorite || false,
userRating: null,

View file

@ -173,6 +173,10 @@ export type JFAlbumArtist = {
PlaybackPositionTicks: number;
Played: boolean;
};
} & {
similarArtists: {
items: JFAlbumArtist[];
};
};
export type JFArtist = {

View file

@ -78,6 +78,7 @@ import { toast } from '/@/renderer/components/toast';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem, ServerType } from '/@/renderer/types';
import { parseSearchParams } from '/@/renderer/utils';
import { subsonicApi } from '/@/renderer/api/subsonic.api';
const api = ky.create({
hooks: {
@ -183,6 +184,15 @@ const getGenreList = async (args: GenreListArgs): Promise<NDGenreList> => {
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<NDAlbumArtistDetail> => {
const { query, server, signal } = args;
const artistInfo = await subsonicApi.getArtistInfo({
query: {
artistId: query.id,
limit: 15,
},
server,
signal,
});
const data = await api
.get(`api/artist/${query.id}`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
@ -191,7 +201,7 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<NDAlbu
})
.json<NDAlbumArtistDetailResponse>();
return { ...data };
return { ...data, similarArtists: artistInfo.similarArtist };
};
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<NDAlbumArtistList> => {
@ -510,10 +520,10 @@ const normalizeSong = (
return {
album: item.album,
albumArtists: [{ id: item.artistId, name: item.artist }],
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
albumId: item.albumId,
artistName: item.artist,
artists: [{ id: item.artistId, name: item.artist }],
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
bitRate: item.bitRate,
bpm: item.bpm ? item.bpm : 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;
return {
albumArtists: [{ id: item.albumArtistId, name: item.albumArtist }],
artists: [{ id: item.artistId, name: item.artist }],
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
backdropImageUrl: imageBackdropUrl,
createdAt: item.createdAt.split('T')[0],
duration: item.duration * 1000 || null,
@ -602,6 +612,12 @@ const normalizeAlbumArtist = (item: NDAlbumArtist, server: ServerListItem): Albu
playCount: item.playCount,
serverId: server.id,
serverType: ServerType.NAVIDROME,
similarArtists:
item.similarArtists?.map((artist) => ({
id: artist.id,
imageUrl: artist?.artistImageUrl || null,
name: artist.name,
})) || null,
songCount: item.songCount,
userFavorite: item.starred,
userRating: item.rating,

View file

@ -1,3 +1,5 @@
import { SSArtistInfo } from '/@/renderer/api/subsonic.types';
export type NDAuthenticate = {
id: string;
isAdmin: boolean;
@ -126,6 +128,8 @@ export type NDAlbumArtist = {
songCount: number;
starred: boolean;
starredAt: string;
} & {
similarArtists?: SSArtistInfo['similarArtist'];
};
export type NDAuthenticationResponse = NDAuthenticate;

View file

@ -16,7 +16,8 @@ import type {
NDSong,
NDUser,
} 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 {
Album,
AlbumArtist,
@ -29,6 +30,7 @@ import type {
RawPlaylistDetailResponse,
RawPlaylistListResponse,
RawSongListResponse,
RawTopSongListResponse,
RawUserListResponse,
} from '/@/renderer/api/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 = (
data: RawMusicFolderListResponse | undefined,
server: ServerListItem | null,
@ -265,5 +286,6 @@ export const normalize = {
playlistDetail,
playlistList,
songList,
topSongList,
userList,
};

View file

@ -9,6 +9,7 @@ import type {
PlaylistSongListQuery,
UserListQuery,
AlbumArtistDetailQuery,
TopSongListQuery,
} from './types';
export const queryKeys = {
@ -22,6 +23,10 @@ export const queryKeys = {
return [serverId, 'albumArtists', 'list'] 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: {
detail: (serverId: string, query?: AlbumDetailQuery) =>

View file

@ -19,12 +19,20 @@ import type {
SSRatingParams,
SSAlbumArtistDetailParams,
SSAlbumArtistListParams,
SSTopSongListParams,
SSTopSongListResponse,
SSArtistInfoParams,
SSArtistInfoResponse,
SSArtistInfo,
SSSong,
SSTopSongList,
} from '/@/renderer/api/subsonic.types';
import {
AlbumArtistDetailArgs,
AlbumArtistListArgs,
AlbumDetailArgs,
AlbumListArgs,
ArtistInfoArgs,
AuthenticationResponse,
FavoriteArgs,
FavoriteResponse,
@ -34,8 +42,12 @@ import {
RatingArgs,
RatingResponse,
ServerListItem,
ServerType,
Song,
TopSongListArgs,
} from '/@/renderer/api/types';
import { toast } from '/@/renderer/components/toast';
import { nanoid } from 'nanoid/non-secure';
const getCoverArtUrl = (args: {
baseUrl: string;
@ -50,7 +62,7 @@ const getCoverArtUrl = (args: {
}
return (
`${args.baseUrl}/getCoverArt.view` +
`${args.baseUrl}/rest/getCoverArt.view` +
`?id=${args.coverArtId}` +
`&${args.credential}` +
'&v=1.13.0' +
@ -65,10 +77,13 @@ const api = ky.create({
async (_request, _options, response) => {
const data = await response.json();
if (data['subsonic-response'].status !== 'ok') {
toast.error({
message: data['subsonic-response'].error.message,
title: 'Issue from Subsonic API',
});
// Suppress code related to non-linked lastfm or spotify from Navidrome
if (data['subsonic-response'].error.code !== 0) {
toast.error({
message: data['subsonic-response'].error.message,
title: 'Issue from Subsonic API',
});
}
}
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 = {
authenticate,
createFavorite,
@ -333,8 +460,14 @@ export const subsonicApi = {
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getArtistInfo,
getCoverArtUrl,
getGenreList,
getMusicFolderList,
getTopSongList,
updateRating,
};
export const ssNormalize = {
song: normalizeSong,
};

View file

@ -65,6 +65,12 @@ export type SSAlbumDetailResponse = {
album: SSAlbum;
};
export type SSArtistInfoParams = {
count?: number;
id: string;
includeNotPresent?: boolean;
};
export type SSArtistInfoResponse = {
artistInfo2: SSArtistInfo;
};
@ -75,6 +81,13 @@ export type SSArtistInfo = {
lastFmUrl?: string;
mediumImageUrl?: string;
musicBrainzId?: string;
similarArtist?: {
albumCount: string;
artistImageUrl?: string;
coverArt?: string;
id: string;
name: string;
}[];
smallImageUrl?: string;
};
@ -186,3 +199,20 @@ export type SSRatingParams = {
export type SSRating = 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;
};

View file

@ -43,6 +43,7 @@ import {
SSAlbumArtistDetail,
SSMusicFolderList,
SSGenreList,
SSTopSongList,
} from '/@/renderer/api/subsonic.types';
export enum LibraryItem {
@ -231,6 +232,7 @@ export type AlbumArtist = {
playCount: number | null;
serverId: string;
serverType: ServerType;
similarArtists: RelatedArtist[] | null;
songCount: number | null;
userFavorite: boolean;
userRating: number | null;
@ -256,6 +258,7 @@ export type Artist = {
export type RelatedArtist = {
id: string;
imageUrl: string | null;
name: string;
};
@ -959,3 +962,24 @@ export const userListSortMap: UserListSortMap = {
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;