From dd2422f852784f10f3d6fbbe50f0ad0c40964588 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat, 15 Nov 2025 12:26:44 -0700 Subject: [PATCH 01/22] add custom headers to dev video test view --- src/pages/developer/VideoTesterView.tsx | 150 +++++++++++++++++++++++- 1 file changed, 146 insertions(+), 4 deletions(-) diff --git a/src/pages/developer/VideoTesterView.tsx b/src/pages/developer/VideoTesterView.tsx index a62968e4..b13fe826 100644 --- a/src/pages/developer/VideoTesterView.tsx +++ b/src/pages/developer/VideoTesterView.tsx @@ -1,14 +1,20 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { prepareStream } from "@/backend/extension/streams"; import { Button } from "@/components/buttons/Button"; +import { Toggle } from "@/components/buttons/Toggle"; import { Dropdown } from "@/components/form/Dropdown"; +import { Icon, Icons } from "@/components/Icon"; import { usePlayer } from "@/components/player/hooks/usePlayer"; import { Title } from "@/components/text/Title"; +import { AuthInputBox } from "@/components/text-inputs/AuthInputBox"; import { TextInputControl } from "@/components/text-inputs/TextInputControl"; +import { Divider } from "@/components/utils/Divider"; import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart"; import { PlayerPart } from "@/pages/parts/player/PlayerPart"; import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; import { SourceSliceSource, StreamType } from "@/stores/player/utils/qualities"; +import { type ExtensionStatus, getExtensionState } from "@/utils/extension"; const testMeta: PlayerMeta = { releaseYear: 2010, @@ -32,14 +38,64 @@ export default function VideoTesterView() { const { status, playMedia, setMeta } = usePlayer(); const [selected, setSelected] = useState("mp4"); const [inputSource, setInputSource] = useState(""); + const [extensionState, setExtensionState] = + useState("unknown"); + const [headersEnabled, setHeadersEnabled] = useState(false); + const [headers, setHeaders] = useState>( + [{ key: "", value: "" }], + ); + + // Check extension state on mount + useEffect(() => { + getExtensionState().then(setExtensionState); + }, []); + + // Header management functions + const addHeader = useCallback(() => { + setHeaders((prev) => [...prev, { key: "", value: "" }]); + }, []); + + const updateHeader = useCallback( + (index: number, field: "key" | "value", value: string) => { + setHeaders((prev) => + prev.map((header, i) => + i === index ? { ...header, [field]: value } : header, + ), + ); + }, + [], + ); + + const removeHeader = useCallback((index: number) => { + setHeaders((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const toggleHeaders = useCallback(() => { + const newEnabled = !headersEnabled; + setHeadersEnabled(newEnabled); + if (!newEnabled) { + setHeaders([{ key: "", value: "" }]); + } + }, [headersEnabled]); const start = useCallback( - (url: string, type: StreamType) => { + async (url: string, type: StreamType) => { + // Build headers object from enabled headers + const headersObj: Record = {}; + if (headersEnabled) { + headers.forEach(({ key, value }) => { + if (key.trim() && value.trim()) { + headersObj[key.trim()] = value.trim(); + } + }); + } + let source: SourceSliceSource; if (type === "hls") { source = { type: "hls", url, + ...(Object.keys(headersObj).length > 0 && { headers: headersObj }), }; } else if (type === "mp4") { source = { @@ -50,12 +106,38 @@ export default function VideoTesterView() { url, }, }, + ...(Object.keys(headersObj).length > 0 && { headers: headersObj }), }; } else throw new Error("Invalid type"); + + // Prepare stream headers if extension is active and headers are present + if (extensionState === "success" && Object.keys(headersObj).length > 0) { + // Create a mock Stream object for prepareStream + const mockStream: any = { + type: type === "hls" ? "hls" : "file", + ...(type === "hls" + ? { playlist: url } + : { + qualities: { + unknown: { + type: "mp4", + url, + }, + }, + }), + headers: headersObj, + }; + try { + await prepareStream(mockStream); + } catch (error) { + console.warn("Failed to prepare stream headers:", error); + } + } + setMeta(testMeta); playMedia(source, [], null); }, - [playMedia, setMeta], + [playMedia, setMeta, headersEnabled, headers, extensionState], ); return ( @@ -85,13 +167,73 @@ export default function VideoTesterView() { setSelectedItem={(item) => setSelected(item.id)} /> + + {extensionState === "success" && ( +
+
+
+

Headers

+
+
+ +
+
+ {headersEnabled && ( + <> + +
+ {headers.length === 0 ? ( +

No headers configured.

+ ) : ( + headers.map((header, index) => ( +
+ + updateHeader(index, "key", value) + } + placeholder="Key" + /> + + updateHeader(index, "value", value) + } + placeholder="Value" + /> + +
+ )) + )} +
+ + + + )} +
+ )} + -
Preset tests
From c907ef0c515a0f63eacc4d58df477cc82d721ec1 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat, 15 Nov 2025 12:31:36 -0700 Subject: [PATCH 02/22] Update traktApi.ts --- src/backend/metadata/traktApi.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/backend/metadata/traktApi.ts b/src/backend/metadata/traktApi.ts index 9c8b0142..c31de5e3 100644 --- a/src/backend/metadata/traktApi.ts +++ b/src/backend/metadata/traktApi.ts @@ -1,4 +1,5 @@ import { SimpleCache } from "@/utils/cache"; +import { getTurnstileToken } from "@/utils/turnstile"; import { getMediaDetails } from "./tmdb"; import { TMDBContentTypes, TMDBMovieData } from "./types/tmdb"; @@ -60,8 +61,15 @@ async function fetchFromTrakt( return cachedResult as T; } - // Make the API request - const response = await fetch(`${TRAKT_BASE_URL}${endpoint}`); + // Get Turnstile token for verification + const turnstileToken = await getTurnstileToken("0x4AAAAAAB6ocCCpurfWRZyC"); + + // Make the API request with Turnstile token + const response = await fetch(`${TRAKT_BASE_URL}${endpoint}`, { + headers: { + "cf-turnstile-response": turnstileToken, + }, + }); if (!response.ok) { throw new Error(`Failed to fetch from ${endpoint}: ${response.statusText}`); } @@ -91,8 +99,15 @@ export async function getReleaseDetails( return cachedResult as TraktReleaseResponse; } - // Make the API request - const response = await fetch(`${TRAKT_BASE_URL}${url}`); + // Get Turnstile token for verification + const turnstileToken = await getTurnstileToken("0x4AAAAAAB6ocCCpurfWRZyC"); + + // Make the API request with Turnstile token + const response = await fetch(`${TRAKT_BASE_URL}${url}`, { + headers: { + "cf-turnstile-response": turnstileToken, + }, + }); if (!response.ok) { throw new Error(`Failed to fetch release details: ${response.statusText}`); } From 796b3e85cc34debfe87e222eddfca1387ae7cd64 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat, 15 Nov 2025 12:41:06 -0700 Subject: [PATCH 03/22] Update VideoTesterView.tsx --- src/pages/developer/VideoTesterView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/developer/VideoTesterView.tsx b/src/pages/developer/VideoTesterView.tsx index b13fe826..ef218383 100644 --- a/src/pages/developer/VideoTesterView.tsx +++ b/src/pages/developer/VideoTesterView.tsx @@ -172,7 +172,7 @@ export default function VideoTesterView() {
-

Headers

+

Headers (Beta)

