Queued right after the ffmpeg burn-in work, asked for directly: see
what's inside a playlist, and launch it - pre-queued with the whole
thing, the same "one click, the rest queues itself" shape starting from
an episode already gets for its series.
- server/src/services/CatalogService.ts: VISIBLE_COLLECTION_TYPES gains
'playlists' - Emby's /Views already returns that CollectionType once
the user has any, so it appears in the sidebar through the exact same
code every other library already uses, nothing else needed there. New
getPlaylistItemsRaw/getPlaylistItems (Emby's own /Playlists/{id}/Items,
mirroring getSeriesEpisodes/getAlbumTracks). New exported
splitPlaylistByMediaType: a playlist can mix movies/episodes and songs,
this app's party model can't - decides video vs music from the first
item's type and drops anything that doesn't match (logged when it
does) - pure, unit-tested on its own.
- server/src/index.ts: new GET /api/catalog/playlists/:playlistId/items
route. POST /api/parties gains a sibling early branch for
item.Type === 'Playlist' (a new createPartyFromPlaylist helper,
deliberately NOT merged into the existing single-item path, which
didn't need touching) - builds a video QueueItem[] exactly like the
episode-queue path already does, or seeds MusicQueueService.init +
addTrack per track for a music result, the same mechanism
POST /api/parties/:id/tracks already uses.
- client/src/tiles.ts: one more DRILL_DOWN entry (Playlist -> its own
page) - a playlist was never directly streamable, same as
Series/MusicArtist/MusicAlbum before it.
- client/src/api/embyApi.ts: getPlaylistItems(). No new API for
*launching* one - createParty(playlistId) already does everything,
same as clicking any other item; the server decides what it means.
- client/src/views/playlist/Playlist.{ts,html,css} (new): a playlist's
contents, read-only past the one "Lancer cette playlist" button -
deliberately no per-item start/add the way album tracks or episode
rows get (an Emby item carries no back-reference to "which playlist
contains me" the way an Episode's SeriesId does, so "start from item
N, queue the rest of the playlist" isn't cheaply reconstructable the
same way - not asked for here anyway). The launch button branches on
the created party's mediaType, same check Player.ts's own dispatcher
already makes.
- client/src/app.ts: registers /playlist/:id.
- server/test/catalogService.test.ts: the new methods' target
URL/mapping, plus splitPlaylistByMediaType's own tests (homogeneous
video, homogeneous music, mixed, empty).
- docs/architecture.md, README.md: design + the scope decision (no
per-item actions) spelled out.
Verified: full typecheck+test+build on both projects (62/62 server
tests). The new page driven through the same mocked-fetch + real-bundle
harness used throughout this session, in a real browser: header/meta/
item list render correctly from mocked catalog data (including the
sidebar showing "Playlists" once a mocked library declares that
CollectionType), and clicking "Lancer cette playlist" reaches
POST /api/parties with the right id and correctly routes into the video
player for a video result. Actual playback past that point still needs
a real Emby server - same standing caveat as the rest of this app.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
153 lines
6.3 KiB
TypeScript
153 lines
6.3 KiB
TypeScript
// Talks ONLY to our own backend now - never directly to Emby. The backend
|
|
// holds the real Emby session (server/src/services/AuthService.ts); this
|
|
// module just wraps fetch calls to our own /api/* routes. See
|
|
// docs/architecture.md for why this changed from the first version (CORS,
|
|
// plus not wanting the browser to hold an Emby token at all).
|
|
import type { ItemDto, LibraryDto, PlaybackOptionsDto, SeasonDto } from '../types';
|
|
|
|
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
const res = await fetch(path, {
|
|
credentials: 'same-origin',
|
|
...options,
|
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error || `Request failed: ${res.status}`);
|
|
}
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
class AuthState {
|
|
userName: string | null = null;
|
|
get isLoggedIn(): boolean {
|
|
return !!this.userName;
|
|
}
|
|
}
|
|
|
|
export const auth = new AuthState();
|
|
|
|
/** Call once on app start to find out if our session cookie is still valid. */
|
|
export async function restoreSession(): Promise<boolean> {
|
|
try {
|
|
const data = await apiFetch<{ userName: string }>('/api/auth/me');
|
|
auth.userName = data.userName;
|
|
return true;
|
|
} catch {
|
|
auth.userName = null;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function login(username: string, password: string): Promise<{ userName: string }> {
|
|
const data = await apiFetch<{ userName: string }>('/api/auth/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ username, password })
|
|
});
|
|
auth.userName = data.userName;
|
|
return data;
|
|
}
|
|
|
|
export async function logout(): Promise<void> {
|
|
await apiFetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
|
auth.userName = null;
|
|
}
|
|
|
|
export async function searchItems(query: string): Promise<ItemDto[]> {
|
|
if (!query || query.trim().length < 2) return [];
|
|
const data = await apiFetch<{ items: ItemDto[] }>(`/api/catalog/search?q=${encodeURIComponent(query)}`);
|
|
return data.items || [];
|
|
}
|
|
|
|
export function getItem(itemId: string): Promise<ItemDto> {
|
|
return apiFetch(`/api/catalog/item/${itemId}`);
|
|
}
|
|
|
|
export async function getLibraries(): Promise<LibraryDto[]> {
|
|
const data = await apiFetch<{ libraries: LibraryDto[] }>('/api/catalog/libraries');
|
|
return data.libraries || [];
|
|
}
|
|
|
|
export async function getLibraryItems(libraryId: string): Promise<ItemDto[]> {
|
|
const data = await apiFetch<{ items: ItemDto[] }>(`/api/catalog/libraries/${libraryId}/items`);
|
|
return data.items || [];
|
|
}
|
|
|
|
export async function getRecentItems(libraryId: string): Promise<ItemDto[]> {
|
|
const data = await apiFetch<{ items: ItemDto[] }>(`/api/catalog/libraries/${libraryId}/recent`);
|
|
return data.items || [];
|
|
}
|
|
|
|
export async function getSeriesSeasons(seriesId: string): Promise<SeasonDto[]> {
|
|
const data = await apiFetch<{ seasons: SeasonDto[] }>(`/api/catalog/series/${seriesId}/seasons`);
|
|
return data.seasons || [];
|
|
}
|
|
|
|
export async function getSeriesEpisodes(seriesId: string, seasonId: string): Promise<ItemDto[]> {
|
|
const data = await apiFetch<{ episodes: ItemDto[] }>(`/api/catalog/series/${seriesId}/episodes?seasonId=${encodeURIComponent(seasonId)}`);
|
|
return data.episodes || [];
|
|
}
|
|
|
|
export async function getArtists(libraryId: string): Promise<ItemDto[]> {
|
|
const data = await apiFetch<{ artists: ItemDto[] }>(`/api/catalog/libraries/${libraryId}/artists`);
|
|
return data.artists || [];
|
|
}
|
|
|
|
export async function getArtistAlbums(artistId: string): Promise<ItemDto[]> {
|
|
const data = await apiFetch<{ albums: ItemDto[] }>(`/api/catalog/artists/${artistId}/albums`);
|
|
return data.albums || [];
|
|
}
|
|
|
|
export async function getAlbumTracks(albumId: string): Promise<ItemDto[]> {
|
|
const data = await apiFetch<{ tracks: ItemDto[] }>(`/api/catalog/albums/${albumId}/tracks`);
|
|
return data.tracks || [];
|
|
}
|
|
|
|
/** A playlist's own contents, in order - see views/playlist/Playlist.ts.
|
|
* Launching the whole thing is createParty(playlistId) (api/party.ts) -
|
|
* same call as any other item, the server decides what "launch a
|
|
* playlist" means from the id alone (see index.ts). */
|
|
export async function getPlaylistItems(playlistId: string): Promise<ItemDto[]> {
|
|
const data = await apiFetch<{ items: ItemDto[] }>(`/api/catalog/playlists/${playlistId}/items`);
|
|
return data.items || [];
|
|
}
|
|
|
|
export function getPlaybackOptions(itemId: string): Promise<PlaybackOptionsDto> {
|
|
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
|
|
}
|
|
|
|
/** `options` may carry AudioStreamIndex/MaxStreamingBitrate/mediaType - not
|
|
* subtitles, which are their own separate request now (see subtitleUrl
|
|
* below and Player.ts's applySubtitleTrack for why). */
|
|
export function streamUrl(itemId: string, options: Record<string, string | number | null | undefined> = {}): string {
|
|
const entries = Object.entries(options).filter(([, v]) => v !== '' && v != null) as [string, string][];
|
|
const qs = new URLSearchParams(entries).toString();
|
|
return `/api/catalog/stream/${itemId}/master.m3u8${qs ? '?' + qs : ''}`;
|
|
}
|
|
|
|
/** Same stream, tagged for a music track - see server/src/services/
|
|
* CatalogService.ts's proxyStream for why mediaType is the one thing
|
|
* that matters here (its hand-built-fallback path is genuinely different
|
|
* for audio). */
|
|
export function audioStreamUrl(itemId: string): string {
|
|
return streamUrl(itemId, { mediaType: 'audio' });
|
|
}
|
|
|
|
/** A subtitle track's own text, as WebVTT (server/src/services/
|
|
* CatalogService.ts's proxySubtitle converts it, whatever Emby's own
|
|
* source format is) - for a native <track src>, not part of the video
|
|
* stream at all. Only ever built for a track getPlaybackOptions already
|
|
* reported as isTextBased - see Player.ts's subtitle <select>. */
|
|
export function subtitleUrl(itemId: string, subtitleIndex: number): string {
|
|
return `/api/catalog/subtitle/${itemId}/${subtitleIndex}`;
|
|
}
|
|
|
|
/** The party's own self-hosted-ffmpeg burn-in stream (server/src/services/
|
|
* TranscodeService.ts) - for a PGS/image subtitle, which a native
|
|
* <track> can't render (see subtitleUrl above). Keyed by partyId, not
|
|
* itemId/subtitleIndex: the party itself carries which combination is
|
|
* currently burned in (Party.burnInSubtitleIndex), and it's shared by
|
|
* every member - see Player.ts's onBurnInChanged. */
|
|
export function burnInStreamUrl(partyId: string): string {
|
|
return `/api/catalog/burn/${partyId}/master.m3u8`;
|
|
}
|