Fix the alphabet index bucketing by display name instead of Emby SortName #16
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
|
- [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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue