Files
jellyfin/web/LibrarySection.tsx
officer a7f1a71785 jellyfin, extracted from the platform as a plugin
Eighth plugin out, and the second resurrection: like soulseek, hono.ts carried
the router import and its mount commented out since 2026-08-13, with the
permission reduced to api: [], so the server booted clean while every panel
request 404'd. jellyfin_servers was also absent from the drizzle barrel, so
bun db:push would have dropped it.

Easier than soulseek in one specific way: mountPrefix derives /jellyfin from
the appName, which is exactly what the router already hardcoded, so no frontend
call site needed repointing. Soulseek needed 47 because its appName and its old
prefix differed. The frontend also already routed through one helper, so there
were five call sites rather than forty-seven.

Self-contained, unlike transmission and soulseek: jellyfin_servers is a
registry this plugin owns outright — label, URL, encrypted token, one active —
because more than one Jellyfin is a thing someone has, and more than one
Transmission is not.

Carries its own .prettierignore for the vendored hls.mjs, rather than leaving a
stale rule behind in the platform naming a path that no longer exists.
2026-08-19 03:17:32 +00:00

115 lines
4.1 KiB
TypeScript

import { useSearchParams } from 'react-router';
import { Loader2 } from 'lucide-react';
import { MediaGrid } from './MediaCard';
import { VIEW_PARAM } from './shared';
import { useJellyfinItems, useJellyfinViews } from './useJellyfinData';
// One library, as a grid.
//
// Which library is `?view=<id>`; the sort is `?sort=` and `?order=`. All three are in the URL rather than in
// component state so a particular view of a particular library is a link you can keep — and so the nav can
// tell which library is open without either panel talking to the other.
const SORTS = [
{ id: 'SortName', label: 'Name' },
{ id: 'DateCreated', label: 'Recently added' },
{ id: 'PremiereDate', label: 'Release date' },
{ id: 'CommunityRating', label: 'Rating' },
{ id: 'Random', label: 'Random' },
] as const;
const SORT_PARAM = 'sort';
const ORDER_PARAM = 'order';
// A show is a Series, not its episodes: a tvshows library browsed recursively for Episode would be thousands
// of tiles of the same six posters. BoxSets are collapsed for the same reason.
const TYPES_FOR_COLLECTION: Record<string, string> = {
movies: 'Movie',
tvshows: 'Series',
homevideos: 'Video,Photo',
musicvideos: 'MusicVideo',
boxsets: 'BoxSet',
playlists: 'Playlist',
};
const CHIP = 'rounded-md px-2 py-1 text-[11px] transition-colors';
export const LibrarySection = () => {
const [params, setParams] = useSearchParams();
const viewId = params.get(VIEW_PARAM);
const sortBy = params.get(SORT_PARAM) ?? 'SortName';
const sortOrder = params.get(ORDER_PARAM) === 'Descending' ? 'Descending' : 'Ascending';
const { data: viewData } = useJellyfinViews();
const view = viewData?.views.find((v) => v.Id === viewId) ?? null;
const { data, isLoading } = useJellyfinItems(
{
parentId: viewId ?? undefined,
includeItemTypes: TYPES_FOR_COLLECTION[view?.CollectionType ?? ''] ?? undefined,
sortBy,
sortOrder,
limit: 500,
collapseBoxSetItems: false,
},
!!viewId,
);
if (!viewId) {
return (
<div className="flex h-full items-center justify-center p-6 text-center text-xs text-muted-foreground">
Pick a library on the left.
</div>
);
}
const items = data?.Items ?? [];
const wide = view?.CollectionType === 'homevideos';
const setSort = (id: string) => {
const next = new URLSearchParams(params);
// Clicking the sort you are already on flips the direction, which is the behaviour every table has and
// saves a second control that would only ever be used with this one.
if (id === sortBy) next.set(ORDER_PARAM, sortOrder === 'Ascending' ? 'Descending' : 'Ascending');
else next.delete(ORDER_PARAM);
next.set(SORT_PARAM, id);
setParams(next, { replace: true });
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b px-4 py-2">
<h2 className="truncate text-sm font-semibold">{view?.Name ?? 'Library'}</h2>
<span className="text-[11px] text-muted-foreground">
{data?.TotalRecordCount != null ? `${data.TotalRecordCount.toLocaleString()} items` : ''}
</span>
<div className="ml-auto flex items-center gap-0.5 rounded-lg bg-muted p-0.5">
{SORTS.map((sort) => (
<button
key={sort.id}
type="button"
onClick={() => setSort(sort.id)}
className={`${CHIP} ${sortBy === sort.id ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
>
{sort.label}
{sortBy === sort.id && sort.id !== 'Random' ? (sortOrder === 'Ascending' ? ' ↑' : ' ↓') : ''}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{isLoading ? (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : items.length ? (
<MediaGrid items={items} wide={wide} />
) : (
<p className="pt-10 text-center text-xs text-muted-foreground">This library is empty.</p>
)}
</div>
</div>
);
};