feat(i18n): the server's closed English vocabularies named in the reader's language (E30 S2)
- lib/vocab + the `vocab` locale area: TMDB crew jobs and departments, auto-tags (D1 A: the data
stays English, the catalog is named at its one source, useTags), built-in download profiles,
YouTube topics; held key-for-key to the backend's own lists by backendIds.contract.
- Countries by ISO code: TMDB parse stores {code, name}; rows stored before are coded from TMDB's
own country list in one request (run_enrich); Intl names them, historic codes (YU, SU, XI…)
from the catalog.
- Player tracks named from the language tag, forced/SDH flags and codec tail, not Plex's
server-language displayTitle; no invented "Audio N" on the server.
- Download lifecycle notices (shared / expiring / expired / evicted) named from data; the share
notice carries the title.
- Scheduler: the job result rides along as data and is named per key (dashboard + run notice).
- Invite status named; a render crash's notice no longer shows the engine's English message.
- Fix: YouTube links …/wiki/Humour, the tagger looked for "Humor" — Comedy never came from a topic.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
"""A YouTube topic URL's slug → the auto-tag it gives (pure). The slugs are the ones YouTube actually
|
||||
links (measured on the owner's channels, 2026-09-29): a key spelled differently from YouTube's page
|
||||
never matches, silently — "Humor" sat in the map for months while YouTube links …/wiki/Humour."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.sync.autotag import map_topic_slug
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("slug", "tag"),
|
||||
[
|
||||
("Humour", "Comedy"),
|
||||
("Video_game_culture", "Gaming"),
|
||||
("Electronic_music", "Music"),
|
||||
("Association_football", "Sports"),
|
||||
("Knowledge", "Education"),
|
||||
("Physical_fitness", "Health & Fitness"),
|
||||
("Television_program", "TV"),
|
||||
("Lifestyle_(sociology)", None), # YouTube's catch-all: deliberately no tag
|
||||
],
|
||||
)
|
||||
def test_a_youtube_topic_slug_gives_its_tag(slug, tag):
|
||||
assert map_topic_slug(slug) == tag
|
||||
@@ -0,0 +1,41 @@
|
||||
"""E30: the "shared with you" notice carries the download's title in its data, so the client can name
|
||||
the notice in the reader's language. Its stored title is an English sentence ("Cats was shared with
|
||||
you") — the only place the title lived, so a Hungarian reader got the English. Needs the DB."""
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import DownloadJob, MediaAsset, Notification
|
||||
|
||||
|
||||
def test_a_share_notice_carries_the_title_and_the_sharer(db, make_user, as_user):
|
||||
owner = make_user("owner@example.com")
|
||||
friend = make_user("friend@example.com")
|
||||
asset = MediaAsset(
|
||||
source_kind="url", source_ref="share-e30", format_sig="sig", status="ready", ref_count=1
|
||||
)
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
job = DownloadJob(
|
||||
user_id=owner.id,
|
||||
asset_id=asset.id,
|
||||
source_kind="url",
|
||||
source_ref="share-e30",
|
||||
status="done",
|
||||
queue_pos=1,
|
||||
profile_snapshot={},
|
||||
display_name="Cats",
|
||||
)
|
||||
db.add(job)
|
||||
db.commit()
|
||||
|
||||
r = as_user(owner).post(f"/api/downloads/{job.id}/share", json={"email": "friend@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
notice = db.execute(select(Notification).where(Notification.user_id == friend.id)).scalar_one()
|
||||
assert notice.type == "download_shared"
|
||||
assert notice.data == {
|
||||
"kind": "download_shared",
|
||||
"from": "owner@example.com",
|
||||
"job_id": job.id,
|
||||
"title": "Cats",
|
||||
}
|
||||
@@ -352,3 +352,52 @@ def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch, override_dep):
|
||||
assert show.imdb_id == "tt0068646" # persisted for next time
|
||||
assert show.people_enriched_at is not None
|
||||
assert [c["name"] for c in show.full_cast] == ["Marlon Brando", "Al Pacino"]
|
||||
|
||||
|
||||
def test_item_detail_names_tracks_from_their_language_not_plexs_title(
|
||||
db, monkeypatch, override_dep
|
||||
):
|
||||
"""E30: Plex titles a track in the Plex SERVER's language ("English (AC3 5.1)", "magyar Forced",
|
||||
"中文 (EAC3 Stereo)"), so a Hungarian reader got a mix. The route sends what the client names it
|
||||
from instead — the language tag, the forced / SDH flags, the codec tail — keeping Plex's title
|
||||
only as the fallback, and no invented English "Audio 2" for a track with neither."""
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="T"))
|
||||
db.commit()
|
||||
user = _user(db, "tracks@example.com")
|
||||
db.commit()
|
||||
streams = [
|
||||
{"streamType": 2, "displayTitle": "English (AC3 5.1)", "languageTag": "en-US"},
|
||||
{"streamType": 2, "displayTitle": "中文 (EAC3 Stereo)", "languageTag": "zh"},
|
||||
{"streamType": 2},
|
||||
{
|
||||
"streamType": 3,
|
||||
"displayTitle": "magyar Forced",
|
||||
"languageTag": "hu",
|
||||
"forced": True,
|
||||
"codec": "srt",
|
||||
},
|
||||
{
|
||||
"streamType": 3,
|
||||
"displayTitle": "English SDH",
|
||||
"languageTag": "en",
|
||||
"hearingImpaired": True,
|
||||
"codec": "srt",
|
||||
},
|
||||
]
|
||||
meta = {**RICH_META, "Media": [{"Part": [{"Stream": streams}]}]}
|
||||
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(meta))
|
||||
body = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1").json()
|
||||
|
||||
naming = ("label", "lang_tag", "forced", "hearing_impaired", "detail")
|
||||
assert [tuple(a[k] for k in naming) for a in body["audio_streams"]] == [
|
||||
("English (AC3 5.1)", "en-US", False, False, "AC3 5.1"),
|
||||
("中文 (EAC3 Stereo)", "zh", False, False, "EAC3 Stereo"),
|
||||
(None, None, False, False, None),
|
||||
]
|
||||
assert [tuple(s[k] for k in naming) for s in body["subtitle_streams"]] == [
|
||||
("magyar Forced", "hu", True, False, None),
|
||||
("English SDH", "en", False, True, None),
|
||||
]
|
||||
|
||||
@@ -49,7 +49,7 @@ MOVIE_PAYLOAD = {
|
||||
],
|
||||
},
|
||||
"production_companies": [{"name": "Paramount"}, {"name": "Alfran"}],
|
||||
"production_countries": [{"name": "United States of America"}],
|
||||
"production_countries": [{"iso_3166_1": "US", "name": "United States of America"}],
|
||||
"recommendations": {
|
||||
"results": [
|
||||
{
|
||||
@@ -126,7 +126,8 @@ def test_parse_movie_pulls_all_fields():
|
||||
},
|
||||
] # the Gaffer is filtered; the two Coppola jobs survive (dedup is by name+job)
|
||||
assert d["studios"] == ["Paramount", "Alfran"]
|
||||
assert d["production_countries"] == ["United States of America"]
|
||||
# E30: the code beside the English name, so the client can name the country in the reader's language.
|
||||
assert d["production_countries"] == [{"code": "US", "name": "United States of America"}]
|
||||
assert [r["tmdb_id"] for r in d["recommendations"]] == [240, 999]
|
||||
assert d["backdrops"] == ["/bd1.jpg", "/bd2.jpg"]
|
||||
# cast + crew names → tmdb person ids for the plex_people foundation
|
||||
@@ -429,3 +430,69 @@ def test_resolve_recs_matches_by_media_type_not_bare_id(db):
|
||||
}
|
||||
assert hit[900]["in_library"] is True and hit[900]["rating_key"] == "m900"
|
||||
assert hit[500]["in_library"] is True and hit[500]["rating_key"] == "s500"
|
||||
|
||||
|
||||
# ---- E30: countries by ISO code -----------------------------------------------------------------
|
||||
|
||||
|
||||
class FakeCountries(FakeTmdb):
|
||||
"""TMDB's /configuration/countries, counting the requests."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.calls = 0
|
||||
|
||||
def countries(self):
|
||||
self.calls += 1
|
||||
return [
|
||||
{"iso_3166_1": "US", "english_name": "United States of America"},
|
||||
{"iso_3166_1": "YU", "english_name": "Yugoslavia"},
|
||||
]
|
||||
|
||||
|
||||
def test_countries_stored_before_codes_get_them_from_tmdbs_list(db):
|
||||
lib = _lib(db)
|
||||
old = PlexItem(
|
||||
rating_key="m1",
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="Old",
|
||||
production_countries=["Yugoslavia", "Atlantis"],
|
||||
tmdb_enriched_at=datetime.now(timezone.utc),
|
||||
)
|
||||
show_lib = _lib(db, key="2", kind="show")
|
||||
show = PlexShow(
|
||||
rating_key="s1",
|
||||
library_id=show_lib.id,
|
||||
title="Show",
|
||||
production_countries=["United States of America"],
|
||||
)
|
||||
new = PlexItem(
|
||||
rating_key="m2",
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="New",
|
||||
production_countries=[{"code": "US", "name": "United States of America"}],
|
||||
)
|
||||
db.add_all([old, show, new])
|
||||
db.commit()
|
||||
|
||||
# Served before the backfill: the bare name, with no code — never a crash on the old shape.
|
||||
assert tmdb.serve_block(db, old, lang="hu")["production_countries"] == [
|
||||
{"code": None, "name": "Yugoslavia"},
|
||||
{"code": None, "name": "Atlantis"},
|
||||
]
|
||||
|
||||
fake = FakeCountries()
|
||||
assert tmdb.backfill_country_codes(db, fake) == 2
|
||||
db.refresh(old)
|
||||
db.refresh(show)
|
||||
assert old.production_countries == [
|
||||
{"code": "YU", "name": "Yugoslavia"},
|
||||
{"code": None, "name": "Atlantis"}, # not in TMDB's list → no code, the name kept
|
||||
]
|
||||
assert show.production_countries == [{"code": "US", "name": "United States of America"}]
|
||||
|
||||
# Nothing left to code: no request at all.
|
||||
assert tmdb.backfill_country_codes(db, fake) == 0
|
||||
assert fake.calls == 1
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
through (at the top of _job): holding _activity_lock across the check-and-set means exactly one
|
||||
caller can hold a job's "running" claim at a time, and it's claimable again once released."""
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import scheduler
|
||||
from app.models import Notification
|
||||
|
||||
_JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs
|
||||
|
||||
@@ -23,3 +26,44 @@ def test_claim_is_exclusive_until_released():
|
||||
# ...and now it can be claimed again.
|
||||
assert scheduler._claim_running(_JOB) is True
|
||||
_release()
|
||||
|
||||
|
||||
# ---- E30: the result as data -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _snapshot_job(job_id):
|
||||
return next(j for j in scheduler.scheduler_snapshot()["jobs"] if j["id"] == job_id)
|
||||
|
||||
|
||||
def test_a_run_keeps_its_result_as_data_for_the_readers_language(db, make_user):
|
||||
"""The dashboard and the manual-run notice showed `_summarize`'s English one-liner ("new=3,
|
||||
failed=0", a Python repr for a nested phase). The result rides along as data, so the client names
|
||||
it; the one-liner stays as the fallback and the audit text."""
|
||||
admin = make_user("sched@example.com", role="admin")
|
||||
token = scheduler._manual_actor.set(admin.id)
|
||||
try:
|
||||
scheduler._job("rss_poll", lambda _db: {"new": 3, "failed": 0, "fallback": 1})
|
||||
scheduler._job("enrich", lambda _db: 5) # enrich returns a bare count
|
||||
finally:
|
||||
scheduler._manual_actor.reset(token)
|
||||
|
||||
rss = _snapshot_job("rss_poll")
|
||||
assert rss["result"] == {"new": 3, "failed": 0, "fallback": 1}
|
||||
assert rss["last_result"] == "new=3, failed=0, fallback=1"
|
||||
assert _snapshot_job("enrich")["result"] == {"enriched": 5}
|
||||
|
||||
notices = db.execute(
|
||||
select(Notification).where(Notification.user_id == admin.id).order_by(Notification.id)
|
||||
).scalars()
|
||||
assert [n.data["result"] for n in notices] == [
|
||||
{"new": 3, "failed": 0, "fallback": 1},
|
||||
{"enriched": 5},
|
||||
]
|
||||
|
||||
|
||||
def test_a_failed_run_drops_the_previous_result(db):
|
||||
scheduler._job("rss_poll", lambda _db: {"new": 1})
|
||||
scheduler._job("rss_poll", lambda _db: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
rss = _snapshot_job("rss_poll")
|
||||
assert rss["last_error"] == "boom"
|
||||
assert rss["result"] is None
|
||||
|
||||
Reference in New Issue
Block a user