feat: sync play queue for navidrome/subsonic (#1335)

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
Kendall Garner
2025-12-13 05:05:00 +00:00
committed by GitHub
parent 13afd3d9c4
commit ed5d590a6b
31 changed files with 648 additions and 107 deletions
@@ -14,6 +14,8 @@ import {
} from '/@/shared/types/domain-types';
import { ServerListItem, ServerType } from '/@/shared/types/types';
const TICKS_PER_MS = 10000;
const getAlbumArtistCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.albumArtist>;
@@ -221,7 +223,7 @@ const normalizeSong = (
createdAt: item.DateCreated,
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
discSubtitle: null,
duration: item.RunTimeTicks / 10000,
duration: item.RunTimeTicks / TICKS_PER_MS,
explicitStatus: null,
gain:
item.NormalizationGain !== undefined
@@ -294,7 +296,7 @@ const normalizeAlbum = (
backdropImageUrl: null,
comment: null,
createdAt: item.DateCreated,
duration: item.RunTimeTicks / 10000,
duration: item.RunTimeTicks / TICKS_PER_MS,
explicitStatus: null,
genres:
item.GenreItems?.map((entry) => ({
@@ -363,7 +365,7 @@ const normalizeAlbumArtist = (
albumCount: item.AlbumCount ?? null,
backgroundImageUrl: null,
biography: item.Overview || null,
duration: item.RunTimeTicks / 10000,
duration: item.RunTimeTicks / TICKS_PER_MS,
genres: item.GenreItems?.map((entry) => ({
_itemType: LibraryItem.GENRE,
_serverId: server?.id || '',
@@ -409,7 +411,7 @@ const normalizePlaylist = (
_serverId: server?.id || '',
_serverType: ServerType.JELLYFIN,
description: item.Overview || null,
duration: item.RunTimeTicks / 10000,
duration: item.RunTimeTicks / TICKS_PER_MS,
genres: item.GenreItems?.map((entry) => ({
_itemType: LibraryItem.GENRE,
_serverId: server?.id || '',
+26
View File
@@ -253,6 +253,7 @@ const sessionInfo = z.object({
CanSeek: z.boolean(),
IsMuted: z.boolean(),
IsPaused: z.boolean(),
PositionTicks: z.number().optional(),
RepeatMode: z.string(),
}),
RemoteEndPoint: z.string(),
@@ -801,6 +802,28 @@ const folderParameters = z.object({
SortOrder: z.enum(sortOrderValues).optional(),
});
const queueItem = z.object({
Id: z.string(),
PlaylistItemId: z.string().optional(),
});
const saveQueueParameters = scrobbleParameters.merge(
z.object({
NowPlayingQueue: z.array(queueItem),
PlaylistItemId: z.string().optional(),
}),
);
const getQueueParameters = z.object({});
const getSessions = z.array(
sessionInfo.merge(
z.object({
PlaylistItemId: z.string().optional(),
}),
),
);
export const jfType = {
_enum: {
albumArtistList: albumArtistListSort,
@@ -825,10 +848,12 @@ export const jfType = {
filterList: filterListParameters,
folder: folderParameters,
genreList: genreListParameters,
getQueue: getQueueParameters,
musicFolderList: musicFolderListParameters,
playlistDetail: playlistDetailParameters,
playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters,
saveQueue: saveQueueParameters,
scrobble: scrobbleParameters,
search: searchParameters,
similarArtistList: similarArtistListParameters,
@@ -853,6 +878,7 @@ export const jfType = {
folderList,
genre,
genreList,
getSessions,
lyrics,
moveItem,
musicFolder,
@@ -676,6 +676,25 @@ const tagListParameters = optionalPaginationParameters.extend({
tag_value: z.string().optional(), // Search
});
const saveQueueParameters = z.object({
current: z.number().optional(),
ids: z.array(z.string()).optional(),
position: z.number().optional(),
});
const saveQueue = z.null();
const queue = z.object({
changedBy: z.string(),
createdAt: z.string(),
current: z.number(),
id: z.string(),
items: z.array(song),
position: z.number(),
updatedAt: z.string(),
userId: z.string(),
});
export const ndType = {
_enum: {
albumArtistList: NDAlbumArtistListSort,
@@ -696,6 +715,7 @@ export const ndType = {
moveItem: moveItemParameters,
playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters,
saveQueue: saveQueueParameters,
shareItem: shareItemParameters,
songList: songListParameters,
tagList: tagListParameters,
@@ -719,7 +739,9 @@ export const ndType = {
playlistList,
playlistSong,
playlistSongList,
queue,
removeFromPlaylist,
saveQueue,
shareItem,
song,
songList,
+42
View File
@@ -356,6 +356,7 @@ const similarSongs = z.object({
export enum SubsonicExtensions {
FORM_POST = 'formPost',
INDEX_BASED_QUEUE = 'indexBasedQueue',
SONG_LYRICS = 'songLyrics',
TRANSCODE_OFFSET = 'transcodeOffset',
}
@@ -617,6 +618,42 @@ const getIndexesParameters = z.object({
musicFolderId: z.string().optional(),
});
const saveQueueParameters = z.object({
current: z.string().optional(),
id: z.string().array(),
position: z.number().optional(),
});
const savePlayQueueByIndexParameters = z.object({
currentIndex: z.number().optional(),
id: z.string().array().optional(),
position: z.number().optional(),
});
const saveQueue = z.null();
const playQueue = z.object({
playQueue: z.object({
changed: z.string(),
changedBy: z.string(),
current: z.string().optional(),
entry: song.array(),
position: z.number().optional(),
username: z.string(),
}),
});
const playQueueByIndex = z.object({
playQueueByIndex: z.object({
changed: z.string(),
changedBy: z.string(),
currentIndex: z.number().optional(),
entry: song.array().optional(),
position: z.number().optional(),
username: z.string(),
}),
});
export const ssType = {
_parameters: {
albumInfo: albumInfoParameters,
@@ -641,6 +678,8 @@ export const ssType = {
getStarred: getStarredParameters,
randomSongList: randomSongListParameters,
removeFavorite: removeFavoriteParameters,
savePlayQueueByIndex: savePlayQueueByIndexParameters,
saveQueue: saveQueueParameters,
scrobble: scrobbleParameters,
search3: search3Parameters,
setRating: setRatingParameters,
@@ -681,8 +720,11 @@ export const ssType = {
ping,
playlist,
playlistListEntry,
playQueue,
playQueueByIndex,
randomSongList,
removeFavorite,
saveQueue,
scrobble,
search3,
serverInfo,
+12
View File
@@ -49,6 +49,18 @@ export const hasFeature = (server: null | ServerListItem, feature: ServerFeature
return (server.features[feature]?.length || 0) > 0;
};
export const hasFeatureWithVersion = (
server: null | ServerListItem,
feature: ServerFeature,
version: number,
): boolean => {
if (!server || !server.features) {
return false;
}
return (server.features[feature] ?? []).includes(version);
};
export type VersionInfo = ReadonlyArray<
[string, Partial<Record<ServerFeature, readonly number[]>>]
>;
@@ -12,7 +12,7 @@ interface DragDropZoneProps {
}
export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZoneProps) => {
const zoneFileInput = useRef<HTMLInputElement | null>();
const zoneFileInput = useRef<HTMLInputElement | null>(null);
const [error, setError] = useState<string>('');
const processItem = useCallback(
@@ -122,7 +122,9 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
) : null}
<input
onChange={onZoneInputChange}
ref={(self) => (zoneFileInput.current = self)}
ref={(self) => {
zoneFileInput.current = self;
}}
style={{ display: 'none' }}
type="file"
/>
+2
View File
@@ -100,6 +100,7 @@ import {
LuSun,
LuTable,
LuTriangleAlert,
LuUpload,
LuUser,
LuUserPen,
LuUserRoundCog,
@@ -227,6 +228,7 @@ export const AppIcon = {
themeLight: LuSun,
track: LuMusic2,
unfavorite: LuHeartCrack,
upload: LuUpload,
user: LuUser,
userManage: LuUserRoundCog,
visibility: MdOutlineVisibility,
+27
View File
@@ -1286,6 +1286,7 @@ export type ControllerEndpoint = {
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistListCount: (args: PlaylistListCountArgs) => Promise<number>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getPlayQueue: (args: GetQueueArgs) => Promise<GetQueueResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
getRoles: (args: BaseEndpointArgs) => Promise<Array<string | { label: string; value: string }>>;
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
@@ -1303,6 +1304,7 @@ export type ControllerEndpoint = {
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>;
savePlayQueue: (args: SaveQueueArgs) => Promise<void>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>;
setRating?: (args: SetRatingArgs) => Promise<RatingResponse>;
@@ -1327,6 +1329,19 @@ export type FontData = {
style: string;
};
export type GetQueueArgs = BaseEndpointArgs;
export interface GetQueueQuery {}
export type GetQueueResponse = {
changed: string;
changedBy: string;
currentIndex: number;
entry: Song[];
positionMs: number;
username: string;
};
export type InternalControllerEndpoint = {
addToPlaylist: (
args: ReplaceApiClientProps<AddToPlaylistArgs>,
@@ -1376,6 +1391,7 @@ export type InternalControllerEndpoint = {
getPlaylistSongList: (
args: ReplaceApiClientProps<PlaylistSongListArgs>,
) => Promise<SongListResponse>;
getPlayQueue: (args: ReplaceApiClientProps<GetQueueArgs>) => Promise<GetQueueResponse>;
getRandomSongList: (
args: ReplaceApiClientProps<RandomSongListArgs>,
) => Promise<SongListResponse>;
@@ -1402,6 +1418,7 @@ export type InternalControllerEndpoint = {
replacePlaylist: (
args: ReplaceApiClientProps<ReplacePlaylistArgs>,
) => Promise<ReplacePlaylistResponse>;
savePlayQueue: (args: ReplaceApiClientProps<SaveQueueArgs>) => Promise<void>;
scrobble: (args: ReplaceApiClientProps<ScrobbleArgs>) => Promise<ScrobbleResponse>;
search: (args: ReplaceApiClientProps<SearchArgs>) => Promise<SearchResponse>;
setRating?: (args: ReplaceApiClientProps<SetRatingArgs>) => Promise<RatingResponse>;
@@ -1439,6 +1456,16 @@ export type MoveItemQuery = {
export type ReplaceApiClientProps<T> = BaseEndpointArgsWithServer & Omit<T, 'apiClientProps'>;
export type SaveQueueArgs = BaseEndpointArgs & {
query: SaveQueueQuery;
};
export type SaveQueueQuery = {
currentIndex?: number;
positionMs?: number;
songs: string[];
};
export type ServerInfo = {
features: ServerFeatures;
id?: string;
+1
View File
@@ -8,6 +8,7 @@ export enum ServerFeature {
OS_FORM_POST = 'osFormPost',
PLAYLISTS_SMART = 'playlistsSmart',
PUBLIC_PLAYLIST = 'publicPlaylist',
SERVER_PLAY_QUEUE = 'serverPlayQueue',
SHARING_ALBUM_SONG = 'sharingAlbumSong',
TAGS = 'tags',
TRACK_ALBUM_ARTIST_SEARCH = 'trackAlbumArtistSearch',