add folder browsing support (#315)

This commit is contained in:
jeffvli
2025-12-02 21:30:44 -08:00
parent 355257104d
commit 917bf91583
53 changed files with 2382 additions and 299 deletions
+16 -11
View File
@@ -4,6 +4,7 @@ import { jfType } from '/@/shared/api/jellyfin/jellyfin-types';
import {
Album,
AlbumArtist,
Folder,
Genre,
LibraryItem,
MusicFolder,
@@ -496,17 +497,20 @@ const normalizeGenre = (
};
};
// 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 normalizeFolder = (
item: z.infer<typeof jfType._response.folder>,
server: null | ServerListItem,
): Folder => {
return {
_itemType: LibraryItem.FOLDER,
_serverId: server?.id || 'unknown',
_serverType: ServerType.JELLYFIN,
children: undefined,
id: item.Id,
name: item.Name || 'Unknown folder',
parentId: item.ParentId,
};
};
// const normalizeScanStatus = () => {
// return {
@@ -518,6 +522,7 @@ const normalizeGenre = (
export const jfNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
folder: normalizeFolder,
genre: normalizeGenre,
musicFolder: normalizeMusicFolder,
playlist: normalizePlaylist,
+33 -1
View File
@@ -487,6 +487,7 @@ const song = z.object({
MediaType: z.string(),
Name: z.string(),
NormalizationGain: z.number().optional(),
ParentId: z.string().optional(),
ParentIndexNumber: z.number(),
People: participant.array().optional(),
PlaylistItemId: z.string().optional(),
@@ -495,7 +496,7 @@ const song = z.object({
ProviderIds: providerIds.optional(),
RunTimeTicks: z.number(),
ServerId: z.string(),
SortName: z.string(),
SortName: z.string().optional(),
Tags: z.string().array().optional(),
Type: z.string(),
UserData: userData.optional(),
@@ -772,6 +773,34 @@ const filters = z.object({
Years: z.number().array().optional(),
});
const folder = 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(),
MediaType: z.string(),
Name: z.string(),
ParentId: z.string().optional(),
ServerId: z.string(),
Type: z.string(),
UserData: userData.optional(),
});
const folderList = pagination.extend({
Items: z.array(folder),
});
const folderParameters = z.object({
Fields: z.string().optional(),
ParentId: z.string().optional(),
SortBy: z.string().optional(),
SortOrder: z.enum(sortOrderValues).optional(),
});
export const jfType = {
_enum: {
albumArtistList: albumArtistListSort,
@@ -794,6 +823,7 @@ export const jfType = {
deletePlaylist: deletePlaylistParameters,
favorite: favoriteParameters,
filterList: filterListParameters,
folder: folderParameters,
genreList: genreListParameters,
musicFolderList: musicFolderListParameters,
playlistDetail: playlistDetailParameters,
@@ -819,6 +849,8 @@ export const jfType = {
error,
favorite,
filters,
folder,
folderList,
genre,
genreList,
lyrics,
@@ -5,6 +5,7 @@ import {
Album,
AlbumArtist,
ExplicitStatus,
Folder,
Genre,
LibraryItem,
Playlist,
@@ -342,9 +343,48 @@ const normalizeGenre = (
};
};
const normalizeFolder = (
item: z.infer<typeof ssType._response.directory>,
server?: null | ServerListItemWithCredential,
): Folder => {
const results = item.child?.reduce(
(acc: { folders: Folder[]; songs: Song[] }, item) => {
const isDirectory = item.isDir === true;
if (isDirectory) {
const folder = normalizeFolder(item, server);
acc.folders.push(folder);
} else {
const song = normalizeSong(item, server);
acc.songs.push(song);
}
return acc;
},
{
folders: [],
songs: [],
},
);
return {
_itemType: LibraryItem.FOLDER,
_serverId: server?.id || 'unknown',
_serverType: ServerType.SUBSONIC,
children: {
folders: results?.folders || [],
songs: results?.songs || [],
},
id: item.id.toString(),
name: item.title,
parentId: item.parent,
};
};
export const ssNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
folder: normalizeFolder,
genre: normalizeGenre,
playlist: normalizePlaylist,
song: normalizeSong,
+49
View File
@@ -548,6 +548,50 @@ const albumInfo = z.object({
}),
});
const getMusicDirectoryParameters = z.object({
id: z.string(),
});
const directory = z.object({
artist: z.string().optional(),
child: z.array(song).optional(),
coverArt: z.string().optional(),
id,
isDir: z.boolean(),
parent: z.string().optional(),
title: z.string(),
});
const getMusicDirectory = z.object({
directory,
});
const getIndexes = z.object({
indexes: z.object({
child: z.array(song),
index: z
.object({
artist: z
.object({
id: z.string(),
name: z.string(),
})
.array(),
})
.array(),
shortcut: z
.object({
id: z.string(),
name: z.string(),
})
.array(),
}),
});
const getIndexesParameters = z.object({
musicFolderId: z.string().optional(),
});
export const ssType = {
_parameters: {
albumInfo: albumInfoParameters,
@@ -563,6 +607,8 @@ export const ssType = {
getArtists: getArtistsParameters,
getGenre: getGenresParameters,
getGenres: getGenresParameters,
getIndexes: getIndexesParameters,
getMusicDirectory: getMusicDirectoryParameters,
getPlaylist: getPlaylistParameters,
getPlaylists: getPlaylistsParameters,
getSong: getSongParameters,
@@ -591,12 +637,15 @@ export const ssType = {
baseResponse,
createFavorite,
createPlaylist,
directory,
genre,
getAlbum,
getAlbumList2,
getArtist,
getArtists,
getGenres,
getIndexes,
getMusicDirectory,
getPlaylist,
getPlaylists,
getSong,
+5
View File
@@ -245,6 +245,11 @@ export const sortSongsByFetchedOrder = (
fetchedIds: string[],
itemType: LibraryItem,
): Song[] => {
// For folders, songs are already in the correct order
if (itemType === LibraryItem.FOLDER) {
return songs;
}
// Group songs by the fetched ID they belong to
const songsByFetchedId = new Map<string, Song[]>();
@@ -0,0 +1,10 @@
import {
Breadcrumbs as MantineBreadcrumbs,
BreadcrumbsProps as MantineBreadcrumbsProps,
} from '@mantine/core';
interface BreadcrumbProps extends MantineBreadcrumbsProps {}
export const Breadcrumb = ({ children, ...props }: BreadcrumbProps) => {
return <MantineBreadcrumbs {...props}>{children}</MantineBreadcrumbs>;
};
@@ -67,6 +67,7 @@ export const ContextMenuPreview = memo(({ items, itemType }: ContextMenuPreviewP
<Icon icon="playlist" size="md" />
)}
{itemType === LibraryItem.GENRE && <Icon icon="genre" size="md" />}
{itemType === LibraryItem.FOLDER && <Icon icon="folder" size="md" />}
{!itemType && <Icon icon="library" size="md" />}
</div>
)}
+4 -1
View File
@@ -1,4 +1,5 @@
import { Tooltip as MantineTooltip, TooltipProps as MantineTooltipProps } from '@mantine/core';
import clsx from 'clsx';
import styles from './tooltip.module.css';
@@ -6,6 +7,7 @@ export interface TooltipProps extends MantineTooltipProps {}
export const Tooltip = ({
children,
classNames,
openDelay = 500,
transitionProps = {
duration: 250,
@@ -18,7 +20,8 @@ export const Tooltip = ({
<MantineTooltip
arrowSize={10}
classNames={{
tooltip: styles.tooltip,
...classNames,
tooltip: clsx(styles.tooltip, classNames?.['tooltip']),
}}
multiline
openDelay={openDelay}
+26
View File
@@ -24,6 +24,7 @@ export enum LibraryItem {
ALBUM = 'album',
ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist',
FOLDER = 'folder',
GENRE = 'genre',
PLAYLIST = 'playlist',
PLAYLIST_SONG = 'playlistSong',
@@ -257,6 +258,29 @@ export type EndpointDetails = {
server: ServerListItem;
};
export type Folder = {
_itemType: LibraryItem.FOLDER;
_serverId: string;
_serverType: ServerType;
children?: {
folders: Folder[];
songs: Song[];
};
id: string;
name: string;
parentId?: string;
};
export type FolderArgs = BaseEndpointArgs & { query: FolderQuery };
export interface FolderQuery extends BaseQuery<SongListSort> {
id: string;
musicFolderId?: string | string[];
searchTerm?: string;
}
export type FolderResponse = Folder;
export type GainInfo = {
album?: number;
track?: number;
@@ -1231,6 +1255,7 @@ export type ControllerEndpoint = {
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getArtistListCount: (args: ArtistListCountArgs) => Promise<number>;
getDownloadUrl: (args: DownloadArgs) => string;
getFolder: (args: FolderArgs) => Promise<FolderResponse>;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getLyrics?: (args: LyricsArgs) => Promise<LyricsResponse>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
@@ -1309,6 +1334,7 @@ export type InternalControllerEndpoint = {
getArtistList: (args: ReplaceApiClientProps<ArtistListArgs>) => Promise<ArtistListResponse>;
getArtistListCount: (args: ReplaceApiClientProps<ArtistListCountArgs>) => Promise<number>;
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
getFolder: (args: ReplaceApiClientProps<FolderArgs>) => Promise<FolderResponse>;
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
getLyrics?: (args: ReplaceApiClientProps<LyricsArgs>) => Promise<LyricsResponse>;
getMusicFolderList: (
+2
View File
@@ -6,6 +6,7 @@ export enum DragTarget {
ALBUM = LibraryItem.ALBUM,
ALBUM_ARTIST = LibraryItem.ALBUM_ARTIST,
ARTIST = LibraryItem.ARTIST,
FOLDER = LibraryItem.FOLDER,
GENERIC = 'generic',
GENRE = LibraryItem.GENRE,
GRID_ROW = 'gridRow',
@@ -19,6 +20,7 @@ export const DragTargetMap = {
[LibraryItem.ALBUM]: DragTarget.ALBUM,
[LibraryItem.ALBUM_ARTIST]: DragTarget.ALBUM_ARTIST,
[LibraryItem.ARTIST]: DragTarget.ARTIST,
[LibraryItem.FOLDER]: DragTarget.FOLDER,
[LibraryItem.GENRE]: DragTarget.GENRE,
[LibraryItem.PLAYLIST]: DragTarget.PLAYLIST,
[LibraryItem.PLAYLIST_SONG]: DragTarget.SONG,