| |
| import { serve } from "https://deno.land/std/http/server.ts"; |
|
|
| const PORT = Deno.env.get("PORT") || 3000; |
|
|
| |
| 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"; |
|
|
| |
| interface MediaFile { |
| file: string; |
| type: string; |
| quality?: string; |
| lang?: string; |
| season?: string | number; |
| episode?: string | number; |
| } |
|
|
| interface Subtitle { |
| url: string; |
| lang: string; |
| type?: string; |
| } |
|
|
| interface SourceInfo { |
| provider: string; |
| files: MediaFile[]; |
| subtitles: Subtitle[]; |
| headers: Record<string, string>; |
| } |
|
|
| 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<ProviderFunctionReturn>; |
| } |
|
|
| |
| 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<string, string> = {}) { |
| 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; |
| } |
| } |
|
|
| |
|
|
| |
| 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, |
| 'Origin': EMBED_SU_DOMAIN, |
| }; |
|
|
| async function getEmbedSu(tmdb_id: string, s?: string, e?: string): Promise<ProviderFunctionReturn> { |
| 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) { |
| 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}`); |
| } |
| } |
|
|
| |
| 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<ProviderFunctionReturn> { |
| 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) { |
| 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}`); |
| } |
| } |
|
|
| |
| const AUTOEMBED_DOMAIN = "https://autoembed.cc/"; |
| const AUTOEMBED_API_URL_BASE = "https://tom.autoembed.cc/api/getVideoSource"; |
|
|
| |
| 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}`; |
|
|
| |
| const urlPath = m3u8Url.split('/'); |
| urlPath.pop(); |
| 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('#')) { |
| |
| let filePath = nextLineTrimmed; |
|
|
| |
| if (!nextLineTrimmed.match(/^https?:\/\//)) { |
| if (nextLineTrimmed.startsWith('/')) { |
| |
| if (domain) { |
| filePath = `${domain}${nextLineTrimmed}`; |
| } |
| } else { |
| |
| if (baseUrl) { |
| filePath = `${baseUrl}${nextLineTrimmed}`; |
| } |
| } |
| } |
| const fileObj: MediaFile = { |
| file: filePath, type: "hls", |
| quality: currentQuality || 'unknown', lang: "en" |
| }; |
| if (s && e) { |
| 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<ProviderFunctionReturn> { |
| 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(); |
| |
| 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}`); |
| } |
| } |
|
|
| |
| const PROVIDERS: Record<string, ProviderConfig> = { |
| 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 }, |
| }; |
|
|
| |
| async function getAllProviders(tmdb_id: string, mediaType: "movie" | "tv", s?: string, e?: string): Promise<ProviderSuccessResult[] | [ProviderErrorResult]> { |
| const providerFetchPromises: Promise<ProviderFunctionReturn>[] = []; |
| 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.")]; |
| } |
|
|
| |
| async function handleRequest(request: Request): Promise<Response> { |
| 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) }); |