forked from github-mirror/Verome-API
Implement direct YouTube innertube API for downloads
This commit is contained in:
188
mod.ts
188
mod.ts
@@ -7,10 +7,78 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { serve } from "https://deno.land/std@0.208.0/http/server.ts";
|
import { serve } from "https://deno.land/std@0.208.0/http/server.ts";
|
||||||
import { Innertube } from "https://deno.land/x/youtubei@v12.2.0/deno.ts";
|
|
||||||
import { YTMusic, YouTubeSearch, LastFM, fetchFromPiped, fetchFromInvidious, getLyrics, getTrendingMusic, getRadio, getTopArtists, getTopTracks, getArtistInfo, getTrackInfo, getSongComplete, getAlbumComplete, getArtistComplete, getFullChain } from "./lib.ts";
|
import { YTMusic, YouTubeSearch, LastFM, fetchFromPiped, fetchFromInvidious, getLyrics, getTrendingMusic, getRadio, getTopArtists, getTopTracks, getArtistInfo, getTrackInfo, getSongComplete, getAlbumComplete, getArtistComplete, getFullChain } from "./lib.ts";
|
||||||
import { html as uiHtml } from "./ui.ts";
|
import { html as uiHtml } from "./ui.ts";
|
||||||
|
|
||||||
|
// Direct YouTube innertube API for downloads
|
||||||
|
async function getYouTubeStreamUrl(videoId: string): Promise<{ url: string; mimeType: string } | null> {
|
||||||
|
const clients = [
|
||||||
|
{
|
||||||
|
name: "ANDROID",
|
||||||
|
context: {
|
||||||
|
client: {
|
||||||
|
clientName: "ANDROID",
|
||||||
|
clientVersion: "19.09.37",
|
||||||
|
androidSdkVersion: 30,
|
||||||
|
userAgent: "com.google.android.youtube/19.09.37 (Linux; U; Android 11) gzip",
|
||||||
|
hl: "en",
|
||||||
|
gl: "US",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IOS",
|
||||||
|
context: {
|
||||||
|
client: {
|
||||||
|
clientName: "IOS",
|
||||||
|
clientVersion: "19.09.3",
|
||||||
|
deviceModel: "iPhone14,3",
|
||||||
|
userAgent: "com.google.ios.youtube/19.09.3 (iPhone14,3; U; CPU iOS 15_6 like Mac OS X)",
|
||||||
|
hl: "en",
|
||||||
|
gl: "US",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://www.youtube.com/youtubei/v1/player?prettyPrint=false", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": client.context.client.userAgent,
|
||||||
|
"X-YouTube-Client-Name": client.name === "ANDROID" ? "3" : "5",
|
||||||
|
"X-YouTube-Client-Version": client.context.client.clientVersion,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
videoId,
|
||||||
|
context: client.context,
|
||||||
|
contentCheckOk: true,
|
||||||
|
racyCheckOk: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.streamingData?.adaptiveFormats) {
|
||||||
|
// Find best audio format (m4a)
|
||||||
|
const audioFormats = data.streamingData.adaptiveFormats.filter(
|
||||||
|
(f: any) => f.mimeType?.includes("audio/mp4")
|
||||||
|
);
|
||||||
|
audioFormats.sort((a: any, b: any) => (b.bitrate || 0) - (a.bitrate || 0));
|
||||||
|
|
||||||
|
if (audioFormats[0]?.url) {
|
||||||
|
return { url: audioFormats[0].url, mimeType: audioFormats[0].mimeType };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const PORT = parseInt(Deno.env.get("PORT") || "8000");
|
const PORT = parseInt(Deno.env.get("PORT") || "8000");
|
||||||
|
|
||||||
const ytmusic = new YTMusic();
|
const ytmusic = new YTMusic();
|
||||||
@@ -265,69 +333,75 @@ async function handler(req: Request): Promise<Response> {
|
|||||||
if (!id) return error("Missing id");
|
if (!id) return error("Missing id");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use Innertube (YouTube.js) for direct access
|
// Try direct YouTube innertube API first
|
||||||
const yt = await Innertube.create({
|
const ytStream = await getYouTubeStreamUrl(id);
|
||||||
retrieve_player: false, // Faster, we just need stream URLs
|
|
||||||
});
|
|
||||||
|
|
||||||
const info = await yt.getBasicInfo(id);
|
if (ytStream?.url) {
|
||||||
|
const ext = ".m4a";
|
||||||
// Get audio-only format (m4a)
|
const filename = `${artist ? artist + " - " : ""}${title}`.replace(/[<>:"/\\|?*]/g, "").trim() + ext;
|
||||||
const audioFormats = info.streaming_data?.adaptive_formats?.filter(
|
|
||||||
(f: any) => f.mime_type?.includes("audio/mp4")
|
const response = await fetch(ytStream.url, {
|
||||||
) || [];
|
headers: {
|
||||||
|
"User-Agent": "com.google.android.youtube/19.09.37 (Linux; U; Android 11) gzip",
|
||||||
// Sort by bitrate and get best quality
|
"Accept": "*/*",
|
||||||
audioFormats.sort((a: any, b: any) => (b.bitrate || 0) - (a.bitrate || 0));
|
},
|
||||||
const audio = audioFormats[0];
|
});
|
||||||
|
|
||||||
if (!audio?.url) {
|
if (response.ok) {
|
||||||
// Fallback to Piped/Invidious
|
return new Response(response.body, {
|
||||||
const piped = await fetchFromPiped(id);
|
headers: {
|
||||||
if (piped.success && piped.streamingUrls) {
|
"Content-Type": "audio/mp4",
|
||||||
const pipedAudio = piped.streamingUrls.find((s: any) => s.mimeType?.includes("audio/mp4"))
|
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
|
||||||
|| piped.streamingUrls.find((s: any) => s.mimeType?.includes("audio"));
|
"Access-Control-Allow-Origin": "*",
|
||||||
if (pipedAudio?.url) {
|
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||||
const response = await fetch(pipedAudio.url);
|
},
|
||||||
if (response.ok) {
|
});
|
||||||
const ext = pipedAudio.mimeType?.includes("webm") ? ".webm" : ".m4a";
|
}
|
||||||
const filename = `${artist ? artist + " - " : ""}${title}`.replace(/[<>:"/\\|?*]/g, "").trim() + ext;
|
}
|
||||||
return new Response(response.body, {
|
|
||||||
headers: {
|
// Fallback to Piped
|
||||||
"Content-Type": pipedAudio.mimeType?.split(";")[0] || "audio/mp4",
|
const piped = await fetchFromPiped(id);
|
||||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
|
if (piped.success && piped.streamingUrls) {
|
||||||
"Access-Control-Allow-Origin": "*",
|
const pipedAudio = piped.streamingUrls.find((s: any) => s.mimeType?.includes("audio/mp4"))
|
||||||
},
|
|| piped.streamingUrls.find((s: any) => s.mimeType?.includes("audio"));
|
||||||
});
|
if (pipedAudio?.url) {
|
||||||
}
|
const response = await fetch(pipedAudio.url);
|
||||||
|
if (response.ok) {
|
||||||
|
const ext = pipedAudio.mimeType?.includes("webm") ? ".webm" : ".m4a";
|
||||||
|
const filename = `${artist ? artist + " - " : ""}${title}`.replace(/[<>:"/\\|?*]/g, "").trim() + ext;
|
||||||
|
return new Response(response.body, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": pipedAudio.mimeType?.split(";")[0] || "audio/mp4",
|
||||||
|
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return json({ success: false, error: "No audio stream found" }, 404);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = ".m4a";
|
// Fallback to Invidious
|
||||||
const filename = `${artist ? artist + " - " : ""}${title}`.replace(/[<>:"/\\|?*]/g, "").trim() + ext;
|
const invidious = await fetchFromInvidious(id);
|
||||||
|
if (invidious.success && invidious.streamingUrls) {
|
||||||
// Fetch and proxy the audio
|
const invAudio = invidious.streamingUrls.find((s: any) => s.type?.includes("audio/mp4"))
|
||||||
const response = await fetch(audio.url, {
|
|| invidious.streamingUrls.find((s: any) => s.type?.includes("audio"));
|
||||||
headers: {
|
if (invAudio?.url) {
|
||||||
"User-Agent": "com.google.android.youtube/19.09.37 (Linux; U; Android 11) gzip",
|
const response = await fetch(invAudio.url);
|
||||||
"Accept": "*/*",
|
if (response.ok) {
|
||||||
},
|
const ext = invAudio.type?.includes("webm") ? ".webm" : ".m4a";
|
||||||
});
|
const filename = `${artist ? artist + " - " : ""}${title}`.replace(/[<>:"/\\|?*]/g, "").trim() + ext;
|
||||||
|
return new Response(response.body, {
|
||||||
if (!response.ok) {
|
headers: {
|
||||||
return json({ success: false, error: `Failed to fetch audio: ${response.status}` }, 502);
|
"Content-Type": invAudio.type?.split(";")[0] || "audio/mp4",
|
||||||
|
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(response.body, {
|
return json({ success: false, error: "No audio stream found" }, 404);
|
||||||
headers: {
|
|
||||||
"Content-Type": "audio/mp4",
|
|
||||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Expose-Headers": "Content-Disposition",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return json({ success: false, error: "Download failed: " + String(err) }, 500);
|
return json({ success: false, error: "Download failed: " + String(err) }, 500);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user