// TMDB Embed API for Deno import { serve } from "https://deno.land/std/http/server.ts"; const PORT = Deno.env.get("PORT") || 3000; // --- Constants & Global Config --- const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; // --- Types --- interface MediaFile { file: string; type: string; quality?: string; lang?: string; season?: string | number; // Added for TV shows episode?: string | number; // Added for TV shows } interface Subtitle { url: string; lang: string; type?: string; } interface SourceInfo { provider: string; files: MediaFile[]; subtitles: Subtitle[]; headers: Record; } interface ProviderSuccessResult { source: SourceInfo; } interface ErrorDetail { error: string; what_happened: string; report_issue: string; } interface ProviderErrorResult { provider: string; ERROR: ErrorDetail[]; } type ProviderFunctionReturn = ProviderSuccessResult | ProviderErrorResult; interface ProviderConfig { id: string; displayName: string; domain: string; fetchFunction: (tmdbId: string, s?: string, e?: string) => Promise; } // --- Helper functions --- function createApiErrorObject(errorMessage: string, statusProviderName: string = "API"): ProviderErrorResult { return { provider: statusProviderName, ERROR: [{ error: `ERROR`, what_happened: errorMessage, report_issue: 'https://github.com/Inside4ndroid/TMDB-Embed-API/issues' }] }; } function createProviderErrorObject(providerName: string, errorMessage: string): ProviderErrorResult { return createApiErrorObject(errorMessage, providerName); } function stringAtob(input: string): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; let str = input.replace(/=+$/, ''); let output = ''; if (str.length % 4 === 1) throw new Error("'atob' failed: The string to be decoded is not correctly encoded."); for (let bc = 0, bs = 0, buffer, i = 0; buffer = str.charAt(i++); ~buffer && (bs = bc % 4 ? bs * 64 + buffer : buffer, bc++ % 4) ? output += String.fromCharCode(255 & bs >> (-2 * bc & 6)) : 0) { buffer = chars.indexOf(buffer); } return output; } async function requestGet(url: string, headers: Record = {}) { try { const response = await fetch(url, { method: 'GET', headers }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); return await response.json(); } catch (error) { console.error(`Request failed for ${url}:`, error); return null; } } function getSizeQuality(url: string): number { try { const parts = url.split('/'); const base64Part = parts[parts.length - 2]; const decodedPart = stringAtob(base64Part); return Number(decodedPart) || 1080; } catch (e) { console.warn(`Failed to get size quality for URL ${url}:`, e); return 720; } } // --- Provider Specific Logic --- // == EmbedSu Provider == const EMBED_SU_DOMAIN = "https://embed.su"; const EMBED_SU_PROXY = "https://iqslgbok.deploy.cx/param/User-Agent=" + encodeURIComponent(USER_AGENT) + "/param/Origin=" + encodeURIComponent(EMBED_SU_DOMAIN) + "/param/Referer=" + encodeURIComponent(EMBED_SU_DOMAIN) + "/"; const EMBED_SU_HEADERS = { 'User-Agent': USER_AGENT, 'Referer': EMBED_SU_DOMAIN,//EMBED_SU_DOMAIN 'Origin': EMBED_SU_DOMAIN,//EMBED_SU_DOMAIN }; async function getEmbedSu(tmdb_id: string, s?: string, e?: string): Promise { const providerName = PROVIDERS.embedsu.displayName; try { const urlSearch = s && e ? `${EMBED_SU_PROXY}${EMBED_SU_DOMAIN}/embed/tv/${tmdb_id}/${s}/${e}` : `${EMBED_SU_PROXY}${EMBED_SU_DOMAIN}/embed/movie/${tmdb_id}`; const htmlSearchResponse = await fetch(urlSearch, { method: 'GET', headers: EMBED_SU_HEADERS }); if (!htmlSearchResponse.ok) return createProviderErrorObject(providerName, `Failed to fetch initial page: HTTP ${htmlSearchResponse.status}`); const textSearch = await htmlSearchResponse.text(); const hashEncodeMatch = textSearch.match(/JSON\.parse\(atob\(\`([^\`]+)/i); const hashEncode = hashEncodeMatch ? hashEncodeMatch[1] : ""; if (!hashEncode) return createProviderErrorObject(providerName, "No encoded hash found in initial page"); let hashDecode; try { hashDecode = JSON.parse(stringAtob(hashEncode)); } catch (err) { return createProviderErrorObject(providerName, `Failed to decode initial hash: ${err.message}`); } const mEncrypt = hashDecode.hash; if (!mEncrypt) return createProviderErrorObject(providerName, "No encrypted hash found in decoded data"); let firstDecode; try { firstDecode = (stringAtob(mEncrypt)).split(".").map(item => item.split("").reverse().join("")); } catch (err) { return createProviderErrorObject(providerName, `Failed to decode first layer: ${err.message}`); } let secondDecode; try { secondDecode = JSON.parse(stringAtob(firstDecode.join("").split("").reverse().join(""))); } catch (err) { return createProviderErrorObject(providerName, `Failed to decode second layer: ${err.message}`); } if (!secondDecode || !Array.isArray(secondDecode) || secondDecode.length === 0) { return createProviderErrorObject(providerName, "No valid sources found after decoding"); } for (const item of secondDecode) { try { if (!item || !item.hash) continue; const urlDirect = `${EMBED_SU_PROXY}${EMBED_SU_DOMAIN}/api/e/${item.hash}`; const dataDirect = await requestGet(urlDirect, { "Referer": EMBED_SU_DOMAIN, "User-Agent": USER_AGENT, "Origin": EMBED_SU_DOMAIN }); if (!dataDirect || !dataDirect.source) { console.warn(`${providerName}: No source found for hash ${item.hash}`); continue; } const tracks: Subtitle[] = (dataDirect.subtitles || []).map((sub: any) => ({ url: sub.file, lang: sub.label ? sub.label.split('-')[0].trim().toLowerCase() : 'en' })).filter((track: Subtitle) => track.url); const requestDirectSize = await fetch(EMBED_SU_PROXY + dataDirect.source, { headers: EMBED_SU_HEADERS, method: "GET" }); if (!requestDirectSize.ok) { console.warn(`${providerName}: Failed to fetch source ${dataDirect.source}: HTTP ${requestDirectSize.status}`); continue; } const parseRequest = await requestDirectSize.text(); const patternSize = parseRequest.split('\n').filter(line => line.includes('/proxy/')); const directQuality: MediaFile[] = patternSize.map(patternItem => { try { const sizeQuality = getSizeQuality(patternItem); let dURL = `${EMBED_SU_DOMAIN}${patternItem}`; dURL = dURL.replace(".png", ".m3u8"); const fileObj: MediaFile = { file: dURL, type: 'hls', quality: `${sizeQuality}p`, lang: 'en' }; if (s && e) { // Add season and episode if they exist (TV show) fileObj.season = s; fileObj.episode = e; } return fileObj; } catch (err) { console.warn(`${providerName}: Failed to process quality for pattern: ${patternItem}`, err); return null; } }).filter((item): item is MediaFile => item !== null); if (!directQuality.length) { console.warn(`${providerName}: No valid qualities found for source ${dataDirect.source}`); continue; } return { source: { provider: providerName, files: directQuality, subtitles: tracks, headers: { "Referer": EMBED_SU_DOMAIN, "User-Agent": USER_AGENT, "Origin": EMBED_SU_DOMAIN } } }; } catch (error) { console.error(`${providerName}: Error processing item ${item.hash}:`, error); } } return createProviderErrorObject(providerName, "No valid sources found after processing all available items"); } catch (error) { console.error(`${providerName}: Unexpected error:`, error); return createProviderErrorObject(providerName, `Unexpected error: ${error.message}`); } } // == VidSrc.SU Provider == const VIDSRC_SU_DOMAIN = "https://vidsrc.su/"; const VIDSRC_SU_HEADERS = { 'User-Agent': USER_AGENT, 'Referer': VIDSRC_SU_DOMAIN, 'Origin': VIDSRC_SU_DOMAIN }; async function getVidSrcSu(tmdb_id: string, s?: string, e?: string): Promise { const providerName = PROVIDERS.vidsrcsu.displayName; const embedUrl = s && e ? `${VIDSRC_SU_DOMAIN}embed/tv/${tmdb_id}/${s}/${e}` : `${VIDSRC_SU_DOMAIN}embed/movie/${tmdb_id}`; try { const response = await fetch(embedUrl, { headers: VIDSRC_SU_HEADERS }); if (!response.ok) return createProviderErrorObject(providerName, `Failed to fetch embed page: HTTP ${response.status}`); const html = await response.text(); let subtitles: Subtitle[] = []; const servers: MediaFile[] = [...html.matchAll(/label: 'Server (?:[^']*)', url: '(https?:\/\/[^']+\.m3u8[^']*)'/gi)].map(match => { const fileObj: MediaFile = { file: match[1], type: "hls", lang: "en" }; if (s && e) { // Add season and episode if they exist fileObj.season = s; fileObj.episode = e; } return fileObj; }); const subtitlesMatch = html.match(/const subtitles = \[(.*?)\];/s); if (subtitlesMatch && subtitlesMatch[1]) { try { const subRaw = `[${subtitlesMatch[1]}]`; let parsedSubs = JSON.parse(subRaw); subtitles = parsedSubs.filter((sub: any) => sub && sub.url && sub.language) .map((sub: any) => ({ url: sub.url, lang: sub.language.toLowerCase(), type: sub.format || (sub.url.includes('.vtt') ? 'vtt' : (sub.url.includes('.srt') ? 'srt' : undefined)) })); } catch (parseError) { console.error(`${providerName}: Error parsing subtitles:`, parseError); subtitles = []; } } if (servers.length === 0) return createProviderErrorObject(providerName, "No valid video streams found in embed page"); return { source: { provider: providerName, files: servers, subtitles: subtitles, headers: { "Referer": VIDSRC_SU_DOMAIN, "User-Agent": USER_AGENT, "Origin": VIDSRC_SU_DOMAIN } } }; } catch (error) { console.error(`${providerName}: Unexpected error:`, error); return createProviderErrorObject(providerName, `Unexpected error: ${error.message}`); } } // == AutoEmbed Provider == const AUTOEMBED_DOMAIN = "https://autoembed.cc/"; const AUTOEMBED_API_URL_BASE = "https://tom.autoembed.cc/api/getVideoSource"; // Modified parseAutoEmbedM3U8 to accept s and e function parseAutoEmbedM3U8(m3u8Content: string, s?: string, e?: string, m3u8Url?: string): MediaFile[] { try { const lines = m3u8Content.split('\n'); const sources: MediaFile[] = []; let currentQuality: string | undefined = undefined; let baseUrl = ''; let domain = ''; if (m3u8Url) { try { const urlObj = new URL(m3u8Url); domain = `${urlObj.protocol}//${urlObj.hostname}`; // Get base URL by removing the filename from the full URL const urlPath = m3u8Url.split('/'); urlPath.pop(); // Remove the filename baseUrl = urlPath.join('/') + '/'; } catch (error) { console.error('AutoEmbed: Error extracting URL information:', error); } } for (let i = 0; i < lines.length; i++) { const trimmedLine = lines[i].trim(); if (trimmedLine.startsWith('#EXT-X-STREAM-INF:')) { const resolutionMatch = trimmedLine.match(/RESOLUTION=\d+x(\d+)/); currentQuality = resolutionMatch && resolutionMatch[1] ? `${resolutionMatch[1]}p` : undefined; for (let j = i + 1; j < lines.length; j++) { const nextLineTrimmed = lines[j].trim(); if (nextLineTrimmed && !nextLineTrimmed.startsWith('#')) { // Handle different types of paths let filePath = nextLineTrimmed; // Skip URLs that already have protocol if (!nextLineTrimmed.match(/^https?:\/\//)) { if (nextLineTrimmed.startsWith('/')) { // Absolute path starting with '/' if (domain) { filePath = `${domain}${nextLineTrimmed}`; } } else { // Relative path if (baseUrl) { filePath = `${baseUrl}${nextLineTrimmed}`; } } } const fileObj: MediaFile = { file: filePath, type: "hls", quality: currentQuality || 'unknown', lang: "en" }; if (s && e) { // Add season and episode if they exist fileObj.season = s; fileObj.episode = e; } sources.push(fileObj); i = j; break; } if (nextLineTrimmed.startsWith('#EXT')) break; } currentQuality = undefined; } } return sources; } catch (error) { console.error('AutoEmbed: Error parsing m3u8:', error); return []; } } function mapAutoEmbedSubtitles(apiSubtitles: any[]): Subtitle[] { if (!apiSubtitles || !Array.isArray(apiSubtitles)) return []; try { return apiSubtitles.map(subtitle => { const lang = (subtitle.label || 'unknown').split(' ')[0].toLowerCase(); const fileUrl = subtitle.file || ''; if (!fileUrl) return null; const fileExtension = fileUrl.split('.').pop()?.toLowerCase(); const type = fileExtension === 'vtt' ? 'vtt' : (fileExtension === 'srt' ? 'srt' : undefined); return { url: fileUrl, lang: lang, type: type }; }).filter((sub): sub is Subtitle => sub !== null && !!sub.url); } catch (error) { console.error('AutoEmbed: Error mapping subtitles:', error); return []; } } async function getAutoEmbed(tmdb_id: string, s?: string, e?: string): Promise { const providerName = PROVIDERS.autoembed.displayName; const params = new URLSearchParams(); if (s && e) { params.append("type", "tv"); params.append("id", `${tmdb_id}/${s}/${e}`); } else { params.append("type", "movie"); params.append("id", tmdb_id); } const apiUrl = `${AUTOEMBED_API_URL_BASE}?${params.toString()}`; try { const response = await fetch(apiUrl, { headers: { 'Referer': AUTOEMBED_DOMAIN, 'User-Agent': USER_AGENT } }); if (!response.ok) { let errorBody = ""; try { errorBody = await response.text(); } catch (_) { } return createProviderErrorObject(providerName, `API request failed: HTTP ${response.status}. ${errorBody}`); } const data = await response.json(); if (data.error || !data.videoSource) return createProviderErrorObject(providerName, data.error || "No videoSource found in API response"); const m3u8Url = data.videoSource; const m3u8Response = await fetch(m3u8Url, { headers: { 'Referer': AUTOEMBED_DOMAIN, 'User-Agent': USER_AGENT } }); if (!m3u8Response.ok) return createProviderErrorObject(providerName, `Failed to fetch m3u8 from ${m3u8Url}: HTTP ${m3u8Response.status}`); const m3u8Content = await m3u8Response.text(); // Pass s and e to the M3U8 parser const files = parseAutoEmbedM3U8(m3u8Content, s, e, m3u8Url); if (files.length === 0) return createProviderErrorObject(providerName, "No valid streams found after parsing m3u8"); const subtitles = data.subtitles ? mapAutoEmbedSubtitles(data.subtitles) : []; return { source: { provider: providerName, files: files, subtitles: subtitles, headers: { "Referer": AUTOEMBED_DOMAIN, "User-Agent": USER_AGENT, "Origin": AUTOEMBED_DOMAIN } } }; } catch (error) { console.error(`${providerName}: Unexpected error - `, error); return createProviderErrorObject(providerName, `Network or processing error: ${error.message}`); } } // --- Provider Configuration --- const PROVIDERS: Record = { embedsu: { id: "embedsu", displayName: "EmbedSu", domain: EMBED_SU_DOMAIN, fetchFunction: getEmbedSu }, vidsrcsu: { id: "vidsrcsu", displayName: "VidsrcSU", domain: VIDSRC_SU_DOMAIN, fetchFunction: getVidSrcSu }, autoembed: { id: "autoembed", displayName: "AutoEmbed", domain: AUTOEMBED_DOMAIN, fetchFunction: getAutoEmbed }, }; // --- Core Logic --- async function getAllProviders(tmdb_id: string, mediaType: "movie" | "tv", s?: string, e?: string): Promise { const providerFetchPromises: Promise[] = []; for (const providerId in PROVIDERS) { const config = PROVIDERS[providerId]; providerFetchPromises.push( config.fetchFunction(tmdb_id, s, e) .catch(err => { console.error(`Critical error during ${config.displayName} fetch: ${err}`); return createProviderErrorObject(config.displayName, `Internal unhandled error: ${err.message || String(err)}`); }) ); } if (providerFetchPromises.length === 0) return [createApiErrorObject("No providers are configured.")]; const allResults = await Promise.all(providerFetchPromises); const successfulResults = allResults.filter((r): r is ProviderSuccessResult => r !== null && typeof r === 'object' && 'source' in r); if (successfulResults.length > 0) return successfulResults; else return [createApiErrorObject("No valid sources found from any provider.")]; } // --- API Routes & Server --- async function handleRequest(request: Request): Promise { const url = new URL(request.url); const path = url.pathname; const searchParams = url.searchParams; const pathParts = path.split('/').filter(part => part !== ''); const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", "Content-Type": "application/json" }; if (request.method === "OPTIONS") return new Response(null, { headers: corsHeaders, status: 204 }); try { const mediaType = pathParts[0]?.toLowerCase(); let resultData: ProviderFunctionReturn | ProviderSuccessResult[] | [ProviderErrorResult]; if (mediaType === "movie" || mediaType === "tv") { const tmdbIdInput = pathParts.length > 2 ? pathParts[2] : pathParts[1]; const providerIdInput = pathParts.length > 2 ? pathParts[1].toLowerCase() : null; if (!tmdbIdInput || !/^\d+$/.test(tmdbIdInput)) { resultData = createApiErrorObject("Valid TMDB ID is required."); return new Response(JSON.stringify(resultData), { headers: corsHeaders, status: 400 }); } const tmdbId = tmdbIdInput; let season: string | undefined = undefined; let episode: string | undefined = undefined; if (mediaType === "tv") { const sParam = searchParams.get("s"); const eParam = searchParams.get("e"); if (!sParam || !eParam) { resultData = createApiErrorObject("Season (s) and episode (e) parameters are required for TV shows."); return new Response(JSON.stringify(resultData), { headers: corsHeaders, status: 400 }); } season = sParam; episode = eParam; } if (providerIdInput) { const providerConfig = PROVIDERS[providerIdInput]; if (providerConfig) resultData = await providerConfig.fetchFunction(tmdbId, season, episode); else { resultData = createApiErrorObject(`Invalid provider: ${providerIdInput}. Available: ${Object.keys(PROVIDERS).join(', ')}`); return new Response(JSON.stringify(resultData), { headers: corsHeaders, status: 400 }); } } else resultData = await getAllProviders(tmdbId, mediaType, season, episode); } else { resultData = createApiErrorObject("Invalid route. Use /movie/tmdb_id or /tv/tmdb_id?s=S&e=E. For specific provider: /movie/provider_name/tmdb_id"); return new Response(JSON.stringify(resultData), { headers: corsHeaders, status: 404 }); } return new Response(JSON.stringify(resultData), { headers: corsHeaders }); } catch (error) { console.error("Server error:", error); const errorResponse = createApiErrorObject(`Server error: ${error.message || String(error)}`); return new Response(JSON.stringify(errorResponse), { headers: corsHeaders, status: 500 }); } } console.log(`TMDB Embed API server starting on port ${PORT}...`); console.log(`Available providers: ${Object.keys(PROVIDERS).join(', ')}`); console.log("Routes:"); console.log(" GET /movie/{tmdb_id}"); console.log(" GET /movie/{provider_id}/{tmdb_id}"); console.log(" GET /tv/{tmdb_id}?s={season_number}&e={episode_number}"); console.log(" GET /tv/{provider_id}/{tmdb_id}?s={season_number}&e={episode_number}"); serve(handleRequest, { port: Number(PORT) });