feat(plex): Settings toggle for Plex watch-sync + one-click import

Admins get a 'Plex watch sync' section in Settings → Account (shown when the Plex module is on):
a two-way-sync toggle whose first enable imports the Plex watch history, a last-import line, and an
'Import from Plex now' button. api.plexWatch{Link,SetLink,Import}; trilingual EN/HU/DE strings.
This commit is contained in:
npeter83 2026-07-09 14:42:32 +02:00
parent 04fb3fb16e
commit a0475eb7bb
5 changed files with 118 additions and 2 deletions

View file

@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Monitor, Trash2, User } from "lucide-react"; import { Bell, Monitor, RefreshCw, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api"; import { api, type Me } from "../lib/api";
import { LS, useAccountPersistedState } from "../lib/storage"; import { LS, useAccountPersistedState } from "../lib/storage";
@ -426,6 +426,70 @@ function SignInMethods({ me }: { me: Me }) {
); );
} }
// Two-way Plex watch-state sync (Phase A): the owner links their account to the Plex admin account
// and does a one-time "Plex is master" import. Admin-only (rides the server admin token) and only
// shown when the Plex module is enabled. Two-way push/pull arrive in later phases.
function PlexWatchSync() {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const link = useQuery({ queryKey: ["plex-watch-link"], queryFn: () => api.plexWatchLink() });
const enabled = link.data?.sync_enabled ?? false;
const announce = (imp: { watched: number; in_progress: number } | null | undefined) => {
if (imp) {
notify({
level: "success",
message: t("settings.plexSync.imported", { watched: imp.watched, in_progress: imp.in_progress }),
});
// The browse grid's watch badges read plex_states — refresh them.
qc.invalidateQueries({ queryKey: ["plex"] });
}
};
const toggle = useMutation({
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
onSuccess: (res) => {
qc.setQueryData(["plex-watch-link"], res);
announce(res.import);
},
});
const reimport = useMutation({
mutationFn: () => api.plexWatchImport(),
onSuccess: (res) => {
qc.setQueryData(["plex-watch-link"], res);
announce(res.import);
},
});
const last = link.data?.last_watch_sync_at
? new Date(link.data.last_watch_sync_at).toLocaleString(i18n.language)
: null;
return (
<Section title={t("settings.plexSync.title")}>
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.plexSync.intro")}</p>
<SettingRow label={t("settings.plexSync.toggle")} hint={t("settings.plexSync.toggleHint")}>
<Switch checked={enabled} onChange={(v) => toggle.mutate(v)} />
</SettingRow>
{enabled && (
<div className="mt-2 flex items-center justify-between gap-3">
<p className="text-xs text-muted">
{last ? t("settings.plexSync.lastSync", { when: last }) : t("settings.plexSync.never")}
</p>
<button
onClick={() => reimport.mutate()}
disabled={reimport.isPending}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm border border-border hover:bg-card/60 disabled:opacity-50 transition shrink-0"
>
<RefreshCw className={`w-4 h-4 ${reimport.isPending ? "animate-spin" : ""}`} />
{t("settings.plexSync.importNow")}
</button>
</div>
)}
</Section>
);
}
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const confirm = useConfirm(); const confirm = useConfirm();
@ -464,6 +528,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
{!me.is_demo && <SignInMethods me={me} />} {!me.is_demo && <SignInMethods me={me} />}
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
{me.is_demo ? ( {me.is_demo ? (
<Section title={t("settings.account.youtubeAccess")}> <Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed"> <p className="text-xs text-muted leading-relaxed">

View file

@ -5,6 +5,16 @@
"notifications": "Benachrichtigungen", "notifications": "Benachrichtigungen",
"account": "Konto" "account": "Konto"
}, },
"plexSync": {
"title": "Plex-Wiedergabesync",
"intro": "Halte den Status „gesehen / wird gerade angesehen“ zwischen Plex und Siftlode synchron. Beim Einschalten wird dein vorhandener Plex-Verlauf einmalig importiert (Plex gewinnt); die laufende Zwei-Wege-Synchronisierung folgt.",
"toggle": "Zwei-Wege-Sync mit Plex",
"toggleHint": "Nutzt das Plex-Konto dieses Servers. Beim Aktivieren wird dein Plex-Wiedergabeverlauf einmalig importiert.",
"importNow": "Jetzt aus Plex importieren",
"imported": "Aus Plex importiert: {{watched}} gesehen, {{in_progress}} laufend.",
"lastSync": "Letzter Import: {{when}}",
"never": "Noch nicht importiert"
},
"save": { "save": {
"unsaved": "Nicht gespeicherte Änderungen", "unsaved": "Nicht gespeicherte Änderungen",
"saving": "Speichern…", "saving": "Speichern…",

View file

@ -5,6 +5,16 @@
"notifications": "Notifications", "notifications": "Notifications",
"account": "Account" "account": "Account"
}, },
"plexSync": {
"title": "Plex watch sync",
"intro": "Sync your watched / in-progress status between Plex and Siftlode. Turning this on imports your existing Plex history now (Plex wins); ongoing two-way sync follows.",
"toggle": "Two-way sync with Plex",
"toggleHint": "Uses this server's Plex account. Turning it on imports your Plex watch history once.",
"importNow": "Import from Plex now",
"imported": "Imported from Plex: {{watched}} watched, {{in_progress}} in progress.",
"lastSync": "Last import: {{when}}",
"never": "Not imported yet"
},
"save": { "save": {
"unsaved": "Unsaved changes", "unsaved": "Unsaved changes",
"saving": "Saving…", "saving": "Saving…",

View file

@ -5,6 +5,16 @@
"notifications": "Értesítések", "notifications": "Értesítések",
"account": "Fiók" "account": "Fiók"
}, },
"plexSync": {
"title": "Plex nézettség-szinkron",
"intro": "Tartsd szinkronban a megnézett / folyamatban lévő státuszt a Plex és a Siftlode között. A bekapcsolás most beimportálja a meglévő Plex-előzményedet (a Plex az irányadó); a folyamatos kétirányú szinkron ezután jön.",
"toggle": "Kétirányú szinkron a Plexszel",
"toggleHint": "A szerver Plex-fiókját használja. Bekapcsoláskor egyszer beimportálja a Plex nézettségi előzményedet.",
"importNow": "Importálás a Plexből most",
"imported": "Importálva a Plexből: {{watched}} megnézve, {{in_progress}} folyamatban.",
"lastSync": "Utolsó import: {{when}}",
"never": "Még nincs importálva"
},
"save": { "save": {
"unsaved": "Nem mentett változások", "unsaved": "Nem mentett változások",
"saving": "Mentés…", "saving": "Mentés…",

View file

@ -810,6 +810,20 @@ export interface PlexPlaySession {
start_s: number; start_s: number;
} }
// Two-way Plex watch-state sync link status (owner account; Phase A).
export interface PlexWatchLink {
linked: boolean;
sync_enabled: boolean;
uses_admin: boolean;
initial_import_done: boolean;
last_watch_sync_at: string | null;
}
export interface PlexWatchImport {
watched: number;
in_progress: number;
scanned: number;
}
// Admin Users & roles page. // Admin Users & roles page.
export interface AdminUserRow { export interface AdminUserRow {
id: number; id: number;
@ -1261,6 +1275,12 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify({ status }), body: JSON.stringify({ status }),
}), }),
// Two-way Plex watch-sync (Phase A: owner link + one-time "Plex is master" import).
plexWatchLink: (): Promise<PlexWatchLink> => req("/api/plex/watch/link"),
plexWatchSetLink: (enabled: boolean): Promise<PlexWatchLink & { import: PlexWatchImport | null }> =>
req("/api/plex/watch/link", { method: "POST", body: JSON.stringify({ enabled }) }),
plexWatchImport: (): Promise<PlexWatchLink & { import: PlexWatchImport }> =>
req("/api/plex/watch/import", { method: "POST" }),
plexStreamFileUrl: (id: string): string => `/api/plex/stream/${encodeURIComponent(id)}/file`, plexStreamFileUrl: (id: string): string => `/api/plex/stream/${encodeURIComponent(id)}/file`,
plexDownloadUrl: (id: string): string => plexDownloadUrl: (id: string): string =>
`/api/plex/stream/${encodeURIComponent(id)}/file?download=1`, `/api/plex/stream/${encodeURIComponent(id)}/file?download=1`,