Browse Emby playlists and launch one as a pre-queued party
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 16s

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>
This commit is contained in:
Nicolas 2026-09-14 19:04:31 +02:00
parent 26da8fc5dc
commit b45a243e59
11 changed files with 426 additions and 15 deletions

View file

@ -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 1. Sign in with your Emby account (just username/password — the server
address is fixed server-side, see Deploying below). address is fixed server-side, see Deploying below).
2. A sidebar lists your Emby libraries (movies, TV, music, music videos — 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 playlists if you have any — anything else Emby might have, like
shown); the home page shows every ongoing 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 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 "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. 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" (its button reads "Ajouter à la file" instead of "Écouter ensemble"
once a party's already going). Starting or joining a video watch party 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. 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 Everything — catalog, images, video, audio, sync — flows through this
app's own backend, which proxies whatever it needs from Emby server-side. 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 - [x] Self-hosted ffmpeg burn-in for PGS/image subtitles (Emby's own
transcode can't render these as text) — one shared stream per transcode can't render these as text) — one shared stream per
party, host-driven — see docs/architecture.md 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) - [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Tune transcoding target / support direct play when possible - [ ] Tune transcoding target / support direct play when possible

View file

@ -103,6 +103,15 @@ export async function getAlbumTracks(albumId: string): Promise<ItemDto[]> {
return data.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> { export function getPlaybackOptions(itemId: string): Promise<PlaybackOptionsDto> {
return apiFetch(`/api/catalog/item/${itemId}/playback-options`); return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
} }

View file

@ -8,6 +8,7 @@ import { renderLibrary } from './views/library/Library';
import { renderSeries } from './views/series/Series'; import { renderSeries } from './views/series/Series';
import { renderArtist } from './views/artist/Artist'; import { renderArtist } from './views/artist/Artist';
import { renderAlbum } from './views/album/Album'; import { renderAlbum } from './views/album/Album';
import { renderPlaylist } from './views/playlist/Playlist';
import { renderPartyPlayer } from './views/player/Player'; import { renderPartyPlayer } from './views/player/Player';
route('/', () => navigate('/home')); route('/', () => navigate('/home'));
@ -17,6 +18,7 @@ route('/library/:id', renderLibrary);
route('/series/:id', renderSeries); route('/series/:id', renderSeries);
route('/artist/:id', renderArtist); route('/artist/:id', renderArtist);
route('/album/:id', renderAlbum); route('/album/:id', renderAlbum);
route('/playlist/:id', renderPlaylist);
route('/party/:id', renderPartyPlayer); route('/party/:id', renderPartyPlayer);
/** /**

View file

@ -45,7 +45,8 @@ export function initialsFor(name: string): string {
const DRILL_DOWN: Record<string, { route: string; label: string }> = { const DRILL_DOWN: Record<string, { route: string; label: string }> = {
Series: { route: 'series', label: 'Voir les épisodes' }, Series: { route: 'series', label: 'Voir les épisodes' },
MusicArtist: { route: 'artist', label: 'Voir les albums' }, 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 { export function tileHtml(item: ItemDto): string {

View file

@ -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; }
}

View file

@ -0,0 +1,13 @@
<button class="linklike" id="back">&larr; Retour</button>
<div class="playlistHeader">
<img class="playlistCover" id="playlistCover" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="seriesInfo">
<h1 id="playlistTitle"></h1>
<p class="hint" id="playlistMeta"></p>
<div class="playlistActions">
<button id="launchBtn">Lancer cette playlist</button>
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
</div>
</div>
<div id="itemList" class="episodeList"><p class="hint">Chargement...</p></div>

View file

@ -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<void> {
const playlistId = params.id as string;
root.innerHTML = '<p class="hint">Chargement...</p>';
let playlist: ItemDto;
let items: ItemDto[];
try {
[playlist, items] = await Promise.all([getItem(playlistId), getPlaylistItems(playlistId)]);
} catch {
root.innerHTML = '<p class="error">Impossible de charger cette playlist.</p>';
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 = '<p class="hint">Cette playlist ne contient rien pour le moment.</p>';
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 `
<div class="episodeRow">
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="episodeInfo">
<strong>${i + 1}. ${escapeHtml(item.name)}${duration ? ` <span class="hint">(${escapeHtml(duration)})</span>` : ''}</strong>
<p class="hint">${escapeHtml(meta)}</p>
</div>
</div>`;
}).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';
}
});
}

View file

@ -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, from ever being able to crash the server again in the meantime,
regardless of how - or whether - that got resolved. 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 ## Known limitations
- Playback always goes through `hls.js` against whatever Emby's - Playback always goes through `hls.js` against whatever Emby's

View file

@ -6,13 +6,14 @@ import express, { type NextFunction, type Request, type Response } from 'express
import { Server as SocketIoServer } from 'socket.io'; import { Server as SocketIoServer } from 'socket.io';
import { EMBY_SERVER_URL, PORT } from './config'; import { EMBY_SERVER_URL, PORT } from './config';
import { Logger } from './Logger';
import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthService'; 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 { PartyService } from './services/PartyService';
import { MusicQueueService } from './services/MusicQueueService'; import { MusicQueueService } from './services/MusicQueueService';
import { TranscodeService } from './services/TranscodeService'; import { TranscodeService } from './services/TranscodeService';
import { SocketGateway } from './realtime/SocketGateway'; 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(); const app = express();
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme 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')) }); 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 // Audio/subtitle tracks available for an item, read from Emby's own playback
// negotiation (PlaybackInfo) - powers the player's language/subtitle pickers. // negotiation (PlaybackInfo) - powers the player's language/subtitle pickers.
app.get('/api/catalog/item/:itemId/playback-options', requireSession, asyncRoute(async (req, res) => { 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 session = req.embySession!;
const item = await CatalogService.getRawItem(session, itemId); 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'; const mediaType: MediaType = item.Type === 'Audio' ? 'music' : 'video';
// Starting a party from an episode queues the rest of the series from // 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; 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<void> {
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 // 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 // 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 // this is actually called from has no live connection to the party it's

View file

@ -4,7 +4,7 @@ import type { Request, Response } from 'express';
import { EMBY_SERVER_URL } from '../config'; import { EMBY_SERVER_URL } from '../config';
import { EmbyClient } from '../emby/EmbyClient'; import { EmbyClient } from '../emby/EmbyClient';
import { Logger } from '../Logger'; 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 // 'MusicVideo' is Emby's own item type for a musicvideos library's
// contents (distinct from the generic 'Video', which is home videos). // 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']); const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']);
// Emby's /Views can return all sorts of things beyond actual media // Emby's /Views can return all sorts of things beyond actual media
// libraries - boxset collections, playlists, live TV, photos, books, a // libraries - boxset collections, live TV, photos, books, a mixed-content
// mixed-content folder with no CollectionType at all - none of which this // folder with no CollectionType at all - none of which this app has any
// app has any UI for. Only these four are ever shown, in the sidebar or // UI for. Only these five are ever shown, in the sidebar or anywhere
// anywhere else: everything else is dropped here, at the source, so every // else: everything else is dropped here, at the source, so every
// consumer (shell/sidebar, home's carousels, direct navigation to a // consumer (shell/sidebar, home's carousels, direct navigation to a
// library route) is filtered the same way without each having to repeat // library route) is filtered the same way without each having to repeat
// the same check. // the same check. 'playlists' only ever shows up here at all once the
const VISIBLE_COLLECTION_TYPES = new Set(['movies', 'tvshows', 'music', 'musicvideos']); // 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 // Image/bitmap subtitle codecs - pre-rendered pictures (from a Blu-ray/DVD
// rip), not text. This app forces SubtitleMethod=Encode whenever a // 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. // 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']); 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 { interface EmbyItemsResponse {
Items?: EmbyItem[]; Items?: EmbyItem[];
} }
@ -133,6 +155,25 @@ class CatalogServiceClass {
return data.Items || []; 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<EmbyItem[]> {
const params = new URLSearchParams({ UserId: session.userId, Fields: 'Overview,ProductionYear,RunTimeTicks,IndexNumber,Album,AlbumArtist' });
const data = await this.fetchJson<EmbyItemsResponse>(`/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<ItemDto[]> {
const items = await this.getPlaylistItemsRaw(session, playlistId);
return items.map((i) => this.toItemDto(i, session.serverId));
}
// Library -> Artists -> Albums -> Tracks, for the "Musique" browsing // Library -> Artists -> Albums -> Tracks, for the "Musique" browsing
// pages. Each its own Emby endpoint/query rather than guessing folder // pages. Each its own Emby endpoint/query rather than guessing folder
// structure, same reasoning as Shows -> Seasons -> Episodes above. // structure, same reasoning as Shows -> Seasons -> Episodes above.

View file

@ -4,9 +4,9 @@
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import type { Request, Response } from 'express'; 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 { 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() }; 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 (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);
});
}); });