diff --git a/README.md b/README.md index 3ae2e14..8a423a8 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,97 @@ # vrc-ytdlp-resolver -A small web tool that resolves direct playable video URLs from YouTube using `yt-dlp`. -This is useful for VRChat world video players that sometimes fail to play YouTube links due to bot protection or signature restrictions. +A small web tool that resolves temporary direct video stream URLs from YouTube using `yt-dlp`. -Instead of relying on in-world extraction, this tool resolves the video **server-side** and outputs a temporary direct streaming link (`*.googlevideo.com`). -You can then paste this link into a VRChat video player that accepts raw media URLs. +This can be useful for VRChat worlds where the built-in video extraction sometimes fails or gets rate-limited. +Instead of resolving the link inside VRChat, this tool resolves it **server-side** and returns a direct playback URL (`*.googlevideo.com`). + +--- + +## ⚠️ Important Behavior (Read This First) + +YouTube's streaming URLs are **time-limited** and often **IP-bound**. + +This means: + +- The direct video link usually **only works for the same public IP** that requested it. +- If **another user** in VRChat tries to use that same link from a **different network**, the video may **fail to load**. +- If your VRChat world is public and players are on different networks → **you must run this tool on a shared server** and the world should receive links resolved **by that shared server**, not by individual players. + +### In short: + +| Who resolves the URL? | Who can watch? | +|-----------------------|---------------| +| A player on home Wi-Fi | Only that player (same IP) | +| A dedicated server | Anyone connected to the VRChat world | + +So if you want **everyone** in the instance to be able to watch: +→ **Host this tool on a server (VPS / Docker / Linux box) and resolve the URLs there.** --- ## ✨ Features -- Resolve YouTube watch URLs into playable direct video URLs -- Prefers **progressive MP4 (H.264 + AAC)** for VRChat compatibility -- Automatically falls back to adaptive video + audio streams if required -- Web UI for easy usage +- Resolves YouTube links to direct streaming URLs +- Prefers **progressive MP4 (H.264 + AAC)** when available +- Falls back to adaptive (video+audio split) if necessary - Shows: - - **Local `yt-dlp` version** - - **Latest available `yt-dlp` version from GitHub** - - Update availability notification + - Local `yt-dlp` version + - Latest available version from GitHub + - Update availability notice +- Clean web UI (no CLI required) --- ## 📦 Requirements - Node.js **18+** -- `yt-dlp` installed and available in the system PATH - (or specify a custom path via `YT_DLP_PATH`) +- `yt-dlp` installed (or provided via Docker image) +- If running in VRChat shared environments: run this **on a server**, not on players’ PCs --- -## 🧱 Installation +## 🐳 Docker (Recommended for Server Deployment) + +```bash +docker run -d \ + --name vrc-ytdlp-resolver \ + -p 8080:3000 \ + mrunknownde/vrc-ytdlp-resolver:latest +```` + +Web UI → [http://localhost:8080](http://localhost:8080) + +--- + +## 🧱 Local Install (Non-Docker) ```bash git clone https://github.com/MrUnknownDE/vrc-ytdlp-resolver cd vrc-ytdlp-resolver npm install -```` - -Make sure `yt-dlp` is installed: - -```bash -# Linux / Mac -curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o yt-dlp -chmod +x yt-dlp -sudo mv yt-dlp /usr/local/bin/ - -# Windows (PowerShell) -winget install yt-dlp.yt-dlp -``` - -(Optional) Set a custom path: - -```bash -export YT_DLP_PATH="/path/to/yt-dlp" -``` - ---- - -## 🚀 Run - -```bash npm run dev ``` -The web interface will be available at: +--- -``` -http://localhost:3000 -``` +## Usage Instructions + +1. Open the web interface +2. Paste a YouTube URL +3. Click **Resolve** +4. Copy the direct link +5. Paste into your VRChat video player + +> Remember: if you are **not** running this on a server, only **you** will be able to watch the video. --- -## 🐳 Docker +## 🔄 Why URLs Expire -```bash -docker build -t vrc-ytdlp-resolver . -docker run --rm -p 3000:3000 vrc-ytdlp-resolver -``` +YouTube uses **signed playback tokens**: ---- +* Expire after minutes/hours +* Often tied to your **public IP** +* Cannot be manually extended -## 🔧 Usage - -1. Open the web UI. -2. Paste a YouTube watch link. -3. Click **Resolve**. -4. Copy the direct playback URL. -5. Paste it into your VRChat video player. - -> ⚠️ Direct streaming URLs are temporary. -> They may expire after several minutes or hours. -> If playback stops later, simply resolve again. - ---- - -## ⚠️ Disclaimer - -This tool **does not download or store media.** -It only extracts direct streaming URLs that YouTube already provides for playback. - -Respect YouTube’s Terms of Service and copyright laws. -Do not use this tool for piracy. \ No newline at end of file +Just resolve again when needed. \ No newline at end of file diff --git a/src/server.js b/src/server.js index 2615545..d578c01 100644 --- a/src/server.js +++ b/src/server.js @@ -10,7 +10,6 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); -app.set('trust proxy', true); app.use(morgan("tiny")); app.use(express.json()); app.use(express.urlencoded({ extended: true })); @@ -19,9 +18,7 @@ app.use(express.static(path.join(__dirname, "..", "public"))); const YT_DLP = process.env.YT_DLP_PATH || "yt-dlp"; const limit = pLimit(2); // simple per-process concurrency limit -// ------------------------- -// Version detection (local + latest from GitHub) -// ------------------------- +// ---- Version detection & refresh (local + latest from GitHub) ---- let localYtDlpVersion = "unknown"; let latestYtDlpVersion = null; @@ -31,7 +28,7 @@ function fetchLatestYtDlpVersion() { { hostname: "api.github.com", path: "/repos/yt-dlp/yt-dlp/releases/latest", - headers: { "User-Agent": "vrc-ytdlp-resolver" } + headers: { "User-Agent": "vrc-ytdlp-webtool" } }, (res) => { let data = ""; @@ -61,7 +58,7 @@ async function detectLocalYtDlpVersion() { } (async () => { - localYtDlpVersion = await detectLocalYtDlpVersion().catch(() => "unknown"); + localYtDlpVersion = await detectLocalYtDlpVersion(); try { latestYtDlpVersion = await fetchLatestYtDlpVersion(); } catch { @@ -77,19 +74,47 @@ async function detectLocalYtDlpVersion() { }, 6 * 60 * 60 * 1000); })(); -// ------------------------- -// Helpers -// ------------------------- -function stripIpParam(directUrl) { - try { - const u = new URL(directUrl); - // remove IP-scoping params without touching signature/expiry/etc. - u.searchParams.delete("ip"); - u.searchParams.delete("ipbits"); - return u.toString(); - } catch { - return directUrl; // if parsing fails, return original +// ---- Core: resolve direct media URL via yt-dlp ---- +async function resolveDirectUrl(inputUrl) { + const formatSelector = + // 1) Progressive MP4 with audio (H.264 + AAC), https + "best[acodec!=none][vcodec*=avc][ext=mp4][protocol*=https]/" + + // 2) Any progressive with audio + "best[acodec!=none][protocol*=https]/" + + // 3) Anything with audio (may be HLS/DASH) + "best[acodec!=none]/" + + // 4) Fallback best + "best"; + + const json = await execJson([ + "-J", + "-f", + formatSelector, + "--no-warnings", + "--no-playlist", + inputUrl + ]); + + // Case A: yt-dlp returns a single playable URL (progressive) + if (json.url) { + return { url: json.url, note: "Direct stream (single URL)." }; } + + // Case B: adaptive (separate video/audio) + if (Array.isArray(json.requested_formats) && json.requested_formats.length) { + const vid = json.requested_formats.find((f) => f.vcodec && f.acodec === "none"); + const aud = json.requested_formats.find((f) => f.acodec && f.vcodec === "none"); + if (vid?.url && aud?.url) { + return { + url: vid.url, + audioUrl: aud.url, + note: + "Adaptive streams (separate video/audio). Many in-world players expect a single URL." + }; + } + } + + throw new Error("Could not extract a playable URL."); } function execJson(args) { @@ -115,54 +140,7 @@ function execJson(args) { }); } -// ------------------------- -// Core: resolve direct media URL via yt-dlp -// ------------------------- -async function resolveDirectUrl(inputUrl) { - const formatSelector = - // 1) Progressive MP4 with audio (H.264 + AAC), https - "best[acodec!=none][vcodec*=avc][ext=mp4][protocol*=https]/" + - // 2) Any progressive with audio - "best[acodec!=none][protocol*=https]/" + - // 3) Anything with audio (may be HLS/DASH) - "best[acodec!=none]/" + - // 4) Fallback best - "best"; - - const json = await execJson([ - "-J", - "-f", - formatSelector, - "--no-warnings", - "--no-playlist", - inputUrl - ]); - - // Case A: yt-dlp returns a single playable URL (progressive) - if (json.url) { - return { url: stripIpParam(json.url), note: "Direct stream (single URL)." }; - } - - // Case B: adaptive (separate video/audio) - if (Array.isArray(json.requested_formats) && json.requested_formats.length) { - const vid = json.requested_formats.find((f) => f.vcodec && f.acodec === "none"); - const aud = json.requested_formats.find((f) => f.acodec && f.vcodec === "none"); - if (vid?.url && aud?.url) { - return { - url: stripIpParam(vid.url), - audioUrl: stripIpParam(aud.url), - note: - "Adaptive streams (separate video/audio). Many in-world players expect a single URL." - }; - } - } - - throw new Error("Could not extract a playable URL."); -} - -// ------------------------- -// Routes -// ------------------------- +// ---- API routes ---- app.post("/api/resolve", async (req, res) => { const { url } = req.body || {}; if ( @@ -192,9 +170,6 @@ app.get("/api/version", (req, res) => { }); }); -// ------------------------- -// Server -// ------------------------- const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`VRC yt-dlp WebTool running at http://localhost:${port}`);