Compare commits

..

No commits in common. "4b67a1a11cb557a04ed816d9ee8855bf40dfe957" and "7eed0d6eb78d8ce5f066aeb5f35b3b66cc2f19b1" have entirely different histories.

8 changed files with 1339 additions and 1668 deletions

4
.gitignore vendored
View File

@ -1,4 +1,2 @@
node_modules
videos
metadata
aliases
v

65
index.html Normal file
View File

@ -0,0 +1,65 @@
<meta name="og:title" content="ytdl host" />
<meta name="og:description" content="paste youtube url, get direct mp4 url" />
<title>ytdl host</title>
<h1>YouTube direct MP4 server</h1>
<p>Oculus VRChat does not support <a href="https://docs.vrchat.com/docs/video-players" target="_blank">playing videos</a> from YouTube (and <a href="https://docs.vrchat.com/docs/www-whitelist" target="_blank">other video sites</a>) because it requires the <a href="https://github.com/yt-dlp/yt-dlp" target="_blank">yt-dlp</a> python program which can't be built into the quest build, so the only way to see video in quest vrchat is with simple direct links to regular video files (requires allowing untrusted URLs). This service makes it easier to get your YouTube videos to show for quest users: Paste a YouTube video URL below, and the server will download and serve a raw mp4 file which you can paste into VRChat. Also useful for whatever other similar scenario.</p>
<label>YouTube URL: <input type="text" placeholder="https://www.youtube.com/watch?v=dQw4w9WgXcQ" id="u" /></label>
<input type="submit" id="s" />
<div id="x"></div>
<p>The <a href="v/">downloads</a> hosted on this server are listed in the frame below (right click to copy urls).</p>
<iframe id="v" src="v/"></iframe>
<footer><small><a href="https://gitea.moe/lamp/ytdl-h" target="_blank">code repository</a> (you can file any issues there)</small></footer>
<script>
var u = document.getElementById("u");
var s = document.getElementById("s");
var x = document.getElementById("x");
var v = document.getElementById("v");
s.onclick = function () {
u.disabled = true;
s.disabled = true;
x.innerText = '';
var ws = new WebSocket(`${location.protocol.replace('http','ws')}//${location.host}/w?url=${encodeURIComponent(u.value)}`);
function p(m) {
x.innerHTML += m + '\n';
x.scrollTop = x.scrollHeight;
}
ws.onerror = function (event) {
p(`<span style="color: red">WebSocket connection failed</span>`);
};
ws.onmessage = function (evt) {
console.log(evt.data)
p("server: " + evt.data);
};
ws.onclose = function () {
u.value = "";
u.disabled = false;
s.disabled = false;
v.src = v.src;
};
};
</script>
<style>
body {
text-align: center;
font-family: sans-serif;
}
#u {
width: 320px;
}
#x {
text-align: left;
white-space: pre;
overflow-y: scroll;
max-height: 160px;
}
#v {
width: 100%;
height: 640px;
border: none;
}
</style>

View File

@ -1,55 +0,0 @@
var getVideoLength = v => require('video-length')(v, {bin: "mediainfo"});
var fs = require("fs");
var cached = {};
async function getList() {
if (!cached.list || Date.now() - cached.list.time > 30000) {
var list = await generateList();
cached.time = Date.now();
cached.list = list;
return list;
}
return cached.list;
}
async function generateList() {
var files = fs.readdirSync("videos");
var list = await Promise.all(files.map(async name => {
var filepath = `videos/${name}`;
var metapath = `./metadata/${name}.json`;
try {
var metadata = require(metapath);
} catch (error) {
var metadata = {};
try {
metadata.duration = await getVideoLength(filepath);
} catch(error) {console.error(error.message)};
fs.writeFileSync(metapath, JSON.stringify(metadata));
}
var {mtime, size} = fs.statSync(filepath);
return {name, mtime, size, duration: metadata.duration};
}));
var aliases = fs.readdirSync("aliases").map(alias => [alias, fs.readFileSync(`aliases/${alias}`,"utf8")]);
for (let [alias, target] of aliases) {
let item = list.find(f => f.name == target);
if (item) {
item.aliases ||= [];
item.aliases.push(alias);
}
}
list = list.sort((a,b) => a.mtime - b.mtime);
return list;
}
function uncacheList() {
cached = {};
}
module.exports = {getList, generateList, uncacheList};

2652
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,10 @@
{
"dependencies": {
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"morgan": "^1.10.0",
"proxy-addr": "^2.0.7",
"qs": "^6.11.1",
"serve-favicon": "^2.5.0",
"video-length": "^2.0.6",
"ws": "^8.13.0"
}
"dependencies": {
"express": "^4.17.3",
"morgan": "^1.10.0",
"proxy-addr": "^2.0.7",
"qs": "^6.10.3",
"serve-index": "^1.9.1",
"ws": "^8.5.0"
}
}

View File

