From 19498438e385f97e966e657396d7b8ea56c347ff Mon Sep 17 00:00:00 2001 From: silentbil Date: Thu, 23 Jul 2026 15:12:01 +0300 Subject: [PATCH 1/2] telegram web --- web/app/globals.css | 88 ++- web/components/details/DetailsDrawer.tsx | 17 +- web/components/settings/SettingsScreen.tsx | 248 +++++++ web/lib/store.tsx | 56 +- web/lib/telegram/client.ts | 485 ++++++++++++++ web/lib/telegram/config.ts | 21 + web/lib/telegram/index.ts | 247 +++++++ web/lib/telegram/matcher.ts | 189 ++++++ web/lib/telegram/stream.ts | 90 +++ web/lib/tmdb.ts | 31 + web/next.config.mjs | 25 + web/package-lock.json | 730 ++++++++++++++++++++- web/package.json | 5 +- web/public/tg-stream-sw.js | 71 ++ web/public/version.json | 2 +- 15 files changed, 2281 insertions(+), 24 deletions(-) create mode 100644 web/lib/telegram/client.ts create mode 100644 web/lib/telegram/config.ts create mode 100644 web/lib/telegram/index.ts create mode 100644 web/lib/telegram/matcher.ts create mode 100644 web/lib/telegram/stream.ts create mode 100644 web/public/tg-stream-sw.js diff --git a/web/app/globals.css b/web/app/globals.css index c1611a5d5..577c405af 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1447,6 +1447,86 @@ input:focus { font-weight: 900; } +/* ── Telegram settings ───────────────────────────────────────────────────── */ +.tg-center { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 12px 0 4px; + text-align: center; +} + +.tg-lead { + max-width: 460px; + color: var(--text, #fff); +} + +.tg-error { + color: var(--pink, #ff4d6d); +} + +.tg-fineprint { + font-size: 12px; +} + +.tg-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + justify-content: center; +} + +.tg-qr { + display: inline-flex; + padding: 14px; + background: #fff; + border-radius: 16px; +} + +.tg-qr img { + display: block; + border-radius: 4px; +} + +.tg-connected { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + padding: 6px 0; +} + +.tg-badge { + display: flex; + flex-direction: column; + gap: 2px; + padding: 12px 16px; + border-radius: 12px; + background: rgba(52, 199, 89, 0.12); + border: 1px solid rgba(52, 199, 89, 0.28); +} + +.tg-badge strong { + color: #34c759; +} + +.tg-badge span { + color: var(--muted); + font-size: 13px; +} + +.danger-button { + background: rgba(255, 77, 109, 0.14); + border: 1px solid rgba(255, 77, 109, 0.4); + color: var(--pink, #ff4d6d); + border-radius: 999px; + padding: 10px 18px; + cursor: pointer; +} + .toast { position: fixed; left: 50%; @@ -6999,12 +7079,13 @@ button, .source-panel { position: relative; + margin-top: 10px; z-index: 1; width: min(1440px, 100%); height: min(88dvh, 940px); max-height: calc(100dvh - clamp(24px, 4.4vh, 48px)); - display: grid; - grid-template-rows: auto auto auto minmax(0, 1fr); + display: flex; + flex-direction: column; gap: 16px; border: 1px solid rgba(255, 255, 255, 0.16); border-radius: 22px; @@ -7116,6 +7197,7 @@ button, overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; + height: 50px; } .source-addon-tabs::-webkit-scrollbar { @@ -7127,6 +7209,7 @@ button, } .source-picker-list { + flex: 1 1 auto; min-height: 0; overflow: auto; display: grid; @@ -10086,6 +10169,7 @@ button, collapsing/clamping rows regardless of their content height. */ .source-picker-list { display: block !important; + margin-top: 40px; } .source-picker-row { diff --git a/web/components/details/DetailsDrawer.tsx b/web/components/details/DetailsDrawer.tsx index c2dbe889f..75076657d 100644 --- a/web/components/details/DetailsDrawer.tsx +++ b/web/components/details/DetailsDrawer.tsx @@ -421,16 +421,19 @@ function SourcePickerModal({ }, [visible, item?.id, selectedEpisode?.season, selectedEpisode?.episode]); const addons = useMemo(() => { + // Only surface addons that actually returned sources for this title. Seeding a + // chip for every installed stream-capable addon meant subtitle providers + // (OpenSubtitles, Ktuvit, Wizdom) — whose manifests also declare a "stream" + // resource — showed up as empty source filters. A name lookup from the + // installed list keeps the pretty addon names. + const nameById = new Map(installedAddons.map((addon) => [addon.id, addon.name])); const unique = new Map(); - installedAddons - .filter((addon) => addon.enabled !== false && addonHasResource(addon, "stream")) - .forEach((addon) => unique.set(addon.id, { id: addon.id, name: addon.name, count: 0 })); streams.forEach((stream) => { const id = stream.addonId || stream.addonName; const existing = unique.get(id); unique.set(id, { id, - name: existing?.name || stream.addonName, + name: existing?.name || nameById.get(id) || stream.addonName, count: (existing?.count ?? 0) + 1 }); }); @@ -791,12 +794,6 @@ function streamBadges(stream: StreamSource) { }).slice(0, 7); } -function addonHasResource(addon: InstalledAddon, resource: string) { - const resources = addon.resources ?? []; - if (!resources.length) return true; - return resources.some((item) => typeof item === "string" ? item === resource : item.name === resource); -} - function detectSourceBadge(text: string) { if (text.includes("2160") || text.includes("4k")) return "4K"; if (text.includes("1080")) return "1080p"; diff --git a/web/components/settings/SettingsScreen.tsx b/web/components/settings/SettingsScreen.tsx index c94daf949..03d0fec6d 100644 --- a/web/components/settings/SettingsScreen.tsx +++ b/web/components/settings/SettingsScreen.tsx @@ -19,6 +19,7 @@ import { Plus, RefreshCw, RotateCcw, + Send, Server, Sparkles, Subtitles, @@ -58,6 +59,7 @@ const SECTIONS = [ { id: "network", label: "Network", icon: Network }, { id: "tv", label: "TV (IPTV)", icon: Tv }, { id: "homeserver", label: "Home Server", icon: Server }, + { id: "telegram", label: "Telegram", icon: Send }, { id: "catalogs", label: "Catalogs", icon: ListVideo }, { id: "addons", label: "Addons", icon: Sparkles }, ] as const; @@ -963,6 +965,8 @@ function SectionBody({ section }: { section: SectionId }) { return ; case "homeserver": return ; + case "telegram": + return ; case "catalogs": return ; case "addons": @@ -1355,6 +1359,250 @@ function HomeServerSection() { ); } +/* ---------- Telegram ---------- */ + +// Lazily-loaded shape of @/lib/telegram. GramJS is a large browser-only bundle, +// so the module (and its dynamic gramjs import) only load when this section is +// first opened. +type TgModule = typeof import("@/lib/telegram"); +type TgAuthState = import("@/lib/telegram").TgAuthState; + +function TelegramSection() { + const { settings, setToast } = useApp(); + const [mod, setMod] = useState(null); + const [authState, setAuthState] = useState({ k: "idle" }); + const [qrDataUrl, setQrDataUrl] = useState(null); + const [phone, setPhone] = useState("+"); + const [code, setCode] = useState(""); + const [password, setPassword] = useState(""); + const [usePhone, setUsePhone] = useState(false); + const [confirmDisconnect, setConfirmDisconnect] = useState(false); + + // Load the Telegram module and subscribe to its auth state. + useEffect(() => { + let unsub: (() => void) | undefined; + let active = true; + void (async () => { + try { + const m = await import("@/lib/telegram"); + if (!active) return; + setMod(m); + unsub = m.subscribe(setAuthState); + } catch { + setToast("Telegram module failed to load in this browser."); + } + })(); + return () => { + active = false; + unsub?.(); + }; + }, [setToast]); + + // Render the QR login link to an image whenever it (re)appears. + useEffect(() => { + if (authState.k !== "waitQr") { + setQrDataUrl(null); + return; + } + const url = authState.url; + let active = true; + void (async () => { + try { + const QRCode = await import("qrcode"); + const dataUrl = await QRCode.toDataURL(url, { margin: 1, width: 320 }); + if (active) setQrDataUrl(dataUrl); + } catch { + /* Fall back to showing the raw link below. */ + } + })(); + return () => { + active = false; + }; + }, [authState]); + + if (!mod) { + return ( + +

Loading…

+
+ ); + } + + const connect = () => { + setUsePhone(false); + void mod.startQrAuth(); + }; + const connectPhone = () => { + const digits = phone.replace(/\D/g, ""); + if (!phone.startsWith("+") || digits.length < 7) { + setToast("Enter a valid phone number in international format (e.g. +1 650 555 1234)."); + return; + } + void mod.startPhoneAuth(phone.trim()); + }; + + return ( + +

+ Connect your Telegram account to stream video files from your chats and + channels as sources — the same feature as the Android app. Everything + runs in your browser; nothing is sent to ARVIO servers. +

+ + {authState.k === "idle" && !usePhone && ( +
+

Scan a QR code with the Telegram app on your phone to sign in.

+
+ + +
+
+ )} + + {authState.k === "idle" && usePhone && ( +
+

Enter your phone number in international format.

+
+ setPhone(e.target.value)} + placeholder="+1 650 555 1234" + inputMode="tel" + /> + + +
+
+ )} + + {authState.k === "initializing" &&

Connecting to Telegram…

} + + {authState.k === "waitQr" && ( +
+

Open Telegram on your phone → Settings → Devices → Link Desktop Device, then scan:

+
+ {qrDataUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + Telegram login QR code + ) : ( +

Generating QR…

+ )} +
+

The code refreshes automatically. Approving it on your phone signs you in here.

+ +
+ )} + + {authState.k === "waitCode" && ( +
+

Enter the {authState.codeLength}-digit code Telegram just sent you.

+
+ setCode(e.target.value.replace(/\D/g, "").slice(0, authState.codeLength))} + placeholder="Login code" + inputMode="numeric" + /> + +
+
+ )} + + {authState.k === "waitPassword" && ( +
+

+ Two-step verification is on. Enter your Telegram password + {authState.hint ? ` (hint: ${authState.hint})` : ""}. +

+
+ setPassword(e.target.value)} + placeholder="2FA password" + type="password" + /> + +
+
+ )} + + {authState.k === "ready" && ( +
+
+ Connected + Signed in as {authState.firstName || "your account"} +
+ {!confirmDisconnect ? ( + + ) : ( +
+ Disconnect and forget this session? + + +
+ )} +
+ )} + + {authState.k === "error" && ( +
+

Connection failed: {authState.message}

+ +
+ )} +
+ ); +} + function TvSettingsSection() { const { settings, updateSettings, refreshIptv, setToast, busy } = useApp(); const [name, setName] = useState(""); diff --git a/web/lib/store.tsx b/web/lib/store.tsx index 8913d3782..83138d3b9 100644 --- a/web/lib/store.tsx +++ b/web/lib/store.tsx @@ -572,6 +572,24 @@ export function AppProvider({ deviceCodeRef.current = deviceCode; }, [deviceCode]); + // Restore a saved Telegram (browser GramJS) session and prep the streaming + // service worker so a connected user's sources resolve and play after a + // reload — the browser equivalent of Android re-opening its TDLib database. + useEffect(() => { + void (async () => { + try { + const tg = await import("./telegram"); + await tg.restoreSession(); + // Only pre-warm the streaming service worker for users who are actually + // connected — no need to register a worker for accounts that never link + // Telegram (a first play still registers it lazily via registerStream). + if (tg.isConnected()) void tg.initTelegramStreaming(); + } catch { + /* Telegram is optional; a failure just leaves it disconnected. */ + } + })(); + }, []); + const persistAddons = useCallback(async (next: InstalledAddon[], options: { removedIds?: string[] } = {}) => { const normalized = normalizeAddons(next); setAddons(normalized); @@ -1031,7 +1049,10 @@ export function AppProvider({ // Preserve the supplemental sources injected in parallel (Xtream VOD and // home-server files) when the addon/debrid streams resolve. const extras = prev.filter( - (source) => source.addonId === "iptv_xtream_vod" || source.addonId === "home_server" + (source) => + source.addonId === "iptv_xtream_vod" || + source.addonId === "home_server" || + source.addonId === "telegram_native" ); const seen = new Set(incoming.map((s) => s.url ?? s.source)); return [...incoming, ...extras.filter((s) => !seen.has(s.url ?? s.source))]; @@ -1092,6 +1113,30 @@ export function AppProvider({ })(); }, []); + // Search the user's connected Telegram account for the opened title and append + // any matching video files as sources — parity with the Android app, which + // surfaces Telegram media in the same source list as addons. + const appendTelegramSources = useCallback((item: MediaItem, season?: number, episode?: number) => { + if (item.isHomeServer) return; + void (async () => { + try { + const { resolveTelegramSources, isConnected } = await import("./telegram"); + if (!isConnected()) return; + const sources = await resolveTelegramSources(item, season, episode, { + language: settingsRef.current.language + }); + if (!sources.length) return; + setStreams((prev) => { + const seen = new Set(prev.map((s) => s.url ?? s.source)); + const fresh = sources.filter((s) => !seen.has(s.url ?? s.source)); + return fresh.length ? [...prev, ...fresh] : prev; + }); + } catch { + // Best-effort; addon/debrid sources are unaffected on failure. + } + })(); + }, []); + const openDetails = useCallback(async (item: MediaItem) => { setSelectedEpisode(null); // Home-server items carry their own metadata + a direct stream URL — no TMDB. @@ -1119,19 +1164,21 @@ export function AppProvider({ setBusy("Finding sources"); appendVodSources(withResumeEpisode); appendHomeServerSources(withResumeEpisode); - const found = await getStreamsProgressive(addonsRef.current, withResumeEpisode, undefined, undefined, setStreams).catch(() => []); + appendTelegramSources(withResumeEpisode); + const found = await getStreamsProgressive(addonsRef.current, withResumeEpisode, undefined, undefined, mergeStreams).catch(() => []); mergeStreams(found); } else if (withResumeEpisode.seasonNumber && withResumeEpisode.episodeNumber) { setSelectedEpisode({ season: withResumeEpisode.seasonNumber, episode: withResumeEpisode.episodeNumber }); setBusy("Finding sources"); appendVodSources(withResumeEpisode, withResumeEpisode.seasonNumber, withResumeEpisode.episodeNumber); appendHomeServerSources(withResumeEpisode, withResumeEpisode.seasonNumber, withResumeEpisode.episodeNumber); + appendTelegramSources(withResumeEpisode, withResumeEpisode.seasonNumber, withResumeEpisode.episodeNumber); const found = await getStreamsProgressive( addonsRef.current, withResumeEpisode, withResumeEpisode.seasonNumber, withResumeEpisode.episodeNumber, - setStreams + mergeStreams ).catch(() => []); mergeStreams(found); } @@ -1145,7 +1192,8 @@ export function AppProvider({ setBusy("Finding sources"); appendVodSources(item, season, episode); appendHomeServerSources(item, season, episode); - const found = await getStreamsProgressive(addonsRef.current, item, season, episode, setStreams).catch(() => []); + appendTelegramSources(item, season, episode); + const found = await getStreamsProgressive(addonsRef.current, item, season, episode, mergeStreams).catch(() => []); mergeStreams(found); setBusy(""); return found; diff --git a/web/lib/telegram/client.ts b/web/lib/telegram/client.ts new file mode 100644 index 000000000..1761882fa --- /dev/null +++ b/web/lib/telegram/client.ts @@ -0,0 +1,485 @@ +// Browser-side Telegram (MTProto) client — the web counterpart of Android's +// native TDLib TelegramClient. It runs GramJS entirely in the browser over +// Telegram's WebSocket transport: QR-code login, session persistence in +// localStorage, chat search, and byte-range file download for streaming. +// +// GramJS is a large, browser-only bundle, so it is loaded lazily on first use +// (never in the Next.js server bundle) via dynamic import(). + +import type { Api, TelegramClient } from "telegram"; +import type { BigInteger } from "big-integer"; +import { + TELEGRAM_API_HASH, + TELEGRAM_API_ID, + TELEGRAM_SESSION_KEY, +} from "./config"; + +export type TgAuthState = + | { k: "idle" } + | { k: "initializing" } + | { k: "waitQr"; url: string } + | { k: "waitPhone" } + | { k: "waitCode"; codeLength: number } + | { k: "waitPassword"; hint?: string } + | { k: "ready"; firstName: string; userId: string } + | { k: "error"; message: string }; + +export interface TgVideo { + /** Dedupe key: `${fileName}|${size}` — mirrors Android's (fileName, fileSize). */ + key: string; + fileName: string; + size: number; + mime: string; + caption: string; + duration: number; + chatId: string; + messageId: number; + /** Raw document, kept for streaming (rebuilds InputDocumentFileLocation). */ + document: Api.Document; + /** Peer the message came from, for file-reference refresh on expiry. */ + peer: Api.TypePeer; +} + +// ---- reactive auth state -------------------------------------------------- + +let state: TgAuthState = { k: "idle" }; +const listeners = new Set<(s: TgAuthState) => void>(); + +export function getAuthState(): TgAuthState { + return state; +} + +export function subscribe(cb: (s: TgAuthState) => void): () => void { + listeners.add(cb); + cb(state); + return () => listeners.delete(cb); +} + +function setState(next: TgAuthState) { + state = next; + for (const cb of listeners) cb(next); +} + +export function isConnected(): boolean { + return state.k === "ready"; +} + +// ---- deferred user inputs (2FA password, phone code) ---------------------- + +type Deferred = { promise: Promise; resolve: (v: T) => void }; +function deferred(): Deferred { + let resolve!: (v: T) => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +let pendingPassword: Deferred | null = null; +let pendingCode: Deferred | null = null; + +export function submitPassword(pw: string) { + pendingPassword?.resolve(pw); +} +export function submitCode(code: string) { + pendingCode?.resolve(code); +} + +// ---- GramJS singleton ----------------------------------------------------- + +interface Gram { + client: TelegramClient; + Api: typeof Api; + bigInt: (v: number | string) => BigInteger; +} + +let gram: Gram | null = null; +let loadingSession = false; + +async function loadGram(sessionString: string): Promise { + const [{ TelegramClient, Api }, { StringSession }, bigIntMod] = await Promise.all([ + import("telegram"), + import("telegram/sessions"), + import("big-integer"), + ]); + const bigInt = (bigIntMod.default ?? bigIntMod) as unknown as (v: number | string) => BigInteger; + const session = new StringSession(sessionString); + const client = new TelegramClient(session, TELEGRAM_API_ID, TELEGRAM_API_HASH, { + connectionRetries: 5, + // GramJS auto-selects the browser WebSocket transport; keep logs quiet. + baseLogger: undefined, + }); + client.setLogLevel?.("error" as never); + return { client, Api, bigInt }; +} + +function savedSession(): string { + if (typeof window === "undefined") return ""; + try { + return window.localStorage.getItem(TELEGRAM_SESSION_KEY) ?? ""; + } catch { + return ""; + } +} + +function persistSession() { + if (!gram || typeof window === "undefined") return; + try { + const str = (gram.client.session.save() as unknown as string) ?? ""; + if (str) window.localStorage.setItem(TELEGRAM_SESSION_KEY, str); + } catch { + /* ignore */ + } +} + +function clearSession() { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(TELEGRAM_SESSION_KEY); + } catch { + /* ignore */ + } +} + +async function ensureGram(): Promise { + if (gram) return gram; + gram = await loadGram(savedSession()); + return gram; +} + +/** + * Restore a previously-saved session on app start. Sets state to `ready` when a + * valid session exists, otherwise `idle`. Safe to call repeatedly. + */ +export async function restoreSession(): Promise { + if (typeof window === "undefined") return; + if (state.k === "ready" || loadingSession) return; + const saved = savedSession(); + if (!saved) return; + loadingSession = true; + try { + const g = await ensureGram(); + await g.client.connect(); + if (await g.client.isUserAuthorized()) { + const me = (await g.client.getMe()) as Api.User; + setState({ k: "ready", firstName: me?.firstName ?? "", userId: String(me?.id ?? "") }); + } else { + clearSession(); + setState({ k: "idle" }); + } + } catch { + setState({ k: "idle" }); + } finally { + loadingSession = false; + } +} + +// ---- QR authentication ---------------------------------------------------- + +function base64Url(buf: Uint8Array): string { + let bin = ""; + for (const b of buf) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export async function startQrAuth(): Promise { + setState({ k: "initializing" }); + pendingPassword = deferred(); + try { + // A fresh QR login needs a clean, unauthorized session. + clearSession(); + gram = await loadGram(""); + const g = gram; + await g.client.connect(); + + const me = (await g.client.signInUserWithQrCode( + { apiId: TELEGRAM_API_ID, apiHash: TELEGRAM_API_HASH }, + { + qrCode: async (code: { token: Buffer | Uint8Array }) => { + const url = `tg://login?token=${base64Url(code.token as Uint8Array)}`; + setState({ k: "waitQr", url }); + }, + password: async (hint?: string) => { + pendingPassword = deferred(); + setState({ k: "waitPassword", hint }); + return pendingPassword.promise; + }, + onError: async (err: Error) => { + setState({ k: "error", message: friendlyError(err.message) }); + return true; // stop retrying + }, + } + )) as Api.User; + + persistSession(); + setState({ k: "ready", firstName: me?.firstName ?? "", userId: String(me?.id ?? "") }); + } catch (e) { + if (state.k !== "error") { + setState({ k: "error", message: friendlyError((e as Error)?.message) }); + } + } +} + +// ---- phone / code authentication (fallback) ------------------------------- + +export async function startPhoneAuth(phone: string): Promise { + setState({ k: "initializing" }); + try { + clearSession(); + gram = await loadGram(""); + const g = gram; + await g.client.connect(); + + const me = (await g.client.signInUser( + { apiId: TELEGRAM_API_ID, apiHash: TELEGRAM_API_HASH }, + { + phoneNumber: async () => phone, + phoneCode: async () => { + pendingCode = deferred(); + setState({ k: "waitCode", codeLength: 5 }); + return pendingCode.promise; + }, + password: async (hint?: string) => { + pendingPassword = deferred(); + setState({ k: "waitPassword", hint }); + return pendingPassword.promise; + }, + onError: async (err: Error) => { + setState({ k: "error", message: friendlyError(err.message) }); + return true; + }, + } + )) as Api.User; + + persistSession(); + setState({ k: "ready", firstName: me?.firstName ?? "", userId: String(me?.id ?? "") }); + } catch (e) { + if (state.k !== "error") { + setState({ k: "error", message: friendlyError((e as Error)?.message) }); + } + } +} + +// ---- disconnect ----------------------------------------------------------- + +export async function disconnect(): Promise { + try { + if (gram) { + await gram.client.invoke(new gram.Api.auth.LogOut()).catch(() => {}); + await gram.client.disconnect().catch(() => {}); + await gram.client.destroy?.().catch(() => {}); + } + } finally { + gram = null; + clearSession(); + setState({ k: "idle" }); + } +} + +export function resetToIdle() { + setState({ k: "idle" }); +} + +// ---- chat search ---------------------------------------------------------- + +/** + * Global search across all the user's chats for video files matching `query`. + * Runs the Video and Document message filters (like Android's two-filter search) + * and dedupes by (fileName, size). + */ +const PAGE_SIZE = 100; // max messages.SearchGlobal returns per call +const MAX_PAGES = 3; // paginate up to 300 raw messages per filter + +export async function searchVideoMessages(query: string, limit: number): Promise { + if (!gram || state.k !== "ready") return []; + const { client, Api, bigInt } = gram; + const filters = [new Api.InputMessagesFilterVideo(), new Api.InputMessagesFilterDocument()]; + const seen = new Set(); + const out: TgVideo[] = []; + + for (const filter of filters) { + // SearchGlobal caps each call at 100 results; TDLib (Android) effectively + // pages through everything. Paginate here so large libraries aren't + // truncated to the newest 100 — the cursor is (nextRate, lastMsgId, lastPeer). + let offsetRate = 0; + let offsetId = 0; + let offsetPeer: Api.TypeInputPeer = new Api.InputPeerEmpty(); + + for (let page = 0; page < MAX_PAGES; page++) { + const res = await client.invoke( + new Api.messages.SearchGlobal({ + q: query, + filter, + minDate: 0, + maxDate: 0, + offsetRate, + offsetPeer, + offsetId, + limit: PAGE_SIZE, + }) + ); + const messages = (res as { messages?: Api.TypeMessage[] }).messages ?? []; + if (messages.length === 0) break; + + for (const msg of messages) { + if (!(msg instanceof Api.Message)) continue; + const media = msg.media; + if (!(media instanceof Api.MessageMediaDocument)) continue; + const doc = media.document; + if (!(doc instanceof Api.Document)) continue; + + const mime = doc.mimeType ?? ""; + if (!mime.startsWith("video/") && mime !== "application/x-matroska") continue; + + const size = typeof doc.size === "number" ? doc.size : Number(doc.size?.toString() ?? 0); + const fileName = documentFileName(doc, Api) || defaultName(mime); + const key = `${fileName}|${size}`; + if (seen.has(key)) continue; + seen.add(key); + + out.push({ + key, + fileName, + size, + mime, + caption: msg.message ?? "", + duration: documentDuration(doc, Api), + chatId: peerIdString(msg.peerId, bigInt), + messageId: msg.id, + document: doc, + peer: msg.peerId, + }); + } + + // Stop when this filter has gathered enough, or the server signalled the + // last page (fewer than a full page returned). + if (out.length >= limit || messages.length < PAGE_SIZE) break; + + const last = messages[messages.length - 1] as Api.Message; + const nextRate = (res as { nextRate?: number }).nextRate; + offsetRate = nextRate ?? offsetRate; + offsetId = last.id; + try { + offsetPeer = await client.getInputEntity(last.peerId); + } catch { + break; // can't resolve the cursor peer — stop paginating this filter + } + } + } + return out; +} + +// ---- byte-range download (streaming) -------------------------------------- + +/** + * Download exactly `[start, start+length)` of a Telegram document. GramJS + * `iterDownload` handles MTProto offset/limit alignment internally; we slice the + * assembled buffer to the exact window the player asked for. This is the browser + * equivalent of Android's TelegramStreamingProxy.downloadChunk. + */ +export async function downloadRange(video: TgVideo, start: number, length: number): Promise { + const g = await ensureGram(); + const { client, Api, bigInt } = g; + + const run = async (doc: Api.Document): Promise => { + const location = new Api.InputDocumentFileLocation({ + id: doc.id, + accessHash: doc.accessHash, + fileReference: doc.fileReference, + thumbSize: "", + }); + const chunks: Uint8Array[] = []; + let received = 0; + const iter = client.iterDownload({ + file: location, + offset: bigInt(start), + limit: length, + requestSize: 512 * 1024, + }); + for await (const chunk of iter) { + const bytes = chunk as unknown as Uint8Array; + chunks.push(bytes); + received += bytes.length; + if (received >= length) break; + } + return concat(chunks).subarray(0, length); + }; + + try { + return await run(video.document); + } catch (e) { + // File references expire (~a few hours). Refresh by re-fetching the message, + // then retry once. Mirrors how Telegram web clients recover mid-playback. + if (isFileReferenceError((e as Error)?.message)) { + const fresh = await refreshDocument(video).catch(() => null); + if (fresh) { + video.document = fresh; + return run(fresh); + } + } + throw e; + } +} + +async function refreshDocument(video: TgVideo): Promise { + const g = gram; + if (!g) return null; + const { client, Api } = g; + const entity = await client.getInputEntity(video.peer); + const msgs = await client.getMessages(entity, { ids: [video.messageId] }); + const msg = msgs?.[0]; + if (msg instanceof Api.Message && msg.media instanceof Api.MessageMediaDocument) { + const doc = msg.media.document; + if (doc instanceof Api.Document) return doc; + } + return null; +} + +// ---- helpers -------------------------------------------------------------- + +function documentFileName(doc: Api.Document, Api: typeof import("telegram").Api): string { + for (const attr of doc.attributes ?? []) { + if (attr instanceof Api.DocumentAttributeFilename) return attr.fileName; + } + return ""; +} + +function documentDuration(doc: Api.Document, Api: typeof import("telegram").Api): number { + for (const attr of doc.attributes ?? []) { + if (attr instanceof Api.DocumentAttributeVideo) return Math.round(attr.duration ?? 0); + } + return 0; +} + +function defaultName(mime: string): string { + return mime === "video/mp4" ? "Default_Name.mp4" : "Default_Name.mkv"; +} + +function peerIdString(peer: Api.TypePeer, bigInt: (v: number | string) => BigInteger): string { + const p = peer as unknown as { channelId?: unknown; chatId?: unknown; userId?: unknown }; + const raw = p.channelId ?? p.chatId ?? p.userId; + return raw != null ? String(raw) : ""; +} + +function isFileReferenceError(msg?: string): boolean { + return !!msg && /FILE_REFERENCE/i.test(msg); +} + +function concat(chunks: Uint8Array[]): Uint8Array { + const total = chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.length; + } + return out; +} + +function friendlyError(raw?: string): string { + if (!raw) return "Telegram connection failed."; + const flood = /FLOOD_WAIT_(\d+)/.exec(raw); + if (flood) return `Too many attempts. Try again in ${flood[1]}s.`; + if (/PHONE_NUMBER_INVALID/.test(raw)) return "That phone number is not valid."; + if (/PHONE_CODE_INVALID/.test(raw)) return "That code is not valid."; + if (/PASSWORD_HASH_INVALID/.test(raw)) return "That 2FA password is not correct."; + return raw; +} diff --git a/web/lib/telegram/config.ts b/web/lib/telegram/config.ts new file mode 100644 index 000000000..aa4f78ee1 --- /dev/null +++ b/web/lib/telegram/config.ts @@ -0,0 +1,21 @@ +// Telegram MTProto app credentials. These are the SAME public api_id / api_hash +// the Android app ships (see app/.../telegram/TelegramConfig.kt) — they identify +// the ARVIO application to Telegram, not the user, and are already shipped in the +// public APK, so surfacing them in the browser bundle exposes nothing new. +export const TELEGRAM_API_ID = 23905496; +export const TELEGRAM_API_HASH = "1e48b355edfe55f9a4fbf8d3c2324628"; + +// localStorage key holding the GramJS StringSession (the authorization key). This +// is the browser equivalent of Android's on-device TDLib database — losing it +// just means the user re-scans the QR code. +export const TELEGRAM_SESSION_KEY = "arvio.web.telegram.session"; + +// Resolver tuning — mirrors TelegramSourceResolver.kt. +export const TELEGRAM_SCORE_THRESHOLD = 55; +export const TELEGRAM_SEARCH_TIMEOUT_MS = 20_000; +export const TELEGRAM_MAX_RESULTS = 100; + +// addonId stamped on every Telegram source so the store can keep them in the +// list when addon/debrid streams resolve later (see mergeStreams). +export const TELEGRAM_ADDON_ID = "telegram_native"; +export const TELEGRAM_ADDON_NAME = "Telegram"; diff --git a/web/lib/telegram/index.ts b/web/lib/telegram/index.ts new file mode 100644 index 000000000..e62afd617 --- /dev/null +++ b/web/lib/telegram/index.ts @@ -0,0 +1,247 @@ +// Telegram source resolver — the web port of TelegramSourceResolver.kt. Turns a +// title (movie or episode) into scored, browser-streamable StreamSource entries +// backed by the user's connected Telegram account. + +import type { MediaItem, StreamSource } from "../types"; +import { + TELEGRAM_ADDON_ID, + TELEGRAM_ADDON_NAME, + TELEGRAM_MAX_RESULTS, + TELEGRAM_SCORE_THRESHOLD, + TELEGRAM_SEARCH_TIMEOUT_MS, +} from "./config"; +import { isConnected, searchVideoMessages, type TgVideo } from "./client"; +import * as matcher from "./matcher"; +import { registerStream } from "./stream"; +import { getTitlesForSearch } from "../tmdb"; + +export { + getAuthState, + subscribe, + isConnected, + restoreSession, + startQrAuth, + startPhoneAuth, + submitCode, + submitPassword, + disconnect, + resetToIdle, + type TgAuthState, +} from "./client"; +export { initTelegramStreaming } from "./stream"; +export { TELEGRAM_ADDON_ID, TELEGRAM_ADDON_NAME } from "./config"; + +export interface TelegramResolveOptions { + excludedChatIds?: string[]; + language?: string; +} + +const CACHE_TTL_SHORT_MS = 2 * 60 * 60 * 1000; +const CACHE_TTL_LONG_MS = 24 * 60 * 60 * 1000; + +interface CacheEntry { + results: StreamSource[]; + expiresAt: number; +} +const cache = new Map(); + +function cacheKey(item: MediaItem, season?: number, episode?: number): string { + const id = item.imdbId || `${item.mediaType}:${item.id}`; + return `${id}:${season ?? ""}:${episode ?? ""}`; +} + +function cacheTtl(year: number | null, isMovie: boolean): number { + if (!isMovie) return CACHE_TTL_SHORT_MS; + const currentYear = new Date().getFullYear(); + return year != null && year < currentYear - 1 ? CACHE_TTL_LONG_MS : CACHE_TTL_SHORT_MS; +} + +/** + * Resolve Telegram sources for a movie or a specific episode. Returns [] when + * Telegram is not connected. Never throws — failures resolve to an empty list. + */ +export async function resolveTelegramSources( + item: MediaItem, + season: number | undefined, + episode: number | undefined, + opts: TelegramResolveOptions = {} +): Promise { + if (!isConnected()) return []; + + const key = cacheKey(item, season, episode); + const cached = cache.get(key); + if (cached && Date.now() < cached.expiresAt) return cached.results; + + const isMovie = item.mediaType === "movie"; + const year = item.year ? Number(item.year) || null : null; + + try { + const results = await withTimeout( + resolveInternal(item, season, episode, year, isMovie, opts), + TELEGRAM_SEARCH_TIMEOUT_MS + ); + cache.set(key, { results, expiresAt: Date.now() + cacheTtl(year, isMovie) }); + return results; + } catch { + return []; + } +} + +async function resolveInternal( + item: MediaItem, + season: number | undefined, + episode: number | undefined, + year: number | null, + isMovie: boolean, + opts: TelegramResolveOptions +): Promise { + const excluded = new Set(opts.excludedChatIds ?? []); + const langCode = (opts.language ?? "en").replace("iw", "he").split("-")[0]; + + // Fetch the English + localized titles from TMDB so we search files named in + // either language — critical for non-English content (e.g. a Hebrew-dubbed + // episode named "מפרץ ההרפתקאות ע1 פ2" won't match an English query). Mirrors + // Android's TelegramSourceResolver.fetchTitles. item.title is the display + // title in the user's language and is always used as a fallback. + const title = item.title; + const { english, localized } = await getTitlesForSearch(item, opts.language ?? "en").catch(() => ({ + english: null as string | null, + localized: null as string | null, + })); + const englishTitle = english ?? title; + const localizedTitle = localized; + + const queries = + season != null && episode != null + ? matcher.buildSeriesQueries(title, season, episode, localizedTitle, englishTitle, langCode) + : matcher.buildMovieQueries(title, year, localizedTitle, englishTitle); + + const seen = new Set(); + const messages: TgVideo[] = []; + + const batches = await Promise.all( + queries.map((q) => + searchVideoMessages(q, TELEGRAM_MAX_RESULTS) + .then((list) => list.filter((m) => !excluded.has(m.chatId))) + .catch(() => [] as TgVideo[]) + ) + ); + for (const batch of batches) { + for (const msg of batch) { + if (seen.has(msg.key)) continue; + seen.add(msg.key); + messages.push(msg); + } + } + + const allScored = messages.map((msg) => ({ + msg, + s: matcher.score({ + fileName: msg.fileName, + caption: msg.caption, + title, + localizedTitle, + englishTitle, + year, + season, + episode, + }), + })); + const scored = allScored.filter((x) => x.s >= TELEGRAM_SCORE_THRESHOLD); + + const sources = scored.map(({ msg }) => toStreamSource(msg)); + + sources.sort((a, b) => { + const heA = langCode === "he" && matcher.isHebrew(a.source) ? 1 : 0; + const heB = langCode === "he" && matcher.isHebrew(b.source) ? 1 : 0; + if (heA !== heB) return heB - heA; + const qA = qualityTier(a.quality); + const qB = qualityTier(b.quality); + if (qA !== qB) return qB - qA; + return (b.sizeBytes ?? 0) - (a.sizeBytes ?? 0); + }); + + return sources; +} + +function toStreamSource(msg: TgVideo): StreamSource { + const isDefault = msg.fileName === "Default_Name.mkv" || msg.fileName === "Default_Name.mp4"; + const displayName = isDefault && msg.caption.trim() ? msg.caption : msg.fileName; + const quality = parseQuality(`${msg.fileName} ${msg.caption}`); + const url = registerStream(msg); + return { + source: displayName, + addonName: TELEGRAM_ADDON_NAME, + addonId: TELEGRAM_ADDON_ID, + quality, + size: formatBytes(msg.size), + sizeBytes: msg.size, + url, + infoHash: null, + fileIdx: null, + behaviorHints: { + notWebReady: false, + filename: msg.fileName, + videoSize: msg.size, + }, + subtitles: [], + sources: [], + description: msg.caption.trim() ? msg.caption : null, + }; +} + +function qualityTier(quality?: string): number { + switch (quality) { + case "4K": + return 6; + case "1080p": + return 5; + case "720p": + return 4; + case "480p": + return 3; + case "360p": + return 2; + case "CAM": + case "SCR": + return 1; + default: + return 0; + } +} + +function parseQuality(raw: string): string { + const t = raw.toLowerCase().replace(/ /g, "."); + const has = (...xs: string[]) => xs.some((x) => t.includes(x)); + if (has("dvdscr", "screener", ".scr.")) return "SCR"; + if (has(".cam.", "camrip", "hdcam", "hdts", "telesync")) return "CAM"; + if (has("360", "36o")) return "360p"; + if (has("480", "48o")) return "480p"; + if (has("720", "72o")) return "720p"; + if (has("1080", "1o8o", "108o", "1o80", ".fhd.")) return "1080p"; + if (has("2160", "216o", ".4k.", ".uhd.", "ultrahd")) return "4K"; + return "Unknown"; +} + +function formatBytes(bytes: number): string { + if (bytes <= 0) return ""; + if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(2)} GB`; + if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`; + return `${(bytes / 1_000).toFixed(0)} KB`; +} + +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timeout")), ms); + promise.then( + (v) => { + clearTimeout(timer); + resolve(v); + }, + (e) => { + clearTimeout(timer); + reject(e); + } + ); + }); +} diff --git a/web/lib/telegram/matcher.ts b/web/lib/telegram/matcher.ts new file mode 100644 index 000000000..f65bf710e --- /dev/null +++ b/web/lib/telegram/matcher.ts @@ -0,0 +1,189 @@ +// Direct port of app/.../telegram/TelegramSearchMatcher.kt — the query builder +// and relevance scorer that decide which Telegram video files match a title. +// Kept byte-for-byte compatible so the web results rank like the Android app's. + +const SEP = "[\\s._\\-x+,&:]{0,2}"; +const SEP_MID = "[\\s._\\-x+,&:]{0,4}"; + +const EPISODE_PATTERN = new RegExp( + `[Ss][e]?(?:ason)?${SEP}(\\d{1,2})${SEP_MID}[Ee][p]?(?:isode)?${SEP}(\\d{1,4})` + + `|ע(?:ונה)?${SEP}(\\d{1,2})${SEP_MID}פ(?:רק)?${SEP}(\\d{1,4})`, + "i" +); +// Season-1 fallback: episode-only Hebrew marker (פרק 5 / פ5) with no season prefix +const EPISODE_ONLY_PATTERN = /פ(?:רק)?[\s._\-x+,&:]{0,2}(\d{1,4})/; +const YEAR_PATTERN = /\b(19|20)\d{2}\b/g; +const NOISE = /[._\-\[\]()'",!?:]/g; +const MULTI_SPACE = /\s+/g; +const SIZE_SUFFIX = /\.(mkv|mp4|avi|mov|wmv|m4v|ts|m2ts)$/i; +const HEBREW_MIN = 0x0590; +const HEBREW_MAX = 0x05ff; + +export function score(params: { + fileName: string; + caption: string; + title: string; + localizedTitle?: string | null; + englishTitle?: string | null; + year?: number | null; + season?: number | null; + episode?: number | null; +}): number { + const { fileName, caption, title, localizedTitle, englishTitle, year, season, episode } = params; + const combined = `${fileName} ${caption}`; + const normalizedCombined = normalize(combined); + const normalizedTitle = normalize(title); + const normalizedLocalized = localizedTitle ? normalize(localizedTitle) : null; + const normalizedEnglish = englishTitle ? normalize(englishTitle) : null; + + const engMatch = !!normalizedEnglish && normalizedEnglish.length > 0 && normalizedCombined.includes(normalizedEnglish); + const locMatch = !!normalizedLocalized && normalizedLocalized.length > 0 && normalizedCombined.includes(normalizedLocalized); + const appMatch = normalizedCombined.includes(normalizedTitle); + + if (!engMatch && !locMatch && !appMatch) return 0; + + let s = 60; + + if (year != null) { + const fileYears = Array.from(combined.matchAll(YEAR_PATTERN)).map((m) => Number(m[0])); + if (fileYears.includes(year)) s += 20; + else if (fileYears.some((y) => Math.abs(y - year) === 1)) s += 5; + else if (fileYears.length === 0) s += 5; + else s -= 10; + } + + if (season != null && episode != null) { + const seFile = extractSeasonEpisode(fileName); + const seCaption = extractSeasonEpisode(caption); + const rightSE = + (seFile?.[0] === season && seFile?.[1] === episode) || + (seCaption?.[0] === season && seCaption?.[1] === episode); + if (rightSE) { + s += 20; + } else if (seFile != null || seCaption != null) { + return 0; // pattern found but wrong S/E + } else if (season === 1) { + const epFile = extractEpisodeOnly(fileName); + const epCaption = extractEpisodeOnly(caption); + if (epFile === episode || epCaption === episode) s += 20; + else return 0; // no identifiable episode marker → not this episode + } else { + // Episode search but the file carries no season/episode marker at all + // (a movie, a season pack, a special). On web we retrieve by bare title, + // so — unlike Android's filename-targeted search — these show up in bulk; + // reject them instead of letting a strong title match pass at threshold. + return 0; + } + } else if (season == null) { + if (EPISODE_PATTERN.test(combined) || EPISODE_PATTERN.test(normalizedCombined)) { + s -= 20; + } + } + + return Math.max(0, Math.min(100, s)); +} + +function extractSeasonEpisode(text: string): [number, number] | null { + const m = EPISODE_PATTERN.exec(text) ?? EPISODE_PATTERN.exec(normalize(text)); + if (!m) return null; + const sVal = toInt(m[1]) ?? toInt(m[3]); + const eVal = toInt(m[2]) ?? toInt(m[4]); + if (sVal == null || eVal == null) return null; + return [sVal, eVal]; +} + +function extractEpisodeOnly(text: string): number | null { + const m = EPISODE_ONLY_PATTERN.exec(text) ?? EPISODE_ONLY_PATTERN.exec(normalize(text)); + return m ? toInt(m[1]) : null; +} + +export function buildMovieQueries( + title: string, + year?: number | null, + localizedTitle?: string | null, + englishTitle?: string | null +): string[] { + const primary = englishTitle ? cleanTitle(englishTitle) : cleanTitle(title); + const localized = localizedTitle ? cleanTitle(localizedTitle) : null; + const queries: string[] = []; + if (year != null) queries.push(`${primary} ${year}`); + queries.push(primary); + if (localized && localized.toLowerCase() !== primary.toLowerCase()) { + if (year != null) queries.push(`${localized} ${year}`); + queries.push(localized); + } + return distinct(queries); +} + +export function buildSeriesQueries( + title: string, + season: number, + episode: number, + localizedTitle?: string | null, + englishTitle?: string | null, + languageCode = "en" +): string[] { + const engBase = englishTitle ? cleanTitle(englishTitle) : cleanTitle(title); + const locBase = localizedTitle ? cleanTitle(localizedTitle) : null; + const titlesAreSame = locBase == null || locBase.toLowerCase() === engBase.toLowerCase(); + const s = String(season); + const e = String(episode); + const s2 = String(season).padStart(2, "0"); + const e2 = String(episode).padStart(2, "0"); + + const queries: string[] = []; + + if (languageCode === "he") { + const hebTitle = titlesAreSame ? engBase : locBase ?? engBase; + queries.push(`${hebTitle} ע${s} פ${e}`, `${hebTitle} ע${s}פ${e}`, `${hebTitle} עונה ${s} פרק ${e}`); + if (season === 1) queries.push(`${hebTitle} פ${e}`, `${hebTitle} פרק ${e}`); + } + + if (!titlesAreSame && locBase) { + queries.push(`${locBase} s${s}e${e}`, `${locBase} s${s2}e${e2}`, `${locBase} s${s} e${e}`, `${locBase} s${s2} e${e2}`); + } + + queries.push(`${engBase} s${s}e${e}`, `${engBase} s${s2}e${e2}`, `${engBase} s${s} e${e}`, `${engBase} s${s2} e${e2}`); + + // Bare-title queries. Telegram's browser search (messages.SearchGlobal) matches + // caption/text tokens, so exact "sXXeXX" phrases often miss files whose season/ + // episode only lives in the filename. Retrieve by title and let score() confirm + // the S/E from the filename (it already requires the exact S/E and rejects + // wrong ones). This mirrors how the movie path already searches. + queries.push(engBase); + if (!titlesAreSame && locBase) queries.push(locBase); + + return distinct(queries.map((q) => q.toLowerCase())); +} + +export function isHebrew(str: string): boolean { + for (const ch of str) { + const code = ch.codePointAt(0) ?? 0; + if (code >= HEBREW_MIN && code <= HEBREW_MAX) return true; + } + return false; +} + +function cleanTitle(title: string): string { + const stripped = title.replace(/:/g, "").replace(/ {2}/g, " ").trim(); + return stripped.normalize("NFKD").replace(/\p{Mn}+/gu, ""); +} + +function normalize(text: string): string { + return text + .replace(SIZE_SUFFIX, "") + .replace(NOISE, " ") + .replace(MULTI_SPACE, " ") + .trim() + .toLowerCase(); +} + +function toInt(v: string | undefined): number | null { + if (v == null || v === "") return null; + const n = Number.parseInt(v, 10); + return Number.isNaN(n) ? null : n; +} + +function distinct(arr: string[]): string[] { + return Array.from(new Set(arr)); +} diff --git a/web/lib/telegram/stream.ts b/web/lib/telegram/stream.ts new file mode 100644 index 000000000..7ee055030 --- /dev/null +++ b/web/lib/telegram/stream.ts @@ -0,0 +1,90 @@ +// Page side of Telegram streaming. Registers the streaming service worker, +// keeps a registry of playable videos keyed by an opaque stream id, and answers +// the worker's byte-range requests by downloading through the authorized GramJS +// client (see downloadRange in client.ts). + +import { downloadRange, type TgVideo } from "./client"; + +// Max bytes served per range request. The player asks for open-ended ranges +// (`bytes=start-`); we cap each response and let it request the next window, +// exactly like Android's 2 MB CHUNK_SIZE proxy loop (a little larger here to cut +// round-trips over the network hop). +const MAX_WINDOW = 4 * 1024 * 1024; + +const registry = new Map(); +let listenerInstalled = false; +let swReady: Promise | null = null; + +export function isStreamingSupported(): boolean { + return typeof navigator !== "undefined" && "serviceWorker" in navigator && typeof MessageChannel !== "undefined"; +} + +/** Register the streaming worker and wire the page-side range handler. Idempotent. */ +export async function initTelegramStreaming(): Promise { + if (!isStreamingSupported()) return; + if (!listenerInstalled) { + navigator.serviceWorker.addEventListener("message", onWorkerMessage); + listenerInstalled = true; + } + if (!swReady) { + swReady = navigator.serviceWorker + .register("/tg-stream-sw.js") + .then(() => navigator.serviceWorker.ready) + .then(() => undefined) + .catch((e) => { + swReady = null; + throw e; + }); + } + await swReady; +} + +/** + * Register a Telegram video for playback and return the same-origin URL the + *