Implement user watch history and personalized video recommendations; enhance upload functionality with tags and custom thumbnails; add admin panel for user and video management.
This commit is contained in:
+5
-1
@@ -6,12 +6,16 @@
|
||||
"subscribers": [
|
||||
"ma",
|
||||
"miles"
|
||||
],
|
||||
"watchHistory": [
|
||||
1757455619505
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 1757455788998,
|
||||
"username": "ma",
|
||||
"password": "$2b$10$FX01zPi0FXvHcXtcsX6t0.w0QHxIkkByhlX8tdx4fboQpnXtn.9a2",
|
||||
"subscribers": []
|
||||
"subscribers": [],
|
||||
"watchHistory": []
|
||||
}
|
||||
]
|
||||
+6
-19
@@ -1,27 +1,12 @@
|
||||
[
|
||||
{
|
||||
"id": 1757455491733,
|
||||
"title": "yapping-yapping-level-today.mp4",
|
||||
"filename": "1757455491493.mp4",
|
||||
"uploader": "miles",
|
||||
"likes": [
|
||||
"miles"
|
||||
],
|
||||
"comments": [
|
||||
{
|
||||
"user": "miles",
|
||||
"text": "hi"
|
||||
}
|
||||
],
|
||||
"thumbnail": "1757455491496.png"
|
||||
},
|
||||
{
|
||||
"id": 1757455619505,
|
||||
"title": "SysDVR_2025_08_31_16_54_21.mp4",
|
||||
"filename": "1757455619060.mp4",
|
||||
"uploader": "miles",
|
||||
"likes": [
|
||||
"ma"
|
||||
"ma",
|
||||
"miles"
|
||||
],
|
||||
"comments": [
|
||||
{
|
||||
@@ -33,7 +18,8 @@
|
||||
"text": "hi"
|
||||
}
|
||||
],
|
||||
"thumbnail": "1757455619233.png"
|
||||
"thumbnail": "1757455619233.png",
|
||||
"_score": 0.2
|
||||
},
|
||||
{
|
||||
"id": 1757455624902,
|
||||
@@ -54,6 +40,7 @@
|
||||
"text": "no"
|
||||
}
|
||||
],
|
||||
"thumbnail": "1757455624790.png"
|
||||
"thumbnail": "1757455624790.png",
|
||||
"_score": 0.4
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Admin Panel - MiniTube</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script src="admin.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Admin Panel</h1>
|
||||
<section>
|
||||
<h2>Delete User Account</h2>
|
||||
<form id="deleteUserForm">
|
||||
<input type="text" id="deleteUsername" placeholder="Username" required>
|
||||
<button type="submit">Delete User</button>
|
||||
</form>
|
||||
<p id="userDeleteMsg"></p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Delete Video</h2>
|
||||
<form id="deleteVideoForm">
|
||||
<input type="text" id="deleteVideoId" placeholder="Video ID" required>
|
||||
<button type="submit">Delete Video</button>
|
||||
</form>
|
||||
<p id="videoDeleteMsg"></p>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
// Admin Panel JS
|
||||
|
||||
document.getElementById('deleteUserForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById('deleteUsername').value.trim();
|
||||
if (!username) return;
|
||||
const res = await fetch('/api/admin/delete-user', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
const data = await res.json();
|
||||
document.getElementById('userDeleteMsg').textContent = data.success ? 'User deleted.' : (data.error || 'Error.');
|
||||
});
|
||||
|
||||
document.getElementById('deleteVideoForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const id = document.getElementById('deleteVideoId').value.trim();
|
||||
if (!id) return;
|
||||
const res = await fetch('/api/admin/delete-video', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id })
|
||||
});
|
||||
const data = await res.json();
|
||||
document.getElementById('videoDeleteMsg').textContent = data.success ? 'Video deleted.' : (data.error || 'Error.');
|
||||
});
|
||||
+117
@@ -1,3 +1,102 @@
|
||||
// -------------------- Recommended Videos (Personalized) --------------------
|
||||
async function loadRecommendedIndex() {
|
||||
const list = document.getElementById('recommendedVideos');
|
||||
if (!list) return;
|
||||
let videos = [];
|
||||
let res;
|
||||
try {
|
||||
res = await fetch('/api/recommended');
|
||||
if (res.ok) {
|
||||
videos = await res.json();
|
||||
} else {
|
||||
res = await fetch('/api/videos');
|
||||
videos = await res.json();
|
||||
}
|
||||
} catch {
|
||||
res = await fetch('/api/videos');
|
||||
videos = await res.json();
|
||||
}
|
||||
// Show top 6
|
||||
const top = videos.slice(0, 6);
|
||||
list.innerHTML = '';
|
||||
for (const v of top) {
|
||||
const li = document.createElement('li');
|
||||
li.style.display = 'flex';
|
||||
li.style.alignItems = 'center';
|
||||
li.style.marginBottom = '1em';
|
||||
li.innerHTML = `
|
||||
<a href="watch.html?id=${v.id}" style="display:flex;align-items:center;text-decoration:none;color:inherit;">
|
||||
<img src="/thumbnails/${v.thumbnail}" width="80" height="60" style="object-fit:cover;border-radius:6px;margin-right:10px;">
|
||||
<div>
|
||||
<div style="font-weight:bold;">${v.title}</div>
|
||||
<div style="font-size:0.9em;color:#aaa;">by ${v.uploader}</div>
|
||||
<div style="font-size:0.9em;color:#aaa;">${v.likes?.length || 0} likes</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
// -------------------- Watch Later & History (localStorage) --------------------
|
||||
function getWatchLater() {
|
||||
return JSON.parse(localStorage.getItem('watchLater') || '[]');
|
||||
}
|
||||
function setWatchLater(arr) {
|
||||
localStorage.setItem('watchLater', JSON.stringify(arr));
|
||||
}
|
||||
function addToWatchLater(video) {
|
||||
let arr = getWatchLater();
|
||||
if (!arr.find(v => v.id === video.id)) {
|
||||
arr.unshift(video);
|
||||
setWatchLater(arr.slice(0, 20));
|
||||
}
|
||||
renderWatchLater();
|
||||
}
|
||||
function removeFromWatchLater(id) {
|
||||
let arr = getWatchLater().filter(v => v.id !== id);
|
||||
setWatchLater(arr);
|
||||
renderWatchLater();
|
||||
}
|
||||
function renderWatchLater() {
|
||||
const list = document.getElementById('watchLaterList');
|
||||
if (!list) return;
|
||||
const arr = getWatchLater();
|
||||
list.innerHTML = '';
|
||||
arr.forEach(v => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<a href="watch.html?id=${v.id}">${v.title}</a> <button data-id="${v.id}" class="removeLater">✕</button>`;
|
||||
list.appendChild(li);
|
||||
});
|
||||
list.querySelectorAll('.removeLater').forEach(btn => {
|
||||
btn.onclick = e => removeFromWatchLater(btn.dataset.id);
|
||||
});
|
||||
}
|
||||
|
||||
function getWatchHistory() {
|
||||
return JSON.parse(localStorage.getItem('watchHistory') || '[]');
|
||||
}
|
||||
function setWatchHistory(arr) {
|
||||
localStorage.setItem('watchHistory', JSON.stringify(arr));
|
||||
}
|
||||
function addToWatchHistory(video) {
|
||||
let arr = getWatchHistory();
|
||||
arr = arr.filter(v => v.id !== video.id);
|
||||
arr.unshift(video);
|
||||
setWatchHistory(arr.slice(0, 20));
|
||||
renderWatchHistory();
|
||||
}
|
||||
function renderWatchHistory() {
|
||||
const list = document.getElementById('watchHistoryList');
|
||||
if (!list) return;
|
||||
const arr = getWatchHistory();
|
||||
list.innerHTML = '';
|
||||
arr.forEach(v => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<a href="watch.html?id=${v.id}">${v.title}</a>`;
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- Load Videos on Homepage --------------------
|
||||
async function loadVideos() {
|
||||
try {
|
||||
@@ -13,9 +112,20 @@ async function loadVideos() {
|
||||
<img src="/thumbnails/${v.thumbnail}" width="120">
|
||||
<a href="watch.html?id=${v.id}">${v.title}</a>
|
||||
<small>by <a href="user.html?username=${v.uploader}">${v.uploader}</a></small>
|
||||
<button class="addLater" data-id="${v.id}">Watch Later</button>
|
||||
`;
|
||||
list.appendChild(li);
|
||||
});
|
||||
// Add event listeners for Watch Later
|
||||
list.querySelectorAll('.addLater').forEach(btn => {
|
||||
btn.onclick = async e => {
|
||||
const id = btn.dataset.id;
|
||||
const v = videos.find(v => v.id == id);
|
||||
if (v) addToWatchLater(v);
|
||||
};
|
||||
});
|
||||
renderWatchLater();
|
||||
renderWatchHistory();
|
||||
} catch (err) {
|
||||
console.error("Error loading videos:", err);
|
||||
}
|
||||
@@ -93,6 +203,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// Homepage video list
|
||||
loadVideos();
|
||||
|
||||
// Recommended sidebar
|
||||
loadRecommendedIndex();
|
||||
|
||||
// Login form
|
||||
const loginForm = document.getElementById("loginForm");
|
||||
if (loginForm) loginForm.addEventListener("submit", loginUser);
|
||||
@@ -108,4 +221,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// Upload form
|
||||
const uploadForm = document.getElementById("uploadForm");
|
||||
if (uploadForm) uploadForm.addEventListener("submit", uploadVideo);
|
||||
|
||||
// Render watch later/history on all pages
|
||||
renderWatchLater();
|
||||
renderWatchHistory();
|
||||
});
|
||||
|
||||
+17
-7
@@ -6,12 +6,22 @@
|
||||
<script src="app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>MiniTube</h1>
|
||||
|
||||
<!-- Upload button -->
|
||||
<p><a href="upload.html"><button>Upload Video</button></a></p>
|
||||
<P> <a href="login.html"><button>login</button></a></P>
|
||||
|
||||
<ul id="videoList"></ul>
|
||||
<div style="display: flex; gap: 2em; align-items: flex-start;">
|
||||
<main style="flex: 2;">
|
||||
<h1>MiniTube</h1>
|
||||
<!-- Upload button -->
|
||||
<p><a href="upload.html"><button>Upload Video</button></a></p>
|
||||
<p><a href="login.html"><button>login</button></a></p>
|
||||
<ul id="videoList"></ul>
|
||||
</main>
|
||||
<aside style="flex: 1; min-width: 220px;">
|
||||
<h2>Recommended</h2>
|
||||
<ul id="recommendedVideos"></ul>
|
||||
<h2>Watch Later</h2>
|
||||
<ul id="watchLaterList"></ul>
|
||||
<h2>Watch History</h2>
|
||||
<ul id="watchHistoryList"></ul>
|
||||
</aside>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -14,6 +14,39 @@
|
||||
<button type="submit">Upload</button>
|
||||
</form>
|
||||
|
||||
<label>Title</label>
|
||||
<input type="text" name="title" id="videoTitleInput" placeholder="Video Title" required>
|
||||
|
||||
<label>Tags (comma separated)</label>
|
||||
<input type="text" name="tags" id="videoTagsInput" placeholder="e.g. music,funny,cat">
|
||||
|
||||
<label>Uploader</label>
|
||||
<input type="text" id="uploaderInput" readonly>
|
||||
|
||||
<label>Video File (.mp4 or .webm)</label>
|
||||
<input type="file" name="video" accept=".mp4,.webm" required>
|
||||
|
||||
<label><input type="checkbox" id="customThumbCheck"> Upload custom thumbnail</label>
|
||||
<input type="file" name="thumbnail" id="customThumbInput" accept="image/*" style="display:none">
|
||||
|
||||
<button type="submit">Upload</button>
|
||||
</form>
|
||||
|
||||
<p><a href="index.html">Back to Home</a></p>
|
||||
<script>
|
||||
// Show/hide custom thumbnail input
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const check = document.getElementById('customThumbCheck');
|
||||
const thumbInput = document.getElementById('customThumbInput');
|
||||
check.addEventListener('change', () => {
|
||||
thumbInput.style.display = check.checked ? '' : 'none';
|
||||
thumbInput.required = check.checked;
|
||||
});
|
||||
// Fetch current user for uploader field
|
||||
fetch('/api/me').then(r => r.json()).then(me => {
|
||||
if (me && me.username) document.getElementById('uploaderInput').value = me.username;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+104
-18
@@ -5,34 +5,60 @@
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="videoTitle"></h1>
|
||||
<video id="videoPlayer" controls width="640"></video>
|
||||
<p>Uploader: <a id="uploaderLink"></a></p>
|
||||
|
||||
<button id="likeBtn">Like</button>
|
||||
<span id="likeCount"></span>
|
||||
<div style="display: flex; gap: 2em; align-items: flex-start;">
|
||||
<main style="flex: 2;">
|
||||
<h1 id="videoTitle"></h1>
|
||||
<video id="videoPlayer" controls width="640"></video>
|
||||
<p>Uploader: <a id="uploaderLink"></a></p>
|
||||
|
||||
<button id="subBtn"></button>
|
||||
<p id="subCount"></p>
|
||||
<button id="likeBtn">Like</button>
|
||||
<span id="likeCount"></span>
|
||||
|
||||
<h2>Change Thumbnail</h2>
|
||||
<form id="thumbForm" enctype="multipart/form-data">
|
||||
<input type="file" name="thumbnail" accept="image/*" required>
|
||||
<button type="submit">Update Thumbnail</button>
|
||||
</form>
|
||||
<button id="subBtn"></button>
|
||||
<p id="subCount"></p>
|
||||
|
||||
<h2>Comments</h2>
|
||||
<ul id="comments"></ul>
|
||||
<form id="commentForm">
|
||||
<input type="text" name="text" placeholder="Write a comment" required>
|
||||
<button type="submit">Post</button>
|
||||
</form>
|
||||
<div id="thumbSection" style="display:none">
|
||||
<h2>Change Thumbnail</h2>
|
||||
<form id="thumbForm" enctype="multipart/form-data">
|
||||
<input type="file" name="thumbnail" accept="image/*" required>
|
||||
<button type="submit">Update Thumbnail</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2>Comments</h2>
|
||||
<ul id="comments"></ul>
|
||||
<form id="commentForm">
|
||||
<input type="text" name="text" placeholder="Write a comment" required>
|
||||
<button type="submit">Post</button>
|
||||
</form>
|
||||
</main>
|
||||
<aside style="flex: 1; min-width: 220px;">
|
||||
<h2>Recommended</h2>
|
||||
<ul id="recommendedVideos"></ul>
|
||||
<h2>Watch Later</h2>
|
||||
<ul id="watchLaterList"></ul>
|
||||
<h2>Watch History</h2>
|
||||
<ul id="watchHistoryList"></ul>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const videoId = params.get("id");
|
||||
let uploader = "";
|
||||
|
||||
|
||||
async function getCurrentUser() {
|
||||
try {
|
||||
const res = await fetch('/api/me');
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVideo() {
|
||||
const res = await fetch(`/api/videos/${videoId}`);
|
||||
const video = await res.json();
|
||||
@@ -48,8 +74,68 @@
|
||||
const userRes = await fetch(`/api/user/${uploader}`);
|
||||
const user = await userRes.json();
|
||||
document.getElementById("subCount").textContent = `Subscribers: ${user.subscribers}`;
|
||||
|
||||
// Only show Change Thumbnail if current user is uploader
|
||||
const me = await getCurrentUser();
|
||||
if (me && me.username === uploader) {
|
||||
document.getElementById("thumbSection").style.display = "block";
|
||||
} else {
|
||||
document.getElementById("thumbSection").style.display = "none";
|
||||
}
|
||||
|
||||
// Load recommended videos
|
||||
loadRecommended(videoId);
|
||||
}
|
||||
|
||||
async function loadRecommended(currentId) {
|
||||
let videos = [];
|
||||
let res;
|
||||
try {
|
||||
res = await fetch('/api/recommended');
|
||||
if (res.ok) {
|
||||
videos = await res.json();
|
||||
} else {
|
||||
// Not logged in, fallback to all videos
|
||||
res = await fetch('/api/videos');
|
||||
videos = await res.json();
|
||||
}
|
||||
} catch {
|
||||
// fallback
|
||||
res = await fetch('/api/videos');
|
||||
videos = await res.json();
|
||||
}
|
||||
// Remove current video
|
||||
videos = videos.filter(v => v.id != currentId);
|
||||
// Show top 6
|
||||
const top = videos.slice(0, 6);
|
||||
const list = document.getElementById('recommendedVideos');
|
||||
list.innerHTML = '';
|
||||
for (const v of top) {
|
||||
const li = document.createElement('li');
|
||||
li.style.display = 'flex';
|
||||
li.style.alignItems = 'center';
|
||||
li.style.marginBottom = '1em';
|
||||
li.innerHTML = `
|
||||
<a href="watch.html?id=${v.id}" style="display:flex;align-items:center;text-decoration:none;color:inherit;">
|
||||
<img src="/thumbnails/${v.thumbnail}" width="80" height="60" style="object-fit:cover;border-radius:6px;margin-right:10px;">
|
||||
<div>
|
||||
<div style="font-weight:bold;">${v.title}</div>
|
||||
<div style="font-size:0.9em;color:#aaa;">by ${v.uploader}</div>
|
||||
<div style="font-size:0.9em;color:#aaa;">${v.likes?.length || 0} likes</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
// Add /api/me endpoint to server.js if not present:
|
||||
// app.get("/api/me", (req, res) => {
|
||||
// if (!req.session.userId) return res.status(401).json({});
|
||||
// const user = users.find(u => u.id === req.session.userId);
|
||||
// if (!user) return res.status(401).json({});
|
||||
// res.json({ username: user.username });
|
||||
// });
|
||||
|
||||
function renderComments(comments) {
|
||||
const list = document.getElementById("comments");
|
||||
list.innerHTML = "";
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
|
||||
// ...existing code...
|
||||
|
||||
|
||||
const express = require("express");
|
||||
const multer = require("multer");
|
||||
const fs = require("fs");
|
||||
@@ -17,6 +20,9 @@ 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"));
|
||||
@@ -102,45 +108,118 @@ app.post("/api/logout", (req, res) => {
|
||||
// ----------------- Videos -----------------
|
||||
app.get("/api/videos", (req, res) => res.json(videos));
|
||||
|
||||
app.post("/api/upload", requireLogin, uploadVideo.single("video"), (req, res) => {
|
||||
const user = users.find(u => u.id === req.session.userId);
|
||||
const videoPath = req.file.path;
|
||||
const thumbFilename = `${Date.now()}.png`;
|
||||
const thumbPath = path.join("thumbnails", thumbFilename);
|
||||
// Support tags, title, and custom thumbnail
|
||||
const uploadFields = uploadVideo.fields([
|
||||
{ name: 'video', maxCount: 1 },
|
||||
{ name: 'thumbnail', maxCount: 1 }
|
||||
]);
|
||||
|
||||
// Generate thumbnail at 2 seconds
|
||||
ffmpeg(videoPath)
|
||||
.screenshots({
|
||||
timestamps: ['2'],
|
||||
filename: thumbFilename,
|
||||
folder: 'thumbnails',
|
||||
size: '320x240'
|
||||
})
|
||||
.on('end', () => {
|
||||
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: Date.now(),
|
||||
title: req.file.originalname,
|
||||
filename: req.file.filename,
|
||||
id: videoId,
|
||||
title,
|
||||
filename: videoFile.filename,
|
||||
uploader: user.username,
|
||||
likes: [],
|
||||
comments: [],
|
||||
thumbnail: thumbFilename
|
||||
thumbnail: thumbFilename,
|
||||
tags
|
||||
};
|
||||
videos.push(newVideo);
|
||||
fs.writeFileSync(videoDbFile, JSON.stringify(videos, null, 2));
|
||||
res.json({ success: true, video: newVideo });
|
||||
})
|
||||
.on('error', err => {
|
||||
console.error("Thumbnail generation error:", err);
|
||||
res.status(500).json({ error: "Video uploaded but thumbnail generation failed." });
|
||||
});
|
||||
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) => {
|
||||
@@ -211,7 +290,36 @@ app.post("/api/subscribe/:username", requireLogin, (req, res) => {
|
||||
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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user