From 6b6a57a5f74e81cffbfb42bb6c3757a379743105 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 16 Sep 2026 21:36:37 +0200 Subject: [PATCH] Fix the alphabet index bucketing by display name instead of Emby SortName Emby orders (and this app already asked it to) by SortName, which strips leading articles - an item like The Bunker sorts among the Bs there, but the index was bucketing by the raw display name, throwing a spurious T heading into the middle of that run. getLibraryItems/ getArtists now also fetch SortName, ItemDto carries it through, and the index buckets by that instead - the same key Emby actually used to order the array, not a guess that usually matches it. Co-Authored-By: Claude Sonnet 5 --- README.md | 4 +++ client/src/alphaIndex.ts | 34 +++++++++++++++------- client/src/types.ts | 4 +++ docs/architecture.md | 41 +++++++++++++++++++++++++++ server/src/services/CatalogService.ts | 5 ++-- server/src/types.ts | 16 +++++++++++ 6 files changed, 91 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 30cdf93..30be34b 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/client/src/alphaIndex.ts b/client/src/alphaIndex.ts index 0c7174a..9406071 100644 --- a/client/src/alphaIndex.ts +++ b/client/src/alphaIndex.ts @@ -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 += `
${bucket}
`; 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; diff --git a/client/src/types.ts b/client/src/types.ts index 484fed6..878106e 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -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; diff --git a/docs/architecture.md b/docs/architecture.md index f7431d0..616f5ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/server/src/services/CatalogService.ts b/server/src/services/CatalogService.ts index 8cc8537..ff739a7 100644 --- a/server/src/services/CatalogService.ts +++ b/server/src/services/CatalogService.ts @@ -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(`/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(`/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, diff --git a/server/src/types.ts b/server/src/types.ts index a97337a..6cf234f 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -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;