| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| const INNERTUBE_API_KEY = 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w'; |
| const INNERTUBE_API_URL = 'https://www.youtube.com/youtubei/v1/player'; |
|
|
| |
| const CLIENTS = { |
| android: { |
| clientName: 'ANDROID', |
| clientVersion: '19.29.37', |
| androidSdkVersion: 30, |
| hl: 'en', |
| gl: 'US', |
| }, |
| ios: { |
| clientName: 'IOS', |
| clientVersion: '19.29.1', |
| deviceModel: 'iPhone16,2', |
| iosVersion: '17.5.1', |
| hl: 'en', |
| gl: 'US', |
| }, |
| web: { |
| clientName: 'WEB', |
| clientVersion: '2.20240726.00.00', |
| hl: 'en', |
| gl: 'US', |
| }, |
| mweb: { |
| clientName: 'MWEB', |
| clientVersion: '2.20240726.01.00', |
| hl: 'en', |
| gl: 'US', |
| }, |
| tv_embedded: { |
| clientName: 'TVHTML5_SIMPLY_EMBEDDED_PLAYER', |
| clientVersion: '2.0', |
| hl: 'en', |
| gl: 'US', |
| }, |
| }; |
|
|
| |
| const CORS_HEADERS = { |
| 'Access-Control-Allow-Origin': '*', |
| 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', |
| 'Access-Control-Allow-Headers': 'Content-Type', |
| 'Content-Type': 'application/json', |
| }; |
|
|
| |
| const QUALITY_HEIGHT_MAP = { |
| best: 1080, |
| medium: 720, |
| low: 480, |
| }; |
|
|
| |
| |
| |
| function extractVideoId(url) { |
| const patterns = [ |
| /(?:v=|\/v\/|youtu\.be\/|\/embed\/|\/shorts\/)([a-zA-Z0-9_-]{11})/, |
| /^([a-zA-Z0-9_-]{11})$/, |
| ]; |
| for (const pattern of patterns) { |
| const match = url.match(pattern); |
| if (match) return match[1]; |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| async function fetchPlayerData(videoId, clientType = 'android') { |
| const client = CLIENTS[clientType]; |
| if (!client) throw new Error(`Unknown client: ${clientType}`); |
|
|
| const body = { |
| videoId, |
| context: { |
| client, |
| }, |
| playbackContext: { |
| contentPlaybackContext: { |
| html5Preference: 'HTML5_PREF_WANTS', |
| }, |
| }, |
| contentCheckOk: true, |
| racyCheckOk: true, |
| }; |
|
|
| const response = await fetch( |
| `${INNERTUBE_API_URL}?key=${INNERTUBE_API_KEY}&prettyPrint=false`, |
| { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| 'User-Agent': 'com.google.android.youtube/19.29.37 (Linux; U; Android 14)', |
| 'Accept': 'application/json', |
| }, |
| body: JSON.stringify(body), |
| } |
| ); |
|
|
| if (!response.ok) { |
| throw new Error(`Innertube API returned ${response.status}`); |
| } |
|
|
| return await response.json(); |
| } |
|
|
| |
| |
| |
| async function fetchWithFallback(videoId) { |
| const clientOrder = ['android', 'ios', 'mweb', 'web', 'tv_embedded']; |
| let lastError = null; |
|
|
| for (const clientType of clientOrder) { |
| try { |
| const data = await fetchPlayerData(videoId, clientType); |
| |
| |
| const status = data?.playabilityStatus?.status; |
| if (status === 'OK' && data?.streamingData) { |
| return { data, clientType }; |
| } |
| |
| |
| if (status === 'LOGIN_REQUIRED' || status === 'UNPLAYABLE') { |
| const reason = data?.playabilityStatus?.reason || 'Unknown'; |
| console.log(`Client ${clientType}: ${status} - ${reason}`); |
| lastError = new Error(`${status}: ${reason}`); |
| continue; |
| } |
| |
| |
| if (status === 'AGE_CHECK_REQUIRED') { |
| console.log(`Client ${clientType}: Age check required, trying next`); |
| lastError = new Error('Age restricted video'); |
| continue; |
| } |
| |
| |
| if (data?.streamingData) { |
| return { data, clientType }; |
| } |
| |
| lastError = new Error(`No streaming data from ${clientType}`); |
| } catch (err) { |
| console.log(`Client ${clientType} failed: ${err.message}`); |
| lastError = err; |
| } |
| } |
|
|
| throw lastError || new Error('All clients failed'); |
| } |
|
|
| |
| |
| |
| function parseFormats(streamingData) { |
| const formats = []; |
| const adaptiveFormats = []; |
|
|
| |
| if (streamingData.formats) { |
| for (const f of streamingData.formats) { |
| formats.push({ |
| itag: f.itag, |
| url: f.url || null, |
| mimeType: f.mimeType || '', |
| quality: f.quality || '', |
| qualityLabel: f.qualityLabel || '', |
| width: f.width || 0, |
| height: f.height || 0, |
| fps: f.fps || 0, |
| bitrate: f.bitrate || 0, |
| contentLength: f.contentLength || 0, |
| hasAudio: true, |
| hasVideo: true, |
| signatureCipher: f.signatureCipher || f.cipher || null, |
| }); |
| } |
| } |
|
|
| |
| if (streamingData.adaptiveFormats) { |
| for (const f of streamingData.adaptiveFormats) { |
| const isVideo = f.mimeType?.startsWith('video/'); |
| const isAudio = f.mimeType?.startsWith('audio/'); |
|
|
| adaptiveFormats.push({ |
| itag: f.itag, |
| url: f.url || null, |
| mimeType: f.mimeType || '', |
| quality: f.quality || '', |
| qualityLabel: f.qualityLabel || '', |
| width: f.width || 0, |
| height: f.height || 0, |
| fps: f.fps || 0, |
| bitrate: f.bitrate || 0, |
| contentLength: f.contentLength || 0, |
| audioQuality: f.audioQuality || '', |
| audioSampleRate: f.audioSampleRate || '', |
| audioChannels: f.audioChannels || 0, |
| hasAudio: isAudio, |
| hasVideo: isVideo, |
| signatureCipher: f.signatureCipher || f.cipher || null, |
| }); |
| } |
| } |
|
|
| return { formats, adaptiveFormats }; |
| } |
|
|
| |
| |
| |
| function findBestDownload(parsedFormats, quality = 'best') { |
| const targetHeight = QUALITY_HEIGHT_MAP[quality] || 1080; |
| const { formats, adaptiveFormats } = parsedFormats; |
|
|
| |
| const combinedFormats = formats |
| .filter(f => f.url && f.height <= targetHeight) |
| .sort((a, b) => b.height - a.height); |
|
|
| if (combinedFormats.length > 0) { |
| return { |
| type: 'combined', |
| videoUrl: combinedFormats[0].url, |
| audioUrl: null, |
| needsMerge: false, |
| format: combinedFormats[0], |
| }; |
| } |
|
|
| |
| const videoFormats = adaptiveFormats |
| .filter(f => f.hasVideo && f.url && f.height <= targetHeight) |
| .sort((a, b) => b.height - a.height); |
|
|
| const audioFormats = adaptiveFormats |
| .filter(f => f.hasAudio && f.url) |
| .sort((a, b) => b.bitrate - a.bitrate); |
|
|
| if (videoFormats.length > 0 && audioFormats.length > 0) { |
| return { |
| type: 'separate', |
| videoUrl: videoFormats[0].url, |
| audioUrl: audioFormats[0].url, |
| needsMerge: true, |
| videoFormat: videoFormats[0], |
| audioFormat: audioFormats[0], |
| }; |
| } |
|
|
| |
| const anyFormat = [...formats, ...adaptiveFormats].find(f => f.url); |
| if (anyFormat) { |
| return { |
| type: 'any', |
| videoUrl: anyFormat.url, |
| audioUrl: null, |
| needsMerge: false, |
| format: anyFormat, |
| }; |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| function extractVideoInfo(playerData) { |
| const videoDetails = playerData.videoDetails || {}; |
| return { |
| videoId: videoDetails.videoId || '', |
| title: videoDetails.title || '', |
| lengthSeconds: parseInt(videoDetails.lengthSeconds || '0'), |
| keywords: videoDetails.keywords || [], |
| channelId: videoDetails.channelId || '', |
| author: videoDetails.author || '', |
| shortDescription: videoDetails.shortDescription || '', |
| viewCount: parseInt(videoDetails.viewCount || '0'), |
| thumbnails: videoDetails.thumbnail?.thumbnails || [], |
| isLiveContent: videoDetails.isLiveContent || false, |
| }; |
| } |
|
|
| |
| |
| |
| function handleOptions() { |
| return new Response(null, { |
| status: 204, |
| headers: CORS_HEADERS, |
| }); |
| } |
|
|
| |
| |
| |
| export default { |
| async fetch(request, env, ctx) { |
| const url = new URL(request.url); |
| const path = url.pathname; |
|
|
| |
| if (request.method === 'OPTIONS') { |
| return handleOptions(); |
| } |
|
|
| |
| if (path === '/' || path === '/health') { |
| return new Response(JSON.stringify({ |
| status: 'ok', |
| service: 'DownTube Worker', |
| version: '1.0.0', |
| endpoints: ['/info', '/download', '/stream'], |
| }), { headers: CORS_HEADERS }); |
| } |
|
|
| |
| if (request.method !== 'POST') { |
| return new Response(JSON.stringify({ error: 'Method not allowed' }), { |
| status: 405, |
| headers: CORS_HEADERS, |
| }); |
| } |
|
|
| try { |
| const body = await request.json(); |
| const videoUrl = body.url || body.video_url || ''; |
| const quality = body.quality || 'best'; |
|
|
| if (!videoUrl) { |
| return new Response(JSON.stringify({ error: 'Missing url parameter' }), { |
| status: 400, |
| headers: CORS_HEADERS, |
| }); |
| } |
|
|
| const videoId = extractVideoId(videoUrl); |
| if (!videoId) { |
| return new Response(JSON.stringify({ error: 'Invalid YouTube URL' }), { |
| status: 400, |
| headers: CORS_HEADERS, |
| }); |
| } |
|
|
| |
| const { data: playerData, clientType } = await fetchWithFallback(videoId); |
| const videoInfo = extractVideoInfo(playerData); |
| const parsedFormats = parseFormats(playerData.streamingData || {}); |
|
|
| |
| const hasCipher = [...parsedFormats.formats, ...parsedFormats.adaptiveFormats] |
| .some(f => f.signatureCipher); |
|
|
| |
| if (path === '/info') { |
| |
| const captions = playerData.captions?.playerCaptionsTracklistRenderer?.captionTracks || []; |
| const subtitles = captions.map(cap => ({ |
| languageCode: cap.languageCode || '', |
| name: cap.name?.simpleText || cap.languageCode || '', |
| url: cap.baseUrl || '', |
| })); |
|
|
| return new Response(JSON.stringify({ |
| success: true, |
| source: 'cloudflare_worker', |
| clientUsed: clientType, |
| video: videoInfo, |
| subtitles, |
| hasCipher, |
| formatCount: parsedFormats.formats.length, |
| adaptiveFormatCount: parsedFormats.adaptiveFormats.length, |
| }), { headers: CORS_HEADERS }); |
| } |
|
|
| |
| if (path === '/stream') { |
| return new Response(JSON.stringify({ |
| success: true, |
| source: 'cloudflare_worker', |
| clientUsed: clientType, |
| video: videoInfo, |
| formats: parsedFormats.formats, |
| adaptiveFormats: parsedFormats.adaptiveFormats, |
| hasCipher, |
| expiresInSeconds: playerData.streamingData?.expiresInSeconds || '', |
| }), { headers: CORS_HEADERS }); |
| } |
|
|
| |
| const download = findBestDownload(parsedFormats, quality); |
|
|
| if (!download) { |
| |
| if (hasCipher) { |
| return new Response(JSON.stringify({ |
| success: false, |
| error: 'Video requires signature deciphering', |
| source: 'cloudflare_worker', |
| clientUsed: clientType, |
| video: videoInfo, |
| hint: 'Use /stream endpoint to get raw format data', |
| hasCipher: true, |
| }), { status: 422, headers: CORS_HEADERS }); |
| } |
|
|
| return new Response(JSON.stringify({ |
| success: false, |
| error: 'No downloadable formats found', |
| source: 'cloudflare_worker', |
| }), { status: 404, headers: CORS_HEADERS }); |
| } |
|
|
| |
| const safeTitle = (videoInfo.title || 'video').replace(/[^\w\s-]/g, '').substring(0, 80); |
| const ext = download.needsMerge ? 'mp4' : |
| (download.format?.mimeType?.includes('webm') ? 'webm' : 'mp4'); |
|
|
| return new Response(JSON.stringify({ |
| success: true, |
| source: 'cloudflare_worker', |
| clientUsed: clientType, |
| video: videoInfo, |
| download: { |
| type: download.type, |
| needsMerge: download.needsMerge, |
| videoUrl: download.videoUrl, |
| audioUrl: download.audioUrl, |
| filename: `${safeTitle}.${ext}`, |
| format: download.format || download.videoFormat, |
| audioFormat: download.audioFormat || null, |
| }, |
| hasCipher, |
| }), { headers: CORS_HEADERS }); |
|
|
| } catch (err) { |
| console.error('Worker error:', err); |
| return new Response(JSON.stringify({ |
| success: false, |
| error: err.message || 'Internal server error', |
| source: 'cloudflare_worker', |
| }), { status: 500, headers: CORS_HEADERS }); |
| } |
| }, |
| }; |
|
|