diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index f632172e..a6dc845a 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -27,6 +27,7 @@ import { useChannelTagActions } from "../lib/useChannelTagActions"; import { useTagSuggestions } from "./useTagSuggestions"; import { useChannelPriority } from "../lib/useChannelPriority"; import { placeByPriority } from "../lib/channelOrder"; +import { serverOrderLang } from "../lib/saveLanguage"; import { focusNavItem } from "../lib/listNav"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import ColumnSortControl from "./ui/ColumnSortControl"; @@ -173,7 +174,7 @@ export default function Channels() { setPriority(c.id, next); qc.setQueryData( qk.channels(), - (old) => old && placeByPriority(old, c.id, lang), + (old) => old && placeByPriority(old, c.id, serverOrderLang(me.preferences)), ); setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 })); }; diff --git a/frontend/src/components/PlexCollectionView.dom.test.tsx b/frontend/src/components/PlexCollectionView.dom.test.tsx index 965c9f77..7bcd337d 100644 --- a/frontend/src/components/PlexCollectionView.dom.test.tsx +++ b/frontend/src/components/PlexCollectionView.dom.test.tsx @@ -248,6 +248,6 @@ describe("PlexCollectionView — the summary (E30 review)", () => { , ); await screen.findByText("One short line."); - expect(screen.queryByText("plex.collection.more")).toBeNull(); // jsdom: nothing is cut + expect(screen.queryByText("plex.textMore")).toBeNull(); // jsdom: nothing is cut }); }); diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index b903f883..a3147178 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -336,8 +336,8 @@ export default function PlexCollectionView({ )} diff --git a/frontend/src/components/PlexPersonView.dom.test.tsx b/frontend/src/components/PlexPersonView.dom.test.tsx index 483d589f..66be5c9c 100644 --- a/frontend/src/components/PlexPersonView.dom.test.tsx +++ b/frontend/src/components/PlexPersonView.dom.test.tsx @@ -7,7 +7,7 @@ // and the api is mocked so the query resolves synchronously in the test. import { setLanguage } from "../i18n"; import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; -import { act, render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { act, render, screen, fireEvent, cleanup, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; beforeAll(() => { @@ -251,7 +251,7 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => { } }); - it("stops asking when the page's own request fails", async () => { + it("stops asking when the page's own request fails, and says so with a retry", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); try { plexPerson.mockReset(); @@ -265,6 +265,12 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => { await act(() => vi.advanceTimersByTimeAsync(10_000)); expect(plexPerson).toHaveBeenCalledTimes(2); // not every 1.5 s for as long as it fails expect(screen.queryByRole("status")).toBeNull(); + // Not a blank that reads as "no biography": the failure is said, and a retry asks again. + const alert = screen.getByRole("alert"); + expect(alert.textContent).toContain("plex.person.bioFailed"); + plexPerson.mockResolvedValue(person({ biography: "Landed bio." })); + fireEvent.click(within(alert).getByRole("button")); + await screen.findByText("Landed bio."); } finally { vi.useRealTimers(); } @@ -277,8 +283,8 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => { plexPerson.mockReset(); plexPerson.mockResolvedValue(person({ biography: "A long life.\n\nMany films." })); wrap(view); - fireEvent.click(await screen.findByText("plex.person.more")); - expect(screen.getByText("plex.person.less").getAttribute("aria-expanded")).toBe("true"); + fireEvent.click(await screen.findByText("plex.textMore")); + expect(screen.getByText("plex.textLess").getAttribute("aria-expanded")).toBe("true"); } finally { tall.mockRestore(); box.mockRestore(); @@ -287,7 +293,7 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => { plexPerson.mockResolvedValue(person({ biography: "Short." })); wrap(view); await screen.findByText("Short."); - expect(screen.queryByText("plex.person.more")).toBeNull(); // jsdom: nothing is cut + expect(screen.queryByText("plex.textMore")).toBeNull(); // jsdom: nothing is cut }); }); diff --git a/frontend/src/components/auditColumns.dom.test.tsx b/frontend/src/components/auditColumns.dom.test.tsx index cc299a5c..ebea3d16 100644 --- a/frontend/src/components/auditColumns.dom.test.tsx +++ b/frontend/src/components/auditColumns.dom.test.tsx @@ -99,9 +99,7 @@ describe("an audit row's details", () => { target_id: "rss_poll", after: { status: "skipped", summary: "sync paused" }, }); - expect(details(skipped)).toBe( - "RSS-lekérdezés (új feltöltések) kihagyva | kihagyva: a szinkron szünetel", - ); + expect(details(skipped)).toBe("RSS-lekérdezés (új feltöltések) kihagyva | a szinkron szünetel"); }); it("tell a secret's reset from its update, and name a choice", () => { diff --git a/frontend/src/components/plexViews.restart.dom.test.tsx b/frontend/src/components/plexViews.restart.dom.test.tsx index fd5f46f2..14080e3f 100644 --- a/frontend/src/components/plexViews.restart.dom.test.tsx +++ b/frontend/src/components/plexViews.restart.dom.test.tsx @@ -132,3 +132,28 @@ describe("the restart button on a show and a season", () => { expect(onPlay).toHaveBeenCalledWith("e11", ALL, true); }); }); + +describe("the show page's summary (E30 review)", () => { + it("is clamped with a More, so a long one can still be read in full", async () => { + const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400); + const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60); + Object.assign(detail.show, { summary: "A long summary.\n\nThat goes on." }); + try { + wrap( + {}} + onPlay={() => {}} + onOpenSeason={() => {}} + onOpenShow={() => {}} + />, + ); + fireEvent.click(await screen.findByText("plex.textMore")); + expect(screen.getByText("plex.textLess").getAttribute("aria-expanded")).toBe("true"); + } finally { + tall.mockRestore(); + box.mockRestore(); + Object.assign(detail.show, { summary: undefined }); + } + }); +}); diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index e31979fd..aff680e3 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -325,17 +325,20 @@ export function PlexPersonView({ + ) : bioOnItsWay ? ( + ) : ( - bioOnItsWay && ( - + // An ask that failed while the bio was on its way is said, not left looking like a + // person with no bio; the retry asks (and waits) again. + d.enriching && + q.isError && ( + q.refetch()}> + {t("plex.person.bioFailed")} + ) )} @@ -614,9 +617,13 @@ export function PlexShowView({ )} {show.summary && ( -

- {show.summary} -

+ )}
{show.resume && ( diff --git a/frontend/src/components/ui/ClampedText.dom.test.tsx b/frontend/src/components/ui/ClampedText.dom.test.tsx index 16a21b11..711fc1c2 100644 --- a/frontend/src/components/ui/ClampedText.dom.test.tsx +++ b/frontend/src/components/ui/ClampedText.dom.test.tsx @@ -3,7 +3,7 @@ // A clamped text offers More only when it is actually cut (E30 S6 on the person page, shared with the // collection page in the E30 review, where a one-line summary carried a More that did nothing). import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { ClampedText } from "./ClampedText"; beforeAll(() => { @@ -44,4 +44,33 @@ describe("ClampedText", () => { box.mockRestore(); } }); + + it("measures again when its box changes: the window or the text size moved the cut", () => { + const seen: ResizeObserverCallback[] = []; // the callbacks of observers watching something + const real = globalThis.ResizeObserver; + globalThis.ResizeObserver = class { + constructor(private cb: ResizeObserverCallback) {} + observe() { + seen.push(this.cb); + } + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + let height = 60; + const tall = vi + .spyOn(HTMLElement.prototype, "scrollHeight", "get") + .mockImplementation(() => height); + const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60); + try { + render(view("A text that fits, until the reader raises the text size.")); + expect(screen.queryByText("More")).toBeNull(); + height = 400; // the text size went up: the same text now runs past three lines + act(() => seen[seen.length - 1]!([], {} as ResizeObserver)); + expect(screen.getByText("More")).toBeTruthy(); + } finally { + tall.mockRestore(); + box.mockRestore(); + globalThis.ResizeObserver = real; + } + }); }); diff --git a/frontend/src/components/ui/ClampedText.tsx b/frontend/src/components/ui/ClampedText.tsx index 0160295f..9422a0e8 100644 --- a/frontend/src/components/ui/ClampedText.tsx +++ b/frontend/src/components/ui/ClampedText.tsx @@ -2,7 +2,7 @@ import { useLayoutEffect, useRef, useState } from "react"; import { Button } from "./Button"; // Static class names, so Tailwind sees them. -const CLAMP = { 3: "line-clamp-3", 8: "line-clamp-8" } as const; +const CLAMP = { 3: "line-clamp-3", 5: "line-clamp-5", 8: "line-clamp-8" } as const; /** A long text (a biography, a summary) clamped to a few lines, with a More / Less toggle offered only * when the text is actually cut — measured, so a short text carries no dead button. Re-measured on a diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 594c0578..7b0781a8 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -1,4 +1,6 @@ { + "textMore": "More", + "textLess": "Less", "navLabel": "Plex", "backToLibrary": "Back to library", "collection": { @@ -13,8 +15,6 @@ "nameMaybeTaken": "A collection with this name may already exist in another library — you can still create it here.", "readOnly": "Read-only", "empty": "This collection has no items.", - "more": "More", - "less": "Less", "addSearch": "Search titles to add…", "addHint": "Type to search for a title to add.", "addNoResults": "No matches.", @@ -353,8 +353,7 @@ "aka": "Also known as", "knownFor": "Known for", "bioLoading": "Loading the biography", - "more": "More", - "less": "Less" + "bioFailed": "Couldn't load the biography right now" }, "view": { "label": "View", diff --git a/frontend/src/i18n/locales/hu/audit.json b/frontend/src/i18n/locales/hu/audit.json index ad682198..0478b75b 100644 --- a/frontend/src/i18n/locales/hu/audit.json +++ b/frontend/src/i18n/locales/hu/audit.json @@ -4,10 +4,10 @@ "empty": "Még nincs naplóbejegyzés.", "system": "Rendszer", "clear": "Napló ürítése", - "clearTitle": "Üríted a naplót?", + "clearTitle": "Üríted az eseménynaplót?", "clearConfirm": "Véglegesen törlöd az összes naplóbejegyzést? Ez nem vonható vissza. (Egy bejegyzés arról, hogy te ürítetted, megmarad.)", "cleared": "{{count}} naplóbejegyzés törölve", - "clearFailed": "Nem sikerült üríteni a naplót", + "clearFailed": "Nem sikerült üríteni az eseménynaplót", "truncated": "A legutóbbi {{returned}} bejegyzés látszik a(z) {{total}}-ból.", "on": "Be", "off": "Ki", diff --git a/frontend/src/i18n/locales/hu/auditActions.json b/frontend/src/i18n/locales/hu/auditActions.json index 735272aa..3f27b63d 100644 --- a/frontend/src/i18n/locales/hu/auditActions.json +++ b/frontend/src/i18n/locales/hu/auditActions.json @@ -20,7 +20,7 @@ "sync_pause": "Szinkron szüneteltetve", "sync_resume": "Szinkron folytatva", "job_run": "Ütemezett feladat lefutott", - "audit_clear": "Napló ürítve", + "audit_clear": "Eseménynapló ürítve", "download_credential_set": "Letöltési cookie-k beállítva", "download_credential_delete": "Letöltési cookie-k törölve", "invite_delete": "Fehérlista-bejegyzés törölve", diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json index f76ee0b0..75cff01b 100644 --- a/frontend/src/i18n/locales/hu/config.json +++ b/frontend/src/i18n/locales/hu/config.json @@ -45,7 +45,7 @@ "hint": "Naponta elkölthető YouTube Data API-egységek (az ingyenes keret 10 000). Az alap 9000 tartalékot hagy." }, "backfill_quota_reserve": { - "label": "Letöltési kvótatartalék", + "label": "Behúzási kvótatartalék", "hint": "Tartalékban tartott egységek, hogy az ütemezett behúzás ne éheztesse ki az interaktív szinkront / dúsítást." }, "search_daily_limit_per_user": { @@ -194,11 +194,11 @@ }, "plex_max_transcodes": { "label": "Max. párhuzamos átkódolás", - "hint": "A nem-H.264 fájlok egyidejű átkódolási munkameneteinek felső korlátja. A remux (stream-copy) munkamenetek csak az összesített munkamenet-korlátba számítanak bele; a direktben játszható fájlokra nincs korlát." + "hint": "A nem-H.264 fájlok egyidejű átkódolási munkameneteinek felső korlátja. A közvetlen közvetítés munkamenetei csak az összesített munkamenet-korlátba számítanak bele; a közvetlenül játszható fájlokra nincs korlát." }, "plex_max_sessions": { "label": "Max párhuzamos lejátszási munkamenet", - "hint": "Az összes egyidejű lejátszási munkamenet felső korlátja, a remuxot (stream-copy) is beleértve — mindegyik a film teljes, szegmentált másolatát írja a munkakötetre. Egy fiók legfeljebb kettőt tarthat. A direktben játszható fájlok nem munkamenetek, rájuk nincs korlát." + "hint": "Az összes egyidejű lejátszási munkamenet felső korlátja, a közvetlen közvetítést is beleértve — mindegyik a film teljes, szegmentált másolatát írja a munkakötetre. Egy fiók legfeljebb kettőt tarthat. A közvetlenül játszható fájlok nem munkamenetek, rájuk nincs korlát." }, "plex_transcode_hw": { "label": "Hardveres átkódolás", @@ -218,7 +218,7 @@ }, "plex_audio_loudnorm": { "label": "Hangerő normalizálása", - "hint": "A sztereó lekeverés EBU R128 szerinti hangerő-normalizálása — azokhoz a filmekhez, ahol a párbeszéd suttogás, az akció meg fülsiketítő. Csak remuxos vagy átkódolt lejátszásnál él (a direktben játszott fájl érintetlen marad). Egy 5.1-es filmen mérve: −27,6 → −16,6 LUFS, szűkebb halk/hangos különbséggel — minden kb. 11 dB-lel hangosabb lesz, szóval bekapcsolás után vedd lejjebb a hangerőt. A következő lejátszástól érvényes." + "hint": "A sztereó lekeverés EBU R128 szerinti hangerő-normalizálása — azokhoz a filmekhez, ahol a párbeszéd suttogás, az akció meg fülsiketítő. Csak közvetlen közvetítésnél vagy átkódolásnál él (a közvetlenül játszott fájl érintetlen marad). Egy 5.1-es filmen mérve: −27,6 → −16,6 LUFS, szűkebb halk/hangos különbséggel — minden kb. 11 dB-lel hangosabb lesz, szóval bekapcsolás után vedd lejjebb a hangerőt. A következő lejátszástól érvényes." }, "tmdb_api_key": { "label": "TMDB API-kulcs", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 625f6cef..844c82d3 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -1,4 +1,6 @@ { + "textMore": "Több", + "textLess": "Kevesebb", "navLabel": "Plex", "backToLibrary": "Vissza a könyvtárhoz", "collection": { @@ -13,8 +15,6 @@ "nameMaybeTaken": "Lehet, hogy már létezik ilyen nevű kollekció egy másik könyvtárban — itt akkor is létrehozhatod.", "readOnly": "Csak olvasható", "empty": "Ennek a kollekciónak nincsenek elemei.", - "more": "Több", - "less": "Kevesebb", "addSearch": "Keress címet a hozzáadáshoz…", "addHint": "Gépelj a hozzáadni kívánt cím kereséséhez.", "addNoResults": "Nincs találat.", @@ -353,8 +353,7 @@ "aka": "Más néven", "knownFor": "Ismert szerepei", "bioLoading": "Az életrajz betöltése", - "more": "Több", - "less": "Kevesebb" + "bioFailed": "Az életrajzot most nem sikerült betölteni" }, "view": { "label": "Nézet", diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index ae127832..af07a43c 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -123,7 +123,7 @@ "channels_processed": "feldolgozott csatorna", "videos_added": "hozzáadott videó", "reconciled_full": "teljesen egyeztetve", - "reason": "ok", + "reason": "indok", "channels_tagged": "címkézett csatorna", "orphan_tags_removed": "törölt árva címke", "probed": "ellenőrizve", diff --git a/frontend/src/lib/jobResult.dom.test.ts b/frontend/src/lib/jobResult.dom.test.ts index e3badee7..4a328d4c 100644 --- a/frontend/src/lib/jobResult.dom.test.ts +++ b/frontend/src/lib/jobResult.dom.test.ts @@ -23,7 +23,7 @@ describe("a job's result", () => { "hu", ), ).toBe( - "friss (feldolgozott csatorna: 0 · hozzáadott videó: 0 · ok: nincs szolgáltatásfiók) · " + + "friss (feldolgozott csatorna: 0 · hozzáadott videó: 0 · indok: nincs szolgáltatásfiók) · " + "mély (feldolgozott csatorna: 2 · hozzáadott videó: 5 · teljesen egyeztetve: 1)", ); }); diff --git a/frontend/src/lib/jobResult.ts b/frontend/src/lib/jobResult.ts index bf215157..53365236 100644 --- a/frontend/src/lib/jobResult.ts +++ b/frontend/src/lib/jobResult.ts @@ -40,7 +40,7 @@ export function jobResultText(t: TFunction, result: JobResult, lang: string): st /** What a job run said, as the inbox notice and the audit row both show it (E30): a title naming the job * and how the run ended, and its result in the reader's language — from the data when it rode along, - * else read back from the English one-liner. A run skipped for a pause names why; a failed run keeps the + * else read back from the English one-liner. A skipped run names only why; a failed run keeps the * upstream's own error text (it is diagnostic). */ export function jobRunText( t: TFunction, @@ -63,8 +63,21 @@ export function jobRunText( : (parseSummary(summary) ?? (run.status === "skipped" && summary ? { skipped: summary } : null)); } + // A skipped run's title already says "skipped": its body is only the reason, not "skipped: ". + const reason = run.status === "skipped" ? onlySkipReason(result) : null; return { title: t(titleKey, { job }), - body: result ? jobResultText(t, result, lang) : summary, + body: + reason !== null + ? t(`scheduler.skip.${vocabKey(reason)}`, { defaultValue: reason }) + : result + ? jobResultText(t, result, lang) + : summary, }; } + +function onlySkipReason(result: JobResult | null): string | null { + const keys = result ? Object.keys(result) : []; + const word = result?.skipped; + return keys.length === 1 && typeof word === "string" ? word : null; +} diff --git a/frontend/src/lib/saveLanguage.dom.test.ts b/frontend/src/lib/saveLanguage.dom.test.ts index c4790cda..3acbd8ab 100644 --- a/frontend/src/lib/saveLanguage.dom.test.ts +++ b/frontend/src/lib/saveLanguage.dom.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { QueryClient } from "@tanstack/react-query"; import { api } from "./api"; import { LANGUAGE_ORDERED, qk } from "./queryKeys"; -import { saveLanguage } from "./saveLanguage"; +import { saveLanguage, serverOrderLang } from "./saveLanguage"; afterEach(() => vi.restoreAllMocks()); @@ -37,3 +37,12 @@ describe("saveLanguage", () => { expect(asked).not.toHaveBeenCalled(); }); }); + +describe("serverOrderLang", () => { + it("is the stored language, else English — never the UI's guess", () => { + expect(serverOrderLang({ language: "hu" })).toBe("hu"); + expect(serverOrderLang({})).toBe("en"); // nothing stored: the server sorts in English + expect(serverOrderLang({ language: "de" })).toBe("en"); + expect(serverOrderLang(undefined)).toBe("en"); + }); +}); diff --git a/frontend/src/lib/saveLanguage.ts b/frontend/src/lib/saveLanguage.ts index f96d9ef1..bd414407 100644 --- a/frontend/src/lib/saveLanguage.ts +++ b/frontend/src/lib/saveLanguage.ts @@ -1,4 +1,5 @@ import type { QueryClient } from "@tanstack/react-query"; +import { isSupported } from "../i18n"; import { api } from "./api"; import { LANGUAGE_ORDERED } from "./queryKeys"; @@ -9,3 +10,11 @@ export async function saveLanguage(qc: QueryClient, code: string): Promise await api.savePrefs({ language: code }); for (const queryKey of LANGUAGE_ORDERED) void qc.invalidateQueries({ queryKey }); } + +/** The language the server orders this account's lists in (backend `mailtext.lang_for_user`): the + * stored preference, else English. Not always the UI's — a browser-detected language, or one picked + * while signed out, is never stored — so a list re-slotted here to match the server's order uses it. */ +export function serverOrderLang(preferences: Record | null | undefined): string { + const lang = preferences?.language; + return typeof lang === "string" && isSupported(lang) ? lang : "en"; +}