Compare commits

...

4 Commits

Author SHA1 Message Date
Lamp 4b67a1a11c i guess done 2023-03-18 21:14:15 -07:00
Lamp b179c9ddca html good 2023-03-18 20:21:45 -07:00
Lamp 4fc1a31c75 i think i will cancel pug 2023-03-18 17:49:49 -07:00
Lamp cf675cb3f8 wip rewrite 2023-03-18 13:23:17 -07:00
8 changed files with 1668 additions and 1339 deletions

4
.gitignore vendored
View File

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

View File

@ -1,65 +0,0 @@
<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>

55
metalist.js Normal file
View File

@ -0,0 +1,55 @@
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,10 +1,12 @@
{
"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"
}
"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"
}
}

129
public/index.html Normal file
View File

@ -0,0 +1,129 @@
<!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,15 +1,43 @@
var express = require("express");
var serveIndex = require("serve-index");
require("express-async-errors");
var serveFavicon = require("serve-favicon");
var morgan = require("morgan");
var http = require("http");
var fs = require("fs");
var {getList} = require("./metalist");
var {readFile} = require("fs/promises");
var app = express();
var server = http.createServer(app);
//try { fs.unlinkSync("web.sock") } catch (e) {}
server.listen(8024, "127.0.0.1");
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);
app.use(morgan(`:date[iso] :req[cf-connecting-ip] :method :url ":req[user-agent]" :referrer`));
app.use(express.static('.'), serveIndex('.', {'icons': true}));
module.exports = server;
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"));

38
wss.js
View File

@ -1,24 +1,35 @@
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: require("./web") });
var wss = new WebSocketServer({ server });
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.headers["cf-connecting-ip"]} - ${msg}`);
var log = (msg, e) => console[e?'error':'log'](`[${new Date().toLocaleString()}] ${req.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: "v", 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: "videos", shell: false});
cp.on("error", error => {
log(error.message, true);
@ -41,11 +52,17 @@ 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}`));
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>`)
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"));
}
uncacheList();
ws.close();
});
@ -58,10 +75,3 @@ 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;
}