embed / web.ts
stnh70's picture
Update web.ts
8963d17 verified
// 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<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>;
}
// --- 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<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;
}
}
// --- 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<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) { // 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<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) { // 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<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();
// 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<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 },
};
// --- Core Logic ---
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.")];
}
// --- API Routes & Server ---
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) });