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 <noreply@anthropic.com>
This commit is contained in:
parent
b34e1ce8ff
commit
6b6a57a5f7
6 changed files with 91 additions and 13 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue