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:
parent
04fb3fb16e
commit
a0475eb7bb
5 changed files with 118 additions and 2 deletions
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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…",
|
||||||
|
|
|
||||||
|
|
@ -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…",
|
||||||
|
|
|
||||||
|
|
@ -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…",
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue