mirror of
https://github.com/p-stream/p-stream.git
synced 2026-04-21 18:22:18 +00:00
Merge branch 'p-stream:production' into substranslate
This commit is contained in:
commit
44618524dd
30 changed files with 743 additions and 459 deletions
|
|
@ -22,7 +22,6 @@ ARG ONBOARDING_CHROME_EXTENSION_INSTALL_LINK
|
||||||
ARG ONBOARDING_PROXY_INSTALL_LINK
|
ARG ONBOARDING_PROXY_INSTALL_LINK
|
||||||
ARG DISALLOWED_IDS
|
ARG DISALLOWED_IDS
|
||||||
ARG CDN_REPLACEMENTS
|
ARG CDN_REPLACEMENTS
|
||||||
ARG TURNSTILE_KEY
|
|
||||||
ARG ALLOW_AUTOPLAY="false"
|
ARG ALLOW_AUTOPLAY="false"
|
||||||
|
|
||||||
ENV VITE_PWA_ENABLED=${PWA_ENABLED}
|
ENV VITE_PWA_ENABLED=${PWA_ENABLED}
|
||||||
|
|
@ -39,7 +38,6 @@ ENV VITE_ONBOARDING_CHROME_EXTENSION_INSTALL_LINK=${ONBOARDING_CHROME_EXTENSION_
|
||||||
ENV VITE_ONBOARDING_PROXY_INSTALL_LINK=${ONBOARDING_PROXY_INSTALL_LINK}
|
ENV VITE_ONBOARDING_PROXY_INSTALL_LINK=${ONBOARDING_PROXY_INSTALL_LINK}
|
||||||
ENV VITE_DISALLOWED_IDS=${DISALLOWED_IDS}
|
ENV VITE_DISALLOWED_IDS=${DISALLOWED_IDS}
|
||||||
ENV VITE_CDN_REPLACEMENTS=${CDN_REPLACEMENTS}
|
ENV VITE_CDN_REPLACEMENTS=${CDN_REPLACEMENTS}
|
||||||
ENV VITE_TURNSTILE_KEY=${TURNSTILE_KEY}
|
|
||||||
ENV VITE_ALLOW_AUTOPLAY=${ALLOW_AUTOPLAY}
|
ENV VITE_ALLOW_AUTOPLAY=${ALLOW_AUTOPLAY}
|
||||||
|
|
||||||
COPY . ./
|
COPY . ./
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ services:
|
||||||
# ONBOARDING_PROXY_INSTALL_LINK: ""
|
# ONBOARDING_PROXY_INSTALL_LINK: ""
|
||||||
# DISALLOWED_IDS: ""
|
# DISALLOWED_IDS: ""
|
||||||
# CDN_REPLACEMENTS: ""
|
# CDN_REPLACEMENTS: ""
|
||||||
# TURNSTILE_KEY: ""
|
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,6 @@ VITE_M3U8_PROXY_URL=...
|
||||||
|
|
||||||
# make sure the domain does NOT have a slash at the end
|
# make sure the domain does NOT have a slash at the end
|
||||||
VITE_APP_DOMAIN=http://localhost:5173
|
VITE_APP_DOMAIN=http://localhost:5173
|
||||||
|
|
||||||
|
# Backend URL(s) - can be a single URL or comma-separated list (e.g., "https://server1.com,https://server2.com,https://server3.com")
|
||||||
|
VITE_BACKEND_URL=https://server1.com,https://server2.com,https://server3.com
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ window.__CONFIG__ = {
|
||||||
// Whether to disable hash-based routing, leave this as false if you don't know what this is
|
// Whether to disable hash-based routing, leave this as false if you don't know what this is
|
||||||
VITE_NORMAL_ROUTER: true,
|
VITE_NORMAL_ROUTER: true,
|
||||||
|
|
||||||
// The backend URL to communicate with
|
// The backend URL(s) to communicate with - can be a single URL or comma-separated list (e.g., "https://server1.com,https://server2.com")
|
||||||
VITE_BACKEND_URL: null,
|
VITE_BACKEND_URL: null,
|
||||||
|
|
||||||
// A comma separated list of disallowed IDs in the case of a DMCA claim - in the format "series-<id>" and "movie-<id>"
|
// A comma separated list of disallowed IDs in the case of a DMCA claim - in the format "series-<id>" and "movie-<id>"
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,16 @@
|
||||||
"title": "Account information"
|
"title": "Account information"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"backendSelection": {
|
||||||
|
"title": "Select Account Server",
|
||||||
|
"description": "Choose which backend server to connect to",
|
||||||
|
"customBackend": "Custom Backend",
|
||||||
|
"customBackendPlaceholder": "https://",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"active": "Active",
|
||||||
|
"selecting": "Selecting..."
|
||||||
|
},
|
||||||
"trust": {
|
"trust": {
|
||||||
"failed": {
|
"failed": {
|
||||||
"text": "Did you configure it correctly?",
|
"text": "Did you configure it correctly?",
|
||||||
|
|
@ -950,12 +960,6 @@
|
||||||
"remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}",
|
"remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}",
|
||||||
"shortRegular": "{{timeWatched}}",
|
"shortRegular": "{{timeWatched}}",
|
||||||
"shortRemaining": "-{{timeLeft}}"
|
"shortRemaining": "-{{timeLeft}}"
|
||||||
},
|
|
||||||
"turnstile": {
|
|
||||||
"description": "Please prove your humanity by completing the quick challenge, this is to keep P-Stream safe.",
|
|
||||||
"error": "Failed to verify your humanity - stream failed to load. Clear your cache and try again, or switch to a different source (tap the gear).",
|
|
||||||
"title": "Are You a Robot 🤖?",
|
|
||||||
"verifyingHumanity": "Verifying your humanity... (^▽^)👍"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
|
|
@ -991,9 +995,14 @@
|
||||||
"loadingUserError": {
|
"loadingUserError": {
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"reset": "Reset custom server",
|
"reset": "Reset custom server",
|
||||||
|
"disconnect": "Disconnect from backend",
|
||||||
"text": "Failed to load your profile",
|
"text": "Failed to load your profile",
|
||||||
"reload": "Reload",
|
"reload": "Reload",
|
||||||
"textWithReset": "Failed to load your profile from your custom server, want to reset back to the default server?"
|
"textWithReset": "Failed to load your profile from your custom server, want to reset back to the default server?",
|
||||||
|
"disconnectTitle": "Disconnect from backend?",
|
||||||
|
"disconnectMessage": "Disconnect from the account server, maintaining the most recent local data. Changes and watched content will not sync until you're signed in again.",
|
||||||
|
"disconnectConfirm": "Confirm",
|
||||||
|
"disconectCancel": "Cancel"
|
||||||
},
|
},
|
||||||
"migration": {
|
"migration": {
|
||||||
"failed": "Failed to migrate your data. 😿",
|
"failed": "Failed to migrate your data. 😿",
|
||||||
|
|
@ -1125,13 +1134,20 @@
|
||||||
"connections": {
|
"connections": {
|
||||||
"server": {
|
"server": {
|
||||||
"description": "If you would like to connect to a custom backend to store your data, enable this and provide the URL. <0>Instructions.</0>",
|
"description": "If you would like to connect to a custom backend to store your data, enable this and provide the URL. <0>Instructions.</0>",
|
||||||
"label": "Custom server",
|
"label": "Backend Server",
|
||||||
"urlLabel": "Custom server URL",
|
"urlLabel": "Custom server URL",
|
||||||
|
"selectBackend": "Select Backend Server",
|
||||||
|
"currentBackend": "Current Backend",
|
||||||
|
"changeWarning": "Changing backend server will log you out. Continue?",
|
||||||
|
"confirm": "Log out and change server",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"changeWarningTitle": "Change Backend Server",
|
||||||
"migration": {
|
"migration": {
|
||||||
"description": "<0>Migrate my data</0> to a new server.",
|
"description": "<0>Migrate my data</0> to a new server.",
|
||||||
"link": "Migrate my data"
|
"link": "Migrate my data"
|
||||||
},
|
},
|
||||||
"documentation": "Backend documentation"
|
"documentation": "Backend documentation",
|
||||||
|
"error": "Error connecting to backend"
|
||||||
},
|
},
|
||||||
"setup": {
|
"setup": {
|
||||||
"doSetup": "Do setup",
|
"doSetup": "Do setup",
|
||||||
|
|
@ -1387,6 +1403,8 @@
|
||||||
"contentMismatch": "Cannot join watch party: The content does not match the host's content.",
|
"contentMismatch": "Cannot join watch party: The content does not match the host's content.",
|
||||||
"episodeMismatch": "Cannot join watch party: You are watching a different episode than the host.",
|
"episodeMismatch": "Cannot join watch party: You are watching a different episode than the host.",
|
||||||
"validating": "Validating watch party...",
|
"validating": "Validating watch party...",
|
||||||
"linkCopied": "Copied!"
|
"linkCopied": "Copied!",
|
||||||
|
"backendRequirement": "All users must use the same backend server",
|
||||||
|
"activeBackend": "Active Backend: {{backend}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import { MetaOutput, NotFoundError, ScrapeMedia } from "@p-stream/providers";
|
import { MetaOutput } from "@p-stream/providers";
|
||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
|
|
||||||
import { mwFetch } from "@/backend/helpers/fetch";
|
|
||||||
import { getTurnstileToken, isTurnstileInitialized } from "@/stores/turnstile";
|
|
||||||
|
|
||||||
let metaDataCache: MetaOutput[] | null = null;
|
let metaDataCache: MetaOutput[] | null = null;
|
||||||
let token: null | string = null;
|
let token: null | string = null;
|
||||||
|
|
||||||
|
|
@ -31,143 +28,6 @@ function getTokenIfValid(): null | string {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMetadata(base: string) {
|
|
||||||
if (metaDataCache) return;
|
|
||||||
const data = await mwFetch<MetaOutput[][]>(`${base}/metadata`);
|
|
||||||
metaDataCache = data.flat();
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrapeMediaToQueryMedia(media: ScrapeMedia) {
|
|
||||||
let extra: Record<string, string> = {};
|
|
||||||
if (media.type === "show") {
|
|
||||||
extra = {
|
|
||||||
episodeNumber: media.episode.number.toString(),
|
|
||||||
episodeTmdbId: media.episode.tmdbId,
|
|
||||||
seasonNumber: media.season.number.toString(),
|
|
||||||
seasonTmdbId: media.season.tmdbId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: media.type,
|
|
||||||
releaseYear: media.releaseYear.toString(),
|
|
||||||
imdbId: media.imdbId,
|
|
||||||
tmdbId: media.tmdbId,
|
|
||||||
title: media.title,
|
|
||||||
...extra,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function addQueryDataToUrl(url: URL, data: Record<string, string | undefined>) {
|
|
||||||
Object.entries(data).forEach((entry) => {
|
|
||||||
if (entry[1]) url.searchParams.set(entry[0], entry[1]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeProviderUrl(base: string) {
|
|
||||||
const makeUrl = (p: string) => new URL(`${base}${p}`);
|
|
||||||
return {
|
|
||||||
scrapeSource(sourceId: string, media: ScrapeMedia) {
|
|
||||||
const url = makeUrl("/scrape/source");
|
|
||||||
addQueryDataToUrl(url, scrapeMediaToQueryMedia(media));
|
|
||||||
addQueryDataToUrl(url, { id: sourceId });
|
|
||||||
return url.toString();
|
|
||||||
},
|
|
||||||
scrapeAll(
|
|
||||||
media: ScrapeMedia,
|
|
||||||
sourceOrder?: string[],
|
|
||||||
embedOrder?: string[],
|
|
||||||
) {
|
|
||||||
const url = makeUrl("/scrape");
|
|
||||||
addQueryDataToUrl(url, scrapeMediaToQueryMedia(media));
|
|
||||||
if (sourceOrder && sourceOrder.length > 0) {
|
|
||||||
url.searchParams.set("sourceOrder", sourceOrder.join(","));
|
|
||||||
}
|
|
||||||
if (embedOrder && embedOrder.length > 0) {
|
|
||||||
url.searchParams.set("embedOrder", embedOrder.join(","));
|
|
||||||
}
|
|
||||||
return url.toString();
|
|
||||||
},
|
|
||||||
scrapeEmbed(embedId: string, embedUrl: string) {
|
|
||||||
const url = makeUrl("/scrape/embed");
|
|
||||||
addQueryDataToUrl(url, { id: embedId, url: embedUrl });
|
|
||||||
return url.toString();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getApiToken(): Promise<string | null> {
|
export async function getApiToken(): Promise<string | null> {
|
||||||
let apiToken = getTokenIfValid();
|
return getTokenIfValid();
|
||||||
if (!apiToken && isTurnstileInitialized()) {
|
|
||||||
apiToken = `turnstile|${await getTurnstileToken()}`;
|
|
||||||
}
|
|
||||||
return apiToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEventInput(inp: string): any {
|
|
||||||
if (inp.length === 0) return {};
|
|
||||||
return JSON.parse(inp);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function connectServerSideEvents<T>(
|
|
||||||
url: string,
|
|
||||||
endEvents: string[],
|
|
||||||
) {
|
|
||||||
const apiToken = await getApiToken();
|
|
||||||
|
|
||||||
// insert token, if its set
|
|
||||||
const parsedUrl = new URL(url);
|
|
||||||
if (apiToken) parsedUrl.searchParams.set("token", apiToken);
|
|
||||||
const eventSource = new EventSource(parsedUrl.toString());
|
|
||||||
|
|
||||||
let promReject: (reason?: any) => void;
|
|
||||||
let promResolve: (value: T) => void;
|
|
||||||
const promise = new Promise<T>((resolve, reject) => {
|
|
||||||
promResolve = resolve;
|
|
||||||
promReject = reject;
|
|
||||||
});
|
|
||||||
|
|
||||||
endEvents.forEach((evt) => {
|
|
||||||
eventSource.addEventListener(evt, (e) => {
|
|
||||||
eventSource.close();
|
|
||||||
promResolve(parseEventInput(e.data));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.addEventListener("token", (e) => {
|
|
||||||
setApiToken(parseEventInput(e.data));
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.addEventListener("error", (err: MessageEvent<any>) => {
|
|
||||||
eventSource.close();
|
|
||||||
if (err.data) {
|
|
||||||
const data = JSON.parse(err.data);
|
|
||||||
let errObj = new Error("scrape error");
|
|
||||||
if (data.name === NotFoundError.name)
|
|
||||||
errObj = new NotFoundError("Notfound from server");
|
|
||||||
Object.assign(errObj, data);
|
|
||||||
promReject(errObj);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error("Failed to connect to SSE", err);
|
|
||||||
promReject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.addEventListener("message", (ev) => {
|
|
||||||
if (!ev) {
|
|
||||||
eventSource.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
promReject(new Error("SSE closed improperly"));
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
promise: () => promise,
|
|
||||||
on<Data>(event: string, cb: (data: Data) => void) {
|
|
||||||
eventSource.addEventListener(event, (e) => cb(JSON.parse(e.data)));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,27 @@ import { useCallback } from "react";
|
||||||
|
|
||||||
import { isExtensionActiveCached } from "@/backend/extension/messaging";
|
import { isExtensionActiveCached } from "@/backend/extension/messaging";
|
||||||
import { ScrapingItems, ScrapingSegment } from "@/hooks/useProviderScrape";
|
import { ScrapingItems, ScrapingSegment } from "@/hooks/useProviderScrape";
|
||||||
import { BACKEND_URL } from "@/setup/constants";
|
import { conf } from "@/setup/config";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { PlayerMeta } from "@/stores/player/slices/source";
|
import { PlayerMeta } from "@/stores/player/slices/source";
|
||||||
|
|
||||||
// for anybody who cares - these are anonymous metrics.
|
// for anybody who cares - these are anonymous metrics.
|
||||||
// They are just used for figuring out if providers are broken or not
|
// They are just used for figuring out if providers are broken or not
|
||||||
const metricsEndpoint = `${BACKEND_URL}/metrics/providers`;
|
// Metrics are always sent to the first configured backend
|
||||||
const captchaMetricsEndpoint = `${BACKEND_URL}/metrics/captcha`;
|
function getMetricsBackendUrl(): string | null {
|
||||||
|
const config = conf();
|
||||||
|
return config.BACKEND_URLS.length > 0
|
||||||
|
? config.BACKEND_URLS[0]
|
||||||
|
: config.BACKEND_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetricsEndpoint(path: string): string | null {
|
||||||
|
const backendUrl = getMetricsBackendUrl();
|
||||||
|
return backendUrl ? `${backendUrl}${path}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metricsEndpoint = getMetricsEndpoint("/metrics/providers");
|
||||||
|
const captchaMetricsEndpoint = getMetricsEndpoint("/metrics/captcha");
|
||||||
const batchId = () => nanoid(32);
|
const batchId = () => nanoid(32);
|
||||||
|
|
||||||
export type ProviderMetric = {
|
export type ProviderMetric = {
|
||||||
|
|
@ -45,7 +58,7 @@ function getStackTrace(error: Error, lines: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reportProviders(items: ProviderMetric[]): Promise<void> {
|
export async function reportProviders(items: ProviderMetric[]): Promise<void> {
|
||||||
if (!BACKEND_URL) return;
|
if (!metricsEndpoint) return;
|
||||||
return ofetch(metricsEndpoint, {
|
return ofetch(metricsEndpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -158,7 +171,7 @@ export function useReportProviders() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reportCaptchaSolve(success: boolean) {
|
export function reportCaptchaSolve(success: boolean) {
|
||||||
if (!BACKEND_URL) return;
|
if (!captchaMetricsEndpoint) return;
|
||||||
ofetch(captchaMetricsEndpoint, {
|
ofetch(captchaMetricsEndpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,7 @@ import {
|
||||||
|
|
||||||
import { sendExtensionRequest } from "@/backend/extension/messaging";
|
import { sendExtensionRequest } from "@/backend/extension/messaging";
|
||||||
import { getApiToken, setApiToken } from "@/backend/helpers/providerApi";
|
import { getApiToken, setApiToken } from "@/backend/helpers/providerApi";
|
||||||
import {
|
import { getM3U8ProxyUrls, getProxyUrls } from "@/utils/proxyUrls";
|
||||||
getM3U8ProxyUrls,
|
|
||||||
getProviderApiUrls,
|
|
||||||
getProxyUrls,
|
|
||||||
} from "@/utils/proxyUrls";
|
|
||||||
|
|
||||||
import { convertBodyToObject, getBodyTypeFromBody } from "../extension/request";
|
import { convertBodyToObject, getBodyTypeFromBody } from "../extension/request";
|
||||||
|
|
||||||
|
|
@ -28,8 +24,6 @@ function makeLoadbalancedList(getter: () => string[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getLoadbalancedProxyUrl = makeLoadbalancedList(getProxyUrls);
|
export const getLoadbalancedProxyUrl = makeLoadbalancedList(getProxyUrls);
|
||||||
export const getLoadbalancedProviderApiUrl =
|
|
||||||
makeLoadbalancedList(getProviderApiUrls);
|
|
||||||
function getEnabledM3U8ProxyUrls() {
|
function getEnabledM3U8ProxyUrls() {
|
||||||
const allM3U8ProxyUrls = getM3U8ProxyUrls();
|
const allM3U8ProxyUrls = getM3U8ProxyUrls();
|
||||||
const enabledProxies = localStorage.getItem("m3u8-proxy-enabled");
|
const enabledProxies = localStorage.getItem("m3u8-proxy-enabled");
|
||||||
|
|
|
||||||
244
src/components/form/BackendSelector.tsx
Normal file
244
src/components/form/BackendSelector.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { MetaResponse, getBackendMeta } from "@/backend/accounts/meta";
|
||||||
|
import { Button } from "@/components/buttons/Button";
|
||||||
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
import { Loading } from "@/components/layout/Loading";
|
||||||
|
import { TextInputControl } from "@/components/text-inputs/TextInputControl";
|
||||||
|
|
||||||
|
interface BackendOption {
|
||||||
|
url: string;
|
||||||
|
meta: MetaResponse | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackendSelectorProps {
|
||||||
|
selectedUrl: string | null;
|
||||||
|
onSelect: (url: string | null) => void;
|
||||||
|
availableUrls: string[];
|
||||||
|
showCustom?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackendOptionItem({
|
||||||
|
option,
|
||||||
|
isSelected,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
option: BackendOption;
|
||||||
|
isSelected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const hostname = option.url ? new URL(option.url).hostname : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={classNames(
|
||||||
|
"w-full p-4 rounded-lg border-2 transition-colors text-left tabbable",
|
||||||
|
isSelected
|
||||||
|
? "border-buttons-purple bg-buttons-purple/10"
|
||||||
|
: "border-transparent bg-authentication-inputBg hover:bg-authentication-inputBg/80",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0",
|
||||||
|
isSelected
|
||||||
|
? "border-buttons-purple bg-buttons-purple"
|
||||||
|
: "border-type-secondary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSelected ? (
|
||||||
|
<Icon icon={Icons.CHECKMARK} className="text-white text-xs" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{option.loading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loading />
|
||||||
|
<span className="text-type-secondary text-sm">
|
||||||
|
{t("auth.backendSelection.selecting")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : option.error ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="text-white font-medium">{hostname}</p>
|
||||||
|
<p className="text-type-secondary text-sm">{option.url}</p>
|
||||||
|
</div>
|
||||||
|
<Icon icon={Icons.WARNING} className="text-type-danger text-sm" />
|
||||||
|
<span className="text-type-danger text-sm">
|
||||||
|
{t("settings.connections.server.error")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : option.meta ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium">{option.meta.name}</p>
|
||||||
|
<p className="text-type-secondary text-sm">{hostname}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium">{hostname}</p>
|
||||||
|
<p className="text-type-secondary text-sm">{option.url}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isSelected ? (
|
||||||
|
<span className="text-buttons-purple text-sm font-medium">
|
||||||
|
{t("auth.backendSelection.active")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackendSelector({
|
||||||
|
selectedUrl,
|
||||||
|
onSelect,
|
||||||
|
availableUrls,
|
||||||
|
showCustom = true,
|
||||||
|
}: BackendSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Helper to strip protocol from URL for display
|
||||||
|
const stripProtocol = (url: string | null): string => {
|
||||||
|
if (!url) return "";
|
||||||
|
return url.replace(/^https?:\/\//, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize customUrl from selectedUrl if it's a custom URL (not in availableUrls)
|
||||||
|
const isCustomUrl = selectedUrl && !availableUrls.includes(selectedUrl);
|
||||||
|
const [customUrl, setCustomUrl] = useState(
|
||||||
|
isCustomUrl ? stripProtocol(selectedUrl) : "",
|
||||||
|
);
|
||||||
|
const [backendOptions, setBackendOptions] = useState<BackendOption[]>([]);
|
||||||
|
|
||||||
|
// Update customUrl when selectedUrl changes and it's a custom URL
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedUrl && !availableUrls.includes(selectedUrl)) {
|
||||||
|
setCustomUrl(stripProtocol(selectedUrl));
|
||||||
|
}
|
||||||
|
}, [selectedUrl, availableUrls]);
|
||||||
|
|
||||||
|
// Initialize and fetch meta for backend options
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchMetas = async () => {
|
||||||
|
const options: BackendOption[] = availableUrls.map((url) => ({
|
||||||
|
url,
|
||||||
|
meta: null,
|
||||||
|
loading: true,
|
||||||
|
error: false,
|
||||||
|
}));
|
||||||
|
setBackendOptions(options);
|
||||||
|
|
||||||
|
const promises = options.map(async (option) => {
|
||||||
|
try {
|
||||||
|
const meta = await getBackendMeta(option.url);
|
||||||
|
return { ...option, meta, loading: false, error: false };
|
||||||
|
} catch {
|
||||||
|
return { ...option, meta: null, loading: false, error: true };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
setBackendOptions(results);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (availableUrls.length > 0) {
|
||||||
|
fetchMetas();
|
||||||
|
}
|
||||||
|
}, [availableUrls]);
|
||||||
|
|
||||||
|
const handleCustomUrlSelect = () => {
|
||||||
|
if (customUrl.trim()) {
|
||||||
|
let url = customUrl.trim();
|
||||||
|
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||||
|
url = `https://${url}`;
|
||||||
|
}
|
||||||
|
onSelect(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCustomUrlSelected =
|
||||||
|
selectedUrl !== null && !availableUrls.includes(selectedUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{backendOptions.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{backendOptions.map((option) => (
|
||||||
|
<BackendOptionItem
|
||||||
|
key={option.url}
|
||||||
|
option={option}
|
||||||
|
isSelected={selectedUrl === option.url}
|
||||||
|
onClick={() => onSelect(option.url)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showCustom && (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"w-full p-4 rounded-lg border-2 transition-colors",
|
||||||
|
isCustomUrlSelected
|
||||||
|
? "border-buttons-purple bg-buttons-purple/10"
|
||||||
|
: "border-transparent bg-authentication-inputBg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0",
|
||||||
|
isCustomUrlSelected
|
||||||
|
? "border-buttons-purple bg-buttons-purple"
|
||||||
|
: "border-type-secondary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCustomUrlSelected ? (
|
||||||
|
<Icon icon={Icons.CHECKMARK} className="text-white text-xs" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{t("auth.backendSelection.customBackend")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isCustomUrlSelected ? (
|
||||||
|
<span className="text-buttons-purple text-sm font-medium">
|
||||||
|
{t("auth.backendSelection.active")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-[1fr,auto] items-center gap-2">
|
||||||
|
<TextInputControl
|
||||||
|
value={customUrl}
|
||||||
|
onChange={setCustomUrl}
|
||||||
|
placeholder={
|
||||||
|
t("auth.backendSelection.customBackendPlaceholder") ??
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
className="w-full flex-1 bg-authentication-inputBg border-2 border-type-secondary/40 px-4 py-3 text-search-text focus:outline-none rounded-lg placeholder:text-gray-700"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
theme="purple"
|
||||||
|
onClick={handleCustomUrlSelect}
|
||||||
|
disabled={!customUrl.trim()}
|
||||||
|
>
|
||||||
|
{t("auth.backendSelection.confirm")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -25,7 +25,7 @@ export function LargeCardText(props: {
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center text-center mb-8">
|
<div className="flex flex-col items-center text-center mb-8">
|
||||||
<div className="flex flex-col items-center text-center max-w-[318px]">
|
<div className="flex flex-col items-center text-center max-w-[320px]">
|
||||||
{props.icon ? (
|
{props.icon ? (
|
||||||
<div className="text-2xl mb-4 text-largeCard-icon">{props.icon}</div>
|
<div className="text-2xl mb-4 text-largeCard-icon">{props.icon}</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,12 @@ import classNames from "classnames";
|
||||||
import FocusTrap from "focus-trap-react";
|
import FocusTrap from "focus-trap-react";
|
||||||
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { Transition } from "@/components/utils/Transition";
|
import { Transition } from "@/components/utils/Transition";
|
||||||
import {
|
import {
|
||||||
useInternalOverlayRouter,
|
useInternalOverlayRouter,
|
||||||
useRouterAnchorUpdate,
|
useRouterAnchorUpdate,
|
||||||
} from "@/hooks/useOverlayRouter";
|
} from "@/hooks/useOverlayRouter";
|
||||||
import { TurnstileProvider, getTurnstile } from "@/stores/turnstile";
|
|
||||||
|
|
||||||
export interface OverlayProps {
|
export interface OverlayProps {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -17,40 +15,6 @@ export interface OverlayProps {
|
||||||
darken?: boolean;
|
darken?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TurnstileInteractive() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [show, setShow] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getTurnstile();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// this may not rerender with different dom structure, must be exactly the same always
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"absolute w-full max-w-[43em] max-h-full p-5 md:p-10 rounded-lg bg-dropdown-altBackground select-none z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform overflow-auto",
|
|
||||||
show ? "" : "hidden",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="w-full h-full grid lg:grid-cols-[1fr,auto] gap-6 md:gap-7 items-center">
|
|
||||||
<div className="text-left">
|
|
||||||
<h2 className="text-type-emphasis font-bold text-lg md:text-xl mb-4 md:mb-6">
|
|
||||||
{t("player.turnstile.title")}
|
|
||||||
</h2>
|
|
||||||
<p className="text-type-emphasis">
|
|
||||||
{t("player.turnstile.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<TurnstileProvider
|
|
||||||
isInPopout
|
|
||||||
onUpdateShow={(shouldShow) => setShow(shouldShow)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OverlayDisplay(props: { children: ReactNode }) {
|
export function OverlayDisplay(props: { children: ReactNode }) {
|
||||||
const router = useInternalOverlayRouter("hello world :)");
|
const router = useInternalOverlayRouter("hello world :)");
|
||||||
const refRouter = useRef(router);
|
const refRouter = useRef(router);
|
||||||
|
|
@ -63,12 +27,7 @@ export function OverlayDisplay(props: { children: ReactNode }) {
|
||||||
r.close();
|
r.close();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
return (
|
return <div className="popout-location">{props.children}</div>;
|
||||||
<div className="popout-location">
|
|
||||||
<TurnstileInteractive />
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OverlayPortal(props: {
|
export function OverlayPortal(props: {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,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 { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||||
import { useWatchPartySync } from "@/hooks/useWatchPartySync";
|
import { useWatchPartySync } from "@/hooks/useWatchPartySync";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { getProgressPercentage } from "@/stores/progress";
|
import { getProgressPercentage } from "@/stores/progress";
|
||||||
|
|
@ -15,6 +16,8 @@ export function WatchPartyStatus() {
|
||||||
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 account = useAuthStore((s) => s.account);
|
||||||
|
const backendUrl = useBackendUrl();
|
||||||
|
const backendHostname = backendUrl ? new URL(backendUrl).hostname : null;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
roomUsers,
|
roomUsers,
|
||||||
|
|
@ -70,6 +73,11 @@ export function WatchPartyStatus() {
|
||||||
{roomCode}
|
{roomCode}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{backendHostname && (
|
||||||
|
<div className="w-full text-xs text-type-secondary text-center">
|
||||||
|
{t("watchParty.activeBackend", { backend: backendHostname })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="w-full text-type-secondary flex justify-between items-center space-x-2">
|
<div className="w-full text-type-secondary flex justify-between items-center space-x-2">
|
||||||
<div className="cursor-pointer" onClick={handleToggleExpanded}>
|
<div className="cursor-pointer" onClick={handleToggleExpanded}>
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,17 @@ export function WatchPartyView({ id }: { id: string }) {
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="text-center">
|
<div className="text-center space-y-2">
|
||||||
|
<div className="text-xs text-type-logo font-semibold flex flex-col gap-1 bg-type-danger/10 px-2 py-1 rounded mb-2">
|
||||||
|
<span className="text-xs">
|
||||||
|
{t("watchParty.backendRequirement")}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
{t("watchParty.activeBackend", {
|
||||||
|
backend: backendUrl || "Unknown",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey={
|
i18nKey={
|
||||||
isHost ? "watchParty.isHost" : "watchParty.isGuest"
|
isHost ? "watchParty.isHost" : "watchParty.isGuest"
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,10 @@ import { useAsyncFn } from "react-use";
|
||||||
|
|
||||||
import { isExtensionActiveCached } from "@/backend/extension/messaging";
|
import { isExtensionActiveCached } from "@/backend/extension/messaging";
|
||||||
import { prepareStream } from "@/backend/extension/streams";
|
import { prepareStream } from "@/backend/extension/streams";
|
||||||
import {
|
|
||||||
connectServerSideEvents,
|
|
||||||
makeProviderUrl,
|
|
||||||
} from "@/backend/helpers/providerApi";
|
|
||||||
import {
|
import {
|
||||||
scrapeSourceOutputToProviderMetric,
|
scrapeSourceOutputToProviderMetric,
|
||||||
useReportProviders,
|
useReportProviders,
|
||||||
} from "@/backend/helpers/report";
|
} from "@/backend/helpers/report";
|
||||||
import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers";
|
|
||||||
import { getProviders } from "@/backend/providers/providers";
|
import { getProviders } from "@/backend/providers/providers";
|
||||||
import { convertProviderCaption } from "@/components/player/utils/captions";
|
import { convertProviderCaption } from "@/components/player/utils/captions";
|
||||||
import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource";
|
import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource";
|
||||||
|
|
@ -60,23 +55,13 @@ export function useEmbedScraping(
|
||||||
);
|
);
|
||||||
|
|
||||||
const [request, run] = useAsyncFn(async () => {
|
const [request, run] = useAsyncFn(async () => {
|
||||||
const providerApiUrl = getLoadbalancedProviderApiUrl();
|
|
||||||
let result: EmbedOutput | undefined;
|
let result: EmbedOutput | undefined;
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
try {
|
try {
|
||||||
if (providerApiUrl && !isExtensionActiveCached()) {
|
|
||||||
const baseUrlMaker = makeProviderUrl(providerApiUrl);
|
|
||||||
const conn = await connectServerSideEvents<EmbedOutput>(
|
|
||||||
baseUrlMaker.scrapeEmbed(embedId, url),
|
|
||||||
["completed", "noOutput"],
|
|
||||||
);
|
|
||||||
result = await conn.promise();
|
|
||||||
} else {
|
|
||||||
result = await getProviders().runEmbedScraper({
|
result = await getProviders().runEmbedScraper({
|
||||||
id: embedId,
|
id: embedId,
|
||||||
url,
|
url,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to scrape ${embedId}`, err);
|
console.error(`Failed to scrape ${embedId}`, err);
|
||||||
const notFound = err instanceof NotFoundError;
|
const notFound = err instanceof NotFoundError;
|
||||||
|
|
@ -148,23 +133,13 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
|
||||||
if (!sourceId || !meta) return null;
|
if (!sourceId || !meta) return null;
|
||||||
setEmbedId(null);
|
setEmbedId(null);
|
||||||
const scrapeMedia = metaToScrapeMedia(meta);
|
const scrapeMedia = metaToScrapeMedia(meta);
|
||||||
const providerApiUrl = getLoadbalancedProviderApiUrl();
|
|
||||||
|
|
||||||
let result: SourcererOutput | undefined;
|
let result: SourcererOutput | undefined;
|
||||||
try {
|
try {
|
||||||
if (providerApiUrl && !isExtensionActiveCached()) {
|
|
||||||
const baseUrlMaker = makeProviderUrl(providerApiUrl);
|
|
||||||
const conn = await connectServerSideEvents<SourcererOutput>(
|
|
||||||
baseUrlMaker.scrapeSource(sourceId, scrapeMedia),
|
|
||||||
["completed", "noOutput"],
|
|
||||||
);
|
|
||||||
result = await conn.promise();
|
|
||||||
} else {
|
|
||||||
result = await getProviders().runSourceScraper({
|
result = await getProviders().runSourceScraper({
|
||||||
id: sourceId,
|
id: sourceId,
|
||||||
media: scrapeMedia,
|
media: scrapeMedia,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to scrape ${sourceId}`, err);
|
console.error(`Failed to scrape ${sourceId}`, err);
|
||||||
const notFound = err instanceof NotFoundError;
|
const notFound = err instanceof NotFoundError;
|
||||||
|
|
@ -199,22 +174,10 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
|
||||||
let embedResult: EmbedOutput | undefined;
|
let embedResult: EmbedOutput | undefined;
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
try {
|
try {
|
||||||
if (providerApiUrl && !isExtensionActiveCached()) {
|
|
||||||
const baseUrlMaker = makeProviderUrl(providerApiUrl);
|
|
||||||
const conn = await connectServerSideEvents<EmbedOutput>(
|
|
||||||
baseUrlMaker.scrapeEmbed(
|
|
||||||
result.embeds[0].embedId,
|
|
||||||
result.embeds[0].url,
|
|
||||||
),
|
|
||||||
["completed", "noOutput"],
|
|
||||||
);
|
|
||||||
embedResult = await conn.promise();
|
|
||||||
} else {
|
|
||||||
embedResult = await getProviders().runEmbedScraper({
|
embedResult = await getProviders().runEmbedScraper({
|
||||||
id: result.embeds[0].embedId,
|
id: result.embeds[0].embedId,
|
||||||
url: result.embeds[0].url,
|
url: result.embeds[0].url,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to scrape ${result.embeds[0].embedId}`, err);
|
console.error(`Failed to scrape ${result.embeds[0].embedId}`, err);
|
||||||
const notFound = err instanceof NotFoundError;
|
const notFound = err instanceof NotFoundError;
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,21 @@ export function useAuth() {
|
||||||
await userDataLogout();
|
await userDataLogout();
|
||||||
}, [userDataLogout, backendUrl, currentAccount]);
|
}, [userDataLogout, backendUrl, currentAccount]);
|
||||||
|
|
||||||
|
const disconnectFromBackend = useCallback(async () => {
|
||||||
|
if (!currentAccount || !backendUrl) return;
|
||||||
|
try {
|
||||||
|
await removeSession(
|
||||||
|
backendUrl,
|
||||||
|
currentAccount.token,
|
||||||
|
currentAccount.sessionId,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// we dont care about failing to delete session
|
||||||
|
}
|
||||||
|
// Only remove the account, keep all local data
|
||||||
|
useAuthStore.getState().removeAccount();
|
||||||
|
}, [backendUrl, currentAccount]);
|
||||||
|
|
||||||
const register = useCallback(
|
const register = useCallback(
|
||||||
async (registerData: RegistrationData) => {
|
async (registerData: RegistrationData) => {
|
||||||
if (!backendUrl) return;
|
if (!backendUrl) return;
|
||||||
|
|
@ -215,6 +230,7 @@ export function useAuth() {
|
||||||
profile,
|
profile,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
disconnectFromBackend,
|
||||||
register,
|
register,
|
||||||
restore,
|
restore,
|
||||||
importData,
|
importData,
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,10 @@ import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
export function useBackendUrl(): string | null {
|
export function useBackendUrl(): string | null {
|
||||||
const backendUrl = useAuthStore((s) => s.backendUrl);
|
const backendUrl = useAuthStore((s) => s.backendUrl);
|
||||||
return backendUrl ?? conf().BACKEND_URL;
|
const config = conf();
|
||||||
|
return (
|
||||||
|
backendUrl ??
|
||||||
|
config.BACKEND_URL ??
|
||||||
|
(config.BACKEND_URLS.length > 0 ? config.BACKEND_URLS[0] : null)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,7 @@ import { RefObject, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { isExtensionActiveCached } from "@/backend/extension/messaging";
|
import { isExtensionActiveCached } from "@/backend/extension/messaging";
|
||||||
import { prepareStream } from "@/backend/extension/streams";
|
import { prepareStream } from "@/backend/extension/streams";
|
||||||
import {
|
import { getCachedMetadata } from "@/backend/helpers/providerApi";
|
||||||
connectServerSideEvents,
|
|
||||||
getCachedMetadata,
|
|
||||||
makeProviderUrl,
|
|
||||||
} from "@/backend/helpers/providerApi";
|
|
||||||
import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers";
|
|
||||||
import { getProviders } from "@/backend/providers/providers";
|
import { getProviders } from "@/backend/providers/providers";
|
||||||
import { getMediaKey } from "@/stores/player/slices/source";
|
import { getMediaKey } from "@/stores/player/slices/source";
|
||||||
import { usePlayerStore } from "@/stores/player/store";
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
|
@ -245,29 +240,6 @@ export function useScrape() {
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const providerApiUrl = getLoadbalancedProviderApiUrl();
|
|
||||||
if (providerApiUrl && !isExtensionActiveCached()) {
|
|
||||||
startScrape();
|
|
||||||
const baseUrlMaker = makeProviderUrl(providerApiUrl);
|
|
||||||
const conn = await connectServerSideEvents<RunOutput | "">(
|
|
||||||
baseUrlMaker.scrapeAll(
|
|
||||||
media,
|
|
||||||
filteredSourceOrder,
|
|
||||||
filteredEmbedOrder,
|
|
||||||
),
|
|
||||||
["completed", "noOutput"],
|
|
||||||
);
|
|
||||||
conn.on("init", initEvent);
|
|
||||||
conn.on("start", startEvent);
|
|
||||||
conn.on("update", updateEvent);
|
|
||||||
conn.on("discoverEmbeds", discoverEmbedsEvent);
|
|
||||||
const sseOutput = await conn.promise();
|
|
||||||
if (sseOutput && isExtensionActiveCached())
|
|
||||||
await prepareStream(sseOutput.stream);
|
|
||||||
|
|
||||||
return getResult(sseOutput === "" ? null : sseOutput);
|
|
||||||
}
|
|
||||||
|
|
||||||
startScrape();
|
startScrape();
|
||||||
const providers = getProviders();
|
const providers = getProviders();
|
||||||
const output = await providers.runAll({
|
const output = await providers.runAll({
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import "./stores/__old/imports";
|
||||||
import "@/setup/ga";
|
import "@/setup/ga";
|
||||||
import "@/assets/css/index.css";
|
import "@/assets/css/index.css";
|
||||||
|
|
||||||
import { StrictMode, Suspense, useCallback } from "react";
|
import { StrictMode, Suspense, useCallback, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { HelmetProvider } from "react-helmet-async";
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
|
|
@ -62,15 +62,19 @@ function ErrorScreen(props: {
|
||||||
showResetButton?: boolean;
|
showResetButton?: boolean;
|
||||||
showLogoutButton?: boolean;
|
showLogoutButton?: boolean;
|
||||||
showReloadButton?: boolean;
|
showReloadButton?: boolean;
|
||||||
|
showDisconnectButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { logout } = useAuth();
|
const { logout, disconnectFromBackend } = useAuth();
|
||||||
const setBackendUrl = useAuthStore((s) => s.setBackendUrl);
|
const setBackendUrl = useAuthStore((s) => s.setBackendUrl);
|
||||||
|
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
|
||||||
|
|
||||||
const resetBackend = useCallback(() => {
|
const resetBackend = useCallback(() => {
|
||||||
setBackendUrl(null);
|
setBackendUrl(null);
|
||||||
// eslint-disable-next-line no-restricted-globals
|
// eslint-disable-next-line no-restricted-globals
|
||||||
location.reload();
|
location.reload();
|
||||||
}, [setBackendUrl]);
|
}, [setBackendUrl]);
|
||||||
|
|
||||||
const logoutFromBackend = useCallback(() => {
|
const logoutFromBackend = useCallback(() => {
|
||||||
logout().then(() => {
|
logout().then(() => {
|
||||||
// eslint-disable-next-line no-restricted-globals
|
// eslint-disable-next-line no-restricted-globals
|
||||||
|
|
@ -78,6 +82,13 @@ function ErrorScreen(props: {
|
||||||
});
|
});
|
||||||
}, [logout]);
|
}, [logout]);
|
||||||
|
|
||||||
|
const handleDisconnectConfirm = useCallback(() => {
|
||||||
|
disconnectFromBackend().then(() => {
|
||||||
|
// eslint-disable-next-line no-restricted-globals
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
}, [disconnectFromBackend]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LargeTextPart
|
<LargeTextPart
|
||||||
iconSlot={
|
iconSlot={
|
||||||
|
|
@ -99,6 +110,16 @@ function ErrorScreen(props: {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{props.showDisconnectButton ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button
|
||||||
|
theme="secondary"
|
||||||
|
onClick={() => setShowDisconnectConfirm(true)}
|
||||||
|
>
|
||||||
|
{t("screens.loadingUserError.disconnect")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{props.showReloadButton ? (
|
{props.showReloadButton ? (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Button theme="secondary" onClick={() => window.location.reload()}>
|
<Button theme="secondary" onClick={() => window.location.reload()}>
|
||||||
|
|
@ -106,6 +127,31 @@ function ErrorScreen(props: {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Disconnect Confirmation Modal */}
|
||||||
|
{showDisconnectConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-modal-background rounded-xl p-8 max-w-md mx-4">
|
||||||
|
<h2 className="text-white text-xl font-semibold mb-4">
|
||||||
|
{t("screens.loadingUserError.disconnectTitle")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-type-secondary mb-6">
|
||||||
|
{t("screens.loadingUserError.disconnectMessage")}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
theme="secondary"
|
||||||
|
onClick={() => setShowDisconnectConfirm(false)}
|
||||||
|
>
|
||||||
|
{t("screens.loadingUserError.disconectCancel")}
|
||||||
|
</Button>
|
||||||
|
<Button theme="danger" onClick={handleDisconnectConfirm}>
|
||||||
|
{t("screens.loadingUserError.disconnectConfirm")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</LargeTextPart>
|
</LargeTextPart>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -115,6 +161,7 @@ function AuthWrapper() {
|
||||||
const backendUrl = conf().BACKEND_URL;
|
const backendUrl = conf().BACKEND_URL;
|
||||||
const userBackendUrl = useBackendUrl();
|
const userBackendUrl = useBackendUrl();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isLoggedIn = !!useAuthStore((s) => s.account);
|
||||||
|
|
||||||
const isCustomUrl = backendUrl !== userBackendUrl;
|
const isCustomUrl = backendUrl !== userBackendUrl;
|
||||||
|
|
||||||
|
|
@ -124,6 +171,7 @@ function AuthWrapper() {
|
||||||
<ErrorScreen
|
<ErrorScreen
|
||||||
showResetButton={isCustomUrl}
|
showResetButton={isCustomUrl}
|
||||||
showLogoutButton={!isCustomUrl}
|
showLogoutButton={!isCustomUrl}
|
||||||
|
showDisconnectButton={!isCustomUrl}
|
||||||
showReloadButton={!isCustomUrl}
|
showReloadButton={!isCustomUrl}
|
||||||
>
|
>
|
||||||
{t(
|
{t(
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,93 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Button } from "@/components/buttons/Button";
|
||||||
|
import { BackendSelector } from "@/components/form/BackendSelector";
|
||||||
|
import {
|
||||||
|
LargeCard,
|
||||||
|
LargeCardButtons,
|
||||||
|
LargeCardText,
|
||||||
|
} from "@/components/layout/LargeCard";
|
||||||
import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
|
import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
|
||||||
import { LoginFormPart } from "@/pages/parts/auth/LoginFormPart";
|
import { LoginFormPart } from "@/pages/parts/auth/LoginFormPart";
|
||||||
import { PageTitle } from "@/pages/parts/util/PageTitle";
|
import { PageTitle } from "@/pages/parts/util/PageTitle";
|
||||||
|
import { conf } from "@/setup/config";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setBackendUrl = useAuthStore((s) => s.setBackendUrl);
|
||||||
|
const config = conf();
|
||||||
|
const availableBackends =
|
||||||
|
config.BACKEND_URLS.length > 0
|
||||||
|
? config.BACKEND_URLS
|
||||||
|
: config.BACKEND_URL
|
||||||
|
? [config.BACKEND_URL]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// If there's only one backend and user hasn't selected a custom one, auto-select it
|
||||||
|
const currentBackendUrl = useAuthStore((s) => s.backendUrl);
|
||||||
|
const defaultBackend =
|
||||||
|
currentBackendUrl ??
|
||||||
|
(availableBackends.length === 1 ? availableBackends[0] : null);
|
||||||
|
|
||||||
|
const [showBackendSelection, setShowBackendSelection] = useState(true);
|
||||||
|
const [selectedBackendUrl, setSelectedBackendUrl] = useState<string | null>(
|
||||||
|
currentBackendUrl ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBackendSelect = (url: string | null) => {
|
||||||
|
setSelectedBackendUrl(url);
|
||||||
|
if (url) {
|
||||||
|
setBackendUrl(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContinue = () => {
|
||||||
|
if (selectedBackendUrl || defaultBackend) {
|
||||||
|
if (selectedBackendUrl) {
|
||||||
|
setBackendUrl(selectedBackendUrl);
|
||||||
|
} else if (defaultBackend) {
|
||||||
|
setBackendUrl(defaultBackend);
|
||||||
|
}
|
||||||
|
setShowBackendSelection(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SubPageLayout>
|
<SubPageLayout>
|
||||||
<PageTitle subpage k="global.pages.login" />
|
<PageTitle subpage k="global.pages.login" />
|
||||||
|
{showBackendSelection &&
|
||||||
|
(availableBackends.length > 1 || !defaultBackend) ? (
|
||||||
|
<LargeCard>
|
||||||
|
<LargeCardText title={t("auth.backendSelection.title")}>
|
||||||
|
{t("auth.backendSelection.description")}
|
||||||
|
</LargeCardText>
|
||||||
|
<BackendSelector
|
||||||
|
selectedUrl={selectedBackendUrl ?? defaultBackend}
|
||||||
|
onSelect={handleBackendSelect}
|
||||||
|
availableUrls={availableBackends}
|
||||||
|
showCustom
|
||||||
|
/>
|
||||||
|
<LargeCardButtons>
|
||||||
|
<Button
|
||||||
|
theme="purple"
|
||||||
|
onClick={handleContinue}
|
||||||
|
disabled={!selectedBackendUrl && !defaultBackend}
|
||||||
|
>
|
||||||
|
{t("auth.register.information.next")}
|
||||||
|
</Button>
|
||||||
|
</LargeCardButtons>
|
||||||
|
</LargeCard>
|
||||||
|
) : (
|
||||||
<LoginFormPart
|
<LoginFormPart
|
||||||
onLogin={() => {
|
onLogin={() => {
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</SubPageLayout>
|
</SubPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3";
|
import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { MetaResponse } from "@/backend/accounts/meta";
|
import { MetaResponse } from "@/backend/accounts/meta";
|
||||||
|
import { Button } from "@/components/buttons/Button";
|
||||||
|
import { BackendSelector } from "@/components/form/BackendSelector";
|
||||||
|
import {
|
||||||
|
LargeCard,
|
||||||
|
LargeCardButtons,
|
||||||
|
LargeCardText,
|
||||||
|
} from "@/components/layout/LargeCard";
|
||||||
import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
|
import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
|
||||||
import {
|
import {
|
||||||
AccountCreatePart,
|
AccountCreatePart,
|
||||||
|
|
@ -12,6 +20,8 @@ import { PassphraseGeneratePart } from "@/pages/parts/auth/PassphraseGeneratePar
|
||||||
import { TrustBackendPart } from "@/pages/parts/auth/TrustBackendPart";
|
import { TrustBackendPart } from "@/pages/parts/auth/TrustBackendPart";
|
||||||
import { VerifyPassphrase } from "@/pages/parts/auth/VerifyPassphrasePart";
|
import { VerifyPassphrase } from "@/pages/parts/auth/VerifyPassphrasePart";
|
||||||
import { PageTitle } from "@/pages/parts/util/PageTitle";
|
import { PageTitle } from "@/pages/parts/util/PageTitle";
|
||||||
|
import { conf } from "@/setup/config";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
function CaptchaProvider(props: {
|
function CaptchaProvider(props: {
|
||||||
siteKey: string | null;
|
siteKey: string | null;
|
||||||
|
|
@ -27,17 +37,65 @@ function CaptchaProvider(props: {
|
||||||
|
|
||||||
export function RegisterPage() {
|
export function RegisterPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [step, setStep] = useState(0);
|
const { t } = useTranslation();
|
||||||
|
const setBackendUrl = useAuthStore((s) => s.setBackendUrl);
|
||||||
|
const currentBackendUrl = useAuthStore((s) => s.backendUrl);
|
||||||
|
const config = conf();
|
||||||
|
const availableBackends =
|
||||||
|
config.BACKEND_URLS.length > 0
|
||||||
|
? config.BACKEND_URLS
|
||||||
|
: config.BACKEND_URL
|
||||||
|
? [config.BACKEND_URL]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const [step, setStep] = useState(-1);
|
||||||
const [mnemonic, setMnemonic] = useState<null | string>(null);
|
const [mnemonic, setMnemonic] = useState<null | string>(null);
|
||||||
const [account, setAccount] = useState<null | AccountProfile>(null);
|
const [account, setAccount] = useState<null | AccountProfile>(null);
|
||||||
const [siteKey, setSiteKey] = useState<string | null>(null);
|
const [siteKey, setSiteKey] = useState<string | null>(null);
|
||||||
|
const [selectedBackendUrl, setSelectedBackendUrl] = useState<string | null>(
|
||||||
|
currentBackendUrl ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBackendSelect = (url: string | null) => {
|
||||||
|
setSelectedBackendUrl(url);
|
||||||
|
if (url) {
|
||||||
|
setBackendUrl(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CaptchaProvider siteKey={siteKey}>
|
<CaptchaProvider siteKey={siteKey}>
|
||||||
<SubPageLayout>
|
<SubPageLayout>
|
||||||
<PageTitle subpage k="global.pages.register" />
|
<PageTitle subpage k="global.pages.register" />
|
||||||
|
{step === -1 ? (
|
||||||
|
<LargeCard>
|
||||||
|
<LargeCardText title={t("auth.backendSelection.title")}>
|
||||||
|
{t("auth.backendSelection.description")}
|
||||||
|
</LargeCardText>
|
||||||
|
<BackendSelector
|
||||||
|
selectedUrl={selectedBackendUrl}
|
||||||
|
onSelect={handleBackendSelect}
|
||||||
|
availableUrls={availableBackends}
|
||||||
|
showCustom
|
||||||
|
/>
|
||||||
|
<LargeCardButtons>
|
||||||
|
<Button
|
||||||
|
theme="purple"
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedBackendUrl) {
|
||||||
|
setStep(0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!selectedBackendUrl}
|
||||||
|
>
|
||||||
|
{t("auth.register.information.next")}
|
||||||
|
</Button>
|
||||||
|
</LargeCardButtons>
|
||||||
|
</LargeCard>
|
||||||
|
) : null}
|
||||||
{step === 0 ? (
|
{step === 0 ? (
|
||||||
<TrustBackendPart
|
<TrustBackendPart
|
||||||
|
backendUrl={selectedBackendUrl}
|
||||||
onNext={(meta: MetaResponse) => {
|
onNext={(meta: MetaResponse) => {
|
||||||
setSiteKey(
|
setSiteKey(
|
||||||
meta.hasCaptcha && meta.captchaClientKey
|
meta.hasCaptcha && meta.captchaClientKey
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,10 @@ import { Button } from "@/components/buttons/Button";
|
||||||
import { SearchBarInput } from "@/components/form/SearchBar";
|
import { SearchBarInput } from "@/components/form/SearchBar";
|
||||||
import { ThinContainer } from "@/components/layout/ThinContainer";
|
import { ThinContainer } from "@/components/layout/ThinContainer";
|
||||||
import { WideContainer } from "@/components/layout/WideContainer";
|
import { WideContainer } from "@/components/layout/WideContainer";
|
||||||
|
import { Modal, ModalCard, useModal } from "@/components/overlays/Modal";
|
||||||
import { UserIcons } from "@/components/UserIcon";
|
import { UserIcons } from "@/components/UserIcon";
|
||||||
import { Divider } from "@/components/utils/Divider";
|
import { Divider } from "@/components/utils/Divider";
|
||||||
import { Heading1 } from "@/components/utils/Text";
|
import { Heading1, Heading2, Paragraph } from "@/components/utils/Text";
|
||||||
import { Transition } from "@/components/utils/Transition";
|
import { Transition } from "@/components/utils/Transition";
|
||||||
import { useAuth } from "@/hooks/auth/useAuth";
|
import { useAuth } from "@/hooks/auth/useAuth";
|
||||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||||
|
|
@ -168,6 +169,10 @@ export function SettingsPage() {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
const prevCategoryRef = useRef<string | null>(null);
|
const prevCategoryRef = useRef<string | null>(null);
|
||||||
|
const backendChangeModal = useModal("settings-backend-change-confirmation");
|
||||||
|
const [pendingBackendChange, setPendingBackendChange] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hash = window.location.hash;
|
const hash = window.location.hash;
|
||||||
|
|
@ -730,25 +735,32 @@ export function SettingsPage() {
|
||||||
updateProfile(state.profile.state);
|
updateProfile(state.profile.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// when backend url gets changed, log the user out first
|
// when backend url gets changed, show confirmation and log the user out (only if logged in)
|
||||||
if (state.backendUrl.changed) {
|
if (state.backendUrl.changed) {
|
||||||
await logout();
|
|
||||||
|
|
||||||
let url = state.backendUrl.state;
|
let url = state.backendUrl.state;
|
||||||
if (url && !url.startsWith("http://") && !url.startsWith("https://")) {
|
if (url && !url.startsWith("http://") && !url.startsWith("https://")) {
|
||||||
url = `https://${url}`;
|
url = `https://${url}`;
|
||||||
}
|
}
|
||||||
|
if (account) {
|
||||||
|
// User is logged in - show confirmation
|
||||||
|
setPendingBackendChange(url);
|
||||||
|
backendChangeModal.show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// User is not logged in - just update without confirmation
|
||||||
setBackendUrl(url);
|
setBackendUrl(url);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
account,
|
account,
|
||||||
backendUrl,
|
backendUrl,
|
||||||
|
backendChangeModal,
|
||||||
|
setPendingBackendChange,
|
||||||
|
state,
|
||||||
|
setBackendUrl,
|
||||||
setEnableThumbnails,
|
setEnableThumbnails,
|
||||||
setFebboxKey,
|
setFebboxKey,
|
||||||
setdebridToken,
|
setdebridToken,
|
||||||
setdebridService,
|
setdebridService,
|
||||||
state,
|
|
||||||
setEnableAutoplay,
|
setEnableAutoplay,
|
||||||
setEnableSkipCredits,
|
setEnableSkipCredits,
|
||||||
setEnableDiscover,
|
setEnableDiscover,
|
||||||
|
|
@ -766,8 +778,6 @@ export function SettingsPage() {
|
||||||
updateDeviceName,
|
updateDeviceName,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
updateNickname,
|
updateNickname,
|
||||||
logout,
|
|
||||||
setBackendUrl,
|
|
||||||
setProxyTmdb,
|
setProxyTmdb,
|
||||||
setEnableCarouselView,
|
setEnableCarouselView,
|
||||||
setEnableMinimalCards,
|
setEnableMinimalCards,
|
||||||
|
|
@ -948,6 +958,43 @@ export function SettingsPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
{account && (
|
||||||
|
<Modal id={backendChangeModal.id}>
|
||||||
|
<ModalCard>
|
||||||
|
<Heading2 className="!mt-0 !mb-4">
|
||||||
|
{t("settings.connections.server.changeWarningTitle")}
|
||||||
|
</Heading2>
|
||||||
|
<Paragraph className="!mt-1 !mb-6">
|
||||||
|
{t("settings.connections.server.changeWarning")}
|
||||||
|
</Paragraph>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
theme="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
backendChangeModal.hide();
|
||||||
|
setPendingBackendChange(null);
|
||||||
|
state.backendUrl.set(backendUrlSetting);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme="purple"
|
||||||
|
onClick={async () => {
|
||||||
|
backendChangeModal.hide();
|
||||||
|
if (pendingBackendChange !== null) {
|
||||||
|
await logout();
|
||||||
|
setBackendUrl(pendingBackendChange);
|
||||||
|
setPendingBackendChange(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("actions.confirm")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalCard>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</SubPageLayout>
|
</SubPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,14 @@ import { MwLink } from "@/components/text/Link";
|
||||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||||
|
|
||||||
interface TrustBackendPartProps {
|
interface TrustBackendPartProps {
|
||||||
|
backendUrl?: string | null;
|
||||||
onNext?: (meta: MetaResponse) => void;
|
onNext?: (meta: MetaResponse) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrustBackendPart(props: TrustBackendPartProps) {
|
export function TrustBackendPart(props: TrustBackendPartProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const backendUrl = useBackendUrl();
|
const defaultBackendUrl = useBackendUrl();
|
||||||
|
const backendUrl = props.backendUrl ?? defaultBackendUrl;
|
||||||
const hostname = useMemo(
|
const hostname = useMemo(
|
||||||
() => (backendUrl ? new URL(backendUrl).hostname : undefined),
|
() => (backendUrl ? new URL(backendUrl).hostname : undefined),
|
||||||
[backendUrl],
|
[backendUrl],
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,10 @@ import type { AsyncReturnType } from "type-fest";
|
||||||
|
|
||||||
import { isAllowedExtensionVersion } from "@/backend/extension/compatibility";
|
import { isAllowedExtensionVersion } from "@/backend/extension/compatibility";
|
||||||
import { extensionInfo, sendPage } from "@/backend/extension/messaging";
|
import { extensionInfo, sendPage } from "@/backend/extension/messaging";
|
||||||
import {
|
import { setCachedMetadata } from "@/backend/helpers/providerApi";
|
||||||
fetchMetadata,
|
|
||||||
setCachedMetadata,
|
|
||||||
} from "@/backend/helpers/providerApi";
|
|
||||||
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
|
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
|
||||||
import { decodeTMDBId } from "@/backend/metadata/tmdb";
|
import { decodeTMDBId } from "@/backend/metadata/tmdb";
|
||||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||||
import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers";
|
|
||||||
import { getProviders } from "@/backend/providers/providers";
|
import { getProviders } from "@/backend/providers/providers";
|
||||||
import { Button } from "@/components/buttons/Button";
|
import { Button } from "@/components/buttons/Button";
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
|
|
@ -52,20 +48,11 @@ export function MetaPart(props: MetaPartProps) {
|
||||||
if (!info.hasPermission) throw new Error("extension-no-permission");
|
if (!info.hasPermission) throw new Error("extension-no-permission");
|
||||||
}
|
}
|
||||||
|
|
||||||
// use api metadata or providers metadata
|
// use providers metadata
|
||||||
const providerApiUrl = getLoadbalancedProviderApiUrl();
|
|
||||||
if (providerApiUrl && !isValidExtension) {
|
|
||||||
try {
|
|
||||||
await fetchMetadata(providerApiUrl);
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error("failed-api-metadata");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setCachedMetadata([
|
setCachedMetadata([
|
||||||
...getProviders().listSources(),
|
...getProviders().listSources(),
|
||||||
...getProviders().listEmbeds(),
|
...getProviders().listEmbeds(),
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
// get media meta data
|
// get media meta data
|
||||||
let data: ReturnType<typeof decodeTMDBId> = null;
|
let data: ReturnType<typeof decodeTMDBId> = null;
|
||||||
|
|
@ -160,28 +147,6 @@ export function MetaPart(props: MetaPartProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error && error.message === "failed-api-metadata") {
|
|
||||||
return (
|
|
||||||
<ErrorLayout>
|
|
||||||
<ErrorContainer>
|
|
||||||
<IconPill icon={Icons.WAND}>
|
|
||||||
{t("player.metadata.failed.badge")}
|
|
||||||
</IconPill>
|
|
||||||
<Title>{t("player.metadata.api.text")}</Title>
|
|
||||||
<Paragraph>{t("player.metadata.api.title")}</Paragraph>
|
|
||||||
<Button
|
|
||||||
href="/"
|
|
||||||
theme="purple"
|
|
||||||
padding="md:px-12 p-2.5"
|
|
||||||
className="mt-6"
|
|
||||||
>
|
|
||||||
{t("player.metadata.failed.homeButton")}
|
|
||||||
</Button>
|
|
||||||
</ErrorContainer>
|
|
||||||
</ErrorLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<ErrorLayout>
|
<ErrorLayout>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import { useOnboardingStore } from "@/stores/onboarding";
|
||||||
import { usePreferencesStore } from "@/stores/preferences";
|
import { usePreferencesStore } from "@/stores/preferences";
|
||||||
import { getExtensionState } from "@/utils/extension";
|
import { getExtensionState } from "@/utils/extension";
|
||||||
import type { ExtensionStatus } from "@/utils/extension";
|
import type { ExtensionStatus } from "@/utils/extension";
|
||||||
import { getProviderApiUrls } from "@/utils/proxyUrls";
|
|
||||||
|
|
||||||
import { ErrorCardInModal } from "../errors/ErrorCard";
|
import { ErrorCardInModal } from "../errors/ErrorCard";
|
||||||
|
|
||||||
|
|
@ -42,9 +41,7 @@ export function ScrapeErrorPart(props: ScrapeErrorPartProps) {
|
||||||
const error = useMemo(() => {
|
const error = useMemo(() => {
|
||||||
const data = props.data;
|
const data = props.data;
|
||||||
let str = "";
|
let str = "";
|
||||||
const apiUrls = getProviderApiUrls();
|
str += `URL - ${location.pathname}\n\n`;
|
||||||
str += `URL - ${location.pathname}\n`;
|
|
||||||
str += `API - ${apiUrls.length > 0}\n\n`;
|
|
||||||
Object.values(data.sources).forEach((v) => {
|
Object.values(data.sources).forEach((v) => {
|
||||||
str += `${v.id}: ${v.status}\n`;
|
str += `${v.id}: ${v.status}\n`;
|
||||||
if (v.reason) str += `${v.reason}\n`;
|
if (v.reason) str += `${v.reason}\n`;
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ import {
|
||||||
useListCenter,
|
useListCenter,
|
||||||
useScrape,
|
useScrape,
|
||||||
} from "@/hooks/useProviderScrape";
|
} from "@/hooks/useProviderScrape";
|
||||||
|
import { playerStatus } from "@/stores/player/slices/source";
|
||||||
import { WarningPart } from "../util/WarningPart";
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
|
||||||
export interface ScrapingProps {
|
export interface ScrapingProps {
|
||||||
media: ScrapeMedia;
|
media: ScrapeMedia;
|
||||||
|
|
@ -40,10 +40,12 @@ export function ScrapingPart(props: ScrapingProps) {
|
||||||
useScrape();
|
useScrape();
|
||||||
const isMounted = useMountedState();
|
const isMounted = useMountedState();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const setStatus = usePlayerStore((s) => s.setStatus);
|
||||||
|
const addFailedSource = usePlayerStore((s) => s.addFailedSource);
|
||||||
|
const sourceId = usePlayerStore((s) => s.sourceId);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const listRef = useRef<HTMLDivElement | null>(null);
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [failedStartScrape, setFailedStartScrape] = useState<boolean>(false);
|
|
||||||
const renderedOnce = useListCenter(
|
const renderedOnce = useListCenter(
|
||||||
containerRef,
|
containerRef,
|
||||||
listRef,
|
listRef,
|
||||||
|
|
@ -86,8 +88,36 @@ export function ScrapingPart(props: ScrapingProps) {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
props.onGetStream?.(output);
|
props.onGetStream?.(output);
|
||||||
})().catch(() => setFailedStartScrape(true));
|
})().catch((error) => {
|
||||||
}, [startScraping, resumeScraping, props, report, isMounted]);
|
if (!isMounted()) return;
|
||||||
|
// Treat scraping failure as fatal error
|
||||||
|
// Mark current source as failed if we have one
|
||||||
|
if (sourceId) {
|
||||||
|
addFailedSource(sourceId);
|
||||||
|
} else if (currentSource) {
|
||||||
|
addFailedSource(currentSource);
|
||||||
|
}
|
||||||
|
// Set error and status to trigger PlaybackErrorPart
|
||||||
|
usePlayerStore.setState((s) => {
|
||||||
|
s.interface.error = {
|
||||||
|
errorName: "ScrapingError",
|
||||||
|
message: error?.message || "Failed to start scraping",
|
||||||
|
type: "global",
|
||||||
|
};
|
||||||
|
s.status = playerStatus.PLAYBACK_ERROR;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
startScraping,
|
||||||
|
resumeScraping,
|
||||||
|
props,
|
||||||
|
report,
|
||||||
|
isMounted,
|
||||||
|
setStatus,
|
||||||
|
addFailedSource,
|
||||||
|
sourceId,
|
||||||
|
currentSource,
|
||||||
|
]);
|
||||||
|
|
||||||
let currentProviderIndex = sourceOrder.findIndex(
|
let currentProviderIndex = sourceOrder.findIndex(
|
||||||
(s) => s.id === currentSource || s.children.includes(currentSource ?? ""),
|
(s) => s.id === currentSource || s.children.includes(currentSource ?? ""),
|
||||||
|
|
@ -95,9 +125,6 @@ export function ScrapingPart(props: ScrapingProps) {
|
||||||
if (currentProviderIndex === -1)
|
if (currentProviderIndex === -1)
|
||||||
currentProviderIndex = sourceOrder.length - 1;
|
currentProviderIndex = sourceOrder.length - 1;
|
||||||
|
|
||||||
if (failedStartScrape)
|
|
||||||
return <WarningPart>{t("player.turnstile.error")}</WarningPart>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-full w-full relative dir-neutral:origin-top-left flex"
|
className="h-full w-full relative dir-neutral:origin-top-left flex"
|
||||||
|
|
@ -106,7 +133,7 @@ export function ScrapingPart(props: ScrapingProps) {
|
||||||
{!sourceOrder || sourceOrder.length === 0 ? (
|
{!sourceOrder || sourceOrder.length === 0 ? (
|
||||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center flex flex-col justify-center z-0">
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center flex flex-col justify-center z-0">
|
||||||
<Loading className="mb-8" />
|
<Loading className="mb-8" />
|
||||||
<p>{t("player.turnstile.verifyingHumanity")}</p>
|
<p>{t("player.scraping.items.pending")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -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 { BackendSelector } from "@/components/form/BackendSelector";
|
||||||
import { Dropdown } from "@/components/form/Dropdown";
|
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";
|
||||||
|
|
@ -184,9 +185,44 @@ function ProxyEdit({
|
||||||
function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) {
|
function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const user = useAuthStore();
|
const user = useAuthStore();
|
||||||
|
const config = conf();
|
||||||
|
const availableBackends =
|
||||||
|
config.BACKEND_URLS.length > 0
|
||||||
|
? config.BACKEND_URLS
|
||||||
|
: config.BACKEND_URL
|
||||||
|
? [config.BACKEND_URL]
|
||||||
|
: [];
|
||||||
|
const currentBackendUrl =
|
||||||
|
backendUrl ?? (availableBackends.length > 0 ? availableBackends[0] : null);
|
||||||
|
const [pendingBackendUrl, setPendingBackendUrl] = useState<string | null>(
|
||||||
|
currentBackendUrl,
|
||||||
|
);
|
||||||
|
const confirmationModal = useModal("backend-change-confirmation");
|
||||||
|
|
||||||
|
const handleBackendSelect = (url: string | null) => {
|
||||||
|
if (!user.account) {
|
||||||
|
// No account - just update without confirmation
|
||||||
|
setBackendUrl(url);
|
||||||
|
setPendingBackendUrl(url);
|
||||||
|
} else if (url !== currentBackendUrl) {
|
||||||
|
// User is logged in and changing backend - show confirmation
|
||||||
|
setPendingBackendUrl(url);
|
||||||
|
confirmationModal.show();
|
||||||
|
} else {
|
||||||
|
// Same backend - just update
|
||||||
|
setBackendUrl(url);
|
||||||
|
setPendingBackendUrl(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmChange = () => {
|
||||||
|
setBackendUrl(pendingBackendUrl);
|
||||||
|
confirmationModal.hide();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<SettingsCard>
|
<SettingsCard>
|
||||||
<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">
|
<p className="text-white font-bold mb-3">
|
||||||
{t("settings.connections.server.label")}
|
{t("settings.connections.server.label")}
|
||||||
|
|
@ -211,27 +247,50 @@ function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{(availableBackends.length > 0 || currentBackendUrl) && (
|
||||||
<Toggle
|
|
||||||
onClick={() => setBackendUrl((s) => (s === null ? "" : null))}
|
|
||||||
enabled={backendUrl !== null}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{backendUrl !== null ? (
|
|
||||||
<>
|
<>
|
||||||
<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("settings.connections.server.urlLabel")}
|
{t("settings.connections.server.selectBackend")}
|
||||||
</p>
|
</p>
|
||||||
|
{availableBackends.length > 0 ? (
|
||||||
|
<BackendSelector
|
||||||
|
selectedUrl={currentBackendUrl}
|
||||||
|
onSelect={handleBackendSelect}
|
||||||
|
availableUrls={availableBackends}
|
||||||
|
showCustom
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<AuthInputBox
|
<AuthInputBox
|
||||||
onChange={setBackendUrl}
|
onChange={setBackendUrl}
|
||||||
value={backendUrl ?? ""}
|
value={backendUrl ?? ""}
|
||||||
placeholder="https://"
|
placeholder="https://"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : null}
|
)}
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
{user.account && (
|
||||||
|
<Modal id={confirmationModal.id}>
|
||||||
|
<ModalCard>
|
||||||
|
<Heading2 className="!mt-0 !mb-4">
|
||||||
|
{t("settings.connections.server.changeWarningTitle")}
|
||||||
|
</Heading2>
|
||||||
|
<Paragraph className="!mt-1 !mb-6">
|
||||||
|
{t("settings.connections.server.changeWarning")}
|
||||||
|
</Paragraph>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button theme="secondary" onClick={confirmationModal.hide}>
|
||||||
|
{t("settings.connections.server.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button theme="purple" onClick={handleConfirmChange}>
|
||||||
|
{t("settings.connections.server.confirm")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalCard>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
import { BlurEllipsis } from "@/pages/layouts/SubPageLayout";
|
|
||||||
|
|
||||||
export function WarningPart(props: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col justify-center items-center h-screen text-center font-medium">
|
|
||||||
<BlurEllipsis />
|
|
||||||
<Icon className="text-type-danger text-2xl" icon={Icons.WARNING} />
|
|
||||||
<div className="max-w-[19rem] mt-3 mb-12 text-type-secondary">
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -18,7 +18,6 @@ interface Config {
|
||||||
NORMAL_ROUTER: boolean;
|
NORMAL_ROUTER: boolean;
|
||||||
BACKEND_URL: string;
|
BACKEND_URL: string;
|
||||||
DISALLOWED_IDS: string;
|
DISALLOWED_IDS: string;
|
||||||
TURNSTILE_KEY: string;
|
|
||||||
CDN_REPLACEMENTS: string;
|
CDN_REPLACEMENTS: string;
|
||||||
HAS_ONBOARDING: string;
|
HAS_ONBOARDING: string;
|
||||||
ONBOARDING_CHROME_EXTENSION_INSTALL_LINK: string;
|
ONBOARDING_CHROME_EXTENSION_INSTALL_LINK: string;
|
||||||
|
|
@ -50,8 +49,8 @@ export interface RuntimeConfig {
|
||||||
PROXY_URLS: string[];
|
PROXY_URLS: string[];
|
||||||
M3U8_PROXY_URLS: string[];
|
M3U8_PROXY_URLS: string[];
|
||||||
BACKEND_URL: string | null;
|
BACKEND_URL: string | null;
|
||||||
|
BACKEND_URLS: string[];
|
||||||
DISALLOWED_IDS: string[];
|
DISALLOWED_IDS: string[];
|
||||||
TURNSTILE_KEY: string | null;
|
|
||||||
CDN_REPLACEMENTS: Array<string[]>;
|
CDN_REPLACEMENTS: Array<string[]>;
|
||||||
HAS_ONBOARDING: boolean;
|
HAS_ONBOARDING: boolean;
|
||||||
ALLOW_AUTOPLAY: boolean;
|
ALLOW_AUTOPLAY: boolean;
|
||||||
|
|
@ -88,7 +87,6 @@ const env: Record<keyof Config, undefined | string> = {
|
||||||
NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER,
|
NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER,
|
||||||
BACKEND_URL: import.meta.env.VITE_BACKEND_URL,
|
BACKEND_URL: import.meta.env.VITE_BACKEND_URL,
|
||||||
DISALLOWED_IDS: import.meta.env.VITE_DISALLOWED_IDS,
|
DISALLOWED_IDS: import.meta.env.VITE_DISALLOWED_IDS,
|
||||||
TURNSTILE_KEY: import.meta.env.VITE_TURNSTILE_KEY,
|
|
||||||
CDN_REPLACEMENTS: import.meta.env.VITE_CDN_REPLACEMENTS,
|
CDN_REPLACEMENTS: import.meta.env.VITE_CDN_REPLACEMENTS,
|
||||||
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,
|
||||||
|
|
@ -140,7 +138,24 @@ export function conf(): RuntimeConfig {
|
||||||
"https://docs.pstream.mov/extension",
|
"https://docs.pstream.mov/extension",
|
||||||
),
|
),
|
||||||
ONBOARDING_PROXY_INSTALL_LINK: getKey("ONBOARDING_PROXY_INSTALL_LINK"),
|
ONBOARDING_PROXY_INSTALL_LINK: getKey("ONBOARDING_PROXY_INSTALL_LINK"),
|
||||||
BACKEND_URL: getKey("BACKEND_URL", BACKEND_URL),
|
BACKEND_URLS: getKey("BACKEND_URL", BACKEND_URL)
|
||||||
|
? getKey("BACKEND_URL", BACKEND_URL)
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter((v) => v.length > 0)
|
||||||
|
: [],
|
||||||
|
BACKEND_URL: (() => {
|
||||||
|
const backendUrlValue = getKey("BACKEND_URL", BACKEND_URL);
|
||||||
|
if (!backendUrlValue) return backendUrlValue;
|
||||||
|
if (backendUrlValue.includes(",")) {
|
||||||
|
const urls = backendUrlValue
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter((v) => v.length > 0);
|
||||||
|
return urls.length > 0 ? urls[0] : backendUrlValue;
|
||||||
|
}
|
||||||
|
return backendUrlValue;
|
||||||
|
})(),
|
||||||
TMDB_READ_API_KEY: getKey("TMDB_READ_API_KEY"),
|
TMDB_READ_API_KEY: getKey("TMDB_READ_API_KEY"),
|
||||||
PROXY_URLS: getKey("CORS_PROXY_URL", "")
|
PROXY_URLS: getKey("CORS_PROXY_URL", "")
|
||||||
.split(",")
|
.split(",")
|
||||||
|
|
@ -153,7 +168,6 @@ export function conf(): RuntimeConfig {
|
||||||
NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true",
|
NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true",
|
||||||
HAS_ONBOARDING: getKey("HAS_ONBOARDING", "false") === "true",
|
HAS_ONBOARDING: getKey("HAS_ONBOARDING", "false") === "true",
|
||||||
ALLOW_AUTOPLAY: getKey("ALLOW_AUTOPLAY", "false") === "true",
|
ALLOW_AUTOPLAY: getKey("ALLOW_AUTOPLAY", "false") === "true",
|
||||||
TURNSTILE_KEY: getKey("TURNSTILE_KEY"),
|
|
||||||
DISALLOWED_IDS: getKey("DISALLOWED_IDS", "")
|
DISALLOWED_IDS: getKey("DISALLOWED_IDS", "")
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((v) => v.trim())
|
.map((v) => v.trim())
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
import { Turnstile } from "@marsidev/react-turnstile";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useRef } from "react";
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { immer } from "zustand/middleware/immer";
|
import { immer } from "zustand/middleware/immer";
|
||||||
|
|
||||||
import { reportCaptchaSolve } from "@/backend/helpers/report";
|
import { reportCaptchaSolve } from "@/backend/helpers/report";
|
||||||
import { conf } from "@/setup/config";
|
|
||||||
|
|
||||||
export interface TurnstileStore {
|
export interface TurnstileStore {
|
||||||
isInWidget: boolean;
|
isInWidget: boolean;
|
||||||
|
|
@ -84,47 +80,3 @@ export async function getTurnstileToken() {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TurnstileProvider(props: {
|
|
||||||
isInPopout?: boolean;
|
|
||||||
onUpdateShow?: (show: boolean) => void;
|
|
||||||
}) {
|
|
||||||
const siteKey = conf().TURNSTILE_KEY;
|
|
||||||
const idRef = useRef<string | null>(null);
|
|
||||||
const setTurnstile = useTurnstileStore((s) => s.setTurnstile);
|
|
||||||
const processToken = useTurnstileStore((s) => s.processToken);
|
|
||||||
if (!siteKey) return null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames({
|
|
||||||
hidden: !props.isInPopout,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Turnstile
|
|
||||||
siteKey={siteKey}
|
|
||||||
options={{
|
|
||||||
refreshExpired: "never",
|
|
||||||
theme: "light",
|
|
||||||
}}
|
|
||||||
onWidgetLoad={(widgetId) => {
|
|
||||||
idRef.current = widgetId;
|
|
||||||
setTurnstile(widgetId, "mwturnstile", !!props.isInPopout);
|
|
||||||
}}
|
|
||||||
onError={() => {
|
|
||||||
const id = idRef.current;
|
|
||||||
if (!id) return;
|
|
||||||
processToken(null, id);
|
|
||||||
}}
|
|
||||||
onSuccess={(token) => {
|
|
||||||
const id = idRef.current;
|
|
||||||
if (!id) return;
|
|
||||||
processToken(token, id);
|
|
||||||
props.onUpdateShow?.(false);
|
|
||||||
}}
|
|
||||||
onBeforeInteractive={() => {
|
|
||||||
props.onUpdateShow?.(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { conf } from "@/setup/config";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
const originalUrls = conf().PROXY_URLS;
|
const originalUrls = conf().PROXY_URLS;
|
||||||
const types = ["proxy", "api"] as const;
|
const types = ["proxy"] as const;
|
||||||
|
|
||||||
type ParsedUrlType = (typeof types)[number];
|
type ParsedUrlType = (typeof types)[number];
|
||||||
|
|
||||||
|
|
@ -73,9 +73,3 @@ export function getProxyUrls() {
|
||||||
export function getM3U8ProxyUrls(): string[] {
|
export function getM3U8ProxyUrls(): string[] {
|
||||||
return conf().M3U8_PROXY_URLS;
|
return conf().M3U8_PROXY_URLS;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProviderApiUrls() {
|
|
||||||
return getParsedUrls()
|
|
||||||
.filter((v) => v.type === "api")
|
|
||||||
.map((v) => v.url);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue