forked from sfja.skp/maotube
Compare commits
15 Commits
Author | SHA1 | Date | |
---|---|---|---|
29a98beb3c | |||
1cc2908933 | |||
3f1648da32 | |||
07ba96295d | |||
cd124f916e | |||
a9b0c2a602 | |||
8852a6f0c3 | |||
52e07b54dc | |||
8a54b3168a | |||
3c9311aef7 | |||
c3314c440a | |||
9fb560fc9a | |||
ba3785df0c | |||
05077cb19a | |||
697f1818ab |
2
.gitignore
vendored
2
.gitignore
vendored
@ -2,3 +2,5 @@ node_modules/
|
|||||||
videos/*
|
videos/*
|
||||||
!videos/.gitkeep
|
!videos/.gitkeep
|
||||||
tmp/
|
tmp/
|
||||||
|
database.sqlite3
|
||||||
|
|
||||||
|
336
index.js
336
index.js
@ -6,11 +6,48 @@ import path from "path";
|
|||||||
import childProcess from "child_process";
|
import childProcess from "child_process";
|
||||||
import levenshtein from "js-levenshtein";
|
import levenshtein from "js-levenshtein";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from "url";
|
||||||
|
import sqlite3 from "sqlite3";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
|
||||||
|
const PORT = 8000;
|
||||||
|
|
||||||
const users = [];
|
|
||||||
let sessions = [];
|
let sessions = [];
|
||||||
const videos = [];
|
let videoQueue = [];
|
||||||
|
|
||||||
|
const db = new sqlite3.Database("database.sqlite3");
|
||||||
|
|
||||||
|
function dbGet(query, ...parameters) {
|
||||||
|
return new Promise((resolve, reject) => db.get(query, parameters, (err, data) => err ? reject(err) : resolve(data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbAll(query, ...parameters) {
|
||||||
|
return new Promise((resolve, reject) => db.all(query, parameters, (err, data) => err ? reject(err) : resolve(data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbRun(query, ...parameters) {
|
||||||
|
return new Promise((resolve, reject) => db.run(query, parameters, (err, data) => err ? reject(err) : resolve(data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(cmd, args) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const process = childProcess.spawn(cmd, args);
|
||||||
|
|
||||||
|
let data = "";
|
||||||
|
let error = false;
|
||||||
|
process.stdout.on("data", chunk => data += chunk);
|
||||||
|
process.stderr.on("data", chunk => {
|
||||||
|
error = true;
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("close", () => resolve({ error, data }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoPath(id) {
|
||||||
|
return `videos/${id}.webm`;
|
||||||
|
}
|
||||||
|
|
||||||
function randomString(length) {
|
function randomString(length) {
|
||||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789";
|
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789";
|
||||||
@ -25,77 +62,8 @@ function dirname() {
|
|||||||
return path.dirname(fileURLToPath(import.meta.url));
|
return path.dirname(fileURLToPath(import.meta.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(cookieParser());
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
|
||||||
|
|
||||||
app.use("/", express.static("public/"));
|
|
||||||
app.use("/videos", express.static("videos/"));
|
|
||||||
|
|
||||||
app.post("/api/register", async (req, res) => {
|
|
||||||
const { username, password } = req.body;
|
|
||||||
if (typeof username !== "string" || typeof password !== "string") {
|
|
||||||
return res.status(400).json({ ok: false, error: "bad request" });
|
|
||||||
}
|
|
||||||
const existingUser = users.find(user => user.username === username);
|
|
||||||
if (existingUser !== undefined) {
|
|
||||||
return res.status(400).json({ ok: false, error: "username taken" });
|
|
||||||
}
|
|
||||||
const passwordHash = await bcrypt.hash(password, 10);
|
|
||||||
const id = users.length;
|
|
||||||
const user = { id, username, passwordHash };
|
|
||||||
users.push(user);
|
|
||||||
return res.status(200).json({ ok: true, user });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/api/login", async (req, res) => {
|
|
||||||
const { username, password } = req.body;
|
|
||||||
if (typeof username !== "string" || typeof password !== "string") {
|
|
||||||
return res.status(400).json({ ok: false, error: "bad request" });
|
|
||||||
}
|
|
||||||
const user = users.find(user => user.username === username);
|
|
||||||
if (user === undefined) {
|
|
||||||
return res.status(400).json({ ok: false, error: "wrong username/password" });
|
|
||||||
}
|
|
||||||
if (!await bcrypt.compare(password, user.passwordHash)) {
|
|
||||||
return res.status(400).json({ ok: false, error: "wrong username/password" });
|
|
||||||
}
|
|
||||||
sessions = sessions.filter(session => session.userId !== user.id);
|
|
||||||
const token = randomString(64);
|
|
||||||
const session = { userId: user.id, token };
|
|
||||||
sessions.push(session);
|
|
||||||
res.clearCookie("token");
|
|
||||||
res.cookie("token", token);
|
|
||||||
return res.status(200).json({ ok: true, session });
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
app.get("/api/search", async (req, res) => {
|
|
||||||
const page = +req.query.page || 0;
|
|
||||||
const search = req.query.query;
|
|
||||||
if (!search) {
|
|
||||||
return res.status(400).json({ ok: false, error: "bad request" });
|
|
||||||
}
|
|
||||||
const [start, end] = [20 * page, 20 * (page + 1)];
|
|
||||||
const withDistance = videos
|
|
||||||
.map(video => ({ dist: levenshtein(search, video.title), ...video }));
|
|
||||||
withDistance.sort((a, b) => a.dist - b.dist);
|
|
||||||
const returnedVideos = withDistance
|
|
||||||
.slice(start, end)
|
|
||||||
.map(video => {
|
|
||||||
const user = users.find(user => user.id === video.userId);
|
|
||||||
if (!user) {
|
|
||||||
return { ...video, author: "[Liberal]" };
|
|
||||||
}
|
|
||||||
return { ...video, author: user.username };
|
|
||||||
});
|
|
||||||
return res.status(200).json({ ok: true, videos: returnedVideos, total: videos.length });
|
|
||||||
});
|
|
||||||
|
|
||||||
function authorized() {
|
function authorized() {
|
||||||
return (req, res, next) => {
|
return async (req, res, next) => {
|
||||||
const token = (() => {
|
const token = (() => {
|
||||||
if (req.cookies && "token" in req.cookies) {
|
if (req.cookies && "token" in req.cookies) {
|
||||||
return req.cookies["token"];
|
return req.cookies["token"];
|
||||||
@ -114,7 +82,7 @@ function authorized() {
|
|||||||
if (session === undefined) {
|
if (session === undefined) {
|
||||||
return res.status(400).json({ ok: false, error: "unauthorized" });
|
return res.status(400).json({ ok: false, error: "unauthorized" });
|
||||||
}
|
}
|
||||||
const user = users.find(user => user.id === session.userId);
|
const user = await dbGet("SELECT * FROM users WHERE id = ?", session.userId);
|
||||||
if (user === undefined) {
|
if (user === undefined) {
|
||||||
throw new Error("error: session with invalid userId");
|
throw new Error("error: session with invalid userId");
|
||||||
}
|
}
|
||||||
@ -123,53 +91,225 @@ function authorized() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
app.use("/", express.static("public/"));
|
||||||
|
app.use("/videos", express.static("videos/"));
|
||||||
|
|
||||||
|
app.post("/api/register", async (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
if (typeof username !== "string" || typeof password !== "string") {
|
||||||
|
return res.status(400).json({ ok: false, error: "bad request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await dbGet("SELECT * FROM users WHERE username = ?", username);
|
||||||
|
|
||||||
|
if (existingUser !== undefined) {
|
||||||
|
return res.status(400).json({ ok: false, error: "username taken" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
await dbGet("INSERT INTO users (username, password) VALUES (?, ?)", username, passwordHash);
|
||||||
|
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/login", async (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
if (typeof username !== "string" || typeof password !== "string") {
|
||||||
|
return res.status(400).json({ ok: false, error: "bad request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await dbGet("SELECT * FROM users WHERE username = ?", username);
|
||||||
|
|
||||||
|
if (user === undefined) {
|
||||||
|
return res.status(400).json({ ok: false, error: "wrong username/password" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await bcrypt.compare(password, user.password)) {
|
||||||
|
return res.status(400).json({ ok: false, error: "wrong username/password" });
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions = sessions.filter(session => session.userId !== user.id);
|
||||||
|
|
||||||
|
const token = randomString(64);
|
||||||
|
const session = { userId: user.id, token };
|
||||||
|
|
||||||
|
sessions.push(session);
|
||||||
|
|
||||||
|
res.clearCookie("token");
|
||||||
|
res.cookie("token", token);
|
||||||
|
|
||||||
|
return res.status(200).json({ ok: true, session });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
app.get("/api/search", async (req, res) => {
|
||||||
|
const page = +req.query.page || 0;
|
||||||
|
const search = req.query.query;
|
||||||
|
|
||||||
|
if (!search) {
|
||||||
|
return res.status(400).json({ ok: false, error: "bad request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [start, end] = [20 * page, 20];
|
||||||
|
const videos = await dbAll(`
|
||||||
|
SELECT videos.*, users.username AS author
|
||||||
|
FROM videos
|
||||||
|
JOIN users ON users.id = videos.user_id
|
||||||
|
WHERE title LIKE CONCAT('%', ?, '%')
|
||||||
|
LIMIT ?
|
||||||
|
OFFSET ?
|
||||||
|
`, search, end, start);
|
||||||
|
|
||||||
|
const { total } = await dbGet("SELECT COUNT(*) AS total FROM videos");
|
||||||
|
|
||||||
|
return res.status(200).json({ ok: true, videos, total });
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/logout", authorized(), (req, res) => {
|
app.get("/api/logout", authorized(), (req, res) => {
|
||||||
sessions = sessions.filter(session => session.userId !== req.user.id);
|
sessions = sessions.filter(session => session.userId !== req.user.id);
|
||||||
return res.status(200).json({ ok: true });
|
return res.status(200).json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/upload_video", authorized(), fileUpload({ limits: { fileSize: 2 ** 26 }, useTempFiles: true }), async (req, res) => {
|
app.post("/api/upload-video", authorized(), fileUpload({ limits: { fileSize: 2 ** 26 }, useTempFiles: true }), async (req, res) => {
|
||||||
const { title } = req.body;
|
const { title, description } = req.body;
|
||||||
|
if (!title) {
|
||||||
|
return res.status(400).json({ ok: false, error: "bad request" });
|
||||||
|
}
|
||||||
|
|
||||||
if (!req.files || !req.files.video) {
|
if (!req.files || !req.files.video) {
|
||||||
return res.status(400).json({ ok: false, error: "bad request" });
|
return res.status(400).json({ ok: false, error: "bad request" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.files.video.mimetype !== "video/mp4") {
|
if (req.files.video.mimetype !== "video/mp4") {
|
||||||
return res.status(400).json({ ok: false, error: "bad mimetype" });
|
return res.status(415).json({ ok: false, error: "bad mimetype" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const id = randomString(4);
|
const id = randomString(4);
|
||||||
const tempPath = req.files.video.tempFilePath;
|
const tempPath = req.files.video.tempFilePath;
|
||||||
const newPath = path.join(dirname(), "videos", `${id}.mp4`);
|
const newPath = path.join(dirname(), videoPath(id));
|
||||||
console.log(newPath);
|
const thumbnailPath = path.join(dirname(), "videos", `${id}.png`);
|
||||||
|
|
||||||
const exitCode = await new Promise((resolve, _reject) => {
|
const durationResult = await runCommand("ffprobe", ["-i", tempPath, "-show_format", "-loglevel", "error"]);
|
||||||
const process = childProcess.spawn("HandBrakeCLI", ["-i", tempPath, "-o", newPath, "-Z", "Social 25 MB 5 Minutes 360p60"]);
|
if (durationResult.error) {
|
||||||
process.stderr.on("data", (data) => {
|
return res.status(400).json({ ok: false, error: "invalid video file" });
|
||||||
console.error(data.toString());
|
|
||||||
});
|
|
||||||
process.on("close", (code) => {
|
|
||||||
resolve(code);
|
|
||||||
})
|
|
||||||
})
|
|
||||||
if (exitCode !== 0) {
|
|
||||||
throw new Error("handbrake failed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const video = {
|
const duration = parseFloat(durationResult.data.match(/duration=([.\d]+)/)[1]) * 1_000_000;;
|
||||||
id,
|
|
||||||
|
const queueItem = {
|
||||||
|
videoId: id,
|
||||||
userId,
|
userId,
|
||||||
title,
|
title,
|
||||||
path: newPath,
|
errors: [],
|
||||||
|
progress: 0,
|
||||||
|
duration,
|
||||||
};
|
};
|
||||||
videos.push(video);
|
|
||||||
|
videoQueue.push(queueItem);
|
||||||
|
|
||||||
|
const progressPath = path.join(dirname(), "tmp", "progress", `${id}.txt`)
|
||||||
|
const uploadProcess = childProcess.spawn("ffmpeg", ["-i", tempPath, "-b:v", "1M", "-b:a", "192k", "-progress", progressPath, "-loglevel", "error", newPath]);
|
||||||
|
const thumbnailProcess = childProcess.spawn("ffmpeg", ["-i", tempPath, "-ss", "00:00:01.000", "-vframes", "1", "-loglevel", "error", thumbnailPath]);
|
||||||
|
|
||||||
|
uploadProcess.stderr.on("data", data => queueItem.errors.push(data.toString()));
|
||||||
|
thumbnailProcess.stderr.on("data", data => queueItem.errors.push(data.toString()));
|
||||||
|
|
||||||
|
uploadProcess.on("close", () => {
|
||||||
|
if (queueItem.errors.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dbRun(
|
||||||
|
"INSERT INTO videos (id, user_id, title, description, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
id, userId, title, description ?? "", new Date().toISOString()
|
||||||
|
);
|
||||||
|
|
||||||
|
const index = videoQueue.indexOf(item => item.videoId === queueItem.videoId)
|
||||||
|
videoQueue.splice(index, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/video-queue", authorized(), (req, res) => {
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
const queue = videoQueue.filter(item => item.userId === userId);
|
||||||
|
|
||||||
|
return res.status(200).json({ ok: true, queue });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/video-info", async (req, res) => {
|
||||||
|
const id = req.query["id"];
|
||||||
|
|
||||||
|
const video = await dbGet(`
|
||||||
|
SELECT videos.id, videos.title, videos.description, videos.created_at, users.username AS author
|
||||||
|
FROM videos
|
||||||
|
JOIN users ON users.id = videos.user_id
|
||||||
|
WHERE videos.id = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, id);
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return res.status(404).json({ ok: false, error: "video not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
video.path = path.join("/", videoPath(id));
|
||||||
|
|
||||||
return res.status(200).json({ ok: true, video });
|
return res.status(200).json({ ok: true, video });
|
||||||
})
|
});
|
||||||
|
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(500).json({ ok: false, error: "server error" })
|
res.status(500).json({ ok: false, error: "server error" })
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(8000, () => {
|
async function main() {
|
||||||
console.log("app at http://localhost:8000/");
|
(async () => {
|
||||||
})
|
const progressPath = path.join(dirname(), "tmp", "progress");
|
||||||
|
|
||||||
|
await fs.mkdir(progressPath, { recursive: true });
|
||||||
|
|
||||||
|
const watch = fs.watch(progressPath, { recursive: true });
|
||||||
|
|
||||||
|
for await (const event of watch) {
|
||||||
|
const videoId = event.filename.replace(".txt", "");
|
||||||
|
const queueItem = videoQueue.find(item => item.videoId === videoId);
|
||||||
|
|
||||||
|
if (!queueItem) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.readFile(path.join(progressPath, event.filename), "utf-8")
|
||||||
|
.then(string => {
|
||||||
|
const index = string.lastIndexOf("out_time_ms=");
|
||||||
|
if (index === -1) return;
|
||||||
|
|
||||||
|
const length = parseInt(string.slice(index).match(/out_time_ms=(\d+)/)[1]);
|
||||||
|
|
||||||
|
for (const i in videoQueue) {
|
||||||
|
if (videoQueue[i].videoId !== videoId) continue;
|
||||||
|
|
||||||
|
videoQueue[i].progress = length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log("app at http://localhost:8000/");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
||||||
|
15
migration.sql
Normal file
15
migration.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE videos (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
998
package-lock.json
generated
998
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-fileupload": "^1.4.3",
|
"express-fileupload": "^1.4.3",
|
||||||
"js-levenshtein": "^1.1.6"
|
"js-levenshtein": "^1.1.6",
|
||||||
|
"sqlite3": "^5.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,39 +3,42 @@ function displayHeader() {
|
|||||||
{
|
{
|
||||||
href: "/",
|
href: "/",
|
||||||
name: "Home",
|
name: "Home",
|
||||||
|
accessKey: "h",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/register/",
|
href: "/register/",
|
||||||
name: "Register",
|
name: "Register",
|
||||||
|
accessKey: "r",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/login/",
|
href: "/login/",
|
||||||
name: "Login",
|
name: "Login",
|
||||||
|
accessKey: "l",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/upload/",
|
href: "/upload/",
|
||||||
name: "Upload",
|
name: "Upload",
|
||||||
|
accessKey: "u",
|
||||||
},
|
},
|
||||||
].map(({name, href}) => {
|
].map(({name, href, accessKey}) => {
|
||||||
if (href === window.location.pathname) {
|
if (href === window.location.pathname) {
|
||||||
return `<a href="${href}"><b>${name}</b></a>`
|
return `<a href="${href}" accesskey="${accessKey}"><b>${name}</b></a>`
|
||||||
} else {
|
} else {
|
||||||
return `<a href="${href}">${name}</a>`
|
return `<a href="${href}" accesskey="${accessKey}">${name}</a>`
|
||||||
}
|
}
|
||||||
}).join(" - ");
|
}).join(" - ");
|
||||||
|
|
||||||
document.querySelector("h1").outerHTML = `
|
document.querySelector("h1").outerHTML = `
|
||||||
<header>
|
<header>
|
||||||
<a href="/">
|
<h1 id="app-name">MaoTube</h1>
|
||||||
<h1 id="app-name">MaoTube</h1>
|
|
||||||
</a>
|
|
||||||
<nav>
|
<nav>
|
||||||
${links}
|
${links}
|
||||||
|
-
|
||||||
|
<form id="search-form" method="GET" target="_self" action="/search">
|
||||||
|
<input type="text" id="search" name="query" placeholder="Search" accesskey="s">
|
||||||
|
<input type="submit" value="Search">
|
||||||
|
</form>
|
||||||
</nav>
|
</nav>
|
||||||
<form method="GET" target="_self" action="/search">
|
|
||||||
<input type="text" id="search" name="query" placeholder="Search">
|
|
||||||
<input type="submit" value="Search">
|
|
||||||
</form>
|
|
||||||
</header>
|
</header>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
8
public/helpers.js
Normal file
8
public/helpers.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
function error(message) {
|
||||||
|
const errorContainer = document.getElementById("mao-error");
|
||||||
|
const errorElement = document.getElementById("mao-error-message");
|
||||||
|
|
||||||
|
errorElement.innerText = message || "unknown error";
|
||||||
|
errorContainer.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@
|
|||||||
<h1>MaoTube</h1>
|
<h1>MaoTube</h1>
|
||||||
<div>
|
<div>
|
||||||
<label for="username"><p>Username</p></label>
|
<label for="username"><p>Username</p></label>
|
||||||
<input type="text" name="username" id="username">
|
<input type="text" name="username" id="username" autofocus>
|
||||||
<label for="password"><p>Password</p></label>
|
<label for="password"><p>Password</p></label>
|
||||||
<input type="password" name="password" id="password">
|
<input type="password" name="password" id="password">
|
||||||
<br>
|
<br>
|
||||||
|
31
public/queue/index.html
Normal file
31
public/queue/index.html
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>MaoTube</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
<script defer src="/helpers.js"></script>
|
||||||
|
<script defer src="script.js"></script>
|
||||||
|
<script defer src="/header.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>MaoTube</h1>
|
||||||
|
<div>
|
||||||
|
<h1>Uploading videos</h1>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div id="result">
|
||||||
|
<noscript>
|
||||||
|
<div class="mao-error">
|
||||||
|
<p>javascript not enabled</p>
|
||||||
|
<p>bottom text</p>
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
<div id="mao-error" class="mao-error hidden">
|
||||||
|
<p id="mao-error-message"></p>
|
||||||
|
<p>bottom text</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
57
public/queue/script.js
Normal file
57
public/queue/script.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
function readableMicroseconds(us) {
|
||||||
|
const min = parseInt(us / 60_000_000);
|
||||||
|
const sec = parseInt((us % 60_000_000) / 1_000_000);
|
||||||
|
|
||||||
|
if (!min) {
|
||||||
|
return `${sec}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${min}m ${sec}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const response = await fetch(`/api/video-queue`);
|
||||||
|
if (!response.ok) {
|
||||||
|
error("something went wrong");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
if (!json.ok) {
|
||||||
|
error(json.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = json.queue;
|
||||||
|
|
||||||
|
document.getElementById("result").innerHTML =
|
||||||
|
`<p>Currently processing ${queue.length} video(s)</p>`
|
||||||
|
+ `<ul id="video-list">`
|
||||||
|
+ queue
|
||||||
|
.map(vid => {
|
||||||
|
const percentage = parseInt(vid.progress / vid.duration * 100);
|
||||||
|
const uploadedTime = readableMicroseconds(vid.progress);
|
||||||
|
const totalTime = readableMicroseconds(vid.duration);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<li>
|
||||||
|
<div class="video-item">
|
||||||
|
<div class="video-image">
|
||||||
|
<img class="shadow" src="/videos/${vid.videoId}.png" alt="">
|
||||||
|
<img class="non-shadow" src="/videos/${vid.videoId}.png" alt="">
|
||||||
|
</div>
|
||||||
|
<span class="video-info">
|
||||||
|
<b>${vid.title}</b>
|
||||||
|
<br>
|
||||||
|
<p>Uploaded ${uploadedTime} of ${totalTime} (${percentage}%)</p>
|
||||||
|
<progress max="${vid.duration}" value="${vid.progress}">${percentage}%</progress>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
+ "</ul>";
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -9,7 +9,7 @@
|
|||||||
<h1>MaoTube</h1>
|
<h1>MaoTube</h1>
|
||||||
<div>
|
<div>
|
||||||
<label for="username"><p>Username</p></label>
|
<label for="username"><p>Username</p></label>
|
||||||
<input type="text" name="username" id="username">
|
<input type="text" name="username" id="username" autofocus>
|
||||||
<label for="password"><p>Password</p></label>
|
<label for="password"><p>Password</p></label>
|
||||||
<input type="password" name="password" id="password">
|
<input type="password" name="password" id="password">
|
||||||
<br>
|
<br>
|
||||||
|
@ -17,14 +17,30 @@ function displayResponse(response) {
|
|||||||
error("search returned no results");
|
error("search returned no results");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
console.log(videos);
|
||||||
const resultElement = document.getElementById("result");
|
const resultElement = document.getElementById("result");
|
||||||
resultElement.innerHTML = `<p>Showing ${videos.length}/${total} results.</p>`
|
resultElement.innerHTML = `<p>Showing ${videos.length}/${total} results.</p>`
|
||||||
+ "<ul id='video-list'>"
|
+ "<ul id='video-list'>"
|
||||||
+ videos
|
+ videos
|
||||||
.map(v => {
|
.map(v => {
|
||||||
return `<li><p><a href="/watch?id=${v.id}">${v.title}</a> - ${v.author}</p></li>`
|
console.log(v);
|
||||||
|
return `
|
||||||
|
<li>
|
||||||
|
<div class="video-item">
|
||||||
|
<div class="video-image">
|
||||||
|
<img class="shadow" src="/videos/${v.id}.png" alt="">
|
||||||
|
<img class="non-shadow" src="/videos/${v.id}.png" alt="">
|
||||||
|
</div>
|
||||||
|
<span class="video-info">
|
||||||
|
<a href="/watch?id=${v.id}">${v.title}</a>
|
||||||
|
<br>
|
||||||
|
by ${v.author}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
})
|
})
|
||||||
.join("")
|
.join("")
|
||||||
+ "</ul>";
|
+ "</ul>";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
124
public/style.css
124
public/style.css
@ -1,26 +1,49 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
|
||||||
|
--red: #c51e0e;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--red: #F4511E;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
*:focus {
|
||||||
color-scheme: light dark;
|
outline: 2px solid var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
background-color: #dff3f1;
|
|
||||||
color: #211;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
a {
|
||||||
body {
|
color: var(--red);
|
||||||
background: #211;
|
}
|
||||||
color: #dff3f1;
|
|
||||||
}
|
textarea, input {
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::file-selector-button {
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-form {
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mao-error {
|
.mao-error {
|
||||||
@ -38,7 +61,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mao-error p {
|
.mao-error p {
|
||||||
color: #fff;
|
color: white;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-family: "Impact", "Bebas", "League Gothic", "Oswald", "Coluna", "Ubuntu Condensed", system-ui, sans-serif;
|
font-family: "Impact", "Bebas", "League Gothic", "Oswald", "Coluna", "Ubuntu Condensed", system-ui, sans-serif;
|
||||||
@ -59,50 +82,75 @@ body {
|
|||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul#video-list {
|
#video-list {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#video-player {
|
#video-player {
|
||||||
max-height: 80vh;
|
height: 60vh;
|
||||||
|
width: 100%;
|
||||||
|
background-color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
#video-result {
|
||||||
|
width: 106vh;
|
||||||
|
display: none;
|
||||||
|
text-align: left;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-item {
|
||||||
|
padding: 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: row;
|
||||||
gap: 30px;
|
align-items: flex-start;
|
||||||
background-image: linear-gradient(to bottom, #c51e0e, #FF5722);
|
gap: 2rem;
|
||||||
padding: 20px;
|
text-align: left;
|
||||||
box-shadow: 0 15px 15px rgba(0, 0, 0, 0.2);
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
color: #fcf4c8;
|
border: 1px solid black;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app-name {
|
@media (prefers-color-scheme: dark) {
|
||||||
padding-right: 20px;
|
.video-item {
|
||||||
margin: 0;
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
.video-item .video-image {
|
||||||
color: #fcf4c8;
|
position: relative;
|
||||||
text-decoration: none;
|
width: 200px;
|
||||||
|
height: 113px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
#search {
|
|
||||||
border: none;
|
.video-item .video-image img {
|
||||||
outline: none;
|
position: absolute;
|
||||||
padding: 10px;
|
inset: 0;
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
width: 100%;
|
||||||
transition-duration: 100ms;
|
height: 100%;
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#search:hover {
|
.video-item .shadow {
|
||||||
background-color: rgba(0, 0, 0, 0.3);
|
object-fit: cover;
|
||||||
|
filter: blur(0.25rem) brightness(50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
#search:focus {
|
.video-item .non-shadow {
|
||||||
background-color: white;
|
object-fit: contain;
|
||||||
color: black;
|
}
|
||||||
|
|
||||||
|
.video-item .video-info {
|
||||||
|
padding-block: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-item a {
|
||||||
|
font-size: 1.4em;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,19 +4,36 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>MaoTube</title>
|
<title>MaoTube</title>
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
<script defer src="/header.js"></script>
|
||||||
|
<script defer src="/helpers.js"></script>
|
||||||
|
<script defer src="script.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>MaoTube</h1>
|
<h1>MaoTube</h1>
|
||||||
<form action="/api/upload_video" method="POST" enctype="multipart/form-data">
|
<form id="upload-form" method="POST" action="/api/upload-video" enctype="multipart/form-data">
|
||||||
<label for="username"><p>Title</p></label>
|
<label for="title"><p>Title</p></label>
|
||||||
<input type="text" name="title">
|
<input type="text" name="title" id="title" required autofocus>
|
||||||
<label for="password"><p>Video</p></label>
|
<label for="description"><p>Description</p></label>
|
||||||
<input type="file" name="video">
|
<textarea name="description" id="description"></textarea>
|
||||||
<br>
|
<label for="video"><p>Video</p></label>
|
||||||
<br>
|
<input type="file" name="video" id="video" required>
|
||||||
|
<br><br>
|
||||||
<input type="submit" id="submit" value="Upload">
|
<input type="submit" id="submit" value="Upload">
|
||||||
|
<br><br>
|
||||||
|
<a href="/queue">View upload queue</a>
|
||||||
</form>
|
</form>
|
||||||
<script src="/header.js"></script>
|
|
||||||
|
<noscript>
|
||||||
|
<div class="mao-error">
|
||||||
|
<p>javascript not enabled</p>
|
||||||
|
<p>bottom text</p>
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<div id="mao-error" class="mao-error hidden">
|
||||||
|
<p id="mao-error-message"></p>
|
||||||
|
<p>bottom text</p>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
22
public/upload/script.js
Normal file
22
public/upload/script.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
document.getElementById("upload-form").addEventListener("submit", event => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const data = new FormData(event.target);
|
||||||
|
|
||||||
|
fetch("/api/upload-video", {
|
||||||
|
method: "POST",
|
||||||
|
body: data,
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
error(response.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
location.href = "/queue";
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
error("failed to upload video");
|
||||||
|
});
|
||||||
|
});
|
@ -4,29 +4,35 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>MaoTube</title>
|
<title>MaoTube</title>
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
<script defer src="script.js"></script>
|
||||||
|
<script defer src="/header.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>MaoTube</h1>
|
<h1>MaoTube</h1>
|
||||||
<form method="GET" target="_self" action="/search">
|
|
||||||
<label for="query"><p>Search</p></label>
|
|
||||||
<input type="text" id="query" name="query" placeholder="...">
|
|
||||||
<input type="submit" value="Search">
|
|
||||||
</form>
|
|
||||||
<br>
|
<br>
|
||||||
<div id="result">
|
<div id="result">
|
||||||
|
<div id="video-result">
|
||||||
|
<video id="video-player"></video>
|
||||||
|
<h1 id="video-title"></h1>
|
||||||
|
<p id="video-author"></p>
|
||||||
|
<br>
|
||||||
|
<hr>
|
||||||
|
<br>
|
||||||
|
<p id="video-description"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<noscript>
|
<noscript>
|
||||||
<div class="mao-error">
|
<div class="mao-error">
|
||||||
<p>javascript not enabled</p>
|
<p>javascript not enabled</p>
|
||||||
<p>bottom text</p>
|
<p>bottom text</p>
|
||||||
</div>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
<div id="mao-error" class="mao-error hidden">
|
<div id="mao-error" class="mao-error hidden">
|
||||||
<p id="mao-error-message"></p>
|
<p id="mao-error-message"></p>
|
||||||
<p>bottom text</p>
|
<p>bottom text</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="script.js"></script>
|
|
||||||
<script src="/header.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
|
|
||||||
function error(message) {
|
function error(message) {
|
||||||
const errorContainer = document.getElementById("mao-error");
|
const errorContainer = document.getElementById("mao-error");
|
||||||
const errorElement = document.getElementById("mao-error-message");
|
const errorElement = document.getElementById("mao-error-message");
|
||||||
|
|
||||||
errorElement.innerText = message;
|
document.getElementById("video-result").style.display = "none";
|
||||||
|
|
||||||
|
errorElement.innerText = message || "unknown error";
|
||||||
errorContainer.classList.remove("hidden");
|
errorContainer.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
async function main() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const id = params.get("id");
|
const id = params.get("id");
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@ -15,17 +16,36 @@ function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = document.getElementById("result");
|
const info = await fetch(`/api/video-info?id=${id}`);
|
||||||
|
if (!info.ok) {
|
||||||
const video = document.createElement("video");
|
error("error fetching video info");
|
||||||
video.controls = true;
|
return;
|
||||||
video.id = "video-player";
|
|
||||||
video.src = `/videos/${id}.mp4`;
|
|
||||||
result.appendChild(video);
|
|
||||||
video.onerror = () => {
|
|
||||||
video.remove();
|
|
||||||
error("invalid id parameter");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const json = await info.json();
|
||||||
|
if (!json.ok) {
|
||||||
|
error(json.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const video = json.video;
|
||||||
|
|
||||||
|
const player = document.getElementById("video-player");
|
||||||
|
player.controls = true;
|
||||||
|
player.src = video.path;
|
||||||
|
player.onerror = () => {
|
||||||
|
error("unable to play video");
|
||||||
|
}
|
||||||
|
player.onload = () => {
|
||||||
|
player.style.height = player.clientWidth / 16 * 9 + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("video-title").innerText = video.title;
|
||||||
|
document.getElementById("video-author").innerText = "by " + video.author + " - published " + new Date(video.created_at).toLocaleDateString();
|
||||||
|
document.getElementById("video-description").innerText = video.description;
|
||||||
|
|
||||||
|
document.getElementById("video-result").style.display = "block";
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user