diff --git a/deno.json b/deno.json index cb06e71..85fafdf 100644 --- a/deno.json +++ b/deno.json @@ -15,7 +15,11 @@ "deploy": { "project": "85252de3-9b36-4d8b-b250-e491b4131838", "exclude": [ - "**/node_modules" + "**/node_modules", + "Music/**", + "virome-music/**", + ".git/**", + ".vscode/**" ], "include": [], "entrypoint": "mod.ts" diff --git a/lib.ts b/lib.ts index 31073be..154ee23 100644 --- a/lib.ts +++ b/lib.ts @@ -989,14 +989,20 @@ export async function fetchFromPiped(videoId: string) { const data = await response.json(); if (data?.audioStreams?.length) { + // Get the proxy host from the instance (e.g., pipedapi.kavin.rocks -> pipedproxy.kavin.rocks) + const instanceUrl = new URL(instance); + const proxyHost = instanceUrl.host.replace('pipedapi', 'pipedproxy').replace('api.', 'proxy.'); + return { success: true, instance, streamingUrls: data.audioStreams.map((s: any) => ({ + // Piped streams are already proxied through their CDN url: s.url, quality: s.quality, mimeType: s.mimeType, bitrate: s.bitrate, + proxyHost, })), metadata: { id: videoId, @@ -1006,6 +1012,8 @@ export async function fetchFromPiped(videoId: string) { duration: data.duration, views: data.views, }, + // Include HLS stream if available (better for streaming) + hlsUrl: data.hls, }; } } catch { @@ -1030,14 +1038,19 @@ export async function fetchFromInvidious(videoId: string) { f.type?.includes("audio") || f.mimeType?.includes("audio") ); + // Use Invidious proxy URLs instead of direct googlevideo URLs + // This bypasses IP restrictions by having Invidious proxy the stream return { success: true, instance, streamingUrls: audioFormats.map((f: any) => ({ - url: f.url, + // Use the instance's proxy endpoint + url: `${instance}/latest_version?id=${videoId}&itag=${f.itag}`, + directUrl: f.url, // Keep original for reference bitrate: f.bitrate, type: f.type, audioQuality: f.audioQuality, + itag: f.itag, })), metadata: { id: videoId, diff --git a/mod.ts b/mod.ts index 9491e4e..189c3a4 100644 --- a/mod.ts +++ b/mod.ts @@ -164,12 +164,13 @@ async function handler(req: Request): Promise { // ============ SEARCH ENDPOINTS ============ - // YouTube Music Search + // YouTube Music Search with YouTube fallback video IDs if (pathname === "/api/search") { const query = searchParams.get("q"); const filter = searchParams.get("filter") || undefined; const continuationToken = searchParams.get("continuationToken") || undefined; const ignoreSpelling = searchParams.get("ignore_spelling") === "true"; + const withFallback = searchParams.get("fallback") !== "0"; // Enable by default // Get region from param or detect from IP let region = searchParams.get("region") || searchParams.get("gl") || undefined; @@ -189,6 +190,43 @@ async function handler(req: Request): Promise { } const results = await ytmusic.search(query || "", filter, continuationToken, ignoreSpelling, region, language); + + // For songs, add fallback YouTube video IDs (embeddable versions) + if (withFallback && filter === "songs" && results.results?.length > 0) { + // Get YouTube video alternatives for the top results + const enhancedResults = await Promise.all( + results.results.slice(0, 10).map(async (song: any) => { + try { + // Search YouTube for official video + const searchQuery = `${song.title} ${song.artists?.[0]?.name || ''} official`; + const ytResults = await youtubeSearch.searchVideos(searchQuery); + + // Find a non-Topic channel video + const alternative = ytResults.results?.find((v: any) => + v.channel?.name && !v.channel.name.includes('Topic') && v.id + ); + + if (alternative) { + return { + ...song, + fallbackVideoId: alternative.id, + fallbackTitle: alternative.title, + }; + } + } catch { + // Ignore errors, just return original + } + return song; + }) + ); + + // Replace first 10 results with enhanced ones, keep the rest + results.results = [ + ...enhancedResults, + ...results.results.slice(10) + ]; + } + return json({ query, filter, region, language, ...results }); } @@ -461,7 +499,7 @@ async function handler(req: Request): Promise { if (!audioUrl) { return error("Missing url parameter"); } - return proxyAudio(audioUrl); + return proxyAudio(audioUrl, req); } if (pathname === "/api/feed/unauthenticated" || pathname.startsWith("/api/feed/channels=")) { @@ -601,32 +639,75 @@ async function handler(req: Request): Promise { } } -// Audio proxy endpoint to bypass CORS -async function proxyAudio(url: string): Promise { +// Audio proxy endpoint to bypass CORS with range request support +async function proxyAudio(url: string, req: Request): Promise { try { - const response = await fetch(url, { - headers: { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - }, - }); + const headers: Record = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Referer": "https://www.youtube.com/", + "Origin": "https://www.youtube.com", + }; - if (!response.ok) { - return new Response("Failed to fetch audio", { status: 502 }); + // Forward range header for seeking support + const rangeHeader = req.headers.get("Range"); + if (rangeHeader) { + headers["Range"] = rangeHeader; } - const headers = new Headers(); - headers.set("Content-Type", response.headers.get("Content-Type") || "audio/mp4"); - headers.set("Access-Control-Allow-Origin", "*"); - headers.set("Cache-Control", "public, max-age=3600"); + const response = await fetch(url, { headers }); + + if (!response.ok && response.status !== 206) { + console.error(`[Proxy] Upstream error: ${response.status} ${response.statusText}`); + return new Response(`Failed to fetch audio: ${response.status}`, { + status: 502, + headers: corsHeaders + }); + } + + const responseHeaders = new Headers(); + responseHeaders.set("Access-Control-Allow-Origin", "*"); + responseHeaders.set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS"); + responseHeaders.set("Access-Control-Allow-Headers", "Range, Content-Type"); + responseHeaders.set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges"); + responseHeaders.set("Cache-Control", "public, max-age=3600"); + + // Copy important headers from upstream + const contentType = response.headers.get("Content-Type"); + if (contentType) { + responseHeaders.set("Content-Type", contentType); + } else { + responseHeaders.set("Content-Type", "audio/mp4"); + } const contentLength = response.headers.get("Content-Length"); if (contentLength) { - headers.set("Content-Length", contentLength); + responseHeaders.set("Content-Length", contentLength); } - return new Response(response.body, { headers }); + const contentRange = response.headers.get("Content-Range"); + if (contentRange) { + responseHeaders.set("Content-Range", contentRange); + } + + const acceptRanges = response.headers.get("Accept-Ranges"); + if (acceptRanges) { + responseHeaders.set("Accept-Ranges", acceptRanges); + } else { + responseHeaders.set("Accept-Ranges", "bytes"); + } + + return new Response(response.body, { + status: response.status, + headers: responseHeaders + }); } catch (err) { - return new Response("Proxy error: " + String(err), { status: 502 }); + console.error("[Proxy] Error:", err); + return new Response("Proxy error: " + String(err), { + status: 502, + headers: corsHeaders + }); } } diff --git a/ui.ts b/ui.ts index 33af241..c48d8b1 100644 --- a/ui.ts +++ b/ui.ts @@ -278,7 +278,32 @@ function showTab(t){ function onYouTubeIframeAPIReady(){ yt=new YT.Player('ytplayer',{height:'0',width:'0',playerVars:{autoplay:1,controls:0},events:{onReady:()=>ready=true,onStateChange:onState,onError:onErr}}); } -function onErr(e){if(e.data===150||e.data===101)setTimeout(()=>play(idx+1),500)} +function onErr(e){ + if(e.data===150||e.data===101||e.data===100){ + // Try fallbackVideoId first, then search YouTube + var s=songs[idx]; + if(s&&s.fallbackVideoId&&!s.triedFallback){ + console.log('Using fallback:',s.fallbackVideoId); + s.triedFallback=true; + yt.loadVideoById(s.fallbackVideoId); + }else if(s&&!s.triedSearch){ + // Search YouTube for playable version + s.triedSearch=true; + searchYouTube(s.title,s.artists?.[0]?.name||'').then(vid=>{ + if(vid){console.log('Found alternative:',vid);yt.loadVideoById(vid)} + }); + } + } +} +async function searchYouTube(title,artist){ + try{ + var q=title+' '+artist+' official'; + var res=await fetch('/api/yt_search?q='+encodeURIComponent(q)+'&filter=videos'); + var data=await res.json(); + var alt=data.results?.find(v=>v.channel?.name&&!v.channel.name.includes('Topic')&&v.id); + return alt?.id||null; + }catch(e){return null} +} function onState(e){ if(e.data===1){playing=true;document.getElementById('playBtn').textContent='⏸';startProgress()} else if(e.data===2){playing=false;document.getElementById('playBtn').textContent='▶';stopProgress()}