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:
@@ -728,7 +728,11 @@ function AdminInvites() {
|
||||
<div key={i.id} className="flex items-center gap-2">
|
||||
<span className="truncate flex-1">{i.email}</span>
|
||||
<span className={i.status === "approved" ? "text-accent" : "text-danger"}>
|
||||
{i.status}
|
||||
{t(
|
||||
i.status === "approved"
|
||||
? "settings.invites.status.approved"
|
||||
: "settings.invites.status.denied",
|
||||
)}
|
||||
</span>
|
||||
{/* Only an approved row with NOBODY behind it yet: this button means "the door is
|
||||
open, come in", which is meaningless to someone already inside — and half the
|
||||
|
||||
@@ -3,35 +3,27 @@ import { ExternalLink } from "lucide-react";
|
||||
import type { ChannelDetail } from "../lib/api";
|
||||
import { linkify } from "../lib/linkify";
|
||||
import { Badge } from "./ui/Badge";
|
||||
import { languageName, regionName, vocab } from "../lib/vocab";
|
||||
|
||||
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
|
||||
function displayName(code: string, lang: string, type: "region" | "language"): string {
|
||||
try {
|
||||
return (
|
||||
new Intl.DisplayNames([lang], { type }).of(type === "region" ? code.toUpperCase() : code) ??
|
||||
code
|
||||
);
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
// YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play".
|
||||
function parseKeywords(s: string): string[] {
|
||||
return (s.match(/"[^"]+"|\S+/g) ?? []).map((k) => k.replace(/^"|"$/g, "")).filter(Boolean);
|
||||
}
|
||||
// topicCategories are Wikipedia URLs (…/wiki/Music) → a readable label, de-duplicated.
|
||||
function topicLabels(urls: string[]): string[] {
|
||||
// topicCategories are Wikipedia URLs (…/wiki/Music) → the page slug ("Pop_music"), de-duplicated. The
|
||||
// slug is the vocabulary word (E30): named in the reader's language, else shown with spaces.
|
||||
function topicSlugs(urls: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const url of urls) {
|
||||
const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url;
|
||||
let label = seg;
|
||||
let slug = seg;
|
||||
try {
|
||||
label = decodeURIComponent(seg);
|
||||
slug = decodeURIComponent(seg);
|
||||
} catch {
|
||||
/* keep raw */
|
||||
}
|
||||
label = label.replace(/_/g, " ").trim();
|
||||
if (label) seen.add(label);
|
||||
slug = slug.trim();
|
||||
if (slug) seen.add(slug);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
@@ -43,7 +35,7 @@ function topicLabels(urls: string[]): string[] {
|
||||
*/
|
||||
export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const topics = ch.topic_categories?.length ? topicLabels(ch.topic_categories) : [];
|
||||
const topics = ch.topic_categories?.length ? topicSlugs(ch.topic_categories) : [];
|
||||
const keywords = ch.keywords ? parseKeywords(ch.keywords) : [];
|
||||
const hasAboutDetails =
|
||||
!!ch.country || !!ch.default_language || topics.length > 0 || keywords.length > 0;
|
||||
@@ -86,14 +78,14 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{displayName(ch.country, i18n.language, "region")}
|
||||
{regionName(t, ch.country, i18n.language)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{ch.default_language && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted w-24 shrink-0">{t("channel.language")}</span>
|
||||
<span>{displayName(ch.default_language, i18n.language, "language")}</span>
|
||||
<span>{languageName(ch.default_language, i18n.language, ch.default_language)}</span>
|
||||
</div>
|
||||
)}
|
||||
{topics.length > 0 && (
|
||||
@@ -102,7 +94,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{/* Future search epic: these topic chips become clickable filters. */}
|
||||
{topics.map((tp) => (
|
||||
<Badge key={tp}>{tp}</Badge>
|
||||
<Badge key={tp}>{vocab(t, "topic", tp, tp.replace(/_/g, " "))}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { channelDetailPollMs, useBackfillFeedRefresh } from "../lib/channelDetailPoll";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -42,6 +42,7 @@ import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { IconLink } from "./ui/LinkButton";
|
||||
import { PageHeading } from "./ui/Headings";
|
||||
import { useTags } from "../lib/useTags";
|
||||
|
||||
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
||||
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
|
||||
@@ -230,11 +231,7 @@ export default function ChannelPage({
|
||||
// invalidates the manager list so a tag added here shows there too.
|
||||
// Only fetch the tag catalog when the editor will actually render (subscribed, non-demo) — an
|
||||
// unsubscribed/demo channel page never shows the control, so the request would be thrown away.
|
||||
const tagsQuery = useQuery({
|
||||
queryKey: qk.tags(),
|
||||
queryFn: api.tags,
|
||||
enabled: !!ch?.subscribed && !me.is_demo,
|
||||
});
|
||||
const tagsQuery = useTags({ enabled: !!ch?.subscribed && !me.is_demo });
|
||||
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
|
||||
const { toggleTag, createAndAttachTag } = useChannelTagActions({
|
||||
optimisticToggle: (id, tagId, attach) =>
|
||||
|
||||
@@ -54,6 +54,7 @@ import { useNavigationActions } from "./navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { useWizard } from "./wizardContext";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { useTags } from "../lib/useTags";
|
||||
|
||||
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
||||
|
||||
@@ -118,7 +119,7 @@ export default function Channels() {
|
||||
};
|
||||
|
||||
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
|
||||
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
|
||||
const tagsQuery = useTags();
|
||||
const statusQuery = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
|
||||
const blockedQuery = useQuery({ queryKey: qk.blockedChannels(), queryFn: api.blockedChannels });
|
||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||
|
||||
@@ -12,6 +12,7 @@ const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { navigateTo } from "../lib/nav";
|
||||
import { profileName } from "../lib/vocab";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import { Input } from "./ui/Input";
|
||||
|
||||
@@ -75,7 +76,7 @@ export default function DownloadDialog({
|
||||
hideLabel
|
||||
selected={chosen == null ? [] : [String(chosen)]}
|
||||
onChange={([v]) => v && setProfileId(Number(v))}
|
||||
options={profiles.map((p) => ({ value: String(p.id), label: p.name }))}
|
||||
options={profiles.map((p) => ({ value: String(p.id), label: profileName(t, p) }))}
|
||||
/>
|
||||
<Button variant="link" onClick={() => setManage(true)} className="text-xs mt-1 mb-4">
|
||||
{t("downloads.dialog.manage")}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E30: a render crash's notice speaks the reader's language. It showed `error.message` — the JS
|
||||
// engine's English ("Cannot read properties of undefined (reading 'x')") — in the toast and the inbox,
|
||||
// where it outlives the reload. The technical text stays in the `ui.crash` log.
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
|
||||
const notify = vi.fn();
|
||||
vi.mock("../lib/notifications", () => ({ notify: (...args: unknown[]) => notify(...args) }));
|
||||
|
||||
import i18n from "../i18n";
|
||||
import { clear, events } from "../lib/log";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("a crash is reported in words the reader reads; the engine's text goes to the log", () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {}); // React reports the throw itself too
|
||||
const Broken = () => {
|
||||
throw new TypeError("Cannot read properties of undefined (reading 'x')");
|
||||
};
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Broken />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "fatal", message: i18n.t("errors.boundary.notifMessage") }),
|
||||
);
|
||||
expect(JSON.stringify(events().filter((e) => e.event === "ui.crash"))).toContain(
|
||||
"Cannot read properties of undefined",
|
||||
);
|
||||
});
|
||||
@@ -32,7 +32,9 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
notify({
|
||||
level: "fatal",
|
||||
title: i18n.t("errors.boundary.notifTitle"),
|
||||
message: error.message || i18n.t("errors.boundary.notifMessage"),
|
||||
// Never the raw `error.message`: a JS engine's English ("Cannot read properties of undefined…")
|
||||
// is no message for the reader (E30) — the `ui.crash` log above carries it for diagnosis.
|
||||
message: i18n.t("errors.boundary.notifMessage"),
|
||||
requiresInteraction: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -330,3 +330,37 @@ describe("NotificationsPanel · the page's background menu", () => {
|
||||
expect(menuRows()).toEqual(["inbox.markAllRead"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a download-lifecycle notice (E30)", () => {
|
||||
it("is named from its data in the reader's language, never the server's English title", () => {
|
||||
h.server = [
|
||||
server(1, {
|
||||
type: "download_shared",
|
||||
title: "Cats was shared with you",
|
||||
body: null,
|
||||
data: { kind: "download_shared", from: "a@b.c", title: "Cats" },
|
||||
} as never),
|
||||
server(2, {
|
||||
type: "download_expiring",
|
||||
title: "Dogs",
|
||||
data: { kind: "download_expiring", title: "Dogs" },
|
||||
} as never),
|
||||
server(3, {
|
||||
type: "download_expired",
|
||||
title: "download", // an untitled video: the server's English fallback word
|
||||
data: { kind: "download_expired", title: null },
|
||||
} as never),
|
||||
server(4, {
|
||||
type: "download_evicted",
|
||||
title: "Birds",
|
||||
data: { kind: "download_evicted", title: "Birds" },
|
||||
} as never),
|
||||
];
|
||||
mount();
|
||||
for (const key of ["downloadShared", "downloadExpiring", "downloadExpired", "downloadEvicted"])
|
||||
expect(screen.getByText(`inbox.${key}.title`)).toBeTruthy();
|
||||
for (const title of ["Cats", "Dogs", "Birds"]) expect(screen.getByText(title)).toBeTruthy();
|
||||
expect(screen.queryByText("Cats was shared with you")).toBeNull();
|
||||
expect(screen.queryByText("download")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
UserPlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, type AppNotification } from "../lib/api";
|
||||
import { api, type AppNotification, type JobResult } from "../lib/api";
|
||||
import { jobResultText } from "../lib/jobResult";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
|
||||
@@ -438,6 +439,14 @@ export default function NotificationsPanel() {
|
||||
}
|
||||
|
||||
/** A server notification's link, from its type and typed payload (the row's button and its menu). */
|
||||
// The download-lifecycle notification types → their `inbox.<key>` copy.
|
||||
const DOWNLOAD_LIFECYCLE: Record<string, string | undefined> = {
|
||||
download_shared: "downloadShared",
|
||||
download_expiring: "downloadExpiring",
|
||||
download_expired: "downloadExpired",
|
||||
download_evicted: "downloadEvicted",
|
||||
};
|
||||
|
||||
function serverKind(n: AppNotification): "scheduler" | "download" | null {
|
||||
if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler";
|
||||
if (n.type === "download_done") return "download";
|
||||
@@ -508,6 +517,8 @@ function NotificationRow({
|
||||
const isScheduler = serverKind(n) === "scheduler";
|
||||
const isDownloadDone = serverKind(n) === "download";
|
||||
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
|
||||
// A download's life after it is done (E30): shared with you, about to expire, expired, evicted.
|
||||
const lifecycle = DOWNLOAD_LIFECYCLE[n.type];
|
||||
let title = n.title;
|
||||
let body = n.body;
|
||||
if (isMaintenance) {
|
||||
@@ -516,13 +527,14 @@ function NotificationRow({
|
||||
title = t("inbox.maintenance.title");
|
||||
body = t("inbox.maintenance.body", { count: d.count });
|
||||
} else if (isScheduler) {
|
||||
// "<Job label> finished/failed", with the raw result summary as the (technical) body.
|
||||
const d = n.data as { job_id: string; status?: string; summary?: string };
|
||||
// "<Job label> finished/failed", with the result as the body — named in the reader's language
|
||||
// when it rode along as data (E30), else the English summary of an older notice.
|
||||
const d = n.data as { job_id: string; status?: string; summary?: string; result?: JobResult };
|
||||
const job = t(`scheduler.jobs.${d.job_id}`, d.job_id);
|
||||
title = t(d.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", {
|
||||
job,
|
||||
});
|
||||
body = d.summary || n.body;
|
||||
body = d.result ? jobResultText(t, d.result) : d.summary || n.body;
|
||||
} else if (isEmailUndelivered) {
|
||||
// Email is fail-soft: a drop leaves no error anywhere else, so this notice IS the trace. Name
|
||||
// the message that was lost, since that is what tells the operator what their users didn't get.
|
||||
@@ -534,6 +546,13 @@ function NotificationRow({
|
||||
: "inbox.emailUndelivered.sendFailed",
|
||||
{ subject: d.subject || n.body || "" },
|
||||
);
|
||||
} else if (lifecycle) {
|
||||
// The server stores an English title ("… was shared with you", or the bare word "download" for an
|
||||
// untitled video); the notice is named from `data`, the video's title as the body — none when it
|
||||
// has no title (or the share notice predates the title riding along).
|
||||
const d = n.data as { title?: string | null; from?: string };
|
||||
title = t(`inbox.${lifecycle}.title`, { from: d.from ?? "" });
|
||||
body = d.title || null;
|
||||
} else if (isDownloadDone) {
|
||||
// "Download ready" / "Edit ready" per the producer's kind, with the video's title as the body
|
||||
// (shipped in data.title). Same notification type; the copy differs so an edit isn't mislabelled.
|
||||
|
||||
@@ -64,7 +64,11 @@ const detail: PlexItemDetail = {
|
||||
{ name: "Sam Writer", job: "Writer" },
|
||||
],
|
||||
studios: ["Studio A", "Studio B"],
|
||||
production_countries: ["United States"],
|
||||
// One coded (named in the reader's language), one from a row enriched before codes were stored.
|
||||
production_countries: [
|
||||
{ code: "HU", name: "Hungary" },
|
||||
{ code: null, name: "United States" },
|
||||
],
|
||||
recommendations: [],
|
||||
backdrops: [],
|
||||
},
|
||||
@@ -93,7 +97,8 @@ describe("PlexInfo enriched detail (page)", () => {
|
||||
expect(screen.getByText("$160M")).toBeTruthy();
|
||||
expect(screen.getByText("$830M")).toBeTruthy();
|
||||
expect(screen.getByText("Studio A")).toBeTruthy(); // tmdb.studios preferred over the fallback
|
||||
expect(screen.getByText("United States")).toBeTruthy();
|
||||
// E30: a coded country is named through the vocab (key-echo i18n here), an uncoded one as stored.
|
||||
expect(screen.getByText("vocab.region.HU, United States")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keyword chips filter the library on click", () => {
|
||||
@@ -127,6 +132,8 @@ describe("PlexInfo enriched detail (page)", () => {
|
||||
const onOpenPerson = vi.fn();
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onOpenPerson={onOpenPerson} />);
|
||||
expect(screen.getByText("plex.info.crew")).toBeTruthy();
|
||||
// E30: the job is TMDB's English word, named through the vocab (key-echo i18n here).
|
||||
expect(screen.getByText("vocab.crewJob.Director")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("Jane Director"));
|
||||
expect(onOpenPerson).toHaveBeenCalledWith("Jane Director");
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import { PlexMenu } from "./PlexMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { usePlexCardActions } from "./usePlexCardActions";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { regionName, vocab } from "../lib/vocab";
|
||||
|
||||
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
||||
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
||||
@@ -233,7 +234,9 @@ function FactsPanel({
|
||||
rows.push({
|
||||
key: "countries",
|
||||
label: t("plex.info.facts.countries"),
|
||||
node: tmdb.production_countries.join(", "),
|
||||
node: tmdb.production_countries
|
||||
.map((c) => regionName(t, c.code, i18n.language, c.name))
|
||||
.join(", "),
|
||||
});
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
@@ -309,7 +312,7 @@ function CrewPanel({
|
||||
) : (
|
||||
<div className="truncate text-sm font-medium">{c.name}</div>
|
||||
)}
|
||||
<div className="truncate text-2xs text-muted">{c.job}</div>
|
||||
<div className="truncate text-2xs text-muted">{vocab(t, "crewJob", c.job)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,8 @@ describe("PlexPersonView", () => {
|
||||
expect(screen.getByText("plex.person.shows")).toBeTruthy();
|
||||
|
||||
// E5 S6 bio hero: department, biography, birth facts, aka.
|
||||
expect(screen.getByText("Acting")).toBeTruthy();
|
||||
// E30: TMDB's English department, named through the vocab (key-echo i18n here).
|
||||
expect(screen.getByText("vocab.department.Acting")).toBeTruthy();
|
||||
expect(screen.getByText("A celebrated actor.")).toBeTruthy();
|
||||
expect(screen.getByText(/1970-01-01/)).toBeTruthy();
|
||||
expect(screen.getByText(/plex\.person\.aka/)).toBeTruthy();
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import { api, HttpError } from "../lib/api";
|
||||
import { api, HttpError, type PlexTrackNaming } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import {
|
||||
audioOrdForLang,
|
||||
@@ -88,6 +88,7 @@ import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Slider } from "./ui/NativeControls";
|
||||
import { formatUnit } from "../lib/format";
|
||||
import { trackLabel } from "../lib/vocab";
|
||||
|
||||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
@@ -131,13 +132,16 @@ const DOUBLE_TAP_MS = 300;
|
||||
type Props = { itemId: string; onClose: () => void; queue?: string[]; fromStart?: boolean };
|
||||
|
||||
export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
// `t` changes identity on a language switch. loadSession only uses it for error strings, so read it
|
||||
// through a ref — keeping it OUT of loadSession's deps. Otherwise a mid-playback language change
|
||||
// rebuilt loadSession → re-ran the detail effect → reloaded the session from the STALE saved resume
|
||||
// position (losing live progress). See the detail effect's deps.
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
// E30: a track named in the reader's language (lib/vocab), not in the Plex server's ("English (AC3 5.1)").
|
||||
const audioName = (a: PlexTrackNaming) => trackLabel(t, a, "audio", i18n.language);
|
||||
const subtitleName = (x: PlexTrackNaming) => trackLabel(t, x, "subtitle", i18n.language);
|
||||
const qc = useQueryClient();
|
||||
const [id, setId] = useState(itemId);
|
||||
// Serve the player's title/summary/rich detail in the active language (E5 S6), matching the grid.
|
||||
@@ -1238,16 +1242,21 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
audio: () => {
|
||||
const ord = cycleAudio();
|
||||
if (ord === undefined) return; // one track (or none): nothing to say
|
||||
const label = detail?.audio_streams.find((a) => a.ord === (ord ?? 0))?.label;
|
||||
flashOsd(`${t("plex.player.audio")} · ${label ?? "—"}`);
|
||||
const track = detail?.audio_streams.find((a) => a.ord === (ord ?? 0));
|
||||
flashOsd(
|
||||
`${t("plex.player.audio")} · ${track ? trackLabel(t, track, "audio", i18n.language) : "—"}`,
|
||||
);
|
||||
},
|
||||
subtitle: () => {
|
||||
const ord = cycleSubtitle();
|
||||
if (ord === undefined) return;
|
||||
const track = ord == null ? null : detail?.subtitle_streams.find((x) => x.ord === ord);
|
||||
const label =
|
||||
ord == null
|
||||
? t("plex.player.subOff")
|
||||
: (detail?.subtitle_streams.find((x) => x.ord === ord)?.label ?? "—");
|
||||
: track
|
||||
? trackLabel(t, track, "subtitle", i18n.language)
|
||||
: "—";
|
||||
flashOsd(`${t("plex.player.subtitles")} · ${label}`);
|
||||
},
|
||||
info: () => setInfoOpen((v) => !v),
|
||||
@@ -1309,6 +1318,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
cycleSubtitle,
|
||||
flashOsd,
|
||||
t,
|
||||
i18n.language,
|
||||
detail,
|
||||
prevId,
|
||||
nextId,
|
||||
@@ -1336,6 +1346,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0;
|
||||
// While scrubbing the head follows the finger (preview); otherwise it tracks playback.
|
||||
const headPct = scrub != null && duration > 0 ? (scrub / duration) * 100 : pct;
|
||||
// The tracks playing now, for the stats overlay.
|
||||
const currentAudio = detail?.audio_streams.find((x) => x.ord === (audioOrd ?? 0));
|
||||
const currentSub = detail?.subtitle_streams.find((x) => x.ord === subOrd);
|
||||
|
||||
// Portaled to <body>, like every other full-screen overlay here (Modal, PlayerModal, ChatDock,
|
||||
// BackToTop). A fixed, full-viewport player must not be painted inside the page scroller: the
|
||||
@@ -1412,7 +1425,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
kind="subtitles"
|
||||
src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset, subGen)}
|
||||
srcLang={selectedSub.language ?? undefined}
|
||||
label={selectedSub.label}
|
||||
label={subtitleName(selectedSub)}
|
||||
{...subTrackListeners}
|
||||
default
|
||||
/>
|
||||
@@ -1466,7 +1479,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
|
||||
<span>{t("plex.player.subPreparing", { label: selectedSub.label })}</span>
|
||||
<span>{t("plex.player.subPreparing", { label: subtitleName(selectedSub) })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1783,14 +1796,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
tone="media"
|
||||
role="menuitemradio"
|
||||
checked={(audioOrd ?? 0) === a.ord}
|
||||
label={a.label}
|
||||
label={audioName(a)}
|
||||
onSelect={() => changeAudio(a.ord)}
|
||||
className={`${TRACK_ROW} ${
|
||||
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
|
||||
}`}
|
||||
>
|
||||
<MenuCheck kind="one" on={(audioOrd ?? 0) === a.ord} />
|
||||
<span className="truncate">{a.label}</span>
|
||||
<span className="truncate">{audioName(a)}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</div>
|
||||
@@ -1815,14 +1828,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
tone="media"
|
||||
role="menuitemradio"
|
||||
checked={subOrd === sub.ord}
|
||||
label={sub.label}
|
||||
label={subtitleName(sub)}
|
||||
onSelect={() => changeSubtitle(sub.ord)}
|
||||
className={`${TRACK_ROW} ${
|
||||
subOrd === sub.ord ? "text-accent" : "text-white"
|
||||
}`}
|
||||
>
|
||||
<MenuCheck kind="one" on={subOrd === sub.ord} />
|
||||
<span className="truncate">{sub.label}</span>
|
||||
<span className="truncate">{subtitleName(sub)}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</div>
|
||||
@@ -1896,13 +1909,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
videoRef={videoRef}
|
||||
hlsRef={hlsRef}
|
||||
fragMeterRef={fragMeterRef}
|
||||
audioLabel={
|
||||
detail?.audio_streams.find((a) => a.ord === (audioOrd ?? 0))?.label ?? undefined
|
||||
}
|
||||
audioLabel={currentAudio ? audioName(currentAudio) : undefined}
|
||||
subLabel={
|
||||
subOrd == null
|
||||
? t("plex.player.subOff")
|
||||
: (detail?.subtitle_streams.find((x) => x.ord === subOrd)?.label ?? undefined)
|
||||
: currentSub
|
||||
? subtitleName(currentSub)
|
||||
: undefined
|
||||
}
|
||||
playable={detail?.playable ?? ""}
|
||||
hwRef={hwRef}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
|
||||
import { profileName } from "../lib/vocab";
|
||||
import { Button } from "./ui/Button";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
@@ -118,7 +119,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||
{builtins.map((p) => (
|
||||
<div key={p.id} className={`${SURFACE.card} flex items-center gap-2 text-sm px-3 py-1.5`}>
|
||||
<Lock className="w-3.5 h-3.5 text-muted shrink-0" />
|
||||
<span className="flex-1 truncate">{p.name}</span>
|
||||
<span className="flex-1 truncate">{profileName(t, p)}</span>
|
||||
</div>
|
||||
))}
|
||||
{mine.length > 0 && (
|
||||
|
||||
@@ -32,6 +32,7 @@ const status = (over: Partial<SchedulerStatus> = {}): SchedulerStatus => ({
|
||||
last_started: null,
|
||||
last_finished: null,
|
||||
last_result: null,
|
||||
result: null,
|
||||
last_error: null,
|
||||
progress: null,
|
||||
},
|
||||
@@ -258,3 +259,25 @@ describe("Scheduler while paused (E28 audit F13)", () => {
|
||||
expect(runOne).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a job's last result (E30)", () => {
|
||||
it("is named from its data, not the server's English one-liner", async () => {
|
||||
const base = status();
|
||||
await mount({
|
||||
...base,
|
||||
jobs: [
|
||||
{
|
||||
...base.jobs[0]!,
|
||||
last_finished: new Date().toISOString(),
|
||||
last_result: "new=3, failed=0",
|
||||
result: { new: 3, failed: 0 },
|
||||
},
|
||||
],
|
||||
});
|
||||
// Key-echo i18n: the keys named, the counts shown.
|
||||
expect(
|
||||
await screen.findByText(/scheduler\.result\.new: 3 · scheduler\.result\.failed: 0/),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText(/new=3/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ const status = (over: Partial<SchedulerStatus> = {}): SchedulerStatus => ({
|
||||
last_started: null,
|
||||
last_finished: null,
|
||||
last_result: null,
|
||||
result: null,
|
||||
last_error: null,
|
||||
progress: null,
|
||||
},
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { jobResultText } from "../lib/jobResult";
|
||||
|
||||
const POLL_MS = 4000;
|
||||
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
|
||||
@@ -255,6 +256,8 @@ function JobRow({
|
||||
: t("scheduler.neverRun")}
|
||||
{job.last_error ? (
|
||||
<span className="text-danger"> · {job.last_error}</span>
|
||||
) : job.result ? (
|
||||
<span> · {jobResultText(t, job.result)}</span>
|
||||
) : job.last_result ? (
|
||||
<span> · {job.last_result}</span>
|
||||
) : null}
|
||||
|
||||
@@ -21,6 +21,7 @@ import MatchToggle from "./ui/MatchToggle";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { useChannelsActions } from "./channelsContext";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { useTags } from "../lib/useTags";
|
||||
|
||||
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
||||
const DATE_PRESETS: { days: number; key: string }[] = [
|
||||
@@ -53,7 +54,7 @@ export default function Sidebar({
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
|
||||
const { focusChannel: onFocusChannel } = useChannelsActions();
|
||||
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
|
||||
const tagsQuery = useTags();
|
||||
const tags = tagsQuery.data ?? [];
|
||||
|
||||
// Live per-tag channel counts for the current filter context. Debounce the search term so
|
||||
|
||||
@@ -16,6 +16,7 @@ import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { useTags } from "../lib/useTags";
|
||||
|
||||
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
|
||||
// hover popover on the count listing the tagged channels (each a link that focuses it in the
|
||||
@@ -112,7 +113,7 @@ export default function TagManager({
|
||||
const confirm = useConfirm();
|
||||
const listFade = useScrollFade();
|
||||
const [newName, setNewName] = useState("");
|
||||
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
|
||||
const tagsQuery = useTags();
|
||||
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
|
||||
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
|
||||
const channelsForTag = (tagId: number) =>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { Badge } from "./ui/Badge";
|
||||
import { useTags } from "../lib/useTags";
|
||||
|
||||
// A video's details (E8·S3, decision D3): full (un-clamped) title, channel, views·duration·date, a
|
||||
// description snippet, and the channel's tags. E28 C1 moved it from a hover ⓘ on the thumbnail into
|
||||
@@ -58,11 +59,7 @@ export default function VideoDetailsAt({
|
||||
enabled: !!video.channel_id,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const { data: tags } = useQuery({
|
||||
queryKey: qk.tags(),
|
||||
queryFn: () => api.tags(),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const { data: tags } = useTags({ staleTime: 5 * 60_000 });
|
||||
|
||||
// The channel's tags (`tag_ids`), in the channel's own id order, resolved to names via the tag list.
|
||||
// These are the channel-tags the channel actually carries; an unknown id (a just-deleted tag) drops.
|
||||
|
||||
@@ -162,6 +162,9 @@ describe("Access requests — the delete affordance", () => {
|
||||
await openAccessTab();
|
||||
|
||||
fireEvent.click(await screen.findByText(/settings\.invites\.decided/));
|
||||
// E30: the decision is named, not the raw status word ("denied").
|
||||
expect(screen.getByText("settings.invites.status.denied")).toBeTruthy();
|
||||
expect(screen.queryByText("denied")).toBeNull();
|
||||
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() => expect(deleteMock).toHaveBeenCalledWith(1));
|
||||
|
||||
@@ -57,6 +57,7 @@ import { Chip } from "./ui/Chip";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { PageHeading } from "./ui/Headings";
|
||||
import { vocab } from "../lib/vocab";
|
||||
|
||||
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
|
||||
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
|
||||
@@ -243,7 +244,9 @@ export function PlexPersonView({
|
||||
<div className="min-w-0 flex-1">
|
||||
<PageHeading className="text-xl font-semibold">{d.name}</PageHeading>
|
||||
{d.known_for_department && (
|
||||
<p className="text-xs font-medium text-accent">{d.known_for_department}</p>
|
||||
<p className="text-xs font-medium text-accent">
|
||||
{vocab(t, "department", d.known_for_department)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 mb-2 text-xs text-muted">
|
||||
{t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })}
|
||||
|
||||
Reference in New Issue
Block a user