Date: Sat, 15 Nov 2025 22:10:08 -0700 Subject: [PATCH 04/22] Revert "Update traktApi.ts" This reverts commit c907ef0c515a0f63eacc4d58df477cc82d721ec1. --- src/backend/metadata/traktApi.ts | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/src/backend/metadata/traktApi.ts b/src/backend/metadata/traktApi.ts index c31de5e3..9c8b0142 100644 --- a/src/backend/metadata/traktApi.ts +++ b/src/backend/metadata/traktApi.ts @@ -1,5 +1,4 @@ import { SimpleCache } from "@/utils/cache"; -import { getTurnstileToken } from "@/utils/turnstile"; import { getMediaDetails } from "./tmdb"; import { TMDBContentTypes, TMDBMovieData } from "./types/tmdb"; @@ -61,15 +60,8 @@ async function fetchFromTrakt( return cachedResult as T; } - // Get Turnstile token for verification - const turnstileToken = await getTurnstileToken("0x4AAAAAAB6ocCCpurfWRZyC"); - - // Make the API request with Turnstile token - const response = await fetch(`${TRAKT_BASE_URL}${endpoint}`, { - headers: { - "cf-turnstile-response": turnstileToken, - }, - }); + // Make the API request + const response = await fetch(`${TRAKT_BASE_URL}${endpoint}`); if (!response.ok) { throw new Error(`Failed to fetch from ${endpoint}: ${response.statusText}`); } @@ -99,15 +91,8 @@ export async function getReleaseDetails( return cachedResult as TraktReleaseResponse; } - // Get Turnstile token for verification - const turnstileToken = await getTurnstileToken("0x4AAAAAAB6ocCCpurfWRZyC"); - - // Make the API request with Turnstile token - const response = await fetch(`${TRAKT_BASE_URL}${url}`, { - headers: { - "cf-turnstile-response": turnstileToken, - }, - }); + // Make the API request + const response = await fetch(`${TRAKT_BASE_URL}${url}`); if (!response.ok) { throw new Error(`Failed to fetch release details: ${response.statusText}`); } From f6e9f2be67182e28acb2e76027fc4ed6eb280c17 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sun, 16 Nov 2025 11:05:57 -0700 Subject: [PATCH 05/22] trakt stuff --- example.env | 1 + src/backend/metadata/traktApi.ts | 228 +++++++++++++++++++++++++++++-- src/setup/config.ts | 4 + 3 files changed, 220 insertions(+), 13 deletions(-) diff --git a/example.env b/example.env index 1f28ec6b..13ca18a1 100644 --- a/example.env +++ b/example.env @@ -1,5 +1,6 @@ VITE_TMDB_READ_API_KEY=... VITE_OPENSEARCH_ENABLED=false +VITE_ENABLE_TRAKT=false # make sure the cors proxy url does NOT have a slash at the end VITE_CORS_PROXY_URL=... diff --git a/src/backend/metadata/traktApi.ts b/src/backend/metadata/traktApi.ts index 9c8b0142..b0a61042 100644 --- a/src/backend/metadata/traktApi.ts +++ b/src/backend/metadata/traktApi.ts @@ -1,4 +1,6 @@ +import { conf } from "@/setup/config"; import { SimpleCache } from "@/utils/cache"; +import { getTurnstileToken } from "@/utils/turnstile"; import { getMediaDetails } from "./tmdb"; import { TMDBContentTypes, TMDBMovieData } from "./types/tmdb"; @@ -49,10 +51,129 @@ const traktCache = new SimpleCache(); traktCache.setCompare((a, b) => a.endpoint === b.endpoint); traktCache.initialize(); +// Authentication state - only track concurrent requests +let isAuthenticating = false; +let authToken: string | null = null; +let tokenExpiry: Date | null = null; + +/** + * Clears the authentication token + */ +function clearAuthToken(): void { + authToken = null; + tokenExpiry = null; + localStorage.removeItem("trakt_auth_token"); + localStorage.removeItem("trakt_token_expiry"); +} + +/** + * Stores the authentication token in memory and localStorage + */ +function storeAuthToken(token: string, expiresAt: string): void { + const expiryDate = new Date(expiresAt); + if (Number.isNaN(expiryDate.getTime())) { + console.error("Invalid expiry date format:", expiresAt); + return; + } + + authToken = token; + tokenExpiry = expiryDate; + + // Store in localStorage for persistence + localStorage.setItem("trakt_auth_token", token); + localStorage.setItem("trakt_token_expiry", expiresAt); +} + +/** + * Checks if user is authenticated by checking token validity + */ +function isAuthenticated(): boolean { + // Check memory first + if (authToken && tokenExpiry && tokenExpiry > new Date()) { + return true; + } + + // Check localStorage + const storedToken = localStorage.getItem("trakt_auth_token"); + const storedExpiry = localStorage.getItem("trakt_token_expiry"); + + if (storedToken && storedExpiry) { + const expiryDate = new Date(storedExpiry); + if (expiryDate > new Date()) { + authToken = storedToken; + tokenExpiry = expiryDate; + return true; + } + // Token expired, clear it + clearAuthToken(); + } + + return false; +} + +/** + * Authenticates with the Trakt API using Cloudflare Turnstile + * Stores the auth token for use in API requests + */ +async function authenticateWithTurnstile(): Promise { + // Prevent concurrent authentication attempts + if (isAuthenticating) { + // Wait for existing authentication to complete + await new Promise((resolve) => { + const checkAuth = () => { + if (!isAuthenticating) { + resolve(); + } else { + setTimeout(checkAuth, 100); + } + }; + checkAuth(); + }); + return; + } + + isAuthenticating = true; + + try { + const turnstileToken = await getTurnstileToken("0x4AAAAAAB6ocCCpurfWRZyC"); + + // Authenticate with the API + const response = await fetch(`${TRAKT_BASE_URL}/auth`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token: turnstileToken, + }), + }); + + if (!response.ok) { + throw new Error(`Authentication failed: ${response.statusText}`); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.message || "Authentication failed"); + } + + // Store the auth token + storeAuthToken(result.auth_token, result.expires_at); + } finally { + isAuthenticating = false; + } +} + // Base function to fetch from Trakt API async function fetchFromTrakt( endpoint: string, ): Promise { + // Check if Trakt is enabled + if (!conf().ENABLE_TRAKT) { + throw new Error("Trakt API is not enabled, using tmdb lists instead."); + } + // Check cache first const cacheKey: TraktCacheKey = { endpoint }; const cachedResult = traktCache.get(cacheKey); @@ -60,11 +181,49 @@ async function fetchFromTrakt( return cachedResult as T; } - // Make the API request - const response = await fetch(`${TRAKT_BASE_URL}${endpoint}`); - if (!response.ok) { - throw new Error(`Failed to fetch from ${endpoint}: ${response.statusText}`); + // Ensure we're authenticated + if (!isAuthenticated()) { + await authenticateWithTurnstile(); } + + // Make the API request with authorization header + const headers: Record = {}; + if (authToken) { + headers.Authorization = `Bearer ${authToken}`; + } + + let response = await fetch(`${TRAKT_BASE_URL}${endpoint}`, { + headers, + }); + + // If request fails, try re-authenticating and retry once + if (!response.ok) { + // If 401, clear token and re-authenticate + if (response.status === 401) { + clearAuthToken(); + } + + // Re-authenticate and retry + await authenticateWithTurnstile(); + + // Rebuild headers after re-authentication + const retryHeaders: Record = {}; + if (authToken) { + retryHeaders.Authorization = `Bearer ${authToken}`; + } + + response = await fetch(`${TRAKT_BASE_URL}${endpoint}`, { + headers: retryHeaders, + }); + + // If retry also fails, throw error + if (!response.ok) { + throw new Error( + `Failed to fetch from ${endpoint}: ${response.statusText}`, + ); + } + } + const result = await response.json(); // Cache the result for 1 hour (3600 seconds) @@ -84,6 +243,11 @@ export async function getReleaseDetails( url += `/${season}/${episode}`; } + // Check if Trakt is enabled + if (!conf().ENABLE_TRAKT) { + throw new Error("Trakt API is not enabled"); + } + // Check cache first const cacheKey: TraktCacheKey = { endpoint: url }; const cachedResult = traktCache.get(cacheKey); @@ -91,11 +255,49 @@ export async function getReleaseDetails( return cachedResult as TraktReleaseResponse; } - // Make the API request - const response = await fetch(`${TRAKT_BASE_URL}${url}`); - if (!response.ok) { - throw new Error(`Failed to fetch release details: ${response.statusText}`); + // Ensure we're authenticated + if (!isAuthenticated()) { + await authenticateWithTurnstile(); } + + // Make the API request with authorization header + const headers: Record = {}; + if (authToken) { + headers.Authorization = `Bearer ${authToken}`; + } + + let response = await fetch(`${TRAKT_BASE_URL}${url}`, { + headers, + }); + + // If request fails, try re-authenticating and retry once + if (!response.ok) { + // If 401, clear token and re-authenticate + if (response.status === 401) { + clearAuthToken(); + } + + // Re-authenticate and retry + await authenticateWithTurnstile(); + + // Rebuild headers after re-authentication + const retryHeaders: Record = {}; + if (authToken) { + retryHeaders.Authorization = `Bearer ${authToken}`; + } + + response = await fetch(`${TRAKT_BASE_URL}${url}`, { + headers: retryHeaders, + }); + + // If retry also fails, throw error + if (!response.ok) { + throw new Error( + `Failed to fetch release details: ${response.statusText}`, + ); + } + } + const result = await response.json(); // Cache the result for 1 hour (3600 seconds) @@ -200,17 +402,17 @@ export const getCuratedMovieLists = async (): Promise => { const lists: CuratedMovieList[] = []; - for (const config of listConfigs) { + for (const listConfig of listConfigs) { try { - const response = await fetchFromTrakt(config.endpoint); + const response = await fetchFromTrakt(listConfig.endpoint); lists.push({ - listName: config.name, - listSlug: config.slug, + listName: listConfig.name, + listSlug: listConfig.slug, tmdbIds: response.movie_tmdb_ids.slice(0, 30), // Limit to first 30 items count: Math.min(response.movie_tmdb_ids.length, 30), // Update count to reflect the limit }); } catch (error) { - console.error(`Failed to fetch ${config.name}:`, error); + console.error(`Failed to fetch ${listConfig.name}:`, error); } } diff --git a/src/setup/config.ts b/src/setup/config.ts index 42b00a58..f9fda765 100644 --- a/src/setup/config.ts +++ b/src/setup/config.ts @@ -19,6 +19,7 @@ interface Config { BACKEND_URL: string; DISALLOWED_IDS: string; TURNSTILE_KEY: string; + ENABLE_TRAKT: string; CDN_REPLACEMENTS: string; HAS_ONBOARDING: string; ONBOARDING_CHROME_EXTENSION_INSTALL_LINK: string; @@ -48,6 +49,7 @@ export interface RuntimeConfig { BACKEND_URL: string | null; DISALLOWED_IDS: string[]; TURNSTILE_KEY: string | null; + ENABLE_TRAKT: boolean; CDN_REPLACEMENTS: Array; HAS_ONBOARDING: boolean; ALLOW_AUTOPLAY: boolean; @@ -81,6 +83,7 @@ const env: Record = { BACKEND_URL: import.meta.env.VITE_BACKEND_URL, DISALLOWED_IDS: import.meta.env.VITE_DISALLOWED_IDS, TURNSTILE_KEY: import.meta.env.VITE_TURNSTILE_KEY, + ENABLE_TRAKT: import.meta.env.VITE_ENABLE_TRAKT, CDN_REPLACEMENTS: import.meta.env.VITE_CDN_REPLACEMENTS, HAS_ONBOARDING: import.meta.env.VITE_HAS_ONBOARDING, ALLOW_AUTOPLAY: import.meta.env.VITE_ALLOW_AUTOPLAY, @@ -142,6 +145,7 @@ export function conf(): RuntimeConfig { HAS_ONBOARDING: getKey("HAS_ONBOARDING", "false") === "true", ALLOW_AUTOPLAY: getKey("ALLOW_AUTOPLAY", "false") === "true", TURNSTILE_KEY: getKey("TURNSTILE_KEY"), + ENABLE_TRAKT: getKey("ENABLE_TRAKT", "false") === "true", DISALLOWED_IDS: getKey("DISALLOWED_IDS", "") .split(",") .map((v) => v.trim()) From 054612b9199215de45301890767c3146d34df1d9 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sun, 16 Nov 2025 12:48:28 -0700 Subject: [PATCH 06/22] Revert "trakt stuff" This reverts commit f6e9f2be67182e28acb2e76027fc4ed6eb280c17. --- example.env | 1 - src/backend/metadata/traktApi.ts | 224 ++----------------------------- src/setup/config.ts | 4 - 3 files changed, 11 insertions(+), 218 deletions(-) diff --git a/example.env b/example.env index 13ca18a1..1f28ec6b 100644 --- a/example.env +++ b/example.env @@ -1,6 +1,5 @@ VITE_TMDB_READ_API_KEY=... VITE_OPENSEARCH_ENABLED=false -VITE_ENABLE_TRAKT=false # make sure the cors proxy url does NOT have a slash at the end VITE_CORS_PROXY_URL=... diff --git a/src/backend/metadata/traktApi.ts b/src/backend/metadata/traktApi.ts index b0a61042..9c8b0142 100644 --- a/src/backend/metadata/traktApi.ts +++ b/src/backend/metadata/traktApi.ts @@ -1,6 +1,4 @@ -import { conf } from "@/setup/config"; import { SimpleCache } from "@/utils/cache"; -import { getTurnstileToken } from "@/utils/turnstile"; import { getMediaDetails } from "./tmdb"; import { TMDBContentTypes, TMDBMovieData } from "./types/tmdb"; @@ -51,129 +49,10 @@ const traktCache = new SimpleCache(); traktCache.setCompare((a, b) => a.endpoint === b.endpoint); traktCache.initialize(); -// Authentication state - only track concurrent requests -let isAuthenticating = false; -let authToken: string | null = null; -let tokenExpiry: Date | null = null; - -/** - * Clears the authentication token - */ -function clearAuthToken(): void { - authToken = null; - tokenExpiry = null; - localStorage.removeItem("trakt_auth_token"); - localStorage.removeItem("trakt_token_expiry"); -} - -/** - * Stores the authentication token in memory and localStorage - */ -function storeAuthToken(token: string, expiresAt: string): void { - const expiryDate = new Date(expiresAt); - if (Number.isNaN(expiryDate.getTime())) { - console.error("Invalid expiry date format:", expiresAt); - return; - } - - authToken = token; - tokenExpiry = expiryDate; - - // Store in localStorage for persistence - localStorage.setItem("trakt_auth_token", token); - localStorage.setItem("trakt_token_expiry", expiresAt); -} - -/** - * Checks if user is authenticated by checking token validity - */ -function isAuthenticated(): boolean { - // Check memory first - if (authToken && tokenExpiry && tokenExpiry > new Date()) { - return true; - } - - // Check localStorage - const storedToken = localStorage.getItem("trakt_auth_token"); - const storedExpiry = localStorage.getItem("trakt_token_expiry"); - - if (storedToken && storedExpiry) { - const expiryDate = new Date(storedExpiry); - if (expiryDate > new Date()) { - authToken = storedToken; - tokenExpiry = expiryDate; - return true; - } - // Token expired, clear it - clearAuthToken(); - } - - return false; -} - -/** - * Authenticates with the Trakt API using Cloudflare Turnstile - * Stores the auth token for use in API requests - */ -async function authenticateWithTurnstile(): Promise { - // Prevent concurrent authentication attempts - if (isAuthenticating) { - // Wait for existing authentication to complete - await new Promise((resolve) => { - const checkAuth = () => { - if (!isAuthenticating) { - resolve(); - } else { - setTimeout(checkAuth, 100); - } - }; - checkAuth(); - }); - return; - } - - isAuthenticating = true; - - try { - const turnstileToken = await getTurnstileToken("0x4AAAAAAB6ocCCpurfWRZyC"); - - // Authenticate with the API - const response = await fetch(`${TRAKT_BASE_URL}/auth`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - token: turnstileToken, - }), - }); - - if (!response.ok) { - throw new Error(`Authentication failed: ${response.statusText}`); - } - - const result = await response.json(); - - if (!result.success) { - throw new Error(result.message || "Authentication failed"); - } - - // Store the auth token - storeAuthToken(result.auth_token, result.expires_at); - } finally { - isAuthenticating = false; - } -} - // Base function to fetch from Trakt API async function fetchFromTrakt( endpoint: string, ): Promise { - // Check if Trakt is enabled - if (!conf().ENABLE_TRAKT) { - throw new Error("Trakt API is not enabled, using tmdb lists instead."); - } - // Check cache first const cacheKey: TraktCacheKey = { endpoint }; const cachedResult = traktCache.get(cacheKey); @@ -181,49 +60,11 @@ async function fetchFromTrakt( return cachedResult as T; } - // Ensure we're authenticated - if (!isAuthenticated()) { - await authenticateWithTurnstile(); - } - - // Make the API request with authorization header - const headers: Record = {}; - if (authToken) { - headers.Authorization = `Bearer ${authToken}`; - } - - let response = await fetch(`${TRAKT_BASE_URL}${endpoint}`, { - headers, - }); - - // If request fails, try re-authenticating and retry once + // Make the API request + const response = await fetch(`${TRAKT_BASE_URL}${endpoint}`); if (!response.ok) { - // If 401, clear token and re-authenticate - if (response.status === 401) { - clearAuthToken(); - } - - // Re-authenticate and retry - await authenticateWithTurnstile(); - - // Rebuild headers after re-authentication - const retryHeaders: Record = {}; - if (authToken) { - retryHeaders.Authorization = `Bearer ${authToken}`; - } - - response = await fetch(`${TRAKT_BASE_URL}${endpoint}`, { - headers: retryHeaders, - }); - - // If retry also fails, throw error - if (!response.ok) { - throw new Error( - `Failed to fetch from ${endpoint}: ${response.statusText}`, - ); - } + throw new Error(`Failed to fetch from ${endpoint}: ${response.statusText}`); } - const result = await response.json(); // Cache the result for 1 hour (3600 seconds) @@ -243,11 +84,6 @@ export async function getReleaseDetails( url += `/${season}/${episode}`; } - // Check if Trakt is enabled - if (!conf().ENABLE_TRAKT) { - throw new Error("Trakt API is not enabled"); - } - // Check cache first const cacheKey: TraktCacheKey = { endpoint: url }; const cachedResult = traktCache.get(cacheKey); @@ -255,49 +91,11 @@ export async function getReleaseDetails( return cachedResult as TraktReleaseResponse; } - // Ensure we're authenticated - if (!isAuthenticated()) { - await authenticateWithTurnstile(); - } - - // Make the API request with authorization header - const headers: Record = {}; - if (authToken) { - headers.Authorization = `Bearer ${authToken}`; - } - - let response = await fetch(`${TRAKT_BASE_URL}${url}`, { - headers, - }); - - // If request fails, try re-authenticating and retry once + // Make the API request + const response = await fetch(`${TRAKT_BASE_URL}${url}`); if (!response.ok) { - // If 401, clear token and re-authenticate - if (response.status === 401) { - clearAuthToken(); - } - - // Re-authenticate and retry - await authenticateWithTurnstile(); - - // Rebuild headers after re-authentication - const retryHeaders: Record = {}; - if (authToken) { - retryHeaders.Authorization = `Bearer ${authToken}`; - } - - response = await fetch(`${TRAKT_BASE_URL}${url}`, { - headers: retryHeaders, - }); - - // If retry also fails, throw error - if (!response.ok) { - throw new Error( - `Failed to fetch release details: ${response.statusText}`, - ); - } + throw new Error(`Failed to fetch release details: ${response.statusText}`); } - const result = await response.json(); // Cache the result for 1 hour (3600 seconds) @@ -402,17 +200,17 @@ export const getCuratedMovieLists = async (): Promise => { const lists: CuratedMovieList[] = []; - for (const listConfig of listConfigs) { + for (const config of listConfigs) { try { - const response = await fetchFromTrakt(listConfig.endpoint); + const response = await fetchFromTrakt(config.endpoint); lists.push({ - listName: listConfig.name, - listSlug: listConfig.slug, + listName: config.name, + listSlug: config.slug, tmdbIds: response.movie_tmdb_ids.slice(0, 30), // Limit to first 30 items count: Math.min(response.movie_tmdb_ids.length, 30), // Update count to reflect the limit }); } catch (error) { - console.error(`Failed to fetch ${listConfig.name}:`, error); + console.error(`Failed to fetch ${config.name}:`, error); } } diff --git a/src/setup/config.ts b/src/setup/config.ts index f9fda765..42b00a58 100644 --- a/src/setup/config.ts +++ b/src/setup/config.ts @@ -19,7 +19,6 @@ interface Config { BACKEND_URL: string; DISALLOWED_IDS: string; TURNSTILE_KEY: string; - ENABLE_TRAKT: string; CDN_REPLACEMENTS: string; HAS_ONBOARDING: string; ONBOARDING_CHROME_EXTENSION_INSTALL_LINK: string; @@ -49,7 +48,6 @@ export interface RuntimeConfig { BACKEND_URL: string | null; DISALLOWED_IDS: string[]; TURNSTILE_KEY: string | null; - ENABLE_TRAKT: boolean; CDN_REPLACEMENTS: Array; HAS_ONBOARDING: boolean; ALLOW_AUTOPLAY: boolean; @@ -83,7 +81,6 @@ const env: Record = { BACKEND_URL: import.meta.env.VITE_BACKEND_URL, DISALLOWED_IDS: import.meta.env.VITE_DISALLOWED_IDS, TURNSTILE_KEY: import.meta.env.VITE_TURNSTILE_KEY, - ENABLE_TRAKT: import.meta.env.VITE_ENABLE_TRAKT, CDN_REPLACEMENTS: import.meta.env.VITE_CDN_REPLACEMENTS, HAS_ONBOARDING: import.meta.env.VITE_HAS_ONBOARDING, ALLOW_AUTOPLAY: import.meta.env.VITE_ALLOW_AUTOPLAY, @@ -145,7 +142,6 @@ export function conf(): RuntimeConfig { HAS_ONBOARDING: getKey("HAS_ONBOARDING", "false") === "true", ALLOW_AUTOPLAY: getKey("ALLOW_AUTOPLAY", "false") === "true", TURNSTILE_KEY: getKey("TURNSTILE_KEY"), - ENABLE_TRAKT: getKey("ENABLE_TRAKT", "false") === "true", DISALLOWED_IDS: getKey("DISALLOWED_IDS", "") .split(",") .map((v) => v.trim()) From ff7a5f4947f94fc2bfcaba5d59ec4aa833006c65 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sun, 16 Nov 2025 13:10:16 -0700 Subject: [PATCH 07/22] update trakt api --- src/backend/metadata/traktApi.ts | 201 ++++++++++++++++++++++++++++--- src/setup/config.ts | 4 + 2 files changed, 187 insertions(+), 18 deletions(-) diff --git a/src/backend/metadata/traktApi.ts b/src/backend/metadata/traktApi.ts index 9c8b0142..a598f297 100644 --- a/src/backend/metadata/traktApi.ts +++ b/src/backend/metadata/traktApi.ts @@ -1,4 +1,6 @@ +import { conf } from "@/setup/config"; import { SimpleCache } from "@/utils/cache"; +import { getTurnstileToken } from "@/utils/turnstile"; import { getMediaDetails } from "./tmdb"; import { TMDBContentTypes, TMDBMovieData } from "./types/tmdb"; @@ -11,6 +13,79 @@ import type { export const TRAKT_BASE_URL = "https://fed-airdate.pstream.mov"; +// Token cookie configuration +const TOKEN_COOKIE_NAME = "turnstile_token"; +const TOKEN_CACHE_DURATION = 10 * 60 * 1000; // 10 minutes in milliseconds + +/** + * Get turnstile token from cookie or fetch new one + */ +const getFreshTurnstileToken = async (): Promise => { + const now = Date.now(); + + // Check if we have a valid cached token in cookie + if (typeof window !== "undefined") { + const cookies = document.cookie.split(";"); + const tokenCookie = cookies.find((cookie) => + cookie.trim().startsWith(`${TOKEN_COOKIE_NAME}=`), + ); + + if (tokenCookie) { + try { + const cookieValue = tokenCookie.split("=")[1]; + const cookieData = JSON.parse(decodeURIComponent(cookieValue)); + const { token, timestamp } = cookieData; + + // Check if token is still valid (within 10 minutes) + if (token && timestamp && now - timestamp < TOKEN_CACHE_DURATION) { + return token; + } + } catch (error) { + // Invalid cookie format, continue to get new token + console.warn("Invalid turnstile token cookie:", error); + } + } + } + + // Get new token from Cloudflare + try { + const token = await getTurnstileToken("0x4AAAAAAB6ocCCpurfWRZyC"); + + // Store token in cookie with expiration + if (typeof window !== "undefined") { + const expiresAt = new Date(now + TOKEN_CACHE_DURATION); + const cookieData = { + token, + timestamp: now, + }; + const cookieValue = encodeURIComponent(JSON.stringify(cookieData)); + + document.cookie = `${TOKEN_COOKIE_NAME}=${cookieValue}; expires=${expiresAt.toUTCString()}; path=/; SameSite=Strict`; + } + + return token; + } catch (error) { + throw new Error(`Failed to get turnstile token: ${error}`); + } +}; + +/** + * Validate turnstile token with server and store for 10 minutes within api. + */ +const validateAndStoreToken = async (token: string): Promise => { + const response = await fetch(`${TRAKT_BASE_URL}/auth`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ token }), + }); + + if (!response.ok) { + throw new Error(`Token validation failed: ${response.statusText}`); + } +}; + // Map provider names to their Trakt endpoints export const PROVIDER_TO_TRAKT_MAP = { "8": "netflixmovies", // Netflix Movies @@ -53,6 +128,10 @@ traktCache.initialize(); async function fetchFromTrakt( endpoint: string, ): Promise { + if (!conf().USE_TRAKT) { + return null as T; + } + // Check cache first const cacheKey: TraktCacheKey = { endpoint }; const cachedResult = traktCache.get(cacheKey); @@ -60,17 +139,58 @@ async function fetchFromTrakt( return cachedResult as T; } - // Make the API request - const response = await fetch(`${TRAKT_BASE_URL}${endpoint}`); - if (!response.ok) { - throw new Error(`Failed to fetch from ${endpoint}: ${response.statusText}`); + // Try up to 2 times: first with cached/fresh token, retry with forced fresh token if auth fails + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + // 1. Get turnstile token (cached or fresh) + const turnstileToken = await getFreshTurnstileToken(); + + // 2. Validate token with server and store for 10 minutes + await validateAndStoreToken(turnstileToken); + + // 3. Make the API request with validated token + const response = await fetch(`${TRAKT_BASE_URL}${endpoint}`, { + headers: { + "x-turnstile-token": turnstileToken, + }, + }); + + if (!response.ok) { + // If auth error on first attempt, clear cookie and retry with fresh token + if ( + (response.status === 401 || response.status === 403) && + attempt === 0 + ) { + // Clear the cookie to force fresh token on retry + if (typeof window !== "undefined") { + document.cookie = `${TOKEN_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`; + } + continue; // Try again + } + throw new Error( + `Failed to fetch from ${endpoint}: ${response.statusText}`, + ); + } + + const result = await response.json(); + + // Cache the result for 1 hour (3600 seconds) + traktCache.set(cacheKey, result, 3600); + + return result as T; + } catch (error) { + // If this was the second attempt or not an auth error, throw + if ( + attempt === 1 || + !(error instanceof Error && error.message.includes("401")) + ) { + throw error; + } + // Otherwise, continue to retry + } } - const result = await response.json(); - // Cache the result for 1 hour (3600 seconds) - traktCache.set(cacheKey, result, 3600); - - return result as T; + throw new Error(`Failed to fetch from ${endpoint} after retries`); } // Release details @@ -84,6 +204,10 @@ export async function getReleaseDetails( url += `/${season}/${episode}`; } + if (!conf().USE_TRAKT) { + return null as unknown as TraktReleaseResponse; + } + // Check cache first const cacheKey: TraktCacheKey = { endpoint: url }; const cachedResult = traktCache.get(cacheKey); @@ -91,17 +215,58 @@ export async function getReleaseDetails( return cachedResult as TraktReleaseResponse; } - // Make the API request - const response = await fetch(`${TRAKT_BASE_URL}${url}`); - if (!response.ok) { - throw new Error(`Failed to fetch release details: ${response.statusText}`); + // Try up to 2 times: first with cached/fresh token, retry with forced fresh token if auth fails + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + // 1. Get turnstile token (cached or fresh) + const turnstileToken = await getFreshTurnstileToken(); + + // 2. Validate token with server and store for 10 minutes + await validateAndStoreToken(turnstileToken); + + // 3. Make the API request with validated token + const response = await fetch(`${TRAKT_BASE_URL}${url}`, { + headers: { + "x-turnstile-token": turnstileToken, + }, + }); + + if (!response.ok) { + // If auth error on first attempt, clear cookie and retry with fresh token + if ( + (response.status === 401 || response.status === 403) && + attempt === 0 + ) { + // Clear the cookie to force fresh token on retry + if (typeof window !== "undefined") { + document.cookie = `${TOKEN_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`; + } + continue; // Try again + } + throw new Error( + `Failed to fetch release details: ${response.statusText}`, + ); + } + + const result = await response.json(); + + // Cache the result for 1 hour (3600 seconds) + traktCache.set(cacheKey, result, 3600); + + return result as TraktReleaseResponse; + } catch (error) { + // If this was the second attempt or not an auth error, throw + if ( + attempt === 1 || + !(error instanceof Error && error.message.includes("401")) + ) { + throw error; + } + // Otherwise, continue to retry + } } - const result = await response.json(); - // Cache the result for 1 hour (3600 seconds) - traktCache.set(cacheKey, result, 3600); - - return result as TraktReleaseResponse; + throw new Error(`Failed to fetch release details after retries`); } // Latest releases diff --git a/src/setup/config.ts b/src/setup/config.ts index 42b00a58..884d92b1 100644 --- a/src/setup/config.ts +++ b/src/setup/config.ts @@ -32,6 +32,7 @@ interface Config { TRACK_SCRIPT: string; // like BANNER_MESSAGE: string; BANNER_ID: string; + USE_TRAKT: boolean; } export interface RuntimeConfig { @@ -60,6 +61,7 @@ export interface RuntimeConfig { TRACK_SCRIPT: string | null; BANNER_MESSAGE: string | null; BANNER_ID: string | null; + USE_TRAKT: boolean; } const env: Record = { @@ -91,6 +93,7 @@ const env: Record = { TRACK_SCRIPT: import.meta.env.VITE_TRACK_SCRIPT, BANNER_MESSAGE: import.meta.env.VITE_BANNER_MESSAGE, BANNER_ID: import.meta.env.VITE_BANNER_ID, + USE_TRAKT: import.meta.env.VITE_USE_TRAKT, }; function coerceUndefined(value: string | null | undefined): string | undefined { @@ -165,5 +168,6 @@ export function conf(): RuntimeConfig { TRACK_SCRIPT: getKey("TRACK_SCRIPT"), BANNER_MESSAGE: getKey("BANNER_MESSAGE"), BANNER_ID: getKey("BANNER_ID"), + USE_TRAKT: getKey("USE_TRAKT", "false") === "true", }; } From f75db9cf99efa33a115ae0136d1ec8301a36ab97 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sun, 16 Nov 2025 13:31:31 -0700 Subject: [PATCH 08/22] prevent multiple requests --- src/backend/metadata/traktApi.ts | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/backend/metadata/traktApi.ts b/src/backend/metadata/traktApi.ts index a598f297..94f43dac 100644 --- a/src/backend/metadata/traktApi.ts +++ b/src/backend/metadata/traktApi.ts @@ -19,8 +19,12 @@ const TOKEN_CACHE_DURATION = 10 * 60 * 1000; // 10 minutes in milliseconds /** * Get turnstile token from cookie or fetch new one + * Returns an object indicating if the token was cached or freshly fetched */ -const getFreshTurnstileToken = async (): Promise => { +const getFreshTurnstileToken = async (): Promise<{ + token: string; + isCached: boolean; +}> => { const now = Date.now(); // Check if we have a valid cached token in cookie @@ -38,7 +42,7 @@ const getFreshTurnstileToken = async (): Promise => { // Check if token is still valid (within 10 minutes) if (token && timestamp && now - timestamp < TOKEN_CACHE_DURATION) { - return token; + return { token, isCached: true }; } } catch (error) { // Invalid cookie format, continue to get new token @@ -63,7 +67,7 @@ const getFreshTurnstileToken = async (): Promise => { document.cookie = `${TOKEN_COOKIE_NAME}=${cookieValue}; expires=${expiresAt.toUTCString()}; path=/; SameSite=Strict`; } - return token; + return { token, isCached: false }; } catch (error) { throw new Error(`Failed to get turnstile token: ${error}`); } @@ -143,10 +147,13 @@ async function fetchFromTrakt( for (let attempt = 0; attempt < 2; attempt += 1) { try { // 1. Get turnstile token (cached or fresh) - const turnstileToken = await getFreshTurnstileToken(); + const { token: turnstileToken, isCached } = + await getFreshTurnstileToken(); - // 2. Validate token with server and store for 10 minutes - await validateAndStoreToken(turnstileToken); + // 2. Only validate with server if token wasn't cached (newly fetched) + if (!isCached) { + await validateAndStoreToken(turnstileToken); + } // 3. Make the API request with validated token const response = await fetch(`${TRAKT_BASE_URL}${endpoint}`, { @@ -219,10 +226,13 @@ export async function getReleaseDetails( for (let attempt = 0; attempt < 2; attempt += 1) { try { // 1. Get turnstile token (cached or fresh) - const turnstileToken = await getFreshTurnstileToken(); + const { token: turnstileToken, isCached } = + await getFreshTurnstileToken(); - // 2. Validate token with server and store for 10 minutes - await validateAndStoreToken(turnstileToken); + // 2. Only validate with server if token wasn't cached (newly fetched) + if (!isCached) { + await validateAndStoreToken(turnstileToken); + } // 3. Make the API request with validated token const response = await fetch(`${TRAKT_BASE_URL}${url}`, { From 5842af7029a0ae1b7178efd1f9138671da88264f Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sun, 16 Nov 2025 13:41:37 -0700 Subject: [PATCH 09/22] remove /lifetime --- src/backend/metadata/traktApi.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/backend/metadata/traktApi.ts b/src/backend/metadata/traktApi.ts index 94f43dac..67a78048 100644 --- a/src/backend/metadata/traktApi.ts +++ b/src/backend/metadata/traktApi.ts @@ -315,7 +315,6 @@ export const getNetworkContent = (tmdbId: string) => // Curated movie lists export const getNarrativeMovies = () => fetchFromTrakt("/narrative"); export const getTopMovies = () => fetchFromTrakt("/top"); -export const getLifetimeMovies = () => fetchFromTrakt("/lifetime"); export const getNeverHeardMovies = () => fetchFromTrakt("/never"); export const getLGBTQContent = () => fetchFromTrakt("/LGBTQ"); export const getMindfuckMovies = () => fetchFromTrakt("/mindfuck"); @@ -341,11 +340,6 @@ export const getCuratedMovieLists = async (): Promise => { slug: "top", endpoint: "/top", }, - { - name: "1001 Movies You Must See Before You Die", - slug: "lifetime", - endpoint: "/lifetime", - }, { name: "Great Movies You May Have Never Heard Of", slug: "never", From 067b6e43bcbce62a1fe266aec9853b06fe48239b Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:49:20 -0700 Subject: [PATCH 10/22] Add nickname to user account settings Introduces a nickname field to user accounts, updates the backend user interface, and extends the settings UI to allow users to view and edit their nickname. Localization strings and state management have been updated accordingly to support this new field. --- src/assets/locales/en.json | 3 ++ src/backend/accounts/user.ts | 6 +-- src/hooks/auth/useAuthData.ts | 1 + src/hooks/useSettingsState.ts | 10 ++++ src/pages/Settings.tsx | 17 +++++++ src/pages/parts/settings/AccountEditPart.tsx | 50 +++++++++++++------- src/stores/auth/index.ts | 3 +- 7 files changed, 69 insertions(+), 21 deletions(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index f0a680a5..eb878262 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -947,6 +947,8 @@ }, "account": { "accountDetails": { + "nicknameLabel": "Nickname", + "nicknamePlaceholder": "Enter your nickname", "deviceNameLabel": "Device name", "deviceNamePlaceholder": "Personal phone", "editProfile": "Edit", @@ -1144,6 +1146,7 @@ "backendVersion": "Backend version", "hostname": "Hostname", "insecure": "Insecure", + "nickname": "Nickname", "notLoggedIn": "You are not logged in", "secure": "Secure", "title": "App stats", diff --git a/src/backend/accounts/user.ts b/src/backend/accounts/user.ts index 5ab9e76c..dd7de943 100644 --- a/src/backend/accounts/user.ts +++ b/src/backend/accounts/user.ts @@ -8,9 +8,8 @@ import { ProgressMediaItem } from "@/stores/progress"; export interface UserResponse { id: string; namespace: string; - name: string; - roles: string[]; - createdAt: string; + nickname: string; + permissions: string[]; profile: { colorA: string; colorB: string; @@ -24,6 +23,7 @@ export interface UserEdit { colorB: string; icon: string; }; + nickname?: string; } export interface BookmarkResponse { diff --git a/src/hooks/auth/useAuthData.ts b/src/hooks/auth/useAuthData.ts index 68cc2ac8..c9efcca5 100644 --- a/src/hooks/auth/useAuthData.ts +++ b/src/hooks/auth/useAuthData.ts @@ -101,6 +101,7 @@ export function useAuthData() { sessionId: loginResponse.session.id, deviceName: session.device, profile: user.profile, + nickname: user.nickname, seed, }; setAccount(account); diff --git a/src/hooks/useSettingsState.ts b/src/hooks/useSettingsState.ts index 48fc0c61..a07f3def 100644 --- a/src/hooks/useSettingsState.ts +++ b/src/hooks/useSettingsState.ts @@ -42,6 +42,7 @@ export function useSettingsState( appLanguage: string, subtitleStyling: SubtitleStyling, deviceName: string, + nickname: string, proxyUrls: string[] | null, backendUrl: string | null, febboxKey: string | null, @@ -110,6 +111,8 @@ export function useSettingsState( resetDeviceName, deviceNameChanged, ] = useDerived(deviceName); + const [nicknameState, setNicknameState, resetNickname, nicknameChanged] = + useDerived(nickname); const [profileState, setProfileState, resetProfile, profileChanged] = useDerived(profile); const [ @@ -263,6 +266,7 @@ export function useSettingsState( resetFebboxKey(); resetRealDebridKey(); resetDeviceName(); + resetNickname(); resetProfile(); resetEnableThumbnails(); resetEnableAutoplay(); @@ -295,6 +299,7 @@ export function useSettingsState( appLanguageChanged || subStylingChanged || deviceNameChanged || + nicknameChanged || backendUrlChanged || proxyUrlsChanged || febboxKeyChanged || @@ -348,6 +353,11 @@ export function useSettingsState( set: setDeviceNameState, changed: deviceNameChanged, }, + nickname: { + state: nicknameState, + set: setNicknameState, + changed: nicknameChanged, + }, proxyUrls: { state: proxyUrlsState, set: setProxyUrls, diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 2cda40cf..f01700cf 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -114,6 +114,8 @@ export function AccountSettings(props: { account: AccountWithToken; deviceName: string; setDeviceName: (s: string) => void; + nickname: string; + setNickname: (s: string) => void; colorA: string; setColorA: (s: string) => void; colorB: string; @@ -136,6 +138,8 @@ export function AccountSettings(props: { s.account); + const setAccount = useAuthStore((s) => s.setAccount); const updateProfile = useAuthStore((s) => s.setAccountProfile); const updateDeviceName = useAuthStore((s) => s.updateDeviceName); const decryptedName = useMemo(() => { @@ -513,6 +518,7 @@ export function SettingsPage() { appLanguage, subStyling, decryptedName, + account?.nickname || "", proxySet, backendUrlSetting, febboxKey, @@ -646,6 +652,14 @@ export function SettingsPage() { }); updateDeviceName(newDeviceName); } + if (state.nickname.changed) { + await editUser(backendUrl, account, { + nickname: state.nickname.state, + }); + // Update the account in the store + const updatedAccount = { ...account, nickname: state.nickname.state }; + setAccount(updatedAccount); + } if (state.profile.changed) { await editUser(backendUrl, account, { profile: state.profile.state, @@ -720,6 +734,7 @@ export function SettingsPage() { setProxySet, updateDeviceName, updateProfile, + setAccount, logout, setBackendUrl, setProxyTmdb, @@ -754,6 +769,8 @@ export function SettingsPage() { account={user.account} deviceName={state.deviceName.state} setDeviceName={state.deviceName.set} + nickname={state.nickname.state} + setNickname={state.nickname.set} colorA={state.profile.state.colorA} setColorA={(v) => { state.profile.set((s) => diff --git a/src/pages/parts/settings/AccountEditPart.tsx b/src/pages/parts/settings/AccountEditPart.tsx index 3b3bb16f..e989698e 100644 --- a/src/pages/parts/settings/AccountEditPart.tsx +++ b/src/pages/parts/settings/AccountEditPart.tsx @@ -13,6 +13,8 @@ import { ProfileEditModal } from "@/pages/parts/settings/ProfileEditModal"; export function AccountEditPart(props: { deviceName: string; setDeviceName: (s: string) => void; + nickname: string; + setNickname: (s: string) => void; colorA: string; setColorA: (s: string) => void; colorB: string; @@ -59,24 +61,38 @@ export function AccountEditPart(props: { />
-
- props.setDeviceName(value)} - /> -
- +
+
+ props.setNickname(value)} + className="w-full" + />
+
+ props.setDeviceName(value)} + className="w-full" + /> +
+
+
+
diff --git a/src/stores/auth/index.ts b/src/stores/auth/index.ts index 0a3c007a..7db2dd37 100644 --- a/src/stores/auth/index.ts +++ b/src/stores/auth/index.ts @@ -8,6 +8,7 @@ export interface Account { colorB: string; icon: string; }; + nickname: string; } export type AccountWithToken = Account & { @@ -25,7 +26,7 @@ interface AuthStore { removeAccount(): void; setAccount(acc: AccountWithToken): void; updateDeviceName(deviceName: string): void; - updateAccount(acc: Account): void; + updateAccount(acc: Partial): void; setAccountProfile(acc: Account["profile"]): void; setBackendUrl(url: null | string): void; setProxySet(urls: null | string[]): void; From 04a08af5e95d585fc5c6f65436468155c00a46f3 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Mon, 17 Nov 2025 13:05:57 -0700 Subject: [PATCH 11/22] fix account nickname not updating or displaying --- src/hooks/auth/useAuth.ts | 10 ++++++++++ src/pages/Settings.tsx | 11 +++++------ src/stores/auth/index.ts | 8 ++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts index 0bd48018..77c2fb86 100644 --- a/src/hooks/auth/useAuth.ts +++ b/src/hooks/auth/useAuth.ts @@ -188,6 +188,16 @@ export function useAuth() { getGroupOrder(backendUrl, account), ]); + // Update account store with fresh user data (including nickname) + const { setAccount } = useAuthStore.getState(); + if (account) { + setAccount({ + ...account, + nickname: user.user.nickname, + profile: user.user.profile, + }); + } + syncData( user.user, user.session, diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index f01700cf..e85d4364 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -485,9 +485,9 @@ export function SettingsPage() { ); const account = useAuthStore((s) => s.account); - const setAccount = useAuthStore((s) => s.setAccount); const updateProfile = useAuthStore((s) => s.setAccountProfile); const updateDeviceName = useAuthStore((s) => s.updateDeviceName); + const updateNickname = useAuthStore((s) => s.setAccountNickname); const decryptedName = useMemo(() => { if (!account) return ""; return decryptData(account.deviceName, base64ToBuffer(account.seed)); @@ -656,14 +656,13 @@ export function SettingsPage() { await editUser(backendUrl, account, { nickname: state.nickname.state, }); - // Update the account in the store - const updatedAccount = { ...account, nickname: state.nickname.state }; - setAccount(updatedAccount); + updateNickname(state.nickname.state); } - if (state.profile.changed) { + if (state.profile.changed && state.profile.state) { await editUser(backendUrl, account, { profile: state.profile.state, }); + updateProfile(state.profile.state); } } @@ -734,7 +733,7 @@ export function SettingsPage() { setProxySet, updateDeviceName, updateProfile, - setAccount, + updateNickname, logout, setBackendUrl, setProxyTmdb, diff --git a/src/stores/auth/index.ts b/src/stores/auth/index.ts index 7db2dd37..5555daaf 100644 --- a/src/stores/auth/index.ts +++ b/src/stores/auth/index.ts @@ -28,6 +28,7 @@ interface AuthStore { updateDeviceName(deviceName: string): void; updateAccount(acc: Partial): void; setAccountProfile(acc: Account["profile"]): void; + setAccountNickname(nickname: string): void; setBackendUrl(url: null | string): void; setProxySet(urls: null | string[]): void; } @@ -65,6 +66,13 @@ export const useAuthStore = create( } }); }, + setAccountNickname(nickname) { + set((s) => { + if (s.account) { + s.account.nickname = nickname; + } + }); + }, updateAccount(acc) { set((s) => { if (!s.account) return; From a76f25fceab90dce07d29449fbb7a60e6a6c3869 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Mon, 17 Nov 2025 13:08:58 -0700 Subject: [PATCH 12/22] make watchparty use new nickname instead of account id --- src/components/player/atoms/WatchPartyStatus.tsx | 12 +++++++++++- .../player/atoms/settings/WatchPartyView.tsx | 10 +++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/components/player/atoms/WatchPartyStatus.tsx b/src/components/player/atoms/WatchPartyStatus.tsx index 5f04ed68..edcb82c1 100644 --- a/src/components/player/atoms/WatchPartyStatus.tsx +++ b/src/components/player/atoms/WatchPartyStatus.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { Button } from "@/components/buttons/Button"; import { Icon, Icons } from "@/components/Icon"; import { useWatchPartySync } from "@/hooks/useWatchPartySync"; +import { useAuthStore } from "@/stores/auth"; import { getProgressPercentage } from "@/stores/progress"; import { useWatchPartyStore } from "@/stores/watchParty"; @@ -13,6 +14,7 @@ export function WatchPartyStatus() { const [expanded, setExpanded] = useState(false); const [showNotification, setShowNotification] = useState(false); const [lastUserCount, setLastUserCount] = useState(1); + const account = useAuthStore((s) => s.account); const { roomUsers, @@ -43,6 +45,14 @@ export function WatchPartyStatus() { setExpanded(!expanded); }; + // Get display name for a user (nickname if it's the current user, otherwise truncated userId) + const getDisplayName = (userId: string) => { + if (account?.userId === userId && account?.nickname) { + return account.nickname; + } + return `${userId.substring(0, 12)}...`; + }; + return (
- {user.userId.substring(0, 8)}... + {getDisplayName(user.userId)} diff --git a/src/components/player/atoms/settings/WatchPartyView.tsx b/src/components/player/atoms/settings/WatchPartyView.tsx index d10b0eab..78297eb4 100644 --- a/src/components/player/atoms/settings/WatchPartyView.tsx +++ b/src/components/player/atoms/settings/WatchPartyView.tsx @@ -34,6 +34,14 @@ export function WatchPartyView({ id }: { id: string }) { const [validationError, setValidationError] = useState(null); const account = useAuthStore((s) => s.account); + // Get display name for a user (nickname if it's the current user, otherwise truncated userId) + const getDisplayName = (userId: string) => { + if (account?.userId === userId && account?.nickname) { + return account.nickname; + } + return `${userId.substring(0, 8)}...`; + }; + const backendMeta = useAsync(async () => { if (!backendUrl) return; return getBackendMeta(backendUrl); @@ -322,7 +330,7 @@ export function WatchPartyView({ id }: { id: string }) { : "text-type-secondary" } > - {user.userId.substring(0, 8)}... + {getDisplayName(user.userId)} From c329118e50bbade04f89add14db5993888fa4bf6 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Mon, 17 Nov 2025 13:30:25 -0700 Subject: [PATCH 13/22] Handle device name decryption errors gracefully Added error handling for device name decryption in Avatar, LinksDropdown, Settings, and DeviceListPart components. If decryption fails, a fallback 'Unknown device' message is shown using a new translation key. This improves user experience by preventing crashes or blank fields when device name decryption fails. --- src/assets/locales/en.json | 1 + src/components/Avatar.tsx | 14 +++++++++++++- src/components/LinksDropdown.tsx | 12 +++++++++++- src/pages/Settings.tsx | 10 ++++++++-- src/pages/parts/settings/DeviceListPart.tsx | 19 ++++++++++++++----- 5 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index eb878262..d5d7e2bd 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -981,6 +981,7 @@ }, "devices": { "deviceNameLabel": "Device name", + "unknownDevice": "Unknown device, error decrypting name", "failed": "Failed to load sessions", "removeDevice": "Remove", "title": "Devices" diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index ec883def..7772abec 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -1,5 +1,6 @@ import classNames from "classnames"; import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { base64ToBuffer, decryptData } from "@/backend/accounts/crypto"; import { Icon, Icons } from "@/components/Icon"; @@ -55,11 +56,22 @@ export function UserAvatar(props: { : null, [auth], ); + const { t } = useTranslation(); if (!auth.account || auth.account === null) return null; const deviceName = bufferSeed - ? decryptData(auth.account.deviceName, bufferSeed) + ? (() => { + try { + return decryptData(auth.account.deviceName, bufferSeed); + } catch (error) { + console.warn( + "Failed to decrypt device name in Avatar, using fallback:", + error, + ); + return t("settings.account.devices.unknownDevice"); + } + })() : "..."; return ( diff --git a/src/components/LinksDropdown.tsx b/src/components/LinksDropdown.tsx index 4300d6fa..d4338b4b 100644 --- a/src/components/LinksDropdown.tsx +++ b/src/components/LinksDropdown.tsx @@ -257,7 +257,17 @@ export function LinksDropdown(props: { children: React.ReactNode }) { {deviceName && bufferSeed ? ( - {decryptData(deviceName, bufferSeed)} + {(() => { + try { + return decryptData(deviceName, bufferSeed); + } catch (error) { + console.warn( + "Failed to decrypt device name in LinksDropdown, using fallback:", + error, + ); + return t("settings.account.unknownDevice"); + } + })()} ) : ( diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index e85d4364..1b1987d5 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -490,8 +490,14 @@ export function SettingsPage() { const updateNickname = useAuthStore((s) => s.setAccountNickname); const decryptedName = useMemo(() => { if (!account) return ""; - return decryptData(account.deviceName, base64ToBuffer(account.seed)); - }, [account]); + try { + return decryptData(account.deviceName, base64ToBuffer(account.seed)); + } catch (error) { + console.warn("Failed to decrypt device name, using fallback:", error); + // Return a fallback device name if decryption fails + return t("settings.account.devices.unknownDevice"); + } + }, [account, t]); const backendUrl = useBackendUrl(); diff --git a/src/pages/parts/settings/DeviceListPart.tsx b/src/pages/parts/settings/DeviceListPart.tsx index 6f12d66e..1917ae69 100644 --- a/src/pages/parts/settings/DeviceListPart.tsx +++ b/src/pages/parts/settings/DeviceListPart.tsx @@ -75,7 +75,16 @@ export function DeviceListPart(props: { const deviceListSorted = useMemo(() => { if (!seed) return []; let list = sessions.map((session) => { - const decryptedName = decryptData(session.device, base64ToBuffer(seed)); + let decryptedName: string; + try { + decryptedName = decryptData(session.device, base64ToBuffer(seed)); + } catch (error) { + console.warn( + `Failed to decrypt device name for session ${session.id}:`, + error, + ); + decryptedName = t("settings.account.devices.unknownDevice"); + } return { current: session.id === currentSessionId, id: session.id, @@ -88,7 +97,7 @@ export function DeviceListPart(props: { return a.name.localeCompare(b.name); }); return list; - }, [seed, sessions, currentSessionId]); + }, [seed, sessions, currentSessionId, t]); if (!seed) return null; return ( @@ -96,10 +105,10 @@ export function DeviceListPart(props: { {t("settings.account.devices.title")} - {props.error ? ( -

{t("settings.account.devices.failed")}

- ) : props.loading ? ( + {props.loading ? ( + ) : props.error && deviceListSorted.length === 0 ? ( +

{t("settings.account.devices.failed")}

) : (
{deviceListSorted.map((session) => ( From 146d35aa11f5211f4576ea5a77c15884f6e4135d Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Tue, 18 Nov 2025 11:36:36 -0700 Subject: [PATCH 14/22] update providers --- pnpm-lock.yaml | 64 +++++++++++++++++++++++++------------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10127a31..092e6db3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,7 +43,7 @@ importers: version: 1.8.0 "@p-stream/providers": specifier: github:p-stream/providers#production - version: https://codeload.github.com/p-stream/providers/tar.gz/f8c0aa098c7a68e325c7af23b4175cf323c69957 + version: https://codeload.github.com/p-stream/providers/tar.gz/35199c5b8e88fe147b2e4d4d8a3289ffb9c6e06c "@plasmohq/messaging": specifier: ^0.6.2 version: 0.6.2(react@18.3.1) @@ -224,7 +224,7 @@ importers: version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) "@vitejs/plugin-react": specifier: ^4.7.0 - version: 4.7.0(vite@5.4.21(@types/node@20.19.23)(terser@5.44.0)) + version: 4.7.0(vite@5.4.21(@types/node@20.19.23)(terser@5.44.1)) autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.6) @@ -302,22 +302,22 @@ importers: version: 5.9.3 vite: specifier: ^5.4.21 - version: 5.4.21(@types/node@20.19.23)(terser@5.44.0) + version: 5.4.21(@types/node@20.19.23)(terser@5.44.1) vite-plugin-checker: specifier: ^0.6.4 - version: 0.6.4(eslint@8.57.1)(optionator@0.9.4)(typescript@5.9.3)(vite@5.4.21(@types/node@20.19.23)(terser@5.44.0)) + version: 0.6.4(eslint@8.57.1)(optionator@0.9.4)(typescript@5.9.3)(vite@5.4.21(@types/node@20.19.23)(terser@5.44.1)) vite-plugin-package-version: specifier: ^1.1.0 - version: 1.1.0(vite@5.4.21(@types/node@20.19.23)(terser@5.44.0)) + version: 1.1.0(vite@5.4.21(@types/node@20.19.23)(terser@5.44.1)) vite-plugin-pwa: specifier: ^0.17.5 - version: 0.17.5(vite@5.4.21(@types/node@20.19.23)(terser@5.44.0))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0) + version: 0.17.5(vite@5.4.21(@types/node@20.19.23)(terser@5.44.1))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0) vite-plugin-static-copy: specifier: ^3.1.4 - version: 3.1.4(vite@5.4.21(@types/node@20.19.23)(terser@5.44.0)) + version: 3.1.4(vite@5.4.21(@types/node@20.19.23)(terser@5.44.1)) vitest: specifier: ^1.6.1 - version: 1.6.1(@types/node@20.19.23)(jsdom@23.2.0)(terser@5.44.0) + version: 1.6.1(@types/node@20.19.23)(jsdom@23.2.0)(terser@5.44.1) workbox-window: specifier: ^7.3.0 version: 7.3.0 @@ -1709,10 +1709,10 @@ packages: } engines: { node: ">=12.4.0" } - "@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/f8c0aa098c7a68e325c7af23b4175cf323c69957": + "@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/35199c5b8e88fe147b2e4d4d8a3289ffb9c6e06c": resolution: { - tarball: https://codeload.github.com/p-stream/providers/tar.gz/f8c0aa098c7a68e325c7af23b4175cf323c69957, + tarball: https://codeload.github.com/p-stream/providers/tar.gz/35199c5b8e88fe147b2e4d4d8a3289ffb9c6e06c, } version: 3.2.0 @@ -6495,10 +6495,10 @@ packages: } engines: { node: ">=10" } - terser@5.44.0: + terser@5.44.1: resolution: { - integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==, + integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==, } engines: { node: ">=10" } hasBin: true @@ -8380,7 +8380,7 @@ snapshots: "@nolyfill/is-core-module@1.0.39": {} - "@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/f8c0aa098c7a68e325c7af23b4175cf323c69957": + "@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/35199c5b8e88fe147b2e4d4d8a3289ffb9c6e06c": dependencies: abort-controller: 3.0.0 cheerio: 1.0.0-rc.12 @@ -8473,7 +8473,7 @@ snapshots: dependencies: serialize-javascript: 6.0.2 smob: 1.5.0 - terser: 5.44.0 + terser: 5.44.1 optionalDependencies: rollup: 4.43.0 @@ -8858,7 +8858,7 @@ snapshots: "@unrs/resolver-binding-win32-x64-msvc@1.11.1": optional: true - "@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@20.19.23)(terser@5.44.0))": + "@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@20.19.23)(terser@5.44.1))": dependencies: "@babel/core": 7.28.5 "@babel/plugin-transform-react-jsx-self": 7.27.1(@babel/core@7.28.5) @@ -8866,7 +8866,7 @@ snapshots: "@rolldown/pluginutils": 1.0.0-beta.27 "@types/babel__core": 7.20.5 react-refresh: 0.17.0 - vite: 5.4.21(@types/node@20.19.23)(terser@5.44.0) + vite: 5.4.21(@types/node@20.19.23)(terser@5.44.1) transitivePeerDependencies: - supports-color @@ -11354,7 +11354,7 @@ snapshots: type-fest: 0.16.0 unique-string: 2.0.0 - terser@5.44.0: + terser@5.44.1: dependencies: "@jridgewell/source-map": 0.3.11 acorn: 8.15.0 @@ -11562,13 +11562,13 @@ snapshots: util-deprecate@1.0.2: {} - vite-node@1.6.1(@types/node@20.19.23)(terser@5.44.0): + vite-node@1.6.1(@types/node@20.19.23)(terser@5.44.1): dependencies: cac: 6.7.14 debug: 4.4.3 pathe: 1.1.2 picocolors: 1.1.1 - vite: 5.4.21(@types/node@20.19.23)(terser@5.44.0) + vite: 5.4.21(@types/node@20.19.23)(terser@5.44.1) transitivePeerDependencies: - "@types/node" - less @@ -11580,7 +11580,7 @@ snapshots: - supports-color - terser - vite-plugin-checker@0.6.4(eslint@8.57.1)(optionator@0.9.4)(typescript@5.9.3)(vite@5.4.21(@types/node@20.19.23)(terser@5.44.0)): + vite-plugin-checker@0.6.4(eslint@8.57.1)(optionator@0.9.4)(typescript@5.9.3)(vite@5.4.21(@types/node@20.19.23)(terser@5.44.1)): dependencies: "@babel/code-frame": 7.27.1 ansi-escapes: 4.3.2 @@ -11593,7 +11593,7 @@ snapshots: semver: 7.7.3 strip-ansi: 6.0.1 tiny-invariant: 1.3.3 - vite: 5.4.21(@types/node@20.19.23)(terser@5.44.0) + vite: 5.4.21(@types/node@20.19.23)(terser@5.44.1) vscode-languageclient: 7.0.0 vscode-languageserver: 7.0.0 vscode-languageserver-textdocument: 1.0.12 @@ -11603,30 +11603,30 @@ snapshots: optionator: 0.9.4 typescript: 5.9.3 - vite-plugin-package-version@1.1.0(vite@5.4.21(@types/node@20.19.23)(terser@5.44.0)): + vite-plugin-package-version@1.1.0(vite@5.4.21(@types/node@20.19.23)(terser@5.44.1)): dependencies: - vite: 5.4.21(@types/node@20.19.23)(terser@5.44.0) + vite: 5.4.21(@types/node@20.19.23)(terser@5.44.1) - vite-plugin-pwa@0.17.5(vite@5.4.21(@types/node@20.19.23)(terser@5.44.0))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0): + vite-plugin-pwa@0.17.5(vite@5.4.21(@types/node@20.19.23)(terser@5.44.1))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0): dependencies: debug: 4.4.3 fast-glob: 3.3.3 pretty-bytes: 6.1.1 - vite: 5.4.21(@types/node@20.19.23)(terser@5.44.0) + vite: 5.4.21(@types/node@20.19.23)(terser@5.44.1) workbox-build: 7.3.0(@types/babel__core@7.20.5) workbox-window: 7.3.0 transitivePeerDependencies: - supports-color - vite-plugin-static-copy@3.1.4(vite@5.4.21(@types/node@20.19.23)(terser@5.44.0)): + vite-plugin-static-copy@3.1.4(vite@5.4.21(@types/node@20.19.23)(terser@5.44.1)): dependencies: chokidar: 3.6.0 p-map: 7.0.3 picocolors: 1.1.1 tinyglobby: 0.2.15 - vite: 5.4.21(@types/node@20.19.23)(terser@5.44.0) + vite: 5.4.21(@types/node@20.19.23)(terser@5.44.1) - vite@5.4.21(@types/node@20.19.23)(terser@5.44.0): + vite@5.4.21(@types/node@20.19.23)(terser@5.44.1): dependencies: esbuild: 0.21.5 postcss: 8.5.6 @@ -11634,9 +11634,9 @@ snapshots: optionalDependencies: "@types/node": 20.19.23 fsevents: 2.3.3 - terser: 5.44.0 + terser: 5.44.1 - vitest@1.6.1(@types/node@20.19.23)(jsdom@23.2.0)(terser@5.44.0): + vitest@1.6.1(@types/node@20.19.23)(jsdom@23.2.0)(terser@5.44.1): dependencies: "@vitest/expect": 1.6.1 "@vitest/runner": 1.6.1 @@ -11655,8 +11655,8 @@ snapshots: strip-literal: 2.1.1 tinybench: 2.9.0 tinypool: 0.8.4 - vite: 5.4.21(@types/node@20.19.23)(terser@5.44.0) - vite-node: 1.6.1(@types/node@20.19.23)(terser@5.44.0) + vite: 5.4.21(@types/node@20.19.23)(terser@5.44.1) + vite-node: 1.6.1(@types/node@20.19.23)(terser@5.44.1) why-is-node-running: 2.3.0 optionalDependencies: "@types/node": 20.19.23 From a4f48b3e97d90e5f1df0ab95fed9d4e09037c920 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 19 Nov 2025 10:52:10 -0700 Subject: [PATCH 15/22] Update SetupPart.tsx --- src/pages/parts/settings/SetupPart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/parts/settings/SetupPart.tsx b/src/pages/parts/settings/SetupPart.tsx index 303af86c..c1ab7fc6 100644 --- a/src/pages/parts/settings/SetupPart.tsx +++ b/src/pages/parts/settings/SetupPart.tsx @@ -58,7 +58,7 @@ function testProxy(url: string) { } export async function testFebboxKey(febboxKey: string | null): Promise { - const febboxApiTestUrl = `https://fed-api.pstream.mov/movie/412059`; + const febboxApiTestUrl = `https://fed-api.pstream.mov/movie/tt0325980`; if (!febboxKey) { return "unset"; From 0a7816ff770c626ac444ce5b8854b6c1960168f6 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 19 Nov 2025 12:35:19 -0700 Subject: [PATCH 16/22] Update debrid key entry --- src/assets/locales/en.json | 16 +- src/backend/accounts/settings.ts | 6 +- src/hooks/auth/useAuthData.ts | 14 +- src/hooks/useSettingsState.ts | 36 ++-- src/pages/Settings.tsx | 36 ++-- src/pages/onboarding/Onboarding.tsx | 145 +--------------- src/pages/parts/auth/VerifyPassphrasePart.tsx | 3 +- src/pages/parts/settings/ConnectionsPart.tsx | 164 +++++++++--------- src/pages/parts/settings/SetupPart.tsx | 39 +++-- src/setup/config.ts | 8 +- src/stores/preferences/index.tsx | 18 +- 11 files changed, 209 insertions(+), 276 deletions(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index d5d7e2bd..eec36bc2 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -1267,14 +1267,18 @@ "invalid_token": "Failed to fetch a 'VIP' stream. Your token is invalid!" } }, - "realdebrid": { - "title": "Real Debrid (Beta)", - "description": "Enter your Real Debrid API key to access Real Debrid. Extension required.", + "debrid": { + "title": "Debrid (Beta)", + "description": "Enter your Debrid API key to access Debrid services. Requires a paid <0>Real-Debrid or <1>TorBox account!", "tokenLabel": "API Key", + "serviceOptions": { + "realdebrid": "Real Debrid", + "torbox": "TorBox" + }, "status": { - "failure": "Failed to connect to Real Debrid. Please check your API key.", - "api_down": "Real Debrid API is currently unavailable. Please try again later.", - "invalid_token": "Invalid API key or non-premium account. Real Debrid requires a premium account." + "failure": "Failed to connect to Debrid. Please check your API key.", + "api_down": "Hmm, something went wrong. Please try again later.", + "invalid_token": "Invalid API key or non-premium account. Debrid requires a premium account." } }, "watchParty": { diff --git a/src/backend/accounts/settings.ts b/src/backend/accounts/settings.ts index 14f9ca67..f0f69b33 100644 --- a/src/backend/accounts/settings.ts +++ b/src/backend/accounts/settings.ts @@ -9,7 +9,8 @@ export interface SettingsInput { defaultSubtitleLanguage?: string; proxyUrls?: string[] | null; febboxKey?: string | null; - realDebridKey?: string | null; + debridToken?: string | null; + debridService?: string; enableThumbnails?: boolean; enableAutoplay?: boolean; enableSkipCredits?: boolean; @@ -42,7 +43,8 @@ export interface SettingsResponse { defaultSubtitleLanguage?: string | null; proxyUrls?: string[] | null; febboxKey?: string | null; - realDebridKey?: string | null; + debridToken?: string | null; + debridService?: string; enableThumbnails?: boolean; enableAutoplay?: boolean; enableSkipCredits?: boolean; diff --git a/src/hooks/auth/useAuthData.ts b/src/hooks/auth/useAuthData.ts index c9efcca5..e7e8b215 100644 --- a/src/hooks/auth/useAuthData.ts +++ b/src/hooks/auth/useAuthData.ts @@ -32,7 +32,8 @@ export function useAuthData() { (s) => s.importSubtitleLanguage, ); const setFebboxKey = usePreferencesStore((s) => s.setFebboxKey); - const setRealDebridKey = usePreferencesStore((s) => s.setRealDebridKey); + const setdebridToken = usePreferencesStore((s) => s.setdebridToken); + const setdebridService = usePreferencesStore((s) => s.setdebridService); const replaceBookmarks = useBookmarkStore((s) => s.replaceBookmarks); const replaceItems = useProgressStore((s) => s.replaceItems); @@ -232,8 +233,12 @@ export function useAuthData() { setFebboxKey(settings.febboxKey); } - if (settings.realDebridKey !== undefined) { - setRealDebridKey(settings.realDebridKey); + if (settings.debridToken !== undefined) { + setdebridToken(settings.debridToken); + } + + if (settings.debridService !== undefined) { + setdebridService(settings.debridService); } if (settings.enableLowPerformanceMode !== undefined) { @@ -288,7 +293,8 @@ export function useAuthData() { setDisabledEmbeds, setProxyTmdb, setFebboxKey, - setRealDebridKey, + setdebridToken, + setdebridService, setEnableLowPerformanceMode, setEnableNativeSubtitles, setEnableHoldToBoost, diff --git a/src/hooks/useSettingsState.ts b/src/hooks/useSettingsState.ts index a07f3def..d65e363d 100644 --- a/src/hooks/useSettingsState.ts +++ b/src/hooks/useSettingsState.ts @@ -46,7 +46,8 @@ export function useSettingsState( proxyUrls: string[] | null, backendUrl: string | null, febboxKey: string | null, - realDebridKey: string | null, + debridToken: string | null, + debridService: string, profile: | { colorA: string; @@ -86,11 +87,17 @@ export function useSettingsState( const [febboxKeyState, setFebboxKey, resetFebboxKey, febboxKeyChanged] = useDerived(febboxKey); const [ - realDebridKeyState, - setRealDebridKey, - resetRealDebridKey, - realDebridKeyChanged, - ] = useDerived(realDebridKey); + debridTokenState, + setdebridToken, + resetdebridToken, + debridTokenChanged, + ] = useDerived(debridToken); + const [ + debridServiceState, + setdebridService, + _resetdebridService, + debridServiceChanged, + ] = useDerived(debridService); const [themeState, setTheme, resetTheme, themeChanged] = useDerived(theme); const setPreviewTheme = usePreviewThemeStore((s) => s.setPreviewTheme); const resetPreviewTheme = useCallback( @@ -264,7 +271,7 @@ export function useSettingsState( resetProxyUrls(); resetBackendUrl(); resetFebboxKey(); - resetRealDebridKey(); + resetdebridToken(); resetDeviceName(); resetNickname(); resetProfile(); @@ -303,7 +310,7 @@ export function useSettingsState( backendUrlChanged || proxyUrlsChanged || febboxKeyChanged || - realDebridKeyChanged || + debridTokenChanged || profileChanged || enableThumbnailsChanged || enableAutoplayChanged || @@ -373,10 +380,15 @@ export function useSettingsState( set: setFebboxKey, changed: febboxKeyChanged, }, - realDebridKey: { - state: realDebridKeyState, - set: setRealDebridKey, - changed: realDebridKeyChanged, + debridToken: { + state: debridTokenState, + set: setdebridToken, + changed: debridTokenChanged, + }, + debridService: { + state: debridServiceState, + set: setdebridService, + changed: debridServiceChanged, }, profile: { state: profileState, diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 1b1987d5..764c9754 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -369,8 +369,10 @@ export function SettingsPage() { const febboxKey = usePreferencesStore((s) => s.febboxKey); const setFebboxKey = usePreferencesStore((s) => s.setFebboxKey); - const realDebridKey = usePreferencesStore((s) => s.realDebridKey); - const setRealDebridKey = usePreferencesStore((s) => s.setRealDebridKey); + const debridToken = usePreferencesStore((s) => s.debridToken); + const setdebridToken = usePreferencesStore((s) => s.setdebridToken); + const debridService = usePreferencesStore((s) => s.debridService); + const setdebridService = usePreferencesStore((s) => s.setdebridService); const enableThumbnails = usePreferencesStore((s) => s.enableThumbnails); const setEnableThumbnails = usePreferencesStore((s) => s.setEnableThumbnails); @@ -511,13 +513,16 @@ export function SettingsPage() { if (settings.febboxKey) { setFebboxKey(settings.febboxKey); } - if (settings.realDebridKey) { - setRealDebridKey(settings.realDebridKey); + if (settings.debridToken) { + setdebridToken(settings.debridToken); + } + if (settings.debridService) { + setdebridService(settings.debridService); } } }; loadSettings(); - }, [account, backendUrl, setFebboxKey, setRealDebridKey]); + }, [account, backendUrl, setFebboxKey, setdebridToken, setdebridService]); const state = useSettingsState( activeTheme, @@ -528,7 +533,8 @@ export function SettingsPage() { proxySet, backendUrlSetting, febboxKey, - realDebridKey, + debridToken, + debridService, account ? account.profile : undefined, enableThumbnails, enableAutoplay, @@ -598,7 +604,8 @@ export function SettingsPage() { state.theme.changed || state.proxyUrls.changed || state.febboxKey.changed || - state.realDebridKey.changed || + state.debridToken.changed || + state.debridService.changed || state.enableThumbnails.changed || state.enableAutoplay.changed || state.enableSkipCredits.changed || @@ -625,7 +632,8 @@ export function SettingsPage() { applicationTheme: state.theme.state, proxyUrls: state.proxyUrls.state?.filter((v) => v !== "") ?? null, febboxKey: state.febboxKey.state, - realDebridKey: state.realDebridKey.state, + debridToken: state.debridToken.state, + debridService: state.debridService.state, enableThumbnails: state.enableThumbnails.state, enableAutoplay: state.enableAutoplay.state, enableSkipCredits: state.enableSkipCredits.state, @@ -690,7 +698,8 @@ export function SettingsPage() { setProxySet(state.proxyUrls.state?.filter((v) => v !== "") ?? null); setEnableSourceOrder(state.enableSourceOrder.state); setFebboxKey(state.febboxKey.state); - setRealDebridKey(state.realDebridKey.state); + setdebridToken(state.debridToken.state); + setdebridService(state.debridService.state); setProxyTmdb(state.proxyTmdb.state); setEnableCarouselView(state.enableCarouselView.state); setForceCompactEpisodeView(state.forceCompactEpisodeView.state); @@ -720,7 +729,8 @@ export function SettingsPage() { backendUrl, setEnableThumbnails, setFebboxKey, - setRealDebridKey, + setdebridToken, + setdebridService, state, setEnableAutoplay, setEnableSkipCredits, @@ -881,8 +891,10 @@ export function SettingsPage() { setProxyUrls={state.proxyUrls.set} febboxKey={state.febboxKey.state} setFebboxKey={state.febboxKey.set} - realDebridKey={state.realDebridKey.state} - setRealDebridKey={state.realDebridKey.set} + debridToken={state.debridToken.state} + setdebridToken={state.debridToken.set} + debridService={state.debridService.state} + setdebridService={state.debridService.set} proxyTmdb={state.proxyTmdb.state} setProxyTmdb={state.proxyTmdb.set} /> diff --git a/src/pages/onboarding/Onboarding.tsx b/src/pages/onboarding/Onboarding.tsx index b45eab04..5558060c 100644 --- a/src/pages/onboarding/Onboarding.tsx +++ b/src/pages/onboarding/Onboarding.tsx @@ -1,10 +1,7 @@ -import { useEffect, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Button } from "@/components/buttons/Button"; -import { Toggle } from "@/components/buttons/Toggle"; import { Icon, Icons } from "@/components/Icon"; -import { SettingsCard } from "@/components/layout/SettingsCard"; import { Stepper } from "@/components/layout/Stepper"; import { BiggerCenterContainer } from "@/components/layout/ThinContainer"; import { VerticalLine } from "@/components/layout/VerticalLine"; @@ -14,11 +11,6 @@ import { ModalCard, useModal, } from "@/components/overlays/Modal"; -import { - StatusCircle, - StatusCircleProps, -} from "@/components/player/internals/StatusCircle"; -import { AuthInputBox } from "@/components/text-inputs/AuthInputBox"; import { Divider } from "@/components/utils/Divider"; import { Ol } from "@/components/utils/Ol"; import { @@ -43,133 +35,7 @@ import { conf } from "@/setup/config"; import { usePreferencesStore } from "@/stores/preferences"; import { getProxyUrls } from "@/utils/proxyUrls"; -import { FebboxSetup } from "../parts/settings/ConnectionsPart"; -import { Status, testRealDebridKey } from "../parts/settings/SetupPart"; - -async function getRealDebridKeyStatus(realDebridKey: string | null) { - if (realDebridKey) { - const status: Status = await testRealDebridKey(realDebridKey); - return status; - } - return "unset"; -} - -export function RealDebridSetup() { - const { t } = useTranslation(); - const realDebridKey = usePreferencesStore((s) => s.realDebridKey); - const setRealDebridKey = usePreferencesStore((s) => s.setRealDebridKey); - - // Initialize isExpanded based on whether realDebridKey has a value - const [isExpanded, setIsExpanded] = useState( - realDebridKey !== null && realDebridKey !== "", - ); - - // Add a separate effect to set the initial state - useEffect(() => { - // If we have a valid key, make sure the section is expanded - if (realDebridKey && realDebridKey.length > 0) { - setIsExpanded(true); - } - }, [realDebridKey]); - - const [status, setStatus] = useState("unset"); - const statusMap: Record = { - error: "error", - success: "success", - unset: "noresult", - api_down: "error", - invalid_token: "error", - }; - - useEffect(() => { - const checkTokenStatus = async () => { - const result = await getRealDebridKeyStatus(realDebridKey); - setStatus(result); - }; - checkTokenStatus(); - }, [realDebridKey]); - - // Toggle handler that preserves the key - const toggleExpanded = () => { - if (isExpanded) { - // Store the key temporarily instead of setting to null - setRealDebridKey(""); - setIsExpanded(false); - } else { - setIsExpanded(true); - } - }; - - if (conf().ALLOW_REAL_DEBRID_KEY) { - return ( -
- -
-
-

- {t("settings.connections.realdebrid.title", "Real Debrid API")} -

-

- {t( - "settings.connections.realdebrid.description", - "Enter your Real Debrid API key to access premium sources.", - )} -

-
-
- -
-
- {isExpanded ? ( - <> - -

- {t("settings.connections.realdebrid.tokenLabel", "API Key")} -

-
- - { - setRealDebridKey(newToken); - }} - value={realDebridKey ?? ""} - placeholder="API Key" - passwordToggleable - className="flex-grow" - /> -
- {status === "error" && ( -

- {t( - "settings.connections.realdebrid.status.failure", - "Failed to connect to Real Debrid. Please check your API key.", - )} -

- )} - {status === "api_down" && ( -

- {t( - "settings.connections.realdebrid.status.api_down", - "Real Debrid API is currently unavailable. Please try again later.", - )} -

- )} - {status === "invalid_token" && ( -

- {t( - "settings.connections.realdebrid.status.invalid_token", - "Invalid API key or non-premium account. Real Debrid requires a premium account.", - )} -

- )} - - ) : null} -
-
- ); - } - return null; -} +import { DebridEdit, FebboxSetup } from "../parts/settings/ConnectionsPart"; function Item(props: { title: string; children: React.ReactNode }) { return ( @@ -419,7 +285,6 @@ export function OnboardingPage() { )}
- {/* */}
s.febboxKey)} @@ -427,6 +292,14 @@ export function OnboardingPage() { mode="onboarding" />
+
+ s.debridToken)} + setdebridToken={usePreferencesStore((s) => s.setdebridToken)} + debridService={usePreferencesStore((s) => s.debridService)} + setdebridService={usePreferencesStore((s) => s.setdebridService)} + /> +
); diff --git a/src/pages/parts/auth/VerifyPassphrasePart.tsx b/src/pages/parts/auth/VerifyPassphrasePart.tsx index 8490394b..fad3d7a0 100644 --- a/src/pages/parts/auth/VerifyPassphrasePart.tsx +++ b/src/pages/parts/auth/VerifyPassphrasePart.tsx @@ -59,7 +59,8 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) { disabledEmbeds: store.disabledEmbeds, proxyTmdb: store.proxyTmdb, febboxKey: store.febboxKey, - realDebridKey: store.realDebridKey, + debridToken: store.debridToken, + debridService: store.debridService, enableLowPerformanceMode: store.enableLowPerformanceMode, enableNativeSubtitles: store.enableNativeSubtitles, enableHoldToBoost: store.enableHoldToBoost, diff --git a/src/pages/parts/settings/ConnectionsPart.tsx b/src/pages/parts/settings/ConnectionsPart.tsx index 544706e0..fc3ae9f0 100644 --- a/src/pages/parts/settings/ConnectionsPart.tsx +++ b/src/pages/parts/settings/ConnectionsPart.tsx @@ -9,6 +9,7 @@ import { Trans, useTranslation } from "react-i18next"; import { Button } from "@/components/buttons/Button"; import { Toggle } from "@/components/buttons/Toggle"; +import { Dropdown } from "@/components/form/Dropdown"; import { Icon, Icons } from "@/components/Icon"; import { SettingsCard } from "@/components/layout/SettingsCard"; import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; @@ -24,7 +25,7 @@ import { SetupPart, Status, testFebboxKey, - testRealDebridKey, + testdebridToken, } from "@/pages/parts/settings/SetupPart"; import { conf } from "@/setup/config"; import { useAuthStore } from "@/stores/auth"; @@ -49,9 +50,11 @@ interface FebboxKeyProps { setFebboxKey: (value: string | null) => void; } -interface RealDebridKeyProps { - realDebridKey: string | null; - setRealDebridKey: Dispatch>; +interface DebridProps { + debridToken: string | null; + setdebridToken: (value: string | null) => void; + debridService: string; + setdebridService: (value: string) => void; } function ProxyEdit({ @@ -460,33 +463,30 @@ export function FebboxSetup({ } } -async function getRealDebridKeyStatus(realDebridKey: string | null) { - if (realDebridKey) { - const status: Status = await testRealDebridKey(realDebridKey); +async function getdebridTokenStatus(debridToken: string | null) { + if (debridToken) { + const status: Status = await testdebridToken(debridToken); return status; } return "unset"; } -function RealDebridKeyEdit({ - realDebridKey, - setRealDebridKey, -}: RealDebridKeyProps) { +export function DebridEdit({ + debridToken, + setdebridToken, + debridService, + setdebridService, +}: DebridProps) { const { t } = useTranslation(); const user = useAuthStore(); const preferences = usePreferencesStore(); // Enable Real Debrid token when account is loaded and we have a token useEffect(() => { - if (user.account && realDebridKey === null && preferences.realDebridKey) { - setRealDebridKey(preferences.realDebridKey); + if (user.account && debridToken === null && preferences.debridToken) { + setdebridToken(preferences.debridToken); } - }, [ - user.account, - realDebridKey, - preferences.realDebridKey, - setRealDebridKey, - ]); + }, [user.account, debridToken, preferences.debridToken, setdebridToken]); const [status, setStatus] = useState("unset"); const statusMap: Record = { @@ -499,73 +499,78 @@ function RealDebridKeyEdit({ useEffect(() => { const checkTokenStatus = async () => { - const result = await getRealDebridKeyStatus(realDebridKey); + const result = await getdebridTokenStatus(debridToken); setStatus(result); }; checkTokenStatus(); - }, [realDebridKey]); + }, [debridToken]); - if (conf().ALLOW_REAL_DEBRID_KEY) { + if (conf().ALLOW_DEBRID_KEY) { return (
-

{t("realdebrid.title")}

-

- {t("realdebrid.description")} -

- - - real-debrid.com - - +

{t("debrid.title")}

+ + + {/* fifth's referral code */} + +
setRealDebridKey((s) => (s === null ? "" : null))} - enabled={realDebridKey !== null} + onClick={() => setdebridToken(debridToken === null ? "" : null)} + enabled={debridToken !== null} />
- {realDebridKey !== null ? ( - <> - -

- {t("realdebrid.tokenLabel")} -

-
- - { - setRealDebridKey(newToken); - }} - value={realDebridKey ?? ""} - placeholder="ABC123..." - passwordToggleable - className="flex-grow" - /> -
- {status === "error" && ( -

- {t("realdebrid.status.failure")} -

- )} - {status === "api_down" && ( -

- {t("realdebrid.status.api_down")} -

- )} - {status === "invalid_token" && ( -

- {t("realdebrid.status.invalid_token")} -

- )} - - ) : null} + +

{t("debrid.tokenLabel")}

+
+
+ + { + setdebridToken(newToken); + }} + value={debridToken ?? ""} + placeholder="ABC123..." + passwordToggleable + className="flex-grow" + /> +
+
+ setdebridService(item.id)} + direction="up" + /> +
+
+ {status === "error" && ( +

{t("debrid.status.failure")}

+ )} + {status === "api_down" && ( +

{t("debrid.status.api_down")}

+ )} + {status === "invalid_token" && ( +

+ {t("debrid.status.invalid_token")} +

+ )}
); } @@ -573,10 +578,7 @@ function RealDebridKeyEdit({ } export function ConnectionsPart( - props: BackendEditProps & - ProxyEditProps & - FebboxKeyProps & - RealDebridKeyProps, + props: BackendEditProps & ProxyEditProps & FebboxKeyProps & DebridProps, ) { const { t } = useTranslation(); return ( @@ -594,15 +596,17 @@ export function ConnectionsPart( backendUrl={props.backendUrl} setBackendUrl={props.setBackendUrl} /> - +
); diff --git a/src/pages/parts/settings/SetupPart.tsx b/src/pages/parts/settings/SetupPart.tsx index c1ab7fc6..7860f681 100644 --- a/src/pages/parts/settings/SetupPart.tsx +++ b/src/pages/parts/settings/SetupPart.tsx @@ -42,7 +42,7 @@ type SetupData = { proxy: Status; defaultProxy: Status; febboxKeyTest?: Status; - realDebridKeyTest?: Status; + debridTokenTest?: Status; }; function testProxy(url: string) { @@ -142,10 +142,10 @@ export async function testFebboxKey(febboxKey: string | null): Promise { return "api_down"; } -export async function testRealDebridKey( - realDebridKey: string | null, +export async function testdebridToken( + debridToken: string | null, ): Promise { - if (!realDebridKey) { + if (!debridToken) { return "unset"; } @@ -160,7 +160,7 @@ export async function testRealDebridKey( { method: "GET", headers: { - Authorization: `Bearer ${realDebridKey}`, + Authorization: `Bearer ${debridToken}`, "Content-Type": "application/json", }, }, @@ -191,10 +191,21 @@ export async function testRealDebridKey( return "api_down"; } +export async function testTorboxToken( + torboxToken: string | null, +): Promise { + if (!torboxToken) { + return "unset"; + } + + // TODO: Implement Torbox token test + return "success"; +} + function useIsSetup() { const proxyUrls = useAuthStore((s) => s.proxySet); const febboxKey = usePreferencesStore((s) => s.febboxKey); - const realDebridKey = usePreferencesStore((s) => s.realDebridKey); + const debridToken = usePreferencesStore((s) => s.debridToken); const { loading, value } = useAsync(async (): Promise => { const extensionStatus: Status = (await isExtensionActive()) ? "success" @@ -210,7 +221,7 @@ function useIsSetup() { } const febboxKeyStatus: Status = await testFebboxKey(febboxKey); - const realDebridKeyStatus: Status = await testRealDebridKey(realDebridKey); + const debridTokenStatus: Status = await testdebridToken(debridToken); return { extension: extensionStatus, @@ -219,23 +230,23 @@ function useIsSetup() { ...(conf().ALLOW_FEBBOX_KEY && { febboxKeyTest: febboxKeyStatus, }), - realDebridKeyTest: realDebridKeyStatus, + debridTokenTest: debridTokenStatus, }; - }, [proxyUrls, febboxKey, realDebridKey]); + }, [proxyUrls, febboxKey, debridToken]); let globalState: Status = "unset"; if ( value?.extension === "success" || value?.proxy === "success" || value?.febboxKeyTest === "success" || - value?.realDebridKeyTest === "success" + value?.debridTokenTest === "success" ) globalState = "success"; if ( value?.proxy === "error" || value?.extension === "error" || value?.febboxKeyTest === "error" || - value?.realDebridKeyTest === "error" + value?.debridTokenTest === "error" ) globalState = "error"; @@ -375,9 +386,9 @@ export function SetupPart() { > {t("settings.connections.setup.items.default")} - {conf().ALLOW_REAL_DEBRID_KEY && ( - - Real Debrid token + {conf().ALLOW_DEBRID_KEY && ( + + Debrid Service )} {conf().ALLOW_FEBBOX_KEY && ( diff --git a/src/setup/config.ts b/src/setup/config.ts index 884d92b1..d84bf55c 100644 --- a/src/setup/config.ts +++ b/src/setup/config.ts @@ -26,7 +26,7 @@ interface Config { ONBOARDING_PROXY_INSTALL_LINK: string; ALLOW_AUTOPLAY: boolean; ALLOW_FEBBOX_KEY: boolean; - ALLOW_REAL_DEBRID_KEY: boolean; + ALLOW_DEBRID_KEY: boolean; SHOW_AD: boolean; AD_CONTENT_URL: string; TRACK_SCRIPT: string; // like @@ -42,7 +42,7 @@ export interface RuntimeConfig { DMCA_EMAIL: string | null; TWITTER_LINK: string; TMDB_READ_API_KEY: string | null; - ALLOW_REAL_DEBRID_KEY: boolean; + ALLOW_DEBRID_KEY: boolean; NORMAL_ROUTER: boolean; PROXY_URLS: string[]; M3U8_PROXY_URLS: string[]; @@ -87,7 +87,7 @@ const env: Record = { HAS_ONBOARDING: import.meta.env.VITE_HAS_ONBOARDING, ALLOW_AUTOPLAY: import.meta.env.VITE_ALLOW_AUTOPLAY, ALLOW_FEBBOX_KEY: import.meta.env.VITE_ALLOW_FEBBOX_KEY, - ALLOW_REAL_DEBRID_KEY: import.meta.env.VITE_ALLOW_REAL_DEBRID_KEY, + ALLOW_DEBRID_KEY: import.meta.env.VITE_ALLOW_DEBRID_KEY, SHOW_AD: import.meta.env.VITE_SHOW_AD, AD_CONTENT_URL: import.meta.env.VITE_AD_CONTENT_URL, TRACK_SCRIPT: import.meta.env.VITE_TRACK_SCRIPT, @@ -159,7 +159,7 @@ export function conf(): RuntimeConfig { ) .filter((v) => v.length === 2), // The format is :,: ALLOW_FEBBOX_KEY: getKey("ALLOW_FEBBOX_KEY", "false") === "true", - ALLOW_REAL_DEBRID_KEY: getKey("ALLOW_REAL_DEBRID_KEY", "false") === "true", + ALLOW_DEBRID_KEY: getKey("ALLOW_DEBRID_KEY", "false") === "true", SHOW_AD: getKey("SHOW_AD", "false") === "true", AD_CONTENT_URL: getKey("AD_CONTENT_URL", "") .split(",") diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx index b25b9867..b5ef6ff1 100644 --- a/src/stores/preferences/index.tsx +++ b/src/stores/preferences/index.tsx @@ -22,7 +22,8 @@ export interface PreferencesStore { disabledEmbeds: string[]; proxyTmdb: boolean; febboxKey: string | null; - realDebridKey: string | null; + debridToken: string | null; + debridService: string; enableLowPerformanceMode: boolean; enableNativeSubtitles: boolean; enableHoldToBoost: boolean; @@ -49,7 +50,8 @@ export interface PreferencesStore { setDisabledEmbeds(v: string[]): void; setProxyTmdb(v: boolean): void; setFebboxKey(v: string | null): void; - setRealDebridKey(v: string | null): void; + setdebridToken(v: string | null): void; + setdebridService(v: string): void; setEnableLowPerformanceMode(v: boolean): void; setEnableNativeSubtitles(v: boolean): void; setEnableHoldToBoost(v: boolean): void; @@ -80,7 +82,8 @@ export const usePreferencesStore = create( disabledEmbeds: [], proxyTmdb: false, febboxKey: null, - realDebridKey: null, + debridToken: null, + debridService: "realdebrid", enableLowPerformanceMode: false, enableNativeSubtitles: false, enableHoldToBoost: true, @@ -182,9 +185,14 @@ export const usePreferencesStore = create( s.febboxKey = v; }); }, - setRealDebridKey(v) { + setdebridToken(v) { set((s) => { - s.realDebridKey = v; + s.debridToken = v; + }); + }, + setdebridService(v) { + set((s) => { + s.debridService = v; }); }, setEnableLowPerformanceMode(v) { From c94d7bc2432dae752669b4b23d4ea3063def056e Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 19 Nov 2025 12:57:19 -0700 Subject: [PATCH 17/22] fix more of the debrid input and testing --- src/pages/onboarding/Onboarding.tsx | 1 + src/pages/parts/settings/ConnectionsPart.tsx | 181 ++++++++++++------- src/pages/parts/settings/SetupPart.tsx | 31 +++- 3 files changed, 145 insertions(+), 68 deletions(-) diff --git a/src/pages/onboarding/Onboarding.tsx b/src/pages/onboarding/Onboarding.tsx index 5558060c..1d47aad9 100644 --- a/src/pages/onboarding/Onboarding.tsx +++ b/src/pages/onboarding/Onboarding.tsx @@ -298,6 +298,7 @@ export function OnboardingPage() { setdebridToken={usePreferencesStore((s) => s.setdebridToken)} debridService={usePreferencesStore((s) => s.debridService)} setdebridService={usePreferencesStore((s) => s.setdebridService)} + mode="onboarding" />
diff --git a/src/pages/parts/settings/ConnectionsPart.tsx b/src/pages/parts/settings/ConnectionsPart.tsx index fc3ae9f0..28b229a7 100644 --- a/src/pages/parts/settings/ConnectionsPart.tsx +++ b/src/pages/parts/settings/ConnectionsPart.tsx @@ -25,6 +25,7 @@ import { SetupPart, Status, testFebboxKey, + testTorboxToken, testdebridToken, } from "@/pages/parts/settings/SetupPart"; import { conf } from "@/setup/config"; @@ -55,6 +56,8 @@ interface DebridProps { setdebridToken: (value: string | null) => void; debridService: string; setdebridService: (value: string) => void; + // eslint-disable-next-line react/no-unused-prop-types + mode?: "onboarding" | "settings"; } function ProxyEdit({ @@ -255,14 +258,14 @@ export function FebboxSetup({ const exampleModal = useModal("febbox-example"); // Initialize expansion state for onboarding mode - const [isExpanded, setIsExpanded] = useState( + const [isFebboxExpanded, setIsFebboxExpanded] = useState( mode === "onboarding" && febboxKey !== null && febboxKey !== "", ); // Expand when key is set in onboarding mode useEffect(() => { if (mode === "onboarding" && febboxKey && febboxKey.length > 0) { - setIsExpanded(true); + setIsFebboxExpanded(true); } }, [febboxKey, mode]); @@ -296,14 +299,14 @@ export function FebboxSetup({ }, [febboxKey]); // Toggle handler based on mode - const toggleExpanded = () => { + const toggleFebboxExpanded = () => { if (mode === "onboarding") { // Onboarding mode: expand/collapse, preserve key - if (isExpanded) { + if (isFebboxExpanded) { setFebboxKey(""); - setIsExpanded(false); + setIsFebboxExpanded(false); } else { - setIsExpanded(true); + setIsFebboxExpanded(true); } } else { // Settings mode: enable/disable @@ -312,7 +315,8 @@ export function FebboxSetup({ }; // Determine if content is visible - const isVisible = mode === "onboarding" ? isExpanded : febboxKey !== null; + const isFebboxVisible = + mode === "onboarding" ? isFebboxExpanded : febboxKey !== null; if (conf().ALLOW_FEBBOX_KEY) { return ( @@ -329,14 +333,14 @@ export function FebboxSetup({
- {isVisible ? ( + {isFebboxVisible ? ( <> @@ -463,9 +467,15 @@ export function FebboxSetup({ } } -async function getdebridTokenStatus(debridToken: string | null) { +async function getdebridTokenStatus( + debridToken: string | null, + debridService: string, +) { if (debridToken) { - const status: Status = await testdebridToken(debridToken); + const status: Status = + debridService === "torbox" + ? await testTorboxToken(debridToken) + : await testdebridToken(debridToken); return status; } return "unset"; @@ -476,11 +486,24 @@ export function DebridEdit({ setdebridToken, debridService, setdebridService, + mode = "settings", }: DebridProps) { const { t } = useTranslation(); const user = useAuthStore(); const preferences = usePreferencesStore(); + // Initialize expansion state for onboarding mode + const [isDebridExpanded, setIsDebridExpanded] = useState( + mode === "onboarding" && debridToken !== null && debridToken !== "", + ); + + // Expand when key is set in onboarding mode + useEffect(() => { + if (mode === "onboarding" && debridToken && debridToken.length > 0) { + setIsDebridExpanded(true); + } + }, [debridToken, mode]); + // Enable Real Debrid token when account is loaded and we have a token useEffect(() => { if (user.account && debridToken === null && preferences.debridToken) { @@ -488,6 +511,26 @@ export function DebridEdit({ } }, [user.account, debridToken, preferences.debridToken, setdebridToken]); + // Determine if content is visible + const isDebridVisible = + mode === "onboarding" ? isDebridExpanded : debridToken !== null; + + // Toggle handler based on mode + const toggleDebridExpanded = () => { + if (mode === "onboarding") { + // Onboarding mode: expand/collapse, preserve key + if (isDebridExpanded) { + setdebridToken(""); + setIsDebridExpanded(false); + } else { + setIsDebridExpanded(true); + } + } else { + // Settings mode: enable/disable + setdebridToken(debridToken === null ? "" : null); + } + }; + const [status, setStatus] = useState("unset"); const statusMap: Record = { error: "error", @@ -499,11 +542,11 @@ export function DebridEdit({ useEffect(() => { const checkTokenStatus = async () => { - const result = await getdebridTokenStatus(debridToken); + const result = await getdebridTokenStatus(debridToken, debridService); setStatus(result); }; checkTokenStatus(); - }, [debridToken]); + }, [debridToken, debridService]); if (conf().ALLOW_DEBRID_KEY) { return ( @@ -518,59 +561,66 @@ export function DebridEdit({
- setdebridToken(debridToken === null ? "" : null)} - enabled={debridToken !== null} - /> +
- -

{t("debrid.tokenLabel")}

-
-
- - { - setdebridToken(newToken); - }} - value={debridToken ?? ""} - placeholder="ABC123..." - passwordToggleable - className="flex-grow" - /> -
-
- setdebridService(item.id)} - direction="up" - /> -
-
- {status === "error" && ( -

{t("debrid.status.failure")}

- )} - {status === "api_down" && ( -

{t("debrid.status.api_down")}

- )} - {status === "invalid_token" && ( -

- {t("debrid.status.invalid_token")} -

- )} + {isDebridVisible ? ( + <> + +

+ {t("debrid.tokenLabel")} +

+
+
+ + { + setdebridToken(newToken); + }} + value={debridToken ?? ""} + placeholder="ABC123..." + passwordToggleable + className="flex-grow" + /> +
+
+ setdebridService(item.id)} + direction="up" + /> +
+
+ {status === "error" && ( +

+ {t("debrid.status.failure")} +

+ )} + {status === "api_down" && ( +

+ {t("debrid.status.api_down")} +

+ )} + {status === "invalid_token" && ( +

+ {t("debrid.status.invalid_token")} +

+ )} + + ) : null} ); } @@ -606,6 +656,7 @@ export function ConnectionsPart( setdebridToken={props.setdebridToken} debridService={props.debridService} setdebridService={props.setdebridService} + mode="settings" /> diff --git a/src/pages/parts/settings/SetupPart.tsx b/src/pages/parts/settings/SetupPart.tsx index 7860f681..0dd9ca3c 100644 --- a/src/pages/parts/settings/SetupPart.tsx +++ b/src/pages/parts/settings/SetupPart.tsx @@ -1,5 +1,6 @@ /* eslint-disable no-console */ import classNames from "classnames"; +import { FetchError } from "ofetch"; import { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -174,12 +175,32 @@ export async function testdebridToken( console.log("RD response did not indicate premium status"); attempts += 1; - if (attempts === maxAttempts) { + if (attempts === maxAttempts || data?.error_code === 8) { return "invalid_token"; } await sleep(3000); } catch (error) { console.error("RD API error:", error); + + // Check if it's a FetchError with error_code 8 (bad_token) + if (error instanceof FetchError) { + try { + const errorData = error.data; + if (errorData?.error_code === 8) { + console.log("RD token is invalid (error_code 8)"); + return "invalid_token"; + } + } catch (parseError) { + console.error("Failed to parse RD error response:", parseError); + } + + // For other HTTP errors (like 500, 502, etc.), treat as API down + if (error.statusCode && error.statusCode >= 500) { + console.log(`RD API down (status ${error.statusCode})`); + return "api_down"; + } + } + attempts += 1; if (attempts === maxAttempts) { return "api_down"; @@ -206,6 +227,7 @@ function useIsSetup() { const proxyUrls = useAuthStore((s) => s.proxySet); const febboxKey = usePreferencesStore((s) => s.febboxKey); const debridToken = usePreferencesStore((s) => s.debridToken); + const debridService = usePreferencesStore((s) => s.debridService); const { loading, value } = useAsync(async (): Promise => { const extensionStatus: Status = (await isExtensionActive()) ? "success" @@ -221,7 +243,10 @@ function useIsSetup() { } const febboxKeyStatus: Status = await testFebboxKey(febboxKey); - const debridTokenStatus: Status = await testdebridToken(debridToken); + const debridTokenStatus: Status = + debridService === "torbox" + ? await testTorboxToken(debridToken) + : await testdebridToken(debridToken); return { extension: extensionStatus, @@ -232,7 +257,7 @@ function useIsSetup() { }), debridTokenTest: debridTokenStatus, }; - }, [proxyUrls, febboxKey, debridToken]); + }, [proxyUrls, febboxKey, debridToken, debridService]); let globalState: Status = "unset"; if ( From a26cab94bf08167b104730fec06500324f06f763 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 19 Nov 2025 14:29:32 -0700 Subject: [PATCH 18/22] Update Settings.tsx --- src/pages/Settings.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 764c9754..c4bb6f90 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -516,9 +516,6 @@ export function SettingsPage() { if (settings.debridToken) { setdebridToken(settings.debridToken); } - if (settings.debridService) { - setdebridService(settings.debridService); - } } }; loadSettings(); From fa7b70b52ab83c916c9664fdf58296a3e503a79b Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 19 Nov 2025 15:14:05 -0700 Subject: [PATCH 19/22] Update useSettingsState.ts --- src/hooks/useSettingsState.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/useSettingsState.ts b/src/hooks/useSettingsState.ts index d65e363d..3ab198b0 100644 --- a/src/hooks/useSettingsState.ts +++ b/src/hooks/useSettingsState.ts @@ -311,6 +311,7 @@ export function useSettingsState( proxyUrlsChanged || febboxKeyChanged || debridTokenChanged || + debridServiceChanged || profileChanged || enableThumbnailsChanged || enableAutoplayChanged || From ae63641d164a28cfc1d5d01052dcb65bf81d5106 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 19 Nov 2025 18:00:29 -0700 Subject: [PATCH 20/22] add notice --- src/assets/locales/en.json | 1 + src/pages/parts/settings/ConnectionsPart.tsx | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index eec36bc2..742e8efa 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -1270,6 +1270,7 @@ "debrid": { "title": "Debrid (Beta)", "description": "Enter your Debrid API key to access Debrid services. Requires a paid <0>Real-Debrid or <1>TorBox account!", + "notice": "Notice: This may not work on all browsers, use Chrome for the best compatibility. If you hear no audio, use another source. :(", "tokenLabel": "API Key", "serviceOptions": { "realdebrid": "Real Debrid", diff --git a/src/pages/parts/settings/ConnectionsPart.tsx b/src/pages/parts/settings/ConnectionsPart.tsx index 28b229a7..c0888c3d 100644 --- a/src/pages/parts/settings/ConnectionsPart.tsx +++ b/src/pages/parts/settings/ConnectionsPart.tsx @@ -559,6 +559,9 @@ export function DebridEdit({ {/* fifth's referral code */} +

+ {t("debrid.notice")} +

From 6a3d81a048fbdfa70239980dc4f59f5b714cc735 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 19 Nov 2025 18:09:13 -0700 Subject: [PATCH 21/22] Update en.json --- src/assets/locales/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 742e8efa..fdbdf4db 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -1270,7 +1270,7 @@ "debrid": { "title": "Debrid (Beta)", "description": "Enter your Debrid API key to access Debrid services. Requires a paid <0>Real-Debrid or <1>TorBox account!", - "notice": "Notice: This may not work on all browsers, use Chrome for the best compatibility. If you hear no audio, use another source. :(", + "notice": "Notice: This may not work on all browsers, use Chromium for the best compatibility. If you hear no audio, use another source. :(", "tokenLabel": "API Key", "serviceOptions": { "realdebrid": "Real Debrid", From 1025ea087d8126fa26b5fdff163dd4a9398bd849 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sun, 23 Nov 2025 15:27:06 -0700 Subject: [PATCH 22/22] Fix view more pages more content, now shows correct media when trakt fails and also scrolls to the top --- src/pages/discover/MoreContent.tsx | 5 +++++ src/pages/discover/components/MediaCarousel.tsx | 6 +++--- src/pages/discover/hooks/useDiscoverMedia.ts | 5 +++++ src/pages/discover/types/discover.ts | 1 + 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/pages/discover/MoreContent.tsx b/src/pages/discover/MoreContent.tsx index 29f86efb..af562f2b 100644 --- a/src/pages/discover/MoreContent.tsx +++ b/src/pages/discover/MoreContent.tsx @@ -97,6 +97,11 @@ export function MoreContent({ onShowDetails }: MoreContentProps) { setIsContentVisible(false); }, [isLoading, mediaItems, currentPage]); + // Scroll to top when entering the page + useEffect(() => { + window.scrollTo(0, 0); + }, [contentType, mediaType, id]); + const handleBack = () => { if (lastView) { navigate(lastView.url); diff --git a/src/pages/discover/components/MediaCarousel.tsx b/src/pages/discover/components/MediaCarousel.tsx index 49c148cc..177b1f42 100644 --- a/src/pages/discover/components/MediaCarousel.tsx +++ b/src/pages/discover/components/MediaCarousel.tsx @@ -198,7 +198,7 @@ export function MediaCarousel({ ]); // Fetch media using our hook - only when carousel has been visible - const { media, sectionTitle } = useDiscoverMedia({ + const { media, sectionTitle, actualContentType } = useDiscoverMedia({ contentType, mediaType, id: selectedProviderId || selectedGenreId || selectedRecommendationId, @@ -298,7 +298,7 @@ export function MediaCarousel({ if (showRecommendations && selectedRecommendationId) { return `${baseLink}/recommendations/${selectedRecommendationId}/${mediaType}`; } - return `${baseLink}/${content.type}/${mediaType}`; + return `${baseLink}/${actualContentType}/${mediaType}`; }, [ moreLink, showProviders, @@ -308,7 +308,7 @@ export function MediaCarousel({ showRecommendations, selectedRecommendationId, mediaType, - content.type, + actualContentType, ]); // Loading state diff --git a/src/pages/discover/hooks/useDiscoverMedia.ts b/src/pages/discover/hooks/useDiscoverMedia.ts index a20f1c07..ea4e719d 100644 --- a/src/pages/discover/hooks/useDiscoverMedia.ts +++ b/src/pages/discover/hooks/useDiscoverMedia.ts @@ -121,6 +121,8 @@ export function useDiscoverMedia({ const [sectionTitle, setSectionTitle] = useState(""); const [currentContentType, setCurrentContentType] = useState(contentType); + const [actualContentType, setActualContentType] = + useState(contentType); const { t } = useTranslation(); const userLanguage = useLanguageStore((s) => s.language); @@ -131,6 +133,7 @@ export function useDiscoverMedia({ if (contentType !== currentContentType) { setMedia([]); setCurrentContentType(contentType); + setActualContentType(contentType); // Reset actual content type to original } }, [contentType, currentContentType]); @@ -475,6 +478,7 @@ export function useDiscoverMedia({ console.info(`Falling back from ${contentType} to ${fallbackType}`); try { const fallbackData = await attemptFetch(fallbackType); + setActualContentType(fallbackType); // Set actual content type to fallback setMedia((prevMedia) => { // If page is 1, replace the media array, otherwise append return page === 1 @@ -526,5 +530,6 @@ export function useDiscoverMedia({ hasMore, refetch: fetchMedia, sectionTitle, + actualContentType, }; } diff --git a/src/pages/discover/types/discover.ts b/src/pages/discover/types/discover.ts index 6343b3e2..2754fa05 100644 --- a/src/pages/discover/types/discover.ts +++ b/src/pages/discover/types/discover.ts @@ -47,6 +47,7 @@ export interface UseDiscoverMediaReturn { hasMore: boolean; refetch: () => Promise; sectionTitle: string; + actualContentType: DiscoverContentType; } export interface Provider {