reorganize global types to shared directory

This commit is contained in:
jeffvli 2025-05-20 18:08:51 -07:00
parent 26c02e03c5
commit 9db2e51d2d
17 changed files with 160 additions and 144 deletions

View file

@ -0,0 +1,639 @@
export enum JFAlbumArtistListSort {
ALBUM = 'Album,SortName',
DURATION = 'Runtime,AlbumArtist,Album,SortName',
NAME = 'SortName,Name',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
}
export enum JFAlbumListSort {
ALBUM_ARTIST = 'AlbumArtist,SortName',
COMMUNITY_RATING = 'CommunityRating,SortName',
CRITIC_RATING = 'CriticRating,SortName',
NAME = 'SortName',
PLAY_COUNT = 'PlayCount',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RELEASE_DATE = 'ProductionYear,PremiereDate,SortName',
}
export enum JFArtistListSort {
ALBUM = 'Album,SortName',
DURATION = 'Runtime,AlbumArtist,Album,SortName',
NAME = 'SortName,Name',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
}
export enum JFCollectionType {
MUSIC = 'music',
PLAYLISTS = 'playlists',
}
export enum JFExternalType {
MUSICBRAINZ = 'MusicBrainz',
THEAUDIODB = 'TheAudioDb',
}
export enum JFGenreListSort {
NAME = 'SortName',
}
export enum JFImageType {
LOGO = 'Logo',
PRIMARY = 'Primary',
}
export enum JFItemType {
AUDIO = 'Audio',
MUSICALBUM = 'MusicAlbum',
}
export enum JFPlaylistListSort {
ALBUM_ARTIST = 'AlbumArtist,SortName',
DURATION = 'Runtime',
NAME = 'SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
SONG_COUNT = 'ChildCount',
}
export enum JFSongListSort {
ALBUM = 'Album,SortName',
ALBUM_ARTIST = 'AlbumArtist,Album,SortName',
ARTIST = 'Artist,Album,SortName',
COMMUNITY_RATING = 'CommunityRating,SortName',
DURATION = 'Runtime,AlbumArtist,Album,SortName',
NAME = 'Name',
PLAY_COUNT = 'PlayCount,SortName',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RECENTLY_PLAYED = 'DatePlayed,SortName',
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
}
export enum JFSortOrder {
ASC = 'Ascending',
DESC = 'Descending',
}
export type JFAddToPlaylist = null;
export type JFAddToPlaylistParams = {
ids: string[];
userId: string;
};
export type JFAddToPlaylistResponse = {
added: number;
};
export type JFAlbum = {
AlbumArtist: string;
AlbumArtists: JFGenericItem[];
AlbumPrimaryImageTag: string;
ArtistItems: JFGenericItem[];
Artists: string[];
ChannelId: null;
ChildCount?: number;
DateCreated: string;
DateLastMediaAdded?: string;
ExternalUrls: ExternalURL[];
GenreItems: JFGenericItem[];
Genres: string[];
Id: string;
ImageBlurHashes: ImageBlurHashes;
ImageTags: ImageTags;
IsFolder: boolean;
LocationType: string;
Name: string;
ParentLogoImageTag: string;
ParentLogoItemId: string;
PremiereDate?: string;
ProductionYear: number;
RunTimeTicks: number;
ServerId: string;
Type: string;
UserData?: UserData;
} & {
songs?: JFSong[];
};
export type JFAlbumArtist = {
BackdropImageTags: string[];
ChannelId: null;
DateCreated: string;
ExternalUrls: ExternalURL[];
GenreItems: GenreItem[];
Genres: string[];
Id: string;
ImageBlurHashes: any;
ImageTags: ImageTags;
LocationType: string;
Name: string;
Overview?: string;
RunTimeTicks: number;
ServerId: string;
Type: string;
UserData: {
IsFavorite: boolean;
Key: string;
PlaybackPositionTicks: number;
PlayCount: number;
Played: boolean;
};
} & {
similarArtists: {
items: JFAlbumArtist[];
};
};
export type JFAlbumArtistDetail = JFAlbumArtistDetailResponse;
export type JFAlbumArtistDetailResponse = JFAlbumArtist;
export type JFAlbumArtistList = {
items: JFAlbumArtist[];
startIndex: number;
totalRecordCount: number;
};
export type JFAlbumArtistListParams = JFBaseParams &
JFPaginationParams & {
filters?: string;
genres?: string;
sortBy?: JFAlbumArtistListSort;
years?: string;
};
export interface JFAlbumArtistListResponse extends JFBasePaginatedResponse {
Items: JFAlbumArtist[];
}
export type JFAlbumDetail = JFAlbum & { songs?: JFSong[] };
export type JFAlbumDetailResponse = JFAlbum;
export type JFAlbumList = {
items: JFAlbum[];
startIndex: number;
totalRecordCount: number;
};
export type JFAlbumListParams = JFBaseParams &
JFPaginationParams & {
albumArtistIds?: string;
artistIds?: string;
filters?: string;
genreIds?: string;
genres?: string;
includeItemTypes: 'MusicAlbum';
isFavorite?: boolean;
searchTerm?: string;
sortBy?: JFAlbumListSort;
tags?: string;
years?: string;
};
export interface JFAlbumListResponse extends JFBasePaginatedResponse {
Items: JFAlbum[];
}
export type JFArtist = {
BackdropImageTags: string[];
ChannelId: null;
DateCreated: string;
ExternalUrls: ExternalURL[];
GenreItems: GenreItem[];
Genres: string[];
Id: string;
ImageBlurHashes: any;
ImageTags: string[];
LocationType: string;
Name: string;
Overview?: string;
RunTimeTicks: number;
ServerId: string;
Type: string;
};
export type JFArtistList = JFArtistListResponse;
export type JFArtistListParams = JFBaseParams &
JFPaginationParams & {
filters?: string;
genres?: string;
sortBy?: JFArtistListSort;
years?: string;
};
export interface JFArtistListResponse extends JFBasePaginatedResponse {
Items: JFAlbumArtist[];
}
export interface JFAuthenticate {
AccessToken: string;
ServerId: string;
SessionInfo: SessionInfo;
User: User;
}
export type JFBasePaginatedResponse = {
StartIndex: number;
TotalRecordCount: number;
};
export type JFCreatePlaylist = JFCreatePlaylistResponse;
export type JFCreatePlaylistResponse = {
Id: string;
};
export type JFGenericItem = {
Id: string;
Name: string;
};
export type JFGenre = {
BackdropImageTags: any[];
ChannelId: null;
Id: string;
ImageBlurHashes: any;
ImageTags: ImageTags;
LocationType: string;
Name: string;
ServerId: string;
Type: string;
};
export type JFGenreList = JFGenreListResponse;
export interface JFGenreListResponse extends JFBasePaginatedResponse {
Items: JFGenre[];
}
export type JFMusicFolder = {
BackdropImageTags: string[];
ChannelId: null;
CollectionType: string;
Id: string;
ImageBlurHashes: ImageBlurHashes;
ImageTags: ImageTags;
IsFolder: boolean;
LocationType: string;
Name: string;
ServerId: string;
Type: string;
UserData: UserData;
};
export type JFMusicFolderList = JFMusicFolder[];
export interface JFMusicFolderListResponse extends JFBasePaginatedResponse {
Items: JFMusicFolder[];
}
export type JFPlaylist = {
BackdropImageTags: string[];
ChannelId: null;
ChildCount?: number;
DateCreated: string;
GenreItems: GenreItem[];
Genres: string[];
Id: string;
ImageBlurHashes: ImageBlurHashes;
ImageTags: ImageTags;
IsFolder: boolean;
LocationType: string;
MediaType: string;
Name: string;
Overview?: string;
RunTimeTicks: number;
ServerId: string;
Type: string;
UserData: UserData;
};
export type JFPlaylistDetail = JFPlaylist & { songs?: JFSong[] };
export type JFPlaylistDetailResponse = JFPlaylist;
export type JFPlaylistList = {
items: JFPlaylist[];
startIndex: number;
totalRecordCount: number;
};
export interface JFPlaylistListResponse extends JFBasePaginatedResponse {
Items: JFPlaylist[];
}
export type JFRemoveFromPlaylist = null;
export type JFRemoveFromPlaylistParams = {
entryIds: string[];
};
export type JFRemoveFromPlaylistResponse = null;
export type JFRequestParams = {
albumArtistIds?: string;
artistIds?: string;
enableImageTypes?: string;
enableTotalRecordCount?: boolean;
enableUserData?: boolean;
excludeItemTypes?: string;
fields?: string;
imageTypeLimit?: number;
includeItemTypes?: string;
isFavorite?: boolean;
limit?: number;
parentId?: string;
recursive?: boolean;
searchTerm?: string;
sortBy?: string;
sortOrder?: 'Ascending' | 'Descending';
startIndex?: number;
userId?: string;
};
export type JFSong = {
Album: string;
AlbumArtist: string;
AlbumArtists: JFGenericItem[];
AlbumId: string;
AlbumPrimaryImageTag: string;
ArtistItems: JFGenericItem[];
Artists: string[];
BackdropImageTags: string[];
ChannelId: null;
DateCreated: string;
ExternalUrls: ExternalURL[];
GenreItems: JFGenericItem[];
Genres: string[];
Id: string;
ImageBlurHashes: ImageBlurHashes;
ImageTags: ImageTags;
IndexNumber: number;
IsFolder: boolean;
LocationType: string;
MediaSources: MediaSources[];
MediaType: string;
Name: string;
ParentIndexNumber: number;
PlaylistItemId?: string;
PremiereDate?: string;
ProductionYear: number;
RunTimeTicks: number;
ServerId: string;
SortName: string;
Type: string;
UserData?: UserData;
};
export type JFSongList = {
items: JFSong[];
startIndex: number;
totalRecordCount: number;
};
export type JFSongListParams = JFBaseParams &
JFPaginationParams & {
albumArtistIds?: string;
albumIds?: string;
artistIds?: string;
contributingArtistIds?: string;
filters?: string;
genreIds?: string;
genres?: string;
ids?: string;
includeItemTypes: 'Audio';
searchTerm?: string;
sortBy?: JFSongListSort;
years?: string;
};
export interface JFSongListResponse extends JFBasePaginatedResponse {
Items: JFSong[];
}
type Capabilities = {
PlayableMediaTypes: any[];
SupportedCommands: any[];
SupportsContentUploading: boolean;
SupportsMediaControl: boolean;
SupportsPersistentIdentifier: boolean;
SupportsSync: boolean;
};
type Configuration = {
DisplayCollectionsView: boolean;
DisplayMissingEpisodes: boolean;
EnableLocalPassword: boolean;
EnableNextEpisodeAutoPlay: boolean;
GroupedFolders: any[];
HidePlayedInLatest: boolean;
LatestItemsExcludes: any[];
MyMediaExcludes: any[];
OrderedViews: any[];
PlayDefaultAudioTrack: boolean;
RememberAudioSelections: boolean;
RememberSubtitleSelections: boolean;
SubtitleLanguagePreference: string;
SubtitleMode: string;
};
type ExternalURL = {
Name: string;
Url: string;
};
type GenreItem = {
Id: string;
Name: string;
};
type ImageBlurHashes = {
Backdrop?: any;
Logo?: any;
Primary?: any;
};
type ImageTags = {
Logo?: string;
Primary?: string;
};
type JFBaseParams = {
enableImageTypes?: JFImageType[];
fields?: string;
imageTypeLimit?: number;
parentId?: string;
recursive?: boolean;
searchTerm?: string;
userId?: string;
};
type JFPaginationParams = {
limit?: number;
nameStartsWith?: string;
sortOrder?: JFSortOrder;
startIndex?: number;
};
type MediaSources = {
Bitrate: number;
Container: string;
DefaultAudioStreamIndex: number;
ETag: string;
Formats: any[];
GenPtsInput: boolean;
Id: string;
IgnoreDts: boolean;
IgnoreIndex: boolean;
IsInfiniteStream: boolean;
IsRemote: boolean;
MediaAttachments: any[];
MediaStreams: MediaStream[];
Name: string;
Path: string;
Protocol: string;
ReadAtNativeFramerate: boolean;
RequiredHttpHeaders: any;
RequiresClosing: boolean;
RequiresLooping: boolean;
RequiresOpening: boolean;
RunTimeTicks: number;
Size: number;
SupportsDirectPlay: boolean;
SupportsDirectStream: boolean;
SupportsProbing: boolean;
SupportsTranscoding: boolean;
Type: string;
};
type MediaStream = {
AspectRatio?: string;
BitDepth?: number;
BitRate?: number;
ChannelLayout?: string;
Channels?: number;
Codec: string;
CodecTimeBase: string;
ColorSpace?: string;
Comment?: string;
DisplayTitle?: string;
Height?: number;
Index: number;
IsDefault: boolean;
IsExternal: boolean;
IsForced: boolean;
IsInterlaced: boolean;
IsTextSubtitleStream: boolean;
Level: number;
PixelFormat?: string;
Profile?: string;
RealFrameRate?: number;
RefFrames?: number;
SampleRate?: number;
SupportsExternalStream: boolean;
TimeBase: string;
Type: string;
Width?: number;
};
type PlayState = {
CanSeek: boolean;
IsMuted: boolean;
IsPaused: boolean;
RepeatMode: string;
};
type Policy = {
AccessSchedules: any[];
AuthenticationProviderId: string;
BlockedChannels: any[];
BlockedMediaFolders: any[];
BlockedTags: any[];
BlockUnratedItems: any[];
EnableAllChannels: boolean;
EnableAllDevices: boolean;
EnableAllFolders: boolean;
EnableAudioPlaybackTranscoding: boolean;
EnableContentDeletion: boolean;
EnableContentDeletionFromFolders: any[];
EnableContentDownloading: boolean;
EnabledChannels: any[];
EnabledDevices: any[];
EnabledFolders: any[];
EnableLiveTvAccess: boolean;
EnableLiveTvManagement: boolean;
EnableMediaConversion: boolean;
EnableMediaPlayback: boolean;
EnablePlaybackRemuxing: boolean;
EnablePublicSharing: boolean;
EnableRemoteAccess: boolean;
EnableRemoteControlOfOtherUsers: boolean;
EnableSharedDeviceControl: boolean;
EnableSyncTranscoding: boolean;
EnableUserPreferenceAccess: boolean;
EnableVideoPlaybackTranscoding: boolean;
ForceRemoteSourceTranscoding: boolean;
InvalidLoginAttemptCount: number;
IsAdministrator: boolean;
IsDisabled: boolean;
IsHidden: boolean;
LoginAttemptsBeforeLockout: number;
MaxActiveSessions: number;
PasswordResetProviderId: string;
RemoteClientBitrateLimit: number;
SyncPlayAccess: string;
};
type SessionInfo = {
AdditionalUsers: any[];
ApplicationVersion: string;
Capabilities: Capabilities;
Client: string;
DeviceId: string;
DeviceName: string;
HasCustomDeviceName: boolean;
Id: string;
IsActive: boolean;
LastActivityDate: string;
LastPlaybackCheckIn: string;
NowPlayingQueue: any[];
NowPlayingQueueFullItems: any[];
PlayableMediaTypes: any[];
PlayState: PlayState;
RemoteEndPoint: string;
ServerId: string;
SupportedCommands: any[];
SupportsMediaControl: boolean;
SupportsRemoteControl: boolean;
UserId: string;
UserName: string;
};
type User = {
Configuration: Configuration;
EnableAutoLogin: boolean;
HasConfiguredEasyPassword: boolean;
HasConfiguredPassword: boolean;
HasPassword: boolean;
Id: string;
LastActivityDate: string;
LastLoginDate: string;
Name: string;
Policy: Policy;
ServerId: string;
};
type UserData = {
IsFavorite: boolean;
Key: string;
PlaybackPositionTicks: number;
PlayCount: number;
Played: boolean;
};

View file

@ -0,0 +1,485 @@
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { JFAlbum, JFGenre, JFMusicFolder, JFPlaylist } from '/@/shared/api/jellyfin.types';
import { jfType } from '/@/shared/api/jellyfin/jellyfin-types';
import {
Album,
AlbumArtist,
Genre,
LibraryItem,
MusicFolder,
Playlist,
RelatedArtist,
Song,
} from '/@/shared/types/domain-types';
import { ServerListItem, ServerType } from '/@/shared/types/types';
const getStreamUrl = (args: {
container?: string;
deviceId: string;
eTag?: string;
id: string;
mediaSourceId?: string;
server: null | ServerListItem;
}) => {
const { deviceId, id, server } = args;
return (
`${server?.url}/audio` +
`/${id}/universal` +
`?userId=${server?.userId}` +
`&deviceId=${deviceId}` +
'&audioCodec=aac' +
`&apiKey=${server?.credential}` +
`&playSessionId=${deviceId}` +
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' +
'&transcodingContainer=ts' +
'&transcodingProtocol=http'
);
};
const getAlbumArtistCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.albumArtist>;
size: number;
}) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96'
);
};
const getAlbumCoverArtUrl = (args: { baseUrl: string; item: JFAlbum; size: number }) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary && !args.item?.AlbumPrimaryImageTag) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96'
);
};
const getSongCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.song>;
size: number;
}) => {
const size = args.size ? args.size : 100;
if (args.item.ImageTags.Primary) {
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96'
);
}
if (args.item?.AlbumPrimaryImageTag) {
// Fall back to album art if no image embedded
return (
`${args.baseUrl}/Items` +
`/${args.item?.AlbumId}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96'
);
}
return null;
};
const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size: number }) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96'
);
};
type AlbumOrSong = z.infer<typeof jfType._response.album> | z.infer<typeof jfType._response.song>;
const getPeople = (item: AlbumOrSong): null | Record<string, RelatedArtist[]> => {
if (item.People) {
const participants: Record<string, RelatedArtist[]> = {};
for (const person of item.People) {
const key = person.Type || '';
const item: RelatedArtist = {
// for other roles, we just want to display this and not filter.
// filtering (and links) would require a separate field, PersonIds
id: '',
imageUrl: null,
name: person.Name,
};
if (key in participants) {
participants[key].push(item);
} else {
participants[key] = [item];
}
}
return participants;
}
return null;
};
const getTags = (item: AlbumOrSong): null | Record<string, string[]> => {
if (item.Tags) {
const tags: Record<string, string[]> = {};
for (const tag of item.Tags) {
tags[tag] = [];
}
return tags;
}
return null;
};
const normalizeSong = (
item: z.infer<typeof jfType._response.song>,
server: null | ServerListItem,
deviceId: string,
imageSize?: number,
): Song => {
return {
album: item.Album,
albumArtists: item.AlbumArtists?.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})),
albumId: item.AlbumId || `dummy/${item.Id}`,
artistName: item?.ArtistItems?.[0]?.Name,
artists: item?.ArtistItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})),
bitRate:
item.MediaSources?.[0].Bitrate &&
Number(Math.trunc(item.MediaSources[0].Bitrate / 1000)),
bpm: null,
channels: null,
comment: null,
compilation: null,
container: (item.MediaSources && item.MediaSources[0]?.Container) || null,
createdAt: item.DateCreated,
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
discSubtitle: null,
duration: item.RunTimeTicks / 10000,
gain:
item.NormalizationGain !== undefined
? {
track: item.NormalizationGain,
}
: item.LUFS
? {
track: -18 - item.LUFS,
}
: null,
genres: item.GenreItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: entry.Name,
})),
id: item.Id,
imagePlaceholderUrl: null,
imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }),
itemType: LibraryItem.SONG,
lastPlayedAt: null,
lyrics: null,
name: item.Name,
participants: getPeople(item),
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
peak: null,
playCount: (item.UserData && item.UserData.PlayCount) || 0,
playlistItemId: item.PlaylistItemId,
releaseDate: item.PremiereDate
? new Date(item.PremiereDate).toISOString()
: item.ProductionYear
? new Date(item.ProductionYear, 0, 1).toISOString()
: null,
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
size: item.MediaSources && item.MediaSources[0]?.Size,
streamUrl: getStreamUrl({
container: item.MediaSources?.[0]?.Container,
deviceId,
eTag: item.MediaSources?.[0]?.ETag,
id: item.Id,
mediaSourceId: item.MediaSources?.[0]?.Id,
server,
}),
tags: getTags(item),
trackNumber: item.IndexNumber,
uniqueId: nanoid(),
updatedAt: item.DateCreated,
userFavorite: (item.UserData && item.UserData.IsFavorite) || false,
userRating: null,
};
};
const normalizeAlbum = (
item: z.infer<typeof jfType._response.album>,
server: null | ServerListItem,
imageSize?: number,
): Album => {
return {
albumArtist: item.AlbumArtist,
albumArtists:
item.AlbumArtists.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})) || [],
artists: item.ArtistItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})),
backdropImageUrl: null,
comment: null,
createdAt: item.DateCreated,
duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: entry.Name,
})),
id: item.Id,
imagePlaceholderUrl: null,
imageUrl: getAlbumCoverArtUrl({
baseUrl: server?.url || '',
item,
size: imageSize || 300,
}),
isCompilation: null,
itemType: LibraryItem.ALBUM,
lastPlayedAt: null,
mbzId: item.ProviderIds?.MusicBrainzAlbum || null,
name: item.Name,
originalDate: null,
participants: getPeople(item),
playCount: item.UserData?.PlayCount || 0,
releaseDate: item.PremiereDate?.split('T')[0] || null,
releaseYear: item.ProductionYear || null,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
size: null,
songCount: item?.ChildCount || null,
songs: item.Songs?.map((song) => normalizeSong(song, server, '', imageSize)),
tags: getTags(item),
uniqueId: nanoid(),
updatedAt: item?.DateLastMediaAdded || item.DateCreated,
userFavorite: item.UserData?.IsFavorite || false,
userRating: null,
};
};
const normalizeAlbumArtist = (
item: z.infer<typeof jfType._response.albumArtist> & {
similarArtists?: z.infer<typeof jfType._response.albumArtistList>;
},
server: null | ServerListItem,
imageSize?: number,
): AlbumArtist => {
const 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,
}),
) || [];
return {
albumCount: item.AlbumCount ?? null,
backgroundImageUrl: null,
biography: item.Overview || null,
duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: entry.Name,
})),
id: item.Id,
imageUrl: getAlbumArtistCoverArtUrl({
baseUrl: server?.url || '',
item,
size: imageSize || 300,
}),
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: null,
mbz: item.ProviderIds?.MusicBrainzArtist || null,
name: item.Name,
playCount: item.UserData?.PlayCount || 0,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
similarArtists,
songCount: item.SongCount ?? null,
userFavorite: item.UserData?.IsFavorite || false,
userRating: null,
};
};
const normalizePlaylist = (
item: z.infer<typeof jfType._response.playlist>,
server: null | ServerListItem,
imageSize?: number,
): Playlist => {
const imageUrl = getPlaylistCoverArtUrl({
baseUrl: server?.url || '',
item,
size: imageSize || 300,
});
const imagePlaceholderUrl = null;
return {
description: item.Overview || null,
duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: entry.Name,
})),
id: item.Id,
imagePlaceholderUrl,
imageUrl: imageUrl || null,
itemType: LibraryItem.PLAYLIST,
name: item.Name,
owner: null,
ownerId: null,
public: null,
rules: null,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
size: null,
songCount: item?.ChildCount || null,
sync: null,
};
};
const normalizeMusicFolder = (item: JFMusicFolder): MusicFolder => {
return {
id: item.Id,
name: item.Name,
};
};
// const normalizeArtist = (item: any) => {
// return {
// album: (item.album || []).map((entry: any) => normalizeAlbum(entry)),
// albumCount: item.AlbumCount,
// duration: item.RunTimeTicks / 10000000,
// genre: item.GenreItems && item.GenreItems.map((entry: any) => normalizeItem(entry)),
// id: item.Id,
// image: getCoverArtUrl(item),
// info: {
// biography: item.Overview,
// externalUrl: (item.ExternalUrls || []).map((entry: any) => normalizeItem(entry)),
// imageUrl: undefined,
// similarArtist: (item.similarArtist || []).map((entry: any) => normalizeArtist(entry)),
// },
// starred: item.UserData && item.UserData?.IsFavorite ? 'true' : undefined,
// title: item.Name,
// uniqueId: nanoid(),
// };
// };
const getGenreCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.genre>;
size: number;
}) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96'
);
};
const normalizeGenre = (item: JFGenre, server: null | ServerListItem): Genre => {
return {
albumCount: undefined,
id: item.Id,
imageUrl: getGenreCoverArtUrl({ baseUrl: server?.url || '', item, size: 200 }),
itemType: LibraryItem.GENRE,
name: item.Name,
songCount: undefined,
};
};
// const normalizeFolder = (item: any) => {
// return {
// created: item.DateCreated,
// id: item.Id,
// image: getCoverArtUrl(item, 150),
// isDir: true,
// title: item.Name,
// type: Item.Folder,
// uniqueId: nanoid(),
// };
// };
// const normalizeScanStatus = () => {
// return {
// count: 'N/a',
// scanning: false,
// };
// };
export const jfNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
genre: normalizeGenre,
musicFolder: normalizeMusicFolder,
playlist: normalizePlaylist,
song: normalizeSong,
};

View file

@ -0,0 +1,778 @@
import { z } from 'zod';
const sortOrderValues = ['Ascending', 'Descending'] as const;
const jfExternal = {
IMDB: 'Imdb',
MUSIC_BRAINZ: 'MusicBrainz',
THE_AUDIO_DB: 'TheAudioDb',
THE_MOVIE_DB: 'TheMovieDb',
TVDB: 'Tvdb',
};
const jfImage = {
BACKDROP: 'Backdrop',
BANNER: 'Banner',
BOX: 'Box',
CHAPTER: 'Chapter',
DISC: 'Disc',
LOGO: 'Logo',
PRIMARY: 'Primary',
THUMB: 'Thumb',
} as const;
const jfCollection = {
MUSIC: 'music',
PLAYLISTS: 'playlists',
} as const;
const error = z.object({
errors: z.object({
recursive: z.array(z.string()),
}),
status: z.number(),
title: z.string(),
traceId: z.string(),
type: z.string(),
});
const baseParameters = z.object({
AlbumArtistIds: z.string().optional(),
ArtistIds: z.string().optional(),
ContributingArtistIds: z.string().optional(),
EnableImageTypes: z.string().optional(),
EnableTotalRecordCount: z.boolean().optional(),
EnableUserData: z.boolean().optional(),
EnableUserDataTypes: z.boolean().optional(),
ExcludeArtistIds: z.string().optional(),
ExcludeItemIds: z.string().optional(),
ExcludeItemTypes: z.string().optional(),
Fields: z.string().optional(),
ImageTypeLimit: z.number().optional(),
IncludeArtists: z.boolean().optional(),
IncludeGenres: z.boolean().optional(),
IncludeItemTypes: z.string().optional(),
IncludeMedia: z.boolean().optional(),
IncludePeople: z.boolean().optional(),
IncludeStudios: z.boolean().optional(),
IsFavorite: z.boolean().optional(),
Limit: z.number().optional(),
MediaTypes: z.string().optional(),
NameStartsWith: z.string().optional(),
ParentId: z.string().optional(),
Recursive: z.boolean().optional(),
SearchTerm: z.string().optional(),
SortBy: z.string().optional(),
SortOrder: z.enum(sortOrderValues).optional(),
StartIndex: z.number().optional(),
Tags: z.string().optional(),
UserId: z.string().optional(),
Years: z.string().optional(),
});
const paginationParameters = z.object({
Limit: z.number().optional(),
SortOrder: z.enum(sortOrderValues).optional(),
StartIndex: z.number().optional(),
});
const pagination = z.object({
StartIndex: z.number(),
TotalRecordCount: z.number(),
});
const imageTags = z.object({
Logo: z.string().optional(),
Primary: z.string().optional(),
});
const imageBlurHashes = z.object({
Backdrop: z.record(z.string(), z.string()).optional(),
Logo: z.record(z.string(), z.string()).optional(),
Primary: z.record(z.string(), z.string()).optional(),
});
const userData = z.object({
IsFavorite: z.boolean(),
Key: z.string(),
PlaybackPositionTicks: z.number(),
PlayCount: z.number(),
Played: z.boolean(),
});
const externalUrl = z.object({
Name: z.string(),
Url: z.string(),
});
const mediaStream = z.object({
AspectRatio: z.string().optional(),
BitDepth: z.number().optional(),
BitRate: z.number().optional(),
ChannelLayout: z.string().optional(),
Channels: z.number().optional(),
Codec: z.string(),
CodecTimeBase: z.string(),
ColorSpace: z.string().optional(),
Comment: z.string().optional(),
DisplayTitle: z.string().optional(),
Height: z.number().optional(),
Index: z.number(),
IsDefault: z.boolean(),
IsExternal: z.boolean(),
IsForced: z.boolean(),
IsInterlaced: z.boolean(),
IsTextSubtitleStream: z.boolean(),
Level: z.number(),
PixelFormat: z.string().optional(),
Profile: z.string().optional(),
RealFrameRate: z.number().optional(),
RefFrames: z.number().optional(),
SampleRate: z.number().optional(),
SupportsExternalStream: z.boolean(),
TimeBase: z.string(),
Type: z.string(),
Width: z.number().optional(),
});
const mediaSources = z.object({
Bitrate: z.number(),
Container: z.string(),
DefaultAudioStreamIndex: z.number(),
ETag: z.string(),
Formats: z.array(z.any()),
GenPtsInput: z.boolean(),
Id: z.string(),
IgnoreDts: z.boolean(),
IgnoreIndex: z.boolean(),
IsInfiniteStream: z.boolean(),
IsRemote: z.boolean(),
MediaAttachments: z.array(z.any()),
MediaStreams: z.array(mediaStream),
Name: z.string(),
Path: z.string(),
Protocol: z.string(),
ReadAtNativeFramerate: z.boolean(),
RequiredHttpHeaders: z.any(),
RequiresClosing: z.boolean(),
RequiresLooping: z.boolean(),
RequiresOpening: z.boolean(),
RunTimeTicks: z.number(),
Size: z.number(),
SupportsDirectPlay: z.boolean(),
SupportsDirectStream: z.boolean(),
SupportsProbing: z.boolean(),
SupportsTranscoding: z.boolean(),
Type: z.string(),
});
const sessionInfo = z.object({
AdditionalUsers: z.array(z.any()),
ApplicationVersion: z.string(),
Capabilities: z.object({
PlayableMediaTypes: z.array(z.any()),
SupportedCommands: z.array(z.any()),
SupportsContentUploading: z.boolean(),
SupportsMediaControl: z.boolean(),
SupportsPersistentIdentifier: z.boolean(),
SupportsSync: z.boolean(),
}),
Client: z.string(),
DeviceId: z.string(),
DeviceName: z.string(),
HasCustomDeviceName: z.boolean(),
Id: z.string(),
IsActive: z.boolean(),
LastActivityDate: z.string(),
LastPlaybackCheckIn: z.string(),
NowPlayingQueue: z.array(z.any()),
NowPlayingQueueFullItems: z.array(z.any()),
PlayableMediaTypes: z.array(z.any()),
PlayState: z.object({
CanSeek: z.boolean(),
IsMuted: z.boolean(),
IsPaused: z.boolean(),
RepeatMode: z.string(),
}),
RemoteEndPoint: z.string(),
ServerId: z.string(),
SupportedCommands: z.array(z.any()),
SupportsMediaControl: z.boolean(),
SupportsRemoteControl: z.boolean(),
UserId: z.string(),
UserName: z.string(),
});
const configuration = z.object({
DisplayCollectionsView: z.boolean(),
DisplayMissingEpisodes: z.boolean(),
EnableLocalPassword: z.boolean(),
EnableNextEpisodeAutoPlay: z.boolean(),
GroupedFolders: z.array(z.any()),
HidePlayedInLatest: z.boolean(),
LatestItemsExcludes: z.array(z.any()),
MyMediaExcludes: z.array(z.any()),
OrderedViews: z.array(z.any()),
PlayDefaultAudioTrack: z.boolean(),
RememberAudioSelections: z.boolean(),
RememberSubtitleSelections: z.boolean(),
SubtitleLanguagePreference: z.string(),
SubtitleMode: z.string(),
});
const policy = z.object({
AccessSchedules: z.array(z.any()),
AuthenticationProviderId: z.string(),
BlockedChannels: z.array(z.any()),
BlockedMediaFolders: z.array(z.any()),
BlockedTags: z.array(z.any()),
BlockUnratedItems: z.array(z.any()),
EnableAllChannels: z.boolean(),
EnableAllDevices: z.boolean(),
EnableAllFolders: z.boolean(),
EnableAudioPlaybackTranscoding: z.boolean(),
EnableContentDeletion: z.boolean(),
EnableContentDeletionFromFolders: z.array(z.any()),
EnableContentDownloading: z.boolean(),
EnabledChannels: z.array(z.any()),
EnabledDevices: z.array(z.any()),
EnabledFolders: z.array(z.any()),
EnableLiveTvAccess: z.boolean(),
EnableLiveTvManagement: z.boolean(),
EnableMediaConversion: z.boolean(),
EnableMediaPlayback: z.boolean(),
EnablePlaybackRemuxing: z.boolean(),
EnablePublicSharing: z.boolean(),
EnableRemoteAccess: z.boolean(),
EnableRemoteControlOfOtherUsers: z.boolean(),
EnableSharedDeviceControl: z.boolean(),
EnableSyncTranscoding: z.boolean(),
EnableUserPreferenceAccess: z.boolean(),
EnableVideoPlaybackTranscoding: z.boolean(),
ForceRemoteSourceTranscoding: z.boolean(),
InvalidLoginAttemptCount: z.number(),
IsAdministrator: z.boolean(),
IsDisabled: z.boolean(),
IsHidden: z.boolean(),
LoginAttemptsBeforeLockout: z.number(),
MaxActiveSessions: z.number(),
PasswordResetProviderId: z.string(),
RemoteClientBitrateLimit: z.number(),
SyncPlayAccess: z.string(),
});
const user = z.object({
Configuration: configuration,
EnableAutoLogin: z.boolean(),
HasConfiguredEasyPassword: z.boolean(),
HasConfiguredPassword: z.boolean(),
HasPassword: z.boolean(),
Id: z.string(),
LastActivityDate: z.string(),
LastLoginDate: z.string(),
Name: z.string(),
Policy: policy,
ServerId: z.string(),
});
const authenticateParameters = z.object({
Pw: z.string(),
Username: z.string(),
});
const authenticate = z.object({
AccessToken: z.string(),
ServerId: z.string(),
SessionInfo: sessionInfo,
User: user,
});
const genreItem = z.object({
Id: z.string(),
Name: z.string(),
});
const genre = z.object({
BackdropImageTags: z.array(z.any()),
ChannelId: z.null(),
Id: z.string(),
ImageBlurHashes: imageBlurHashes,
ImageTags: imageTags,
LocationType: z.string(),
Name: z.string(),
ServerId: z.string(),
Type: z.string(),
});
const genreList = pagination.extend({
Items: z.array(genre),
});
const genreListSort = {
NAME: 'SortName',
} as const;
const genreListParameters = paginationParameters.merge(
baseParameters.extend({
SearchTerm: z.string().optional(),
SortBy: z.nativeEnum(genreListSort).optional(),
}),
);
const musicFolder = z.object({
BackdropImageTags: z.array(z.string()),
ChannelId: z.null(),
CollectionType: z.string(),
Id: z.string(),
ImageBlurHashes: imageBlurHashes,
ImageTags: imageTags,
IsFolder: z.boolean(),
LocationType: z.string(),
Name: z.string(),
ServerId: z.string(),
Type: z.string(),
UserData: userData,
});
const musicFolderListParameters = z.object({
UserId: z.string(),
});
const musicFolderList = z.object({
Items: z.array(musicFolder),
});
const playlist = z.object({
BackdropImageTags: z.array(z.string()),
ChannelId: z.null(),
ChildCount: z.number().optional(),
DateCreated: z.string(),
GenreItems: z.array(genreItem),
Genres: z.array(z.string()),
Id: z.string(),
ImageBlurHashes: imageBlurHashes,
ImageTags: imageTags,
IsFolder: z.boolean(),
LocationType: z.string(),
MediaType: z.string(),
Name: z.string(),
Overview: z.string().optional(),
RunTimeTicks: z.number(),
ServerId: z.string(),
Type: z.string(),
UserData: userData,
});
const playlistListSort = {
ALBUM_ARTIST: 'AlbumArtist,SortName',
DURATION: 'Runtime',
NAME: 'SortName',
RECENTLY_ADDED: 'DateCreated,SortName',
SONG_COUNT: 'ChildCount',
} as const;
const playlistListParameters = paginationParameters.merge(
baseParameters.extend({
IncludeItemTypes: z.literal('Playlist'),
SortBy: z.nativeEnum(playlistListSort).optional(),
}),
);
const playlistList = pagination.extend({
Items: z.array(playlist),
});
const genericItem = z.object({
Id: z.string(),
Name: z.string(),
});
const participant = z.object({
Id: z.string(),
Name: z.string(),
Type: z.string().optional(),
});
const songDetailParameters = baseParameters;
const song = z.object({
Album: z.string(),
AlbumArtist: z.string(),
AlbumArtists: z.array(genericItem),
AlbumId: z.string().optional(),
AlbumPrimaryImageTag: z.string(),
ArtistItems: z.array(genericItem),
Artists: z.array(z.string()),
BackdropImageTags: z.array(z.string()),
ChannelId: z.null(),
DateCreated: z.string(),
ExternalUrls: z.array(externalUrl),
GenreItems: z.array(genericItem),
Genres: z.array(z.string()),
Id: z.string(),
ImageBlurHashes: imageBlurHashes,
ImageTags: imageTags,
IndexNumber: z.number(),
IsFolder: z.boolean(),
LocationType: z.string(),
LUFS: z.number().optional(),
MediaSources: z.array(mediaSources),
MediaType: z.string(),
Name: z.string(),
NormalizationGain: z.number().optional(),
ParentIndexNumber: z.number(),
People: participant.array().optional(),
PlaylistItemId: z.string().optional(),
PremiereDate: z.string().optional(),
ProductionYear: z.number(),
RunTimeTicks: z.number(),
ServerId: z.string(),
SortName: z.string(),
Tags: z.string().array().optional(),
Type: z.string(),
UserData: userData.optional(),
});
const providerIds = z.object({
MusicBrainzAlbum: z.string().optional(),
MusicBrainzArtist: z.string().optional(),
});
const albumArtist = z.object({
AlbumCount: z.number().optional(),
BackdropImageTags: z.array(z.string()),
ChannelId: z.null(),
DateCreated: z.string(),
ExternalUrls: z.array(externalUrl),
GenreItems: z.array(genreItem),
Genres: z.array(z.string()),
Id: z.string(),
ImageBlurHashes: imageBlurHashes,
ImageTags: imageTags,
LocationType: z.string(),
Name: z.string(),
Overview: z.string(),
ProviderIds: providerIds.optional(),
RunTimeTicks: z.number(),
ServerId: z.string(),
SongCount: z.number().optional(),
Type: z.string(),
UserData: userData.optional(),
});
const albumDetailParameters = baseParameters;
const album = z.object({
AlbumArtist: z.string(),
AlbumArtists: z.array(genericItem),
AlbumPrimaryImageTag: z.string(),
ArtistItems: z.array(genericItem),
Artists: z.array(z.string()),
ChannelId: z.null(),
ChildCount: z.number().optional(),
DateCreated: z.string(),
DateLastMediaAdded: z.string().optional(),
ExternalUrls: z.array(externalUrl),
GenreItems: z.array(genericItem),
Genres: z.array(z.string()),
Id: z.string(),
ImageBlurHashes: imageBlurHashes,
ImageTags: imageTags,
IsFolder: z.boolean(),
LocationType: z.string(),
Name: z.string(),
ParentLogoImageTag: z.string(),
ParentLogoItemId: z.string(),
People: participant.array().optional(),
PremiereDate: z.string().optional(),
ProductionYear: z.number(),
ProviderIds: providerIds.optional(),
RunTimeTicks: z.number(),
ServerId: z.string(),
Songs: z.array(song).optional(), // This is not a native Jellyfin property -- this is used for combined album detail
Tags: z.string().array().optional(),
Type: z.string(),
UserData: userData.optional(),
});
const albumListSort = {
ALBUM_ARTIST: 'AlbumArtist,SortName',
COMMUNITY_RATING: 'CommunityRating,SortName',
CRITIC_RATING: 'CriticRating,SortName',
NAME: 'SortName',
PLAY_COUNT: 'PlayCount',
RANDOM: 'Random,SortName',
RECENTLY_ADDED: 'DateCreated,SortName',
RELEASE_DATE: 'ProductionYear,PremiereDate,SortName',
} as const;
const albumListParameters = paginationParameters.merge(
baseParameters.extend({
Filters: z.string().optional(),
GenreIds: z.string().optional(),
Genres: z.string().optional(),
IncludeItemTypes: z.literal('MusicAlbum'),
IsFavorite: z.boolean().optional(),
SearchTerm: z.string().optional(),
SortBy: z.nativeEnum(albumListSort).optional(),
Tags: z.string().optional(),
Years: z.string().optional(),
}),
);
const albumList = pagination.extend({
Items: z.array(album),
});
const albumArtistListSort = {
ALBUM: 'Album,SortName',
DURATION: 'Runtime,AlbumArtist,Album,SortName',
NAME: 'SortName,Name',
RANDOM: 'Random,SortName',
RECENTLY_ADDED: 'DateCreated,SortName',
RELEASE_DATE: 'PremiereDate,AlbumArtist,Album,SortName',
} as const;
const albumArtistListParameters = paginationParameters.merge(
baseParameters.extend({
Filters: z.string().optional(),
Genres: z.string().optional(),
SortBy: z.nativeEnum(albumArtistListSort).optional(),
Years: z.string().optional(),
}),
);
const albumArtistList = pagination.extend({
Items: z.array(albumArtist),
});
const similarArtistListParameters = baseParameters.extend({
Limit: z.number().optional(),
});
const songListSort = {
ALBUM: 'Album,SortName',
ALBUM_ARTIST: 'AlbumArtist,Album,SortName',
ALBUM_DETAIL: 'ParentIndexNumber,IndexNumber,SortName',
ARTIST: 'Artist,Album,SortName',
COMMUNITY_RATING: 'CommunityRating,SortName',
DURATION: 'Runtime,AlbumArtist,Album,SortName',
NAME: 'Name',
PLAY_COUNT: 'PlayCount,SortName',
RANDOM: 'Random,SortName',
RECENTLY_ADDED: 'DateCreated,SortName',
RECENTLY_PLAYED: 'DatePlayed,SortName',
RELEASE_DATE: 'PremiereDate,AlbumArtist,Album,SortName',
} as const;
const songListParameters = paginationParameters.merge(
baseParameters.extend({
AlbumArtistIds: z.string().optional(),
AlbumIds: z.string().optional(),
ArtistIds: z.string().optional(),
Filters: z.string().optional(),
GenreIds: z.string().optional(),
Genres: z.string().optional(),
IsFavorite: z.boolean().optional(),
IsPlayed: z.boolean().optional(),
SearchTerm: z.string().optional(),
SortBy: z.nativeEnum(songListSort).optional(),
Tags: z.string().optional(),
Years: z.string().optional(),
}),
);
const songList = pagination.extend({
Items: z.array(song),
});
const playlistSongList = songList;
const topSongsList = songList;
const playlistDetailParameters = baseParameters.extend({
Ids: z.string(),
});
const createPlaylistParameters = z.object({
IsPublic: z.boolean().optional(),
MediaType: z.literal('Audio'),
Name: z.string(),
UserId: z.string(),
});
const createPlaylist = z.object({
Id: z.string(),
});
const updatePlaylist = z.null();
const updatePlaylistParameters = z.object({
Genres: z.array(genreItem),
IsPublic: z.boolean().optional(),
MediaType: z.literal('Audio'),
Name: z.string(),
PremiereDate: z.null(),
ProviderIds: z.object({}),
Tags: z.array(genericItem),
UserId: z.string(),
});
const addToPlaylist = z.object({
Added: z.number(),
});
const addToPlaylistParameters = z.object({
Ids: z.string(),
UserId: z.string(),
});
const removeFromPlaylist = z.null();
const removeFromPlaylistParameters = z.object({
EntryIds: z.string(),
});
const deletePlaylist = z.null();
const deletePlaylistParameters = z.object({
Id: z.string(),
});
const scrobbleParameters = z.object({
EventName: z.string().optional(),
IsPaused: z.boolean().optional(),
ItemId: z.string(),
PositionTicks: z.number().optional(),
});
const scrobble = z.any();
const favorite = z.object({
IsFavorite: z.boolean(),
ItemId: z.string(),
Key: z.string(),
LastPlayedDate: z.string(),
Likes: z.boolean(),
PlaybackPositionTicks: z.number(),
PlayCount: z.number(),
Played: z.boolean(),
PlayedPercentage: z.number(),
Rating: z.number(),
UnplayedItemCount: z.number(),
});
const favoriteParameters = z.object({});
const searchParameters = paginationParameters.merge(baseParameters);
const search = z.any();
const lyricText = z.object({
Start: z.number().optional(),
Text: z.string(),
});
const lyrics = z.object({
Lyrics: z.array(lyricText),
});
const serverInfo = z.object({
Version: z.string(),
});
const similarSongsParameters = z.object({
Fields: z.string().optional(),
Limit: z.number().optional(),
UserId: z.string().optional(),
});
const similarSongs = pagination.extend({
Items: z.array(song),
});
export enum JellyfinExtensions {
SONG_LYRICS = 'songLyrics',
}
const moveItem = z.null();
const filterListParameters = z.object({
IncludeItemTypes: z.string().optional(),
ParentId: z.string().optional(),
UserId: z.string().optional(),
});
const filters = z.object({
Genres: z.string().array().optional(),
Tags: z.string().array().optional(),
Years: z.number().array().optional(),
});
export const jfType = {
_enum: {
albumArtistList: albumArtistListSort,
albumList: albumListSort,
collection: jfCollection,
external: jfExternal,
genreList: genreListSort,
image: jfImage,
playlistList: playlistListSort,
songList: songListSort,
},
_parameters: {
addToPlaylist: addToPlaylistParameters,
albumArtistDetail: baseParameters,
albumArtistList: albumArtistListParameters,
albumDetail: albumDetailParameters,
albumList: albumListParameters,
authenticate: authenticateParameters,
createPlaylist: createPlaylistParameters,
deletePlaylist: deletePlaylistParameters,
favorite: favoriteParameters,
filterList: filterListParameters,
genreList: genreListParameters,
musicFolderList: musicFolderListParameters,
playlistDetail: playlistDetailParameters,
playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters,
scrobble: scrobbleParameters,
search: searchParameters,
similarArtistList: similarArtistListParameters,
similarSongs: similarSongsParameters,
songDetail: songDetailParameters,
songList: songListParameters,
updatePlaylist: updatePlaylistParameters,
},
_response: {
addToPlaylist,
album,
albumArtist,
albumArtistList,
albumList,
authenticate,
createPlaylist,
deletePlaylist,
error,
favorite,
filters,
genre,
genreList,
lyrics,
moveItem,
musicFolderList,
playlist,
playlistList,
playlistSongList,
removeFromPlaylist,
scrobble,
search,
serverInfo,
similarSongs,
song,
songList,
topSongsList,
updatePlaylist,
user,
},
};

View file

@ -0,0 +1,533 @@
import { SSArtistInfo } from '/@/shared/api/subsonic.types';
export enum NDAlbumArtistListSort {
ALBUM_COUNT = 'albumCount',
FAVORITED = 'starred_at',
NAME = 'name',
PLAY_COUNT = 'playCount',
RATING = 'rating',
SONG_COUNT = 'songCount',
}
export enum NDAlbumListSort {
ALBUM_ARTIST = 'album_artist',
ARTIST = 'artist',
DURATION = 'duration',
NAME = 'name',
PLAY_COUNT = 'play_count',
PLAY_DATE = 'play_date',
RANDOM = 'random',
RATING = 'rating',
RECENTLY_ADDED = 'recently_added',
SONG_COUNT = 'songCount',
STARRED = 'starred_at',
YEAR = 'max_year',
}
export enum NDGenreListSort {
NAME = 'name',
}
export enum NDPlaylistListSort {
DURATION = 'duration',
NAME = 'name',
OWNER = 'owner_name',
PUBLIC = 'public',
SONG_COUNT = 'songCount',
UPDATED_AT = 'updatedAt',
}
export enum NDSongListSort {
ALBUM = 'album',
ALBUM_ARTIST = 'order_album_artist_name',
ALBUM_SONGS = 'album',
ARTIST = 'artist',
BPM = 'bpm',
CHANNELS = 'channels',
COMMENT = 'comment',
DURATION = 'duration',
FAVORITED = 'starred_at',
GENRE = 'genre',
ID = 'id',
PLAY_COUNT = 'playCount',
PLAY_DATE = 'playDate',
RANDOM = 'random',
RATING = 'rating',
RECENTLY_ADDED = 'createdAt',
TITLE = 'title',
TRACK = 'track',
YEAR = 'year',
}
export enum NDSortOrder {
ASC = 'ASC',
DESC = 'DESC',
}
export type NDAddToPlaylist = null;
export type NDAddToPlaylistBody = {
ids: string[];
};
export type NDAddToPlaylistResponse = {
added: number;
};
export type NDAlbum = {
albumArtist: string;
albumArtistId: string;
allArtistIds: string;
artist: string;
artistId: string;
compilation: boolean;
coverArtId?: string; // Removed after v0.48.0
coverArtPath?: string; // Removed after v0.48.0
createdAt: string;
duration: number;
fullText: string;
genre: string;
genres: NDGenre[];
id: string;
maxYear: number;
mbzAlbumArtistId: string;
mbzAlbumId: string;
minYear: number;
name: string;
orderAlbumArtistName: string;
orderAlbumName: string;
playCount: number;
playDate: string;
rating: number;
size: number;
songCount: number;
sortAlbumArtistName: string;
sortArtistName: string;
starred: boolean;
starredAt: string;
updatedAt: string;
} & { songs?: NDSong[] };
export type NDAlbumArtist = {
albumCount: number;
biography: string;
externalInfoUpdatedAt: string;
externalUrl: string;
fullText: string;
genres: NDGenre[];
id: string;
largeImageUrl?: string;
mbzArtistId: string;
mediumImageUrl?: string;
name: string;
orderArtistName: string;
playCount: number;
playDate: string;
rating: number;
size: number;
smallImageUrl?: string;
songCount: number;
starred: boolean;
starredAt: string;
} & {
similarArtists?: SSArtistInfo['similarArtist'];
};
export type NDAlbumArtistDetail = NDAlbumArtist;
export type NDAlbumArtistDetailResponse = NDAlbumArtist;
export type NDAlbumArtistList = {
items: NDAlbumArtist[];
startIndex: number;
totalRecordCount: number;
};
export type NDAlbumArtistListParams = NDOrder &
NDPagination & {
_sort?: NDAlbumArtistListSort;
genre_id?: string;
starred?: boolean;
};
export type NDAlbumDetail = NDAlbum & { songs?: NDSongListResponse };
export type NDAlbumDetailResponse = NDAlbum;
export type NDAlbumList = {
items: NDAlbum[];
startIndex: number;
totalRecordCount: number;
};
export type NDAlbumListParams = NDOrder &
NDPagination & {
_sort?: NDAlbumListSort;
album_id?: string;
artist_id?: string;
compilation?: boolean;
genre_id?: string;
has_rating?: boolean;
id?: string;
name?: string;
recently_played?: boolean;
starred?: boolean;
year?: number;
};
export type NDAlbumListResponse = NDAlbum[];
export type NDArtistListResponse = NDAlbumArtist[];
export type NDAuthenticate = {
id: string;
isAdmin: boolean;
name: string;
subsonicSalt: string;
subsonicToken: string;
token: string;
username: string;
};
export type NDAuthenticationResponse = NDAuthenticate;
export type NDCreatePlaylist = NDCreatePlaylistResponse;
export type NDCreatePlaylistParams = {
comment?: string;
name: string;
public?: boolean;
rules?: null | Record<string, unknown>;
};
export type NDCreatePlaylistResponse = {
id: string;
};
export type NDDeletePlaylist = NDDeletePlaylistResponse;
export type NDDeletePlaylistParams = {
id: string;
};
export type NDDeletePlaylistResponse = null;
export type NDGenre = {
id: string;
name: string;
};
export type NDGenreList = NDGenre[];
export type NDGenreListParams = NDOrder &
NDPagination & {
_sort?: NDGenreListSort;
id?: string;
};
export type NDGenreListResponse = NDGenre[];
export type NDOrder = {
_order?: NDSortOrder;
};
export type NDPagination = {
_end?: number;
_start?: number;
};
export type NDPlaylist = {
comment: string;
createdAt: string;
duration: number;
evaluatedAt: string;
id: string;
name: string;
ownerId: string;
ownerName: string;
path: string;
public: boolean;
rules: null | Record<string, unknown>;
size: number;
songCount: number;
sync: boolean;
updatedAt: string;
};
export type NDPlaylistDetail = NDPlaylist;
export type NDPlaylistDetailResponse = NDPlaylist;
export type NDPlaylistList = {
items: NDPlaylist[];
startIndex: number;
totalRecordCount: number;
};
export type NDPlaylistListParams = NDOrder &
NDPagination & {
_sort?: NDPlaylistListSort;
owner_id?: string;
};
export type NDPlaylistListResponse = NDPlaylist[];
export type NDPlaylistSong = NDSong & {
mediaFileId: string;
playlistId: string;
};
export type NDPlaylistSongList = {
items: NDPlaylistSong[];
startIndex: number;
totalRecordCount: number;
};
export type NDPlaylistSongListResponse = NDPlaylistSong[];
export type NDRemoveFromPlaylist = null;
export type NDRemoveFromPlaylistParams = {
id: string[];
};
export type NDRemoveFromPlaylistResponse = {
ids: string[];
};
export type NDSong = {
album: string;
albumArtist: string;
albumArtistId: string;
albumId: string;
artist: string;
artistId: string;
bitRate: number;
bookmarkPosition: number;
bpm?: number;
channels?: number;
comment?: string;
compilation: boolean;
createdAt: string;
discNumber: number;
duration: number;
fullText: string;
genre: string;
genres: NDGenre[];
hasCoverArt: boolean;
id: string;
lyrics?: string;
mbzAlbumArtistId: string;
mbzAlbumId: string;
mbzArtistId: string;
mbzTrackId: string;
orderAlbumArtistName: string;
orderAlbumName: string;
orderArtistName: string;
orderTitle: string;
path: string;
playCount: number;
playDate: string;
rating: number;
size: number;
sortAlbumArtistName: string;
sortArtistName: string;
starred: boolean;
starredAt: string;
suffix: string;
title: string;
trackNumber: number;
updatedAt: string;
year: number;
};
export type NDSongDetail = NDSong;
export type NDSongDetailResponse = NDSong;
export type NDSongList = {
items: NDSong[];
startIndex: number;
totalRecordCount: number;
};
export type NDSongListParams = NDOrder &
NDPagination & {
_sort?: NDSongListSort;
album_id?: string[];
artist_id?: string[];
genre_id?: string;
starred?: boolean;
};
export type NDSongListResponse = NDSong[];
export type NDUpdatePlaylistParams = Partial<NDPlaylist>;
export type NDUpdatePlaylistResponse = NDPlaylist;
export type NDUser = {
createdAt: string;
email: string;
id: string;
isAdmin: boolean;
lastAccessAt: string;
lastLoginAt: string;
name: string;
updatedAt: string;
userName: string;
};
export const NDSongQueryFields = [
{ label: 'Album', type: 'string', value: 'album' },
{ label: 'Album Artist', type: 'string', value: 'albumartist' },
{ label: 'Album Artists', type: 'string', value: 'albumartists' },
{ label: 'Album Comment', type: 'string', value: 'albumcomment' },
{ label: 'Album Type', type: 'string', value: 'albumtype' },
{ label: 'Album Version', type: 'string', value: 'albumversion' },
{ label: 'Arranger', type: 'string', value: 'arranger' },
{ label: 'Artist', type: 'string', value: 'artist' },
{ label: 'Artists', type: 'string', value: 'artists' },
{ label: 'Barcode', type: 'string', value: 'barcode' },
{ label: 'Bitrate', type: 'number', value: 'bitrate' },
{ label: 'BPM', type: 'number', value: 'bpm' },
{ label: 'Catalog Number', type: 'string', value: 'catalognumber' },
{ label: 'Channels', type: 'number', value: 'channels' },
{ label: 'Comment', type: 'string', value: 'comment' },
{ label: 'Composer', type: 'string', value: 'composer' },
{ label: 'Conductor', type: 'string', value: 'conductor' },
{ label: 'Copyright', type: 'string', value: 'copyright' },
{ label: 'Date Added', type: 'date', value: 'dateadded' },
{ label: 'Date Favorited', type: 'date', value: 'dateloved' },
{ label: 'Date Last Played', type: 'date', value: 'lastplayed' },
{ label: 'Date Modified', type: 'date', value: 'datemodified' },
{ label: 'DJ Mixer', type: 'string', value: 'djmixer' },
{ label: 'Director', type: 'string', value: 'director' },
{ label: 'Disc Number', type: 'number', value: 'discnumber' },
{ label: 'Disc Subtitle', type: 'string', value: 'discsubtitle' },
{ label: 'Disc Total', type: 'number', value: 'disctotal' },
{ label: 'Duration', type: 'number', value: 'duration' },
{ label: 'Encoded By', type: 'string', value: 'encodedby' },
{ label: 'Encoder Settings', type: 'string', value: 'encodersettings' },
{ label: 'Engineer', type: 'string', value: 'engineer' },
{ label: 'Explicit Status', type: 'string', value: 'explicitstatus' },
{ label: 'File Path', type: 'string', value: 'filepath' },
{ label: 'File Type', type: 'string', value: 'filetype' },
{ label: 'Genre', type: 'string', value: 'genre' },
{ label: 'Grouping', type: 'string', value: 'grouping' },
{ label: 'Has CoverArt', type: 'boolean', value: 'hascoverart' },
{ label: 'Is Compilation', type: 'boolean', value: 'compilation' },
{ label: 'Is Favorite', type: 'boolean', value: 'loved' },
{ label: 'ISRC', type: 'string', value: 'isrc' },
{ label: 'Key', type: 'string', value: 'key' },
{ label: 'Language', type: 'string', value: 'language' },
{ label: 'License', type: 'string', value: 'license' },
{ label: 'Lyricist', type: 'string', value: 'lyricist' },
{ label: 'Lyrics', type: 'string', value: 'lyrics' },
{ label: 'Media', type: 'string', value: 'media' },
{ label: 'Mixer', type: 'string', value: 'mixer' },
{ label: 'Mood', type: 'string', value: 'mood' },
{ label: 'Movement', type: 'string', value: 'movement' },
{ label: 'Movement Name', type: 'string', value: 'movementname' },
{ label: 'Movement Total', type: 'number', value: 'movementtotal' },
{ label: 'MusicBrainz Artist Id', type: 'string', value: 'musicbrainz_albumartistid' },
{ label: 'MusicBrainz Album Artist Id', type: 'string', value: 'musicbrainz_albumartistid' },
{ label: 'MusicBrainz Album Id', type: 'string', value: 'musicbrainz_albumid' },
{ label: 'MusicBrainz Disc Id', type: 'string', value: 'musicbrainz_discid' },
{ label: 'MusicBrainz Recording Id', type: 'string', value: 'musicbrainz_recordingid' },
{ label: 'MusicBrainz Release Group Id', type: 'string', value: 'musicbrainz_releasegroupid' },
{ label: 'MusicBrainz Track Id', type: 'string', value: 'musicbrainz_trackid' },
{ label: 'MusicBrainz Work Id', type: 'string', value: 'musicbrainz_workid' },
{ label: 'Name', type: 'string', value: 'title' },
{ label: 'Original Date', type: 'date', value: 'originaldate' },
{ label: 'Performer', type: 'string', value: 'performer' },
{ label: 'Play Count', type: 'number', value: 'playcount' },
{ label: 'Playlist', type: 'playlist', value: 'id' },
{ label: 'Producer', type: 'string', value: 'producer' },
{ label: 'R128 Album Gain', type: 'number', value: 'r128_album_gain' },
{ label: 'R128 Track Gain', type: 'number', value: 'r128_track_gain' },
{ label: 'Rating', type: 'number', value: 'rating' },
{ label: 'Record Label', type: 'string', value: 'recordlabel' },
{ label: 'Recording Date', type: 'date', value: 'recordingdate' },
{ label: 'Release Country', type: 'string', value: 'releasecountry' },
{ label: 'Release Date', type: 'date', value: 'releasedate' },
{ label: 'Release Status', type: 'string', value: 'releasestatus' },
{ label: 'Release Type', type: 'string', value: 'releasetype' },
{ label: 'ReplayGain Album Gain', type: 'number', value: 'replaygain_album_gain' },
{ label: 'ReplayGain Album Peak', type: 'number', value: 'replaygain_album_peak' },
{ label: 'ReplayGain Track Gain', type: 'number', value: 'replaygain_track_gain' },
{ label: 'ReplayGain Track Peak', type: 'number', value: 'replaygain_track_peak' },
{ label: 'Remixer', type: 'string', value: 'remixer' },
{ label: 'Script', type: 'string', value: 'script' },
{ label: 'Size', type: 'number', value: 'size' },
{ label: 'Sort Album', type: 'string', value: 'albumsort' },
{ label: 'Sort Album Artist', type: 'string', value: 'albumartistsort' },
{ label: 'Sort Album Artists', type: 'string', value: 'albumartistssort' },
{ label: 'Sort Artist', type: 'string', value: 'artistsort' },
{ label: 'Sort Artists', type: 'string', value: 'artistssort' },
{ label: 'Sort Composer', type: 'string', value: 'composersort' },
{ label: 'Sort Lyricist', type: 'string', value: 'lyricistsort' },
{ label: 'Sort Name', type: 'string', value: 'titlesort' },
{ label: 'Subtitle', type: 'string', value: 'subtitle' },
{ label: 'Track Number', type: 'number', value: 'track' },
{ label: 'Track Total', type: 'number', value: 'tracktotal' },
{ label: 'Year', type: 'number', value: 'year' },
{ label: 'Website', type: 'string', value: 'website' },
{ label: 'Work', type: 'string', value: 'work' },
];
export const NDSongQueryPlaylistOperators = [
{ label: 'is in', value: 'inPlaylist' },
{ label: 'is not in', value: 'notInPlaylist' },
];
export const NDSongQueryDateOperators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
{ label: 'is before', value: 'before' },
{ label: 'is after', value: 'after' },
{ label: 'is in the last', value: 'inTheLast' },
{ label: 'is not in the last', value: 'notInTheLast' },
{ label: 'is in the range', value: 'inTheRange' },
];
export const NDSongQueryStringOperators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
{ label: 'contains', value: 'contains' },
{ label: 'does not contain', value: 'notContains' },
{ label: 'starts with', value: 'startsWith' },
{ label: 'ends with', value: 'endsWith' },
];
export const NDSongQueryBooleanOperators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
];
export const NDSongQueryNumberOperators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
{ label: 'contains', value: 'contains' },
{ label: 'does not contain', value: 'notContains' },
{ label: 'is greater than', value: 'gt' },
{ label: 'is less than', value: 'lt' },
{ label: 'is in the range', value: 'inTheRange' },
];
export enum NDUserListSort {
NAME = 'name',
}
export type NDUserList = {
items: NDUser[];
startIndex: number;
totalRecordCount: number;
};
export type NDUserListParams = NDOrder &
NDPagination & {
_sort?: NDUserListSort;
};
export type NDUserListResponse = NDUser[];

View file

@ -0,0 +1,398 @@
import { nanoid } from 'nanoid';
import z from 'zod';
import { NDGenre } from '/@/shared/api/navidrome.types';
import { ndType } from '/@/shared/api/navidrome/navidrome-types';
import { ssType } from '/@/shared/api/subsonic/subsonic-types';
import {
Album,
AlbumArtist,
Genre,
LibraryItem,
Playlist,
RelatedArtist,
Song,
User,
} from '/@/shared/types/domain-types';
import { ServerListItem, ServerType } from '/@/shared/types/types';
const getImageUrl = (args: { url: null | string }) => {
const { url } = args;
if (url === '/app/artist-placeholder.webp') {
return null;
}
return url;
};
const getCoverArtUrl = (args: {
baseUrl: string | undefined;
coverArtId: string;
credential: string | undefined;
size: number;
}) => {
const size = args.size ? args.size : 250;
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
return null;
}
return (
`${args.baseUrl}/rest/getCoverArt.view` +
`?id=${args.coverArtId}` +
`&${args.credential}` +
'&v=1.13.0' +
'&c=Feishin' +
`&size=${size}`
);
};
interface WithDate {
playDate?: string;
}
const normalizePlayDate = (item: WithDate): null | string => {
return !item.playDate || item.playDate.includes('0001-') ? null : item.playDate;
};
const getArtists = (
item:
| z.infer<typeof ndType._response.album>
| z.infer<typeof ndType._response.playlistSong>
| z.infer<typeof ndType._response.song>,
) => {
let albumArtists: RelatedArtist[] | undefined;
let artists: RelatedArtist[] | undefined;
let participants: null | Record<string, RelatedArtist[]> = null;
if (item.participants) {
participants = {};
for (const [role, list] of Object.entries(item.participants)) {
if (role === 'albumartist' || role === 'artist') {
const roleList = list.map((item) => ({
id: item.id,
imageUrl: null,
name: item.name,
}));
if (role === 'albumartist') {
albumArtists = roleList;
} else {
artists = roleList;
}
} else {
const subRoles = new Map<string | undefined, RelatedArtist[]>();
for (const artist of list) {
const item: RelatedArtist = {
id: artist.id,
imageUrl: null,
name: artist.name,
};
if (subRoles.has(artist.subRole)) {
subRoles.get(artist.subRole)!.push(item);
} else {
subRoles.set(artist.subRole, [item]);
}
}
for (const [subRole, items] of subRoles.entries()) {
if (subRole) {
participants[`${role} (${subRole})`] = items;
} else {
participants[role] = items;
}
}
}
}
}
if (albumArtists === undefined) {
albumArtists = [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }];
}
if (artists === undefined) {
artists = [{ id: item.artistId, imageUrl: null, name: item.artist }];
}
return { albumArtists, artists, participants };
};
const normalizeSong = (
item: z.infer<typeof ndType._response.playlistSong> | z.infer<typeof ndType._response.song>,
server: null | ServerListItem,
imageSize?: number,
): Song => {
let id;
let playlistItemId;
// Dynamically determine the id field based on whether or not the item is a playlist song
if ('mediaFileId' in item) {
id = item.mediaFileId;
playlistItemId = item.id;
} else {
id = item.id;
}
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: id,
credential: server?.credential,
size: imageSize || 100,
});
const imagePlaceholderUrl = null;
return {
album: item.album,
albumId: item.albumId,
...getArtists(item),
artistName: item.artist,
bitRate: item.bitRate,
bpm: item.bpm ? item.bpm : null,
channels: item.channels ? item.channels : null,
comment: item.comment ? item.comment : null,
compilation: item.compilation,
container: item.suffix,
createdAt: item.createdAt.split('T')[0],
discNumber: item.discNumber,
discSubtitle: item.discSubtitle ? item.discSubtitle : null,
duration: item.duration * 1000,
gain:
item.rgAlbumGain || item.rgTrackGain
? { album: item.rgAlbumGain, track: item.rgTrackGain }
: null,
genres: (item.genres || []).map((genre) => ({
id: genre.id,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: genre.name,
})),
id,
imagePlaceholderUrl,
imageUrl,
itemType: LibraryItem.SONG,
lastPlayedAt: normalizePlayDate(item),
lyrics: item.lyrics ? item.lyrics : null,
name: item.title,
path: item.path,
peak:
item.rgAlbumPeak || item.rgTrackPeak
? { album: item.rgAlbumPeak, track: item.rgTrackPeak }
: null,
playCount: item.playCount || 0,
playlistItemId,
releaseDate: (item.releaseDate
? new Date(item.releaseDate)
: new Date(Date.UTC(item.year, 0, 1))
).toISOString(),
releaseYear: String(item.year),
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
size: item.size,
streamUrl: `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`,
tags: item.tags || null,
trackNumber: item.trackNumber,
uniqueId: nanoid(),
updatedAt: item.updatedAt,
userFavorite: item.starred || false,
userRating: item.rating || null,
};
};
const normalizeAlbum = (
item: z.infer<typeof ndType._response.album> & {
songs?: z.infer<typeof ndType._response.songList>;
},
server: null | ServerListItem,
imageSize?: number,
): Album => {
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArtId || item.id,
credential: server?.credential,
size: imageSize || 300,
});
const imagePlaceholderUrl = null;
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
return {
albumArtist: item.albumArtist,
...getArtists(item),
backdropImageUrl: imageBackdropUrl,
comment: item.comment || null,
createdAt: item.createdAt.split('T')[0],
duration: item.duration !== undefined ? item.duration * 1000 : null,
genres: (item.genres || []).map((genre) => ({
id: genre.id,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: genre.name,
})),
id: item.id,
imagePlaceholderUrl,
imageUrl,
isCompilation: item.compilation,
itemType: LibraryItem.ALBUM,
lastPlayedAt: normalizePlayDate(item),
mbzId: item.mbzAlbumId || null,
name: item.name,
originalDate: item.originalDate
? new Date(item.originalDate).toISOString()
: item.originalYear
? new Date(Date.UTC(item.originalYear, 0, 1)).toISOString()
: null,
playCount: item.playCount || 0,
releaseDate: (item.releaseDate
? new Date(item.releaseDate)
: new Date(Date.UTC(item.minYear, 0, 1))
).toISOString(),
releaseYear: item.minYear,
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
size: item.size,
songCount: item.songCount,
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server)) : undefined,
tags: item.tags || null,
uniqueId: nanoid(),
updatedAt: item.updatedAt,
userFavorite: item.starred,
userRating: item.rating || null,
};
};
const normalizeAlbumArtist = (
item: z.infer<typeof ndType._response.albumArtist> & {
similarArtists?: z.infer<typeof ssType._response.artistInfo>['artistInfo']['similarArtist'];
},
server: null | ServerListItem,
): AlbumArtist => {
let imageUrl = getImageUrl({ url: item?.largeImageUrl || null });
if (!imageUrl) {
imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: `ar-${item.id}`,
credential: server?.credential,
size: 300,
});
}
let albumCount: number;
let songCount: number;
if (item.stats) {
albumCount = Math.max(
item.stats.albumartist?.albumCount ?? 0,
item.stats.artist?.albumCount ?? 0,
);
songCount = Math.max(
item.stats.albumartist?.songCount ?? 0,
item.stats.artist?.songCount ?? 0,
);
} else {
albumCount = item.albumCount;
songCount = item.songCount;
}
return {
albumCount,
backgroundImageUrl: null,
biography: item.biography || null,
duration: null,
genres: (item.genres || []).map((genre) => ({
id: genre.id,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: genre.name,
})),
id: item.id,
imageUrl: imageUrl || null,
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: normalizePlayDate(item),
mbz: item.mbzArtistId || null,
name: item.name,
playCount: item.playCount || 0,
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
similarArtists:
item.similarArtists?.map((artist) => ({
id: artist.id,
imageUrl: artist?.artistImageUrl || null,
name: artist.name,
})) || null,
songCount,
userFavorite: item.starred,
userRating: item.rating,
};
};
const normalizePlaylist = (
item: z.infer<typeof ndType._response.playlist>,
server: null | ServerListItem,
imageSize?: number,
): Playlist => {
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.id,
credential: server?.credential,
size: imageSize || 300,
});
const imagePlaceholderUrl = null;
return {
description: item.comment,
duration: item.duration * 1000,
genres: [],
id: item.id,
imagePlaceholderUrl,
imageUrl,
itemType: LibraryItem.PLAYLIST,
name: item.name,
owner: item.ownerName,
ownerId: item.ownerId,
public: item.public,
rules: item?.rules || null,
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
size: item.size,
songCount: item.songCount,
sync: item.sync,
};
};
const normalizeGenre = (item: NDGenre): Genre => {
return {
albumCount: undefined,
id: item.id,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: item.name,
songCount: undefined,
};
};
const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {
return {
createdAt: item.createdAt,
email: item.email || null,
id: item.id,
isAdmin: item.isAdmin,
lastLoginAt: item.lastLoginAt,
name: item.userName,
updatedAt: item.updatedAt,
};
};
export const ndNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
genre: normalizeGenre,
playlist: normalizePlaylist,
song: normalizeSong,
user: normalizeUser,
};

View file

@ -0,0 +1,402 @@
import { z } from 'zod';
import {
NDAlbumArtistListSort,
NDAlbumListSort,
NDPlaylistListSort,
NDSongListSort,
} from '/@/shared/api/navidrome.types';
const sortOrderValues = ['ASC', 'DESC'] as const;
const error = z.string();
const paginationParameters = z.object({
_end: z.number().optional(),
_order: z.enum(sortOrderValues),
_start: z.number().optional(),
});
const authenticate = z.object({
id: z.string(),
isAdmin: z.boolean(),
name: z.string(),
subsonicSalt: z.string(),
subsonicToken: z.string(),
token: z.string(),
username: z.string(),
});
const authenticateParameters = z.object({
password: z.string(),
username: z.string(),
});
const user = z.object({
createdAt: z.string(),
email: z.string().optional(),
id: z.string(),
isAdmin: z.boolean(),
lastAccessAt: z.string(),
lastLoginAt: z.string(),
name: z.string(),
updatedAt: z.string(),
userName: z.string(),
});
const userList = z.array(user);
const ndUserListSort = {
NAME: 'name',
} as const;
const userListParameters = paginationParameters.extend({
_sort: z.nativeEnum(ndUserListSort).optional(),
});
const genre = z.object({
id: z.string(),
name: z.string(),
});
const genreListSort = {
NAME: 'name',
SONG_COUNT: 'songCount',
} as const;
const genreListParameters = paginationParameters.extend({
_sort: z.nativeEnum(genreListSort).optional(),
name: z.string().optional(),
});
const genreList = z.array(genre);
const stats = z.object({
albumCount: z.number(),
size: z.number(),
songCount: z.number(),
});
const albumArtist = z.object({
albumCount: z.number(),
biography: z.string(),
externalInfoUpdatedAt: z.string(),
externalUrl: z.string(),
fullText: z.string(),
genres: z.array(genre).nullable(),
id: z.string(),
largeImageUrl: z.string().optional(),
mbzArtistId: z.string().optional(),
mediumImageUrl: z.string().optional(),
name: z.string(),
orderArtistName: z.string(),
playCount: z.number().optional(),
playDate: z.string().optional(),
rating: z.number(),
size: z.number(),
smallImageUrl: z.string().optional(),
songCount: z.number(),
starred: z.boolean(),
starredAt: z.string(),
stats: z.record(z.string(), stats).optional(),
});
const albumArtistList = z.array(albumArtist);
const albumArtistListParameters = paginationParameters.extend({
_sort: z.nativeEnum(NDAlbumArtistListSort).optional(),
genre_id: z.string().optional(),
missing: z.boolean().optional(),
name: z.string().optional(),
role: z.string().optional(),
starred: z.boolean().optional(),
});
const participant = z.object({
id: z.string(),
name: z.string(),
subRole: z.string().optional(),
});
const participants = z.record(z.string(), z.array(participant));
const album = z.object({
albumArtist: z.string(),
albumArtistId: z.string(),
allArtistIds: z.string(),
artist: z.string(),
artistId: z.string(),
comment: z.string().optional(),
compilation: z.boolean(),
coverArtId: z.string().optional(), // Removed after v0.48.0
coverArtPath: z.string().optional(), // Removed after v0.48.0
createdAt: z.string(),
duration: z.number().optional(),
fullText: z.string(),
genre: z.string(),
genres: z.array(genre).nullable(),
id: z.string(),
maxYear: z.number(),
mbzAlbumArtistId: z.string().optional(),
mbzAlbumId: z.string().optional(),
minYear: z.number(),
name: z.string(),
orderAlbumArtistName: z.string(),
orderAlbumName: z.string(),
originalDate: z.string().optional(),
originalYear: z.number().optional(),
participants: z.optional(participants),
playCount: z.number().optional(),
playDate: z.string().optional(),
rating: z.number().optional(),
releaseDate: z.string().optional(),
size: z.number(),
songCount: z.number(),
sortAlbumArtistName: z.string(),
sortArtistName: z.string(),
starred: z.boolean(),
starredAt: z.string().optional(),
tags: z.record(z.string(), z.array(z.string())).optional(),
updatedAt: z.string(),
});
const albumList = z.array(album);
const albumListParameters = paginationParameters.extend({
_sort: z.nativeEnum(NDAlbumListSort).optional(),
album_id: z.string().optional(),
artist_id: z.string().optional(),
compilation: z.boolean().optional(),
genre_id: z.string().optional(),
has_rating: z.boolean().optional(),
id: z.string().optional(),
name: z.string().optional(),
recently_added: z.boolean().optional(),
recently_played: z.boolean().optional(),
starred: z.boolean().optional(),
year: z.number().optional(),
});
const song = z.object({
album: z.string(),
albumArtist: z.string(),
albumArtistId: z.string(),
albumId: z.string(),
artist: z.string(),
artistId: z.string(),
bitRate: z.number(),
bookmarkPosition: z.number(),
bpm: z.number().optional(),
catalogNum: z.string().optional(),
channels: z.number().optional(),
comment: z.string().optional(),
compilation: z.boolean(),
createdAt: z.string(),
discNumber: z.number(),
discSubtitle: z.string().optional(),
duration: z.number(),
embedArtPath: z.string().optional(),
externalInfoUpdatedAt: z.string().optional(),
externalUrl: z.string().optional(),
fullText: z.string(),
genre: z.string(),
genres: z.array(genre).nullable(),
hasCoverArt: z.boolean(),
id: z.string(),
imageFiles: z.string().optional(),
largeImageUrl: z.string().optional(),
lyrics: z.string().optional(),
mbzAlbumArtistId: z.string().optional(),
mbzAlbumId: z.string().optional(),
mbzArtistId: z.string().optional(),
mbzTrackId: z.string().optional(),
mediumImageUrl: z.string().optional(),
orderAlbumArtistName: z.string(),
orderAlbumName: z.string(),
orderArtistName: z.string(),
orderTitle: z.string(),
participants: z.optional(participants),
path: z.string(),
playCount: z.number().optional(),
playDate: z.string().optional(),
rating: z.number().optional(),
releaseDate: z.string().optional(),
rgAlbumGain: z.number().optional(),
rgAlbumPeak: z.number().optional(),
rgTrackGain: z.number().optional(),
rgTrackPeak: z.number().optional(),
size: z.number(),
smallImageUrl: z.string().optional(),
sortAlbumArtistName: z.string(),
sortArtistName: z.string(),
starred: z.boolean(),
starredAt: z.string().optional(),
suffix: z.string(),
tags: z.record(z.string(), z.array(z.string())).optional(),
title: z.string(),
trackNumber: z.number(),
updatedAt: z.string(),
year: z.number(),
});
const songList = z.array(song);
const songListParameters = paginationParameters.extend({
_sort: z.nativeEnum(NDSongListSort).optional(),
album_artist_id: z.array(z.string()).optional(),
album_id: z.array(z.string()).optional(),
artist_id: z.array(z.string()).optional(),
genre_id: z.array(z.string()).optional(),
path: z.string().optional(),
starred: z.boolean().optional(),
title: z.string().optional(),
year: z.number().optional(),
});
const playlist = z.object({
comment: z.string(),
createdAt: z.string(),
duration: z.number(),
evaluatedAt: z.string(),
id: z.string(),
name: z.string(),
ownerId: z.string(),
ownerName: z.string(),
path: z.string(),
public: z.boolean(),
rules: z.record(z.string(), z.any()),
size: z.number(),
songCount: z.number(),
sync: z.boolean(),
updatedAt: z.string(),
});
const playlistList = z.array(playlist);
const playlistListParameters = paginationParameters.extend({
_sort: z.nativeEnum(NDPlaylistListSort).optional(),
owner_id: z.string().optional(),
q: z.string().optional(),
smart: z.boolean().optional(),
});
const playlistSong = song.extend({
mediaFileId: z.string(),
playlistId: z.string(),
});
const playlistSongList = z.array(playlistSong);
const createPlaylist = playlist.pick({
id: true,
});
const createPlaylistParameters = z.object({
comment: z.string().optional(),
name: z.string(),
public: z.boolean().optional(),
rules: z.record(z.any()).optional(),
sync: z.boolean().optional(),
});
const updatePlaylist = playlist;
const updatePlaylistParameters = createPlaylistParameters.partial();
const deletePlaylist = z.null();
const addToPlaylist = z.object({
added: z.number(),
});
const addToPlaylistParameters = z.object({
ids: z.array(z.string()),
});
const removeFromPlaylist = z.object({
ids: z.array(z.string()),
});
const removeFromPlaylistParameters = z.object({
id: z.array(z.string()),
});
const shareItem = z.object({
id: z.string(),
});
const shareItemParameters = z.object({
description: z.string(),
downloadable: z.boolean(),
expires: z.number(),
resourceIds: z.string(),
resourceType: z.string(),
});
const moveItemParameters = z.object({
insert_before: z.string(),
});
const moveItem = z.null();
const tag = z.object({
albumCount: z.number().optional(),
id: z.string(),
songCount: z.number().optional(),
tagName: z.string(),
tagValue: z.string(),
});
const tags = z.array(tag);
export const ndType = {
_enum: {
albumArtistList: NDAlbumArtistListSort,
albumList: NDAlbumListSort,
genreList: genreListSort,
playlistList: NDPlaylistListSort,
songList: NDSongListSort,
userList: ndUserListSort,
},
_parameters: {
addToPlaylist: addToPlaylistParameters,
albumArtistList: albumArtistListParameters,
albumList: albumListParameters,
authenticate: authenticateParameters,
createPlaylist: createPlaylistParameters,
genreList: genreListParameters,
moveItem: moveItemParameters,
playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters,
shareItem: shareItemParameters,
songList: songListParameters,
updatePlaylist: updatePlaylistParameters,
userList: userListParameters,
},
_response: {
addToPlaylist,
album,
albumArtist,
albumArtistList,
albumList,
authenticate,
createPlaylist,
deletePlaylist,
error,
genre,
genreList,
moveItem,
playlist,
playlistList,
playlistSong,
playlistSongList,
removeFromPlaylist,
shareItem,
song,
songList,
tags,
updatePlaylist,
user,
userList,
},
};

View file

@ -0,0 +1,224 @@
export type SSAlbum = SSAlbumListEntry & {
song: SSSong[];
};
export type SSAlbumArtistDetail = SSAlbumArtistListEntry & { album: SSAlbumListEntry[] };
export type SSAlbumArtistDetailParams = {
id: string;
};
export type SSAlbumArtistDetailResponse = {
artist: SSAlbumArtistListEntry & {
album: SSAlbumListEntry[];
};
};
export type SSAlbumArtistList = {
items: SSAlbumArtistListEntry[];
startIndex: number;
totalRecordCount: null | number;
};
export type SSAlbumArtistListEntry = {
albumCount: string;
artistImageUrl?: string;
coverArt?: string;
id: string;
name: string;
};
export type SSAlbumArtistListParams = {
musicFolderId?: string;
};
export type SSAlbumArtistListResponse = {
artists: {
ignoredArticles: string;
index: SSArtistIndex[];
lastModified: number;
};
};
export type SSAlbumDetail = Omit<SSAlbum, 'song'> & { songs: SSSong[] };
export type SSAlbumDetailResponse = {
album: SSAlbum;
};
export type SSAlbumList = {
items: SSAlbumListEntry[];
startIndex: number;
totalRecordCount: null | number;
};
export type SSAlbumListEntry = {
album: string;
artist: string;
artistId: string;
coverArt: string;
created: string;
duration: number;
genre?: string;
id: string;
isDir: boolean;
isVideo: boolean;
name: string;
parent: string;
songCount: number;
starred?: boolean;
title: string;
userRating?: number;
year: number;
};
export type SSAlbumListParams = {
fromYear?: number;
genre?: string;
musicFolderId?: string;
offset?: number;
size?: number;
toYear?: number;
type: string;
};
export type SSAlbumListResponse = {
albumList2: {
album: SSAlbumListEntry[];
};
};
export type SSArtistIndex = {
artist: SSAlbumArtistListEntry[];
name: string;
};
export type SSArtistInfo = {
biography: string;
largeImageUrl?: string;
lastFmUrl?: string;
mediumImageUrl?: string;
musicBrainzId?: string;
similarArtist?: {
albumCount: string;
artistImageUrl?: string;
coverArt?: string;
id: string;
name: string;
}[];
smallImageUrl?: string;
};
export type SSArtistInfoParams = {
count?: number;
id: string;
includeNotPresent?: boolean;
};
export type SSArtistInfoResponse = {
artistInfo2: SSArtistInfo;
};
export type SSBaseResponse = {
serverVersion?: 'string';
status: 'string';
type?: 'string';
version: 'string';
};
export type SSFavorite = null;
export type SSFavoriteParams = {
albumId?: string;
artistId?: string;
id?: string;
};
export type SSFavoriteResponse = null;
export type SSGenre = {
albumCount?: number;
songCount?: number;
value: string;
};
export type SSGenreList = SSGenre[];
export type SSGenreListResponse = {
genres: {
genre: SSGenre[];
};
};
export type SSMusicFolder = {
id: number;
name: string;
};
export type SSMusicFolderList = SSMusicFolder[];
export type SSMusicFolderListResponse = {
musicFolders: {
musicFolder: SSMusicFolder[];
};
};
export type SSRating = null;
export type SSRatingParams = {
id: string;
rating: number;
};
export type SSRatingResponse = null;
export type SSScrobbleParams = {
id: string;
submission?: boolean;
time?: number;
};
export type SSSong = {
album: string;
albumId: string;
artist: string;
artistId?: string;
bitRate: number;
contentType: string;
coverArt: string;
created: string;
discNumber?: number;
duration: number;
genre: string;
id: string;
isDir: boolean;
isVideo: boolean;
parent: string;
path: string;
playCount: number;
size: number;
starred?: boolean;
suffix: string;
title: string;
track: number;
type: string;
userRating?: number;
year: number;
};
export type SSTopSongList = {
items: SSSong[];
startIndex: number;
totalRecordCount: null | number;
};
export type SSTopSongListParams = {
artist: string;
count?: number;
};
export type SSTopSongListResponse = {
topSongs: {
song: SSSong[];
};
};

View file

@ -0,0 +1,327 @@
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { ssType } from '/@/shared/api/subsonic/subsonic-types';
import {
Album,
AlbumArtist,
Genre,
LibraryItem,
Playlist,
QueueSong,
RelatedArtist,
ServerListItem,
ServerType,
} from '/@/shared/types/domain-types';
const getCoverArtUrl = (args: {
baseUrl: string | undefined;
coverArtId?: string;
credential: string | undefined;
size: number;
}) => {
const size = args.size ? args.size : 250;
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
return null;
}
return (
`${args.baseUrl}/rest/getCoverArt.view` +
`?id=${args.coverArtId}` +
`&${args.credential}` +
'&v=1.13.0' +
'&c=Feishin' +
`&size=${size}`
);
};
const getArtists = (
item:
| z.infer<typeof ssType._response.album>
| z.infer<typeof ssType._response.albumListEntry>
| z.infer<typeof ssType._response.song>,
) => {
const albumArtists: RelatedArtist[] = item.albumArtists
? item.albumArtists.map((item) => ({
id: item.id.toString(),
imageUrl: null,
name: item.name,
}))
: [
{
id: item.artistId?.toString() || '',
imageUrl: null,
name: item.artist || '',
},
];
const artists: RelatedArtist[] = item.artists
? item.artists.map((item) => ({
id: item.id.toString(),
imageUrl: null,
name: item.name,
}))
: [
{
id: item.artistId?.toString() || '',
imageUrl: null,
name: item.artist || '',
},
];
let participants: null | Record<string, RelatedArtist[]> = null;
if (item.contributors) {
participants = {};
for (const contributor of item.contributors) {
const artist = {
id: contributor.artist.id?.toString() || '',
imageUrl: null,
name: contributor.artist.name || '',
};
const role = contributor.subRole
? `${contributor.role} (${contributor.subRole})`
: contributor.role;
if (role in participants) {
participants[role].push(artist);
} else {
participants[role] = [artist];
}
}
}
return { albumArtists, artists, participants };
};
const getGenres = (
item:
| z.infer<typeof ssType._response.album>
| z.infer<typeof ssType._response.albumListEntry>
| z.infer<typeof ssType._response.song>,
): Genre[] => {
return item.genres
? item.genres.map((genre) => ({
id: genre.name,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: genre.name,
}))
: item.genre
? [
{
id: item.genre,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: item.genre,
},
]
: [];
};
const normalizeSong = (
item: z.infer<typeof ssType._response.song>,
server: null | ServerListItem,
size?: number,
): QueueSong => {
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt?.toString(),
credential: server?.credential,
size: size || 300,
}) || null;
const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=Feishin&${server?.credential}`;
return {
album: item.album || '',
albumId: item.albumId?.toString() || '',
artistName: item.artist || '',
...getArtists(item),
bitRate: item.bitRate || 0,
bpm: item.bpm || null,
channels: null,
comment: null,
compilation: null,
container: item.contentType,
createdAt: item.created,
discNumber: item.discNumber || 1,
discSubtitle: null,
duration: item.duration ? item.duration * 1000 : 0,
gain:
item.replayGain && (item.replayGain.albumGain || item.replayGain.trackGain)
? {
album: item.replayGain.albumGain,
track: item.replayGain.trackGain,
}
: null,
genres: getGenres(item),
id: item.id.toString(),
imagePlaceholderUrl: null,
imageUrl,
itemType: LibraryItem.SONG,
lastPlayedAt: null,
lyrics: null,
name: item.title,
path: item.path,
peak:
item.replayGain && (item.replayGain.albumPeak || item.replayGain.trackPeak)
? {
album: item.replayGain.albumPeak,
track: item.replayGain.trackPeak,
}
: null,
playCount: item?.playCount || 0,
releaseDate: null,
releaseYear: item.year ? String(item.year) : null,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
size: item.size,
streamUrl,
tags: null,
trackNumber: item.track || 1,
uniqueId: nanoid(),
updatedAt: '',
userFavorite: item.starred || false,
userRating: item.userRating || null,
};
};
const normalizeAlbumArtist = (
item:
| z.infer<typeof ssType._response.albumArtist>
| z.infer<typeof ssType._response.artistListEntry>,
server: null | ServerListItem,
imageSize?: number,
): AlbumArtist => {
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt?.toString(),
credential: server?.credential,
size: imageSize || 100,
}) || null;
return {
albumCount: item.albumCount ? Number(item.albumCount) : 0,
backgroundImageUrl: null,
biography: null,
duration: null,
genres: [],
id: item.id.toString(),
imageUrl,
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: null,
mbz: null,
name: item.name,
playCount: null,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
similarArtists: [],
songCount: null,
userFavorite: false,
userRating: null,
};
};
const normalizeAlbum = (
item: z.infer<typeof ssType._response.album> | z.infer<typeof ssType._response.albumListEntry>,
server: null | ServerListItem,
imageSize?: number,
): Album => {
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt?.toString(),
credential: server?.credential,
size: imageSize || 300,
}) || null;
return {
albumArtist: item.artist,
...getArtists(item),
backdropImageUrl: null,
comment: null,
createdAt: item.created,
duration: item.duration * 1000,
genres: getGenres(item),
id: item.id.toString(),
imagePlaceholderUrl: null,
imageUrl,
isCompilation: null,
itemType: LibraryItem.ALBUM,
lastPlayedAt: null,
mbzId: null,
name: item.name,
originalDate: null,
playCount: null,
releaseDate: item.year ? new Date(Date.UTC(item.year, 0, 1)).toISOString() : null,
releaseYear: item.year ? Number(item.year) : null,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
size: null,
songCount: item.songCount,
songs:
(item as z.infer<typeof ssType._response.album>).song?.map((song) =>
normalizeSong(song, server),
) || [],
tags: item.tags || null,
uniqueId: nanoid(),
updatedAt: item.created,
userFavorite: item.starred || false,
userRating: item.userRating || null,
};
};
const normalizePlaylist = (
item:
| z.infer<typeof ssType._response.playlist>
| z.infer<typeof ssType._response.playlistListEntry>,
server: null | ServerListItem,
): Playlist => {
return {
description: item.comment || null,
duration: item.duration,
genres: [],
id: item.id.toString(),
imagePlaceholderUrl: null,
imageUrl: getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt?.toString(),
credential: server?.credential,
size: 300,
}),
itemType: LibraryItem.PLAYLIST,
name: item.name,
owner: item.owner,
ownerId: item.owner,
public: item.public,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
size: null,
songCount: item.songCount,
};
};
const normalizeGenre = (item: z.infer<typeof ssType._response.genre>): Genre => {
return {
albumCount: item.albumCount,
id: item.value,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: item.value,
songCount: item.songCount,
};
};
export const ssNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
genre: normalizeGenre,
playlist: normalizePlaylist,
song: normalizeSong,
};

