diff --git a/web/app/api/simkl/[...path]/route.ts b/web/app/api/simkl/[...path]/route.ts new file mode 100644 index 000000000..1ee20f6c7 --- /dev/null +++ b/web/app/api/simkl/[...path]/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; + +function envValue(value: string | undefined, fallback = "") { + return value && !value.startsWith("$") ? value : fallback; +} + +async function handler(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + const { path } = await context.params; + const netlifyBackendUrl = ( + process.env.NEXT_PUBLIC_NETLIFY_BACKEND_URL ?? + process.env.NETLIFY_BACKEND_URL ?? + "https://auth.arvio.tv/.netlify/functions" + ).replace(/\/+$/, ""); + const appAnonKey = envValue(process.env.NEXT_PUBLIC_ARVIO_APP_ANON_KEY, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ""); + const simklClientId = process.env.NEXT_PUBLIC_SIMKL_CLIENT_ID ?? process.env.SIMKL_CLIENT_ID ?? ""; + const simklSecret = process.env.SIMKL_CLIENT_SECRET ?? ""; + const input = new URL(request.url); + const method = request.method; + const body = method === "GET" || method === "HEAD" ? undefined : await request.text(); + const normalizedPath = path.join("/"); + + let target: URL; + let headers: HeadersInit; + + const usesNetlifyProxy = netlifyBackendUrl.startsWith("https://") && appAnonKey.length > 40; + + if (usesNetlifyProxy) { + target = new URL(`${netlifyBackendUrl}/simkl-proxy`); + target.searchParams.set("path", `/${normalizedPath}`); + target.searchParams.set("method", method); + input.searchParams.forEach((value, key) => target.searchParams.set(key, value)); + headers = { + apikey: appAnonKey, + Authorization: `Bearer ${appAnonKey}` + }; + const userToken = request.headers.get("x-user-token"); + if (userToken) headers["x-user-token" as keyof HeadersInit] = userToken; + } else if (simklClientId) { + target = new URL(`https://api.simkl.com/${normalizedPath}`); + input.searchParams.forEach((value, key) => target.searchParams.set(key, value)); + headers = { + "content-type": "application/json", + "simkl-api-key": simklClientId + }; + const userToken = request.headers.get("x-user-token"); + if (userToken) headers.Authorization = `Bearer ${userToken}`; + } else { + return NextResponse.json({ error: "Simkl proxy is not configured" }, { status: 500 }); + } + + const parsedBody = body && normalizedPath === "oauth/token" && simklSecret && !usesNetlifyProxy + ? JSON.stringify({ ...JSON.parse(body), client_id: simklClientId, client_secret: simklSecret }) + : body; + + const response = await fetch(target, { + method, + headers, + body: parsedBody, + cache: "no-store" + }); + + const responseHeaders = new Headers(); + responseHeaders.set("content-type", response.headers.get("content-type") ?? "application/json"); + + return new NextResponse(response.body, { + status: response.status, + headers: responseHeaders + }); +} + +export const GET = handler; +export const POST = handler; +export const DELETE = handler; diff --git a/web/components/details/DetailsDrawer.tsx b/web/components/details/DetailsDrawer.tsx index a037b76a6..c73983e9b 100644 --- a/web/components/details/DetailsDrawer.tsx +++ b/web/components/details/DetailsDrawer.tsx @@ -37,7 +37,7 @@ function needsDetailsHydration(item: MediaItem) { } function DetailsView({ item }: { item: MediaItem }) { - const { streams, selectedEpisode, activeProfile, addons: installedAddons, loadEpisodeStreams, openDetails, playStream, playTrailer, setToast, settings, watchlist, refreshData, busy, isWatched, markWatchedLocally, openContextMenu, toggleWatched } = useApp(); + const { streams, selectedEpisode, activeProfile, addons: installedAddons, loadEpisodeStreams, openDetails, playStream, playTrailer, setToast, settings, watchlist, refreshData, busy, isWatched, markWatchedLocally, openContextMenu, toggleWatchlist, toggleWatched } = useApp(); const [detailsItem, setDetailsItem] = useState(item); const [detailsLoading, setDetailsLoading] = useState(false); const [reviews, setReviews] = useState([]); @@ -139,7 +139,7 @@ function DetailsView({ item }: { item: MediaItem }) { const addToWatchlist = async () => { if (!syncClient().isConnected) { - setToast("Connect Trakt or MDBList in Settings to use Watchlist."); + setToast("Connect Trakt, Simkl, or MDBList in Settings to use Watchlist."); return; } try { @@ -153,7 +153,7 @@ function DetailsView({ item }: { item: MediaItem }) { const removeFromWatchlist = async () => { if (!syncClient().isConnected) { - setToast("Connect Trakt or MDBList in Settings to remove watchlist items."); + setToast("Connect Trakt, Simkl, or MDBList in Settings to remove watchlist items."); return; } try { @@ -262,9 +262,9 @@ function DetailsView({ item }: { item: MediaItem }) { {continueLabel} {inWatchlist ? ( - + ) : ( - + )} {displayItem.trailerUrl && ( diff --git a/web/components/settings/SettingsScreen.tsx b/web/components/settings/SettingsScreen.tsx index 74e9d96a4..5704316ab 100644 --- a/web/components/settings/SettingsScreen.tsx +++ b/web/components/settings/SettingsScreen.tsx @@ -39,6 +39,7 @@ import { hasNetlifyBackendConfig, hasSupabaseConfig, hasTraktConfig, + hasSimklConfig, getAuthPortalUrl, } from "@/lib/config"; import { @@ -1152,17 +1153,24 @@ function AccountsSection() { auth, traktConnected, mdblistConnected, + simklConnected, deviceCode, + simklDeviceCode, signOut, beginTrakt, pollTrakt, disconnectTrakt, connectMdblist, disconnectMdblist, + beginSimkl, + pollSimkl, + disconnectSimkl, refreshData, } = useApp(); const [traktError, setTraktError] = useState(null); const [traktBusy, setTraktBusy] = useState<"start" | "poll" | null>(null); + const [simklError, setSimklError] = useState(null); + const [simklBusy, setSimklBusy] = useState<"start" | "poll" | null>(null); const [mdblistKey, setMdblistKey] = useState(""); const [mdblistError, setMdblistError] = useState(null); const [mdblistBusy, setMdblistBusy] = useState(false); @@ -1206,6 +1214,38 @@ function AccountsSection() { } }; + const startSimklLink = async () => { + setSimklBusy("start"); + setSimklError(null); + try { + await beginSimkl(); + } catch (error) { + setSimklError( + error instanceof Error + ? error.message + : "Could not start Simkl device link.", + ); + } finally { + setSimklBusy(null); + } + }; + + const approveSimklLink = async () => { + setSimklBusy("poll"); + setSimklError(null); + try { + await pollSimkl(); + } catch (error) { + setSimklError( + error instanceof Error + ? error.message + : "Simkl has not approved this device yet.", + ); + } finally { + setSimklBusy(null); + } + }; + const connectMdblistLink = async () => { setMdblistBusy(true); setMdblistError(null); @@ -1260,6 +1300,20 @@ function AccountsSection() { : "Missing config"} +
+ Simkl + + {simklConnected + ? "Connected" + : hasSimklConfig() + ? "Not linked" + : "Missing config"} + +
+
+ MDBList + {mdblistConnected ? "Connected" : "Not linked"} +
Sync {auth ? "Cloud saved" : "Local only"} @@ -1327,6 +1381,54 @@ function AccountsSection() { )} + + {!hasSimklConfig() && ( +

Simkl client configuration is missing.

+ )} + {simklError &&

{simklError}

} + {simklConnected ? ( + + ) : ( + <> + + {simklDeviceCode && ( +
+ {simklDeviceCode.user_code} +

+ Open{" "} + + {simklDeviceCode.verification_url || "https://simkl.com/pin"} + {" "} + and enter the code above +

+ +
+ )} + + )} +
+ {mdblistError &&

{mdblistError}

} {mdblistConnected ? ( diff --git a/web/components/shell/SyncStrip.tsx b/web/components/shell/SyncStrip.tsx index 898fba0b3..1e98c68c4 100644 --- a/web/components/shell/SyncStrip.tsx +++ b/web/components/shell/SyncStrip.tsx @@ -4,12 +4,13 @@ import { Cloud } from "lucide-react"; import { useApp } from "@/lib/store"; export function SyncStrip() { - const { busy, auth, traktConnected } = useApp(); + const { busy, auth, traktConnected, simklConnected, mdblistConnected } = useApp(); + const syncLabel = traktConnected ? "Trakt On" : simklConnected ? "Simkl On" : mdblistConnected ? "MDBList On" : "Sync Off"; return (
{busy || (auth ? "Cloud online" : "Cloud offline")} - {traktConnected ? "Trakt On" : "Trakt Off"} + {syncLabel}
); } diff --git a/web/components/watchlist/WatchlistScreen.tsx b/web/components/watchlist/WatchlistScreen.tsx index 95abc0e12..f6d848bdf 100644 --- a/web/components/watchlist/WatchlistScreen.tsx +++ b/web/components/watchlist/WatchlistScreen.tsx @@ -15,7 +15,7 @@ const BUILTIN_SOURCES = [ ] as const; export function WatchlistScreen() { - const { watchlist, traktConnected, openDetails, settings, loadTraktLists, loadTraktListItems } = useApp(); + const { watchlist, traktConnected, simklConnected, mdblistConnected, openDetails, settings, loadTraktLists, loadTraktListItems } = useApp(); const [sort, setSort] = useState("added"); const [filter, setFilter] = useState("all"); const [source, setSource] = useState("watchlist"); @@ -87,7 +87,15 @@ export function WatchlistScreen() {
-

{traktConnected ? "Synced with your Trakt account" : "Connect Trakt in Settings to sync"}

+

+ {traktConnected + ? "Synced with your Trakt account" + : simklConnected + ? "Synced with your Simkl account" + : mdblistConnected + ? "Synced with your MDBList account" + : "Connect Trakt, Simkl, or MDBList in Settings to sync"} +

Watchlist

diff --git a/web/lib/config.ts b/web/lib/config.ts index 0a63224b7..790d15578 100644 --- a/web/lib/config.ts +++ b/web/lib/config.ts @@ -10,6 +10,8 @@ export const config = { resolverUrl: envValue(process.env.NEXT_PUBLIC_ARVIO_RESOLVER_URL, ""), traktClientId: process.env.NEXT_PUBLIC_TRAKT_CLIENT_ID ?? "", traktClientSecret: envValue(process.env.NEXT_PUBLIC_TRAKT_CLIENT_SECRET, ""), + simklClientId: process.env.NEXT_PUBLIC_SIMKL_CLIENT_ID ?? process.env.SIMKL_CLIENT_ID ?? "", + simklClientSecret: envValue(process.env.NEXT_PUBLIC_SIMKL_CLIENT_SECRET, process.env.SIMKL_CLIENT_SECRET ?? ""), allowNetlifyMediaProxy: envValue(process.env.NEXT_PUBLIC_ALLOW_NETLIFY_MEDIA_PROXY, "false") === "true", // Web subscription: the Ko-fi membership page the paywall links to, and a // master switch to enable the paywall (off by default so nothing changes for @@ -37,6 +39,10 @@ export function hasTraktConfig() { return config.traktClientId.length > 10 && !config.traktClientId.startsWith("__"); } +export function hasSimklConfig() { + return hasNetlifyBackendConfig() || (config.simklClientId.length > 10 && !config.simklClientId.startsWith("__")); +} + export function getAuthPortalUrl(): string { const backend = config.netlifyBackendUrl; try { diff --git a/web/lib/simkl.ts b/web/lib/simkl.ts new file mode 100644 index 000000000..752823dbd --- /dev/null +++ b/web/lib/simkl.ts @@ -0,0 +1,223 @@ +import { SyncClient, SyncMediaRef } from "./sync"; +import { loadStored, removeStored, saveStored } from "./storage"; +import { jsonRequest } from "./http"; + +const SIMKL_TOKEN_KEY = "arvio.web.simkl.token"; + +export interface SimklToken { + access_token: string; +} + +export interface SimklPinCode { + user_code: string; + verification_url: string; + expires_in: number; + interval: number; +} + +function extractItems(res: unknown, key: "movies" | "shows" | "anime"): T[] { + if (!res) return []; + if (Array.isArray(res)) return res as T[]; + if (typeof res === "object" && res !== null && key in res) { + const list = (res as Record)[key]; + if (Array.isArray(list)) return list as T[]; + } + return []; +} + +export class SimklClient implements SyncClient { + token: SimklToken | null = loadStored(SIMKL_TOKEN_KEY, null); + + get isConnected(): boolean { + return Boolean(this.token?.access_token); + } + + setToken(token: SimklToken | null) { + this.token = token; + if (this.token) saveStored(SIMKL_TOKEN_KEY, this.token); + else removeStored(SIMKL_TOKEN_KEY); + } + + disconnect() { + this.setToken(null); + } + + private async simkl(path: string, options: RequestInit = {}): Promise { + const headers: Record = { + "content-type": "application/json", + ...(options.headers as Record) + }; + if (this.token?.access_token) { + headers["x-user-token"] = this.token.access_token; + } + return jsonRequest(`/api/simkl${path}`, { ...options, headers }); + } + + async beginPinAuth(): Promise { + return this.simkl("/oauth/pin"); + } + + async pollPinToken(userCode: string): Promise { + type PollRes = { result: string; access_token?: string; message?: string }; + const res = await this.simkl(`/oauth/pin/${userCode}`); + if (res.result === "OK" && res.access_token) { + this.setToken({ access_token: res.access_token }); + return true; + } + return false; + } + + /** + * Watchlist: fetch movies, shows, and anime concurrently, filter items with status "plantowatch", + * and map them to standard trakt/simkl item structures for UI consumption. + */ + async watchlist(): Promise { + if (!this.isConnected) return []; + try { + const [moviesRes, showsRes, animeRes] = await Promise.all([ + this.simkl("/sync/all-items/movies").catch(() => null), + this.simkl("/sync/all-items/shows").catch(() => null), + this.simkl("/sync/all-items/anime").catch(() => null) + ]); + + type SimklMovieRow = { movie?: { title?: string; year?: number; ids?: { tmdb?: number; simkl?: number; imdb?: string } }; status?: string; last_watched_at?: string }; + type SimklShowRow = { show?: { title?: string; year?: number; ids?: { tmdb?: number; simkl?: number; imdb?: string } }; status?: string; last_watched_at?: string }; + + const movies = extractItems(moviesRes, "movies") + .filter((item) => item.status === "plantowatch" && item.movie?.ids?.tmdb) + .map((item) => ({ + type: "movie", + movie: item.movie, + listed_at: item.last_watched_at + })); + + const shows = extractItems(showsRes, "shows") + .filter((item) => item.status === "plantowatch" && item.show?.ids?.tmdb) + .map((item) => ({ + type: "show", + show: item.show, + listed_at: item.last_watched_at + })); + + const anime = extractItems(animeRes, "anime") + .filter((item) => item.status === "plantowatch" && item.show?.ids?.tmdb) + .map((item) => ({ + type: "show", + show: item.show, + listed_at: item.last_watched_at + })); + + return [...movies, ...shows, ...anime]; + } catch { + return []; + } + } + + async playback(): Promise { + return []; + } + + /** + * Watched items: + * For movies -> /sync/all-items/movies (filtered by completed/watching/last_watched_at) + * For shows -> combines /sync/all-items/shows and /sync/all-items/anime (with granular episode records) + */ + async watched(type: "movies" | "shows"): Promise { + if (!this.isConnected) return []; + try { + if (type === "movies") { + const res = await this.simkl("/sync/all-items/movies").catch(() => null); + type SimklMovieRow = { movie?: { title?: string; year?: number; ids?: { tmdb?: number; simkl?: number; imdb?: string } }; status?: string; last_watched_at?: string }; + return extractItems(res, "movies").filter( + (item) => item.movie?.ids?.tmdb && (item.status === "completed" || item.status === "watching" || Boolean(item.last_watched_at)) + ); + } else { + const [showsRes, animeRes] = await Promise.all([ + this.simkl("/sync/all-items/shows").catch(() => null), + this.simkl("/sync/all-items/anime").catch(() => null) + ]); + type SimklShowRow = { show?: { title?: string; year?: number; ids?: { tmdb?: number; simkl?: number; imdb?: string } }; status?: string; last_watched_at?: string; seasons?: Array<{ number?: number; episodes?: Array<{ number?: number }> }> }; + const shows = extractItems(showsRes, "shows"); + const anime = extractItems(animeRes, "anime"); + return [...shows, ...anime]; + } + } catch { + return []; + } + } + + /** + * Add movie, show, or anime to Watchlist ("plantowatch") via /sync/add-to-list. + */ + async addToWatchlist(item: SyncMediaRef): Promise { + if (!this.isConnected) return; + const body = item.mediaType === "movie" + ? { movies: [{ to: "plantowatch", ids: { tmdb: item.tmdbId } }] } + : { shows: [{ to: "plantowatch", ids: { tmdb: item.tmdbId } }] }; + await this.simkl("/sync/add-to-list", { method: "POST", body: JSON.stringify(body) }); + } + + /** + * Remove item from watchlist. Uses /sync/history/remove for unwatched items. + */ + async removeFromWatchlist(item: SyncMediaRef): Promise { + if (!this.isConnected) return; + const body = item.mediaType === "movie" + ? { movies: [{ ids: { tmdb: item.tmdbId } }] } + : { shows: [{ ids: { tmdb: item.tmdbId } }] }; + await this.simkl("/sync/history/remove", { method: "POST", body: JSON.stringify(body) }); + } + + /** + * Mark movie, entire show, or specific episode as watched via /sync/history?allow_rewatch=yes. + */ + async addToHistory(item: SyncMediaRef): Promise { + if (!this.isConnected) return; + const hasEpisode = typeof item.season === "number" && typeof item.episode === "number"; + const body = item.mediaType === "movie" + ? { movies: [{ ids: { tmdb: item.tmdbId } }] } + : { + shows: [{ + ids: { tmdb: item.tmdbId }, + seasons: hasEpisode ? [{ number: item.season!, episodes: [{ number: item.episode! }] }] : undefined + }] + }; + await this.simkl("/sync/history?allow_rewatch=yes", { method: "POST", body: JSON.stringify(body) }); + } + + /** + * Mark movie, entire show, or specific episode as unwatched via /sync/history/remove. + */ + async removeFromHistory(item: SyncMediaRef): Promise { + if (!this.isConnected) return; + const hasEpisode = typeof item.season === "number" && typeof item.episode === "number"; + const body = item.mediaType === "movie" + ? { movies: [{ ids: { tmdb: item.tmdbId } }] } + : { + shows: [{ + ids: { tmdb: item.tmdbId }, + seasons: hasEpisode ? [{ number: item.season!, episodes: [{ number: item.episode! }] }] : undefined + }] + }; + await this.simkl("/sync/history/remove", { method: "POST", body: JSON.stringify(body) }); + } + + async dismissFromContinueWatching(): Promise { + // No-op for Simkl + } + + async scrobble(action: "start" | "pause" | "stop", item: SyncMediaRef & { progress: number }): Promise { + if (!this.isConnected) return; + const normProgress = item.progress <= 1.0 ? item.progress * 100 : item.progress; + const body = item.mediaType === "movie" + ? { movie: { ids: { tmdb: item.tmdbId } }, progress: normProgress } + : { + show: { ids: { tmdb: item.tmdbId } }, + episode: typeof item.episode === "number" ? { number: item.episode } : undefined, + progress: normProgress + }; + await this.simkl(`/scrobble/${action}`, { method: "POST", body: JSON.stringify(body) }); + } +} + +export const simklClient = new SimklClient(); diff --git a/web/lib/store.tsx b/web/lib/store.tsx index 12cd9961f..19d0dd8f8 100644 --- a/web/lib/store.tsx +++ b/web/lib/store.tsx @@ -17,6 +17,7 @@ import { loadStored, purgeLegacyStorage, removeStored, saveStored } from "./stor import { getDetails, loadCatalog, searchMedia } from "./tmdb"; import { TraktClient, type TraktDeviceCode } from "./trakt"; import { mdblistClient } from "./mdblist"; +import { simklClient, type SimklPinCode } from "./simkl"; import { activeSyncProvider, syncClient } from "./sync"; import type { AppSettings, @@ -202,20 +203,25 @@ function mediaWatchKey(item: MediaItem, seasonNumber?: number | null, episodeNum if (item.mediaType === "movie") return `movie:${item.id}`; const season = seasonNumber ?? item.seasonNumber ?? null; const episode = episodeNumber ?? item.episodeNumber ?? null; - if (season && episode) return `tv:${item.id}:${season}:${episode}`; + if (season !== null && episode !== null && season !== undefined && episode !== undefined) return `tv:${item.id}:${season}:${episode}`; return `tv:${item.id}`; } function traktWatchedKeys(movies: unknown[], shows: unknown[]) { const keys = new Set(); movies.forEach((raw) => { - const item = raw as { movie?: { ids?: { tmdb?: number } } }; + const item = raw as { movie?: { ids?: { tmdb?: number } }; status?: string; last_watched_at?: string }; const tmdb = item.movie?.ids?.tmdb; - if (tmdb) keys.add(`movie:${tmdb}`); + if (tmdb) { + if (item.status === undefined || item.status === "completed" || item.status === "watching" || Boolean(item.last_watched_at)) { + keys.add(`movie:${tmdb}`); + } + } }); shows.forEach((raw) => { const item = raw as { show?: { ids?: { tmdb?: number }; aired_episodes?: number }; + status?: string; seasons?: Array<{ number?: number; episodes?: Array<{ number?: number }> }>; }; const tmdb = item.show?.ids?.tmdb; @@ -226,15 +232,14 @@ function traktWatchedKeys(movies: unknown[], shows: unknown[]) { // Specials (season 0) don't count toward aired_episodes. if (seasonNumber === undefined || seasonNumber === null) return; season.episodes?.forEach((episode) => { - if (!episode.number) return; + if (episode.number === undefined || episode.number === null) return; keys.add(`tv:${tmdb}:${seasonNumber}:${episode.number}`); if (seasonNumber > 0) watchedEpisodes += 1; }); }); - // Trakt lists a show here after a single played episode; only badge the - // whole show as watched when every aired episode has been seen. + // If entire show marked completed, or all aired episodes watched, badge whole show const aired = item.show?.aired_episodes; - if (typeof aired === "number" && aired > 0 && watchedEpisodes >= aired) { + if (item.status === "completed" || (typeof aired === "number" && aired > 0 && watchedEpisodes >= aired)) { keys.add(`tv:${tmdb}`); } }); @@ -480,7 +485,9 @@ export interface AppStore { auth: AuthSession | null; traktConnected: boolean; mdblistConnected: boolean; + simklConnected: boolean; deviceCode: TraktDeviceCode | null; + simklDeviceCode: SimklPinCode | null; busy: string; toast: string | null; setToast: (value: string | null) => void; @@ -505,6 +512,9 @@ export interface AppStore { disconnectTrakt: () => void; connectMdblist: (key: string) => Promise; disconnectMdblist: () => void; + beginSimkl: () => Promise; + pollSimkl: () => Promise; + disconnectSimkl: () => void; // Watchlist list-source switcher (Trakt custom lists / collection). loadTraktLists: () => Promise>; loadTraktListItems: (source: string) => Promise; @@ -594,7 +604,9 @@ export function AppProvider({ const [auth, setAuth] = useState(() => authClient.session); const [traktConnected, setTraktConnected] = useState(() => traktClient.isConnected); const [mdblistConnected, setMdblistConnected] = useState(() => mdblistClient.isConnected); + const [simklConnected, setSimklConnected] = useState(() => simklClient.isConnected); const [deviceCode, setDeviceCode] = useState(null); + const [simklDeviceCode, setSimklDeviceCode] = useState(null); const [busy, setBusy] = useState("Loading ARVIO"); const [toast, setToast] = useState(null); const [cloudProfilesHydrated, setCloudProfilesHydrated] = useState(() => !authClient.session); @@ -657,6 +669,11 @@ export function AppProvider({ deviceCodeRef.current = deviceCode; }, [deviceCode]); + const simklDeviceCodeRef = useRef(simklDeviceCode); + useEffect(() => { + simklDeviceCodeRef.current = simklDeviceCode; + }, [simklDeviceCode]); + // Restore a saved Telegram (browser GramJS) session and prep the streaming // service worker so a connected user's sources resolve and play after a // reload — the browser equivalent of Android re-opening its TDLib database. @@ -1609,9 +1626,11 @@ export function AppProvider({ await traktClient.pollDeviceToken(code.device_code); setTraktConnected(true); setDeviceCode(null); - // Mutual exclusion: connecting Trakt drops MDBList for this profile. + // Mutual exclusion: connecting Trakt drops MDBList and Simkl for this profile. mdblistClient.disconnect(); setMdblistConnected(false); + simklClient.disconnect(); + setSimklConnected(false); // Persist the token to cloud so other devices (and future sessions) see the // connection — parity with the Android app's traktTokens payload. if (traktClient.token && activeProfileId) { @@ -1630,9 +1649,11 @@ export function AppProvider({ if (!ok) throw new Error("Invalid MDBList API key"); mdblistClient.setKey(key); setMdblistConnected(true); - // Mutual exclusion: connecting MDBList drops Trakt for this profile. + // Mutual exclusion: connecting MDBList drops Trakt and Simkl for this profile. traktClient.disconnect(); setTraktConnected(false); + simklClient.disconnect(); + setSimklConnected(false); await refreshData(); }, [refreshData]); @@ -1642,6 +1663,33 @@ export function AppProvider({ void refreshData(); }, [refreshData]); + const beginSimkl = useCallback(async () => { + setSimklDeviceCode(await simklClient.beginPinAuth()); + }, []); + + const pollSimkl = useCallback(async () => { + const code = simklDeviceCodeRef.current; + if (!code) return; + const ok = await simklClient.pollPinToken(code.user_code); + if (!ok) { + throw new Error("Simkl has not approved this PIN yet. Please approve the code on Simkl."); + } + setSimklConnected(true); + setSimklDeviceCode(null); + // Mutual exclusion: connecting Simkl drops Trakt & MDBList for this profile. + traktClient.disconnect(); + setTraktConnected(false); + mdblistClient.disconnect(); + setMdblistConnected(false); + await refreshData(); + }, [refreshData]); + + const disconnectSimkl = useCallback(() => { + simklClient.disconnect(); + setSimklConnected(false); + void refreshData(); + }, [refreshData]); + // Watchlist list-source switcher. Returns the user's custom Trakt lists to // populate the dropdown (built-in Watchlist/Collection are added by the UI). const loadTraktLists = useCallback(async (): Promise> => { @@ -1767,7 +1815,7 @@ export function AppProvider({ const toggleWatchlist = useCallback(async (item: MediaItem) => { const inWatchlist = watchlist.some((entry) => entry.mediaType === item.mediaType && entry.id === item.id); if (activeSyncProvider() === "none") { - setToast("Connect Trakt or MDBList in Settings to use Watchlist."); + setToast("Connect Trakt, Simkl, or MDBList in Settings to use Watchlist."); return; } const slim = slimCacheItem(item); @@ -1827,7 +1875,12 @@ export function AppProvider({ if (activeSyncProvider() !== "none") { try { - const ref = { mediaType: item.mediaType, tmdbId: item.id, season: seasonNumber, episode: episodeNumber }; + const ref = { + mediaType: item.mediaType, + tmdbId: item.id, + season: typeof seasonNumber === "number" ? seasonNumber : item.seasonNumber ?? undefined, + episode: typeof episodeNumber === "number" ? episodeNumber : item.episodeNumber ?? undefined + }; if (!currentlyWatched) { await syncClient().addToHistory(ref); } else { @@ -1918,7 +1971,9 @@ export function AppProvider({ auth, traktConnected, mdblistConnected, + simklConnected, deviceCode, + simklDeviceCode, busy, toast, setToast, @@ -1942,6 +1997,9 @@ export function AppProvider({ disconnectTrakt, connectMdblist, disconnectMdblist, + beginSimkl, + pollSimkl, + disconnectSimkl, loadTraktLists, loadTraktListItems, toggleWatchlist, @@ -1954,11 +2012,11 @@ export function AppProvider({ view, cloudLoginRequired, profiles, activeProfile, avatarImages, manageMode, selectProfile, createProfile, updateProfileAction, deleteProfileAction, switchProfile, goToLogin, backToProfiles, section, categories, catalogConfigs, loadCatalogRow, homeServerRows, continueWatching, watchlist, isWatched, hero, heroPreview, selected, streams, selectedEpisode, loadEpisodeStreams, advanceEpisode, activeStream, activeChannel, - addons, addonsReady, iptvSnapshot, query, results, settings, auth, traktConnected, mdblistConnected, deviceCode, busy, toast, + addons, addonsReady, iptvSnapshot, query, results, settings, auth, traktConnected, mdblistConnected, simklConnected, deviceCode, simklDeviceCode, busy, toast, updateSettings, refreshData, openDetails, closeDetails, playStream, playTrailer, playChannel, playCatchup, closePlayer, refreshIptv, loadIptvGuide, installAddon, removeAddon, setAddonsState, signIn, signOut, beginTrakt, pollTrakt, disconnectTrakt, - connectMdblist, disconnectMdblist, + connectMdblist, disconnectMdblist, beginSimkl, pollSimkl, disconnectSimkl, loadTraktLists, loadTraktListItems, toggleWatchlist, toggleWatched, removeFromContinueWatching, activeContextMenu, openContextMenu, closeContextMenu ]); diff --git a/web/lib/sync.ts b/web/lib/sync.ts index 530c016c1..959592b16 100644 --- a/web/lib/sync.ts +++ b/web/lib/sync.ts @@ -1,7 +1,8 @@ import { mdblistClient } from "./mdblist"; +import { simklClient } from "./simkl"; import { traktClient } from "./store"; -export type SyncProvider = "trakt" | "mdblist" | "none"; +export type SyncProvider = "trakt" | "mdblist" | "simkl" | "none"; export interface SyncMediaRef { mediaType: "movie" | "tv"; @@ -11,8 +12,7 @@ export interface SyncMediaRef { } /** - * The read/write surface shared by Trakt and MDBList. Both clients return reads - * in Trakt-compatible shapes, so the store's mappers work with either provider. + * The read/write surface shared by Trakt, MDBList, and Simkl. */ export interface SyncClient { readonly isConnected: boolean; @@ -27,14 +27,17 @@ export interface SyncClient { scrobble(action: "start" | "pause" | "stop", item: SyncMediaRef & { progress: number }): Promise; } -/** Which remote a profile is actively connected to (MDBList takes precedence). */ +/** Which remote a profile is actively connected to. */ export function activeSyncProvider(): SyncProvider { if (mdblistClient.isConnected) return "mdblist"; + if (simklClient.isConnected) return "simkl"; if (traktClient.isConnected) return "trakt"; return "none"; } -/** The active provider client, or the Trakt client when neither is connected. */ +/** The active provider client, or the Trakt client when none is connected. */ export function syncClient(): SyncClient { - return (mdblistClient.isConnected ? mdblistClient : traktClient) as unknown as SyncClient; + if (mdblistClient.isConnected) return mdblistClient as unknown as SyncClient; + if (simklClient.isConnected) return simklClient as unknown as SyncClient; + return traktClient as unknown as SyncClient; } diff --git a/web/public/version.json b/web/public/version.json index 81beef3a7..fee05f35e 100644 --- a/web/public/version.json +++ b/web/public/version.json @@ -1 +1 @@ -{"v":"1785220462255"} \ No newline at end of file +{"v":"1786530701001"} \ No newline at end of file