@ -1,129 +0,0 @@
<!DOCTYPE html><html><head>
<meta name="og:title" content="Quest VRChat Video Server" />
<meta name="og:description" content="Import YouTube videos and get short link for Quest VRChat" />
<title>Quest VRChat Video Server</title>
<style>
#url_input {
width: 320px;
}
#alias_input {
width: 64px;
}
#server_output {
max-height: 160px;
white-space: pre;
overflow-y: scroll;
font-family: monospace;
border: 1px gray solid;
padding: 3px;
}
table, th, td {
border: 1px black solid;
border-collapse: collapse;
padding: 3px;
}
</style>
</head><body>
<h1>Quest VRChat Video Server</h1>
<div>Import YouTube videos onto this server to...</div>
<ul>
<li>Make the videos work for your friends on Quest</li>
<li><b>NEW:</b> Get memorable short URLs that you can type in Quest!</li>
</ul>
<p>
<div>Just paste a YouTube video URL here: <input id="url_input" type="text" placeholder="https://www.youtube.com/watch?v=dQw4w9WgXcQ" /></label></div>
<div>and optionally type a short memorable alias for it: <input id="alias_input" type="text" placeholder="rick" maxlength="32" /></div>
<div>and click <input id="submit_button" type="submit" />!</div>
</p>
<p id="server_output"></p>
<script>
var url_input = document.getElementById("url_input");
var alias_input = document.getElementById("alias_input");
var submit_button = document.getElementById("submit_button");
var server_output = document.getElementById("server_output");
submit_button.onclick = function() {
url_input.disabled = true;
alias_input.disabled = true;
submit_button.disabled = true;
server_output.innerText = '';
var ws = new WebSocket(`${location.protocol.replace('http','ws')}//${location.host}/w?url=${encodeURIComponent(url_input.value)}&alias=${encodeURIComponent(alias_input.value)}`);
function print(html) {
server_output.innerHTML += html + '\n';
server_output.scrollTop = server_output.scrollHeight;
}
ws.onerror = function (event) {
print(`<span style="color: red">WebSocket connection failed</span>`);
};
ws.onmessage = function (evt) {
console.log(evt.data)
print(evt.data);
};
ws.onclose = function () {
url_input.value = "";
alias_input.value = "";
url_input.disabled = false;
alias_input.disabled = false;
submit_button.disabled = false;
loadList();
}
}
</script>
<h2>Videos</h2>
<table>
<thead>
<tr>
<th>Short links</th>
<th></th>
<th>File name</th>
<th>Duration</th>
<th>Size</th>
<th>Date added</th>
</tr>
</thead>
<tbody id="tbody">
</tbody>
</table>
<script>
var tbody = document.getElementById("tbody");
function loadList() {
fetch("/api/list").then(res => res.json()).then(list => {
tbody.innerHTML = '';
list.forEach((file, index) => {
var url = `${location.origin}/v/${encodeURIComponent(file.name)}`;
var row = tbody.insertRow(0);
row.insertCell().innerText = [index, ...file.aliases||[]].map(x => `http://${location.host}/${x}`).join('\n');
row.insertCell().innerHTML = `<button title="Copy URL" onclick="navigator.clipboard.writeText('${url}')">📋</button>`;
row.insertCell().innerHTML = `<a href="${url}">${file.name}</a>`;
row.insertCell().innerHTML = `<span title="${file.duration} seconds">${formatDuration(file.duration)}</span>`;
row.insertCell().innerHTML = `<span title="${file.size} bytes">${formatBytes(file.size)}</span>`
row.insertCell().innerHTML = `<span title="${file.mtime}">${new Date(file.mtime).toLocaleString()}</span>`;
});
});
}
loadList();
function formatDuration(seconds) {
var d = new Date(0);
d.setSeconds(seconds);
var hms = d.toISOString().substring(11, 19);
if (hms.startsWith("00:")) hms = hms.substring(3);
return hms;
}
function formatBytes(bytes) {
if (bytes == 0) return "0 B";
var e = Math.floor(Math.log(bytes) / Math.log(1000));
return (bytes / Math.pow(1000, e)).toFixed(2) + ' ' + ' KMGTP'.charAt(e) + 'B';
}
</script>
</body></html>

46
web.js
View File

