326 lines
11 KiB
JavaScript
326 lines
11 KiB
JavaScript
|
|
// ...existing code...
|
|
|
|
|
|
const express = require("express");
|
|
const multer = require("multer");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const session = require("express-session");
|
|
const bcrypt = require("bcryptjs");
|
|
const ffmpeg = require("fluent-ffmpeg");
|
|
|
|
const app = express();
|
|
const PORT = 3000;
|
|
|
|
// Data storage
|
|
const userDbFile = "./data/users.json";
|
|
const videoDbFile = "./data/videos.json";
|
|
|
|
let users = fs.existsSync(userDbFile) ? JSON.parse(fs.readFileSync(userDbFile)) : [];
|
|
let videos = fs.existsSync(videoDbFile) ? JSON.parse(fs.readFileSync(videoDbFile)) : [];
|
|
|
|
// Ensure all users have a watchHistory array
|
|
users.forEach(u => { if (!u.watchHistory) u.watchHistory = []; });
|
|
|
|
// Middleware
|
|
app.use(express.static("public"));
|
|
app.use("/videos", express.static("videos"));
|
|
app.use("/thumbnails", express.static("thumbnails"));
|
|
app.use(express.json());
|
|
app.use(session({
|
|
secret: "minitube_secret_key",
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
}));
|
|
|
|
// Helpers
|
|
function requireLogin(req, res, next) {
|
|
if (!req.session.userId) return res.status(403).json({ error: "Login required" });
|
|
next();
|
|
}
|
|
|
|
// ----------------- Multer for Videos -----------------
|
|
const videoStorage = multer.diskStorage({
|
|
destination: "videos/",
|
|
filename: (req, file, cb) => {
|
|
cb(null, Date.now() + path.extname(file.originalname));
|
|
},
|
|
});
|
|
const uploadVideo = multer({
|
|
storage: videoStorage,
|
|
fileFilter: (req, file, cb) => {
|
|
const ext = path.extname(file.originalname).toLowerCase();
|
|
const allowed = [".mp4", ".webm"];
|
|
if (!allowed.includes(ext)) {
|
|
return cb(new Error("Only .mp4 and .webm allowed"));
|
|
}
|
|
cb(null, true);
|
|
}
|
|
});
|
|
|
|
// ----------------- Multer for Thumbnails -----------------
|
|
const thumbStorage = multer.diskStorage({
|
|
destination: "thumbnails/",
|
|
filename: (req, file, cb) => {
|
|
cb(null, Date.now() + path.extname(file.originalname));
|
|
},
|
|
});
|
|
const uploadThumb = multer({
|
|
storage: thumbStorage,
|
|
fileFilter: (req, file, cb) => {
|
|
const ext = path.extname(file.originalname).toLowerCase();
|
|
const allowed = [".png", ".jpg", ".jpeg"];
|
|
if (!allowed.includes(ext)) {
|
|
return cb(new Error("Only .png, .jpg, .jpeg thumbnails allowed"));
|
|
}
|
|
cb(null, true);
|
|
}
|
|
});
|
|
|
|
// ----------------- Auth -----------------
|
|
app.post("/api/register", (req, res) => {
|
|
const { username, password } = req.body;
|
|
if (users.find(u => u.username === username)) {
|
|
return res.status(400).json({ error: "Username taken" });
|
|
}
|
|
const hash = bcrypt.hashSync(password, 10);
|
|
const user = { id: Date.now(), username, password: hash, subscribers: [] };
|
|
users.push(user);
|
|
fs.writeFileSync(userDbFile, JSON.stringify(users, null, 2));
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.post("/api/login", (req, res) => {
|
|
const { username, password } = req.body;
|
|
const user = users.find(u => u.username === username);
|
|
if (!user || !bcrypt.compareSync(password, user.password)) {
|
|
return res.status(400).json({ error: "Invalid login" });
|
|
}
|
|
req.session.userId = user.id;
|
|
res.json({ success: true, username: user.username });
|
|
});
|
|
|
|
app.post("/api/logout", (req, res) => {
|
|
req.session.destroy(() => res.json({ success: true }));
|
|
});
|
|
|
|
// ----------------- Videos -----------------
|
|
app.get("/api/videos", (req, res) => res.json(videos));
|
|
|
|
// Support tags, title, and custom thumbnail
|
|
const uploadFields = uploadVideo.fields([
|
|
{ name: 'video', maxCount: 1 },
|
|
{ name: 'thumbnail', maxCount: 1 }
|
|
]);
|
|
|
|
app.post("/api/upload", requireLogin, (req, res) => {
|
|
uploadFields(req, res, function (err) {
|
|
if (err) return res.status(400).json({ error: err.message });
|
|
const user = users.find(u => u.id === req.session.userId);
|
|
if (!req.files || !req.files['video'] || !req.files['video'][0]) {
|
|
return res.status(400).json({ error: "No video uploaded" });
|
|
}
|
|
const videoFile = req.files['video'][0];
|
|
const title = req.body.title || videoFile.originalname;
|
|
const tags = (req.body.tags || "").split(",").map(t => t.trim()).filter(Boolean);
|
|
const videoId = Date.now();
|
|
|
|
// If custom thumbnail provided, use it
|
|
let thumbFilename = null;
|
|
if (req.files['thumbnail'] && req.files['thumbnail'][0]) {
|
|
thumbFilename = req.files['thumbnail'][0].filename;
|
|
finishUpload();
|
|
} else {
|
|
// Auto-generate thumbnail
|
|
thumbFilename = `${videoId}.png`;
|
|
const videoPath = videoFile.path;
|
|
ffmpeg(videoPath)
|
|
.screenshots({
|
|
timestamps: ['2'],
|
|
filename: thumbFilename,
|
|
folder: 'thumbnails',
|
|
size: '320x240'
|
|
})
|
|
.on('end', finishUpload)
|
|
.on('error', err => {
|
|
console.error("Thumbnail generation error:", err);
|
|
finishUpload("Video uploaded but thumbnail generation failed.");
|
|
});
|
|
}
|
|
|
|
function finishUpload(thumbnailError) {
|
|
const newVideo = {
|
|
id: videoId,
|
|
title,
|
|
filename: videoFile.filename,
|
|
uploader: user.username,
|
|
likes: [],
|
|
comments: [],
|
|
thumbnail: thumbFilename,
|
|
tags
|
|
};
|
|
videos.push(newVideo);
|
|
fs.writeFileSync(videoDbFile, JSON.stringify(videos, null, 2));
|
|
if (thumbnailError) {
|
|
res.status(500).json({ error: thumbnailError, video: newVideo });
|
|
} else {
|
|
res.json({ success: true, video: newVideo });
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
|
|
// Get video and record watch history if logged in
|
|
app.get("/api/videos/:id", (req, res) => {
|
|
const video = videos.find(v => v.id == req.params.id);
|
|
if (!video) return res.status(404).json({ error: "Not found" });
|
|
|
|
// Record watch history for logged-in user
|
|
if (req.session.userId) {
|
|
const user = users.find(u => u.id === req.session.userId);
|
|
if (user && !user.watchHistory.includes(video.id)) {
|
|
user.watchHistory.push(video.id);
|
|
fs.writeFileSync(userDbFile, JSON.stringify(users, null, 2));
|
|
}
|
|
}
|
|
res.json(video);
|
|
});
|
|
// Personalized recommendations based on tags and watch history
|
|
app.get("/api/recommended", requireLogin, (req, res) => {
|
|
const user = users.find(u => u.id === req.session.userId);
|
|
if (!user) return res.status(401).json([]);
|
|
|
|
// Get videos not watched yet
|
|
let unwatched = videos.filter(v => !user.watchHistory.includes(v.id));
|
|
|
|
// Get tags from recently watched videos (last 5)
|
|
let recent = user.watchHistory.slice(-5).map(id => videos.find(v => v.id === id)).filter(Boolean);
|
|
let tagCounts = {};
|
|
recent.forEach(v => {
|
|
if (Array.isArray(v.tags)) {
|
|
v.tags.forEach(tag => {
|
|
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
|
});
|
|
}
|
|
});
|
|
// Score unwatched videos by tag overlap
|
|
unwatched.forEach(v => {
|
|
v._score = 0;
|
|
if (Array.isArray(v.tags)) {
|
|
v.tags.forEach(tag => {
|
|
v._score += tagCounts[tag] || 0;
|
|
});
|
|
}
|
|
// Boost by likes
|
|
v._score += (v.likes?.length || 0) * 0.2;
|
|
});
|
|
// Sort by score, then most recent
|
|
unwatched.sort((a, b) => b._score - a._score || b.id - a.id);
|
|
res.json(unwatched.slice(0, 8));
|
|
});
|
|
|
|
// Change thumbnail
|
|
app.post("/api/videos/:id/thumbnail", requireLogin, uploadThumb.single("thumbnail"), (req, res) => {
|
|
const video = videos.find(v => v.id == req.params.id);
|
|
if (!video) return res.status(404).json({ error: "Video not found" });
|
|
|
|
const user = users.find(u => u.id === req.session.userId);
|
|
if (video.uploader !== user.username) {
|
|
return res.status(403).json({ error: "Not your video" });
|
|
}
|
|
|
|
video.thumbnail = req.file.filename;
|
|
fs.writeFileSync(videoDbFile, JSON.stringify(videos, null, 2));
|
|
res.json({ success: true, thumbnail: video.thumbnail });
|
|
});
|
|
|
|
// Like
|
|
app.post("/api/videos/:id/like", requireLogin, (req, res) => {
|
|
const video = videos.find(v => v.id == req.params.id);
|
|
if (!video) return res.status(404).json({ error: "Not found" });
|
|
|
|
const user = users.find(u => u.id === req.session.userId);
|
|
if (video.likes.includes(user.username)) {
|
|
video.likes = video.likes.filter(u => u !== user.username);
|
|
} else {
|
|
video.likes.push(user.username);
|
|
}
|
|
fs.writeFileSync(videoDbFile, JSON.stringify(videos, null, 2));
|
|
res.json({ likes: video.likes.length });
|
|
});
|
|
|
|
// Comment
|
|
app.post("/api/videos/:id/comment", requireLogin, (req, res) => {
|
|
const video = videos.find(v => v.id == req.params.id);
|
|
if (!video) return res.status(404).json({ error: "Not found" });
|
|
|
|
const user = users.find(u => u.id === req.session.userId);
|
|
const comment = { user: user.username, text: req.body.text };
|
|
video.comments.push(comment);
|
|
fs.writeFileSync(videoDbFile, JSON.stringify(videos, null, 2));
|
|
res.json(comment);
|
|
});
|
|
|
|
// ----------------- User Pages -----------------
|
|
app.get("/api/user/:username", (req, res) => {
|
|
const user = users.find(u => u.username === req.params.username);
|
|
if (!user) return res.status(404).json({ error: "User not found" });
|
|
|
|
const userVideos = videos.filter(v => v.uploader === user.username);
|
|
res.json({
|
|
username: user.username,
|
|
subscribers: user.subscribers.length,
|
|
videos: userVideos
|
|
});
|
|
});
|
|
|
|
app.post("/api/subscribe/:username", requireLogin, (req, res) => {
|
|
const target = users.find(u => u.username === req.params.username);
|
|
if (!target) return res.status(404).json({ error: "User not found" });
|
|
|
|
const me = users.find(u => u.id === req.session.userId);
|
|
if (target.subscribers.includes(me.username)) {
|
|
target.subscribers = target.subscribers.filter(s => s !== me.username);
|
|
} else {
|
|
target.subscribers.push(me.username);
|
|
}
|
|
fs.writeFileSync(userDbFile, JSON.stringify(users, null, 2));
|
|
res.json({ success: true, subscribers: target.subscribers.length });
|
|
});
|
|
|
|
|
|
// ----------------- Start -----------------
|
|
app.listen(PORT, () =>
|
|
console.log(`MiniTube running at http://localhost:${PORT}`)
|
|
);
|
|
|
|
// ----------------- Admin Panel (MUST be at the very end) -----------------
|
|
function requireAdmin(req, res, next) {
|
|
const user = users.find(u => u.id === req.session.userId);
|
|
if (!user || !user.isAdmin) {
|
|
return res.status(403).json({ error: 'Admin only' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
app.post('/api/admin/delete-user', requireLogin, requireAdmin, (req, res) => {
|
|
const { username } = req.body;
|
|
if (!username) return res.status(400).json({ error: 'Username required' });
|
|
const idx = users.findIndex(u => u.username === username);
|
|
if (idx === -1) return res.status(404).json({ error: 'User not found' });
|
|
users.splice(idx, 1);
|
|
fs.writeFileSync(userDbFile, JSON.stringify(users, null, 2));
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.post('/api/admin/delete-video', requireLogin, requireAdmin, (req, res) => {
|
|
const { id } = req.body;
|
|
const idx = videos.findIndex(v => String(v.id) === String(id));
|
|
if (idx === -1) return res.status(404).json({ error: 'Video not found' });
|
|
videos.splice(idx, 1);
|
|
fs.writeFileSync(videoDbFile, JSON.stringify(videos, null, 2));
|
|
res.json({ success: true });
|
|
});
|