Fix the alphabet index bucketing by display name instead of Emby SortName #16

Merged
kyuno merged 1 commit from fix/alphabet-index-sort-order into master 2026-09-16 21:44:45 +02:00
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 - [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 right-edge A-Z rail to jump straight to one, drag-to-scrub like
iOS's own Contacts — see docs/architecture.md 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 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)

View file

@ -11,12 +11,25 @@ import type { ItemDto } from './types';
const ALL_BUCKETS = ['#', ...Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))]; 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 * An item's index bucket: the first letter of its `sortName` - NOT
* the index to group it, not off in its own "É"/"#" bucket) - anything * `name` - with accents folded to their base (so "Écho" lands on "E",
* else (a digit, empty) falls back to "#". */ * matching how a French speaker would expect the index to group it, not
function bucketFor(name: string): string { * off in its own "É"/"#" bucket). Anything else (a digit, empty) falls
const base = (name || '').trim().normalize('NFD').replace(/[̀-ͯ]/g, ''); * 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(); const c = base.charAt(0).toUpperCase();
return c >= 'A' && c <= 'Z' ? c : '#'; 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 * not one big innerHTML write - see that function's own comment for why
* that matters on a 1000+-item library), plus a `.letterHeading` inserted * that matters on a 1000+-item library), plus a `.letterHeading` inserted
* right before the first tile of each new bucket. `items` is assumed * right before the first tile of each new bucket. `items` is assumed
* already alphabetical (embyApi's getLibraryItems/getArtists both ask * already ordered by `sortName` (see bucketFor) - a chunk boundary
* Emby for SortBy: SortName) - a chunk boundary landing mid-bucket is * landing mid-bucket is fine, it's still one contiguous run either way.
* fine, it's still one contiguous run either way.
*/ */
export function renderGroupedTilesInChunks(container: HTMLElement, items: ItemDto[], chunkSize = 40): void { export function renderGroupedTilesInChunks(container: HTMLElement, items: ItemDto[], chunkSize = 40): void {
container.innerHTML = ''; container.innerHTML = '';
@ -39,7 +51,7 @@ export function renderGroupedTilesInChunks(container: HTMLElement, items: ItemDt
if (!chunk.length) return; if (!chunk.length) return;
let html = ''; let html = '';
for (const item of chunk) { for (const item of chunk) {
const bucket = bucketFor(item.name); const bucket = bucketFor(item);
if (bucket !== lastBucket) { if (bucket !== lastBucket) {
html += `<div class="letterHeading" data-letter-anchor="${bucket}">${bucket}</div>`; html += `<div class="letterHeading" data-letter-anchor="${bucket}">${bucket}</div>`;
lastBucket = bucket; lastBucket = bucket;
@ -100,7 +112,7 @@ export function wireSectionSearchSync(input: HTMLInputElement, grid: HTMLElement
* once before. * once before.
*/ */
export function wireAlphaRail(root: HTMLElement, grid: HTMLElement, items: ItemDto[]): CleanupFn { 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 libraryHeader = root.querySelector('.libraryHeader') as HTMLElement;
const bottomStack = document.querySelector('.bottomStack') as HTMLElement | null; const bottomStack = document.querySelector('.bottomStack') as HTMLElement | null;

View file

@ -9,6 +9,10 @@
export interface ItemDto { export interface ItemDto {
id: string; id: string;
name: 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; overview?: string;
year?: number; year?: number;
type?: string; 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 stopped that last reaction cold, confirming the observer was actually
disconnected rather than just idle. 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 ## Known limitations
- Playback always goes through `hls.js` against whatever Emby's - 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 // so a 300 cap would have silently dropped 75% of them, not just
// trimmed an edge case. Rendering that many is tiles.ts's // trimmed an edge case. Rendering that many is tiles.ts's
// renderTilesInChunks's job, not this query'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.'); 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)); return (data.Items || []).map((a) => this.toItemDto(a, session.serverId));
} }
@ -261,7 +261,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: 'ProductionYear,AlbumId', Fields: 'ProductionYear,AlbumId,SortName',
ImageTypeLimit: '1' 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.'); 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 { return {
id: item.Id, id: item.Id,
name: item.Name, name: item.Name,
sortName: item.SortName || item.Name,
overview: item.Overview, overview: item.Overview,
year: item.ProductionYear, year: item.ProductionYear,
type: item.Type, type: item.Type,

View file

@ -157,6 +157,12 @@ declare global {
export interface EmbyItem { export interface EmbyItem {
Id: string; Id: string;
Name: 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; Overview?: string;
ProductionYear?: number; ProductionYear?: number;
Type?: string; Type?: string;
@ -183,6 +189,16 @@ export interface EmbyItem {
export interface ItemDto { export interface ItemDto {
id: string; id: string;
name: 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; overview?: string;
year?: number; year?: number;
type?: string; type?: string;