Compare commits

..

No commits in common. "v2" and "v1" have entirely different histories.
v2 ... v1

11 changed files with 128 additions and 141 deletions

View File

@ -1,3 +0,0 @@
node_modules
downloads
metadata

3
.gitignore vendored
View File

@ -1,3 +1,4 @@
node_modules node_modules
downloads videos
metadata metadata
aliases

View File

@ -1,13 +0,0 @@
FROM alpine:3.20
RUN adduser -S ytdl
RUN apk add --no-cache nodejs npm python3 py3-pip ffmpeg mediainfo make g++
ADD . /app
WORKDIR /app
RUN npm ci
ENV DOWNLOADS=/downloads
RUN mkdir -p $DOWNLOADS/metadata; chown -R ytdl $DOWNLOADS
USER ytdl
ENV PATH="/home/ytdl/.local/bin:$PATH"
VOLUME $DOWNLOADS
EXPOSE 8080
CMD ["sh", "-c", "pip install --break-system-packages --upgrade yt-dlp; exec node ."]

View File

@ -1,5 +1,2 @@
process.env.DOWNLOADS ||= "downloads";
process.env.PORT ||= 8080;
require("./web.js") require("./web.js")
require("./wss.js") require("./wss.js")

View File

@ -1,27 +1,26 @@
var getVideoLength = v => require('video-length')(v, {bin: "mediainfo"}); var getVideoLength = v => require('video-length')(v, {bin: "mediainfo"});
var getDiskInfo = require('diskusage').check;
var fs = require("fs"); var fs = require("fs");
var path = require("path");
var cached = {}; var cached = {};
async function getList() { async function getList() {
if (!cached.list || Date.now() - cached.time > 30000) { if (!cached.list || Date.now() - cached.list.time > 30000) {
cached.list = await generateList(); var list = await generateList();
cached.diskinfo = await getDiskInfo(process.env.DOWNLOADS);
cached.time = Date.now(); cached.time = Date.now();
cached.list = list;
return list;
} }
return cached; return cached.list;
} }
async function generateList() { async function generateList() {
var files = fs.readdirSync(process.env.DOWNLOADS, {withFileTypes: true}).filter(f => f.isFile()).map(f => f.name); var files = fs.readdirSync("videos");
var list = await Promise.all(files.map(async name => { var list = await Promise.all(files.map(async name => {
var filepath = path.join(process.env.DOWNLOADS, name); var filepath = `videos/${name}`;
var metapath = path.join(process.env.DOWNLOADS, "metadata", name) + ".json"; var metapath = `./metadata/${name}.json`;
try { try {
var metadata = JSON.parse(fs.readFileSync(metapath, "utf8")); var metadata = require(metapath);
} catch (error) { } catch (error) {
var metadata = {}; var metadata = {};
try { try {
@ -34,6 +33,15 @@ async function generateList() {
return {name, mtime, size, duration: metadata.duration}; 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); list = list.sort((a,b) => a.mtime - b.mtime);
return list; return list;
} }

40
package-lock.json generated
View File

@ -5,7 +5,6 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"diskusage": "^1.2.0",
"express": "^4.18.2", "express": "^4.18.2",
"express-async-errors": "^3.1.1", "express-async-errors": "^3.1.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
@ -174,16 +173,6 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/diskusage": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/diskusage/-/diskusage-1.2.0.tgz",
"integrity": "sha512-2u3OG3xuf5MFyzc4MctNRUKjjwK+UkovRYdD2ed/NZNZPrt0lqHnLKxGhlFVvAb4/oufIgQG3nWgwmeTbHOvXA==",
"hasInstallScript": true,
"dependencies": {
"es6-promise": "^4.2.8",
"nan": "^2.18.0"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -197,11 +186,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
},
"node_modules/escape-html": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@ -531,11 +515,6 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
}, },
"node_modules/nan": {
"version": "2.18.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz",
"integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w=="
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@ -960,15 +939,6 @@
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="
}, },
"diskusage": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/diskusage/-/diskusage-1.2.0.tgz",
"integrity": "sha512-2u3OG3xuf5MFyzc4MctNRUKjjwK+UkovRYdD2ed/NZNZPrt0lqHnLKxGhlFVvAb4/oufIgQG3nWgwmeTbHOvXA==",
"requires": {
"es6-promise": "^4.2.8",
"nan": "^2.18.0"
}
},
"ee-first": { "ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -979,11 +949,6 @@
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="
}, },
"es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
},
"escape-html": { "escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@ -1232,11 +1197,6 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
}, },
"nan": {
"version": "2.18.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz",
"integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w=="
},
"negotiator": { "negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",

View File

@ -1,6 +1,5 @@
{ {
"dependencies": { "dependencies": {
"diskusage": "^1.2.0",
"express": "^4.18.2", "express": "^4.18.2",
"express-async-errors": "^3.1.1", "express-async-errors": "^3.1.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",

View File

@ -1,5 +1,7 @@
<!DOCTYPE html><html><head> <!DOCTYPE html><html><head>
<title>ytdl server</title> <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> <style>
#url_input { #url_input {
@ -26,22 +28,31 @@
</style> </style>
</head><body> </head><body>
<h1>ytdl server</h1> <h1>Quest VRChat Video Server</h1>
<div>paste url from one of <a href="https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md" target="_blank">supported sites</a> to import and serve best quality media file for things like vrchat where a streamable url is required.</div> <div>Import YouTube videos onto this server to...</div>
<ul>
<li>Make the videos work for your friends on Quest (allow untrusted URLs)</li>
<li><b>NEW:</b> Get memorable short URLs that you can type in Quest!</li>
</ul>
<p> <p>
<div>url: <input id="url_input" type="text" placeholder="https://www.youtube.com/watch?v=dQw4w9WgXcQ" /></label><input id="submit_button" type="submit" /></div> <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>
<p id="server_output"></p> <p id="server_output"></p>
<p><b>NEW:</b> Watch YouTube on Quest WITHOUT PC friends or memorized URLs with <a href="https://www.u2b.cx/">u2b.cx</a>!</p>
<script> <script>
var url_input = document.getElementById("url_input"); var url_input = document.getElementById("url_input");
var alias_input = document.getElementById("alias_input");
var submit_button = document.getElementById("submit_button"); var submit_button = document.getElementById("submit_button");
var server_output = document.getElementById("server_output"); var server_output = document.getElementById("server_output");
submit_button.onclick = function() { submit_button.onclick = function() {
url_input.disabled = true; url_input.disabled = true;
alias_input.disabled = true;
submit_button.disabled = true; submit_button.disabled = true;
server_output.innerText = ''; server_output.innerText = '';
var ws = new WebSocket(`${location.protocol.replace('http','ws')}//${location.host}/w?url=${encodeURIComponent(url_input.value)}`); 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) { function print(html) {
server_output.innerHTML += html + '\n'; server_output.innerHTML += html + '\n';
server_output.scrollTop = server_output.scrollHeight; server_output.scrollTop = server_output.scrollHeight;
@ -55,17 +66,20 @@
}; };
ws.onclose = function () { ws.onclose = function () {
url_input.value = ""; url_input.value = "";
alias_input.value = "";
url_input.disabled = false; url_input.disabled = false;
alias_input.disabled = false;
submit_button.disabled = false; submit_button.disabled = false;
loadList(); loadList();
} }
} }
</script> </script>
<h2>Downloads</h2> <h2>Videos</h2>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Short links</th>
<th></th> <th></th>
<th>File name</th> <th>File name</th>
<th>Duration</th> <th>Duration</th>
@ -75,29 +89,30 @@
</thead> </thead>
<tbody id="tbody"> <tbody id="tbody">
</tbody> </tbody>
</table> </table>
<p id="diskinfo"></p>
<script> <script>
var tbody = document.getElementById("tbody");
function loadList() { function loadList() {
fetch("/api/list").then(res => res.json()).then(data => { fetch("/api/list").then(res => res.json()).then(list => {
tbody.innerHTML = ''; tbody.innerHTML = '';
data.list.forEach((file, index) => { list.forEach((file, index) => {
var url = `${location.origin}/v/${encodeURIComponent(file.name)}`; var url = `${location.origin}/v/${encodeURIComponent(file.name)}`;
var row = tbody.insertRow(0); var row = tbody.insertRow(0);
row.insertCell().innerText = [index, ...file.aliases||[]].map(x => `${location.origin}/${x}`).join('\n');
row.insertCell().innerHTML = `<button title="Copy URL" onclick="navigator.clipboard.writeText('${url}')">📋</button>`; 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 = `<a href="${url}">${file.name}</a>`;
row.insertCell().innerHTML = `<span title="${file.duration} seconds">${formatDuration(file.duration)}</span>`; 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.size} bytes">${formatBytes(file.size)}</span>`
row.insertCell().innerHTML = `<span title="${file.mtime}">${new Date(file.mtime).toLocaleString()}</span>`; row.insertCell().innerHTML = `<span title="${file.mtime}">${new Date(file.mtime).toLocaleString()}</span>`;
}); });
diskinfo.innerText = `Disk free: ${formatBytes(data.diskinfo.available)}`;
}); });
} }
loadList(); loadList();
function formatDuration(seconds) { function formatDuration(seconds) {
if (!seconds) return "?";
var d = new Date(0); var d = new Date(0);
d.setSeconds(seconds); d.setSeconds(seconds);
var hms = d.toISOString().substring(11, 19); var hms = d.toISOString().substring(11, 19);

View File

@ -1,3 +0,0 @@
docker build -t ytdl-server .
docker run -d --restart=unless-stopped --name ytdl-server -p 8660:8080 -v "/zpool1/fungible/ytdls:/downloads" ytdl-server

29
web.js
View File

@ -3,20 +3,41 @@ require("express-async-errors");
var serveFavicon = require("serve-favicon"); var serveFavicon = require("serve-favicon");
var morgan = require("morgan"); var morgan = require("morgan");
var {getList} = require("./metalist"); var {getList} = require("./metalist");
var {readFile} = require("fs/promises");
var app = module.exports.app = express(); var app = module.exports.app = express();
app.set("env", process.env.NODE_ENV || "production"); 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"]); 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, process.env.ADDR); module.exports.server = app.listen(process.env.PORT || 8024, process.env.ADDR);
app.use(morgan(`:date[iso] :remote-addr :method :url ":req[user-agent]" :referrer`)); app.use(morgan(`:date[iso] :req[cf-connecting-ip] :method :url ":req[user-agent]" :referrer`));
app.use(serveFavicon("favicon.ico")); app.use(serveFavicon("favicon.ico"));
app.use("/v/", express.static(process.env.DOWNLOADS)); 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) => { app.get("/api/list", async (req, res) => {
res.send(await getList()); var list = await getList();
res.send(list);
}); });
app.use(express.static("public")); app.use(express.static("public"));

65
wss.js
View File

@ -3,9 +3,8 @@ var qs = require("qs");
var proxyaddr = require("proxy-addr"); var proxyaddr = require("proxy-addr");
var child_process = require("child_process"); var child_process = require("child_process");
var {app, server} = require("./web"); var {app, server} = require("./web");
var {uncacheList} = require("./metalist"); var {getList, uncacheList} = require("./metalist");
var fs = require("fs");
var lastUpdate = Date.now();
var wss = new WebSocketServer({ server }); var wss = new WebSocketServer({ server });
@ -16,30 +15,21 @@ wss.on("connection", function(ws, req) {
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.ip} - ${msg}`);
log("socket open: " + req.url); log("socket open: " + req.url);
var url = req.query.url; var yturl = req.query.url;
if (!url) { ws.send(color("red", "missing url")); ws.close(); return } if (!yturl) { ws.send(color("red", "missing url")); ws.close(); return }
if (Date.now() - lastUpdate > 8.64e7) { var alias = req.query.alias;
ws.send("updating yt-dlp..."); if (alias) {
var cp = makeCp("pip", ["install", "--break-system-packages", "--upgrade", "yt-dlp"]); alias = alias.toLowerCase();
cp.on("close", next); 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 }
lastUpdate = Date.now(); if (fs.existsSync(`aliases/${alias}`)) { ws.send(color("red", `the alias ${alias} already exists.`)); ws.close(); return }
} else next();
function next() {
var cp = makeCp("yt-dlp", ["--no-mtime", url], {cwd: process.env.DOWNLOADS, shell: false});
cp.on("close", (code, signal) => {
uncacheList();
ws.close();
});
ws.on("close", () => {
log("socket closed");
});
} }
function makeCp(command, args, options) { var ytid = yturl.match(/https:\/\/(?:(?:www\.)?youtube\.com\/(?:watch\?v=|shorts\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/)?.[1];
ws.send(color("green", `spawning ${command}...`)); if (!ytid) { ws.send(color("red", "youtube url not valid")); ws.close(); return }
var cp = child_process.spawn(command, args, options);
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});
cp.on("error", error => { cp.on("error", error => {
log(error.message, true); log(error.message, true);
@ -49,22 +39,37 @@ wss.on("connection", function(ws, req) {
var msg = data.toString().trim(); var msg = data.toString().trim();
if (msg) { if (msg) {
log(msg); log(msg);
ws.send(command + ": " + color("blue", msg)); ws.send("yt-dlp: " + color("blue", msg));
} }
}); });
cp.stderr.on("data", data => { cp.stderr.on("data", data => {
var msg = data.toString().trim(); var msg = data.toString().trim();
if (msg) { if (msg) {
log(msg, true); log(msg, true);
ws.send(color("orange", command + ": " + color("blue", msg))); ws.send(color("orange", "yt-dlp: " + color("blue", msg)));
} }
}); });
cp.on("close", (code, signal) => { cp.on("close", (code, signal) => {
log(`${command} exit ${code} (${signal})`); log(`cp exit ${code} (${signal})`);
ws.send(color("green", `${command} exited with code ${code}`)); ws.send(color("green", `yt-dlp exited with code ${code}`));
}); let files = fs.readdirSync("videos");
return cp; let file = files.find(file => file.includes(`[${ytid}]`));
if (file) {
if (alias) fs.writeFileSync(`aliases/${alias}`, file);
let url = `https://${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();
});
ws.on("close", () => {
log("socket closed");
cp.kill();
});
}); });
function color(color, text) { function color(color, text) {