Merge pull request 'Fix the alphabet index bucketing by display name instead of Emby SortName' (#16) from fix/alphabet-index-sort-order into master
All checks were successful
Publier l'image Docker / test (push) Successful in 25s
Publier l'image Docker / publish (push) Successful in 14s

Reviewed-on: #16
This commit is contained in:
kyuno 2026-09-16 21:44:44 +02:00
commit 292fec9c19
6 changed files with 91 additions and 13 deletions

View file

@ -372,6 +372,10 @@ Then put it behind your existing reverse proxy like everything else
- [x] Library alphabet index: sticky per-letter section headings plus a
right-edge A-Z rail to jump straight to one, drag-to-scrub like
iOS's own Contacts — see docs/architecture.md
- [x] Fixed the alphabet index's headers landing in the wrong order —
it was bucketing by display name instead of Emby's own SortName,
so an article-stripped title ("The ...") threw a whole run off —
see docs/architecture.md
- [ ] 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
confirmed here — no real device/session)

View file

@ -11,12 +11,25 @@ import type { ItemDto } from './types';
const ALL_BUCKETS = ['#', ...Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))];
/** A name's index bucket: its first letter, accents folded to their base
* (so "Écho" lands on "E", matching how a French speaker would expect
* the index to group it, not off in its own "É"/"#" bucket) - anything
* else (a digit, empty) falls back to "#". */
function bucketFor(name: string): string {
const base = (name || '').trim().normalize('NFD').replace(/[̀-ͯ]/g, '');
/**
* An item's index bucket: the first letter of its `sortName` - NOT
* `name` - with accents folded to their base (so "Écho" lands on "E",
* matching how a French speaker would expect the index to group it, not
* off in its own "É"/"#" bucket). Anything else (a digit, empty) falls
* back to "#".
*
* `sortName`, deliberately, not the display name: `items` arrives in
* Emby's own SortName order (getLibraryItems/getArtists both request
* `SortBy: SortName`), and that order strips leading articles ("Le
* Voyage" sorts as "Voyage") - bucketing by the raw display name instead
* put a "L" heading in the middle of a "V" run the one time an article
* did that, reported directly as headers in no sensible order at all.
* Bucketing by the exact same key Emby used to order the array is what
* guarantees a bucket is always one contiguous run, not a guess that it
* happens to line up.
*/
function bucketFor(item: ItemDto): string {
const base = (item.sortName || item.name || '').trim().normalize('NFD').replace(/[̀-ͯ]/g, '');
const c = base.charAt(0).toUpperCase();
return c >= 'A' && c <= 'Z' ? c : '#';
}
@ -26,9 +39,8 @@ function bucketFor(name: string): string {
* not one big innerHTML write - see that function's own comment for why
* that matters on a 1000+-item library), plus a `.letterHeading` inserted
* right before the first tile of each new bucket. `items` is assumed
* already alphabetical (embyApi's getLibraryItems/getArtists both ask
* Emby for SortBy: SortName) - a chunk boundary landing mid-bucket is
* fine, it's still one contiguous run either way.
* already ordered by `sortName` (see bucketFor) - a chunk boundary
* landing mid-bucket is fine, it's still one contiguous run either way.
*/
export function renderGroupedTilesInChunks(container: HTMLElement, items: ItemDto[], chunkSize = 40): void {
container.innerHTML = '';
@ -39,7 +51,7 @@ export function renderGroupedTilesInChunks(container: HTMLElement, items: ItemDt
if (!chunk.length) return;
let html = '';
for (const item of chunk) {
const bucket = bucketFor(item.name);
const bucket = bucketFor(item);
if (bucket !== lastBucket) {
html += `<div class="letterHeading" data-letter-anchor="${bucket}">${bucket}</div>`;
lastBucket = bucket;
@ -100,7 +112,7 @@ export function wireSectionSearchSync(input: HTMLInputElement, grid: HTMLElement
* once before.
*/
export function wireAlphaRail(root: HTMLElement, grid: HTMLElement, items: ItemDto[]): CleanupFn {
const present = new Set(items.map((it) => bucketFor(it.name)));
const present = new Set(items.map((it) => bucketFor(it)));
const libraryHeader = root.querySelector('.libraryHeader') as HTMLElement;
const bottomStack = document.querySelector('.bottomStack') as HTMLElement | null;

View file

@ -9,6 +9,10 @@
export interface ItemDto {
id: string;
name: string;
/** Emby's own sort key (articles stripped) when the server requested
* it, the display name otherwise - see server/src/types.ts's own
* ItemDto.sortName for why alphaIndex.ts buckets by this, not name. */
sortName: string;
overview?: string;
year?: number;
type?: string;

View file

@ -4062,6 +4062,47 @@ rail's bottom edge with it and back; and calling the returned cleanup
stopped that last reaction cold, confirming the observer was actually
disconnected rather than just idle.
## The alphabet index's headers were in the wrong order
Reported directly, against a real library: header letters coming out
as e.g. `A, B, T, B, L` instead of one run per letter in order. Not a
sorting bug in Emby, and not a bug in `renderGroupedTilesInChunks`'s own
"new bucket = new heading" logic either - both were already correct.
The actual mismatch: `getLibraryItems`/`getArtists` ask Emby to *order*
the array by `SortBy: SortName`, but never asked for each item's
`SortName` *value* back, so `alphaIndex.ts`'s `bucketFor` had nothing to
bucket by except the raw display `name` - and Emby's `SortName` strips
leading articles ("The", "Le", "L'", ...) for exactly the kind of
natural alphabetical browsing this index is for. An item like "The
Bunker" sorts (correctly) among the B's by Emby's own SortName, but
bucketing it by its literal display name instead computed "T" - a
spurious heading mid-run, immediately followed by a second, duplicate
"B" heading once the next real B-titled item came right back around.
Every other letter, with no such item, looked completely fine, which is
why this wasn't caught by the earlier synthetic verification (its fake
titles never happened to include an article-stripped one at all).
Fixed at the source instead of trying to reimplement Emby's own
article-stripping rules client-side (which language's articles, and
which are configurable server-side, isn't this app's business to know):
`getLibraryItems`/`getArtists` now also request `Fields: SortName`, new
`ItemDto.sortName` carries it through (falling back to `name` when a
caller doesn't request it - same "populated where it matters, harmless
default elsewhere" shape `durationTicks`/`indexNumber` already use), and
`alphaIndex.ts` buckets by *that*, not `name`. Bucketing by the exact
same key Emby used to order the array is what guarantees a bucket is
always one contiguous run - not bucketing by something that's usually
close enough.
Verified: full `typecheck`/`test`/`build` on both projects (147/147
server tests). Reproduced the exact reported shape first - a small
fake item list in Emby's own SortName order, one entry's display name
starting with a different letter than its `sortName` (`"The Bunker"`,
`sortName: "Bunker"`, positioned between two other B titles) - confirmed
it produced the same `A, B, T, B, L` heading sequence against the old
bucket-by-`name` code, then confirmed the fix collapses it to the
correct `A, B, L` against the exact same input.
## Known limitations
- Playback always goes through `hls.js` against whatever Emby's

View file

@ -205,7 +205,7 @@ class CatalogServiceClass {
// so a 300 cap would have silently dropped 75% of them, not just
// trimmed an edge case. Rendering that many is tiles.ts's
// renderTilesInChunks's job, not this query's.
const params = new URLSearchParams({ ParentId: libraryId, UserId: session.userId, SortBy: 'SortName' });
const params = new URLSearchParams({ ParentId: libraryId, UserId: session.userId, SortBy: 'SortName', Fields: 'SortName' });
const data = await this.fetchJson<EmbyItemsResponse>(`/Artists?${params}`, session, 'Impossible de récupérer les artistes.');
return (data.Items || []).map((a) => this.toItemDto(a, session.serverId));
}
@ -261,7 +261,7 @@ class CatalogServiceClass {
ParentId: libraryId,
Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode)
SortBy: 'SortName',
Fields: 'ProductionYear,AlbumId',
Fields: 'ProductionYear,AlbumId,SortName',
ImageTypeLimit: '1'
});
const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer le contenu de cette bibliothèque.');
@ -503,6 +503,7 @@ class CatalogServiceClass {
return {
id: item.Id,
name: item.Name,
sortName: item.SortName || item.Name,
overview: item.Overview,
year: item.ProductionYear,
type: item.Type,

View file

@ -157,6 +157,12 @@ declare global {
export interface EmbyItem {
Id: string;
Name: string;
/** Emby's own sort key - articles ("The", "Le", "L'", ...) stripped,
* diacritics/case normalized its own way. Only present when a caller's
* `Fields` actually asks for it (getLibraryItems/getArtists do, for
* the library alphabet index - see ItemDto.sortName and
* CatalogService.toItemDto). */
SortName?: string;
Overview?: string;
ProductionYear?: number;
Type?: string;
@ -183,6 +189,16 @@ export interface EmbyItem {
export interface ItemDto {
id: string;
name: string;
/** Emby's own SortName when the caller requested it, the display name
* otherwise (CatalogService.toItemDto always falls back) - same
* "populated where it matters, harmless default elsewhere" shape as
* durationTicks/indexNumber below. Only client/src/alphaIndex.ts reads
* this today, to bucket a library by letter the same way Emby itself
* already ordered it: Emby's SortName strips leading articles (e.g.
* "Le Voyage" sorts as "Voyage"), so bucketing by the raw display name
* instead put a "L" header in the middle of a "V" run - reported
* directly as headers landing in no sensible order at all. */
sortName: string;
overview?: string;
year?: number;
type?: string;