16 changed files with 653 additions and 161 deletions
@ -0,0 +1,158 @@ |
|||||||
|
import { MetaOutput, NotFoundError, ScrapeMedia } from "@movie-web/providers"; |
||||||
|
import { jwtDecode } from "jwt-decode"; |
||||||
|
|
||||||
|
import { mwFetch } from "@/backend/helpers/fetch"; |
||||||
|
import { getTurnstileToken, isTurnstileInitialized } from "@/stores/turnstile"; |
||||||
|
|
||||||
|
let metaDataCache: MetaOutput[] | null = null; |
||||||
|
let token: null | string = null; |
||||||
|
|
||||||
|
export function setCachedMetadata(data: MetaOutput[]) { |
||||||
|
metaDataCache = data; |
||||||
|
} |
||||||
|
|
||||||
|
export function getCachedMetadata(): MetaOutput[] { |
||||||
|
return metaDataCache ?? []; |
||||||
|
} |
||||||
|
|
||||||
|
export function setApiToken(newToken: string) { |
||||||
|
token = newToken; |
||||||
|
} |
||||||
|
|
||||||
|
function getTokenIfValid(): null | string { |
||||||
|
if (!token) return null; |
||||||
|
try { |
||||||
|
const body = jwtDecode(token); |
||||||
|
if (!body.exp) return `jwt|${token}`; |
||||||
|
if (Date.now() / 1000 < body.exp) return `jwt|${token}`; |
||||||
|
} catch (err) { |
||||||
|
// we dont care about parse errors
|
||||||
|
} |
||||||
|
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) { |
||||||
|
const url = makeUrl("/scrape"); |
||||||
|
addQueryDataToUrl(url, scrapeMediaToQueryMedia(media)); |
||||||
|
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> { |
||||||
|
let apiToken = getTokenIfValid(); |
||||||
|
if (!apiToken && isTurnstileInitialized()) { |
||||||
|
apiToken = `turnstile|${await getTurnstileToken()}`; |
||||||
|
} |
||||||
|
return apiToken; |
||||||
|
} |
||||||
|
|
||||||
|
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(JSON.parse(e.data)); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
eventSource.addEventListener("token", (e) => { |
||||||
|
setApiToken(JSON.parse(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))); |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
@ -0,0 +1,81 @@ |
|||||||
|
import Turnstile, { BoundTurnstileObject } from "react-turnstile"; |
||||||
|
import { create } from "zustand"; |
||||||
|
import { immer } from "zustand/middleware/immer"; |
||||||
|
|
||||||
|
import { conf } from "@/setup/config"; |
||||||
|
|
||||||
|
export interface TurnstileStore { |
||||||
|
turnstile: BoundTurnstileObject | null; |
||||||
|
cbs: ((token: string | null) => void)[]; |
||||||
|
setTurnstile(v: BoundTurnstileObject | null): void; |
||||||
|
getToken(): Promise<string>; |
||||||
|
processToken(token: string | null): void; |
||||||
|
} |
||||||
|
|
||||||
|
export const useTurnstileStore = create( |
||||||
|
immer<TurnstileStore>((set, get) => ({ |
||||||
|
turnstile: null, |
||||||
|
cbs: [], |
||||||
|
processToken(token) { |
||||||
|
const cbs = get().cbs; |
||||||
|
cbs.forEach((fn) => fn(token)); |
||||||
|
set((s) => { |
||||||
|
s.cbs = []; |
||||||
|
}); |
||||||
|
}, |
||||||
|
getToken() { |
||||||
|
return new Promise((resolve, reject) => { |
||||||
|
set((s) => { |
||||||
|
s.cbs = [ |
||||||
|
...s.cbs, |
||||||
|
(token) => { |
||||||
|
if (!token) reject(new Error("Failed to get token")); |
||||||
|
else resolve(token); |
||||||
|
}, |
||||||
|
]; |
||||||
|
}); |
||||||
|
}); |
||||||
|
}, |
||||||
|
setTurnstile(v) { |
||||||
|
set((s) => { |
||||||
|
s.turnstile = v; |
||||||
|
}); |
||||||
|
}, |
||||||
|
})) |
||||||
|
); |
||||||
|
|
||||||
|
export function getTurnstile() { |
||||||
|
return useTurnstileStore.getState().turnstile; |
||||||
|
} |
||||||
|
|
||||||
|
export function isTurnstileInitialized() { |
||||||
|
return !!getTurnstile(); |
||||||
|
} |
||||||
|
|
||||||
|
export function getTurnstileToken() { |
||||||
|
const turnstile = getTurnstile(); |
||||||
|
turnstile?.reset(); |
||||||
|
turnstile?.execute(); |
||||||
|
return useTurnstileStore.getState().getToken(); |
||||||
|
} |
||||||
|
|
||||||
|
export function TurnstileProvider() { |
||||||
|
const siteKey = conf().TURNSTILE_KEY; |
||||||
|
const setTurnstile = useTurnstileStore((s) => s.setTurnstile); |
||||||
|
const processToken = useTurnstileStore((s) => s.processToken); |
||||||
|
if (!siteKey) return null; |
||||||
|
return ( |
||||||
|
<Turnstile |
||||||
|
sitekey={siteKey} |
||||||
|
onLoad={(_widgetId, bound) => { |
||||||
|
setTurnstile(bound); |
||||||
|
}} |
||||||
|
onError={() => { |
||||||
|
processToken(null); |
||||||
|
}} |
||||||
|
onVerify={(token) => { |
||||||
|
processToken(token); |
||||||
|
}} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,77 @@ |
|||||||
|
import { conf } from "@/setup/config"; |
||||||
|
import { useAuthStore } from "@/stores/auth"; |
||||||
|
|
||||||
|
const originalUrls = conf().PROXY_URLS; |
||||||
|
const types = ["proxy", "api"] as const; |
||||||
|
|
||||||
|
type ParsedUrlType = (typeof types)[number]; |
||||||
|
|
||||||
|
export interface ParsedUrl { |
||||||
|
url: string; |
||||||
|
type: ParsedUrlType; |
||||||
|
} |
||||||
|
|
||||||
|
function canParseUrl(url: string): boolean { |
||||||
|
try { |
||||||
|
return !!new URL(url); |
||||||
|
} catch { |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function isParsedUrlType(type: string): type is ParsedUrlType { |
||||||
|
return types.includes(type as any); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Turn a string like "a=b;c=d;d=e" into a dictionary object |
||||||
|
*/ |
||||||
|
function parseParams(input: string): Record<string, string> { |
||||||
|
const entriesParams = input |
||||||
|
.split(";") |
||||||
|
.map((param) => param.split("=", 2).filter((part) => part.length !== 0)) |
||||||
|
.filter((v) => v.length === 2); |
||||||
|
return Object.fromEntries(entriesParams); |
||||||
|
} |
||||||
|
|
||||||
|
export function getParsedUrls() { |
||||||
|
const urls = useAuthStore.getState().proxySet ?? originalUrls; |
||||||
|
const output: ParsedUrl[] = []; |
||||||
|
urls.forEach((url) => { |
||||||
|
if (!url.startsWith("|")) { |
||||||
|
if (canParseUrl(url)) { |
||||||
|
output.push({ |
||||||
|
url, |
||||||
|
type: "proxy", |
||||||
|
}); |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const match = /^\|([^|]+)\|(.*)$/g.exec(url); |
||||||
|
if (!match || !match[2]) return; |
||||||
|
if (!canParseUrl(match[2])) return; |
||||||
|
const params = parseParams(match[1]); |
||||||
|
const type = params.type ?? "proxy"; |
||||||
|
|
||||||
|
if (!isParsedUrlType(type)) return; |
||||||
|
output.push({ |
||||||
|
url: match[2], |
||||||
|
type, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
return output; |
||||||
|
} |
||||||
|
|
||||||
|
export function getProxyUrls() { |
||||||
|
return getParsedUrls() |
||||||
|
.filter((v) => v.type === "proxy") |
||||||
|
.map((v) => v.url); |
||||||
|
} |
||||||
|
|
||||||
|
export function getProviderApiUrls() { |
||||||
|
return getParsedUrls() |
||||||
|
.filter((v) => v.type === "api") |
||||||
|
.map((v) => v.url); |
||||||
|
} |
Loading…
Reference in new issue