Merge branch 'production' of https://github.com/zisra/p-stream into production

This commit is contained in:
Isra 2025-11-24 17:23:18 +08:00
commit d2acf73793
28 changed files with 834 additions and 480 deletions

View file

@ -43,7 +43,7 @@ importers:
version: 1.8.0 version: 1.8.0
"@p-stream/providers": "@p-stream/providers":
specifier: github:p-stream/providers#production 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": "@plasmohq/messaging":
specifier: ^0.6.2 specifier: ^0.6.2
version: 0.6.2(react@18.3.1) 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) version: 6.21.0(eslint@8.57.1)(typescript@5.9.3)
"@vitejs/plugin-react": "@vitejs/plugin-react":
specifier: ^4.7.0 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: autoprefixer:
specifier: ^10.4.21 specifier: ^10.4.21
version: 10.4.21(postcss@8.5.6) version: 10.4.21(postcss@8.5.6)
@ -302,22 +302,22 @@ importers:
version: 5.9.3 version: 5.9.3
vite: vite:
specifier: ^5.4.21 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: vite-plugin-checker:
specifier: ^0.6.4 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: vite-plugin-package-version:
specifier: ^1.1.0 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: vite-plugin-pwa:
specifier: ^0.17.5 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: vite-plugin-static-copy:
specifier: ^3.1.4 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: vitest:
specifier: ^1.6.1 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: workbox-window:
specifier: ^7.3.0 specifier: ^7.3.0
version: 7.3.0 version: 7.3.0
@ -1709,10 +1709,10 @@ packages:
} }
engines: { node: ">=12.4.0" } 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: 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 version: 3.2.0
@ -6495,10 +6495,10 @@ packages:
} }
engines: { node: ">=10" } engines: { node: ">=10" }
terser@5.44.0: terser@5.44.1:
resolution: resolution:
{ {
integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==, integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==,
} }
engines: { node: ">=10" } engines: { node: ">=10" }
hasBin: true hasBin: true
@ -8380,7 +8380,7 @@ snapshots:
"@nolyfill/is-core-module@1.0.39": {} "@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: dependencies:
abort-controller: 3.0.0 abort-controller: 3.0.0
cheerio: 1.0.0-rc.12 cheerio: 1.0.0-rc.12
@ -8473,7 +8473,7 @@ snapshots:
dependencies: dependencies:
serialize-javascript: 6.0.2 serialize-javascript: 6.0.2
smob: 1.5.0 smob: 1.5.0
terser: 5.44.0 terser: 5.44.1
optionalDependencies: optionalDependencies:
rollup: 4.43.0 rollup: 4.43.0
@ -8858,7 +8858,7 @@ snapshots:
"@unrs/resolver-binding-win32-x64-msvc@1.11.1": "@unrs/resolver-binding-win32-x64-msvc@1.11.1":
optional: true 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: dependencies:
"@babel/core": 7.28.5 "@babel/core": 7.28.5
"@babel/plugin-transform-react-jsx-self": 7.27.1(@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 "@rolldown/pluginutils": 1.0.0-beta.27
"@types/babel__core": 7.20.5 "@types/babel__core": 7.20.5
react-refresh: 0.17.0 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: transitivePeerDependencies:
- supports-color - supports-color
@ -11354,7 +11354,7 @@ snapshots:
type-fest: 0.16.0 type-fest: 0.16.0
unique-string: 2.0.0 unique-string: 2.0.0
terser@5.44.0: terser@5.44.1:
dependencies: dependencies:
"@jridgewell/source-map": 0.3.11 "@jridgewell/source-map": 0.3.11
acorn: 8.15.0 acorn: 8.15.0
@ -11562,13 +11562,13 @@ snapshots:
util-deprecate@1.0.2: {} 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: dependencies:
cac: 6.7.14 cac: 6.7.14
debug: 4.4.3 debug: 4.4.3
pathe: 1.1.2 pathe: 1.1.2
picocolors: 1.1.1 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: transitivePeerDependencies:
- "@types/node" - "@types/node"
- less - less
@ -11580,7 +11580,7 @@ snapshots:
- supports-color - supports-color
- terser - 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: dependencies:
"@babel/code-frame": 7.27.1 "@babel/code-frame": 7.27.1
ansi-escapes: 4.3.2 ansi-escapes: 4.3.2
@ -11593,7 +11593,7 @@ snapshots:
semver: 7.7.3 semver: 7.7.3
strip-ansi: 6.0.1 strip-ansi: 6.0.1
tiny-invariant: 1.3.3 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-languageclient: 7.0.0
vscode-languageserver: 7.0.0 vscode-languageserver: 7.0.0
vscode-languageserver-textdocument: 1.0.12 vscode-languageserver-textdocument: 1.0.12
@ -11603,30 +11603,30 @@ snapshots:
optionator: 0.9.4 optionator: 0.9.4
typescript: 5.9.3 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: 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: dependencies:
debug: 4.4.3 debug: 4.4.3
fast-glob: 3.3.3 fast-glob: 3.3.3
pretty-bytes: 6.1.1 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-build: 7.3.0(@types/babel__core@7.20.5)
workbox-window: 7.3.0 workbox-window: 7.3.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: dependencies:
chokidar: 3.6.0 chokidar: 3.6.0
p-map: 7.0.3 p-map: 7.0.3
picocolors: 1.1.1 picocolors: 1.1.1
tinyglobby: 0.2.15 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: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5
postcss: 8.5.6 postcss: 8.5.6
@ -11634,9 +11634,9 @@ snapshots:
optionalDependencies: optionalDependencies:
"@types/node": 20.19.23 "@types/node": 20.19.23
fsevents: 2.3.3 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: dependencies:
"@vitest/expect": 1.6.1 "@vitest/expect": 1.6.1
"@vitest/runner": 1.6.1 "@vitest/runner": 1.6.1
@ -11655,8 +11655,8 @@ snapshots:
strip-literal: 2.1.1 strip-literal: 2.1.1
tinybench: 2.9.0 tinybench: 2.9.0
tinypool: 0.8.4 tinypool: 0.8.4
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-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)
why-is-node-running: 2.3.0 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
"@types/node": 20.19.23 "@types/node": 20.19.23

View file

@ -947,6 +947,8 @@
}, },
"account": { "account": {
"accountDetails": { "accountDetails": {
"nicknameLabel": "Nickname",
"nicknamePlaceholder": "Enter your nickname",
"deviceNameLabel": "Device name", "deviceNameLabel": "Device name",
"deviceNamePlaceholder": "Personal phone", "deviceNamePlaceholder": "Personal phone",
"editProfile": "Edit", "editProfile": "Edit",
@ -979,6 +981,7 @@
}, },
"devices": { "devices": {
"deviceNameLabel": "Device name", "deviceNameLabel": "Device name",
"unknownDevice": "Unknown device, error decrypting name",
"failed": "Failed to load sessions", "failed": "Failed to load sessions",
"removeDevice": "Remove", "removeDevice": "Remove",
"title": "Devices" "title": "Devices"
@ -1144,6 +1147,7 @@
"backendVersion": "Backend version", "backendVersion": "Backend version",
"hostname": "Hostname", "hostname": "Hostname",
"insecure": "Insecure", "insecure": "Insecure",
"nickname": "Nickname",
"notLoggedIn": "You are not logged in", "notLoggedIn": "You are not logged in",
"secure": "Secure", "secure": "Secure",
"title": "App stats", "title": "App stats",
@ -1263,14 +1267,19 @@
"invalid_token": "Failed to fetch a 'VIP' stream. Your token is invalid!" "invalid_token": "Failed to fetch a 'VIP' stream. Your token is invalid!"
} }
}, },
"realdebrid": { "debrid": {
"title": "Real Debrid (Beta)", "title": "Debrid (Beta)",
"description": "Enter your Real Debrid API key to access Real Debrid. Extension required.", "description": "Enter your Debrid API key to access Debrid services. Requires a paid <0>Real-Debrid</0> or <1>TorBox</1> account!",
"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", "tokenLabel": "API Key",
"serviceOptions": {
"realdebrid": "Real Debrid",
"torbox": "TorBox"
},
"status": { "status": {
"failure": "Failed to connect to Real Debrid. Please check your API key.", "failure": "Failed to connect to Debrid. Please check your API key.",
"api_down": "Real Debrid API is currently unavailable. Please try again later.", "api_down": "Hmm, something went wrong. Please try again later.",
"invalid_token": "Invalid API key or non-premium account. Real Debrid requires a premium account." "invalid_token": "Invalid API key or non-premium account. Debrid requires a premium account."
} }
}, },
"watchParty": { "watchParty": {

View file

@ -9,7 +9,8 @@ export interface SettingsInput {
defaultSubtitleLanguage?: string; defaultSubtitleLanguage?: string;
proxyUrls?: string[] | null; proxyUrls?: string[] | null;
febboxKey?: string | null; febboxKey?: string | null;
realDebridKey?: string | null; debridToken?: string | null;
debridService?: string;
enableThumbnails?: boolean; enableThumbnails?: boolean;
enableAutoplay?: boolean; enableAutoplay?: boolean;
enableSkipCredits?: boolean; enableSkipCredits?: boolean;
@ -42,7 +43,8 @@ export interface SettingsResponse {
defaultSubtitleLanguage?: string | null; defaultSubtitleLanguage?: string | null;
proxyUrls?: string[] | null; proxyUrls?: string[] | null;
febboxKey?: string | null; febboxKey?: string | null;
realDebridKey?: string | null; debridToken?: string | null;
debridService?: string;
enableThumbnails?: boolean; enableThumbnails?: boolean;
enableAutoplay?: boolean; enableAutoplay?: boolean;
enableSkipCredits?: boolean; enableSkipCredits?: boolean;

View file

@ -8,9 +8,8 @@ import { ProgressMediaItem } from "@/stores/progress";
export interface UserResponse { export interface UserResponse {
id: string; id: string;
namespace: string; namespace: string;
name: string; nickname: string;
roles: string[]; permissions: string[];
createdAt: string;
profile: { profile: {
colorA: string; colorA: string;
colorB: string; colorB: string;
@ -24,6 +23,7 @@ export interface UserEdit {
colorB: string; colorB: string;
icon: string; icon: string;
}; };
nickname?: string;
} }
export interface BookmarkResponse { export interface BookmarkResponse {

View file

@ -1,4 +1,6 @@
import { conf } from "@/setup/config";
import { SimpleCache } from "@/utils/cache"; import { SimpleCache } from "@/utils/cache";
import { getTurnstileToken } from "@/utils/turnstile";
import { getMediaDetails } from "./tmdb"; import { getMediaDetails } from "./tmdb";
import { TMDBContentTypes, TMDBMovieData } from "./types/tmdb"; import { TMDBContentTypes, TMDBMovieData } from "./types/tmdb";
@ -11,6 +13,83 @@ import type {
export const TRAKT_BASE_URL = "https://fed-airdate.pstream.mov"; 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
* Returns an object indicating if the token was cached or freshly fetched
*/
const getFreshTurnstileToken = async (): Promise<{
token: string;
isCached: boolean;
}> => {
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, isCached: true };
}
} 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, isCached: false };
} 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<void> => {
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 // Map provider names to their Trakt endpoints
export const PROVIDER_TO_TRAKT_MAP = { export const PROVIDER_TO_TRAKT_MAP = {
"8": "netflixmovies", // Netflix Movies "8": "netflixmovies", // Netflix Movies
@ -53,6 +132,10 @@ traktCache.initialize();
async function fetchFromTrakt<T = TraktListResponse>( async function fetchFromTrakt<T = TraktListResponse>(
endpoint: string, endpoint: string,
): Promise<T> { ): Promise<T> {
if (!conf().USE_TRAKT) {
return null as T;
}
// Check cache first // Check cache first
const cacheKey: TraktCacheKey = { endpoint }; const cacheKey: TraktCacheKey = { endpoint };
const cachedResult = traktCache.get(cacheKey); const cachedResult = traktCache.get(cacheKey);
@ -60,17 +143,61 @@ async function fetchFromTrakt<T = TraktListResponse>(
return cachedResult as T; return cachedResult as T;
} }
// Make the API request // Try up to 2 times: first with cached/fresh token, retry with forced fresh token if auth fails
const response = await fetch(`${TRAKT_BASE_URL}${endpoint}`); for (let attempt = 0; attempt < 2; attempt += 1) {
if (!response.ok) { try {
throw new Error(`Failed to fetch from ${endpoint}: ${response.statusText}`); // 1. Get turnstile token (cached or fresh)
const { token: turnstileToken, isCached } =
await getFreshTurnstileToken();
// 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}`, {
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) throw new Error(`Failed to fetch from ${endpoint} after retries`);
traktCache.set(cacheKey, result, 3600);
return result as T;
} }
// Release details // Release details
@ -84,6 +211,10 @@ export async function getReleaseDetails(
url += `/${season}/${episode}`; url += `/${season}/${episode}`;
} }
if (!conf().USE_TRAKT) {
return null as unknown as TraktReleaseResponse;
}
// Check cache first // Check cache first
const cacheKey: TraktCacheKey = { endpoint: url }; const cacheKey: TraktCacheKey = { endpoint: url };
const cachedResult = traktCache.get(cacheKey); const cachedResult = traktCache.get(cacheKey);
@ -91,17 +222,61 @@ export async function getReleaseDetails(
return cachedResult as TraktReleaseResponse; return cachedResult as TraktReleaseResponse;
} }
// Make the API request // Try up to 2 times: first with cached/fresh token, retry with forced fresh token if auth fails
const response = await fetch(`${TRAKT_BASE_URL}${url}`); for (let attempt = 0; attempt < 2; attempt += 1) {
if (!response.ok) { try {
throw new Error(`Failed to fetch release details: ${response.statusText}`); // 1. Get turnstile token (cached or fresh)
const { token: turnstileToken, isCached } =
await getFreshTurnstileToken();
// 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}`, {
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) throw new Error(`Failed to fetch release details after retries`);
traktCache.set(cacheKey, result, 3600);
return result as TraktReleaseResponse;
} }
// Latest releases // Latest releases
@ -140,7 +315,6 @@ export const getNetworkContent = (tmdbId: string) =>
// Curated movie lists // Curated movie lists
export const getNarrativeMovies = () => fetchFromTrakt("/narrative"); export const getNarrativeMovies = () => fetchFromTrakt("/narrative");
export const getTopMovies = () => fetchFromTrakt("/top"); export const getTopMovies = () => fetchFromTrakt("/top");
export const getLifetimeMovies = () => fetchFromTrakt("/lifetime");
export const getNeverHeardMovies = () => fetchFromTrakt("/never"); export const getNeverHeardMovies = () => fetchFromTrakt("/never");
export const getLGBTQContent = () => fetchFromTrakt("/LGBTQ"); export const getLGBTQContent = () => fetchFromTrakt("/LGBTQ");
export const getMindfuckMovies = () => fetchFromTrakt("/mindfuck"); export const getMindfuckMovies = () => fetchFromTrakt("/mindfuck");
@ -166,11 +340,6 @@ export const getCuratedMovieLists = async (): Promise<CuratedMovieList[]> => {
slug: "top", slug: "top",
endpoint: "/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", name: "Great Movies You May Have Never Heard Of",
slug: "never", slug: "never",

View file

@ -1,5 +1,6 @@
import classNames from "classnames"; import classNames from "classnames";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { base64ToBuffer, decryptData } from "@/backend/accounts/crypto"; import { base64ToBuffer, decryptData } from "@/backend/accounts/crypto";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
@ -55,11 +56,22 @@ export function UserAvatar(props: {
: null, : null,
[auth], [auth],
); );
const { t } = useTranslation();
if (!auth.account || auth.account === null) return null; if (!auth.account || auth.account === null) return null;
const deviceName = bufferSeed 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 ( return (

View file

@ -257,7 +257,17 @@ export function LinksDropdown(props: { children: React.ReactNode }) {
{deviceName && bufferSeed ? ( {deviceName && bufferSeed ? (
<DropdownLink className="text-white" href="/settings"> <DropdownLink className="text-white" href="/settings">
<UserAvatar /> <UserAvatar />
{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");
}
})()}
</DropdownLink> </DropdownLink>
) : ( ) : (
<DropdownLink href="/login" icon={Icons.RISING_STAR} highlight> <DropdownLink href="/login" icon={Icons.RISING_STAR} highlight>

View file

@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button"; import { Button } from "@/components/buttons/Button";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { useWatchPartySync } from "@/hooks/useWatchPartySync"; import { useWatchPartySync } from "@/hooks/useWatchPartySync";
import { useAuthStore } from "@/stores/auth";
import { getProgressPercentage } from "@/stores/progress"; import { getProgressPercentage } from "@/stores/progress";
import { useWatchPartyStore } from "@/stores/watchParty"; import { useWatchPartyStore } from "@/stores/watchParty";
@ -13,6 +14,7 @@ export function WatchPartyStatus() {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [showNotification, setShowNotification] = useState(false); const [showNotification, setShowNotification] = useState(false);
const [lastUserCount, setLastUserCount] = useState(1); const [lastUserCount, setLastUserCount] = useState(1);
const account = useAuthStore((s) => s.account);
const { const {
roomUsers, roomUsers,
@ -43,6 +45,14 @@ export function WatchPartyStatus() {
setExpanded(!expanded); 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 ( return (
<div <div
className={`absolute top-4 right-4 z-50 p-2 bg-mediaCard-shadow bg-opacity-70 backdrop-blur-sm rounded-md text-white text-xs className={`absolute top-4 right-4 z-50 p-2 bg-mediaCard-shadow bg-opacity-70 backdrop-blur-sm rounded-md text-white text-xs
@ -106,7 +116,7 @@ export function WatchPartyStatus() {
className={`w-3 h-3 ${user.isHost ? "text-onboarding-best" : ""}`} className={`w-3 h-3 ${user.isHost ? "text-onboarding-best" : ""}`}
/> />
<span className={user.isHost ? "text-onboarding-best" : ""}> <span className={user.isHost ? "text-onboarding-best" : ""}>
{user.userId.substring(0, 8)}... {getDisplayName(user.userId)}
</span> </span>
</span> </span>
<span className="text-type-secondary"> <span className="text-type-secondary">

View file

@ -34,6 +34,14 @@ export function WatchPartyView({ id }: { id: string }) {
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
const account = useAuthStore((s) => s.account); 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 () => { const backendMeta = useAsync(async () => {
if (!backendUrl) return; if (!backendUrl) return;
return getBackendMeta(backendUrl); return getBackendMeta(backendUrl);
@ -322,7 +330,7 @@ export function WatchPartyView({ id }: { id: string }) {
: "text-type-secondary" : "text-type-secondary"
} }
> >
{user.userId.substring(0, 8)}... {getDisplayName(user.userId)}
</span> </span>
</span> </span>
<span className="text-type-secondary"> <span className="text-type-secondary">

View file

@ -188,6 +188,16 @@ export function useAuth() {
getGroupOrder(backendUrl, account), 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( syncData(
user.user, user.user,
user.session, user.session,

View file

@ -32,7 +32,8 @@ export function useAuthData() {
(s) => s.importSubtitleLanguage, (s) => s.importSubtitleLanguage,
); );
const setFebboxKey = usePreferencesStore((s) => s.setFebboxKey); 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 replaceBookmarks = useBookmarkStore((s) => s.replaceBookmarks);
const replaceItems = useProgressStore((s) => s.replaceItems); const replaceItems = useProgressStore((s) => s.replaceItems);
@ -101,6 +102,7 @@ export function useAuthData() {
sessionId: loginResponse.session.id, sessionId: loginResponse.session.id,
deviceName: session.device, deviceName: session.device,
profile: user.profile, profile: user.profile,
nickname: user.nickname,
seed, seed,
}; };
setAccount(account); setAccount(account);
@ -231,8 +233,12 @@ export function useAuthData() {
setFebboxKey(settings.febboxKey); setFebboxKey(settings.febboxKey);
} }
if (settings.realDebridKey !== undefined) { if (settings.debridToken !== undefined) {
setRealDebridKey(settings.realDebridKey); setdebridToken(settings.debridToken);
}
if (settings.debridService !== undefined) {
setdebridService(settings.debridService);
} }
if (settings.enableLowPerformanceMode !== undefined) { if (settings.enableLowPerformanceMode !== undefined) {
@ -287,7 +293,8 @@ export function useAuthData() {
setDisabledEmbeds, setDisabledEmbeds,
setProxyTmdb, setProxyTmdb,
setFebboxKey, setFebboxKey,
setRealDebridKey, setdebridToken,
setdebridService,
setEnableLowPerformanceMode, setEnableLowPerformanceMode,
setEnableNativeSubtitles, setEnableNativeSubtitles,
setEnableHoldToBoost, setEnableHoldToBoost,

View file

@ -42,10 +42,12 @@ export function useSettingsState(
appLanguage: string, appLanguage: string,
subtitleStyling: SubtitleStyling, subtitleStyling: SubtitleStyling,
deviceName: string, deviceName: string,
nickname: string,
proxyUrls: string[] | null, proxyUrls: string[] | null,
backendUrl: string | null, backendUrl: string | null,
febboxKey: string | null, febboxKey: string | null,
realDebridKey: string | null, debridToken: string | null,
debridService: string,
profile: profile:
| { | {
colorA: string; colorA: string;
@ -85,11 +87,17 @@ export function useSettingsState(
const [febboxKeyState, setFebboxKey, resetFebboxKey, febboxKeyChanged] = const [febboxKeyState, setFebboxKey, resetFebboxKey, febboxKeyChanged] =
useDerived(febboxKey); useDerived(febboxKey);
const [ const [
realDebridKeyState, debridTokenState,
setRealDebridKey, setdebridToken,
resetRealDebridKey, resetdebridToken,
realDebridKeyChanged, debridTokenChanged,
] = useDerived(realDebridKey); ] = useDerived(debridToken);
const [
debridServiceState,
setdebridService,
_resetdebridService,
debridServiceChanged,
] = useDerived(debridService);
const [themeState, setTheme, resetTheme, themeChanged] = useDerived(theme); const [themeState, setTheme, resetTheme, themeChanged] = useDerived(theme);
const setPreviewTheme = usePreviewThemeStore((s) => s.setPreviewTheme); const setPreviewTheme = usePreviewThemeStore((s) => s.setPreviewTheme);
const resetPreviewTheme = useCallback( const resetPreviewTheme = useCallback(
@ -110,6 +118,8 @@ export function useSettingsState(
resetDeviceName, resetDeviceName,
deviceNameChanged, deviceNameChanged,
] = useDerived(deviceName); ] = useDerived(deviceName);
const [nicknameState, setNicknameState, resetNickname, nicknameChanged] =
useDerived(nickname);
const [profileState, setProfileState, resetProfile, profileChanged] = const [profileState, setProfileState, resetProfile, profileChanged] =
useDerived(profile); useDerived(profile);
const [ const [
@ -261,8 +271,9 @@ export function useSettingsState(
resetProxyUrls(); resetProxyUrls();
resetBackendUrl(); resetBackendUrl();
resetFebboxKey(); resetFebboxKey();
resetRealDebridKey(); resetdebridToken();
resetDeviceName(); resetDeviceName();
resetNickname();
resetProfile(); resetProfile();
resetEnableThumbnails(); resetEnableThumbnails();
resetEnableAutoplay(); resetEnableAutoplay();
@ -295,10 +306,12 @@ export function useSettingsState(
appLanguageChanged || appLanguageChanged ||
subStylingChanged || subStylingChanged ||
deviceNameChanged || deviceNameChanged ||
nicknameChanged ||
backendUrlChanged || backendUrlChanged ||
proxyUrlsChanged || proxyUrlsChanged ||
febboxKeyChanged || febboxKeyChanged ||
realDebridKeyChanged || debridTokenChanged ||
debridServiceChanged ||
profileChanged || profileChanged ||
enableThumbnailsChanged || enableThumbnailsChanged ||
enableAutoplayChanged || enableAutoplayChanged ||
@ -348,6 +361,11 @@ export function useSettingsState(
set: setDeviceNameState, set: setDeviceNameState,
changed: deviceNameChanged, changed: deviceNameChanged,
}, },
nickname: {
state: nicknameState,
set: setNicknameState,
changed: nicknameChanged,
},
proxyUrls: { proxyUrls: {
state: proxyUrlsState, state: proxyUrlsState,
set: setProxyUrls, set: setProxyUrls,
@ -363,10 +381,15 @@ export function useSettingsState(
set: setFebboxKey, set: setFebboxKey,
changed: febboxKeyChanged, changed: febboxKeyChanged,
}, },
realDebridKey: { debridToken: {
state: realDebridKeyState, state: debridTokenState,
set: setRealDebridKey, set: setdebridToken,
changed: realDebridKeyChanged, changed: debridTokenChanged,
},
debridService: {
state: debridServiceState,
set: setdebridService,
changed: debridServiceChanged,
}, },
profile: { profile: {
state: profileState, state: profileState,

View file

@ -114,6 +114,8 @@ export function AccountSettings(props: {
account: AccountWithToken; account: AccountWithToken;
deviceName: string; deviceName: string;
setDeviceName: (s: string) => void; setDeviceName: (s: string) => void;
nickname: string;
setNickname: (s: string) => void;
colorA: string; colorA: string;
setColorA: (s: string) => void; setColorA: (s: string) => void;
colorB: string; colorB: string;
@ -136,6 +138,8 @@ export function AccountSettings(props: {
<AccountEditPart <AccountEditPart
deviceName={props.deviceName} deviceName={props.deviceName}
setDeviceName={props.setDeviceName} setDeviceName={props.setDeviceName}
nickname={props.nickname}
setNickname={props.setNickname}
colorA={props.colorA} colorA={props.colorA}
setColorA={props.setColorA} setColorA={props.setColorA}
colorB={props.colorB} colorB={props.colorB}
@ -365,8 +369,10 @@ export function SettingsPage() {
const febboxKey = usePreferencesStore((s) => s.febboxKey); const febboxKey = usePreferencesStore((s) => s.febboxKey);
const setFebboxKey = usePreferencesStore((s) => s.setFebboxKey); const setFebboxKey = usePreferencesStore((s) => s.setFebboxKey);
const realDebridKey = usePreferencesStore((s) => s.realDebridKey); const debridToken = usePreferencesStore((s) => s.debridToken);
const setRealDebridKey = usePreferencesStore((s) => s.setRealDebridKey); 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 enableThumbnails = usePreferencesStore((s) => s.enableThumbnails);
const setEnableThumbnails = usePreferencesStore((s) => s.setEnableThumbnails); const setEnableThumbnails = usePreferencesStore((s) => s.setEnableThumbnails);
@ -483,10 +489,17 @@ export function SettingsPage() {
const account = useAuthStore((s) => s.account); const account = useAuthStore((s) => s.account);
const updateProfile = useAuthStore((s) => s.setAccountProfile); const updateProfile = useAuthStore((s) => s.setAccountProfile);
const updateDeviceName = useAuthStore((s) => s.updateDeviceName); const updateDeviceName = useAuthStore((s) => s.updateDeviceName);
const updateNickname = useAuthStore((s) => s.setAccountNickname);
const decryptedName = useMemo(() => { const decryptedName = useMemo(() => {
if (!account) return ""; if (!account) return "";
return decryptData(account.deviceName, base64ToBuffer(account.seed)); try {
}, [account]); 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(); const backendUrl = useBackendUrl();
@ -500,23 +513,25 @@ export function SettingsPage() {
if (settings.febboxKey) { if (settings.febboxKey) {
setFebboxKey(settings.febboxKey); setFebboxKey(settings.febboxKey);
} }
if (settings.realDebridKey) { if (settings.debridToken) {
setRealDebridKey(settings.realDebridKey); setdebridToken(settings.debridToken);
} }
} }
}; };
loadSettings(); loadSettings();
}, [account, backendUrl, setFebboxKey, setRealDebridKey]); }, [account, backendUrl, setFebboxKey, setdebridToken, setdebridService]);
const state = useSettingsState( const state = useSettingsState(
activeTheme, activeTheme,
appLanguage, appLanguage,
subStyling, subStyling,
decryptedName, decryptedName,
account?.nickname || "",
proxySet, proxySet,
backendUrlSetting, backendUrlSetting,
febboxKey, febboxKey,
realDebridKey, debridToken,
debridService,
account ? account.profile : undefined, account ? account.profile : undefined,
enableThumbnails, enableThumbnails,
enableAutoplay, enableAutoplay,
@ -586,7 +601,8 @@ export function SettingsPage() {
state.theme.changed || state.theme.changed ||
state.proxyUrls.changed || state.proxyUrls.changed ||
state.febboxKey.changed || state.febboxKey.changed ||
state.realDebridKey.changed || state.debridToken.changed ||
state.debridService.changed ||
state.enableThumbnails.changed || state.enableThumbnails.changed ||
state.enableAutoplay.changed || state.enableAutoplay.changed ||
state.enableSkipCredits.changed || state.enableSkipCredits.changed ||
@ -613,7 +629,8 @@ export function SettingsPage() {
applicationTheme: state.theme.state, applicationTheme: state.theme.state,
proxyUrls: state.proxyUrls.state?.filter((v) => v !== "") ?? null, proxyUrls: state.proxyUrls.state?.filter((v) => v !== "") ?? null,
febboxKey: state.febboxKey.state, febboxKey: state.febboxKey.state,
realDebridKey: state.realDebridKey.state, debridToken: state.debridToken.state,
debridService: state.debridService.state,
enableThumbnails: state.enableThumbnails.state, enableThumbnails: state.enableThumbnails.state,
enableAutoplay: state.enableAutoplay.state, enableAutoplay: state.enableAutoplay.state,
enableSkipCredits: state.enableSkipCredits.state, enableSkipCredits: state.enableSkipCredits.state,
@ -646,10 +663,17 @@ export function SettingsPage() {
}); });
updateDeviceName(newDeviceName); updateDeviceName(newDeviceName);
} }
if (state.profile.changed) { if (state.nickname.changed) {
await editUser(backendUrl, account, {
nickname: state.nickname.state,
});
updateNickname(state.nickname.state);
}
if (state.profile.changed && state.profile.state) {
await editUser(backendUrl, account, { await editUser(backendUrl, account, {
profile: state.profile.state, profile: state.profile.state,
}); });
updateProfile(state.profile.state);
} }
} }
@ -671,7 +695,8 @@ export function SettingsPage() {
setProxySet(state.proxyUrls.state?.filter((v) => v !== "") ?? null); setProxySet(state.proxyUrls.state?.filter((v) => v !== "") ?? null);
setEnableSourceOrder(state.enableSourceOrder.state); setEnableSourceOrder(state.enableSourceOrder.state);
setFebboxKey(state.febboxKey.state); setFebboxKey(state.febboxKey.state);
setRealDebridKey(state.realDebridKey.state); setdebridToken(state.debridToken.state);
setdebridService(state.debridService.state);
setProxyTmdb(state.proxyTmdb.state); setProxyTmdb(state.proxyTmdb.state);
setEnableCarouselView(state.enableCarouselView.state); setEnableCarouselView(state.enableCarouselView.state);
setForceCompactEpisodeView(state.forceCompactEpisodeView.state); setForceCompactEpisodeView(state.forceCompactEpisodeView.state);
@ -701,7 +726,8 @@ export function SettingsPage() {
backendUrl, backendUrl,
setEnableThumbnails, setEnableThumbnails,
setFebboxKey, setFebboxKey,
setRealDebridKey, setdebridToken,
setdebridService,
state, state,
setEnableAutoplay, setEnableAutoplay,
setEnableSkipCredits, setEnableSkipCredits,
@ -720,6 +746,7 @@ export function SettingsPage() {
setProxySet, setProxySet,
updateDeviceName, updateDeviceName,
updateProfile, updateProfile,
updateNickname,
logout, logout,
setBackendUrl, setBackendUrl,
setProxyTmdb, setProxyTmdb,
@ -754,6 +781,8 @@ export function SettingsPage() {
account={user.account} account={user.account}
deviceName={state.deviceName.state} deviceName={state.deviceName.state}
setDeviceName={state.deviceName.set} setDeviceName={state.deviceName.set}
nickname={state.nickname.state}
setNickname={state.nickname.set}
colorA={state.profile.state.colorA} colorA={state.profile.state.colorA}
setColorA={(v) => { setColorA={(v) => {
state.profile.set((s) => state.profile.set((s) =>
@ -859,8 +888,10 @@ export function SettingsPage() {
setProxyUrls={state.proxyUrls.set} setProxyUrls={state.proxyUrls.set}
febboxKey={state.febboxKey.state} febboxKey={state.febboxKey.state}
setFebboxKey={state.febboxKey.set} setFebboxKey={state.febboxKey.set}
realDebridKey={state.realDebridKey.state} debridToken={state.debridToken.state}
setRealDebridKey={state.realDebridKey.set} setdebridToken={state.debridToken.set}
debridService={state.debridService.state}
setdebridService={state.debridService.set}
proxyTmdb={state.proxyTmdb.state} proxyTmdb={state.proxyTmdb.state}
setProxyTmdb={state.proxyTmdb.set} setProxyTmdb={state.proxyTmdb.set}
/> />

View file

@ -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 { Button } from "@/components/buttons/Button";
import { Toggle } from "@/components/buttons/Toggle";
import { Dropdown } from "@/components/form/Dropdown"; import { Dropdown } from "@/components/form/Dropdown";
import { Icon, Icons } from "@/components/Icon";
import { usePlayer } from "@/components/player/hooks/usePlayer"; import { usePlayer } from "@/components/player/hooks/usePlayer";
import { Title } from "@/components/text/Title"; import { Title } from "@/components/text/Title";
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
import { TextInputControl } from "@/components/text-inputs/TextInputControl"; import { TextInputControl } from "@/components/text-inputs/TextInputControl";
import { Divider } from "@/components/utils/Divider";
import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart"; import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart";
import { PlayerPart } from "@/pages/parts/player/PlayerPart"; import { PlayerPart } from "@/pages/parts/player/PlayerPart";
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
import { SourceSliceSource, StreamType } from "@/stores/player/utils/qualities"; import { SourceSliceSource, StreamType } from "@/stores/player/utils/qualities";
import { type ExtensionStatus, getExtensionState } from "@/utils/extension";
const testMeta: PlayerMeta = { const testMeta: PlayerMeta = {
releaseYear: 2010, releaseYear: 2010,
@ -32,14 +38,64 @@ export default function VideoTesterView() {
const { status, playMedia, setMeta } = usePlayer(); const { status, playMedia, setMeta } = usePlayer();
const [selected, setSelected] = useState("mp4"); const [selected, setSelected] = useState("mp4");
const [inputSource, setInputSource] = useState(""); const [inputSource, setInputSource] = useState("");
const [extensionState, setExtensionState] =
useState<ExtensionStatus>("unknown");
const [headersEnabled, setHeadersEnabled] = useState(false);
const [headers, setHeaders] = useState<Array<{ key: string; value: string }>>(
[{ 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( const start = useCallback(
(url: string, type: StreamType) => { async (url: string, type: StreamType) => {
// Build headers object from enabled headers
const headersObj: Record<string, string> = {};
if (headersEnabled) {
headers.forEach(({ key, value }) => {
if (key.trim() && value.trim()) {
headersObj[key.trim()] = value.trim();
}
});
}
let source: SourceSliceSource; let source: SourceSliceSource;
if (type === "hls") { if (type === "hls") {
source = { source = {
type: "hls", type: "hls",
url, url,
...(Object.keys(headersObj).length > 0 && { headers: headersObj }),
}; };
} else if (type === "mp4") { } else if (type === "mp4") {
source = { source = {
@ -50,12 +106,38 @@ export default function VideoTesterView() {
url, url,
}, },
}, },
...(Object.keys(headersObj).length > 0 && { headers: headersObj }),
}; };
} else throw new Error("Invalid type"); } 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); setMeta(testMeta);
playMedia(source, [], null); playMedia(source, [], null);
}, },
[playMedia, setMeta], [playMedia, setMeta, headersEnabled, headers, extensionState],
); );
return ( return (
@ -85,13 +167,73 @@ export default function VideoTesterView() {
setSelectedItem={(item) => setSelected(item.id)} setSelectedItem={(item) => setSelected(item.id)}
/> />
</div> </div>
{extensionState === "success" && (
<div className="flex-1 mb-4">
<div className="flex justify-between items-center gap-4">
<div className="my-3">
<p className="text-white font-bold">Headers (Beta)</p>
</div>
<div>
<Toggle
onClick={toggleHeaders}
enabled={headersEnabled}
/>
</div>
</div>
{headersEnabled && (
<>
<Divider marginClass="my-6 px-8 box-content -mx-8" />
<div className="my-6 space-y-2">
{headers.length === 0 ? (
<p>No headers configured.</p>
) : (
headers.map((header, index) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={index}
className="grid grid-cols-[1fr,1fr,auto] items-center gap-2"
>
<AuthInputBox
value={header.key}
onChange={(value) =>
updateHeader(index, "key", value)
}
placeholder="Key"
/>
<AuthInputBox
value={header.value}
onChange={(value) =>
updateHeader(index, "value", value)
}
placeholder="Value"
/>
<button
type="button"
onClick={() => removeHeader(index)}
className="h-full scale-90 hover:scale-100 rounded-full aspect-square bg-authentication-inputBg hover:bg-authentication-inputBgHover flex justify-center items-center transition-transform duration-200 hover:text-white cursor-pointer"
>
<Icon className="text-xl" icon={Icons.X} />
</button>
</div>
))
)}
</div>
<Button theme="purple" onClick={addHeader}>
Add header
</Button>
</>
)}
</div>
)}
<Button <Button
onClick={() => start(inputSource, selected as StreamType)} onClick={() => start(inputSource, selected as StreamType)}
> >
Start stream Start stream
</Button> </Button>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<Title>Preset tests</Title> <Title>Preset tests</Title>
<div className="grid grid-cols-[1fr,1fr] gap-2"> <div className="grid grid-cols-[1fr,1fr] gap-2">

View file

@ -97,6 +97,11 @@ export function MoreContent({ onShowDetails }: MoreContentProps) {
setIsContentVisible(false); setIsContentVisible(false);
}, [isLoading, mediaItems, currentPage]); }, [isLoading, mediaItems, currentPage]);
// Scroll to top when entering the page
useEffect(() => {
window.scrollTo(0, 0);
}, [contentType, mediaType, id]);
const handleBack = () => { const handleBack = () => {
if (lastView) { if (lastView) {
navigate(lastView.url); navigate(lastView.url);

View file

@ -198,7 +198,7 @@ export function MediaCarousel({
]); ]);
// Fetch media using our hook - only when carousel has been visible // Fetch media using our hook - only when carousel has been visible
const { media, sectionTitle } = useDiscoverMedia({ const { media, sectionTitle, actualContentType } = useDiscoverMedia({
contentType, contentType,
mediaType, mediaType,
id: selectedProviderId || selectedGenreId || selectedRecommendationId, id: selectedProviderId || selectedGenreId || selectedRecommendationId,
@ -298,7 +298,7 @@ export function MediaCarousel({
if (showRecommendations && selectedRecommendationId) { if (showRecommendations && selectedRecommendationId) {
return `${baseLink}/recommendations/${selectedRecommendationId}/${mediaType}`; return `${baseLink}/recommendations/${selectedRecommendationId}/${mediaType}`;
} }
return `${baseLink}/${content.type}/${mediaType}`; return `${baseLink}/${actualContentType}/${mediaType}`;
}, [ }, [
moreLink, moreLink,
showProviders, showProviders,
@ -308,7 +308,7 @@ export function MediaCarousel({
showRecommendations, showRecommendations,
selectedRecommendationId, selectedRecommendationId,
mediaType, mediaType,
content.type, actualContentType,
]); ]);
// Loading state // Loading state

View file

@ -121,6 +121,8 @@ export function useDiscoverMedia({
const [sectionTitle, setSectionTitle] = useState<string>(""); const [sectionTitle, setSectionTitle] = useState<string>("");
const [currentContentType, setCurrentContentType] = const [currentContentType, setCurrentContentType] =
useState<string>(contentType); useState<string>(contentType);
const [actualContentType, setActualContentType] =
useState<DiscoverContentType>(contentType);
const { t } = useTranslation(); const { t } = useTranslation();
const userLanguage = useLanguageStore((s) => s.language); const userLanguage = useLanguageStore((s) => s.language);
@ -131,6 +133,7 @@ export function useDiscoverMedia({
if (contentType !== currentContentType) { if (contentType !== currentContentType) {
setMedia([]); setMedia([]);
setCurrentContentType(contentType); setCurrentContentType(contentType);
setActualContentType(contentType); // Reset actual content type to original
} }
}, [contentType, currentContentType]); }, [contentType, currentContentType]);
@ -475,6 +478,7 @@ export function useDiscoverMedia({
console.info(`Falling back from ${contentType} to ${fallbackType}`); console.info(`Falling back from ${contentType} to ${fallbackType}`);
try { try {
const fallbackData = await attemptFetch(fallbackType); const fallbackData = await attemptFetch(fallbackType);
setActualContentType(fallbackType); // Set actual content type to fallback
setMedia((prevMedia) => { setMedia((prevMedia) => {
// If page is 1, replace the media array, otherwise append // If page is 1, replace the media array, otherwise append
return page === 1 return page === 1
@ -526,5 +530,6 @@ export function useDiscoverMedia({
hasMore, hasMore,
refetch: fetchMedia, refetch: fetchMedia,
sectionTitle, sectionTitle,
actualContentType,
}; };
} }

View file

@ -47,6 +47,7 @@ export interface UseDiscoverMediaReturn {
hasMore: boolean; hasMore: boolean;
refetch: () => Promise<void>; refetch: () => Promise<void>;
sectionTitle: string; sectionTitle: string;
actualContentType: DiscoverContentType;
} }
export interface Provider { export interface Provider {

View file

@ -1,10 +1,7 @@
import { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button"; import { Button } from "@/components/buttons/Button";
import { Toggle } from "@/components/buttons/Toggle";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { SettingsCard } from "@/components/layout/SettingsCard";
import { Stepper } from "@/components/layout/Stepper"; import { Stepper } from "@/components/layout/Stepper";
import { BiggerCenterContainer } from "@/components/layout/ThinContainer"; import { BiggerCenterContainer } from "@/components/layout/ThinContainer";
import { VerticalLine } from "@/components/layout/VerticalLine"; import { VerticalLine } from "@/components/layout/VerticalLine";
@ -14,11 +11,6 @@ import {
ModalCard, ModalCard,
useModal, useModal,
} from "@/components/overlays/Modal"; } 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 { Divider } from "@/components/utils/Divider";
import { Ol } from "@/components/utils/Ol"; import { Ol } from "@/components/utils/Ol";
import { import {
@ -43,133 +35,7 @@ import { conf } from "@/setup/config";
import { usePreferencesStore } from "@/stores/preferences"; import { usePreferencesStore } from "@/stores/preferences";
import { getProxyUrls } from "@/utils/proxyUrls"; import { getProxyUrls } from "@/utils/proxyUrls";
import { FebboxSetup } from "../parts/settings/ConnectionsPart"; import { DebridEdit, 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<Status>("unset");
const statusMap: Record<Status, StatusCircleProps["type"]> = {
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 (
<div className="mt-6">
<SettingsCard>
<div className="flex justify-between items-center gap-4">
<div className="my-3">
<p className="text-white font-bold mb-3">
{t("settings.connections.realdebrid.title", "Real Debrid API")}
</p>
<p className="max-w-[30rem] font-medium">
{t(
"settings.connections.realdebrid.description",
"Enter your Real Debrid API key to access premium sources.",
)}
</p>
</div>
<div>
<Toggle onClick={toggleExpanded} enabled={isExpanded} />
</div>
</div>
{isExpanded ? (
<>
<Divider marginClass="my-6 px-8 box-content -mx-8" />
<p className="text-white font-bold mb-3">
{t("settings.connections.realdebrid.tokenLabel", "API Key")}
</p>
<div className="flex items-center w-full">
<StatusCircle type={statusMap[status]} className="mx-2 mr-4" />
<AuthInputBox
onChange={(newToken) => {
setRealDebridKey(newToken);
}}
value={realDebridKey ?? ""}
placeholder="API Key"
passwordToggleable
className="flex-grow"
/>
</div>
{status === "error" && (
<p className="text-type-danger mt-4">
{t(
"settings.connections.realdebrid.status.failure",
"Failed to connect to Real Debrid. Please check your API key.",
)}
</p>
)}
{status === "api_down" && (
<p className="text-type-danger mt-4">
{t(
"settings.connections.realdebrid.status.api_down",
"Real Debrid API is currently unavailable. Please try again later.",
)}
</p>
)}
{status === "invalid_token" && (
<p className="text-type-danger mt-4">
{t(
"settings.connections.realdebrid.status.invalid_token",
"Invalid API key or non-premium account. Real Debrid requires a premium account.",
)}
</p>
)}
</>
) : null}
</SettingsCard>
</div>
);
}
return null;
}
function Item(props: { title: string; children: React.ReactNode }) { function Item(props: { title: string; children: React.ReactNode }) {
return ( return (
@ -419,7 +285,6 @@ export function OnboardingPage() {
)} )}
</div> </div>
{/* <RealDebridSetup /> */}
<div className="mt-6"> <div className="mt-6">
<FebboxSetup <FebboxSetup
febboxKey={usePreferencesStore((s) => s.febboxKey)} febboxKey={usePreferencesStore((s) => s.febboxKey)}
@ -427,6 +292,15 @@ export function OnboardingPage() {
mode="onboarding" mode="onboarding"
/> />
</div> </div>
<div className="mt-6">
<DebridEdit
debridToken={usePreferencesStore((s) => s.debridToken)}
setdebridToken={usePreferencesStore((s) => s.setdebridToken)}
debridService={usePreferencesStore((s) => s.debridService)}
setdebridService={usePreferencesStore((s) => s.setdebridService)}
mode="onboarding"
/>
</div>
</BiggerCenterContainer> </BiggerCenterContainer>
</MinimalPageLayout> </MinimalPageLayout>
); );

View file

@ -6,16 +6,10 @@ import { Button } from "@/components/buttons/Button";
import { Toggle } from "@/components/buttons/Toggle"; import { Toggle } from "@/components/buttons/Toggle";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { Box } from "@/components/layout/Box"; import { Box } from "@/components/layout/Box";
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
import { Divider } from "@/components/utils/Divider"; import { Divider } from "@/components/utils/Divider";
import { Heading2 } from "@/components/utils/Text"; import { Heading2 } from "@/components/utils/Text";
import { getM3U8ProxyUrls } from "@/utils/proxyUrls"; import { getM3U8ProxyUrls } from "@/utils/proxyUrls";
interface M3U8Proxy {
id: string;
url: string;
}
export function M3U8ProxyItem(props: { export function M3U8ProxyItem(props: {
name: string; name: string;
errored?: boolean; errored?: boolean;
@ -63,26 +57,13 @@ export function M3U8ProxyItem(props: {
} }
export function M3U8TestPart() { export function M3U8TestPart() {
const defaultProxyList = useMemo(() => { const m3u8ProxyList = useMemo(() => {
return getM3U8ProxyUrls().map((v, ind) => ({ return getM3U8ProxyUrls().map((v, ind) => ({
id: ind.toString(), id: ind.toString(),
url: v, url: v,
})); }));
}, []); }, []);
// Load editable proxy list from localStorage
const [m3u8ProxyList, setM3u8ProxyList] = useState<M3U8Proxy[]>(() => {
const saved = localStorage.getItem("m3u8-proxy-list");
if (saved) {
try {
return JSON.parse(saved);
} catch {
return defaultProxyList;
}
}
return defaultProxyList;
});
// Load enabled proxies from localStorage // Load enabled proxies from localStorage
const [enabledProxies, setEnabledProxies] = useState<Record<string, boolean>>( const [enabledProxies, setEnabledProxies] = useState<Record<string, boolean>>(
() => { () => {
@ -99,11 +80,6 @@ export function M3U8TestPart() {
}, },
); );
// Save proxy list to localStorage
useEffect(() => {
localStorage.setItem("m3u8-proxy-list", JSON.stringify(m3u8ProxyList));
}, [m3u8ProxyList]);
// Save enabled proxies to localStorage // Save enabled proxies to localStorage
useEffect(() => { useEffect(() => {
localStorage.setItem("m3u8-proxy-enabled", JSON.stringify(enabledProxies)); localStorage.setItem("m3u8-proxy-enabled", JSON.stringify(enabledProxies));
@ -130,9 +106,9 @@ export function M3U8TestPart() {
setProxyState([]); setProxyState([]);
const activeProxies = m3u8ProxyList.filter( const activeProxies = m3u8ProxyList.filter(
(proxy: M3U8Proxy) => enabledProxies[proxy.id], (proxy) => enabledProxies[proxy.id],
); );
const proxyPromises = activeProxies.map(async (proxy: M3U8Proxy) => { const proxyPromises = activeProxies.map(async (proxy) => {
try { try {
if (proxy.url.endsWith("/")) { if (proxy.url.endsWith("/")) {
updateProxy(proxy.id, { updateProxy(proxy.id, {
@ -177,76 +153,29 @@ export function M3U8TestPart() {
})); }));
}; };
const addProxy = () => { const allEnabled = m3u8ProxyList.every((proxy) => enabledProxies[proxy.id]);
const newId = Date.now().toString(); const noneEnabled = m3u8ProxyList.every((proxy) => !enabledProxies[proxy.id]);
setM3u8ProxyList((prev: M3U8Proxy[]) => [...prev, { id: newId, url: "" }]);
setEnabledProxies((prev) => ({ ...prev, [newId]: true }));
};
const changeProxy = (id: string, url: string) => {
setM3u8ProxyList((prev: M3U8Proxy[]) =>
prev.map((proxy: M3U8Proxy) =>
proxy.id === id ? { ...proxy, url } : proxy,
),
);
};
const removeProxy = (id: string) => {
setM3u8ProxyList((prev: M3U8Proxy[]) =>
prev.filter((proxy: M3U8Proxy) => proxy.id !== id),
);
setEnabledProxies((prev) => {
const newEnabled = { ...prev };
delete newEnabled[id];
return newEnabled;
});
};
const resetProxies = () => {
setM3u8ProxyList(defaultProxyList);
setEnabledProxies(
Object.fromEntries(
defaultProxyList.map((proxy: M3U8Proxy) => [proxy.id, true]),
),
);
};
const allEnabled = m3u8ProxyList.every(
(proxy: M3U8Proxy) => enabledProxies[proxy.id],
);
const noneEnabled = m3u8ProxyList.every(
(proxy: M3U8Proxy) => !enabledProxies[proxy.id],
);
const handleToggleAll = () => { const handleToggleAll = () => {
if (allEnabled) { if (allEnabled) {
// Disable all // Disable all
setEnabledProxies( setEnabledProxies(
Object.fromEntries( Object.fromEntries(m3u8ProxyList.map((proxy) => [proxy.id, false])),
m3u8ProxyList.map((proxy: M3U8Proxy) => [proxy.id, false]),
),
); );
} else { } else {
// Enable all // Enable all
setEnabledProxies( setEnabledProxies(
Object.fromEntries( Object.fromEntries(m3u8ProxyList.map((proxy) => [proxy.id, true])),
m3u8ProxyList.map((proxy: M3U8Proxy) => [proxy.id, true]),
),
); );
} }
}; };
const enabledCount = m3u8ProxyList.filter( const enabledCount = m3u8ProxyList.filter(
(proxy: M3U8Proxy) => enabledProxies[proxy.id], (proxy) => enabledProxies[proxy.id],
).length; ).length;
return ( return (
<> <>
<Heading2 className="!mb-0 mt-12">M3U8 Proxy Configuration</Heading2>
<Box>
<p className="text-white font-bold mb-3">M3U8 Proxy URLs</p>
</Box>
<Heading2 className="!mb-0 mt-12">M3U8 Proxy tests</Heading2> <Heading2 className="!mb-0 mt-12">M3U8 Proxy tests</Heading2>
<div className="flex items-center justify-between mb-8 mt-2"> <div className="flex items-center justify-between mb-8 mt-2">
<p> <p>
@ -262,7 +191,7 @@ export function M3U8TestPart() {
</Button> </Button>
</div> </div>
<Box> <Box>
{m3u8ProxyList.map((v: M3U8Proxy, i: number) => { {m3u8ProxyList.map((v, i) => {
const s = proxyState.find((segment) => segment.id === v.id); const s = proxyState.find((segment) => segment.id === v.id);
const name = `M3U8 Proxy ${i + 1}`; const name = `M3U8 Proxy ${i + 1}`;
const enabled = enabledProxies[v.id]; const enabled = enabledProxies[v.id];
@ -380,40 +309,6 @@ export function M3U8TestPart() {
</Button> </Button>
)} )}
</div> </div>
<Divider />
<div className="my-6 space-y-2">
{m3u8ProxyList.length === 0 ? (
<p>No M3U8 proxies configured.</p>
) : (
m3u8ProxyList.map((proxy: M3U8Proxy) => (
<div
key={proxy.id}
className="grid grid-cols-[1fr,auto] items-center gap-2"
>
<AuthInputBox
value={proxy.url}
onChange={(url) => changeProxy(proxy.id, url)}
placeholder="https://"
/>
<button
type="button"
onClick={() => removeProxy(proxy.id)}
className="h-full scale-90 hover:scale-100 rounded-full aspect-square bg-authentication-inputBg hover:bg-authentication-inputBgHover flex justify-center items-center transition-transform duration-200 hover:text-white cursor-pointer"
>
<Icon className="text-xl" icon={Icons.X} />
</button>
</div>
))
)}
</div>
<div className="flex gap-2">
<Button theme="purple" onClick={addProxy}>
Add Proxy
</Button>
<Button theme="secondary" onClick={resetProxies}>
Reset to Default
</Button>
</div>
</Box> </Box>
</> </>
); );

View file

@ -59,7 +59,8 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) {
disabledEmbeds: store.disabledEmbeds, disabledEmbeds: store.disabledEmbeds,
proxyTmdb: store.proxyTmdb, proxyTmdb: store.proxyTmdb,
febboxKey: store.febboxKey, febboxKey: store.febboxKey,
realDebridKey: store.realDebridKey, debridToken: store.debridToken,
debridService: store.debridService,
enableLowPerformanceMode: store.enableLowPerformanceMode, enableLowPerformanceMode: store.enableLowPerformanceMode,
enableNativeSubtitles: store.enableNativeSubtitles, enableNativeSubtitles: store.enableNativeSubtitles,
enableHoldToBoost: store.enableHoldToBoost, enableHoldToBoost: store.enableHoldToBoost,

View file

@ -13,6 +13,8 @@ import { ProfileEditModal } from "@/pages/parts/settings/ProfileEditModal";
export function AccountEditPart(props: { export function AccountEditPart(props: {
deviceName: string; deviceName: string;
setDeviceName: (s: string) => void; setDeviceName: (s: string) => void;
nickname: string;
setNickname: (s: string) => void;
colorA: string; colorA: string;
setColorA: (s: string) => void; setColorA: (s: string) => void;
colorB: string; colorB: string;
@ -59,24 +61,38 @@ export function AccountEditPart(props: {
/> />
</div> </div>
<div> <div>
<div className="space-y-8 max-w-xs"> <div className="flex flex-col md:flex-row md:gap-4 gap-4">
<AuthInputBox <div className="w-full">
label={ <AuthInputBox
t("settings.account.accountDetails.deviceNameLabel") ?? label={t("settings.account.accountDetails.nicknameLabel")}
undefined placeholder={t(
} "settings.account.accountDetails.nicknamePlaceholder",
placeholder={ )}
t("settings.account.accountDetails.deviceNamePlaceholder") ?? value={props.nickname}
undefined onChange={(value) => props.setNickname(value)}
} className="w-full"
value={props.deviceName} />
onChange={(value) => props.setDeviceName(value)}
/>
<div className="flex space-x-3">
<Button className="logout-button" theme="danger" onClick={logout}>
{t("settings.account.accountDetails.logoutButton")}
</Button>
</div> </div>
<div className="w-full">
<AuthInputBox
label={
t("settings.account.accountDetails.deviceNameLabel") ??
undefined
}
placeholder={
t("settings.account.accountDetails.deviceNamePlaceholder") ??
undefined
}
value={props.deviceName}
onChange={(value) => props.setDeviceName(value)}
className="w-full"
/>
</div>
</div>
<div className="flex space-x-3 mt-4">
<Button className="logout-button" theme="danger" onClick={logout}>
{t("settings.account.accountDetails.logoutButton")}
</Button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -9,6 +9,7 @@ import { Trans, useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button"; import { Button } from "@/components/buttons/Button";
import { Toggle } from "@/components/buttons/Toggle"; import { Toggle } from "@/components/buttons/Toggle";
import { Dropdown } from "@/components/form/Dropdown";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { SettingsCard } from "@/components/layout/SettingsCard"; import { SettingsCard } from "@/components/layout/SettingsCard";
import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; import { Modal, ModalCard, useModal } from "@/components/overlays/Modal";
@ -24,7 +25,8 @@ import {
SetupPart, SetupPart,
Status, Status,
testFebboxKey, testFebboxKey,
testRealDebridKey, testTorboxToken,
testdebridToken,
} from "@/pages/parts/settings/SetupPart"; } from "@/pages/parts/settings/SetupPart";
import { conf } from "@/setup/config"; import { conf } from "@/setup/config";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
@ -49,9 +51,13 @@ interface FebboxKeyProps {
setFebboxKey: (value: string | null) => void; setFebboxKey: (value: string | null) => void;
} }
interface RealDebridKeyProps { interface DebridProps {
realDebridKey: string | null; debridToken: string | null;
setRealDebridKey: Dispatch<SetStateAction<string | null>>; 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({ function ProxyEdit({
@ -252,14 +258,14 @@ export function FebboxSetup({
const exampleModal = useModal("febbox-example"); const exampleModal = useModal("febbox-example");
// Initialize expansion state for onboarding mode // Initialize expansion state for onboarding mode
const [isExpanded, setIsExpanded] = useState( const [isFebboxExpanded, setIsFebboxExpanded] = useState(
mode === "onboarding" && febboxKey !== null && febboxKey !== "", mode === "onboarding" && febboxKey !== null && febboxKey !== "",
); );
// Expand when key is set in onboarding mode // Expand when key is set in onboarding mode
useEffect(() => { useEffect(() => {
if (mode === "onboarding" && febboxKey && febboxKey.length > 0) { if (mode === "onboarding" && febboxKey && febboxKey.length > 0) {
setIsExpanded(true); setIsFebboxExpanded(true);
} }
}, [febboxKey, mode]); }, [febboxKey, mode]);
@ -293,14 +299,14 @@ export function FebboxSetup({
}, [febboxKey]); }, [febboxKey]);
// Toggle handler based on mode // Toggle handler based on mode
const toggleExpanded = () => { const toggleFebboxExpanded = () => {
if (mode === "onboarding") { if (mode === "onboarding") {
// Onboarding mode: expand/collapse, preserve key // Onboarding mode: expand/collapse, preserve key
if (isExpanded) { if (isFebboxExpanded) {
setFebboxKey(""); setFebboxKey("");
setIsExpanded(false); setIsFebboxExpanded(false);
} else { } else {
setIsExpanded(true); setIsFebboxExpanded(true);
} }
} else { } else {
// Settings mode: enable/disable // Settings mode: enable/disable
@ -309,7 +315,8 @@ export function FebboxSetup({
}; };
// Determine if content is visible // Determine if content is visible
const isVisible = mode === "onboarding" ? isExpanded : febboxKey !== null; const isFebboxVisible =
mode === "onboarding" ? isFebboxExpanded : febboxKey !== null;
if (conf().ALLOW_FEBBOX_KEY) { if (conf().ALLOW_FEBBOX_KEY) {
return ( return (
@ -326,14 +333,14 @@ export function FebboxSetup({
</div> </div>
<div> <div>
<Toggle <Toggle
onClick={toggleExpanded} onClick={toggleFebboxExpanded}
enabled={ enabled={
mode === "onboarding" ? isExpanded : febboxKey !== null mode === "onboarding" ? isFebboxExpanded : febboxKey !== null
} }
/> />
</div> </div>
</div> </div>
{isVisible ? ( {isFebboxVisible ? (
<> <>
<Divider marginClass="my-6 px-8 box-content -mx-8" /> <Divider marginClass="my-6 px-8 box-content -mx-8" />
@ -460,33 +467,69 @@ export function FebboxSetup({
} }
} }
async function getRealDebridKeyStatus(realDebridKey: string | null) { async function getdebridTokenStatus(
if (realDebridKey) { debridToken: string | null,
const status: Status = await testRealDebridKey(realDebridKey); debridService: string,
) {
if (debridToken) {
const status: Status =
debridService === "torbox"
? await testTorboxToken(debridToken)
: await testdebridToken(debridToken);
return status; return status;
} }
return "unset"; return "unset";
} }
function RealDebridKeyEdit({ export function DebridEdit({
realDebridKey, debridToken,
setRealDebridKey, setdebridToken,
}: RealDebridKeyProps) { debridService,
setdebridService,
mode = "settings",
}: DebridProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const user = useAuthStore(); const user = useAuthStore();
const preferences = usePreferencesStore(); 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 // Enable Real Debrid token when account is loaded and we have a token
useEffect(() => { useEffect(() => {
if (user.account && realDebridKey === null && preferences.realDebridKey) { if (user.account && debridToken === null && preferences.debridToken) {
setRealDebridKey(preferences.realDebridKey); setdebridToken(preferences.debridToken);
} }
}, [ }, [user.account, debridToken, preferences.debridToken, setdebridToken]);
user.account,
realDebridKey, // Determine if content is visible
preferences.realDebridKey, const isDebridVisible =
setRealDebridKey, 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<Status>("unset"); const [status, setStatus] = useState<Status>("unset");
const statusMap: Record<Status, StatusCircleProps["type"]> = { const statusMap: Record<Status, StatusCircleProps["type"]> = {
@ -499,69 +542,84 @@ function RealDebridKeyEdit({
useEffect(() => { useEffect(() => {
const checkTokenStatus = async () => { const checkTokenStatus = async () => {
const result = await getRealDebridKeyStatus(realDebridKey); const result = await getdebridTokenStatus(debridToken, debridService);
setStatus(result); setStatus(result);
}; };
checkTokenStatus(); checkTokenStatus();
}, [realDebridKey]); }, [debridToken, debridService]);
if (conf().ALLOW_REAL_DEBRID_KEY) { if (conf().ALLOW_DEBRID_KEY) {
return ( return (
<SettingsCard> <SettingsCard>
<div className="flex justify-between items-center gap-4"> <div className="flex justify-between items-center gap-4">
<div className="my-3"> <div className="my-3">
<p className="text-white font-bold mb-3">{t("realdebrid.title")}</p> <p className="text-white font-bold mb-3">{t("debrid.title")}</p>
<p className="max-w-[30rem] font-medium"> <Trans i18nKey="debrid.description">
{t("realdebrid.description")} <MwLink to="https://real-debrid.com/" />
{/* fifth's referral code */}
<MwLink to="https://torbox.app/subscription?referral=3f665ece-0405-4012-9db7-c6f90e8567e1" />
</Trans>
<p className="text-type-danger mt-2 max-w-[30rem]">
{t("debrid.notice")}
</p> </p>
<MwLink>
<a
href="https://real-debrid.com/"
target="_blank"
rel="noreferrer"
>
real-debrid.com
</a>
</MwLink>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Toggle <Toggle onClick={toggleDebridExpanded} enabled={isDebridVisible} />
onClick={() => setRealDebridKey((s) => (s === null ? "" : null))}
enabled={realDebridKey !== null}
/>
</div> </div>
</div> </div>
{realDebridKey !== null ? ( {isDebridVisible ? (
<> <>
<Divider marginClass="my-6 px-8 box-content -mx-8" /> <Divider marginClass="my-6 px-8 box-content -mx-8" />
<p className="text-white font-bold mb-3"> <p className="text-white font-bold mb-3">
{t("realdebrid.tokenLabel")} {t("debrid.tokenLabel")}
</p> </p>
<div className="flex items-center w-full"> <div className="flex md:flex-row flex-col items-center w-full gap-4">
<StatusCircle type={statusMap[status]} className="mx-2 mr-4" /> <div className="flex items-center w-full">
<AuthInputBox <StatusCircle type={statusMap[status]} className="mx-2 mr-4" />
onChange={(newToken) => { <AuthInputBox
setRealDebridKey(newToken); onChange={(newToken) => {
}} setdebridToken(newToken);
value={realDebridKey ?? ""} }}
placeholder="ABC123..." value={debridToken ?? ""}
passwordToggleable placeholder="ABC123..."
className="flex-grow" passwordToggleable
/> className="flex-grow"
/>
</div>
<div className="flex items-center">
<Dropdown
options={[
{
id: "realdebrid",
name: t("debrid.serviceOptions.realdebrid"),
},
{
id: "torbox",
name: t("debrid.serviceOptions.torbox"),
},
]}
selectedItem={{
id: debridService,
name: t(`debrid.serviceOptions.${debridService}`),
}}
setSelectedItem={(item) => setdebridService(item.id)}
direction="up"
/>
</div>
</div> </div>
{status === "error" && ( {status === "error" && (
<p className="text-type-danger mt-4"> <p className="text-type-danger mt-4">
{t("realdebrid.status.failure")} {t("debrid.status.failure")}
</p> </p>
)} )}
{status === "api_down" && ( {status === "api_down" && (
<p className="text-type-danger mt-4"> <p className="text-type-danger mt-4">
{t("realdebrid.status.api_down")} {t("debrid.status.api_down")}
</p> </p>
)} )}
{status === "invalid_token" && ( {status === "invalid_token" && (
<p className="text-type-danger mt-4"> <p className="text-type-danger mt-4">
{t("realdebrid.status.invalid_token")} {t("debrid.status.invalid_token")}
</p> </p>
)} )}
</> </>
@ -573,10 +631,7 @@ function RealDebridKeyEdit({
} }
export function ConnectionsPart( export function ConnectionsPart(
props: BackendEditProps & props: BackendEditProps & ProxyEditProps & FebboxKeyProps & DebridProps,
ProxyEditProps &
FebboxKeyProps &
RealDebridKeyProps,
) { ) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@ -594,15 +649,18 @@ export function ConnectionsPart(
backendUrl={props.backendUrl} backendUrl={props.backendUrl}
setBackendUrl={props.setBackendUrl} setBackendUrl={props.setBackendUrl}
/> />
<RealDebridKeyEdit
realDebridKey={props.realDebridKey}
setRealDebridKey={props.setRealDebridKey}
/>
<FebboxSetup <FebboxSetup
febboxKey={props.febboxKey} febboxKey={props.febboxKey}
setFebboxKey={props.setFebboxKey} setFebboxKey={props.setFebboxKey}
mode="settings" mode="settings"
/> />
<DebridEdit
debridToken={props.debridToken}
setdebridToken={props.setdebridToken}
debridService={props.debridService}
setdebridService={props.setdebridService}
mode="settings"
/>
</div> </div>
</div> </div>
); );

View file

@ -75,7 +75,16 @@ export function DeviceListPart(props: {
const deviceListSorted = useMemo(() => { const deviceListSorted = useMemo(() => {
if (!seed) return []; if (!seed) return [];
let list = sessions.map((session) => { 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 { return {
current: session.id === currentSessionId, current: session.id === currentSessionId,
id: session.id, id: session.id,
@ -88,7 +97,7 @@ export function DeviceListPart(props: {
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
return list; return list;
}, [seed, sessions, currentSessionId]); }, [seed, sessions, currentSessionId, t]);
if (!seed) return null; if (!seed) return null;
return ( return (
@ -96,10 +105,10 @@ export function DeviceListPart(props: {
<Heading2 border className="mt-0 mb-9"> <Heading2 border className="mt-0 mb-9">
{t("settings.account.devices.title")} {t("settings.account.devices.title")}
</Heading2> </Heading2>
{props.error ? ( {props.loading ? (
<p>{t("settings.account.devices.failed")}</p>
) : props.loading ? (
<Loading /> <Loading />
) : props.error && deviceListSorted.length === 0 ? (
<p>{t("settings.account.devices.failed")}</p>
) : ( ) : (
<div className="space-y-5"> <div className="space-y-5">
{deviceListSorted.map((session) => ( {deviceListSorted.map((session) => (

View file

@ -1,5 +1,6 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import classNames from "classnames"; import classNames from "classnames";
import { FetchError } from "ofetch";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@ -42,7 +43,7 @@ type SetupData = {
proxy: Status; proxy: Status;
defaultProxy: Status; defaultProxy: Status;
febboxKeyTest?: Status; febboxKeyTest?: Status;
realDebridKeyTest?: Status; debridTokenTest?: Status;
}; };
function testProxy(url: string) { function testProxy(url: string) {
@ -58,7 +59,7 @@ function testProxy(url: string) {
} }
export async function testFebboxKey(febboxKey: string | null): Promise<Status> { export async function testFebboxKey(febboxKey: string | null): Promise<Status> {
const febboxApiTestUrl = `https://fed-api.pstream.mov/movie/412059`; const febboxApiTestUrl = `https://fed-api.pstream.mov/movie/tt0325980`;
if (!febboxKey) { if (!febboxKey) {
return "unset"; return "unset";
@ -142,10 +143,10 @@ export async function testFebboxKey(febboxKey: string | null): Promise<Status> {
return "api_down"; return "api_down";
} }
export async function testRealDebridKey( export async function testdebridToken(
realDebridKey: string | null, debridToken: string | null,
): Promise<Status> { ): Promise<Status> {
if (!realDebridKey) { if (!debridToken) {
return "unset"; return "unset";
} }
@ -160,7 +161,7 @@ export async function testRealDebridKey(
{ {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${realDebridKey}`, Authorization: `Bearer ${debridToken}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}, },
@ -174,12 +175,32 @@ export async function testRealDebridKey(
console.log("RD response did not indicate premium status"); console.log("RD response did not indicate premium status");
attempts += 1; attempts += 1;
if (attempts === maxAttempts) { if (attempts === maxAttempts || data?.error_code === 8) {
return "invalid_token"; return "invalid_token";
} }
await sleep(3000); await sleep(3000);
} catch (error) { } catch (error) {
console.error("RD API error:", 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; attempts += 1;
if (attempts === maxAttempts) { if (attempts === maxAttempts) {
return "api_down"; return "api_down";
@ -191,10 +212,22 @@ export async function testRealDebridKey(
return "api_down"; return "api_down";
} }
export async function testTorboxToken(
torboxToken: string | null,
): Promise<Status> {
if (!torboxToken) {
return "unset";
}
// TODO: Implement Torbox token test
return "success";
}
function useIsSetup() { function useIsSetup() {
const proxyUrls = useAuthStore((s) => s.proxySet); const proxyUrls = useAuthStore((s) => s.proxySet);
const febboxKey = usePreferencesStore((s) => s.febboxKey); const febboxKey = usePreferencesStore((s) => s.febboxKey);
const realDebridKey = usePreferencesStore((s) => s.realDebridKey); const debridToken = usePreferencesStore((s) => s.debridToken);
const debridService = usePreferencesStore((s) => s.debridService);
const { loading, value } = useAsync(async (): Promise<SetupData> => { const { loading, value } = useAsync(async (): Promise<SetupData> => {
const extensionStatus: Status = (await isExtensionActive()) const extensionStatus: Status = (await isExtensionActive())
? "success" ? "success"
@ -210,7 +243,10 @@ function useIsSetup() {
} }
const febboxKeyStatus: Status = await testFebboxKey(febboxKey); const febboxKeyStatus: Status = await testFebboxKey(febboxKey);
const realDebridKeyStatus: Status = await testRealDebridKey(realDebridKey); const debridTokenStatus: Status =
debridService === "torbox"
? await testTorboxToken(debridToken)
: await testdebridToken(debridToken);
return { return {
extension: extensionStatus, extension: extensionStatus,
@ -219,23 +255,23 @@ function useIsSetup() {
...(conf().ALLOW_FEBBOX_KEY && { ...(conf().ALLOW_FEBBOX_KEY && {
febboxKeyTest: febboxKeyStatus, febboxKeyTest: febboxKeyStatus,
}), }),
realDebridKeyTest: realDebridKeyStatus, debridTokenTest: debridTokenStatus,
}; };
}, [proxyUrls, febboxKey, realDebridKey]); }, [proxyUrls, febboxKey, debridToken, debridService]);
let globalState: Status = "unset"; let globalState: Status = "unset";
if ( if (
value?.extension === "success" || value?.extension === "success" ||
value?.proxy === "success" || value?.proxy === "success" ||
value?.febboxKeyTest === "success" || value?.febboxKeyTest === "success" ||
value?.realDebridKeyTest === "success" value?.debridTokenTest === "success"
) )
globalState = "success"; globalState = "success";
if ( if (
value?.proxy === "error" || value?.proxy === "error" ||
value?.extension === "error" || value?.extension === "error" ||
value?.febboxKeyTest === "error" || value?.febboxKeyTest === "error" ||
value?.realDebridKeyTest === "error" value?.debridTokenTest === "error"
) )
globalState = "error"; globalState = "error";
@ -375,9 +411,9 @@ export function SetupPart() {
> >
{t("settings.connections.setup.items.default")} {t("settings.connections.setup.items.default")}
</SetupCheckList> </SetupCheckList>
{conf().ALLOW_REAL_DEBRID_KEY && ( {conf().ALLOW_DEBRID_KEY && (
<SetupCheckList status={setupStates.realDebridKeyTest || "unset"}> <SetupCheckList status={setupStates.debridTokenTest || "unset"}>
Real Debrid token Debrid Service
</SetupCheckList> </SetupCheckList>
)} )}
{conf().ALLOW_FEBBOX_KEY && ( {conf().ALLOW_FEBBOX_KEY && (

View file

@ -26,12 +26,13 @@ interface Config {
ONBOARDING_PROXY_INSTALL_LINK: string; ONBOARDING_PROXY_INSTALL_LINK: string;
ALLOW_AUTOPLAY: boolean; ALLOW_AUTOPLAY: boolean;
ALLOW_FEBBOX_KEY: boolean; ALLOW_FEBBOX_KEY: boolean;
ALLOW_REAL_DEBRID_KEY: boolean; ALLOW_DEBRID_KEY: boolean;
SHOW_AD: boolean; SHOW_AD: boolean;
AD_CONTENT_URL: string; AD_CONTENT_URL: string;
TRACK_SCRIPT: string; // like <script src="https://umami.com/script.js"></script> TRACK_SCRIPT: string; // like <script src="https://umami.com/script.js"></script>
BANNER_MESSAGE: string; BANNER_MESSAGE: string;
BANNER_ID: string; BANNER_ID: string;
USE_TRAKT: boolean;
} }
export interface RuntimeConfig { export interface RuntimeConfig {
@ -41,7 +42,7 @@ export interface RuntimeConfig {
DMCA_EMAIL: string | null; DMCA_EMAIL: string | null;
TWITTER_LINK: string; TWITTER_LINK: string;
TMDB_READ_API_KEY: string | null; TMDB_READ_API_KEY: string | null;
ALLOW_REAL_DEBRID_KEY: boolean; ALLOW_DEBRID_KEY: boolean;
NORMAL_ROUTER: boolean; NORMAL_ROUTER: boolean;
PROXY_URLS: string[]; PROXY_URLS: string[];
M3U8_PROXY_URLS: string[]; M3U8_PROXY_URLS: string[];
@ -60,6 +61,7 @@ export interface RuntimeConfig {
TRACK_SCRIPT: string | null; TRACK_SCRIPT: string | null;
BANNER_MESSAGE: string | null; BANNER_MESSAGE: string | null;
BANNER_ID: string | null; BANNER_ID: string | null;
USE_TRAKT: boolean;
} }
const env: Record<keyof Config, undefined | string> = { const env: Record<keyof Config, undefined | string> = {
@ -85,12 +87,13 @@ const env: Record<keyof Config, undefined | string> = {
HAS_ONBOARDING: import.meta.env.VITE_HAS_ONBOARDING, HAS_ONBOARDING: import.meta.env.VITE_HAS_ONBOARDING,
ALLOW_AUTOPLAY: import.meta.env.VITE_ALLOW_AUTOPLAY, ALLOW_AUTOPLAY: import.meta.env.VITE_ALLOW_AUTOPLAY,
ALLOW_FEBBOX_KEY: import.meta.env.VITE_ALLOW_FEBBOX_KEY, 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, SHOW_AD: import.meta.env.VITE_SHOW_AD,
AD_CONTENT_URL: import.meta.env.VITE_AD_CONTENT_URL, AD_CONTENT_URL: import.meta.env.VITE_AD_CONTENT_URL,
TRACK_SCRIPT: import.meta.env.VITE_TRACK_SCRIPT, TRACK_SCRIPT: import.meta.env.VITE_TRACK_SCRIPT,
BANNER_MESSAGE: import.meta.env.VITE_BANNER_MESSAGE, BANNER_MESSAGE: import.meta.env.VITE_BANNER_MESSAGE,
BANNER_ID: import.meta.env.VITE_BANNER_ID, BANNER_ID: import.meta.env.VITE_BANNER_ID,
USE_TRAKT: import.meta.env.VITE_USE_TRAKT,
}; };
function coerceUndefined(value: string | null | undefined): string | undefined { function coerceUndefined(value: string | null | undefined): string | undefined {
@ -156,7 +159,7 @@ export function conf(): RuntimeConfig {
) )
.filter((v) => v.length === 2), // The format is <beforeA>:<afterA>,<beforeB>:<afterB> .filter((v) => v.length === 2), // The format is <beforeA>:<afterA>,<beforeB>:<afterB>
ALLOW_FEBBOX_KEY: getKey("ALLOW_FEBBOX_KEY", "false") === "true", 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", SHOW_AD: getKey("SHOW_AD", "false") === "true",
AD_CONTENT_URL: getKey("AD_CONTENT_URL", "") AD_CONTENT_URL: getKey("AD_CONTENT_URL", "")
.split(",") .split(",")
@ -165,5 +168,6 @@ export function conf(): RuntimeConfig {
TRACK_SCRIPT: getKey("TRACK_SCRIPT"), TRACK_SCRIPT: getKey("TRACK_SCRIPT"),
BANNER_MESSAGE: getKey("BANNER_MESSAGE"), BANNER_MESSAGE: getKey("BANNER_MESSAGE"),
BANNER_ID: getKey("BANNER_ID"), BANNER_ID: getKey("BANNER_ID"),
USE_TRAKT: getKey("USE_TRAKT", "false") === "true",
}; };
} }

View file

@ -8,6 +8,7 @@ export interface Account {
colorB: string; colorB: string;
icon: string; icon: string;
}; };
nickname: string;
} }
export type AccountWithToken = Account & { export type AccountWithToken = Account & {
@ -25,8 +26,9 @@ interface AuthStore {
removeAccount(): void; removeAccount(): void;
setAccount(acc: AccountWithToken): void; setAccount(acc: AccountWithToken): void;
updateDeviceName(deviceName: string): void; updateDeviceName(deviceName: string): void;
updateAccount(acc: Account): void; updateAccount(acc: Partial<Account>): void;
setAccountProfile(acc: Account["profile"]): void; setAccountProfile(acc: Account["profile"]): void;
setAccountNickname(nickname: string): void;
setBackendUrl(url: null | string): void; setBackendUrl(url: null | string): void;
setProxySet(urls: null | string[]): void; setProxySet(urls: null | string[]): void;
} }
@ -64,6 +66,13 @@ export const useAuthStore = create(
} }
}); });
}, },
setAccountNickname(nickname) {
set((s) => {
if (s.account) {
s.account.nickname = nickname;
}
});
},
updateAccount(acc) { updateAccount(acc) {
set((s) => { set((s) => {
if (!s.account) return; if (!s.account) return;

View file

@ -22,7 +22,8 @@ export interface PreferencesStore {
disabledEmbeds: string[]; disabledEmbeds: string[];
proxyTmdb: boolean; proxyTmdb: boolean;
febboxKey: string | null; febboxKey: string | null;
realDebridKey: string | null; debridToken: string | null;
debridService: string;
enableLowPerformanceMode: boolean; enableLowPerformanceMode: boolean;
enableNativeSubtitles: boolean; enableNativeSubtitles: boolean;
enableHoldToBoost: boolean; enableHoldToBoost: boolean;
@ -49,7 +50,8 @@ export interface PreferencesStore {
setDisabledEmbeds(v: string[]): void; setDisabledEmbeds(v: string[]): void;
setProxyTmdb(v: boolean): void; setProxyTmdb(v: boolean): void;
setFebboxKey(v: string | null): void; setFebboxKey(v: string | null): void;
setRealDebridKey(v: string | null): void; setdebridToken(v: string | null): void;
setdebridService(v: string): void;
setEnableLowPerformanceMode(v: boolean): void; setEnableLowPerformanceMode(v: boolean): void;
setEnableNativeSubtitles(v: boolean): void; setEnableNativeSubtitles(v: boolean): void;
setEnableHoldToBoost(v: boolean): void; setEnableHoldToBoost(v: boolean): void;
@ -80,7 +82,8 @@ export const usePreferencesStore = create(
disabledEmbeds: [], disabledEmbeds: [],
proxyTmdb: false, proxyTmdb: false,
febboxKey: null, febboxKey: null,
realDebridKey: null, debridToken: null,
debridService: "realdebrid",
enableLowPerformanceMode: false, enableLowPerformanceMode: false,
enableNativeSubtitles: false, enableNativeSubtitles: false,
enableHoldToBoost: true, enableHoldToBoost: true,
@ -182,9 +185,14 @@ export const usePreferencesStore = create(
s.febboxKey = v; s.febboxKey = v;
}); });
}, },
setRealDebridKey(v) { setdebridToken(v) {
set((s) => { set((s) => {
s.realDebridKey = v; s.debridToken = v;
});
},
setdebridService(v) {
set((s) => {
s.debridService = v;
}); });
}, },
setEnableLowPerformanceMode(v) { setEnableLowPerformanceMode(v) {