Browse Emby playlists and launch one as a pre-queued party
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:
parent
26da8fc5dc
commit
b45a243e59
11 changed files with 426 additions and 15 deletions
12
README.md
12
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
|
||||
|
||||
|
|
|
|||
|
|
@ -103,6 +103,15 @@ export async function getAlbumTracks(albumId: string): Promise<ItemDto[]> {
|
|||
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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ export function initialsFor(name: string): string {
|
|||
const DRILL_DOWN: Record<string, { route: string; label: string }> = {
|
||||
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 {
|
||||
|
|
|
|||
36
client/src/views/playlist/Playlist.css
Normal file
36
client/src/views/playlist/Playlist.css
Normal 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; }
|
||||
}
|
||||
13
client/src/views/playlist/Playlist.html
Normal file
13
client/src/views/playlist/Playlist.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<button class="linklike" id="back">← 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>
|
||||
89
client/src/views/playlist/Playlist.ts
Normal file
89
client/src/views/playlist/Playlist.ts
Normal 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';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
// pages. Each its own Emby endpoint/query rather than guessing folder
|
||||
// structure, same reasoning as Shows -> Seasons -> Episodes above.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue