feat(i18n): every server A→Z list in the reader's alphabet (E30 S3)

The Postgres image is Alpine, whose en_US.utf8 is byte order: "Életrajzi" and
"Ötödik" sorted after "Zorró" in the feed, the channel lists, the Plex grid,
its genres and collections. `app/collation.py` names the ICU collation of the
reader's language (hu-x-icu puts "Ö" after "O" and "Cs" after "Cu"), wraps
every text ORDER BY with it - the feed's keyset cursor compares with the same
expression, so paging walks the order it sorted in - and orders the lists built
in Python (genre and studio facets, collection members, the discovery re-sort)
through the same collation. Plex title tiebreaks use the shown (HU) title.
This commit is contained in:
2026-09-29 22:39:57 +02:00
parent 584d082b6b
commit 67c9f10153
15 changed files with 328 additions and 38 deletions
+13 -7
View File
@@ -15,6 +15,8 @@ from sqlalchemy import (
)
from sqlalchemy.orm import Session, aliased
from app.mailtext import lang_for_user
from app.collation import collated, sorted_words
from app import sysconfig
from app.auth import current_user
from app.db import get_db
@@ -55,14 +57,15 @@ from app.utils import to_tsquery_str
router = APIRouter(prefix="/api/plex", tags=["plex"])
def sort_column(u, sort: str):
def sort_column(u, sort: str, lang: str = "en"):
"""The browse grid's sort key -> orderable column on the union subquery. Module-level (not inline
in the handler) so a test can pin its vocabulary to `PLEX_SORT_KEYS` — a key the Sort control
offers but this cannot order would quietly fall back to `added_at` and look like a broken control.
An unknown key (a stale bookmark, a hand-edited URL) deliberately keeps that fallback."""
An unknown key (a stale bookmark, a hand-edited URL) deliberately keeps that fallback. The title sorts
in the reader's alphabet (`collation`)."""
return {
"added": u.c.added_at,
"title": func.lower(u.c.title),
"title": collated(u.c.title, lang),
"year": u.c.year,
"rating": u.c.rating,
"duration": u.c.duration_s,
@@ -79,7 +82,7 @@ def list_libraries(user: User = Depends(current_user), db: Session = Depends(get
db.execute(
select(PlexLibrary)
.where(PlexLibrary.overlay_of.is_(None))
.order_by(PlexLibrary.kind.desc(), PlexLibrary.title)
.order_by(PlexLibrary.kind.desc(), collated(PlexLibrary.title, lang_for_user(user)))
)
.scalars()
.all()
@@ -238,7 +241,7 @@ def unified_library(
if tsq is not None:
order = [u.c.rank.desc()]
else:
col = sort_column(u, sort)
col = sort_column(u, sort, lang)
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
order.append(u.c.rk.desc())
rows = db.execute(select(u).order_by(*order).offset(offset).limit(limit)).all()
@@ -530,16 +533,19 @@ def facets(
for c in split_csv(f.playable):
if c in PLAYBACK_CLASSES:
playable_counts.setdefault(c, 0)
# Names in the reader's alphabet: the genres are Hungarian words for a Hungarian reader (E30 S1).
alpha = {w: i for i, w in enumerate(sorted_words(db, [*genre_counts, *studio_counts], lang))}
return {
"genres": [
{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0])
{"value": g, "count": c}
for g, c in sorted(genre_counts.items(), key=lambda kv: alpha[kv[0]])
],
"content_ratings": [
{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])
],
"studios": [
{"value": s, "count": c}
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0]))
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], alpha[kv[0]]))
],
# Fixed good→heavy order (not by count), so the class list reads as a severity scale.
"playable": [
+19 -8
View File
@@ -9,6 +9,8 @@ from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.mailtext import lang_for_user
from app.collation import collated, sorted_words
from app import logs
from app.auth import admin_user, current_user
from app.db import get_db
@@ -25,7 +27,7 @@ from app.plex import sync as plex_sync
from app.plex.cards import display_title, movie_card, norm_lang, show_card
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key
from app.plex.query import content_libs, runtime_by_key, top_art_keys
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
from app.utils import like_escape
from app.refusal import refused
from app.routes._common import not_found
@@ -142,7 +144,7 @@ def collections(
db.execute(
query.order_by(
PlexCollection.child_count.desc().nullslast(),
func.lower(PlexCollection.title),
collated(PlexCollection.title, lang_for_user(user)),
)
)
.scalars()
@@ -208,16 +210,24 @@ MEMBER_SORT_KEYS: dict[str, Callable[[Member], Any]] = {
DEFAULT_MEMBER_SORT = "year"
def sort_members(members: list[Member], sort: str, sort_dir: str) -> list[Member]:
def sort_members(
members: list[Member], sort: str, sort_dir: str, alpha: dict[str, int] | None = None
) -> list[Member]:
"""Order a collection's members by one of `PLEX_SORT_KEYS`.
Two properties worth stating because they are easy to get wrong with a plain `reverse=True`:
members with no value for the key sink to the END in BOTH directions (matching the browse grid's
`nullslast()` — a movie with no rating is not "the worst rated"), and ties always resolve
title-ascending, so flipping the direction reverses the ranking rather than the alphabet inside
it. An unknown key falls back to the page's default instead of returning an arbitrary order."""
it. An unknown key falls back to the page's default instead of returning an arbitrary order.
`alpha` ranks each title in the reader's alphabet (`collation.sorted_words`); without it, titles
compare as Python strings, which puts every accented initial after "z"."""
by_title = (lambda m: alpha[m.title]) if alpha is not None else (lambda m: m.title)
key = MEMBER_SORT_KEYS.get(sort, MEMBER_SORT_KEYS[DEFAULT_MEMBER_SORT])
ordered = sorted(members, key=lambda m: m.title) # stable secondary, applied first
if key is MEMBER_SORT_KEYS["title"]:
key = by_title
ordered = sorted(members, key=by_title) # stable secondary, applied first
have = [m for m in ordered if key(m) is not None]
missing = [m for m in ordered if key(m) is None]
have.sort(key=key, reverse=sort_dir != "asc")
@@ -246,7 +256,7 @@ def collection_detail(
db.execute(
select(PlexItem)
.where(PlexItem.kind == "movie", PlexItem.collection_keys.contains([rating_key]))
.order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title))
.order_by(PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang))
)
.scalars()
.all()
@@ -255,7 +265,7 @@ def collection_detail(
db.execute(
select(PlexShow)
.where(PlexShow.collection_keys.contains([rating_key]))
.order_by(PlexShow.year.asc().nullslast(), func.lower(PlexShow.title))
.order_by(PlexShow.year.asc().nullslast(), collated(title_col(PlexShow, lang), lang))
)
.scalars()
.all()
@@ -310,7 +320,8 @@ def collection_detail(
)
for sh in shows
]
members = sort_members(members, sort, sort_dir)
alpha = {w: i for i, w in enumerate(sorted_words(db, [m.title for m in members], lang))}
members = sort_members(members, sort, sort_dir, alpha)
items = [
movie_card(m.obj, mstate.get(m.obj.id), lang=lang)
if m.kind == "movie"
+8 -3
View File
@@ -7,9 +7,10 @@ from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.collation import collated, sorted_words
from app import logs
from app.auth import current_user
from app.db import get_db
@@ -25,6 +26,7 @@ from app.models import (
from app.plex import detail as plex_detail
from app.plex import people as plex_people
from app.plex import rollup, subtitles
from app.plex.query import title_col
from app.plex import tmdb as plex_tmdb
from app.plex.cards import episode_card, movie_card, show_card
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
@@ -277,7 +279,8 @@ def _collection_strips(db: Session, it: PlexItem, user_id: int, lang: str) -> li
).scalars()
}
# Smallest (most specific — e.g. a franchise) first; the big auto-lists (IMDb/TMDb Popular) last.
ordered = sorted((c for c in cols.values()), key=lambda c: (c.child_count or 0, c.title))
alpha = {w: i for i, w in enumerate(sorted_words(db, [c.title for c in cols.values()], lang))}
ordered = sorted(cols.values(), key=lambda c: (c.child_count or 0, alpha[c.title]))
out = []
for col in ordered:
rk = col.rating_key
@@ -289,7 +292,9 @@ def _collection_strips(db: Session, it: PlexItem, user_id: int, lang: str) -> li
PlexItem.kind == "movie",
PlexItem.collection_keys.contains([rk]),
)
.order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title))
.order_by(
PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang)
)
.limit(60)
)
.scalars()
+3 -1
View File
@@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
from app.collation import sorted_words
from app.auth import current_user
from app.db import get_db
from app.models import PlexItem, PlexShow, PlexState, User
@@ -264,7 +265,8 @@ def _genre_tiles(
expanded = base.subquery()
for g, c in db.execute(select(expanded.c.g, func.count()).group_by(expanded.c.g)).all():
counts[g] = counts.get(g, 0) + c
top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:GENRE_TILES]
alpha = {w: i for i, w in enumerate(sorted_words(db, list(counts), lang))}
top = sorted(counts.items(), key=lambda kv: (-kv[1], alpha[kv[0]]))[:GENRE_TILES]
# A 2×2 poster collage per tile: the top movies in that genre (color hash is the client fallback).
art = top_art_keys(
db,
+8 -3
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.collation import collated
from app import logs
from app.auth import current_user
from app.db import SessionLocal, get_db
@@ -19,7 +20,7 @@ from app.models import (
from app.plex import rollup
from app.plex import tmdb as plex_tmdb
from app.plex.cards import movie_card, norm_lang, show_card
from app.plex.query import content_libs, split_csv
from app.plex.query import content_libs, split_csv, title_col
router = APIRouter(prefix="/api/plex", tags=["plex"])
log = logs.get_logger(__name__)
@@ -202,7 +203,9 @@ def person_detail(
PlexItem.directors.contains([name]),
),
)
.order_by(PlexItem.year.desc().nullslast(), func.lower(PlexItem.title))
.order_by(
PlexItem.year.desc().nullslast(), collated(title_col(PlexItem, lang), lang)
)
.limit(_PERSON_SECTION_MAX)
)
.scalars()
@@ -223,7 +226,9 @@ def person_detail(
PlexShow.directors.contains([name]),
),
)
.order_by(PlexShow.year.desc().nullslast(), func.lower(PlexShow.title))
.order_by(
PlexShow.year.desc().nullslast(), collated(title_col(PlexShow, lang), lang)
)
.limit(_PERSON_SECTION_MAX)
)
.scalars()