View file

@ -0,0 +1,609 @@
import { z } from 'zod';
const baseResponse = z.object({
'subsonic-response': z.object({
status: z.string(),
version: z.string(),
}),
});
const authenticate = z.null();
const authenticateParameters = z.object({
c: z.string(),
f: z.string(),
p: z.string().optional(),
s: z.string().optional(),
t: z.string().optional(),
u: z.string(),
v: z.string(),
});
const id = z.number().or(z.string());
const createFavoriteParameters = z.object({
albumId: z.array(z.string()).optional(),
artistId: z.array(z.string()).optional(),
id: z.array(z.string()).optional(),
});
const createFavorite = z.null();
const removeFavoriteParameters = z.object({
albumId: z.array(z.string()).optional(),
artistId: z.array(z.string()).optional(),
id: z.array(z.string()).optional(),
});
const removeFavorite = z.null();
const setRatingParameters = z.object({
id: z.string(),
rating: z.number(),
});
const setRating = z.null();
const musicFolder = z.object({
id,
name: z.string(),
});
const musicFolderList = z.object({
musicFolders: z.object({
musicFolder: z.array(musicFolder),
}),
});
const songGain = z.object({
albumGain: z.number().optional(),
albumPeak: z.number().optional(),
trackGain: z.number().optional(),
trackPeak: z.number().optional(),
});
const genreItem = z.object({
name: z.string(),
});
const simpleArtist = z.object({
id: z.string(),
name: z.string(),
});
const contributor = z.object({
artist: simpleArtist,
role: z.string(),
subRole: z.string().optional(),
});
const song = z.object({
album: z.string().optional(),
albumArtists: z.array(simpleArtist),
albumId: id.optional(),
artist: z.string().optional(),
artistId: id.optional(),
artists: z.array(simpleArtist),
averageRating: z.number().optional(),
bitRate: z.number().optional(),
bpm: z.number().optional(),
contentType: z.string(),
contributors: z.array(contributor).optional(),
coverArt: z.string().optional(),
created: z.string(),
discNumber: z.number(),
duration: z.number().optional(),
genre: z.string().optional(),
genres: z.array(genreItem).optional(),
id,
isDir: z.boolean(),
isVideo: z.boolean(),
musicBrainzId: z.string().optional(),
parent: z.string(),
path: z.string(),
playCount: z.number().optional(),
replayGain: songGain.optional(),
size: z.number(),
starred: z.boolean().optional(),
suffix: z.string(),
title: z.string(),
track: z.number().optional(),
type: z.string(),
userRating: z.number().optional(),
year: z.number().optional(),
});
const album = z.object({
album: z.string(),
albumArtists: z.array(simpleArtist),
artist: z.string(),
artistId: id,
artists: z.array(simpleArtist),
contributors: z.array(contributor).optional(),
coverArt: z.string(),
created: z.string(),
duration: z.number(),
genre: z.string().optional(),
genres: z.array(genreItem).optional(),
id,
isCompilation: z.boolean().optional(),
isDir: z.boolean(),
isVideo: z.boolean(),
name: z.string(),
parent: z.string(),
song: z.array(song),
songCount: z.number(),
starred: z.boolean().optional(),
title: z.string(),
userRating: z.number().optional(),
year: z.number().optional(),
});
const albumListEntry = album.omit({
song: true,
});
const albumListParameters = z.object({
fromYear: z.number().optional(),
genre: z.string().optional(),
musicFolderId: z.string().optional(),
offset: z.number().optional(),
size: z.number().optional(),
toYear: z.number().optional(),
type: z.string().optional(),
});
const albumList = z.array(album.omit({ song: true }));
const albumArtist = z.object({
album: z.array(album).optional(),
albumCount: z.string(),
artistImageUrl: z.string().optional(),
coverArt: z.string().optional(),
id,
name: z.string(),
roles: z.array(z.string()).optional(),
starred: z.string().optional(),
});
const albumArtistList = z.object({
artist: z.array(albumArtist),
name: z.string(),
});
const artistListEntry = albumArtist.pick({
albumCount: true,
coverArt: true,
id: true,
name: true,
roles: true,
starred: true,
});
const artistInfoParameters = z.object({
count: z.number().optional(),
id: z.string(),
includeNotPresent: z.boolean().optional(),
});
const artistInfo = z.object({
artistInfo: z.object({
biography: z.string().optional(),
largeImageUrl: z.string().optional(),
lastFmUrl: z.string().optional(),
mediumImageUrl: z.string().optional(),
musicBrainzId: z.string().optional(),
similarArtist: z.array(
z.object({
albumCount: z.string(),
artistImageUrl: z.string().optional(),
coverArt: z.string().optional(),
id: z.string(),
name: z.string(),
}),
),
smallImageUrl: z.string().optional(),
}),
});
const topSongsListParameters = z.object({
artist: z.string(), // The name of the artist, not the artist ID
count: z.number().optional(),
});
const topSongsList = z.object({
topSongs: z
.object({
song: z.array(song),
})
.optional(),
});
const scrobbleParameters = z.object({
id: z.string(),
submission: z.boolean().optional(),
time: z.number().optional(), // The time (in milliseconds since 1 Jan 1970) at which the song was listened to.
});
const scrobble = z.null();
const search3 = z.object({
searchResult3: z
.object({
album: z.array(album).optional(),
artist: z.array(albumArtist).optional(),
song: z.array(song).optional(),
})
.optional(),
});
const search3Parameters = z.object({
albumCount: z.number().optional(),
albumOffset: z.number().optional(),
artistCount: z.number().optional(),
artistOffset: z.number().optional(),
musicFolderId: z.string().optional(),
query: z.string().optional(),
songCount: z.number().optional(),
songOffset: z.number().optional(),
});
const randomSongListParameters = z.object({
fromYear: z.number().optional(),
genre: z.string().optional(),
musicFolderId: z.string().optional(),
size: z.number().optional(),
toYear: z.number().optional(),
});
const randomSongList = z.object({
randomSongs: z
.object({
song: z.array(song),
})
.optional(),
});
const ping = z.object({
openSubsonic: z.boolean().optional(),
serverVersion: z.string().optional(),
version: z.string(),
});
const extension = z.object({
name: z.string(),
versions: z.number().array(),
});
const serverInfo = z.object({
openSubsonicExtensions: z.array(extension).optional(),
});
const structuredLyricsParameters = z.object({
id: z.string(),
});
const lyricLine = z.object({
start: z.number().optional(),
value: z.string(),
});
const structuredLyric = z.object({
displayArtist: z.string().optional(),
displayTitle: z.string().optional(),
lang: z.string(),
line: z.array(lyricLine),
offset: z.number().optional(),
synced: z.boolean(),
});
const structuredLyrics = z.object({
lyricsList: z
.object({
structuredLyrics: z.array(structuredLyric).optional(),
})
.optional(),
});
const similarSongsParameters = z.object({
count: z.number().optional(),
id: z.string(),
});
const similarSongs = z.object({
similarSongs: z
.object({
song: z.array(song),
})
.optional(),
});
export enum SubsonicExtensions {
FORM_POST = 'formPost',
SONG_LYRICS = 'songLyrics',
TRANSCODE_OFFSET = 'transcodeOffset',
}
const updatePlaylistParameters = z.object({
comment: z.string().optional(),
name: z.string().optional(),
playlistId: z.string(),
public: z.boolean().optional(),
songIdToAdd: z.array(z.string()).optional(),
songIndexToRemove: z.array(z.string()).optional(),
});
const getStarredParameters = z.object({
musicFolderId: z.string().optional(),
});
const getStarred = z.object({
starred: z
.object({
album: z.array(albumListEntry),
artist: z.array(artistListEntry),
song: z.array(song),
})
.optional(),
});
const getSongsByGenreParameters = z.object({
count: z.number().optional(),
genre: z.string(),
musicFolderId: z.string().optional(),
offset: z.number().optional(),
});
const getSongsByGenre = z.object({
songsByGenre: z
.object({
song: z.array(song),
})
.optional(),
});
const getAlbumParameters = z.object({
id: z.string(),
musicFolderId: z.string().optional(),
});
const getAlbum = z.object({
album,
});
const getArtistParameters = z.object({
id: z.string(),
});
const getArtist = z.object({
artist: albumArtist,
});
const getSongParameters = z.object({
id: z.string(),
});
const getSong = z.object({
song,
});
const getArtistsParameters = z.object({
musicFolderId: z.string().optional(),
});
const getArtists = z.object({
artists: z.object({
ignoredArticles: z.string(),
index: z.array(
z.object({
artist: z.array(artistListEntry),
name: z.string(),
}),
),
}),
});
const deletePlaylistParameters = z.object({
id: z.string(),
});
const createPlaylistParameters = z.object({
name: z.string(),
playlistId: z.string().optional(),
songId: z.array(z.string()).optional(),
});
const playlist = z.object({
changed: z.string().optional(),
comment: z.string().optional(),
coverArt: z.string().optional(),
created: z.string(),
duration: z.number(),
entry: z.array(song).optional(),
id,
name: z.string(),
owner: z.string(),
public: z.boolean(),
songCount: z.number(),
});
const createPlaylist = z.object({
playlist,
});
const getPlaylistsParameters = z.object({
username: z.string().optional(),
});
const playlistListEntry = playlist.omit({
entry: true,
});
const getPlaylists = z.object({
playlists: z
.object({
playlist: z.array(playlistListEntry),
})
.optional(),
});
const getPlaylistParameters = z.object({
id: z.string(),
});
const getPlaylist = z.object({
playlist,
});
const genre = z.object({
albumCount: z.number(),
songCount: z.number(),
value: z.string(),
});
const getGenresParameters = z.object({});
const getGenres = z.object({
genres: z
.object({
genre: z.array(genre),
})
.optional(),
});
export enum AlbumListSortType {
ALPHABETICAL_BY_ARTIST = 'alphabeticalByArtist',
ALPHABETICAL_BY_NAME = 'alphabeticalByName',
BY_GENRE = 'byGenre',
BY_YEAR = 'byYear',
FREQUENT = 'frequent',
NEWEST = 'newest',
RANDOM = 'random',
RECENT = 'recent',
STARRED = 'starred',
}
const getAlbumList2Parameters = z
.object({
fromYear: z.number().optional(),
genre: z.string().optional(),
musicFolderId: z.string().optional(),
offset: z.number().optional(),
size: z.number().optional(),
toYear: z.number().optional(),
type: z.nativeEnum(AlbumListSortType),
})
.refine(
(val) => {
if (val.type === AlbumListSortType.BY_YEAR) {
return val.fromYear !== undefined && val.toYear !== undefined;
}
return true;
},
{
message: 'Parameters "fromYear" and "toYear" are required when using sort "byYear"',
},
)
.refine(
(val) => {
if (val.type === AlbumListSortType.BY_GENRE) {
return val.genre !== undefined;
}
return true;
},
{ message: 'Parameter "genre" is required when using sort "byGenre"' },
);
const getAlbumList2 = z.object({
albumList2: z.object({
album: z.array(albumListEntry),
}),
});
const albumInfoParameters = z.object({
id: z.string(),
});
const albumInfo = z.object({
albumInfo: z.object({
largeImageUrl: z.string().optional(),
lastFmUrl: z.string().optional(),
mediumImageUrl: z.string().optional(),
musicBrainzId: z.string().optional(),
notes: z.string().optional(),
smallImageUrl: z.string().optional(),
}),
});
export const ssType = {
_parameters: {
albumInfo: albumInfoParameters,
albumList: albumListParameters,
artistInfo: artistInfoParameters,
authenticate: authenticateParameters,
createFavorite: createFavoriteParameters,
createPlaylist: createPlaylistParameters,
deletePlaylist: deletePlaylistParameters,
getAlbum: getAlbumParameters,
getAlbumList2: getAlbumList2Parameters,
getArtist: getArtistParameters,
getArtists: getArtistsParameters,
getGenre: getGenresParameters,
getGenres: getGenresParameters,
getPlaylist: getPlaylistParameters,
getPlaylists: getPlaylistsParameters,
getSong: getSongParameters,
getSongsByGenre: getSongsByGenreParameters,
getStarred: getStarredParameters,
randomSongList: randomSongListParameters,
removeFavorite: removeFavoriteParameters,
scrobble: scrobbleParameters,
search3: search3Parameters,
setRating: setRatingParameters,
similarSongs: similarSongsParameters,
structuredLyrics: structuredLyricsParameters,
topSongsList: topSongsListParameters,
updatePlaylist: updatePlaylistParameters,
},
_response: {
album,
albumArtist,
albumArtistList,
albumInfo,
albumList,
albumListEntry,
artistInfo,
artistListEntry,
authenticate,
baseResponse,
createFavorite,
createPlaylist,
genre,
getAlbum,
getAlbumList2,
getArtist,
getArtists,
getGenres,
getPlaylist,
getPlaylists,
getSong,
getSongsByGenre,
getStarred,
musicFolderList,
ping,
playlist,
playlistListEntry,
randomSongList,
removeFavorite,
scrobble,
search3,
serverInfo,
setRating,
similarSongs,
song,
structuredLyrics,
topSongsList,
},
};

113
src/shared/api/utils.ts Normal file
View file

@ -0,0 +1,113 @@
import { AxiosHeaders } from 'axios';
import isElectron from 'is-electron';
import semverCoerce from 'semver/functions/coerce';
import semverGte from 'semver/functions/gte';
import { z } from 'zod';
import { ServerListItem } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
// Since ts-rest client returns a strict response type, we need to add the headers to the body object
export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
return z.object({
data: itemSchema,
headers: z.instanceof(AxiosHeaders),
});
};
export const resultSubsonicBaseResponse = <ItemType extends z.ZodRawShape>(
itemSchema: ItemType,
) => {
return z.object({
'subsonic-response': z
.object({
status: z.string(),
version: z.string(),
})
.extend(itemSchema),
});
};
export const hasFeature = (server: null | ServerListItem, feature: ServerFeature): boolean => {
if (!server || !server.features) {
return false;
}
return (server.features[feature]?.length || 0) > 0;
};
export type VersionInfo = ReadonlyArray<[string, Record<string, readonly number[]>]>;
/**
* Returns the available server features given the version string.
* @param versionInfo a list, in DECREASING VERSION order, of the features supported by the server.
* The first version match will automatically consider the rest matched.
* @example
* ```
* // The CORRECT way to order
* const VERSION_INFO: VersionInfo = [
* ['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],
* ['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],
* ];
* // INCORRECT way to order
* const VERSION_INFO: VersionInfo = [
* ['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],
* ['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],
* ];
* ```
* @param version the version string (SemVer)
* @returns a Record containing the matched features (if any) and their versions
*/
export const getFeatures = (
versionInfo: VersionInfo,
version: string,
): Record<string, number[]> => {
const cleanVersion = semverCoerce(version);
const features: Record<string, number[]> = {};
let matched = cleanVersion === null;
for (const [version, supportedFeatures] of versionInfo) {
if (!matched) {
matched = semverGte(cleanVersion!, version);
}
if (matched) {
for (const [feature, feat] of Object.entries(supportedFeatures)) {
if (feature in features) {
features[feature].push(...feat);
} else {
features[feature] = [...feat];
}
}
}
}
return features;
};
export const getClientType = (): string => {
if (isElectron()) {
return 'Desktop Client';
}
const agent = navigator.userAgent;
switch (true) {
case agent.toLowerCase().indexOf('edge') > -1:
return 'Microsoft Edge';
case agent.toLowerCase().indexOf('edg/') > -1:
return 'Edge Chromium'; // Match also / to avoid matching for the older Edge
case agent.toLowerCase().indexOf('opr') > -1:
return 'Opera';
case agent.toLowerCase().indexOf('chrome') > -1:
return 'Chrome';
case agent.toLowerCase().indexOf('trident') > -1:
return 'Internet Explorer';
case agent.toLowerCase().indexOf('firefox') > -1:
return 'Firefox';
case agent.toLowerCase().indexOf('safari') > -1:
return 'Safari';
default:
return 'PC';
}
};
export const SEPARATOR_STRING = ' · ';

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
// Should follow a strict naming convention: "<FEATURE GROUP>_<FEATURE NAME>"
// For example: <FEATURE GROUP>: "Playlists", <FEATURE NAME>: "Smart" = "PLAYLISTS_SMART"
export enum ServerFeature {
BFR = 'bfr',
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
PLAYLISTS_SMART = 'playlistsSmart',
PUBLIC_PLAYLIST = 'publicPlaylist',
SHARING_ALBUM_SONG = 'sharingAlbumSong',
TAGS = 'tags',
}
export type ServerFeatures = Partial<Record<ServerFeature, number[]>>;

245
src/shared/types/types.ts Normal file
View file

@ -0,0 +1,245 @@
import { AppRoute } from '@ts-rest/core';
import { ReactNode } from 'react';
import { Song } from 'src/main/features/core/lyrics/netease';
import {
Album,
AlbumArtist,
Artist,
LibraryItem,
Playlist,
QueueSong,
} from '/@/shared/types/domain-types';
import { ServerFeatures } from '/@/shared/types/features-types';
export enum ListDisplayType {
CARD = 'card',
POSTER = 'poster',
TABLE = 'table',
TABLE_PAGINATED = 'paginatedTable',
}
export enum Platform {
LINUX = 'linux',
MACOS = 'macos',
WEB = 'web',
WINDOWS = 'windows',
}
export enum ServerType {
JELLYFIN = 'jellyfin',
NAVIDROME = 'navidrome',
SUBSONIC = 'subsonic',
}
export type CardRoute = {
route: AppRoute | string;
slugs?: RouteSlug[];
};
export type CardRow<T> = {
arrayProperty?: string;
format?: (value: T) => ReactNode;
property: keyof T;
route?: CardRoute;
};
export type RouteSlug = {
idProperty: string;
slugProperty: string;
};
export type TablePagination = {
currentPage: number;
itemsPerPage: number;
totalItems: number;
totalPages: number;
};
export type TableType =
| 'albumDetail'
| 'fullScreen'
| 'nowPlaying'
| 'sideDrawerQueue'
| 'sideQueue'
| 'songs';
export const toServerType = (value?: string): null | ServerType => {
switch (value?.toLowerCase()) {
case ServerType.JELLYFIN:
return ServerType.JELLYFIN;
case ServerType.NAVIDROME:
return ServerType.NAVIDROME;
case ServerType.SUBSONIC:
return ServerType.SUBSONIC;
default:
return null;
}
};
export enum AuthState {
INVALID = 'invalid',
LOADING = 'loading',
VALID = 'valid',
}
export enum CrossfadeStyle {
CONSTANT_POWER = 'constantPower',
CONSTANT_POWER_SLOW_CUT = 'constantPowerSlowCut',
CONSTANT_POWER_SLOW_FADE = 'constantPowerSlowFade',
DIPPED = 'dipped',
EQUALPOWER = 'equalPower',
LINEAR = 'linear',
}
export enum FontType {
BUILT_IN = 'builtIn',
CUSTOM = 'custom',
SYSTEM = 'system',
}
export enum Play {
LAST = 'last',
NEXT = 'next',
NOW = 'now',
SHUFFLE = 'shuffle',
}
export enum PlaybackStyle {
CROSSFADE = 'crossfade',
GAPLESS = 'gapless',
}
export enum PlaybackType {
LOCAL = 'local',
WEB = 'web',
}
export enum PlayerRepeat {
ALL = 'all',
NONE = 'none',
ONE = 'one',
}
export enum PlayerShuffle {
ALBUM = 'album',
NONE = 'none',
TRACK = 'track',
}
export enum PlayerStatus {
PAUSED = 'paused',
PLAYING = 'playing',
}
export enum TableColumn {
ACTIONS = 'actions',
ALBUM = 'album',
ALBUM_ARTIST = 'albumArtist',
ALBUM_COUNT = 'albumCount',
ARTIST = 'artist',
BIOGRAPHY = 'biography',
BIT_RATE = 'bitRate',
BPM = 'bpm',
CHANNELS = 'channels',
CODEC = 'codec',
COMMENT = 'comment',
DATE_ADDED = 'dateAdded',
DISC_NUMBER = 'discNumber',
DURATION = 'duration',
GENRE = 'genre',
LAST_PLAYED = 'lastPlayedAt',
OWNER = 'username',
PATH = 'path',
PLAY_COUNT = 'playCount',
RELEASE_DATE = 'releaseDate',
ROW_INDEX = 'rowIndex',
SIZE = 'size',
SKIP = 'skip',
SONG_COUNT = 'songCount',
TITLE = 'title',
TITLE_COMBINED = 'titleCombined',
TRACK_NUMBER = 'trackNumber',
USER_FAVORITE = 'userFavorite',
USER_RATING = 'userRating',
YEAR = 'releaseYear',
}
export type GridCardData = {
cardControls: any;
cardRows: CardRow<Album | AlbumArtist | Artist | Playlist | Song>[];
columnCount: number;
display: ListDisplayType;
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
itemCount: number;
itemData: any[];
itemGap: number;
itemHeight: number;
itemType: LibraryItem;
itemWidth: number;
playButtonBehavior: Play;
resetInfiniteLoaderCache: () => void;
route: CardRoute;
};
export type PlayQueueAddOptions = {
byData?: QueueSong[];
byItemType?: {
id: string[];
type: LibraryItem;
};
initialIndex?: number;
initialSongId?: string;
playType: Play;
query?: Record<string, any>;
};
export type QueryBuilderGroup = {
group: QueryBuilderGroup[];
rules: QueryBuilderRule[];
type: 'all' | 'any';
uniqueId: string;
};
export type QueryBuilderRule = {
field?: null | string;
operator?: null | string;
uniqueId: string;
value?: any | Date | null | number | string | undefined;
};
export type ServerListItem = {
credential: string;
features?: ServerFeatures;
id: string;
name: string;
ndCredential?: string;
savePassword?: boolean;
type: ServerType;
url: string;
userId: null | string;
username: string;
version?: string;
};
export type SongState = {
position?: number;
repeat?: PlayerRepeat;
shuffle?: boolean;
song?: QueueSong;
status?: PlayerStatus;
/** This volume is in range 0-100 */
volume?: number;
};
export type TitleTheme = 'dark' | 'light' | 'system';
export interface UniqueId {
uniqueId: string;
}
export type WebAudio = {
context: AudioContext;
gain: GainNode;
};