@ -1,43 +1,15 @@
var express = require("express");
require("express-async-errors");
var serveFavicon = require("serve-favicon");
var serveIndex = require("serve-index");
var morgan = require("morgan");
var {getList} = require("./metalist");
var {readFile} = require("fs/promises");
var http = require("http");
var fs = require("fs");
var app = module.exports.app = express();
app.set("env", process.env.NODE_ENV || "production");
app.set("trust proxy", ["loopback","linklocal","uniquelocal","173.245.48.0/20","103.21.244.0/22","103.22.200.0/22","103.31.4.0/22","141.101.64.0/18","108.162.192.0/18","190.93.240.0/20","188.114.96.0/20","197.234.240.0/22","198.41.128.0/17","162.158.0.0/15","104.16.0.0/13","104.24.0.0/14","172.64.0.0/13","131.0.72.0/22","2400:cb00::/32","2606:4700::/32","2803:f800::/32","2405:b500::/32","2405:8100::/32","2a06:98c0::/29","2c0f:f248::/32"]);
module.exports.server = app.listen(process.env.PORT || 8024, process.env.ADDR);
var app = express();
var server = http.createServer(app);
//try { fs.unlinkSync("web.sock") } catch (e) {}
server.listen(8024, "127.0.0.1");
app.use(morgan(`:date[iso] :req[cf-connecting-ip] :method :url ":req[user-agent]" :referrer`));
app.use(express.static('.'), serveIndex('.', {'icons': true}));
app.use(serveFavicon("favicon.ico"));
app.use("/v/", express.static("videos"));
app.get("/:num", async (req, res, next) => {
var num = Number(req.params.num);
if (isNaN(num)) return next();
var list = await getList();
var item = list[num];
if (!item) return next();
res.sendFile(item.name, {root: "videos"});
});
app.get("/:alias", async (req, res, next) => {
try {
var target = await readFile(`aliases/${req.params.alias}`, "utf8");
if (!target) next();
res.sendFile(target, {root: "videos"});
} catch(error) {
next();
}
});
app.get("/api/list", async (req, res) => {
var list = await getList();
res.send(list);
});
app.use(express.static("public"));
module.exports = server;

38
wss.js
View File

@ -1,35 +1,24 @@
var {WebSocketServer} = require("ws");
var qs = require("qs");
var proxyaddr = require("proxy-addr");
var child_process = require("child_process");
var {app, server} = require("./web");
var {getList, uncacheList} = require("./metalist");
var fs = require("fs");
var wss = new WebSocketServer({ server });
var wss = new WebSocketServer({ server: require("./web") });
wss.on("connection", function(ws, req) {
req.ip = proxyaddr(req, app.get("trust proxy"));
req.query = qs.parse(req.url.substring(req.url.indexOf('?')+1));
var log = (msg, e) => console[e?'error':'log'](`[${new Date().toLocaleString()}] ${req.ip} - ${msg}`);
var log = (msg, e) => console[e?'error':'log'](`[${new Date().toLocaleString()}] ${req.headers["cf-connecting-ip"]} - ${msg}`);
log("socket open: " + req.url);
var yturl = req.query.url;
if (!yturl) { ws.send(color("red", "missing url")); ws.close(); return }
var alias = req.query.alias;
if (alias) {
alias = alias.toLowerCase();
if (!/^[a-z0-9-_]{1,32}$/.test(alias)) { ws.send(color("red", "alias must be no more than 32 chars of only letters, numbers, hypens or underscores.")); ws.close(); return }
if (fs.existsSync(`aliases/${alias}`)) { ws.send(color("red", `the alias ${alias} already exists.`)); ws.close(); return }
}
var ytid = yturl.match(/https:\/\/(?:(?:www\.)?youtube\.com\/(?:watch\?v=|shorts\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/)?.[1];
if (!ytid) { ws.send(color("red", "youtube url not valid")); ws.close(); return }
ws.send(color("green", "spawning yt-dlp..."));
var cp = child_process.spawn("yt-dlp", ["--format=mp4", "--max-filesize=1G", `--match-filter=duration<7200`, "--no-mtime", `https://www.youtube.com/watch?v=${ytid}`], {cwd: "videos", shell: false});
var cp = child_process.spawn("yt-dlp", ["--format=mp4", "--max-filesize=1G", `--match-filter=duration<7200`, "--no-mtime", `https://www.youtube.com/watch?v=${ytid}`], {cwd: "v", shell: false});
cp.on("error", error => {
log(error.message, true);
@ -52,17 +41,11 @@ wss.on("connection", function(ws, req) {
cp.on("close", (code, signal) => {
log(`cp exit ${code} (${signal})`);
ws.send(color("green", `yt-dlp exited with code ${code}`));
let files = fs.readdirSync("videos");
let file = files.find(file => file.includes(`[${ytid}]`));
if (file) {
if (alias) fs.writeFileSync(`aliases/${alias}`, file);
let url = `http://${req.headers.host}/v/${file}`;
ws.send(`MP4 URL (<button onclick="navigator.clipboard.writeText(\`${url}\`)">Copy</button>): <a href="${url}">${url}</a>`)
} else {
ws.send(color("red", "error: couldn't find file to alias"));
var f = findFileForYtid(ytid);``
if (f) {
var g = `https://${req.headers.host}/v/${encodeURIComponent(f)}`;
ws.send(`MP4 URL (<button onclick="navigator.clipboard.writeText(\`${g}\`)">Copy</button>): <a href="${g}" target="_blank">${g}</a>`)
}
uncacheList();
ws.close();
});
@ -75,3 +58,10 @@ wss.on("connection", function(ws, req) {
function color(color, text) {
return `<span style="color: ${color}">${text}</span>`;
}
function findFileForYtid(ytid) {
var d = fs.readdirSync("v");
var e = `[${ytid}].mp4`
var f = d.find(f => f.endsWith(e));
return f;
}