feishin/src/renderer/api/navidrome/navidrome-controller.ts

611 lines
21 KiB
TypeScript
Raw Normal View History

import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
2023-04-23 19:57:10 -07:00
import {
2023-07-01 19:10:05 -07:00
albumArtistListSortMap,
sortOrderMap,
AuthenticationResponse,
userListSortMap,
albumListSortMap,
songListSortMap,
playlistListSortMap,
PlaylistSongListArgs,
PlaylistSongListResponse,
2023-07-31 17:16:48 -07:00
genreListSortMap,
Song,
2024-09-26 04:23:08 +00:00
ControllerEndpoint,
2023-04-23 19:57:10 -07:00
} from '../types';
import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils';
2024-03-05 14:05:01 -08:00
import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types';
import { SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-types';
import { NDSongListSort } from '/@/renderer/api/navidrome.types';
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
2024-09-26 04:23:08 +00:00
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
const VERSION_INFO: VersionInfo = [
['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],
['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],
];
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
export const NavidromeController: ControllerEndpoint = {
addToPlaylist: async (args) => {
const { body, query, apiClientProps } = args;
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
const res = await ndApiClient(apiClientProps).addToPlaylist({
body: {
ids: body.songId,
},
params: {
id: query.id,
},
});
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
if (res.status !== 200) {
throw new Error('Failed to add to playlist');
}
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
return null;
},
authenticate: async (url, body): Promise<AuthenticationResponse> => {
const cleanServerUrl = url.replace(/\/$/, '');
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
const res = await ndApiClient({ server: null, url: cleanServerUrl }).authenticate({
body: {
password: body.password,
username: body.username,
},
});
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
if (res.status !== 200) {
throw new Error('Failed to authenticate');
}
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
return {
credential: `u=${body.username}&s=${res.body.data.subsonicSalt}&t=${res.body.data.subsonicToken}`,
ndCredential: res.body.data.token,
userId: res.body.data.id,
username: res.body.data.username,
};
},
createFavorite: SubsonicController.createFavorite,
createPlaylist: async (args) => {
const { body, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).createPlaylist({
body: {
comment: body.comment,
name: body.name,
public: body.public,
rules: body._custom?.navidrome?.rules,
sync: body._custom?.navidrome?.sync,
},
});
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
if (res.status !== 200) {
throw new Error('Failed to create playlist');
}
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
return {
id: res.body.data.id,
};
},
deleteFavorite: SubsonicController.deleteFavorite,
deletePlaylist: async (args) => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).deletePlaylist({
body: null,
params: {
id: query.id,
},
});
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
if (res.status !== 200) {
throw new Error('Failed to delete playlist');
}
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
return null;
},
getAlbumArtistDetail: async (args) => {
const { query, apiClientProps } = args;
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
const res = await ndApiClient(apiClientProps).getAlbumArtistDetail({
params: {
id: query.id,
},
});
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({
query: {
count: 10,
id: query.id,
},
});
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
if (res.status !== 200) {
throw new Error('Failed to get album artist detail');
}
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
if (!apiClientProps.server) {
throw new Error('Server is required');
}
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
// Prefer images from getArtistInfo first (which should be proxied)
// Prioritize large > medium > small
return ndNormalize.albumArtist(
{
...res.body.data,
...(artistInfoRes.status === 200 && {
largeImageUrl:
artistInfoRes.body.artistInfo.largeImageUrl ||
artistInfoRes.body.artistInfo.mediumImageUrl ||
artistInfoRes.body.artistInfo.smallImageUrl ||
res.body.data.largeImageUrl,
similarArtists: artistInfoRes.body.artistInfo.similarArtist,
}),
},
apiClientProps.server,
);
},
getAlbumArtistList: async (args) => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getAlbumArtistList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumArtistListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
name: query.searchTerm,
...query._custom?.navidrome,
},
});
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
if (res.status !== 200) {
throw new Error('Failed to get album artist list');
}
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
return {
items: res.body.data.map((albumArtist) =>
// Navidrome native API will return only external URL small/medium/large
// image URL. Set large image to undefined to force `albumArtist` to use
// /rest/getCoverArt.view?id=ar-...
ndNormalize.albumArtist(
{
...albumArtist,
largeImageUrl: undefined,
},
apiClientProps.server,
),
),
startIndex: query.startIndex,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
},
getAlbumArtistListCount: async ({ apiClientProps, query }) =>
NavidromeController.getAlbumArtistList({
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getAlbumDetail: async (args) => {
const { query, apiClientProps } = args;
const albumRes = await ndApiClient(apiClientProps).getAlbumDetail({
params: {
id: query.id,
},
});
const songsData = await ndApiClient(apiClientProps).getSongList({
query: {
_end: 0,
_order: 'ASC',
_sort: NDSongListSort.ALBUM,
_start: 0,
album_id: [query.id],
},
});
if (albumRes.status !== 200 || songsData.status !== 200) {
throw new Error('Failed to get album detail');
}
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
return ndNormalize.album(
{ ...albumRes.body.data, songs: songsData.body.data },
apiClientProps.server,
);
},
getAlbumList: async (args) => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getAlbumList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
artist_id: query.artistIds?.[0],
compilation: query.compilation,
genre_id: query.genres?.[0],
name: query.searchTerm,
...query._custom?.navidrome,
starred: query.favorite,
},
});
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
if (res.status !== 200) {
throw new Error('Failed to get album list');
}
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
return {
items: res.body.data.map((album) => ndNormalize.album(album, apiClientProps.server)),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
},
getAlbumListCount: async ({ apiClientProps, query }) =>
NavidromeController.getAlbumList({
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getDownloadUrl: SubsonicController.getDownloadUrl,
getGenreList: async (args) => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getGenreList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: genreListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
name: query.searchTerm,
},
});
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
if (res.status !== 200) {
throw new Error('Failed to get genre list');
}
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
return {
items: res.body.data.map((genre) => ndNormalize.genre(genre)),
startIndex: query.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
},
getLyrics: SubsonicController.getLyrics,
getMusicFolderList: SubsonicController.getMusicFolderList,
getPlaylistDetail: async (args) => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getPlaylistDetail({
params: {
id: query.id,
},
});
2023-07-01 19:10:05 -07:00
2024-09-26 04:23:08 +00:00
if (res.status !== 200) {
throw new Error('Failed to get playlist detail');
}
2023-07-01 19:10:05 -07:00
2024-09-26 04:23:08 +00:00
return ndNormalize.playlist(res.body.data, apiClientProps.server);
},
getPlaylistList: async (args) => {
const { query, apiClientProps } = args;
const customQuery = query._custom?.navidrome;
// Smart playlists only became available in 0.48.0. Do not filter for previous versions
if (
customQuery &&
customQuery.smart !== undefined &&
!hasFeature(apiClientProps.server, ServerFeature.PLAYLISTS_SMART)
) {
customQuery.smart = undefined;
}
2023-07-01 19:10:05 -07:00
2024-09-26 04:23:08 +00:00
const res = await ndApiClient(apiClientProps).getPlaylistList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
_start: query.startIndex,
q: query.searchTerm,
...customQuery,
},
});
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
if (res.status !== 200) {
throw new Error('Failed to get playlist list');
}
2023-04-23 19:57:10 -07:00
2024-09-26 04:23:08 +00:00
return {
items: res.body.data.map((item) => ndNormalize.playlist(item, apiClientProps.server)),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
},
getPlaylistListCount: async ({ apiClientProps, query }) =>
NavidromeController.getPlaylistList({
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getPlaylistSongList: async (args: PlaylistSongListArgs): Promise<PlaylistSongListResponse> => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getPlaylistSongList({
params: {
id: query.id,
},
query: {
2024-09-28 21:22:14 -07:00
_end: query.startIndex + (query.limit || -1),
2024-09-26 04:23:08 +00:00
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC',
_sort: query.sortBy
? songListSortMap.navidrome[query.sortBy]
: ndType._enum.songList.ID,
_start: query.startIndex,
},
});
2024-09-26 04:23:08 +00:00
if (res.status !== 200) {
throw new Error('Failed to get playlist song list');
}
2024-09-26 04:23:08 +00:00
return {
2025-01-24 16:36:06 -08:00
items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server)),
2024-09-26 04:23:08 +00:00
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
},
getRandomSongList: SubsonicController.getRandomSongList,
getServerInfo: async (args) => {
const { apiClientProps } = args;
// Navidrome will always populate serverVersion
const ping = await ssApiClient(apiClientProps).ping();
if (ping.status !== 200) {
throw new Error('Failed to ping server');
}
2024-09-26 04:23:08 +00:00
const navidromeFeatures: Record<string, number[]> = getFeatures(
VERSION_INFO,
ping.body.serverVersion!,
);
2024-09-26 04:23:08 +00:00
if (ping.body.openSubsonic) {
const res = await ssApiClient(apiClientProps).getServerInfo();
2024-09-26 04:23:08 +00:00
if (res.status !== 200) {
throw new Error('Failed to get server extensions');
}
2024-09-26 04:23:08 +00:00
// The type here isn't necessarily an array (even though it's supposed to be). This is
// an implementation detail of Navidrome 0.50. Do a type check to make sure it's actually
// an array, and not an empty object.
if (Array.isArray(res.body.openSubsonicExtensions)) {
for (const extension of res.body.openSubsonicExtensions) {
navidromeFeatures[extension.name] = extension.versions;
}
}
}
2024-09-26 04:23:08 +00:00
const features: ServerFeatures = {
lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS],
playlistsSmart: !!navidromeFeatures[ServerFeature.PLAYLISTS_SMART],
publicPlaylist: true,
sharingAlbumSong: !!navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG],
};
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! };
},
getSimilarSongs: async (args) => {
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) {
2025-01-24 16:36:06 -08:00
acc.push(ssNormalize.song(song, apiClientProps.server));
2024-09-26 04:23:08 +00:00
}
return acc;
}, []);
if (similar.length > 0) {
return similar;
}
}
2024-03-03 22:15:49 -08:00
2024-09-26 04:23:08 +00:00
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');
}
2024-09-26 04:23:08 +00:00
return fallback.body.data.reduce<Song[]>((acc, song) => {
if (song.id !== query.songId) {
2025-01-24 16:36:06 -08:00
acc.push(ndNormalize.song(song, apiClientProps.server));
}
return acc;
}, []);
2024-09-26 04:23:08 +00:00
},
getSongDetail: async (args) => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getSongDetail({
params: {
id: query.id,
},
});
2024-09-26 04:23:08 +00:00
if (res.status !== 200) {
throw new Error('Failed to get song detail');
}
2024-09-26 04:23:08 +00:00
2025-01-24 16:36:06 -08:00
return ndNormalize.song(res.body.data, apiClientProps.server);
2024-09-26 04:23:08 +00:00
},
getSongList: async (args) => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getSongList({
query: {
_end: query.startIndex + (query.limit || -1),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: songListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
album_artist_id: query.artistIds,
album_id: query.albumIds,
genre_id: query.genreIds,
starred: query.favorite,
title: query.searchTerm,
...query._custom?.navidrome,
},
});
if (res.status !== 200) {
throw new Error('Failed to get song list');
}
2024-09-26 04:23:08 +00:00
return {
items: res.body.data.map((song) =>
2025-01-24 16:36:06 -08:00
ndNormalize.song(song, apiClientProps.server, query.imageSize),
2024-09-26 04:23:08 +00:00
),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
},
getSongListCount: async ({ apiClientProps, query }) =>
NavidromeController.getSongList({
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getStructuredLyrics: SubsonicController.getStructuredLyrics,
2024-09-26 04:23:08 +00:00
getTopSongs: SubsonicController.getTopSongs,
getTranscodingUrl: SubsonicController.getTranscodingUrl,
getUserList: async (args) => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getUserList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: userListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
...query._custom?.navidrome,
},
});
2024-09-26 04:23:08 +00:00
if (res.status !== 200) {
throw new Error('Failed to get user list');
}
return {
items: res.body.data.map((user) => ndNormalize.user(user)),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
},
movePlaylistItem: async (args) => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).movePlaylistItem({
body: {
insert_before: (query.endingIndex + 1).toString(),
},
params: {
playlistId: query.playlistId,
trackNumber: query.startingIndex.toString(),
},
});
if (res.status !== 200) {
throw new Error('Failed to move item in playlist');
}
},
removeFromPlaylist: async (args) => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).removeFromPlaylist({
body: null,
params: {
id: query.id,
},
query: {
id: query.songId,
},
});
if (res.status !== 200) {
throw new Error('Failed to remove from playlist');
}
return null;
},
scrobble: SubsonicController.scrobble,
search: SubsonicController.search,
setRating: SubsonicController.setRating,
shareItem: async (args) => {
const { body, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).shareItem({
body: {
description: body.description,
downloadable: body.downloadable,
expires: body.expires,
resourceIds: body.resourceIds,
resourceType: body.resourceType,
},
});
if (res.status !== 200) {
throw new Error('Failed to share item');
}
return {
id: res.body.data.id,
};
},
updatePlaylist: async (args) => {
const { query, body, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).updatePlaylist({
body: {
comment: body.comment || '',
name: body.name,
public: body?.public || false,
rules: body._custom?.navidrome?.rules ? body._custom.navidrome.rules : undefined,
sync: body._custom?.navidrome?.sync || undefined,
},
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to update playlist');
}
2024-08-25 15:21:56 -07:00
2024-09-26 04:23:08 +00:00
return null;
},
2023-04-23 19:57:10 -07:00
};