feat: add artist radio and track radio (in context menu) (#1437)

* Add API support for artist radio and track radio features

* Add translation strings and settings UI for artist radio count

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
farfromrefuge
2025-12-24 05:46:19 +01:00
committed by GitHub
parent dcb84dd442
commit a322717e0e
22 changed files with 18700 additions and 4 deletions
+14
View File
@@ -320,6 +320,20 @@ export const controller: GeneralController = {
query: mergeMusicFolderId(args.query, server),
});
},
getArtistRadio(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getArtistRadio`,
);
}
return apiController(
'getArtistRadio',
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
getDownloadUrl(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -426,6 +426,27 @@ export const JellyfinController: InternalControllerEndpoint = {
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getArtistRadio: async (args) => {
const { apiClientProps, query } = args;
// For Jellyfin, use instant mix for artist radio
const res = await jfApiClient(apiClientProps).getInstantMix({
params: {
itemId: query.artistId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ParentId',
Limit: query.count,
UserId: apiClientProps.server?.userId || undefined,
},
});
if (res.status !== 200) {
throw new Error('Failed to get artist radio songs');
}
return res.body.Items.map((song) => jfNormalize.song(song, apiClientProps.server));
},
getDownloadUrl: (args) => {
const { apiClientProps, query } = args;
@@ -401,6 +401,32 @@ export const NavidromeController: InternalControllerEndpoint = {
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getArtistRadio: async (args) => {
const { apiClientProps, query } = args;
// Use getSimilarSongs2 API for artist radio
const res = await ssApiClient({
...apiClientProps,
silent: true,
}).getSimilarSongs2({
query: {
count: query.count,
id: query.artistId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get artist radio songs');
}
if (!res.body.similarSongs2?.song) {
return [];
}
return res.body.similarSongs2.song.map((song) =>
ssNormalize.song(song, apiClientProps.server),
);
},
getDownloadUrl: SubsonicController.getDownloadUrl,
getFolder: SubsonicController.getFolder,
getGenreList: async (args) => {
+5
View File
@@ -4,6 +4,7 @@ import type {
AlbumDetailQuery,
AlbumListQuery,
ArtistListQuery,
ArtistRadioQuery,
FolderQuery,
GenreListQuery,
LyricSearchQuery,
@@ -340,6 +341,10 @@ export const queryKeys: Record<
root: (serverId: string) => [serverId] as const,
},
songs: {
artistRadio: (serverId: string, query?: ArtistRadioQuery) => {
if (query) return [serverId, 'songs', 'artistRadio', query] as const;
return [serverId, 'songs', 'artistRadio'] as const;
},
count: (serverId: string, query?: SongListQuery) => {
const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) {
@@ -201,6 +201,14 @@ export const contract = c.router({
200: ssType._response.similarSongs,
},
},
getSimilarSongs2: {
method: 'GET',
path: 'getSimilarSongs2',
query: ssType._parameters.similarSongs2,
responses: {
200: ssType._response.similarSongs2,
},
},
getSong: {
method: 'GET',
path: 'getSong.view',
@@ -682,6 +682,28 @@ export const SubsonicController: InternalControllerEndpoint = {
...args,
query: { ...args.query, startIndex: 0 },
}).then((res) => res!.totalRecordCount!),
getArtistRadio: async (args) => {
const { apiClientProps, query } = args;
const res = await ssApiClient(apiClientProps).getSimilarSongs2({
query: {
count: query.count,
id: query.artistId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get artist radio songs');
}
if (!res.body.similarSongs2?.song) {
return [];
}
return res.body.similarSongs2.song.map((song) =>
ssNormalize.song(song, apiClientProps.server),
);
},
getDownloadUrl: (args) => {
const { apiClientProps, query } = args;
@@ -880,6 +902,7 @@ export const SubsonicController: InternalControllerEndpoint = {
totalRecordCount: res.body.musicFolders.musicFolder.length,
};
},
getPlaylistDetail: async (args) => {
const { apiClientProps, query } = args;
@@ -895,7 +918,6 @@ export const SubsonicController: InternalControllerEndpoint = {
return ssNormalize.playlist(res.body.playlist, apiClientProps.server);
},
getPlaylistList: async ({ apiClientProps, query }) => {
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';