mirror of
https://github.com/PR0M3TH3AN/bitvid-seeder.git
synced 2026-11-05 04:11:10 +00:00
update
This commit is contained in:
1 parent
52d7963c7a
commit
5ca7606b6c
4 files changed
+435
-309
No files matched your search
@@ -3,21 +3,15 @@
|
||||
class="hidden fixed inset-0 z-50"
|
||||
style="background: transparent"
|
||||
>
|
||||
<!-- Dark/blur overlay -->
|
||||
<div class="modal-overlay"></div>
|
||||
|
||||
<!-- Modal container flex layout -->
|
||||
<div class="modal-container">
|
||||
<!-- Modal content -->
|
||||
<div class="modal-content flex flex-col">
|
||||
<!-- Header with sticky gradient -->
|
||||
<div
|
||||
class="modal-header-gradient p-4 flex items-center justify-between"
|
||||
style="position: sticky; top: 0"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white">Profile</h2>
|
||||
<button id="closeProfileModal" class="modal-close-btn">
|
||||
<!-- Basic close icon (X) -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-white"
|
||||
@@ -34,8 +28,6 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body (scrollable content) -->
|
||||
<div class="modal-body">
|
||||
<div class="flex items-center space-x-4 mb-4">
|
||||
<img
|
||||
@@ -52,8 +44,6 @@
|
||||
Additional profile details or user settings can go here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
id="profileLogoutBtn"
|
||||
|
||||
+71
-128
@@ -9,23 +9,20 @@
|
||||
href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!-- Font Awesome (for gear icon, etc.) -->
|
||||
<!-- Font Awesome -->
|
||||
<link
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<!-- Load nostr.js module -->
|
||||
<script type="module" src="js/nostr.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container my-4">
|
||||
<h1 class="mb-4">bitvid Torrent Seeder</h1>
|
||||
|
||||
<!--
|
||||
Circular Login Button / Profile Button.
|
||||
If user is not logged in, it shows a default avatar.
|
||||
Clicking it either triggers login or opens profile modal.
|
||||
-->
|
||||
<!-- Circular Login/Profile Button -->
|
||||
<div class="d-flex justify-content-end mb-4">
|
||||
<button
|
||||
id="loginButton"
|
||||
@@ -47,7 +44,6 @@
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add Torrent Form -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Add New Torrent</div>
|
||||
@@ -70,14 +66,12 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Torrents Table -->
|
||||
<div class="card mb-4">
|
||||
<div
|
||||
class="card-header d-flex justify-content-between align-items-center"
|
||||
>
|
||||
Active Torrents
|
||||
<!-- Settings Gear Icon -->
|
||||
<button
|
||||
class="btn btn-link"
|
||||
data-bs-toggle="modal"
|
||||
@@ -100,15 +94,12 @@
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="torrentList">
|
||||
<!-- Rows populated dynamically -->
|
||||
</tbody>
|
||||
<tbody id="torrentList"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div
|
||||
class="modal fade"
|
||||
@@ -129,7 +120,6 @@
|
||||
></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Storage Settings Form -->
|
||||
<form id="configForm">
|
||||
<div class="mb-3">
|
||||
<label for="storageDir" class="form-label"
|
||||
@@ -152,10 +142,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Modal Container (will be loaded from external HTML) -->
|
||||
<!-- Profile Modal Container -->
|
||||
<div id="profileModalContainer"></div>
|
||||
|
||||
<!-- Helper to format bytes -->
|
||||
<script>
|
||||
function formatBytes(bytes) {
|
||||
@@ -166,22 +154,77 @@
|
||||
return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i];
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Main UI Script -->
|
||||
<script>
|
||||
// -----------------------------------------
|
||||
// Load the profile modal from external file
|
||||
// -----------------------------------------
|
||||
async function updateProfileUI(pubkey) {
|
||||
const btnAvatar = document.getElementById("loginButtonAvatar");
|
||||
const profileName = document.getElementById("profileModalName");
|
||||
if (!pubkey) {
|
||||
btnAvatar.src = "assets/svg/default-profile.svg";
|
||||
if (profileName) profileName.textContent = "Not logged in";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const events = await window.nostrClient.pool.list(
|
||||
window.nostrClient.relays,
|
||||
[{ kinds: [0], authors: [pubkey], limit: 1 }]
|
||||
);
|
||||
let displayName = pubkey;
|
||||
let picture = "assets/svg/default-profile.svg";
|
||||
if (events.length && events[0].content) {
|
||||
const data = JSON.parse(events[0].content);
|
||||
displayName = data.name || data.display_name || pubkey;
|
||||
picture = data.picture || "assets/svg/default-profile.svg";
|
||||
}
|
||||
btnAvatar.src = picture;
|
||||
if (profileName) profileName.textContent = displayName;
|
||||
} catch (error) {
|
||||
console.error("Error fetching profile data:", error);
|
||||
btnAvatar.src = "assets/svg/default-profile.svg";
|
||||
if (profileName) profileName.textContent = pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
const nostrClient = {
|
||||
login: async () => {
|
||||
if (!window.nostr) {
|
||||
throw new Error(
|
||||
"Nostr extension not found. Please install a Nostr extension."
|
||||
);
|
||||
}
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
return pubkey;
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem("userPubKey");
|
||||
},
|
||||
};
|
||||
|
||||
async function login() {
|
||||
try {
|
||||
const pubkey = await nostrClient.login();
|
||||
if (pubkey) {
|
||||
localStorage.setItem("userPubKey", pubkey);
|
||||
await updateProfileUI(pubkey);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Login failed:", error);
|
||||
alert("Login failed. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
nostrClient.logout();
|
||||
updateProfileUI(null);
|
||||
}
|
||||
|
||||
async function loadProfileModal() {
|
||||
try {
|
||||
const response = await fetch("components/profile-modal.html");
|
||||
const html = await response.text();
|
||||
document.getElementById("profileModalContainer").innerHTML = html;
|
||||
|
||||
// Once loaded, set up close and logout event listeners
|
||||
const closeBtn = document.getElementById("closeProfileModal");
|
||||
const logoutBtn = document.getElementById("profileLogoutBtn");
|
||||
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener("click", () => {
|
||||
toggleProfileModal(false);
|
||||
@@ -198,7 +241,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Show or hide the profile modal
|
||||
function toggleProfileModal(show) {
|
||||
const modal = document.getElementById("profileModal");
|
||||
if (!modal) return;
|
||||
@@ -209,84 +251,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------
|
||||
// Nostr login logic
|
||||
// -----------------------------------------
|
||||
const nostrClient = {
|
||||
login: async () => {
|
||||
if (!window.nostr) {
|
||||
throw new Error(
|
||||
"Nostr extension not found. Please install a Nostr extension."
|
||||
);
|
||||
}
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
return pubkey;
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem("userPubKey");
|
||||
},
|
||||
};
|
||||
|
||||
// Attempt login
|
||||
async function login() {
|
||||
try {
|
||||
const pubkey = await nostrClient.login();
|
||||
if (pubkey) {
|
||||
localStorage.setItem("userPubKey", pubkey);
|
||||
updateProfileUI(pubkey);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Login failed:", error);
|
||||
alert("Login failed. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
// Logout
|
||||
function logout() {
|
||||
nostrClient.logout();
|
||||
updateProfileUI(null);
|
||||
}
|
||||
|
||||
// -----------------------------------------
|
||||
// UI updates for the login button/avatar
|
||||
// -----------------------------------------
|
||||
function updateProfileUI(pubkey) {
|
||||
const btnAvatar = document.getElementById("loginButtonAvatar");
|
||||
const profileName = document.getElementById("profileModalName");
|
||||
// Use the user's public key or another identifier as needed
|
||||
if (!pubkey) {
|
||||
btnAvatar.src = "assets/svg/default-profile.svg";
|
||||
if (profileName) profileName.textContent = "Not logged in";
|
||||
} else {
|
||||
btnAvatar.src = "assets/svg/default-profile.svg"; // Replace with a proper avatar if available
|
||||
if (profileName) profileName.textContent = pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
// When login button is clicked
|
||||
document.getElementById("loginButton").addEventListener("click", () => {
|
||||
const userPubKey = localStorage.getItem("userPubKey");
|
||||
if (!userPubKey) {
|
||||
// Not logged in, so log in
|
||||
login().then(() => {
|
||||
// After login, open the modal
|
||||
toggleProfileModal(true);
|
||||
});
|
||||
} else {
|
||||
// Already logged in, just show the profile modal
|
||||
toggleProfileModal(true);
|
||||
}
|
||||
});
|
||||
|
||||
// On page load, check if user is logged in
|
||||
const savedPubKey = localStorage.getItem("userPubKey") || null;
|
||||
if (savedPubKey) {
|
||||
updateProfileUI(savedPubKey);
|
||||
}
|
||||
|
||||
// -----------------------------------------
|
||||
// Add torrent form
|
||||
// -----------------------------------------
|
||||
document
|
||||
.getElementById("addTorrentForm")
|
||||
.addEventListener("submit", async (e) => {
|
||||
@@ -305,58 +285,36 @@
|
||||
fetchTorrents();
|
||||
});
|
||||
|
||||
// -----------------------------------------
|
||||
// Fetching torrents
|
||||
// -----------------------------------------
|
||||
async function fetchTorrents() {
|
||||
if (!localStorage.getItem("userPubKey")) {
|
||||
// Don't block the entire page, just skip fetching.
|
||||
return;
|
||||
}
|
||||
if (!localStorage.getItem("userPubKey")) return;
|
||||
try {
|
||||
const res = await fetch("/torrents");
|
||||
const torrents = await res.json();
|
||||
const list = document.getElementById("torrentList");
|
||||
list.innerHTML = "";
|
||||
|
||||
torrents.forEach((torrent) => {
|
||||
const tr = document.createElement("tr");
|
||||
|
||||
// Name
|
||||
const tdName = document.createElement("td");
|
||||
tdName.textContent = torrent.name;
|
||||
tr.appendChild(tdName);
|
||||
|
||||
// Progress
|
||||
const tdProgress = document.createElement("td");
|
||||
tdProgress.textContent = (torrent.progress * 100).toFixed(2) + "%";
|
||||
tr.appendChild(tdProgress);
|
||||
|
||||
// Peers
|
||||
const tdPeers = document.createElement("td");
|
||||
tdPeers.textContent = torrent.numPeers;
|
||||
tr.appendChild(tdPeers);
|
||||
|
||||
// Downloaded
|
||||
const tdDownloaded = document.createElement("td");
|
||||
tdDownloaded.textContent = formatBytes(torrent.downloaded);
|
||||
tr.appendChild(tdDownloaded);
|
||||
|
||||
// Uploaded
|
||||
const tdUploaded = document.createElement("td");
|
||||
tdUploaded.textContent = formatBytes(torrent.uploaded);
|
||||
tr.appendChild(tdUploaded);
|
||||
|
||||
// Status
|
||||
const tdStatus = document.createElement("td");
|
||||
tdStatus.textContent = torrent.paused ? "Paused" : "Active";
|
||||
tr.appendChild(tdStatus);
|
||||
|
||||
// Actions
|
||||
const tdActions = document.createElement("td");
|
||||
const toggleBtn = document.createElement("button");
|
||||
toggleBtn.className = "btn btn-warning btn-sm me-2";
|
||||
|
||||
if (torrent.paused) {
|
||||
toggleBtn.textContent = "Resume";
|
||||
toggleBtn.onclick = async () => {
|
||||
@@ -371,8 +329,6 @@
|
||||
};
|
||||
}
|
||||
tdActions.appendChild(toggleBtn);
|
||||
|
||||
// Remove button
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.textContent = "Remove";
|
||||
removeBtn.className = "btn btn-danger btn-sm";
|
||||
@@ -381,7 +337,6 @@
|
||||
fetchTorrents();
|
||||
};
|
||||
tdActions.appendChild(removeBtn);
|
||||
|
||||
tr.appendChild(tdActions);
|
||||
list.appendChild(tr);
|
||||
});
|
||||
@@ -390,13 +345,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------
|
||||
// Fetching and updating config
|
||||
// -----------------------------------------
|
||||
async function fetchConfig() {
|
||||
if (!localStorage.getItem("userPubKey")) {
|
||||
return;
|
||||
}
|
||||
if (!localStorage.getItem("userPubKey")) return;
|
||||
try {
|
||||
const res = await fetch("/config");
|
||||
const config = await res.json();
|
||||
@@ -408,7 +358,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Update config form
|
||||
document
|
||||
.getElementById("configForm")
|
||||
.addEventListener("submit", async (e) => {
|
||||
@@ -422,29 +371,23 @@
|
||||
const result = await res.json();
|
||||
document.getElementById("currentStorage").textContent =
|
||||
"Current storage directory: " + result.storageDir;
|
||||
// Close modal
|
||||
bootstrap.Modal.getInstance(
|
||||
document.getElementById("settingsModal")
|
||||
).hide();
|
||||
});
|
||||
|
||||
// -----------------------------------------
|
||||
// Interval to refresh torrent list & config
|
||||
// -----------------------------------------
|
||||
setInterval(() => {
|
||||
fetchTorrents();
|
||||
fetchConfig();
|
||||
}, 5000);
|
||||
|
||||
// On page load, run the same fetch calls
|
||||
fetchProfileModalOnLoad();
|
||||
function fetchProfileModalOnLoad() {
|
||||
loadProfileModal();
|
||||
fetchTorrents();
|
||||
fetchConfig();
|
||||
}
|
||||
fetchProfileModalOnLoad();
|
||||
</script>
|
||||
|
||||
<!-- Bootstrap Bundle JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// public/js/nostr.js
|
||||
import { nip19, SimplePool } from "https://esm.sh/[email protected]";
|
||||
|
||||
class NostrClient {
|
||||
constructor() {
|
||||
this.pool = new SimplePool();
|
||||
this.pubkey = null;
|
||||
this.relays = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.snort.social",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.nostr.band",
|
||||
];
|
||||
}
|
||||
|
||||
async login() {
|
||||
if (!window.nostr) {
|
||||
throw new Error("Please install a Nostr extension (e.g. Alby).");
|
||||
}
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
this.pubkey = pubkey;
|
||||
return pubkey;
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.pubkey = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const nostrClient = new NostrClient();
|
||||
window.nostrClient = nostrClient;
|
||||
window.NostrTools = { nip19, SimplePool };
|
||||
@@ -1,95 +1,57 @@
|
||||
import { EventEmitter } from 'events';
|
||||
// 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 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 { exec } from "child_process";
|
||||
|
||||
// Set up Winston logger
|
||||
// Set up Winston logger (debug level)
|
||||
const logger = winston.createLogger({
|
||||
level: 'info',
|
||||
level: "debug",
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.printf(
|
||||
({ timestamp, level, message }) => `${timestamp} ${level}: ${message}`
|
||||
)
|
||||
),
|
||||
transports: [new winston.transports.Console()]
|
||||
transports: [new winston.transports.Console()],
|
||||
});
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
const client = new WebTorrent();
|
||||
|
||||
// Increase max listeners for our client
|
||||
client.setMaxListeners(50);
|
||||
|
||||
// ES module __dirname workaround
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Rate limiting middleware (100 requests per minute per IP)
|
||||
// Rate limiting middleware
|
||||
const limiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 100
|
||||
max: 100,
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// Serve static files and parse JSON bodies
|
||||
app.use(express.static('public'));
|
||||
app.use(express.json());
|
||||
|
||||
// Load configuration from config.json
|
||||
const CONFIG_FILE = path.join(__dirname, 'config.json');
|
||||
let config = { storageDir: path.join(__dirname, 'downloads') };
|
||||
// --- Global Variables and Helper Functions ---
|
||||
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
const configData = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
||||
config = JSON.parse(configData);
|
||||
// Resolve storageDir relative to __dirname
|
||||
config.storageDir = path.resolve(__dirname, config.storageDir);
|
||||
} else {
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify({ storageDir: './downloads' }, null, 2));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error reading config file, using default config: ' + err);
|
||||
}
|
||||
// Configuration
|
||||
const CONFIG_FILE = path.join(__dirname, "config.json");
|
||||
let config = { storageDir: path.join(__dirname, "downloads") };
|
||||
|
||||
let STORAGE_DIR = config.storageDir;
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// JSON file to store torrent metadata
|
||||
const TORRENTS_FILE = path.join(__dirname, 'torrents.json');
|
||||
// Torrents data
|
||||
const TORRENTS_FILE = path.join(__dirname, "torrents.json");
|
||||
let savedTorrents = [];
|
||||
try {
|
||||
if (fs.existsSync(TORRENTS_FILE)) {
|
||||
const data = fs.readFileSync(TORRENTS_FILE, 'utf-8');
|
||||
savedTorrents = JSON.parse(data);
|
||||
logger.info(`Loaded saved torrents: ${JSON.stringify(savedTorrents)}`);
|
||||
} else {
|
||||
logger.info('No torrents.json file found. Starting with an empty list.');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error loading torrents file: ' + err);
|
||||
}
|
||||
|
||||
function saveTorrents() {
|
||||
try {
|
||||
fs.writeFileSync(TORRENTS_FILE, JSON.stringify(savedTorrents, null, 2));
|
||||
} catch (err) {
|
||||
logger.error('Error saving torrents file: ' + err);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Check if a path exists asynchronously
|
||||
// Helper: Check if a path exists
|
||||
async function pathExists(p) {
|
||||
try {
|
||||
await fsPromises.access(p);
|
||||
@@ -99,124 +61,223 @@ async function pathExists(p) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /torrents - Return both active and paused torrents
|
||||
app.get('/torrents', (req, res) => {
|
||||
// Load configuration from file
|
||||
async function loadConfig() {
|
||||
try {
|
||||
// Active torrents: those in the client
|
||||
const active = client.torrents.map(t => ({
|
||||
if (await pathExists(CONFIG_FILE)) {
|
||||
const data = await fsPromises.readFile(CONFIG_FILE, "utf-8");
|
||||
config = JSON.parse(data);
|
||||
config.storageDir = path.resolve(__dirname, config.storageDir);
|
||||
} else {
|
||||
await fsPromises.writeFile(
|
||||
CONFIG_FILE,
|
||||
JSON.stringify({ storageDir: "./downloads" }, null, 2)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Error reading config file, using default config: " + err);
|
||||
}
|
||||
}
|
||||
await loadConfig();
|
||||
|
||||
// Load saved torrents from file
|
||||
async function loadSavedTorrentsFromFile() {
|
||||
try {
|
||||
if (fs.existsSync(TORRENTS_FILE)) {
|
||||
const data = await fsPromises.readFile(TORRENTS_FILE, "utf-8");
|
||||
savedTorrents = JSON.parse(data);
|
||||
logger.info(`Loaded saved torrents: ${JSON.stringify(savedTorrents)}`);
|
||||
} else {
|
||||
logger.info("No torrents.json file found. Starting with an empty list.");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Error loading torrents file: " + err);
|
||||
}
|
||||
}
|
||||
await loadSavedTorrentsFromFile();
|
||||
|
||||
// Save torrents to file
|
||||
async function saveTorrents() {
|
||||
try {
|
||||
await fsPromises.writeFile(
|
||||
TORRENTS_FILE,
|
||||
JSON.stringify(savedTorrents, null, 2)
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error("Error saving torrents file: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure storage directory exists
|
||||
let STORAGE_DIR = config.storageDir;
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Attach torrent debug events
|
||||
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 Active Torrents on Startup ---
|
||||
// For each saved torrent that is not paused, add (or seed) it.
|
||||
async function resumeActiveTorrents() {
|
||||
logger.info("Resuming active torrents...");
|
||||
for (const torrent of savedTorrents) {
|
||||
if (!torrent.paused) {
|
||||
try {
|
||||
const torrentPath = path.join(STORAGE_DIR, torrent.name);
|
||||
const exists = await pathExists(torrentPath);
|
||||
if (exists) {
|
||||
client.seed(
|
||||
torrentPath,
|
||||
{ name: torrent.name, keepSeeding: true },
|
||||
(torrentInstance) => {
|
||||
attachTorrentDebugEvents(torrentInstance);
|
||||
logger.info(
|
||||
`Resumed seeding torrent: ${torrentInstance.infoHash}`
|
||||
);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
client.add(
|
||||
torrent.magnet,
|
||||
{ path: STORAGE_DIR, keepSeeding: true },
|
||||
(torrentInstance) => {
|
||||
attachTorrentDebugEvents(torrentInstance);
|
||||
logger.info(
|
||||
`Resumed downloading torrent: ${torrentInstance.infoHash}`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error resuming torrent ${torrent.name}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await resumeActiveTorrents();
|
||||
|
||||
// --- API Routes ---
|
||||
// GET /config - Return current configuration as JSON
|
||||
app.get("/config", async (req, res) => {
|
||||
try {
|
||||
res.json({ storageDir: config.storageDir });
|
||||
} catch (err) {
|
||||
logger.error("Error getting config: " + err);
|
||||
res.status(500).json({ error: "Error reading config" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /torrents - Return both active and paused torrents
|
||||
app.get("/torrents", (req, res) => {
|
||||
try {
|
||||
const active = client.torrents.map((t) => ({
|
||||
infoHash: t.infoHash,
|
||||
name: t.name,
|
||||
progress: t.progress,
|
||||
numPeers: t.numPeers,
|
||||
downloaded: t.downloaded,
|
||||
uploaded: t.uploaded,
|
||||
paused: false
|
||||
paused: false,
|
||||
}));
|
||||
// Paused torrents: those saved with paused: true
|
||||
const paused = savedTorrents
|
||||
.filter(s => s.paused)
|
||||
.map(s => ({
|
||||
.filter((s) => s.paused)
|
||||
.map((s) => ({
|
||||
infoHash: s.infoHash,
|
||||
name: s.name,
|
||||
progress: 1,
|
||||
numPeers: 0,
|
||||
downloaded: 0,
|
||||
uploaded: 0,
|
||||
paused: true
|
||||
paused: true,
|
||||
}));
|
||||
const torrents = [...active, ...paused];
|
||||
logger.info('GET /torrents returning: ' + JSON.stringify(torrents));
|
||||
logger.info("GET /torrents returning: " + JSON.stringify(torrents));
|
||||
res.json(torrents);
|
||||
} catch (err) {
|
||||
logger.error('Error listing torrents: ' + err);
|
||||
res.status(500).json({ error: 'Error listing torrents' });
|
||||
logger.error("Error listing torrents: " + err);
|
||||
res.status(500).json({ error: "Error listing torrents" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /pause/:infoHash - Pause a torrent (remove it from the active client and mark as paused)
|
||||
app.post('/pause/:infoHash', (req, res) => {
|
||||
// POST /pause/:infoHash - Pause a torrent
|
||||
app.post("/pause/:infoHash", async (req, res) => {
|
||||
const infoHash = req.params.infoHash;
|
||||
const torrent = client.get(infoHash);
|
||||
if (!torrent) {
|
||||
return res.status(400).json({ error: 'Torrent not active or already paused' });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Torrent not active or already paused" });
|
||||
}
|
||||
client.remove(infoHash, { destroyStore: false }, err => {
|
||||
client.remove(infoHash, { destroyStore: false }, async (err) => {
|
||||
if (err) {
|
||||
logger.error('Error pausing torrent: ' + err);
|
||||
return res.status(500).json({ error: 'Error pausing torrent' });
|
||||
logger.error("Error pausing torrent: " + err);
|
||||
return res.status(500).json({ error: "Error pausing torrent" });
|
||||
}
|
||||
// Update savedTorrents to mark this torrent as paused.
|
||||
const entry = savedTorrents.find(s => s.infoHash === infoHash);
|
||||
const entry = savedTorrents.find((s) => s.infoHash === infoHash);
|
||||
if (entry) entry.paused = true;
|
||||
saveTorrents();
|
||||
await saveTorrents();
|
||||
logger.info(`Torrent paused: ${infoHash}`);
|
||||
res.json({ message: 'Torrent paused' });
|
||||
res.json({ message: "Torrent paused" });
|
||||
});
|
||||
});
|
||||
|
||||
// POST /resume/:infoHash - Resume a paused torrent (re-add it to the client)
|
||||
app.post('/resume/:infoHash', async (req, res) => {
|
||||
// POST /resume/:infoHash - Resume a paused torrent
|
||||
app.post("/resume/:infoHash", async (req, res) => {
|
||||
const infoHash = req.params.infoHash;
|
||||
const entry = savedTorrents.find(s => s.infoHash === infoHash);
|
||||
const entry = savedTorrents.find((s) => s.infoHash === infoHash);
|
||||
if (!entry) {
|
||||
return res.status(400).json({ error: 'Torrent not found in saved data' });
|
||||
return res.status(400).json({ error: "Torrent not found in saved data" });
|
||||
}
|
||||
entry.paused = false;
|
||||
saveTorrents();
|
||||
await saveTorrents();
|
||||
const torrentPath = path.join(STORAGE_DIR, entry.name);
|
||||
const exists = await pathExists(torrentPath);
|
||||
try {
|
||||
if (exists) {
|
||||
client.seed(torrentPath, { name: entry.name, keepSeeding: true }, torrent => {
|
||||
logger.info(`Torrent resumed (seeding from file): ${torrent.infoHash}`);
|
||||
res.json({ message: 'Torrent resumed' });
|
||||
});
|
||||
client.seed(
|
||||
torrentPath,
|
||||
{ name: entry.name, keepSeeding: true },
|
||||
(torrent) => {
|
||||
attachTorrentDebugEvents(torrent);
|
||||
logger.info(
|
||||
`Torrent resumed (seeding from file): ${torrent.infoHash}`
|
||||
);
|
||||
res.json({ message: "Torrent resumed" });
|
||||
}
|
||||
);
|
||||
} else {
|
||||
client.add(entry.magnet, { path: STORAGE_DIR, keepSeeding: true }, torrent => {
|
||||
logger.info(`Torrent resumed (downloading): ${torrent.infoHash}`);
|
||||
res.json({ message: 'Torrent resumed' });
|
||||
});
|
||||
client.add(
|
||||
entry.magnet,
|
||||
{ path: STORAGE_DIR, keepSeeding: true },
|
||||
(torrent) => {
|
||||
attachTorrentDebugEvents(torrent);
|
||||
logger.info(`Torrent resumed (downloading): ${torrent.infoHash}`);
|
||||
res.json({ message: "Torrent resumed" });
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error resuming torrent: ' + err);
|
||||
res.status(500).json({ error: 'Error resuming torrent' });
|
||||
logger.error("Error resuming torrent: " + err);
|
||||
res.status(500).json({ error: "Error resuming torrent" });
|
||||
}
|
||||
});
|
||||
|
||||
// Load saved torrents concurrently on startup.
|
||||
// For each saved torrent, if the expected file/folder exists, we seed from that location;
|
||||
// otherwise, we add the torrent to download.
|
||||
// (Paused torrents will have "paused": true in savedTorrents and should not be added automatically.)
|
||||
async function loadSavedTorrents() {
|
||||
await Promise.all(
|
||||
savedTorrents.map(async torrentData => {
|
||||
// Only auto-load if not marked paused.
|
||||
if (torrentData.paused) return;
|
||||
const torrentPath = path.join(STORAGE_DIR, torrentData.name);
|
||||
const exists = await pathExists(torrentPath);
|
||||
try {
|
||||
if (exists) {
|
||||
client.seed(torrentPath, { name: torrentData.name, keepSeeding: true }, torrent => {
|
||||
logger.info(`Seeding torrent from existing files: ${torrent.infoHash}`);
|
||||
});
|
||||
} else {
|
||||
client.add(torrentData.magnet, { path: STORAGE_DIR, keepSeeding: true }, torrent => {
|
||||
logger.info(`Downloading torrent: ${torrent.infoHash}`);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error loading saved torrent on startup: ' + err);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
loadSavedTorrents();
|
||||
|
||||
// POST /config - Update storage directory (with input validation)
|
||||
app.post('/config', (req, res) => {
|
||||
// POST /config - Update storage directory
|
||||
app.post("/config", async (req, res) => {
|
||||
const { storageDir } = req.body;
|
||||
if (!storageDir || typeof storageDir !== 'string') {
|
||||
return res.status(400).json({ error: 'Invalid storageDir provided' });
|
||||
if (!storageDir || typeof storageDir !== "string") {
|
||||
return res.status(400).json({ error: "Invalid storageDir provided" });
|
||||
}
|
||||
try {
|
||||
STORAGE_DIR = path.resolve(__dirname, storageDir);
|
||||
@@ -224,83 +285,183 @@ app.post('/config', (req, res) => {
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify({ storageDir }, null, 2));
|
||||
res.json({ message: 'Config updated', storageDir: STORAGE_DIR });
|
||||
await fsPromises.writeFile(
|
||||
CONFIG_FILE,
|
||||
JSON.stringify({ storageDir }, null, 2)
|
||||
);
|
||||
res.json({ message: "Config updated", storageDir: STORAGE_DIR });
|
||||
} catch (err) {
|
||||
logger.error('Error updating config: ' + err);
|
||||
res.status(500).json({ error: 'Error updating config' });
|
||||
logger.error("Error updating config: " + err);
|
||||
res.status(500).json({ error: "Error updating config" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /add - Add a new torrent (with basic validation and duplicate check)
|
||||
app.post('/add', (req, res) => {
|
||||
// POST /add - Add a new torrent
|
||||
app.post("/add", (req, res) => {
|
||||
const { magnet } = req.body;
|
||||
if (!magnet || typeof magnet !== 'string') {
|
||||
return res.status(400).json({ error: 'No valid magnet link provided' });
|
||||
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 (!magnet.startsWith("magnet:?")) {
|
||||
return res.status(400).json({ error: "Invalid magnet link format" });
|
||||
}
|
||||
if (savedTorrents.some(t => t.magnet === magnet)) {
|
||||
logger.info('Attempted to add duplicate torrent.');
|
||||
return res.status(409).json({ error: 'Torrent already added.' });
|
||||
if (savedTorrents.some((t) => t.magnet === magnet)) {
|
||||
logger.info("Attempted to add duplicate torrent.");
|
||||
return res.status(409).json({ error: "Torrent already added." });
|
||||
}
|
||||
try {
|
||||
client.add(magnet, { path: STORAGE_DIR, keepSeeding: true }, torrent => {
|
||||
client.add(magnet, { path: STORAGE_DIR, keepSeeding: true }, (torrent) => {
|
||||
attachTorrentDebugEvents(torrent);
|
||||
logger.info(`Torrent added: ${torrent.infoHash}`);
|
||||
const torrentData = {
|
||||
magnet,
|
||||
infoHash: torrent.infoHash,
|
||||
name: torrent.name,
|
||||
paused: false
|
||||
paused: false,
|
||||
};
|
||||
savedTorrents.push(torrentData);
|
||||
saveTorrents();
|
||||
res.json(torrentData);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Error adding torrent: ' + err);
|
||||
res.status(500).json({ error: 'Error adding torrent' });
|
||||
logger.error("Error adding torrent: " + err);
|
||||
res.status(500).json({ error: "Error adding torrent" });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /remove/:infoHash - Remove a torrent completely.
|
||||
app.delete('/remove/:infoHash', (req, res) => {
|
||||
app.delete("/remove/:infoHash", (req, res) => {
|
||||
const infoHash = req.params.infoHash;
|
||||
if (!infoHash || typeof infoHash !== 'string') {
|
||||
return res.status(400).json({ error: 'Invalid 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);
|
||||
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' });
|
||||
return res.status(400).json({ error: "Torrent name not found" });
|
||||
}
|
||||
try {
|
||||
client.remove(infoHash, { destroyStore: true }, async err => {
|
||||
client.remove(infoHash, { destroyStore: true }, async (err) => {
|
||||
if (err) {
|
||||
logger.error('Error removing torrent: ' + err);
|
||||
return res.status(500).json({ error: 'Error removing torrent' });
|
||||
logger.error("Error removing torrent: " + err);
|
||||
return res.status(500).json({ error: "Error removing torrent" });
|
||||
}
|
||||
savedTorrents = savedTorrents.filter(t => t.infoHash !== infoHash);
|
||||
saveTorrents();
|
||||
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);
|
||||
logger.error("Error removing torrent files: " + rmErr);
|
||||
}
|
||||
}
|
||||
logger.info(`Torrent removed: ${infoHash}`);
|
||||
res.json({ message: 'Torrent removed' });
|
||||
res.json({ message: "Torrent removed" });
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Error processing removal: ' + err);
|
||||
res.status(500).json({ error: 'Error processing removal' });
|
||||
logger.error("Error processing removal: " + err);
|
||||
res.status(500).json({ error: "Error processing removal" });
|
||||
}
|
||||
});
|
||||
|
||||
// --- End API Routes ---
|
||||
|
||||
// Serve static files from "public"
|
||||
app.use(express.static("public"));
|
||||
|
||||
// Log system limits periodically
|
||||
function logSystemLimits() {
|
||||
const platform = process.platform;
|
||||
if (platform === "win32") {
|
||||
logger.info("Running on Windows.");
|
||||
const activeHandles = process._getActiveHandles
|
||||
? process._getActiveHandles().length
|
||||
: "N/A";
|
||||
logger.info(`Active handles: ${activeHandles}`);
|
||||
exec("netsh int tcp show global", (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
logger.error(`Error running netsh: ${err.message}`);
|
||||
} else {
|
||||
logger.info(`TCP Global Settings:\n${stdout}`);
|
||||
}
|
||||
});
|
||||
} else if (platform === "linux") {
|
||||
exec('bash -c "ulimit -n"', (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
logger.error(`Error fetching file descriptor limit: ${err.message}`);
|
||||
} else {
|
||||
logger.info(`File descriptor limit: ${stdout.trim()}`);
|
||||
}
|
||||
});
|
||||
exec(`lsof -p ${process.pid} | wc -l`, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
logger.error(`Error counting open file descriptors: ${err.message}`);
|
||||
} else {
|
||||
logger.info(`Current open file descriptors: ${stdout.trim()}`);
|
||||
}
|
||||
});
|
||||
exec("cat /proc/sys/net/core/rmem_max", (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
logger.error(`Error fetching rmem_max: ${err.message}`);
|
||||
} else {
|
||||
logger.info(`Receive buffer max (rmem_max): ${stdout.trim()}`);
|
||||
}
|
||||
});
|
||||
exec("cat /proc/sys/net/core/wmem_max", (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
logger.error(`Error fetching wmem_max: ${err.message}`);
|
||||
} else {
|
||||
logger.info(`Send buffer max (wmem_max): ${stdout.trim()}`);
|
||||
}
|
||||
});
|
||||
} else if (platform === "darwin") {
|
||||
exec("ulimit -n", (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
logger.error(`Error fetching file descriptor limit: ${err.message}`);
|
||||
} else {
|
||||
logger.info(`File descriptor limit: ${stdout.trim()}`);
|
||||
}
|
||||
});
|
||||
exec(`lsof -p ${process.pid} | wc -l`, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
logger.error(`Error counting open file descriptors: ${err.message}`);
|
||||
} else {
|
||||
logger.info(`Current open file descriptors: ${stdout.trim()}`);
|
||||
}
|
||||
});
|
||||
exec("sysctl kern.ipc.maxsockbuf", (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
logger.error(`Error fetching kern.ipc.maxsockbuf: ${err.message}`);
|
||||
} else {
|
||||
logger.info(`kern.ipc.maxsockbuf: ${stdout.trim()}`);
|
||||
}
|
||||
});
|
||||
exec("sysctl net.inet.tcp.recvspace", (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
logger.error(`Error fetching net.inet.tcp.recvspace: ${err.message}`);
|
||||
} else {
|
||||
logger.info(`net.inet.tcp.recvspace: ${stdout.trim()}`);
|
||||
}
|
||||
});
|
||||
exec("sysctl net.inet.tcp.sendspace", (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
logger.error(`Error fetching net.inet.tcp.sendspace: ${err.message}`);
|
||||
} else {
|
||||
logger.info(`net.inet.tcp.sendspace: ${stdout.trim()}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
`Platform ${platform} not specifically supported for system limits logging.`
|
||||
);
|
||||
}
|
||||
}
|
||||
logSystemLimits();
|
||||
setInterval(logSystemLimits, 60000);
|
||||
|
||||
// Start the server
|
||||
const server = app.listen(port, () => {
|
||||
logger.info(`Server running on http://localhost:${port}`);
|
||||
@@ -308,19 +469,18 @@ const server = app.listen(port, () => {
|
||||
|
||||
// Graceful shutdown
|
||||
function shutdown() {
|
||||
logger.info('Shutting down server...');
|
||||
logger.info("Shutting down server...");
|
||||
server.close(() => {
|
||||
logger.info('HTTP server closed.');
|
||||
client.destroy(err => {
|
||||
logger.info("HTTP server closed.");
|
||||
client.destroy((err) => {
|
||||
if (err) {
|
||||
logger.error('Error destroying WebTorrent client: ' + err);
|
||||
logger.error("Error destroying WebTorrent client: " + err);
|
||||
} else {
|
||||
logger.info('WebTorrent client destroyed.');
|
||||
logger.info("WebTorrent client destroyed.");
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
Reference in new issue
Block a user