diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index da85714..f98421d 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { useQueryClient } from "@tanstack/react-query"; -import { Bell, Monitor, Trash2, User } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Bell, Monitor, RefreshCw, Trash2, User } from "lucide-react"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, type Me } from "../lib/api"; 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 ( +
+

{t("settings.plexSync.intro")}

+ + toggle.mutate(v)} /> + + {enabled && ( +
+

+ {last ? t("settings.plexSync.lastSync", { when: last }) : t("settings.plexSync.never")} +

+ +
+ )} +
+ ); +} + function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { const { t } = useTranslation(); const confirm = useConfirm(); @@ -464,6 +528,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { {!me.is_demo && } + {me.role === "admin" && me.plex_enabled && } + {me.is_demo ? (

diff --git a/frontend/src/i18n/locales/de/settings.json b/frontend/src/i18n/locales/de/settings.json index 228d491..51dfb58 100644 --- a/frontend/src/i18n/locales/de/settings.json +++ b/frontend/src/i18n/locales/de/settings.json @@ -5,6 +5,16 @@ "notifications": "Benachrichtigungen", "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": { "unsaved": "Nicht gespeicherte Änderungen", "saving": "Speichern…", diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 21fb2e7..8d79214 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -5,6 +5,16 @@ "notifications": "Notifications", "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": { "unsaved": "Unsaved changes", "saving": "Saving…", diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index 6ae1077..6d987cc 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -5,6 +5,16 @@ "notifications": "Értesítések", "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": { "unsaved": "Nem mentett változások", "saving": "Mentés…", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 51ff4ff..b8f889a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -810,6 +810,20 @@ export interface PlexPlaySession { 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. export interface AdminUserRow { id: number; @@ -1261,6 +1275,12 @@ export const api = { method: "POST", body: JSON.stringify({ status }), }), + // Two-way Plex watch-sync (Phase A: owner link + one-time "Plex is master" import). + plexWatchLink: (): Promise => req("/api/plex/watch/link"), + plexWatchSetLink: (enabled: boolean): Promise => + req("/api/plex/watch/link", { method: "POST", body: JSON.stringify({ enabled }) }), + plexWatchImport: (): Promise => + req("/api/plex/watch/import", { method: "POST" }), plexStreamFileUrl: (id: string): string => `/api/plex/stream/${encodeURIComponent(id)}/file`, plexDownloadUrl: (id: string): string => `/api/plex/stream/${encodeURIComponent(id)}/file?download=1`,