Fix the listen-party queue's layout and its missing album art
Reported directly: the music bar's queue popup looked broken, and the
listen-party card on the home page never showed an image. Put together a
mockup first (this app's own theme tokens, not a generic one) to confirm
the redesign direction before touching any code, since the queue side
was a real UI rework, not a one-line fix.
The layout bug: MusicBar.ts's queue rows were reusing views/album's
shared .trackRow (styles/trackList.css) - a CSS grid with 4 fixed
columns sized for THAT page's own row shape (track number, name,
duration, a button). The queue never had a track number, and rendered
completely different content in that same slot, so the track's own name
- the first child - landed in the 28px column meant for a one-or-two-
digit index, crushed to its first couple of characters, while the
artist/album text filled the wide column meant for the name instead.
Fixed with a dedicated .queueRow (flex, not grid): album art, a
truncating name+artist/album/added-by info column, duration on the
right.
The missing images: confirmed directly against the real Emby server
(three sampled tracks, Fields=AlbumId,ImageTags) that an individual
Audio item essentially never carries its own Primary image - every one
tested answered 500, not even a clean 404 - while the same tracks'
parent albums mostly did. Every place that built an image URL from a
raw EmbyItem was independently reconstructing /api/catalog/image/{id}
by hand (CatalogService.toItemDto, and three separate spots in
index.ts's party-creation routes bypassing toItemDto entirely) - all
now funnel through one new CatalogService.imageUrlFor(item), which
points a track at its AlbumId instead of its own id when one's known.
EmbyItem gained AlbumId; every Fields param listing Audio items now
requests it.
While fixing the now-working image, the home page's party card turned
out to be the wrong shape for music too: a fixed 48x72 portrait box,
correct for a movie/show poster, forcing a hard crop on an album's
square cover. Video parties are untouched; a music party's card now
gets a 56x56 square box instead, toggled from LobbyPartyDto.mediaType.
Tests: imageUrlFor (movie/episode unaffected, a track with an AlbumId
falls back to it, a track with neither still falls back to its own id
rather than a broken URL). Full typecheck/test/build clean on both
projects (118/118 server tests). Verified visually against the actual
compiled app.css (not just the mockup) - injected real markup using the
new classes into a running instance and confirmed the queue rows and
both card shapes render as designed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b4c81d820c
commit
5a1bc8d932
10 changed files with 186 additions and 17 deletions
|
|
@ -302,6 +302,10 @@ Then put it behind your existing reverse proxy like everything else
|
||||||
- [x] Cast a party's video to a Chromecast (Default Media Receiver),
|
- [x] Cast a party's video to a Chromecast (Default Media Receiver),
|
||||||
with a play/pause/seek remote on the sender's own screen — see
|
with a play/pause/seek remote on the sender's own screen — see
|
||||||
docs/architecture.md
|
docs/architecture.md
|
||||||
|
- [x] Fixed the listen-party queue's broken layout (it was reusing an
|
||||||
|
album page's row grid) and its missing album art (a track almost
|
||||||
|
never has its own image — now falls back to its album's) — 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)
|
||||||
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
|
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
|
||||||
confirmed here — no real device/session)
|
confirmed here — no real device/session)
|
||||||
|
|
|
||||||
|
|
@ -99,3 +99,54 @@
|
||||||
.musicBarInfo { margin-right: 0; flex-basis: 100%; order: -1; text-align: center; }
|
.musicBarInfo { margin-right: 0; flex-basis: 100%; order: -1; text-align: center; }
|
||||||
.musicBarQueue { max-width: none; margin: 0; border-radius: 0; }
|
.musicBarQueue { max-width: none; margin: 0; border-radius: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* One queue entry - a plain flex row (image, then a shrinking/truncating
|
||||||
|
info column, then the duration), NOT views/album's shared .trackRow
|
||||||
|
grid (styles/trackList.css) - that one's 4 fixed columns assume
|
||||||
|
Album.ts's own index/name/duration/button shape, which crushed this
|
||||||
|
list's track names into its 28px index column when reused here. See
|
||||||
|
MusicBar.ts's own comment. */
|
||||||
|
.queueRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queueRowArt {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-header);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queueRowArt.noImage { visibility: hidden; }
|
||||||
|
|
||||||
|
.queueRowInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queueRowName {
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Artist/album/"Ajouté par X" condensed onto one secondary line (see
|
||||||
|
MusicBar.ts) rather than each getting their own column - there's no
|
||||||
|
room for 3+ columns once a name needs to read in full, unlike
|
||||||
|
Album.ts's simpler index/name/duration/button row. */
|
||||||
|
.queueRowMeta {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -267,13 +267,22 @@ function applyMusicState(music: MusicPublicState): void {
|
||||||
playPauseBtn.disabled = !music.current;
|
playPauseBtn.disabled = !music.current;
|
||||||
updateVoteSkipLabel();
|
updateVoteSkipLabel();
|
||||||
|
|
||||||
|
// Its own .queueRow, NOT views/album's shared .trackRow (see
|
||||||
|
// styles/trackList.css) - that one is a CSS grid whose 4 columns
|
||||||
|
// (28px index / 1fr name / auto duration / auto button) assume
|
||||||
|
// Album.ts's own row shape. Reusing it here used to crush the track
|
||||||
|
// NAME into the 28px column meant for a track number, since this
|
||||||
|
// queue's rows never had an index at all - a real, visibly broken
|
||||||
|
// layout bug, not just a style mismatch. See docs/architecture.md.
|
||||||
queueList.innerHTML = music.queue.length
|
queueList.innerHTML = music.queue.length
|
||||||
? music.queue.map((t) => `
|
? music.queue.map((t) => `
|
||||||
<div class="trackRow">
|
<div class="queueRow">
|
||||||
<span class="trackName">${escapeHtml(t.itemName)}</span>
|
<img class="queueRowArt" src="${escapeHtml(t.itemImageUrl || '')}" alt="" onerror="this.classList.add('noImage')" />
|
||||||
<span class="hint">${escapeHtml([t.artistName, t.albumName].filter(Boolean).join(' - '))}</span>
|
<div class="queueRowInfo">
|
||||||
<span class="trackDuration">${t.durationSeconds ? formatDuration(t.durationSeconds) : ''}</span>
|
<span class="queueRowName">${escapeHtml(t.itemName)}</span>
|
||||||
<span class="hint">Ajouté par ${escapeHtml(t.addedByUserName)}</span>
|
<span class="hint queueRowMeta">${escapeHtml([t.artistName, t.albumName, `Ajouté par ${t.addedByUserName}`].filter(Boolean).join(' · '))}</span>
|
||||||
|
</div>
|
||||||
|
<span class="hint trackDuration">${t.durationSeconds ? formatDuration(t.durationSeconds) : ''}</span>
|
||||||
</div>`).join('')
|
</div>`).join('')
|
||||||
: '<p class="hint">Rien en attente.</p>';
|
: '<p class="hint">Rien en attente.</p>';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,14 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A music party's own cover art is square (an album's, not a movie/show
|
||||||
|
poster) - the portrait 48x72 box above was forcing a hard crop on it.
|
||||||
|
Video parties keep the portrait box unchanged. */
|
||||||
|
.partyCard img.partyCardArt--square {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
.partyCard img.noImage { visibility: hidden; }
|
.partyCard img.noImage { visibility: hidden; }
|
||||||
|
|
||||||
.partyCardInfo {
|
.partyCardInfo {
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
|
||||||
section.hidden = false;
|
section.hidden = false;
|
||||||
list.innerHTML = parties.map((p) => `
|
list.innerHTML = parties.map((p) => `
|
||||||
<div class="partyCard">
|
<div class="partyCard">
|
||||||
<img src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
|
<img class="${p.mediaType === 'music' ? 'partyCardArt--square' : ''}" src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
|
||||||
<div class="partyCardInfo">
|
<div class="partyCardInfo">
|
||||||
<strong>${escapeHtml(p.itemName || '')}</strong>
|
<strong>${escapeHtml(p.itemName || '')}</strong>
|
||||||
<span class="hint">
|
<span class="hint">
|
||||||
|
|
|
||||||
|
|
@ -2520,6 +2520,63 @@ output cleanly are all unconfirmed, the same way this project already
|
||||||
flags its other can't-verify-without-the-real-thing assumptions. Worth
|
flags its other can't-verify-without-the-real-thing assumptions. Worth
|
||||||
trying with a real device once deployed.
|
trying with a real device once deployed.
|
||||||
|
|
||||||
|
## Fixing the listen-party queue's layout and its missing album art
|
||||||
|
|
||||||
|
Reported directly: the music bar's "File d'attente" popup looked broken,
|
||||||
|
and the listen-party card on the home page never showed an image. A
|
||||||
|
mockup was put together first (before touching any code) to confirm the
|
||||||
|
direction, since the queue side was a genuine UI rework, not just a
|
||||||
|
one-line fix.
|
||||||
|
|
||||||
|
**The layout bug**: `MusicBar.ts`'s queue rows were reusing
|
||||||
|
`styles/trackList.css`'s shared `.trackRow` - a CSS grid with 4 fixed
|
||||||
|
columns (`28px 1fr auto auto`) sized for `views/album`'s own row shape
|
||||||
|
(track number, name, duration, an "Ajouter"/"Écouter ensemble" button).
|
||||||
|
The queue's rows never had a track number at all, and rendered
|
||||||
|
completely different content in that same slot - the track's own NAME
|
||||||
|
was the first child, so it landed in the 28px column meant for a
|
||||||
|
one-or-two-digit index, crushed down to its first couple of characters,
|
||||||
|
while the artist/album text spilled into the wide `1fr` column instead
|
||||||
|
meant for the name. Fixed with a dedicated `.queueRow` (flex, not grid -
|
||||||
|
image, then a truncating info column, then the duration), rather than
|
||||||
|
trying to force this list's different shape through Album's grid with
|
||||||
|
more special-casing.
|
||||||
|
|
||||||
|
**The missing images**: confirmed directly against the real Emby server
|
||||||
|
(three sampled tracks, `Fields=AlbumId,ImageTags`) that an individual
|
||||||
|
Audio item essentially never carries its own Primary image - every one
|
||||||
|
tested answered `500`, not even a clean `404` - while the same tracks'
|
||||||
|
parent albums mostly did (`200 image/jpeg` for two of three; the third
|
||||||
|
album genuinely has no artwork uploaded at all, a real data gap, not a
|
||||||
|
bug). Every place that built an image URL from a raw `EmbyItem`
|
||||||
|
independently reconstructed `/api/catalog/image/${item.Id}` by hand
|
||||||
|
(`CatalogService.toItemDto`, and three separate spots in `index.ts`'s
|
||||||
|
party-creation routes that skip `toItemDto` entirely) - all now funnel
|
||||||
|
through one new `CatalogService.imageUrlFor(item)`, which points a music
|
||||||
|
track at its `AlbumId` instead of its own `Id` when one's known.
|
||||||
|
`EmbyItem` gained the `AlbumId` field, and every `Fields` param that
|
||||||
|
lists Audio items among its results (`getAlbumTracks`,
|
||||||
|
`getPlaylistItemsRaw`, and defensively `getRecentItems`/
|
||||||
|
`getLibraryItems`) now requests it - a single-item detail fetch
|
||||||
|
(`getRawItem`, used by party creation) already includes it with no
|
||||||
|
`Fields` param needed, confirmed against the real server.
|
||||||
|
|
||||||
|
While fixing the home-page card's now-working image, its box turned out
|
||||||
|
to be the wrong shape for music too: `.partyCard img` was a fixed
|
||||||
|
48×72 portrait box - correct for a movie/show poster, but a hard crop
|
||||||
|
for an album's square cover. A video party's card is untouched; a
|
||||||
|
music party's now gets a 56×56 square box instead
|
||||||
|
(`.partyCardArt--square`, toggled from `LobbyPartyDto.mediaType` in
|
||||||
|
`Home.ts`).
|
||||||
|
|
||||||
|
Verified: full `typecheck`/`test`/`build` on both projects, including
|
||||||
|
new unit tests for `imageUrlFor` (a movie/episode unaffected, a track
|
||||||
|
with an `AlbumId` falls back to it, a track with none still falls back
|
||||||
|
to its own id rather than building a broken URL). No server is needed
|
||||||
|
to see the queue/card layout itself, so those were checked visually
|
||||||
|
against a mockup built from this app's own real theme tokens/typefaces
|
||||||
|
rather than a live instance.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
- Playback always goes through `hls.js` against whatever Emby's
|
- Playback always goes through `hls.js` against whatever Emby's
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ 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 { 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, splitPlaylistByMediaType } from './services/CatalogService';
|
import { CatalogService, CatalogFetchError, imageUrlFor, 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';
|
||||||
|
|
@ -323,7 +323,7 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
|
||||||
session.userName,
|
session.userName,
|
||||||
itemId,
|
itemId,
|
||||||
queue ? queue[0]!.itemName : item.Name,
|
queue ? queue[0]!.itemName : item.Name,
|
||||||
`/api/catalog/image/${itemId}`,
|
imageUrlFor(item),
|
||||||
queue,
|
queue,
|
||||||
mediaType
|
mediaType
|
||||||
);
|
);
|
||||||
|
|
@ -335,7 +335,7 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
|
||||||
itemName: item.Name,
|
itemName: item.Name,
|
||||||
artistName: item.AlbumArtist || item.Artists?.[0] || null,
|
artistName: item.AlbumArtist || item.Artists?.[0] || null,
|
||||||
albumName: item.Album || null,
|
albumName: item.Album || null,
|
||||||
itemImageUrl: `/api/catalog/image/${itemId}`,
|
itemImageUrl: imageUrlFor(item),
|
||||||
durationTicks: item.RunTimeTicks || null
|
durationTicks: item.RunTimeTicks || null
|
||||||
}, session.userId, session.userName);
|
}, session.userId, session.userName);
|
||||||
}
|
}
|
||||||
|
|
@ -375,7 +375,7 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
|
||||||
const first = items[0]!;
|
const first = items[0]!;
|
||||||
const party = PartyService.create(
|
const party = PartyService.create(
|
||||||
session.userId, session.userName,
|
session.userId, session.userName,
|
||||||
first.Id, first.Name, `/api/catalog/image/${first.Id}`,
|
first.Id, first.Name, imageUrlFor(first),
|
||||||
null, mediaType
|
null, mediaType
|
||||||
);
|
);
|
||||||
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
||||||
|
|
@ -388,7 +388,7 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
|
||||||
itemName: track.Name,
|
itemName: track.Name,
|
||||||
artistName: track.AlbumArtist || track.Artists?.[0] || null,
|
artistName: track.AlbumArtist || track.Artists?.[0] || null,
|
||||||
albumName: track.Album || null,
|
albumName: track.Album || null,
|
||||||
itemImageUrl: `/api/catalog/image/${track.Id}`,
|
itemImageUrl: imageUrlFor(track),
|
||||||
durationTicks: track.RunTimeTicks || null
|
durationTicks: track.RunTimeTicks || null
|
||||||
};
|
};
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,26 @@ const IMAGE_SUBTITLE_CODECS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvb
|
||||||
* unit-tested directly (see catalogService.test.ts) without needing to
|
* unit-tested directly (see catalogService.test.ts) without needing to
|
||||||
* exercise the actual /api/parties route that calls it (index.ts).
|
* exercise the actual /api/parties route that calls it (index.ts).
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The image URL for a raw Emby item - every place that builds one from an
|
||||||
|
* `EmbyItem` (toItemDto below, and the party-creation routes in index.ts
|
||||||
|
* that hand-build one straight from a freshly-fetched item without going
|
||||||
|
* through a DTO) funnels through here. Almost always just `item.Id`
|
||||||
|
* itself, EXCEPT a music track: confirmed directly against a real server
|
||||||
|
* that an individual Audio item essentially never carries its own Primary
|
||||||
|
* image (every track sampled answered with a 500, not even a clean 404),
|
||||||
|
* while its parent album usually does - so a track's own id would just
|
||||||
|
* mean a permanently broken/missing thumbnail everywhere one is shown
|
||||||
|
* (the listen-party queue, the home page's live-party card) instead of
|
||||||
|
* the same cover art every other client shows next to it. Callers must
|
||||||
|
* request `AlbumId` in their own `Fields` param for this to have
|
||||||
|
* anything to fall back to - see getAlbumTracks/getPlaylistItemsRaw.
|
||||||
|
*/
|
||||||
|
export function imageUrlFor(item: EmbyItem): string {
|
||||||
|
const imageItemId = item.Type === 'Audio' && item.AlbumId ? item.AlbumId : item.Id;
|
||||||
|
return `/api/catalog/image/${imageItemId}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function splitPlaylistByMediaType(items: EmbyItem[]): { mediaType: MediaType; items: EmbyItem[] } {
|
export function splitPlaylistByMediaType(items: EmbyItem[]): { mediaType: MediaType; items: EmbyItem[] } {
|
||||||
if (!items.length) return { mediaType: 'video', items: [] };
|
if (!items.length) return { mediaType: 'video', items: [] };
|
||||||
const isMusic = items[0]!.Type === 'Audio';
|
const isMusic = items[0]!.Type === 'Audio';
|
||||||
|
|
@ -129,7 +149,7 @@ class CatalogServiceClass {
|
||||||
|
|
||||||
/** Home page carousel: Emby's own "recently added" endpoint, one row per library. */
|
/** Home page carousel: Emby's own "recently added" endpoint, one row per library. */
|
||||||
async getRecentItems(session: EmbySession, libraryId: string): Promise<ItemDto[]> {
|
async getRecentItems(session: EmbySession, libraryId: string): Promise<ItemDto[]> {
|
||||||
const params = new URLSearchParams({ ParentId: libraryId, Limit: '16', Fields: 'ProductionYear' });
|
const params = new URLSearchParams({ ParentId: libraryId, Limit: '16', Fields: 'ProductionYear,AlbumId' });
|
||||||
const data = await this.fetchJson<EmbyItemsResponse | EmbyItem[]>(`/Users/${session.userId}/Items/Latest?${params}`, session, 'Impossible de récupérer les ajouts récents.');
|
const data = await this.fetchJson<EmbyItemsResponse | EmbyItem[]>(`/Users/${session.userId}/Items/Latest?${params}`, session, 'Impossible de récupérer les ajouts récents.');
|
||||||
// Unlike most Items endpoints, Latest returns a plain array, not {Items:[...]}.
|
// Unlike most Items endpoints, Latest returns a plain array, not {Items:[...]}.
|
||||||
const items = Array.isArray(data) ? data : (data.Items || []);
|
const items = Array.isArray(data) ? data : (data.Items || []);
|
||||||
|
|
@ -164,7 +184,7 @@ class CatalogServiceClass {
|
||||||
* getPlaylistItems below for the DTO-shaped equivalent the client's
|
* getPlaylistItems below for the DTO-shaped equivalent the client's
|
||||||
* own playlist page reads instead. */
|
* own playlist page reads instead. */
|
||||||
async getPlaylistItemsRaw(session: EmbySession, playlistId: string): Promise<EmbyItem[]> {
|
async getPlaylistItemsRaw(session: EmbySession, playlistId: string): Promise<EmbyItem[]> {
|
||||||
const params = new URLSearchParams({ UserId: session.userId, Fields: 'Overview,ProductionYear,RunTimeTicks,IndexNumber,Album,AlbumArtist' });
|
const params = new URLSearchParams({ UserId: session.userId, Fields: 'Overview,ProductionYear,RunTimeTicks,IndexNumber,Album,AlbumArtist,AlbumId' });
|
||||||
const data = await this.fetchJson<EmbyItemsResponse>(`/Playlists/${playlistId}/Items?${params}`, session, 'Impossible de récupérer le contenu de cette playlist.');
|
const data = await this.fetchJson<EmbyItemsResponse>(`/Playlists/${playlistId}/Items?${params}`, session, 'Impossible de récupérer le contenu de cette playlist.');
|
||||||
return data.Items || [];
|
return data.Items || [];
|
||||||
}
|
}
|
||||||
|
|
@ -203,7 +223,7 @@ class CatalogServiceClass {
|
||||||
UserId: session.userId,
|
UserId: session.userId,
|
||||||
IncludeItemTypes: 'Audio',
|
IncludeItemTypes: 'Audio',
|
||||||
SortBy: 'IndexNumber',
|
SortBy: 'IndexNumber',
|
||||||
Fields: 'RunTimeTicks,IndexNumber,Album,AlbumArtist'
|
Fields: 'RunTimeTicks,IndexNumber,Album,AlbumArtist,AlbumId'
|
||||||
});
|
});
|
||||||
const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer les morceaux.');
|
const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer les morceaux.');
|
||||||
return (data.Items || []).map((t) => this.toItemDto(t, session.serverId));
|
return (data.Items || []).map((t) => this.toItemDto(t, session.serverId));
|
||||||
|
|
@ -214,7 +234,7 @@ class CatalogServiceClass {
|
||||||
ParentId: libraryId,
|
ParentId: libraryId,
|
||||||
Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode)
|
Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode)
|
||||||
SortBy: 'SortName',
|
SortBy: 'SortName',
|
||||||
Fields: 'Overview,ProductionYear',
|
Fields: 'Overview,ProductionYear,AlbumId',
|
||||||
ImageTypeLimit: '1',
|
ImageTypeLimit: '1',
|
||||||
Limit: '300'
|
Limit: '300'
|
||||||
});
|
});
|
||||||
|
|
@ -410,7 +430,7 @@ class CatalogServiceClass {
|
||||||
year: item.ProductionYear,
|
year: item.ProductionYear,
|
||||||
type: item.Type,
|
type: item.Type,
|
||||||
canWatchParty: WATCH_PARTY_TYPES.has(item.Type || ''),
|
canWatchParty: WATCH_PARTY_TYPES.has(item.Type || ''),
|
||||||
imageUrl: `/api/catalog/image/${item.Id}`,
|
imageUrl: imageUrlFor(item),
|
||||||
embyUrl: `${EMBY_SERVER_URL}/web/index.html#!/item?id=${item.Id}&serverId=${encodeURIComponent(serverId || '')}`,
|
embyUrl: `${EMBY_SERVER_URL}/web/index.html#!/item?id=${item.Id}&serverId=${encodeURIComponent(serverId || '')}`,
|
||||||
// Track-specific (null for anything else - harmless, every consumer
|
// Track-specific (null for anything else - harmless, every consumer
|
||||||
// that cares about these only reads them for an Audio item).
|
// that cares about these only reads them for an Audio item).
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,12 @@ export interface EmbyItem {
|
||||||
Album?: string;
|
Album?: string;
|
||||||
AlbumArtist?: string;
|
AlbumArtist?: string;
|
||||||
Artists?: string[];
|
Artists?: string[];
|
||||||
|
/** The parent album's own id - only meaningful for Type: 'Audio'. See
|
||||||
|
* CatalogService.imageUrlFor: confirmed against a real server that an
|
||||||
|
* individual track essentially never has its own Primary image (every
|
||||||
|
* one sampled 500'd), only its album does - this is how an image URL
|
||||||
|
* falls back to the album's art instead. */
|
||||||
|
AlbumId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** This app's own DTO shape - what toItemDto() in CatalogService produces,
|
/** This app's own DTO shape - what toItemDto() in CatalogService produces,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
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, splitPlaylistByMediaType } from '../src/services/CatalogService';
|
import { CatalogService, imageUrlFor, splitPlaylistByMediaType } from '../src/services/CatalogService';
|
||||||
import { EmbyClient } from '../src/emby/EmbyClient';
|
import { EmbyClient } from '../src/emby/EmbyClient';
|
||||||
import type { EmbyItem, EmbySession } from '../src/types';
|
import type { EmbyItem, EmbySession } from '../src/types';
|
||||||
|
|
||||||
|
|
@ -223,3 +223,17 @@ describe('splitPlaylistByMediaType', () => {
|
||||||
assert.equal(items.length, 0);
|
assert.equal(items.length, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('imageUrlFor', () => {
|
||||||
|
it('a movie/episode points at its own id, same as ever', () => {
|
||||||
|
assert.equal(imageUrlFor({ Id: 'm1', Name: 'Un Film', Type: 'Movie' }), '/api/catalog/image/m1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a music track falls back to its ALBUM\'s id - confirmed against a real server that a track essentially never has its own Primary image', () => {
|
||||||
|
assert.equal(imageUrlFor({ Id: 't1', Name: 'Track', Type: 'Audio', AlbumId: 'al1' }), '/api/catalog/image/al1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a track with no AlbumId at all (nothing to fall back to) still points at its own id, not a broken URL', () => {
|
||||||
|
assert.equal(imageUrlFor({ Id: 't1', Name: 'Track', Type: 'Audio' }), '/api/catalog/image/t1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue