Files
2025-02-24 22:19:25 -05:00

656 lines
20 KiB
JavaScript

// server.js
import { EventEmitter } from "events";
EventEmitter.defaultMaxListeners = 50;
import express from "express";
import WebTorrent from "webtorrent";
import fs from "fs";
import { promises as fsPromises } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import rateLimit from "express-rate-limit";
import winston from "winston";
import JSONStream from "JSONStream";
import { lock, unlock } from "proper-lockfile";
import os from "os";
import multer from "multer"; // For file uploads
// Logger Setup
const logger = winston.createLogger({
level: "info", // Changed from "debug" to "info" to reduce logging
format: winston.format.combine(
winston.format.timestamp(),
winston.format.printf(
({ timestamp, level, message }) => `${timestamp} ${level}: ${message}`
)
),
transports: [new winston.transports.Console()],
});
// Global Variables
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CONFIG_FILE = path.join(__dirname, "config.json");
let config = { storageDir: path.join(__dirname, "downloads") };
const TORRENTS_FILE = path.join(__dirname, "torrents.json");
let savedTorrents = [];
// Helper Functions
async function pathExists(p) {
try {
await fsPromises.access(p);
return true;
} catch {
return false;
}
}
function formatBytes(bytes) {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i];
}
// Load Configuration
async function loadConfig() {
try {
if (!fs.existsSync(CONFIG_FILE)) {
logger.info(
"No config.json file found. Creating new default config file."
);
const defaultStorageDir = path.join(
os.homedir(),
"Documents",
"bitvid-seeder"
);
const defaultConfig = {
storageDir: defaultStorageDir,
maxConns: 200,
utp: false,
};
fs.writeFileSync(
CONFIG_FILE,
JSON.stringify(defaultConfig, null, 2),
"utf8"
);
config = defaultConfig;
} else {
const data = await fsPromises.readFile(CONFIG_FILE, "utf-8");
config = JSON.parse(data);
if (!config.maxConns) config.maxConns = 200;
if (config.utp === undefined) config.utp = false;
if (!config.storageDir || config.storageDir.trim() === "") {
config.storageDir = path.join(
os.homedir(),
"Documents",
"bitvid-seeder"
);
} else {
config.storageDir = path.resolve(config.storageDir);
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf8");
}
} catch (err) {
logger.error("Error reading config file, using default config: " + err);
config = {
storageDir: path.join(os.homedir(), "Documents", "bitvid-seeder"),
maxConns: 200,
utp: false,
};
}
}
// Load Torrents with Streaming and Migrate Old Entries
async function loadSavedTorrentsFromFile() {
if (!fs.existsSync(TORRENTS_FILE)) {
logger.info("No torrents.json file found. Creating new empty file.");
fs.writeFileSync(TORRENTS_FILE, "[]", "utf8");
}
return new Promise((resolve, reject) => {
const stream = fs.createReadStream(TORRENTS_FILE, { encoding: "utf8" });
const parser = JSONStream.parse("*");
savedTorrents = [];
parser.on("data", (torrent) => {
// Migrate old 'paused' field to 'state'
if (torrent.paused !== undefined) {
torrent.state = torrent.paused ? "paused" : "active";
delete torrent.paused;
}
savedTorrents.push(torrent);
});
parser.on("end", () => resolve());
parser.on("error", (err) => reject(err));
stream.pipe(parser);
});
}
// Asynchronous Save Torrents with File Locking
async function saveTorrents() {
await lock(TORRENTS_FILE);
try {
const uniqueTorrents = Array.from(
new Set(savedTorrents.map((t) => t.infoHash))
).map((infoHash) => savedTorrents.find((t) => t.infoHash === infoHash));
savedTorrents = uniqueTorrents;
const stream = JSONStream.stringify("[\n", ",\n", "\n]");
const fileStream = fs.createWriteStream(TORRENTS_FILE);
stream.pipe(fileStream);
savedTorrents.forEach((torrent) => stream.write(torrent));
stream.end();
await new Promise((resolve, reject) => {
fileStream.on("finish", resolve);
fileStream.on("error", reject);
});
logger.info("Torrents saved to file");
} catch (err) {
logger.error("Error saving torrents: " + err);
} finally {
await unlock(TORRENTS_FILE);
}
}
// Torrent Event Debugging (optional, can be removed if not needed)
function attachTorrentDebugEvents(torrent) {
torrent.on("wire", (wire, addr) => {
logger.debug(`Torrent ${torrent.infoHash} connected to peer: ${addr}`);
});
torrent.on("upload", (bytes) => {
logger.debug(`Torrent ${torrent.infoHash} uploaded ${bytes} bytes`);
});
torrent.on("download", (bytes) => {
logger.debug(`Torrent ${torrent.infoHash} downloaded ${bytes} bytes`);
});
}
// Resume Torrents on Startup
async function resumeActiveTorrents(client, STORAGE_DIR) {
logger.info("Starting to resume torrents...");
if (savedTorrents.length === 0) {
logger.info("No saved torrents found in torrents.json.");
return;
}
for (const torrentData of savedTorrents) {
const { magnet, infoHash, name, state } = torrentData;
if (magnet) {
const torrent = await new Promise((resolve, reject) => {
const t = client.add(magnet, { path: STORAGE_DIR }, resolve);
t.on("error", reject);
});
if (state === "paused") {
torrent.pause();
}
attachTorrentDebugEvents(torrent);
logger.info(`Torrent ${infoHash} added with state: ${state}`);
} else {
// Handle seeding existing files
const torrentPath = path.join(STORAGE_DIR, name);
if (await pathExists(torrentPath)) {
const torrent = await new Promise((resolve, reject) => {
const t = client.seed(torrentPath, { name }, resolve);
t.on("error", reject);
});
if (state === "paused") {
torrent.pause();
}
attachTorrentDebugEvents(torrent);
logger.info(`Torrent ${infoHash} seeded with state: ${state}`);
} else {
logger.warn(`File missing for torrent ${name}, cannot seed.`);
}
}
}
}
// Main Initialization Function
async function initialize() {
// Load configuration first
await loadConfig();
// Ensure storage directory exists
let STORAGE_DIR = config.storageDir;
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
// Initialize Express app and WebTorrent client
const app = express();
const port = process.env.PORT || 3000;
const client = new WebTorrent({
maxConns: config.maxConns,
utp: config.utp,
utpOpts: { port: 6881 }, // Specify a different port for UTP
});
client.setMaxListeners(50);
// Rate Limiting Middleware
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
});
app.use(limiter);
app.use(express.json());
// Multer configuration for file uploads
const upload = multer({ dest: "uploads/" });
// Load saved torrents
await loadSavedTorrentsFromFile();
// API Routes
// Get configuration
app.get("/config", async (req, res) => {
try {
res.json({
storageDir: config.storageDir,
maxConns: config.maxConns,
utp: config.utp,
});
} catch (err) {
logger.error("Error getting config: " + err);
res.status(500).json({ error: "Error reading config" });
}
});
// List torrents with pagination
app.get("/torrents", (req, res) => {
try {
logger.info(`Listing torrents: ${JSON.stringify(savedTorrents)}`);
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const start = (page - 1) * limit;
const end = start + limit;
const allTorrents = savedTorrents.map((saved) => {
const torrent = client.get(saved.infoHash);
if (torrent) {
logger.info(
`Active torrent ${saved.infoHash}: ${JSON.stringify({
progress: torrent.progress,
numPeers: torrent.numPeers,
downloaded: torrent.downloaded,
uploaded: torrent.uploaded,
status: saved.state,
})}`
);
return {
infoHash: saved.infoHash,
name: saved.name,
progress: torrent.progress,
numPeers: torrent.numPeers,
downloaded: torrent.downloaded,
uploaded: torrent.uploaded,
status: saved.state,
magnet: saved.magnet,
};
} else {
logger.info(
`Paused torrent ${saved.infoHash}: ${JSON.stringify({
progress: 1,
numPeers: 0,
downloaded: 0,
uploaded: 0,
status: saved.state,
})}`
);
return {
infoHash: saved.infoHash,
name: saved.name,
progress: 1,
numPeers: 0,
downloaded: 0,
uploaded: 0,
status: saved.state,
magnet: saved.magnet,
};
}
});
const paginatedTorrents = allTorrents.slice(start, end);
res.json({
torrents: paginatedTorrents,
total: allTorrents.length,
page,
limit,
});
} catch (err) {
logger.error("Error listing torrents: " + err.message);
res.status(500).json({
torrents: [],
total: 0,
page: parseInt(req.query.page) || 1,
limit: parseInt(req.query.limit) || 10,
error: "Error listing torrents",
message: err.message,
});
}
});
// Download route
app.get("/download/:infoHash", async (req, res) => {
const infoHash = req.params.infoHash;
const savedData = savedTorrents.find((t) => t.infoHash === infoHash);
if (!savedData) {
return res.status(404).json({ error: "Torrent not found" });
}
const filePath = path.join(STORAGE_DIR, savedData.name);
if (!(await pathExists(filePath))) {
return res.status(404).json({ error: "File not found" });
}
res.download(filePath, savedData.name, (err) => {
if (err) {
logger.error("Error downloading file: " + err);
res.status(500).json({ error: "Error downloading file" });
}
});
});
// Pause a torrent
app.post("/pause/:infoHash", async (req, res) => {
const infoHash = req.params.infoHash;
let torrent = client.get(infoHash);
// If the torrent isn't currently in the client, try re-adding it from savedTorrents.
if (!torrent) {
const savedEntry = savedTorrents.find((s) => s.infoHash === infoHash);
if (savedEntry && savedEntry.magnet) {
try {
torrent = await new Promise((resolve, reject) => {
const t = client.add(
savedEntry.magnet,
{ path: STORAGE_DIR },
resolve
);
t.on("error", reject);
});
} catch (err) {
logger.error("Error re-adding torrent for pause: " + err.message);
return res.status(500).json({ error: "Error pausing torrent" });
}
}
}
if (torrent) {
torrent.pause();
const entry = savedTorrents.find((s) => s.infoHash === infoHash);
if (entry) entry.state = "paused";
await saveTorrents();
res.json({ message: "Torrent paused" });
} else {
res.status(400).json({ error: "Torrent not found" });
}
});
// Resume a torrent
app.post("/resume/:infoHash", async (req, res) => {
const infoHash = req.params.infoHash;
let torrent = client.get(infoHash);
// If the torrent isn't in the client, re-add it from the saved torrents
if (!torrent) {
const savedEntry = savedTorrents.find((s) => s.infoHash === infoHash);
if (savedEntry && savedEntry.magnet) {
try {
torrent = await new Promise((resolve, reject) => {
const t = client.add(
savedEntry.magnet,
{ path: STORAGE_DIR },
resolve
);
t.on("error", reject);
});
} catch (err) {
logger.error("Error re-adding torrent for resume: " + err.message);
return res.status(500).json({ error: "Error resuming torrent" });
}
}
}
if (torrent) {
torrent.resume();
const entry = savedTorrents.find((s) => s.infoHash === infoHash);
if (entry) entry.state = "active";
await saveTorrents();
res.json({ message: "Torrent resumed" });
} else {
res.status(400).json({ error: "Torrent not found" });
}
});
// Update configuration with path validation
app.post("/config", async (req, res) => {
const { storageDir, maxConns, utp } = req.body;
try {
if (storageDir) {
const resolvedPath = path.resolve(storageDir);
if (!path.isAbsolute(resolvedPath)) {
throw new Error("Path must be absolute");
}
if (!fs.existsSync(resolvedPath)) {
fs.mkdirSync(resolvedPath, { recursive: true });
}
config.storageDir = resolvedPath;
STORAGE_DIR = resolvedPath;
}
if (maxConns !== undefined) {
if (!Number.isInteger(maxConns) || maxConns <= 0) {
throw new Error("maxConns must be a positive integer");
}
config.maxConns = maxConns;
}
if (utp !== undefined) {
if (typeof utp !== "boolean") {
throw new Error("utp must be a boolean");
}
config.utp = utp;
}
await fsPromises.writeFile(
CONFIG_FILE,
JSON.stringify(config, null, 2),
"utf8"
);
res.json({ message: "Config updated", config });
} catch (err) {
logger.error("Error updating config: " + err.message);
res
.status(500)
.json({ error: "Error updating config", message: err.message });
}
});
// Add a new torrent
app.post("/add", async (req, res) => {
const { magnet } = req.body;
if (!magnet || typeof magnet !== "string") {
return res.status(400).json({ error: "No valid magnet link provided" });
}
if (!magnet.startsWith("magnet:?")) {
return res.status(400).json({ error: "Invalid magnet link format" });
}
if (savedTorrents.some((t) => t.magnet === magnet)) {
return res.status(409).json({ error: "Torrent already added." });
}
try {
const torrent = await new Promise((resolve, reject) => {
const t = client.add(magnet, { path: STORAGE_DIR }, resolve);
t.on("error", reject);
});
attachTorrentDebugEvents(torrent);
const torrentData = {
magnet,
infoHash: torrent.infoHash,
name: torrent.name,
state: "active",
};
savedTorrents.push(torrentData);
await saveTorrents();
res.json(torrentData);
} catch (err) {
logger.error(`Error adding torrent: ${err.message}`);
res
.status(500)
.json({ error: "Error adding torrent", details: err.message });
}
});
// Upload and seed a video file
app.post("/upload", upload.single("video"), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: "No video file provided" });
}
try {
const tempFilePath = req.file.path;
const fileName = req.file.originalname;
const destPath = path.join(STORAGE_DIR, fileName);
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
await fsPromises.copyFile(tempFilePath, destPath);
await fsPromises.unlink(tempFilePath);
let attempts = 0;
const maxAttempts = 5;
while (attempts < maxAttempts) {
try {
await fsPromises.access(destPath, fs.constants.R_OK);
break;
} catch (err) {
if (err.code === "EBUSY") {
attempts++;
logger.info(
`File ${destPath} is busy, retrying (${attempts}/${maxAttempts})...`
);
await new Promise((resolve) => setTimeout(resolve, 1000));
} else {
throw err;
}
}
}
if (attempts === maxAttempts) {
throw new Error(
`File ${destPath} remained locked after ${maxAttempts} attempts`
);
}
client.seed(destPath, { name: fileName }, (torrent) => {
attachTorrentDebugEvents(torrent);
const torrentData = {
magnet: torrent.magnetURI,
infoHash: torrent.infoHash,
name: fileName,
state: "active",
dateAdded: new Date().toISOString(),
size: torrent.length,
};
savedTorrents.push(torrentData);
saveTorrents();
res.json(torrentData);
});
} catch (err) {
logger.error("Error uploading file: " + err.message);
res
.status(500)
.json({ error: "Error uploading file", message: err.message });
}
});
// Remove a torrent
app.delete("/remove/:infoHash", (req, res) => {
const infoHash = req.params.infoHash;
if (!infoHash || typeof infoHash !== "string") {
return res.status(400).json({ error: "Invalid infoHash" });
}
const torrent = client.get(infoHash);
const savedData = savedTorrents.find((t) => t.infoHash === infoHash);
const torrentName =
(torrent && torrent.name) || (savedData && savedData.name);
if (!torrentName) {
return res.status(400).json({ error: "Torrent name not found" });
}
try {
client.remove(infoHash, { destroyStore: true }, async (err) => {
if (err) {
logger.error("Error removing torrent: " + err);
return res.status(500).json({ error: "Error removing torrent" });
}
savedTorrents = savedTorrents.filter((t) => t.infoHash !== infoHash);
await saveTorrents();
const torrentPath = path.join(STORAGE_DIR, torrentName);
if (await pathExists(torrentPath)) {
try {
await fsPromises.rm(torrentPath, { recursive: true, force: true });
} catch (rmErr) {
logger.error("Error removing torrent files: " + rmErr);
}
}
res.json({ message: "Torrent removed" });
});
} catch (err) {
logger.error("Error processing removal: " + err);
res.status(500).json({ error: "Error processing removal" });
}
});
// Serve static files from the "public" folder
app.use(express.static("public"));
// Resource Usage Monitoring
setInterval(() => {
const cpuUsage = os.loadavg()[0];
const freeMem = os.freemem();
const totalMem = os.totalmem();
const memUsage = ((totalMem - freeMem) / totalMem) * 100;
logger.info(
`CPU Load: ${cpuUsage.toFixed(2)}%, Memory Usage: ${memUsage.toFixed(2)}%`
);
const mem = process.memoryUsage();
logger.info(
`Memory: RSS=${formatBytes(mem.rss)}, Heap=${formatBytes(
mem.heapUsed
)}/${formatBytes(mem.heapTotal)}`
);
logger.info(
`Active torrents: ${client.torrents.length}, Paused torrents: ${
savedTorrents.filter((t) => t.state === "paused").length
}`
);
}, 60000);
// Function to launch the HTTP server
function launchServer() {
const server = app.listen(port, async () => {
logger.info(`Server running on http://localhost:${port}`);
await resumeActiveTorrents(client, STORAGE_DIR);
});
// Graceful shutdown
function shutdown() {
logger.info("Shutting down server...");
server.close(() => {
logger.info("HTTP server closed.");
client.destroy((err) => {
if (err) {
logger.error("Error destroying WebTorrent client: " + err);
} else {
logger.info("WebTorrent client destroyed.");
}
process.exit(0);
});
});
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
return port;
}
return launchServer();
}
// Exported startServer function for external usage
async function startServer() {
return initialize();
}
// Conditionally start the server if this module is the entry point
if (process.argv[1] === fileURLToPath(import.meta.url)) {
startServer();
}
export { startServer };