diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cef04ee90..a8ee4330a 100755 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -637,6 +637,7 @@ "addNext": "next", "addLastShuffled": "last (shuffled)", "addNextShuffled": "next (shuffled)", + "albumRadio": "album radio", "artistRadio": "artist radio", "holdToShuffle": "hold to shuffle", "favorite": "favorite", diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index dffa34e78..4c474d70e 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -308,6 +308,20 @@ export const controller: GeneralController = { }), ); }, + getAlbumRadio(args) { + const server = getServerById(args.apiClientProps.serverId); + + if (!server) { + throw new Error( + `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getAlbumRadio`, + ); + } + + return apiController( + 'getAlbumRadio', + server.type, + )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); + }, getArtistList(args) { const server = getServerById(args.apiClientProps.serverId); diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 1c4bb0e48..50fec66b1 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -433,6 +433,34 @@ export const JellyfinController: InternalControllerEndpoint = { apiClientProps, query: { ...query, limit: 1, startIndex: 0 }, }).then((result) => result!.totalRecordCount!), + getAlbumRadio: async (args) => { + const { apiClientProps, query } = args; + + // For Jellyfin, use instant mix for album radio + const res = await jfApiClient(apiClientProps).getInstantMix({ + params: { + itemId: query.albumId, + }, + query: { + Fields: JF_FIELDS.SONG, + Limit: query.count, + UserId: apiClientProps.server?.userId || undefined, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album radio songs'); + } + + return res.body.Items.map((song) => + jfNormalize.song( + song, + apiClientProps.server, + args.context?.pathReplace, + args.context?.pathReplaceWith, + ), + ); + }, getArtistList: async (args) => { const { apiClientProps, query } = args; diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 73c382d88..cf9a07f8d 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -376,6 +376,32 @@ export const NavidromeController: InternalControllerEndpoint = { apiClientProps, query: { ...query, limit: 1, startIndex: 0 }, }).then((result) => result!.totalRecordCount!), + getAlbumRadio: async (args) => { + const { apiClientProps, query } = args; + + // Use getSimilarSongs API for album radio + const res = await ssApiClient({ + ...apiClientProps, + silent: true, + }).getSimilarSongs({ + query: { + count: query.count, + id: query.albumId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album radio songs'); + } + + if (!res.body.similarSongs?.song) { + return []; + } + + return res.body.similarSongs.song.map((song) => + ssNormalize.song(song, apiClientProps.server), + ); + }, getArtistList: async (args) => { const { apiClientProps, query } = args; diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 6c97d38bb..649b97767 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -3,6 +3,7 @@ import type { AlbumArtistListQuery, AlbumDetailQuery, AlbumListQuery, + AlbumRadioQuery, ArtistListQuery, ArtistRadioQuery, FolderQuery, @@ -348,6 +349,10 @@ export const queryKeys: Record< root: (serverId: string) => [serverId] as const, }, songs: { + albumRadio: (serverId: string, query?: AlbumRadioQuery) => { + if (query) return [serverId, 'songs', 'albumRadio', query] as const; + return [serverId, 'songs', 'albumRadio'] as const; + }, artistRadio: (serverId: string, query?: ArtistRadioQuery) => { if (query) return [serverId, 'songs', 'artistRadio', query] as const; return [serverId, 'songs', 'artistRadio'] as const; diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index d97151add..b2bea65ba 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -676,6 +676,33 @@ export const SubsonicController: InternalControllerEndpoint = { return totalRecordCount; }, + getAlbumRadio: async (args) => { + const { apiClientProps, context, query } = args; + + const res = await ssApiClient(apiClientProps).getSimilarSongs({ + query: { + count: query.count, + id: query.albumId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album radio songs'); + } + + if (!res.body.similarSongs?.song) { + return []; + } + + return res.body.similarSongs.song.map((song) => + ssNormalize.song( + song, + apiClientProps.server, + context?.pathReplace, + context?.pathReplaceWith, + ), + ); + }, getArtistList: async (args) => { const { apiClientProps, query } = args; diff --git a/src/renderer/features/albums/components/album-detail-header.tsx b/src/renderer/features/albums/components/album-detail-header.tsx index b15ff85c3..5311c03e1 100644 --- a/src/renderer/features/albums/components/album-detail-header.tsx +++ b/src/renderer/features/albums/components/album-detail-header.tsx @@ -1,10 +1,11 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { forwardRef, Fragment, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useParams } from 'react-router'; import styles from './album-detail-header.module.css'; +import { queryKeys } from '/@/renderer/api/query-keys'; import { albumQueries } from '/@/renderer/features/albums/api/album-api'; import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; @@ -15,9 +16,10 @@ import { } from '/@/renderer/features/shared/components/library-header'; import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite'; import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating'; +import { songsQueries } from '/@/renderer/features/songs/api/songs-api'; import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer, useShowRatings } from '/@/renderer/store'; -import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; +import { useArtistRadioCount, usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils'; import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types'; import { Group } from '/@/shared/components/group/group'; @@ -32,6 +34,8 @@ export const AlbumDetailHeader = forwardRef((_props, ref) => { const { t } = useTranslation(); const server = useCurrentServer(); const showRatings = useShowRatings(); + const queryClient = useQueryClient(); + const albumRadioCount = useArtistRadioCount(); const detailQuery = useQuery( albumQueries.detail({ query: { id: albumId }, serverId: server?.id }), ); @@ -41,7 +45,7 @@ export const AlbumDetailHeader = forwardRef((_props, ref) => { (detailQuery?.data?._serverType === ServerType.NAVIDROME || detailQuery?.data?._serverType === ServerType.SUBSONIC); - const { addToQueueByFetch } = usePlayer(); + const { addToQueueByData, addToQueueByFetch } = usePlayer(); const playButtonBehavior = usePlayButtonBehavior(); const setRating = useSetRating(); @@ -92,6 +96,28 @@ export const AlbumDetailHeader = forwardRef((_props, ref) => { }); }; + const handleAlbumRadio = async () => { + if (!server?.id || !albumId) return; + + try { + const albumRadioSongs = await queryClient.fetchQuery({ + ...songsQueries.albumRadio({ + query: { + albumId: albumId, + count: albumRadioCount, + }, + serverId: server.id, + }), + queryKey: queryKeys.player.fetch({ albumId: albumId }), + }); + if (albumRadioSongs && albumRadioSongs.length > 0) { + addToQueueByData(albumRadioSongs, Play.NOW); + } + } catch (error) { + console.error('Failed to load album radio:', error); + } + }; + const releaseYear = detailQuery?.data?.releaseYear; const releaseDate = detailQuery?.data?.releaseDate; @@ -249,6 +275,7 @@ export const AlbumDetailHeader = forwardRef((_props, ref) => { handlePlay(type)} diff --git a/src/renderer/features/context-menu/actions/play-album-radio-action.tsx b/src/renderer/features/context-menu/actions/play-album-radio-action.tsx new file mode 100644 index 000000000..ed056cd29 --- /dev/null +++ b/src/renderer/features/context-menu/actions/play-album-radio-action.tsx @@ -0,0 +1,92 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { queryKeys } from '/@/renderer/api/query-keys'; +import { usePlayer } from '/@/renderer/features/player/context/player-context'; +import { songsQueries } from '/@/renderer/features/songs/api/songs-api'; +import { useArtistRadioCount, useCurrentServerId, usePlayButtonBehavior } from '/@/renderer/store'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { Album } from '/@/shared/types/domain-types'; +import { Play } from '/@/shared/types/types'; + +interface PlayAlbumRadioActionProps { + album: Album; + disabled?: boolean; +} + +export const PlayAlbumRadioAction = ({ album, disabled }: PlayAlbumRadioActionProps) => { + const albumRadioCount = useArtistRadioCount(); // Reuse the same setting for album radio + const { t } = useTranslation(); + const player = usePlayer(); + const serverId = useCurrentServerId(); + const queryClient = useQueryClient(); + const playButtonBehavior = usePlayButtonBehavior(); + + const handlePlayAlbumRadio = useCallback( + async (playType: Play) => { + if (!serverId || !album) return; + + try { + const albumRadioSongs = await queryClient.fetchQuery({ + ...songsQueries.albumRadio({ + query: { + albumId: album.id, + count: albumRadioCount, + }, + serverId: serverId, + }), + queryKey: queryKeys.player.fetch({ albumId: album.id }), + }); + if (albumRadioSongs && albumRadioSongs.length > 0) { + player.addToQueueByData(albumRadioSongs, playType); + } + } catch (error) { + console.error('Failed to load album radio:', error); + } + }, + [album, albumRadioCount, player, queryClient, serverId], + ); + + const handlePlayAlbumRadioNow = useCallback(() => { + handlePlayAlbumRadio(Play.NOW); + }, [handlePlayAlbumRadio]); + + const handlePlayAlbumRadioNext = useCallback(() => { + handlePlayAlbumRadio(Play.NEXT); + }, [handlePlayAlbumRadio]); + + const handlePlayAlbumRadioLast = useCallback(() => { + handlePlayAlbumRadio(Play.LAST); + }, [handlePlayAlbumRadio]); + + const defaultPlayAlbumRadioAction = useCallback(() => { + handlePlayAlbumRadio(playButtonBehavior); + }, [handlePlayAlbumRadio, playButtonBehavior]); + + return ( + + + + {t('player.albumRadio', { postProcess: 'sentenceCase' })} + + + + + {t('player.play', { postProcess: 'sentenceCase' })} + + + {t('player.addNext', { postProcess: 'sentenceCase' })} + + + {t('player.addLast', { postProcess: 'sentenceCase' })} + + + + ); +}; diff --git a/src/renderer/features/context-menu/menus/album-context-menu.tsx b/src/renderer/features/context-menu/menus/album-context-menu.tsx index 1c3a8db30..47ec905c1 100644 --- a/src/renderer/features/context-menu/menus/album-context-menu.tsx +++ b/src/renderer/features/context-menu/menus/album-context-menu.tsx @@ -5,6 +5,7 @@ import { DownloadAction } from '/@/renderer/features/context-menu/actions/downlo import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action'; import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action'; import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action'; +import { PlayAlbumRadioAction } from '/@/renderer/features/context-menu/actions/play-album-radio-action'; import { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action'; import { SetRatingAction } from '/@/renderer/features/context-menu/actions/set-rating-action'; import { ShareAction } from '/@/renderer/features/context-menu/actions/share-action'; @@ -28,6 +29,7 @@ export const AlbumContextMenu = ({ items, type }: AlbumContextMenuProps) => { bottomStickyContent={} > + 1} /> diff --git a/src/renderer/features/shared/components/library-header.tsx b/src/renderer/features/shared/components/library-header.tsx index a096f8952..1b649aa93 100644 --- a/src/renderer/features/shared/components/library-header.tsx +++ b/src/renderer/features/shared/components/library-header.tsx @@ -287,6 +287,7 @@ export const calculateTitleSize = (title: string) => { interface LibraryHeaderMenuProps { favorite?: boolean; + onAlbumRadio?: () => void; onArtistRadio?: () => void; onFavorite?: (e: React.MouseEvent) => void; onMore?: (e: React.MouseEvent) => void; @@ -298,6 +299,7 @@ interface LibraryHeaderMenuProps { export const LibraryHeaderMenu = ({ favorite, + onAlbumRadio, onArtistRadio, onFavorite, onMore, @@ -349,8 +351,26 @@ export const LibraryHeaderMenu = ({ {onPlay && ( )} + {onAlbumRadio && ( + + )} {onArtistRadio && (