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;