diff --git a/README.md b/README.md index 9b93715..e9a8659 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ including why this replaced an earlier Emby-plugin attempt (still in 1. Sign in with your Emby account (just username/password — the server address is fixed server-side, see Deploying below). -2. A sidebar lists your Emby libraries (movies, TV, music, music videos — - anything else Emby might have, like photos or a boxset collection, isn't - shown); the home page shows every ongoing +2. A sidebar lists your Emby libraries (movies, TV, music, music videos, + playlists if you have any — anything else Emby might have, like + photos or a boxset collection, isn't shown); the home page shows every ongoing watch party live (host, how long it's been running, member count) plus a "recently added" row per library, Emby-style. No join codes — if a party is running, it's already on the list, updating in real time. @@ -51,6 +51,10 @@ including why this replaced an earlier Emby-plugin attempt (still in (its button reads "Ajouter à la file" instead of "Écouter ensemble" once a party's already going). Starting or joining a video watch party always leaves the listen party first — the two don't run at once. +8. A playlist works the same idea as a series: open it to see what's + inside, then "Lancer cette playlist" starts one party pre-loaded with + everything in it — a video queue if it's movies/episodes, or the + participative music queue if it's tracks. Everything — catalog, images, video, audio, sync — flows through this app's own backend, which proxies whatever it needs from Emby server-side. @@ -235,6 +239,8 @@ Then put it behind your existing reverse proxy like everything else - [x] Self-hosted ffmpeg burn-in for PGS/image subtitles (Emby's own transcode can't render these as text) — one shared stream per party, host-driven — see docs/architecture.md +- [x] Browse Emby playlists and launch one as a pre-queued party (video + queue or the participative music queue) — see docs/architecture.md - [ ] Try a full party end-to-end with two real accounts (still the main gap) - [ ] Tune transcoding target / support direct play when possible diff --git a/client/src/api/embyApi.ts b/client/src/api/embyApi.ts index 6f39bbd..11389b6 100644 --- a/client/src/api/embyApi.ts +++ b/client/src/api/embyApi.ts @@ -103,6 +103,15 @@ export async function getAlbumTracks(albumId: string): Promise { 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 { + const data = await apiFetch<{ items: ItemDto[] }>(`/api/catalog/playlists/${playlistId}/items`); + return data.items || []; +} + export function getPlaybackOptions(itemId: string): Promise { return apiFetch(`/api/catalog/item/${itemId}/playback-options`); } diff --git a/client/src/app.ts b/client/src/app.ts index 07deb96..f9b18a8 100644 --- a/client/src/app.ts +++ b/client/src/app.ts @@ -8,6 +8,7 @@ import { renderLibrary } from './views/library/Library'; import { renderSeries } from './views/series/Series'; import { renderArtist } from './views/artist/Artist'; import { renderAlbum } from './views/album/Album'; +import { renderPlaylist } from './views/playlist/Playlist'; import { renderPartyPlayer } from './views/player/Player'; route('/', () => navigate('/home')); @@ -17,6 +18,7 @@ route('/library/:id', renderLibrary); route('/series/:id', renderSeries); route('/artist/:id', renderArtist); route('/album/:id', renderAlbum); +route('/playlist/:id', renderPlaylist); route('/party/:id', renderPartyPlayer); /** diff --git a/client/src/tiles.ts b/client/src/tiles.ts index 2a25ea9..29b572c 100644 --- a/client/src/tiles.ts +++ b/client/src/tiles.ts @@ -45,7 +45,8 @@ export function initialsFor(name: string): string { const DRILL_DOWN: Record = { Series: { route: 'series', label: 'Voir les épisodes' }, MusicArtist: { route: 'artist', label: 'Voir les albums' }, - MusicAlbum: { route: 'album', label: 'Voir les morceaux' } + MusicAlbum: { route: 'album', label: 'Voir les morceaux' }, + Playlist: { route: 'playlist', label: 'Voir la playlist' } }; export function tileHtml(item: ItemDto): string { diff --git a/client/src/views/playlist/Playlist.css b/client/src/views/playlist/Playlist.css new file mode 100644 index 0000000..9254204 --- /dev/null +++ b/client/src/views/playlist/Playlist.css @@ -0,0 +1,36 @@ +/* ---- Playlist page: detail header + item list ---- + .seriesInfo/.episodeList/.episodeRow are reused as-is from + views/series/Series.css (already in the bundle - Series.ts is always + a registered route, see app.ts) - same precedent Album.css already + set for .seriesInfo. Only the header's own cover art needs its own + rule here, same reason Album.css gives .albumCover its own aspect + ratio instead of reusing .seriesPoster's. */ + +.playlistHeader { + display: flex; + gap: 20px; + margin-bottom: 24px; +} + +.playlistCover { + width: 160px; + aspect-ratio: 1 / 1; + object-fit: cover; + border-radius: 8px; + background: var(--panel); + flex-shrink: 0; +} + +.playlistCover.noImage { visibility: hidden; } + +.playlistActions { + display: flex; + align-items: center; + gap: 16px; + margin-top: 4px; +} + +@media (max-width: 600px) { + .playlistHeader { flex-direction: column; } + .playlistCover { width: 120px; } +} diff --git a/client/src/views/playlist/Playlist.html b/client/src/views/playlist/Playlist.html new file mode 100644 index 0000000..105dc7d --- /dev/null +++ b/client/src/views/playlist/Playlist.html @@ -0,0 +1,13 @@ + +
+ +
+

+

+
+ + Voir sur Emby +
+
+
+

Chargement...

diff --git a/client/src/views/playlist/Playlist.ts b/client/src/views/playlist/Playlist.ts new file mode 100644 index 0000000..0d3bb8d --- /dev/null +++ b/client/src/views/playlist/Playlist.ts @@ -0,0 +1,89 @@ +import './Playlist.css'; +import html from './Playlist.html'; +import { getItem, getPlaylistItems } from '../../api/embyApi'; +import { createParty } from '../../api/party'; +import { joinMusicParty } from '../../musicBar/MusicBar'; +import { navigate } from '../../router'; +import type { RouteParams } from '../../router'; +import { escapeHtml, formatDuration } from '../../tiles'; +import type { ItemDto } from '../../types'; + +const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations + +/** + * Route `/playlist/:id` - a playlist's own contents, read-only (see + * docs/architecture.md's "Scope" note: no per-item start/add here, only + * views/library's poster tiles and views/album's per-track rows do + * that - a playlist 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 something this page can + * reconstruct the way views/series does). The one action here is + * "Lancer cette playlist" - launches the whole thing as a party, + * pre-queued server-side (see index.ts's createPartyFromPlaylist). + */ +export async function renderPlaylist(root: HTMLElement, params: RouteParams): Promise { + const playlistId = params.id as string; + root.innerHTML = '

Chargement...

'; + + let playlist: ItemDto; + let items: ItemDto[]; + try { + [playlist, items] = await Promise.all([getItem(playlistId), getPlaylistItems(playlistId)]); + } catch { + root.innerHTML = '

Impossible de charger cette playlist.

'; + return; + } + + root.innerHTML = html; + root.querySelector('#back')!.addEventListener('click', () => history.back()); + (root.querySelector('#playlistCover') as HTMLImageElement).src = playlist.imageUrl; + root.querySelector('#playlistTitle')!.textContent = playlist.name; + root.querySelector('#playlistMeta')!.textContent = items.length + ? `${items.length} élément${items.length > 1 ? 's' : ''}` + : 'Playlist vide'; + (root.querySelector('#embyLink') as HTMLAnchorElement).href = playlist.embyUrl; + + const listEl = root.querySelector('#itemList')!; + const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement; + + if (!items.length) { + listEl.innerHTML = '

Cette playlist ne contient rien pour le moment.

'; + launchBtn.disabled = true; + return; + } + + listEl.innerHTML = items.map((item, i) => { + const meta = item.overview || [item.artistName, item.albumName].filter(Boolean).join(' · '); + const duration = item.durationTicks ? formatDuration(item.durationTicks / TICKS_PER_SECOND) : ''; + return ` +
+ +
+ ${i + 1}. ${escapeHtml(item.name)}${duration ? ` (${escapeHtml(duration)})` : ''} +

${escapeHtml(meta)}

+
+
`; + }).join(''); + + launchBtn.addEventListener('click', async () => { + launchBtn.disabled = true; + launchBtn.textContent = 'Démarrage...'; + try { + const party = await createParty(playlistId); + if (party.mediaType === 'music') { + // Same "no dedicated page for music" rule as everywhere else + // (views/album, views/player's own dispatcher) - the persistent + // bottom bar attaches and this page just goes back to being a + // normal page underneath it. + joinMusicParty(party.partyId, party); + navigate('/home'); + } else { + navigate(`/party/${party.partyId}`); + } + } catch (err) { + alert((err as Error).message); + launchBtn.disabled = false; + launchBtn.textContent = 'Lancer cette playlist'; + } + }); +} diff --git a/docs/architecture.md b/docs/architecture.md index a9a5cc0..fcaec83 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1810,6 +1810,85 @@ it had actually been asked for. This pass alone was enough to stop it from ever being able to crash the server again in the meantime, regardless of how - or whether - that got resolved. +## Playlist browsing + launch + +Asked for directly, queued right after the ffmpeg burn-in work above: +see what's inside an Emby playlist, and launch it as a party pre-queued +with the whole thing - the same "start from one click, the rest queues +itself" shape starting from an episode already gets for its series. + +**The sidebar needed nothing new**: Emby's `/Views` already returns a +`CollectionType: 'playlists'` entry once the user has at least one - +`CatalogService.VISIBLE_COLLECTION_TYPES` just had to stop dropping it, +and it shows up in `Shell.ts`'s sidebar through the exact same code path +every other library already does (see "UI: Emby-like shell" above). +`Library.ts`'s existing generic branch (`getLibraryItems` - direct +children of a `ParentId`) already returns a playlist library's own +playlists correctly; only music's artist grid needed a *dedicated* +endpoint, because a flat listing there would have returned raw tracks +instead of the artist grouping its own page needs. Playlists don't have +that problem - a playlist library's children already are the playlists +themselves. + +**Tile drill-down, not a direct "start party" button**: like +Series/MusicArtist/MusicAlbum before it, a playlist tile links into its +own page (`tiles.ts`'s `DRILL_DOWN` table, `Playlist: { route: +'playlist', ... }`) rather than getting `canWatchParty`'s button +directly - a playlist itself was never something Emby could stream on +its own. + +**Scope, deliberately narrower than it could be**: the new +`views/playlist/Playlist.ts` page is read-only past the one "Lancer +cette playlist" action - no per-item start/add the way `views/album`'s +track rows or `views/series`'s episode rows get. That's not laziness: +an Episode carries `SeriesId`, which is what lets `POST /api/parties` +reconstruct "queue the rest of the series from here" from just the +clicked item's id - a playlist item carries no equivalent +"which playlist(s) contain me" back-reference, so "start from item N, +queue the rest of *this playlist*" isn't cheaply reconstructable the +same way. Building that properly would mean threading a playlist id +through the click, a real protocol change for a page whose actual ask +was launching the playlist as a whole, not per-item granularity - left +for if it's ever actually asked for. + +**Launching one**: `POST /api/parties` (`index.ts`) already special-cases +`item.Type === 'Episode'` to build a series queue; a playlist launch is +a sibling early branch (`item.Type === 'Playlist'`) calling a new, +separate `createPartyFromPlaylist` helper rather than merged into the +existing single-item path - that path is already working/relied-upon +and didn't need touching. Emby allows mixing movies/episodes and songs +in one playlist; this app's party model can't (a party is either +`'video'` or `'music'`). `CatalogService.splitPlaylistByMediaType` - a +small, pure, fully unit-tested function - decides which from the +playlist's *first* item and drops anything that doesn't match, logged +server-side so a mixed playlist isn't silently confusing from `docker +logs`. A video result builds a `QueueItem[]` exactly like the +episode-queue path already does; a music result seeds +`MusicQueueService.init` with the first track and `addTrack`s the rest - +the same mechanism `POST /api/parties/:id/tracks` already uses, just +called several times in a row here instead of once per click. + +**Launching from the client**: `Playlist.ts`'s one button calls the +existing `createParty(playlistId)` - no new client API for *starting* +one, since the server already decides everything from the id alone, the +same as clicking any other item. It then branches on the created +party's `mediaType` (`joinMusicParty` vs. `navigate('/party/:id')`) - +the same check `Player.ts`'s own dispatcher already makes, just once, +at creation time instead of at every join. + +Verified: the new `CatalogService` methods and `splitPlaylistByMediaType` +(homogeneous video, homogeneous music, mixed - first item's type wins) +are unit-tested with a mocked `EmbyClient`, matching every other +`catalogService.test.ts` case. Full typecheck/test/build on both +projects. The new page was driven through the same mocked-`fetch` + +real-bundle harness used throughout this session in a real browser - +confirmed the header/meta/item list render correctly from mocked catalog +data, and that clicking "Lancer cette playlist" reaches +`POST /api/parties` with the right id and correctly routes into the +video player for a video result (the same standing caveat as everywhere +else applies past that point - no real Emby server reachable here to +confirm actual playback). + ## Known limitations - Playback always goes through `hls.js` against whatever Emby's diff --git a/server/src/index.ts b/server/src/index.ts index e62d8be..ff48d1e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,13 +6,14 @@ import express, { type NextFunction, type Request, type Response } from 'express import { Server as SocketIoServer } from 'socket.io'; import { EMBY_SERVER_URL, PORT } from './config'; +import { Logger } from './Logger'; import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthService'; -import { CatalogService, CatalogFetchError } from './services/CatalogService'; +import { CatalogService, CatalogFetchError, splitPlaylistByMediaType } from './services/CatalogService'; import { PartyService } from './services/PartyService'; import { MusicQueueService } from './services/MusicQueueService'; import { TranscodeService } from './services/TranscodeService'; import { SocketGateway } from './realtime/SocketGateway'; -import type { ClientToServerEvents, MediaType, QueueItem, ServerToClientEvents } from './types'; +import type { ClientToServerEvents, EmbySession, MediaType, QueueItem, ServerToClientEvents } from './types'; const app = express(); app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme @@ -146,6 +147,12 @@ app.get('/api/catalog/albums/:albumId/tracks', requireSession, asyncRoute(async res.json({ tracks: await CatalogService.getAlbumTracks(req.embySession!, param(req, 'albumId')) }); })); +// A playlist's own contents, in order - for views/playlist. See +// POST /api/parties below for actually launching one. +app.get('/api/catalog/playlists/:playlistId/items', requireSession, asyncRoute(async (req, res) => { + res.json({ items: await CatalogService.getPlaylistItems(req.embySession!, param(req, 'playlistId')) }); +})); + // Audio/subtitle tracks available for an item, read from Emby's own playback // negotiation (PlaybackInfo) - powers the player's language/subtitle pickers. app.get('/api/catalog/item/:itemId/playback-options', requireSession, asyncRoute(async (req, res) => { @@ -240,6 +247,17 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => { const session = req.embySession!; const item = await CatalogService.getRawItem(session, itemId); + + // A playlist itself was never something Emby could stream - launching + // one means queuing everything inside it instead, a separate enough + // shape (video queue vs. seeding the participative music queue several + // tracks at once) that it's its own function rather than folded into + // the single-item path below. See docs/architecture.md. + if (item.Type === 'Playlist') { + await createPartyFromPlaylist(res, session, itemId); + return; + } + const mediaType: MediaType = item.Type === 'Audio' ? 'music' : 'video'; // Starting a party from an episode queues the rest of the series from @@ -292,6 +310,69 @@ function formatEpisodeName(episode: { ParentIndexNumber?: number; IndexNumber?: return episode.Name; } +/** + * The "launch this playlist" half of POST /api/parties above - kept + * separate rather than merged into the single-item path, which is + * already working/relied-upon and doesn't need touching for this. + * splitPlaylistByMediaType (CatalogService) decides video vs music from + * the playlist's first item and drops anything that doesn't match (a + * mixed playlist - not something this app's party model can represent). + */ +async function createPartyFromPlaylist(res: Response, session: EmbySession, playlistId: string): Promise { + const raw = await CatalogService.getPlaylistItemsRaw(session, playlistId); + const { mediaType, items } = splitPlaylistByMediaType(raw); + if (!items.length) { + res.status(400).json({ error: 'Cette playlist est vide.' }); + return; + } + if (items.length !== raw.length) { + Logger.party(`playlist ${playlistId} is mixed-media - keeping only the ${items.length}/${raw.length} ${mediaType} item(s)`); + } + + if (mediaType === 'music') { + const first = items[0]!; + const party = PartyService.create( + session.userId, session.userName, + first.Id, first.Name, `/api/catalog/image/${first.Id}`, + null, mediaType + ); + // Same mechanism POST /api/parties/:id/tracks already uses (see + // below) - init seeds+auto-starts the first track, addTrack queues + // the rest, exactly as if they'd been added one at a time. + items.forEach((track, i) => { + const trackInput = { + itemId: track.Id, + itemName: track.Name, + artistName: track.AlbumArtist || track.Artists?.[0] || null, + albumName: track.Album || null, + itemImageUrl: `/api/catalog/image/${track.Id}`, + durationTicks: track.RunTimeTicks || null + }; + if (i === 0) { + MusicQueueService.init(party, trackInput, session.userId, session.userName); + } else { + MusicQueueService.addTrack(party, trackInput, session.userId, session.userName); + } + }); + SocketGateway.broadcastLobby(io); + res.json(PartyService.toPublicState(party, session.userId)); + return; + } + + const queue: QueueItem[] = items.map((e) => ({ + itemId: e.Id, + itemName: e.Type === 'Episode' ? formatEpisodeName(e) : e.Name, + itemImageUrl: `/api/catalog/image/${e.Id}` + })); + const party = PartyService.create( + session.userId, session.userName, + queue[0]!.itemId, queue[0]!.itemName, queue[0]!.itemImageUrl, + queue, mediaType + ); + SocketGateway.broadcastLobby(io); + res.json(PartyService.toPublicState(party, session.userId)); +} + // Adding a track to a music party's shared queue is REST, not a socket // event (see SocketGateway's top comment): the album-browsing page that // this is actually called from has no live connection to the party it's diff --git a/server/src/services/CatalogService.ts b/server/src/services/CatalogService.ts index 6a42e34..e3f341e 100644 --- a/server/src/services/CatalogService.ts +++ b/server/src/services/CatalogService.ts @@ -4,7 +4,7 @@ import type { Request, Response } from 'express'; import { EMBY_SERVER_URL } from '../config'; import { EmbyClient } from '../emby/EmbyClient'; import { Logger } from '../Logger'; -import type { EmbyItem, EmbySession, ItemDto, LibraryDto, PlaybackOptionsDto, SeasonDto } from '../types'; +import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackOptionsDto, SeasonDto } from '../types'; // 'MusicVideo' is Emby's own item type for a musicvideos library's // contents (distinct from the generic 'Video', which is home videos). @@ -14,14 +14,16 @@ import type { EmbyItem, EmbySession, ItemDto, LibraryDto, PlaybackOptionsDto, Se const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']); // Emby's /Views can return all sorts of things beyond actual media -// libraries - boxset collections, playlists, live TV, photos, books, a -// mixed-content folder with no CollectionType at all - none of which this -// app has any UI for. Only these four are ever shown, in the sidebar or -// anywhere else: everything else is dropped here, at the source, so every +// libraries - boxset collections, live TV, photos, books, a mixed-content +// folder with no CollectionType at all - none of which this app has any +// UI for. Only these five are ever shown, in the sidebar or anywhere +// else: everything else is dropped here, at the source, so every // consumer (shell/sidebar, home's carousels, direct navigation to a // library route) is filtered the same way without each having to repeat -// the same check. -const VISIBLE_COLLECTION_TYPES = new Set(['movies', 'tvshows', 'music', 'musicvideos']); +// the same check. 'playlists' only ever shows up here at all once the +// user has at least one - same as any other library, nothing special to +// handle for "no playlists yet". +const VISIBLE_COLLECTION_TYPES = new Set(['movies', 'tvshows', 'music', 'musicvideos', 'playlists']); // Image/bitmap subtitle codecs - pre-rendered pictures (from a Blu-ray/DVD // rip), not text. This app forces SubtitleMethod=Encode whenever a @@ -35,6 +37,26 @@ const VISIBLE_COLLECTION_TYPES = new Set(['movies', 'tvshows', 'music', 'musicvi // instead of letting someone pick a track that's guaranteed to fail. const IMAGE_SUBTITLE_CODECS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvb_subtitle']); +/** + * Emby allows mixing movies/episodes and songs in the same playlist; + * this app's party model can't (a party is either 'video' or 'music' - + * see docs/architecture.md). Decides which of the two a playlist launch + * should become from its *first* item's type, then drops anything that + * doesn't match - good enough for v1 (most playlists are single-media + * anyway), logged by the caller so a mixed one isn't silently confusing + * from `docker logs`. Pure - no Emby/session involved - so it's + * unit-tested directly (see catalogService.test.ts) without needing to + * exercise the actual /api/parties route that calls it (index.ts). + */ +export function splitPlaylistByMediaType(items: EmbyItem[]): { mediaType: MediaType; items: EmbyItem[] } { + if (!items.length) return { mediaType: 'video', items: [] }; + const isMusic = items[0]!.Type === 'Audio'; + return { + mediaType: isMusic ? 'music' : 'video', + items: items.filter((i) => (i.Type === 'Audio') === isMusic) + }; +} + interface EmbyItemsResponse { Items?: EmbyItem[]; } @@ -133,6 +155,25 @@ class CatalogServiceClass { return data.Items || []; } + /** A playlist's own contents, in order - Emby's standard `/Playlists` + * endpoint, distinct from a generic library listing the same way + * Shows/Episodes above is. `Fields` covers what either a video or a + * music playlist's items need (see toItemDto) - a playlist can hold + * either, see splitPlaylistByMediaType. Raw (not DTO-shaped), used by + * index.ts to build a new party's queue when launching one - see + * getPlaylistItems below for the DTO-shaped equivalent the client's + * own playlist page reads instead. */ + async getPlaylistItemsRaw(session: EmbySession, playlistId: string): Promise { + const params = new URLSearchParams({ UserId: session.userId, Fields: 'Overview,ProductionYear,RunTimeTicks,IndexNumber,Album,AlbumArtist' }); + const data = await this.fetchJson(`/Playlists/${playlistId}/Items?${params}`, session, 'Impossible de récupérer le contenu de cette playlist.'); + return data.Items || []; + } + + async getPlaylistItems(session: EmbySession, playlistId: string): Promise { + const items = await this.getPlaylistItemsRaw(session, playlistId); + return items.map((i) => this.toItemDto(i, session.serverId)); + } + // Library -> Artists -> Albums -> Tracks, for the "Musique" browsing // pages. Each its own Emby endpoint/query rather than guessing folder // structure, same reasoning as Shows -> Seasons -> Episodes above. diff --git a/server/test/catalogService.test.ts b/server/test/catalogService.test.ts index 68e7f67..ef3990e 100644 --- a/server/test/catalogService.test.ts +++ b/server/test/catalogService.test.ts @@ -4,9 +4,9 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import type { Request, Response } from 'express'; -import { CatalogService } from '../src/services/CatalogService'; +import { CatalogService, splitPlaylistByMediaType } from '../src/services/CatalogService'; import { EmbyClient } from '../src/emby/EmbyClient'; -import type { EmbySession } from '../src/types'; +import type { EmbyItem, EmbySession } from '../src/types'; const session: EmbySession = { embyToken: 'tok', userId: 'u1', userName: 'Alice', serverId: 'srv1', createdUtc: Date.now() }; @@ -157,4 +157,58 @@ describe('CatalogService', () => { (err: Error & { status?: number }) => err.status === 502 ); }); + + it('getPlaylistItemsRaw asks Emby\'s own /Playlists endpoint, in order', async (t) => { + let capturedPath = ''; + t.mock.method(EmbyClient, 'fetch', async (path: string) => { + capturedPath = path; + return jsonResponse({ Items: [{ Id: 't1', Name: 'Track One', Type: 'Audio' }, { Id: 't2', Name: 'Track Two', Type: 'Audio' }] }); + }); + + const items = await CatalogService.getPlaylistItemsRaw(session, 'pl1'); + + assert.ok(capturedPath.startsWith('/Playlists/pl1/Items?')); + assert.deepEqual(items.map((i) => i.Id), ['t1', 't2']); + }); + + it('getPlaylistItems maps a playlist\'s contents through the same DTO shape as everywhere else', async (t) => { + t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ + Items: [{ Id: 't1', Name: 'Track One', Type: 'Audio', AlbumArtist: 'Nova Wilder', RunTimeTicks: 20_000_000 }] + })); + + const items = await CatalogService.getPlaylistItems(session, 'pl1'); + + assert.equal(items[0]?.name, 'Track One'); + assert.equal(items[0]?.artistName, 'Nova Wilder'); + assert.equal(items[0]?.durationTicks, 20_000_000); + }); +}); + +describe('splitPlaylistByMediaType', () => { + const audio = (id: string): EmbyItem => ({ Id: id, Name: id, Type: 'Audio' }); + const movie = (id: string): EmbyItem => ({ Id: id, Name: id, Type: 'Movie' }); + + it('a homogeneous music playlist stays entirely music', () => { + const { mediaType, items } = splitPlaylistByMediaType([audio('t1'), audio('t2')]); + assert.equal(mediaType, 'music'); + assert.equal(items.length, 2); + }); + + it('a homogeneous video playlist stays entirely video', () => { + const { mediaType, items } = splitPlaylistByMediaType([movie('m1'), movie('m2')]); + assert.equal(mediaType, 'video'); + assert.equal(items.length, 2); + }); + + it('a mixed playlist keeps only the first item\'s type, dropping the rest', () => { + const { mediaType, items } = splitPlaylistByMediaType([movie('m1'), audio('t1'), movie('m2')]); + assert.equal(mediaType, 'video'); + assert.deepEqual(items.map((i) => i.Id), ['m1', 'm2']); + }); + + it('an empty playlist is handled without throwing', () => { + const { mediaType, items } = splitPlaylistByMediaType([]); + assert.equal(mediaType, 'video'); + assert.equal(items.length, 0); + }); });