Merge pull request #2 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-02-24 12:40:22 -05:00
commit 9e50d0b406
50 files changed
+6684 -486

No files matched your search

+5
View File
@@ -1,5 +1,10 @@
node_modules/
downloads/
dist/
uploads/
temp/
saved_config.yaml
repo-context.txt
torrents.json
config.json
+96 -30
View File
@@ -1,16 +1,18 @@
# bitvid Seeder
Simple Torrent Seeder is a Node.js application that uses WebTorrent to seed torrents from the command line and provides a modern web interface to manage them. The app saves torrent metadata to a JSON file so that torrents can be resumed automatically when the server restarts. It supports adding, pausing/resuming, and removing torrents, and displays details such as progress, peer count, and upload/download amounts.
bitvid Torrent Seeder is a Node.js application that uses WebTorrent to seed torrents and provides a modern web interface for managing them. The app saves torrent metadata to a JSON file (`torrents.json`), allowing torrents to be resumed automatically when the server restarts. It supports adding, pausing/resuming, and removing torrents, and displays details such as progress, peer count, and upload/download amounts. The application can be run as a standalone server or packaged as a desktop application using Electron, providing a system tray icon for easy access.
## Features
- **Add Torrents:** Enter a magnet link to add a new torrent.
- **Active/Paused View:** See a list of active torrents and paused ones.
- **Pause/Resume:** Pause a torrent (which removes it from active seeding) and resume it later.
- **Remove Torrents:** Remove a torrent from the client and delete its files.
- **Detailed Status:** View progress, peer count, downloaded, and uploaded amounts.
- **Persistent Storage:** Torrent metadata is saved to a JSON file for persistence.
- **Add Torrents:** Add torrents via magnet links or by uploading video files.
- **Active/Paused View:** View active and paused torrents in a paginated table.
- **Pause/Resume:** Pause active torrents to stop seeding or resume paused torrents.
- **Remove Torrents:** Remove torrents and delete associated files.
- **Detailed Status:** See real-time progress, peer count, and data transfer amounts.
- **Persistent Storage:** Torrent metadata is saved in `torrents.json` for persistence across restarts.
- **Responsive UI:** A modern, desktop- and mobile-friendly interface built with Bootstrap.
- **System Tray Support (Electron):** Run the app in the background with a tray icon for quick access to the UI.
- **Nostr Integration:** Manage user profiles and authentication via Nostr.
## Prerequisites
@@ -32,75 +34,139 @@ Simple Torrent Seeder is a Node.js application that uses WebTorrent to seed torr
npm install
```
This command installs the following packages:
- express
- webtorrent
- express-rate-limit
- winston
This command installs the necessary packages, including:
- `express`
- `webtorrent`
- `express-rate-limit`
- `winston`
- `jsonstream`
- `multer`
- `proper-lockfile`
3. **Install Electron and Electron Builder for Packaging (Optional):**
If you plan to package the app as a desktop application, install Electron and Electron Builder:
```bash
npm install electron electron-builder --save-dev
```
## Configuration
- The app uses a `config.json` file to store settings. If one does not exist, it will be created automatically with the default storage directory set to `./downloads`.
- You can change the storage directory either via the web interface under **Storage Settings** or by editing the `config.json` file directly.
- The app uses a `config.json` file to store settings, such as the storage directory for torrent files. If `config.json` does not exist, it will be created automatically with the default storage directory set to `./downloads`.
- You can change the storage directory via the web interface under **Storage Settings** or by editing `config.json` directly.
## Running the App
Start the server with:
### Running as a Server
To run the app as a standalone server, use:
```bash
node server.js
```
Then open your web browser and navigate to [http://localhost:3000](http://localhost:3000).
Then, open your web browser and navigate to [http://localhost:3000](http://localhost:3000).
### Running with Electron (Tray Icon Support)
To run the app with Electron for system tray support:
1. Ensure you have installed Electron:
```bash
npm install electron --save-dev
```
2. Start the app using:
```bash
npm run electron
```
- This will launch the app in the background with a tray icon.
- Right-click the tray icon to open the UI or quit the app.
- The UI will automatically open in your default browser on startup.
## Usage
### Adding a Torrent
- Enter a magnet link into the **Add New Torrent** form and click **Add Torrent**.
- The torrent will be added and saved to the `torrents.json` file.
- **Via Magnet Link:** Enter a magnet link into the **Add New Torrent** form and click **Add Torrent**.
- **Via File Upload:** Upload a video file to seed it directly.
### Viewing Torrents
- The active torrents are displayed in a table that shows:
- The active torrents are displayed in a table showing:
- **Name**
- **Progress**
- **Progress** (percentage)
- **Peer Count**
- **Downloaded** (in human-readable format)
- **Uploaded** (in human-readable format)
- **Downloaded** (human-readable format)
- **Uploaded** (human-readable format)
- **Status** (Active or Paused)
- The table refreshes automatically every 5 seconds.
### Pause/Resume
- **Pause:** Click the **Pause** button to stop seeding a torrent (its metadata remains saved).
- **Resume:** Click the **Resume** button to re-add a paused torrent (if files exist, it will be seeded).
- **Pause:** Click the **Pause** button to stop seeding a torrent (metadata remains saved).
- **Resume:** Click the **Resume** button to re-add a paused torrent (seeds if files exist).
### Removing a Torrent
- Click the **Remove** button to delete a torrent from the client and remove its files from disk.
- Click the **Remove** button to delete a torrent and remove its files from disk.
### Changing Storage Settings
- Update the storage directory in the **Storage Settings** form and click **Update Storage Directory**.
- Update the storage directory in the **Storage Settings** modal and click **Update Storage Directory**.
- The current storage directory is displayed below the form.
### Nostr Profile Management
- Use the circular profile button in the top-right corner to log in via Nostr.
- Once logged in, your profile avatar and name will be displayed.
## Graceful Shutdown
The application handles termination signals (SIGINT, SIGTERM) by closing the HTTP server and destroying the WebTorrent client, ensuring that all active processes are terminated properly.
The application handles termination signals (SIGINT, SIGTERM) by closing the HTTP server and destroying the WebTorrent client, ensuring all processes are terminated properly.
## Troubleshooting
- **Max Listeners Warning:**
If you receive a "MaxListenersExceededWarning" message, the default maximum has been increased by setting:
```js
import { EventEmitter } from 'events';
import { EventEmitter } from "events";
EventEmitter.defaultMaxListeners = 50;
```
at the top of the code.
- **Torrent Not Resuming:**
Ensure that the file/folder names in your downloads directory match the `name` property stored in the `torrents.json` file.
Ensure that the file/folder names in your storage directory match the `name` property stored in the `torrents.json` file.
## Packaging the Application as a Windows Executable (.exe)
To package the application as a standalone Windows executable with a system tray icon:
1. **Install Electron Builder:**
```bash
npm install electron-builder --save-dev
```
2. **Run the Build Script:**
- Open a command prompt or terminal **with administrative privileges**. On Windows, right-click Command Prompt or PowerShell and select "Run as administrator."
- Navigate to your project directory:
```bash
cd path\to\bitvid-seeder
```
- Run the build command:
```bash
npm run build
```
This will create a `dist` folder containing the executable (`bitvid-seeder.exe`) and an installer (e.g., `bitvid-seeder Setup.exe`). The executable includes the system tray icon and opens the UI in the default browser on startup.
**Note:** Running the build script with administrative privileges is necessary to handle symbolic link creation during the build process.
## License
-3
View File
@@ -1,3 +0,0 @@
{
"storageDir": "/home/user/Documents/simple-torrent-seeder/downloads"
}
+27
View File
@@ -0,0 +1,27 @@
import { createWindowsInstaller } from "electron-winstaller";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function main() {
try {
await createWindowsInstaller({
appDirectory: path.join(__dirname, "dist", "bitvid-seeder-win32-x64"),
outputDirectory: path.join(__dirname, "dist", "installer"),
authors: "thePR0M3TH3AN",
exe: "bitvid-seeder.exe",
setupExe: "bitvid-installer.exe",
setupIcon: path.join(__dirname, "public", "assets", "favicon.ico"),
noMsi: true,
description: "A nostr and webtorrent torrent seeder application",
});
console.log("Installer created successfully!");
} catch (error) {
console.error(`Error creating installer: ${error.message}`);
process.exit(1);
}
}
main();
+47
View File
@@ -0,0 +1,47 @@
// main.js
import { app, Tray, Menu, shell } from "electron";
import path from "path";
import { fileURLToPath } from "url";
import { startServer } from "./server.js"; // Import your server with a modification
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
let tray = null;
let serverPort = null;
app.on("ready", () => {
// Start the Express server and get the port
serverPort = startServer();
// Create the tray icon
tray = new Tray(path.join(__dirname, "public", "assets", "favicon.ico")); // Use your app icon
const contextMenu = Menu.buildFromTemplate([
{
label: "Open UI",
click: () => {
shell.openExternal(`http://localhost:${serverPort}`);
},
},
{
label: "Quit",
click: () => {
app.quit();
},
},
]);
tray.setToolTip("bitvid-seeder");
tray.setContextMenu(contextMenu);
// Optional: Open the UI in the default browser on startup
shell.openExternal(`http://localhost:${serverPort}`);
});
// Handle app quit
app.on("window-all-closed", () => {
// Do nothing; keep the app running in the tray
});
app.on("quit", () => {
// Perform any cleanup if needed
});
+5021 -3
View File
File diff suppressed because it is too large. Load diff
+46 -4
View File
@@ -1,15 +1,57 @@
{
"name": "simple-torrent-seeder",
"name": "bitvid-seeder",
"version": "1.0.0",
"main": "server.js",
"description": "A nostr and webtorrent torrent seeder application",
"author": "npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx",
"main": "main.js",
"type": "module",
"bin": "server.js",
"pkg": {
"targets": [
"node18-win-x64"
],
"outputPath": "dist",
"assets": [
"public/**/*"
]
},
"scripts": {
"start": "node server.js"
"start": "node server.js",
"electron": "electron .",
"package": "electron-packager . bitvid-seeder --platform=win32 --arch=x64 --out=dist --overwrite --asar --icon=public/assets/png/favicon.ico",
"create-installer": "node create-installer.js",
"build": "electron-builder --win --x64"
},
"build": {
"appId": "com.yourcompany.bitvidseeder",
"productName": "bitvid-seeder",
"directories": {
"output": "dist"
},
"win": {
"icon": "public/assets/png/android-chrome-512x512.png",
"target": [
"nsis"
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
},
"dependencies": {
"express": "^4.18.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"JSONStream": "^1.3.5",
"multer": "^1.4.5-lts.1",
"proper-lockfile": "^4.1.2",
"webtorrent": "^2.5.19",
"winston": "^3.17.0"
},
"devDependencies": {
"electron": "^32.1.2",
"electron-builder": "^24.13.3",
"electron-packager": "^17.1.2",
"electron-winstaller": "^5.4.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M12,0C5.373,0 0,5.373 0,12C0,18.627 5.373,24 12,24C18.627,24 24,18.627 24,12C24,5.373 18.627,0 12,0ZM9.967,16.01C10.531,14.221 11.599,12.078 11.788,11.536C12.061,10.749 11.577,10.4 10.048,11.745L9.708,11.105C11.452,9.208 15.043,8.779 13.821,11.718C13.058,13.553 12.512,14.792 12.2,15.748C11.745,17.141 12.894,16.576 14.019,15.537C14.172,15.787 14.222,15.868 14.375,16.156C11.877,18.534 9.104,18.744 9.967,16.01ZM14.709,7.841C14.177,8.294 13.389,8.284 12.948,7.819C12.507,7.354 12.581,6.611 13.112,6.158C13.644,5.705 14.432,5.716 14.873,6.18C15.312,6.646 15.24,7.389 14.709,7.841Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.0349343,0,0,0.0349343,-55.5414,-28.6416)">
<path d="M1878.62,1496.58C1719.5,1470.41 1597.95,1332.09 1597.95,1165.6C1597.95,980.47 1748.25,830.166 1933.38,830.166C2118.51,830.166 2268.82,980.47 2268.82,1165.6C2268.82,1331.86 2147.6,1470.03 1988.8,1496.47L1988.8,1466.79C2131.38,1440.68 2239.59,1315.68 2239.59,1165.6C2239.59,996.604 2102.38,859.399 1933.38,859.399C1764.39,859.399 1627.18,996.604 1627.18,1165.6C1627.18,1315.91 1735.72,1441.07 1878.62,1466.91L1878.62,1496.58ZM1878.62,1400.68C1771.77,1375.85 1692.06,1279.96 1692.06,1165.6C1692.06,1032.41 1800.19,924.274 1933.38,924.274C2066.57,924.274 2174.71,1032.41 2174.71,1165.6C2174.71,1279.73 2095.32,1375.46 1988.8,1400.53L1988.8,1370.37C2079.02,1345.98 2145.48,1263.49 2145.48,1165.6C2145.48,1048.54 2050.44,953.507 1933.38,953.507C1816.33,953.507 1721.29,1048.54 1721.29,1165.6C1721.29,1263.73 1788.07,1346.38 1878.62,1370.55L1878.62,1400.68ZM1878.62,1304.01C1823.55,1282.18 1784.56,1228.41 1784.56,1165.6C1784.56,1083.46 1851.24,1016.77 1933.38,1016.77C2015.52,1016.77 2082.21,1083.46 2082.21,1165.6C2082.21,1228.16 2043.53,1281.76 1988.8,1303.75L1988.8,1271.59C2026.93,1251.59 2052.98,1211.62 2052.98,1165.6C2052.98,1099.6 1999.39,1046.01 1933.38,1046.01C1867.38,1046.01 1813.79,1099.6 1813.79,1165.6C1813.79,1211.88 1840.13,1252.05 1878.62,1271.93L1878.62,1304.01ZM1933.38,1109.82C1964.17,1109.82 1989.16,1134.82 1989.16,1165.6C1989.16,1196.39 1964.17,1221.38 1933.38,1221.38C1902.6,1221.38 1877.6,1196.39 1877.6,1165.6C1877.6,1134.82 1902.6,1109.82 1933.38,1109.82Z" style="fill:rgb(92,111,138);"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1.09255,0,0,1.09255,-0.61419,-0.557138)">
<path d="M11.546,1.337C17.151,1.337 21.702,5.888 21.702,11.493C21.702,17.099 17.151,21.65 11.546,21.65C5.94,21.65 1.389,17.099 1.389,11.493C1.389,5.888 5.94,1.337 11.546,1.337ZM10.288,13.666C10.735,14.248 11.352,14.54 12.138,14.54C12.878,14.54 13.491,14.29 13.975,13.792C14.46,13.293 14.702,12.697 14.702,12.003C14.702,11.141 14.3,10.526 13.495,10.158C13.924,9.994 14.274,9.743 14.544,9.407C14.814,9.071 14.949,8.687 14.949,8.254C14.949,7.757 14.757,7.325 14.375,6.959C13.992,6.593 13.445,6.409 12.734,6.409C11.929,6.409 11.274,6.652 10.768,7.136C10.262,7.62 9.882,8.454 9.628,9.638L8.143,16.578L9.666,16.578L10.288,13.666ZM11.8,9.445L11.532,10.619C11.611,10.616 11.672,10.614 11.715,10.614C12.741,10.614 13.254,11.043 13.254,11.901C13.254,12.398 13.108,12.773 12.817,13.025C12.525,13.277 12.221,13.403 11.902,13.403C11.566,13.403 11.283,13.287 11.052,13.057C10.822,12.826 10.706,12.491 10.706,12.051C10.706,11.819 10.744,11.524 10.819,11.166L11.162,9.595C11.266,9.112 11.373,8.744 11.484,8.49C11.595,8.236 11.76,8.03 11.98,7.871C12.2,7.712 12.449,7.632 12.728,7.632C12.953,7.632 13.137,7.698 13.278,7.831C13.419,7.963 13.49,8.124 13.49,8.313C13.49,8.592 13.372,8.851 13.136,9.091C12.9,9.33 12.53,9.45 12.026,9.45C11.983,9.45 11.908,9.448 11.8,9.445Z" style="fill:rgb(92,111,138);"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 318.32 108.98">
<defs>
<style>
.cls-1 {
fill: #fe0032;
}
.cls-2 {
fill: #fff;
}
</style>
</defs>
<g>
<rect class="cls-1" x="7.1" y="7.8" width="5.84" height="5.48"/>
<rect class="cls-1" x="7.1" y="18.78" width="24.02" height="5.48"/>
<rect class="cls-1" x="7.1" y="29.77" width="42.85" height="5.48"/>
<rect class="cls-1" x="7.1" y="40.76" width="61.68" height="5.48"/>
<rect class="cls-1" x="7.1" y="51.75" width="80.88" height="5.48"/>
<rect class="cls-1" x="7.1" y="62.74" width="61.68" height="5.48"/>
<rect class="cls-1" x="7.1" y="73.73" width="42.85" height="5.48"/>
<rect class="cls-1" x="7.1" y="84.72" width="24.02" height="5.48"/>
<rect class="cls-1" x="7.1" y="95.71" width="5.84" height="5.48"/>
</g>
<g>
<path class="cls-2" d="M121.28,25.08v44.54c0,5.67,2.79,8.54,8.22,8.54h11.57c5.51,0,8.3-2.87,8.3-8.54v-13.09c0-5.67-2.79-8.46-8.3-8.46h-16.6v-5.83h17.72c8.7,0,13.01,4.47,13.01,13.33v15.01c0,8.86-4.31,13.33-13.01,13.33h-13.73c-8.7,0-13.01-4.47-13.01-13.33V25.08h5.83Z"/>
<path class="cls-2" d="M168.53,25.08v6.26h-5.83v-6.26h5.83ZM168.53,42.64v41.27h-5.83v-41.27h5.83Z"/>
<path class="cls-2" d="M191.36,25.08v17.16h9.02v5.19h-9.02v36.48h-5.83v-36.48h-9.82v-5.19h9.82v-17.16h5.83Z"/>
<path class="cls-2" d="M212.88,42.24l16.84,35.68,16.6-35.68h6.07l-19.56,41.67h-6.31l-20.04-41.67h6.39Z"/>
<path class="cls-2" d="M264.05,25.08v6.26h-5.83v-6.26h5.83ZM264.05,42.24v41.67h-5.83v-41.67h5.83Z"/>
<path class="cls-2" d="M311.21,25.08v45.5c0,8.86-4.31,13.33-13.01,13.33h-13.73c-8.7,0-13.01-4.47-13.01-13.33v-15.01c0-8.86,4.31-13.33,13.01-13.33h17.72v5.83h-16.6c-5.51,0-8.3,2.79-8.3,8.46v13.09c0,5.67,2.79,8.54,8.3,8.54h11.49c5.51,0,8.3-2.87,8.3-8.54V25.08h5.83Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 318.32 108.98">
<defs>
<style>
.cls-1 {
fill: #fe0032;
}
.cls-2 {
fill: #383838;
}
</style>
</defs>
<g>
<rect class="cls-1" x="7.1" y="7.8" width="5.84" height="5.48"/>
<rect class="cls-1" x="7.1" y="18.78" width="24.02" height="5.48"/>
<rect class="cls-1" x="7.1" y="29.77" width="42.85" height="5.48"/>
<rect class="cls-1" x="7.1" y="40.76" width="61.68" height="5.48"/>
<rect class="cls-1" x="7.1" y="51.75" width="80.88" height="5.48"/>
<rect class="cls-1" x="7.1" y="62.74" width="61.68" height="5.48"/>
<rect class="cls-1" x="7.1" y="73.73" width="42.85" height="5.48"/>
<rect class="cls-1" x="7.1" y="84.72" width="24.02" height="5.48"/>
<rect class="cls-1" x="7.1" y="95.71" width="5.84" height="5.48"/>
</g>
<g>
<path class="cls-2" d="M121.28,25.08v44.54c0,5.67,2.79,8.54,8.22,8.54h11.57c5.51,0,8.3-2.87,8.3-8.54v-13.09c0-5.67-2.79-8.46-8.3-8.46h-16.6v-5.83h17.72c8.7,0,13.01,4.47,13.01,13.33v15.01c0,8.86-4.31,13.33-13.01,13.33h-13.73c-8.7,0-13.01-4.47-13.01-13.33V25.08h5.83Z"/>
<path class="cls-2" d="M168.53,25.08v6.26h-5.83v-6.26h5.83ZM168.53,42.64v41.27h-5.83v-41.27h5.83Z"/>
<path class="cls-2" d="M191.36,25.08v17.16h9.02v5.19h-9.02v36.48h-5.83v-36.48h-9.82v-5.19h9.82v-17.16h5.83Z"/>
<path class="cls-2" d="M212.88,42.24l16.84,35.68,16.6-35.68h6.07l-19.56,41.67h-6.31l-20.04-41.67h6.39Z"/>
<path class="cls-2" d="M264.05,25.08v6.26h-5.83v-6.26h5.83ZM264.05,42.24v41.67h-5.83v-41.67h5.83Z"/>
<path class="cls-2" d="M311.21,25.08v45.5c0,8.86-4.31,13.33-13.01,13.33h-13.73c-8.7,0-13.01-4.47-13.01-13.33v-15.01c0-8.86,4.31-13.33,13.01-13.33h17.72v5.83h-16.6c-5.51,0-8.3,2.79-8.3,8.46v13.09c0,5.67,2.79,8.54,8.3,8.54h11.49c5.51,0,8.3-2.87,8.3-8.54V25.08h5.83Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M13.744,8C13.744,8 15.266,0 10.409,0L2,0L2,24L22,24L22,11C22,7.581 16.753,7.255 13.744,8ZM18,19L6,19L6,18L18,18L18,19ZM18,16L6,16L6,15L18,15L18,16ZM18,13L6,13L6,12L18,12L18,13ZM14.568,0.075C16.77,1.249 20.506,4.958 22,6.956C20.714,6.056 17.956,5.299 15.909,5.777C16.131,4.309 15.724,1.243 14.568,0.075Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 817 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M17.374 20.235c2.444-2.981 6.626-8.157 6.626-8.157l-3.846-3.092s-2.857 3.523-6.571 8.097c-4.312 5.312-11.881-2.41-6.671-6.671 4.561-3.729 8.097-6.57 8.097-6.57l-3.092-3.842s-5.173 4.181-8.157 6.621c-2.662 2.175-3.76 4.749-3.76 7.24 0 5.254 4.867 10.139 10.121 10.139 2.487 0 5.064-1.095 7.253-3.765zm4.724-7.953l-1.699 2.111-1.74-1.397 1.701-2.114 1.738 1.4zm-10.386-10.385l1.4 1.738-2.113 1.701-1.397-1.74 2.11-1.699z"/></svg>

After

Width:  |  Height:  |  Size: 519 B

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M12,0C5.373,0 0,5.373 0,12C0,18.627 5.373,24 12,24C18.627,24 24,18.627 24,12C24,5.373 18.627,0 12,0ZM12,22C8.877,22 6.086,20.559 4.251,18.31C4.51,17.722 5.034,17.315 6.118,17.064C8.362,16.546 10.577,16.083 9.511,14.119C6.356,8.299 8.612,5 12,5C15.322,5 17.634,8.177 14.489,14.119C13.454,16.071 15.589,16.535 17.882,17.064C18.964,17.314 19.492,17.719 19.753,18.305C17.917,20.558 15.125,22 12,22Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 909 B

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M12,0C18.623,0 24,5.377 24,12C24,18.623 18.623,24 12,24C5.377,24 0,18.623 0,12C0,5.377 5.377,0 12,0M14.557,16L9.442,16C9.988,18.46 10.883,20.114 12,21.744C13.194,20.003 14.041,18.334 14.557,16M7.4,16L2.833,16C4.069,18.825 6.537,20.972 9.588,21.716C8.54,19.983 7.805,18.058 7.4,16M21.167,16L16.6,16C16.209,17.988 15.505,19.887 14.425,21.694C17.437,20.931 19.942,18.799 21.167,16M7.162,10L2.2,10C1.933,11.313 1.933,12.685 2.2,14L7.115,14C6.996,12.671 7.014,11.328 7.162,10M14.823,10L9.176,10C9.011,11.326 8.991,12.672 9.123,14L14.876,14C15.009,12.672 14.987,11.327 14.823,10M21.8,10L16.837,10C16.985,11.328 17.003,12.671 16.885,14L21.8,14C22.06,12.715 22.073,11.352 21.8,10M9.644,2.271C6.567,3.003 4.077,5.157 2.833,8L7.486,8C7.921,5.958 8.664,4.015 9.644,2.271M11.999,2.223C10.91,3.993 10.089,5.676 9.536,8L14.463,8C13.929,5.754 13.126,4.052 11.999,2.223M14.367,2.292C15.38,4.104 16.1,6.052 16.513,8L21.167,8C19.935,5.184 17.405,3.042 14.367,2.292" style="fill:rgb(92,111,138);"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M12,0C18.623,0 24,5.377 24,12C24,18.623 18.623,24 12,24C5.377,24 0,18.623 0,12C0,5.377 5.377,0 12,0ZM12,2C17.519,2 22,6.481 22,12C22,17.519 17.519,22 12,22C6.481,22 2,17.519 2,12C2,6.481 6.481,2 12,2ZM13.476,14.955C14.464,14.55 15.233,13.744 15.592,12.739L18,6L11.328,8.387C10.322,8.747 9.517,9.518 9.112,10.506L6.047,18L13.476,14.955ZM13.354,10.669C13.905,11.22 13.905,12.115 13.354,12.665C12.803,13.216 11.909,13.216 11.358,12.665C10.807,12.115 10.807,11.22 11.358,10.669C11.909,10.118 12.803,10.118 13.354,10.669Z" style="fill:rgb(92,111,138);"/>
</svg>

After

Width:  |  Height:  |  Size: 1013 B

+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="iconmonstr-weather-64.svg" transform="matrix(1,0,0,1,12,12)">
<g id="iconmonstr-filter-down-lined.svg" transform="matrix(1,0,0,1,-12,-12)">
<path d="M15.344,17.778C15.344,17.364 15.008,17.028 14.594,17.028L9.434,17.028C9.02,17.028 8.684,17.364 8.684,17.778C8.684,18.192 9.02,18.528 9.434,18.528L14.594,18.528C15.008,18.528 15.344,18.192 15.344,17.778ZM17.55,13.778C17.55,13.364 17.214,13.028 16.8,13.028L7.204,13.028C6.79,13.028 6.454,13.364 6.454,13.778C6.454,14.192 6.79,14.528 7.204,14.528L16.8,14.528C17.214,14.528 17.55,14.192 17.55,13.778ZM20,9.778C20,9.364 19.664,9.028 19.25,9.028L4.75,9.028C4.336,9.028 4,9.364 4,9.778C4,10.192 4.336,10.528 4.75,10.528L19.25,10.528C19.664,10.528 20,10.192 20,9.778ZM22,5.778C22,5.364 21.664,5.028 21.25,5.028L2.75,5.028C2.336,5.028 2,5.364 2,5.778C2,6.192 2.336,6.528 2.75,6.528L21.25,6.528C21.664,6.528 22,6.192 22,5.778Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
</g>
<g id="iconmonstr-filter-down-lined.svg1" serif:id="iconmonstr-filter-down-lined.svg">
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M6.676,9.18C5.25,9.171 3.459,9.944 2.093,11.31C1.572,11.831 1.114,12.439 0.76,13.122C1.992,12.189 3.307,11.897 4.846,12.761C5.299,11.562 5.902,10.343 6.676,9.18ZM14.83,17.323C13.566,18.149 12.324,18.745 11.249,19.165C12.112,20.705 11.82,22.018 10.888,23.25C11.572,22.897 12.179,22.438 12.7,21.916C14.07,20.547 14.844,18.751 14.83,17.323ZM19.957,4.035C19.613,4.011 19.276,4 18.946,4C11.777,4 7.697,9.465 6.213,13.86L10.152,17.8C14.677,16.18 20,12.251 20,5.158C20,4.792 19.986,4.418 19.957,4.035ZM11.717,12.293C11.391,11.968 11.391,11.44 11.717,11.115C12.042,10.789 12.57,10.789 12.895,11.115C13.221,11.441 13.221,11.968 12.895,12.293C12.569,12.619 12.042,12.619 11.717,12.293ZM14.073,9.937C13.422,9.287 13.422,8.231 14.073,7.58C14.724,6.929 15.779,6.929 16.429,7.58C17.08,8.231 17.08,9.286 16.429,9.937C15.779,10.587 14.725,10.587 14.073,9.937ZM1.641,20.315L0.886,19.56L5.226,15.237L5.981,15.992L1.641,20.315ZM5.79,21.862L5.035,21.107L8.064,18.053L8.819,18.808L5.79,21.862ZM0.755,24L-0,23.245L5.373,17.881L6.129,18.636L0.755,24ZM21.838,9.709C21.65,10.327 21.165,10.811 20.547,11C21.165,11.188 21.65,11.672 21.838,12.291C22.027,11.672 22.511,11.188 23.129,11C22.511,10.812 22.027,10.328 21.838,9.709ZM7.183,3.205C6.936,4.015 6.302,4.648 5.493,4.895C6.303,5.142 6.936,5.776 7.183,6.585C7.431,5.776 8.064,5.142 8.873,4.895C8.063,4.648 7.431,4.015 7.183,3.205ZM5.356,0C5.157,0.649 4.65,1.157 4,1.355C4.65,1.554 5.157,2.062 5.356,2.71C5.554,2.061 6.062,1.553 6.71,1.355C6.062,1.157 5.555,0.649 5.356,0ZM10.743,0C10.427,1.035 9.616,1.846 8.58,2.163C9.616,2.479 10.427,3.289 10.743,4.326C11.059,3.29 11.87,2.48 12.905,2.163C11.87,1.846 11.06,1.035 10.743,0ZM21.838,13.64C21.522,14.676 20.711,15.486 19.675,15.803C20.711,16.119 21.522,16.965 21.838,18C22.154,16.964 22.965,16.119 24,15.803C22.965,15.486 22.154,14.676 21.838,13.64Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M12,0C5.374,0 0,5.373 0,12C0,17.302 3.438,21.8 8.207,23.387C8.806,23.498 9,23.126 9,22.81L9,20.576C5.662,21.302 4.967,19.16 4.967,19.16C4.421,17.773 3.634,17.404 3.634,17.404C2.545,16.659 3.717,16.675 3.717,16.675C4.922,16.759 5.556,17.912 5.556,17.912C6.626,19.746 8.363,19.216 9.048,18.909C9.155,18.134 9.466,17.604 9.81,17.305C7.145,17 4.343,15.971 4.343,11.374C4.343,10.063 4.812,8.993 5.579,8.153C5.455,7.85 5.044,6.629 5.696,4.977C5.696,4.977 6.704,4.655 8.997,6.207C9.954,5.941 10.98,5.808 12,5.803C13.02,5.808 14.047,5.941 15.006,6.207C17.297,4.655 18.303,4.977 18.303,4.977C18.956,6.63 18.545,7.851 18.421,8.153C19.191,8.993 19.656,10.064 19.656,11.374C19.656,15.983 16.849,16.998 14.177,17.295C14.607,17.667 15,18.397 15,19.517L15,22.81C15,23.129 15.192,23.504 15.801,23.386C20.566,21.797 24,17.3 24,12C24,5.373 18.627,0 12,0Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M23.334,11.96C22.621,11.234 22.462,10.131 22.941,9.233C23.283,8.593 23.307,7.832 23.005,7.171C22.704,6.511 22.112,6.029 21.404,5.869C20.413,5.644 19.682,4.802 19.601,3.788C19.542,3.065 19.15,2.41 18.539,2.018C17.93,1.625 17.172,1.54 16.489,1.789C15.533,2.136 14.463,1.821 13.847,1.013C13.407,0.437 12.723,0.098 11.997,0.098C11.272,0.098 10.588,0.437 10.148,1.013C9.535,1.822 8.465,2.137 7.509,1.79C6.827,1.542 6.069,1.627 5.459,2.019C4.849,2.411 4.456,3.066 4.398,3.789C4.316,4.803 3.586,5.646 2.595,5.87C1.887,6.03 1.295,6.512 0.994,7.172C0.693,7.832 0.717,8.594 1.059,9.233C1.538,10.13 1.379,11.234 0.667,11.96C0.158,12.477 -0.08,13.202 0.023,13.92C0.126,14.638 0.559,15.267 1.193,15.62C2.081,16.115 2.545,17.13 2.337,18.125C2.19,18.835 2.381,19.573 2.856,20.121C3.332,20.67 4.036,20.965 4.758,20.919C5.774,20.856 6.711,21.459 7.075,22.408C7.334,23.086 7.895,23.603 8.592,23.807C9.287,24.011 10.039,23.879 10.623,23.45C11.442,22.847 12.559,22.847 13.377,23.45C13.961,23.88 14.713,24.012 15.408,23.807C16.105,23.603 16.666,23.085 16.926,22.408C17.289,21.459 18.227,20.855 19.242,20.919C19.966,20.965 20.669,20.67 21.144,20.121C21.619,19.573 21.811,18.835 21.663,18.125C21.456,17.13 21.919,16.115 22.808,15.62C23.441,15.266 23.873,14.638 23.977,13.92C24.081,13.202 23.842,12.477 23.334,11.96ZM10.75,17.39L6.25,13.026L8.107,11.169L10.75,13.675L16.393,7.891L18.25,9.748L10.75,17.39Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M12,6.453L21,14.828L21,24L15,24L15,18L9,18L9,24L3,24L3,14.828L12,6.453ZM24,12.148L12,1L0,12.133L1.361,13.598L12,3.73L22.639,13.613L24,12.148Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 656 B

+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="iconmonstr-weather-64.svg" transform="matrix(1,0,0,1,12,12)">
<g id="iconmonstr-whats-hot-1.svg" transform="matrix(1,0,0,1,-12,-12)">
<clipPath id="_clip1">
<rect x="0" y="0" width="24" height="24"/>
</clipPath>
<g clip-path="url(#_clip1)">
<path d="M8.625,0C9.235,7.189 3,9.664 3,15.996C3,20.297 6.069,23.968 12,24C17.931,24.032 21,19.586 21,15.044C21,10.903 18.938,6.998 15.048,4.57C15.972,7.177 14.742,9.558 13.547,10.378C13.617,7.041 12.422,2.089 8.625,0ZM13.336,13C17.091,16.989 14.785,22 11.769,22C9.934,22 8.99,20.735 9,19.423C9.019,16.99 11.737,16.988 13.336,13Z" style="fill:rgb(92,111,138);"/>
</g>
</g>
<g id="iconmonstr-whats-hot-1.svg1" serif:id="iconmonstr-whats-hot-1.svg">
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M21,7.702L12.5,12.322L12.5,22C14.067,21.135 18.879,18.483 20.477,17.601C20.8,17.424 21,17.082 21,16.71L21,7.702ZM11.5,12.321L3,7.599L3,16.605C3,16.975 3.197,17.313 3.514,17.492C5.104,18.39 9.93,21.115 11.5,22L11.5,12.321ZM3.421,6.692L12,11.455L20.672,6.742C20.672,6.742 14.041,3.004 12.486,2.128C12.335,2.043 12.167,2 12,2C11.832,2 11.665,2.043 11.514,2.128C9.959,3.004 3.421,6.692 3.421,6.692Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 909 B

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M21.337,0.458L17.4,4.394C14.951,2.928 11.73,3.25 9.62,5.36L5.359,9.622C3.249,11.732 2.926,14.953 4.391,17.402L0.456,21.337C-0.153,21.946 -0.153,22.934 0.456,23.544C1.065,24.154 2.054,24.153 2.663,23.544L6.598,19.609C9.047,21.074 12.268,20.753 14.378,18.642L18.639,14.381C20.749,12.271 21.072,9.05 19.607,6.6L23.543,2.664C24.152,2.055 24.152,1.067 23.543,0.457C22.934,-0.153 21.946,-0.152 21.337,0.458ZM16.475,12.215L12.214,16.476C11.319,17.37 10.038,17.645 8.904,17.303L10.536,15.671C11.145,15.062 11.145,14.074 10.536,13.464C9.927,12.854 8.938,12.855 8.329,13.464L6.698,15.096C6.355,13.963 6.631,12.681 7.524,11.787L11.785,7.526C12.68,6.632 13.961,6.357 15.095,6.699L13.464,8.329C12.855,8.938 12.855,9.926 13.464,10.536C14.073,11.146 15.062,11.145 15.671,10.536L17.301,8.906C17.644,10.039 17.368,11.321 16.475,12.215Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M22,16.75C22,16.336 21.664,16 21.25,16L2.75,16C2.336,16 2,16.336 2,16.75C2,17.164 2.336,17.5 2.75,17.5L21.25,17.5C21.664,17.5 22,17.164 22,16.75ZM22,11.75C22,11.336 21.664,11 21.25,11L2.75,11C2.336,11 2,11.336 2,11.75C2,12.164 2.336,12.5 2.75,12.5L21.25,12.5C21.664,12.5 22,12.164 22,11.75ZM22,6.75C22,6.336 21.664,6 21.25,6L2.75,6C2.336,6 2,6.336 2,6.75C2,7.164 2.336,7.5 2.75,7.5L21.25,7.5C21.664,7.5 22,7.164 22,6.75Z" style="fill:rgb(17,24,39);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 933 B

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="Artboard1" transform="matrix(0.217336,0,0,0.428004,4.79827,-2.20936)">
<rect x="-22.078" y="5.162" width="110.428" height="56.074" style="fill:none;"/>
<g id="nostr-head.svg" transform="matrix(0.143477,0,0,0.0728563,33.1364,33.1991)">
<g transform="matrix(1,0,0,1,-310,-310)">
<path d="M620,270.227L620,597.655C620,609.968 610.081,619.961 597.859,619.961L332.161,619.961C319.938,619.961 310.02,609.968 310.02,597.655L310.02,536.678C311.23,461.931 319.079,390.332 335.558,357.759C345.438,338.168 361.722,327.506 380.427,321.802C415.768,311.102 477.779,318.419 504.099,317.16C504.099,317.16 583.605,320.346 583.605,274.987C583.605,238.48 548.07,241.352 548.07,241.352C508.902,242.374 479.068,239.699 459.738,232.028C427.365,219.203 426.272,195.678 426.155,187.81C424.554,96.934 291.549,86.037 174.359,108.579C46.235,133.127 175.765,318.143 175.765,565.121L175.765,598.088C175.531,610.204 165.807,620 153.702,620L22.142,620C9.919,620 0,610.008 0,597.694L0,31.393C0,19.08 9.919,9.088 22.142,9.088L145.813,9.088C158.036,9.088 167.955,19.08 167.955,31.393C167.955,49.687 188.378,59.876 203.139,49.215C247.617,17.113 304.709,0 368.595,0C511.714,0 619.922,84.031 619.922,270.227L620,270.227ZM382.419,203.782C382.419,177.424 361.214,156.062 335.051,156.062C308.887,156.062 287.683,177.424 287.683,203.782C287.683,230.14 308.887,251.501 335.051,251.501C361.214,251.501 382.419,230.14 382.419,203.782Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="iconmonstr-weather-64.svg" transform="matrix(1,0,0,1,12,12)">
<g transform="matrix(1,0,0,1,-12,-12)">
<path d="M11,10L0,10L0,8L11,8C11.552,8 12,7.552 12,7C12,6.448 11.552,6 11,6C10.597,6 10.253,6.242 10.095,6.587L8.346,5.631C8.845,4.666 9.84,4 11,4C14.971,4 14.969,10 11,10ZM18,17C18,15.344 16.656,14 15,14L0,14L0,16L15,16C15.552,16 16,16.448 16,17C16,17.552 15.552,18 15,18C14.597,18 14.253,17.758 14.095,17.413L12.346,18.369C12.845,19.334 13.84,20 15,20C16.656,20 18,18.656 18,17ZM19.014,9.345C19.096,8.592 19.726,8 20.5,8C21.327,8 22,8.673 22,9.5C22,10.327 21.327,11 20.5,11L0,11L0,13L20.5,13C22.432,13 24,11.432 24,9.5C24,7.568 22.432,6 20.5,6C18.876,6 17.523,7.116 17.128,8.617L19.014,9.345Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M23.961,8.429C23.13,9.411 22.347,10.347 22,12.204L22,18.887L18,21.366L18,12.205C17.653,10.348 16.87,9.412 16.039,8.43C15.131,7.355 14,6.019 14,3.801L14.019,3.456L12,2L6.455,6L0,2L0,20L6.455,24L12,20L17.545,24L24,20L24,8.382L23.961,8.429ZM11,18.255L7,21.14L7,8.073L11,5.187L11,18.255ZM20,0C17.9,0 16,1.702 16,3.801C16,6.922 19.188,7.252 20,12C20.812,7.252 24,6.922 24,3.801C24,1.702 22.1,0 20,0ZM20,5.5C19.172,5.5 18.5,4.829 18.5,4C18.5,3.171 19.172,2.5 20,2.5C20.828,2.5 21.5,3.171 21.5,4C21.5,4.829 20.828,5.5 20,5.5Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M5 7c2.761 0 5 2.239 5 5s-2.239 5-5 5-5-2.239-5-5 2.239-5 5-5zm11.122 12.065c-.073.301-.122.611-.122.935 0 2.209 1.791 4 4 4s4-1.791 4-4-1.791-4-4-4c-1.165 0-2.204.506-2.935 1.301l-5.488-2.927c-.23.636-.549 1.229-.943 1.764l5.488 2.927zm7.878-15.065c0-2.209-1.791-4-4-4s-4 1.791-4 4c0 .324.049.634.122.935l-5.488 2.927c.395.535.713 1.127.943 1.764l5.488-2.927c.731.795 1.77 1.301 2.935 1.301 2.209 0 4-1.791 4-4z"/></svg>

After

Width:  |  Height:  |  Size: 513 B

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M21.698,10.658L24,12L11.998,19L0,12L2.301,10.658L11.998,16.316L21.698,10.658ZM11.998,21.315L2.301,15.657L0,17L11.998,24L24,17L21.698,15.658L11.998,21.315ZM24,7L11.998,-0L0,7L11.998,14L24,7Z" style="fill:white;fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 694 B

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M21.698,10.658L24,12L11.998,19L0,12L2.301,10.658L11.998,16.316L21.698,10.658ZM11.998,21.315L2.301,15.657L0,17L11.998,24L24,17L21.698,15.658L11.998,21.315ZM24,7L11.998,-0L0,7L11.998,14L24,7Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 704 B

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M24,13.616L24,10.384C22.349,9.797 21.306,9.632 20.781,8.365L20.781,8.364C20.254,7.093 20.881,6.23 21.628,4.657L19.343,2.372C17.782,3.114 16.91,3.747 15.636,3.219L15.635,3.219C14.366,2.693 14.2,1.643 13.616,0L10.384,0C9.802,1.635 9.635,2.692 8.365,3.219L8.364,3.219C7.093,3.747 6.232,3.121 4.657,2.372L2.372,4.657C3.117,6.225 3.747,7.091 3.219,8.364C2.692,9.635 1.635,9.802 0,10.384L0,13.616C1.632,14.196 2.692,14.365 3.219,15.635C3.749,16.917 3.105,17.801 2.372,19.342L4.657,21.628C6.219,20.885 7.091,20.253 8.364,20.781L8.365,20.781C9.635,21.307 9.801,22.36 10.384,24L13.616,24C14.198,22.364 14.366,21.31 15.643,20.778L15.644,20.778C16.906,20.254 17.764,20.879 19.342,21.629L21.627,19.343C20.883,17.78 20.252,16.91 20.779,15.637C21.306,14.366 22.367,14.197 24,13.616ZM12,16C9.791,16 8,14.209 8,12C8,9.791 9.791,8 12,8C14.209,8 16,9.791 16,12C16,14.209 14.209,16 12,16Z" style="fill:white;fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+57
View File
@@ -0,0 +1,57 @@
<div
id="profileModal"
class="hidden fixed inset-0 z-50"
style="background: transparent"
>
<div class="modal-overlay"></div>
<div class="modal-container">
<div class="modal-content flex flex-col">
<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">
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="modal-body flex flex-col items-center justify-center">
<div class="flex items-center space-x-4 mb-4">
<img
id="profileModalAvatar"
src="assets/svg/default-profile.svg"
alt="Profile"
class="profile-avatar object-cover rounded-full"
/>
<p id="profileModalName" class="text-lg font-bold truncate">
Loading...
</p>
</div>
<p class="text-sm text-gray-400">
Additional profile details or user settings can go here.
</p>
</div>
<div class="modal-footer">
<button
id="profileLogoutBtn"
class="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 focus:outline-none"
>
Logout
</button>
</div>
</div>
</div>
</div>
+428 -102
View File
@@ -4,31 +4,96 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>bitvid Torrent Seeder</title>
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="assets/favicon.ico" />
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" />
<link
href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<!-- 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>
<!-- Updated Header with Logo and Text -->
<div class="text-center mb-4">
<img
src="assets/svg/bitvid-logo-light-mode.svg"
alt="Bitvid Logo"
class="img-fluid mb-2"
style="max-width: 200px"
/>
<h2 class="mb-0">Seeder Server</h2>
</div>
<!-- Circular Login/Profile Button -->
<div class="d-flex justify-content-end mb-4">
<button
id="loginButton"
class="btn p-0 rounded-circle me-3"
style="
width: 48px;
height: 48px;
background-color: #1e293b;
display: flex;
align-items: center;
justify-content: center;
"
>
<img
id="loginButtonAvatar"
src="assets/svg/default-profile.svg"
alt="Profile Icon"
style="width: 40px; height: 40px; border-radius: 50%"
/>
</button>
</div>
<!-- Add Torrent Form -->
<div class="card mb-4">
<div class="card-header">Add New Torrent</div>
<div class="card-body">
<form id="addTorrentForm" class="row g-3">
<div class="col-md-9">
<input type="text" id="magnet" class="form-control" placeholder="Enter magnet link" required>
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">Add New Torrent</h5>
</div>
<div class="col-md-3">
</div>
<div class="card-body">
<form id="addTorrentForm" class="row g-3" enctype="multipart/form-data">
<div class="col-md-9">
<input type="text" id="magnet" class="form-control" placeholder="Enter magnet link" />
<label for="videoFile" class="form-label mt-2">Or upload a video file:</label>
<input type="file" id="videoFile" class="form-control" accept="video/*" />
</div>
<div class="col-md-3 align-self-end">
<button type="submit" class="btn btn-primary w-100">Add Torrent</button>
</div>
</form>
</div>
</div>
<!-- Active Torrents Table -->
<div class="card mb-4">
<div class="card-header">Active Torrents</div>
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">Active Torrents</h5>
<div class="d-flex align-items-center">
<div id="paginationContainer" class="me-3">
<!-- Pagination will be inserted here -->
</div>
<button
class="btn btn-link"
data-bs-toggle="modal"
data-bs-target="#settingsModal"
>
<i class="fas fa-cog"></i>
</button>
</div>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table id="torrentTable" class="table table-striped">
@@ -43,166 +108,427 @@
<th>Actions</th>
</tr>
</thead>
<tbody id="torrentList">
<!-- Rows populated dynamically -->
</tbody>
<tbody id="torrentList"></tbody>
</table>
</div>
</div>
</div>
<!-- Storage Settings -->
<div class="card mb-4">
<div class="card-header">Storage Settings</div>
<div class="card-body">
<form id="configForm" class="row g-3">
<div class="col-md-9">
<input type="text" id="storageDir" class="form-control" placeholder="Storage directory" required>
<!-- Settings Modal -->
<div
class="modal fade"
id="settingsModal"
tabindex="-1"
aria-labelledby="settingsModalLabel"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content custom-modal-content">
<div class="modal-header custom-modal-header">
<h5 class="modal-title" id="settingsModalLabel">Settings</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-secondary w-100">Update Storage Directory</button>
<div class="modal-body custom-modal-body">
<form id="configForm">
<div class="mb-3">
<label for="storageDir" class="form-label"
>Storage Directory</label
>
<input
type="text"
id="storageDir"
class="form-control"
placeholder="Storage directory"
required
/>
</div>
<button type="submit" class="btn custom-btn-primary w-100">
Update Storage Directory
</button>
</form>
<p id="currentStorage" class="mt-3"></p>
<p id="currentStorage" class="mt-3 text-center"></p>
</div>
</div>
</div>
</div>
<!-- Profile Modal Container -->
<div id="profileModalContainer"></div>
<!-- Helper to format bytes -->
<script>
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
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];
return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i];
}
</script>
<!-- JavaScript for UI -->
<!-- Main UI Script -->
<script>
async function fetchTorrents() {
window.addEventListener("nostrClientReady", () => {
// Pagination variables
let currentPage = 1;
const limit = 20;
// Profile-related functions
async function updateProfileUI(pubkey) {
const btnAvatar = document.getElementById("loginButtonAvatar");
const profileAvatar = document.getElementById("profileModalAvatar");
const profileName = document.getElementById("profileModalName");
if (!pubkey) {
btnAvatar.src = "assets/svg/default-profile.svg";
if (profileAvatar)
profileAvatar.src = "assets/svg/default-profile.svg";
if (profileName) profileName.textContent = "Not logged in";
return;
}
try {
const res = await fetch('/torrents');
const torrents = await res.json();
const list = document.getElementById('torrentList');
list.innerHTML = '';
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 (profileAvatar) profileAvatar.src = picture;
if (profileName) profileName.textContent = displayName;
} catch (error) {
console.error("Error fetching profile data:", error);
btnAvatar.src = "assets/svg/default-profile.svg";
if (profileAvatar)
profileAvatar.src = "assets/svg/default-profile.svg";
if (profileName) profileName.textContent = pubkey;
}
}
torrents.forEach(torrent => {
const tr = document.createElement('tr');
async function login() {
try {
const pubkey = await window.nostrClient.login();
if (pubkey) {
localStorage.setItem("userPubKey", pubkey);
await updateProfileUI(pubkey);
}
} catch (error) {
console.error("Login failed:", error);
alert("Login failed. Please try again.");
}
}
// Name column
const tdName = document.createElement('td');
function logout() {
window.nostrClient.logout();
localStorage.removeItem("userPubKey");
updateProfileUI(null);
}
async function loadProfileModal() {
try {
const response = await fetch("components/profile-modal.html");
const html = await response.text();
document.getElementById("profileModalContainer").innerHTML = html;
const closeBtn = document.getElementById("closeProfileModal");
const logoutBtn = document.getElementById("profileLogoutBtn");
if (closeBtn) {
closeBtn.addEventListener("click", () => {
toggleProfileModal(false);
});
}
if (logoutBtn) {
logoutBtn.addEventListener("click", () => {
logout();
toggleProfileModal(false);
});
}
} catch (err) {
console.error("Error loading profile modal:", err);
}
}
function toggleProfileModal(show) {
const modal = document.getElementById("profileModal");
if (!modal) return;
modal.classList.toggle("hidden", !show);
}
// Profile initialization
document
.getElementById("loginButton")
.addEventListener("click", async () => {
const userPubKey = localStorage.getItem("userPubKey");
if (!userPubKey) {
await login();
toggleProfileModal(true);
await updateProfileUI(localStorage.getItem("userPubKey"));
} else {
toggleProfileModal(true);
await updateProfileUI(userPubKey);
}
});
const savedPubKey = localStorage.getItem("userPubKey") || null;
if (savedPubKey) {
updateProfileUI(savedPubKey);
}
loadProfileModal();
// Torrent management functions
document
.getElementById("addTorrentForm")
.addEventListener("submit", async (e) => {
e.preventDefault();
if (!localStorage.getItem("userPubKey")) {
alert("Please login to add a torrent.");
return;
}
const magnet = document.getElementById("magnet").value;
const videoFile = document.getElementById("videoFile").files[0];
if (magnet && videoFile) {
alert(
"Please provide either a magnet link or a video file, not both."
);
return;
}
if (magnet) {
// Add torrent via magnet link
await fetch("/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ magnet }),
});
document.getElementById("magnet").value = "";
fetchTorrents(currentPage);
} else if (videoFile) {
// Upload video file
const formData = new FormData();
formData.append("video", videoFile);
await fetch("/upload", {
method: "POST",
body: formData,
});
document.getElementById("videoFile").value = "";
fetchTorrents(currentPage);
} else {
alert("Please provide a magnet link or a video file.");
}
});
async function fetchTorrents(page) {
if (!localStorage.getItem("userPubKey")) return;
try {
const res = await fetch(`/torrents?page=${page}&limit=${limit}`);
if (!res.ok) {
throw new Error(`Server error: ${res.status} ${res.statusText}`);
}
const data = await res.json();
if (!data.torrents) {
console.error("No torrents found in response:", data);
return;
}
const { torrents, total, page: current } = data;
const list = document.getElementById("torrentList");
list.innerHTML = "";
torrents.forEach((torrent) => {
const tr = document.createElement("tr");
const tdName = document.createElement("td");
tdName.textContent = torrent.name;
tr.appendChild(tdName);
// Progress column
const tdProgress = document.createElement('td');
tdProgress.textContent = (torrent.progress * 100).toFixed(2) + '%';
const tdProgress = document.createElement("td");
tdProgress.textContent =
(torrent.progress * 100).toFixed(2) + "%";
tr.appendChild(tdProgress);
// Peers column
const tdPeers = document.createElement('td');
const tdPeers = document.createElement("td");
tdPeers.textContent = torrent.numPeers;
tr.appendChild(tdPeers);
// Downloaded column
const tdDownloaded = document.createElement('td');
const tdDownloaded = document.createElement("td");
tdDownloaded.textContent = formatBytes(torrent.downloaded);
tr.appendChild(tdDownloaded);
// Uploaded column
const tdUploaded = document.createElement('td');
const tdUploaded = document.createElement("td");
tdUploaded.textContent = formatBytes(torrent.uploaded);
tr.appendChild(tdUploaded);
// Status column
const tdStatus = document.createElement('td');
tdStatus.textContent = torrent.paused ? 'Paused' : 'Active';
const tdStatus = document.createElement("td");
tdStatus.textContent = torrent.paused ? "Paused" : "Active";
tr.appendChild(tdStatus);
const tdActions = document.createElement("td");
// Actions column
const tdActions = document.createElement('td');
// Pause/Resume button
const toggleBtn = document.createElement('button');
toggleBtn.className = 'btn btn-warning btn-sm me-2';
const toggleBtn = document.createElement("button");
toggleBtn.className = "btn btn-warning btn-sm me-2";
if (torrent.paused) {
toggleBtn.textContent = 'Resume';
toggleBtn.textContent = "Resume";
toggleBtn.onclick = async () => {
await fetch('/resume/' + torrent.infoHash, { method: 'POST' });
fetchTorrents();
await fetch("/resume/" + torrent.infoHash, {
method: "POST",
});
fetchTorrents(currentPage);
};
} else {
toggleBtn.textContent = 'Pause';
toggleBtn.textContent = "Pause";
toggleBtn.onclick = async () => {
await fetch('/pause/' + torrent.infoHash, { method: 'POST' });
fetchTorrents();
await fetch("/pause/" + torrent.infoHash, { method: "POST" });
fetchTorrents(currentPage);
};
}
tdActions.appendChild(toggleBtn);
// Remove button
const removeBtn = document.createElement('button');
removeBtn.textContent = 'Remove';
removeBtn.className = 'btn btn-danger btn-sm';
const removeBtn = document.createElement("button");
removeBtn.textContent = "Remove";
removeBtn.className = "btn btn-danger btn-sm me-2";
removeBtn.onclick = async () => {
await fetch('/remove/' + torrent.infoHash, { method: 'DELETE' });
fetchTorrents();
await fetch("/remove/" + torrent.infoHash, {
method: "DELETE",
});
fetchTorrents(currentPage);
};
tdActions.appendChild(removeBtn);
tr.appendChild(tdActions);
// Copy Magnet button
const copyMagnetBtn = document.createElement("button");
copyMagnetBtn.textContent = "Copy Magnet";
copyMagnetBtn.className = "btn btn-secondary btn-sm me-2";
copyMagnetBtn.onclick = async () => {
try {
if (torrent.magnet) {
await navigator.clipboard.writeText(torrent.magnet);
alert("Magnet link copied to clipboard!");
} else {
alert("Magnet link not found.");
}
} catch (err) {
console.error("Error copying magnet link:", err);
alert("Failed to copy magnet link.");
}
};
tdActions.appendChild(copyMagnetBtn);
// New Download button
const downloadBtn = document.createElement("button");
downloadBtn.textContent = "Download";
downloadBtn.className = "btn btn-primary btn-sm";
downloadBtn.onclick = () => {
window.location.href = `/download/${torrent.infoHash}`;
};
tdActions.appendChild(downloadBtn);
tr.appendChild(tdActions);
list.appendChild(tr);
});
updatePaginationControls(total, current);
} catch (error) {
console.error('Error fetching torrents:', error);
console.error("Error fetching torrents:", error);
}
}
function updatePaginationControls(total, page) {
const totalPages = Math.ceil(total / limit);
const pagination = document.createElement("div");
pagination.className = "d-flex justify-content-center mt-3";
// Previous button
const prevBtn = document.createElement("button");
prevBtn.className = "btn btn-secondary me-2";
prevBtn.textContent = "Previous";
prevBtn.disabled = page === 1;
prevBtn.onclick = () => {
if (page > 1) {
currentPage = page - 1;
fetchTorrents(currentPage);
}
};
pagination.appendChild(prevBtn);
// Page number buttons
for (let i = 1; i <= totalPages; i++) {
const pageBtn = document.createElement("button");
pageBtn.className =
i === page ? "btn btn-primary me-1" : "btn btn-secondary me-1";
pageBtn.textContent = i;
pageBtn.onclick = () => {
currentPage = i;
fetchTorrents(currentPage);
};
pagination.appendChild(pageBtn);
}
// Next button
const nextBtn = document.createElement("button");
nextBtn.className = "btn btn-secondary";
nextBtn.textContent = "Next";
nextBtn.disabled = page === totalPages;
nextBtn.onclick = () => {
if (page < totalPages) {
currentPage = page + 1;
fetchTorrents(currentPage);
}
};
pagination.appendChild(nextBtn);
// Insert pagination into the designated container in Active Torrents header
const paginationContainer = document.getElementById(
"paginationContainer"
);
const existingPagination =
paginationContainer.querySelector(".d-flex");
if (existingPagination) {
existingPagination.remove(); // Remove any existing pagination to avoid duplicates
}
paginationContainer.appendChild(pagination);
}
async function fetchConfig() {
if (!localStorage.getItem("userPubKey")) return;
try {
const res = await fetch('/config');
const res = await fetch("/config");
const config = await res.json();
document.getElementById('currentStorage').textContent = 'Current storage directory: ' + config.storageDir;
document.getElementById('storageDir').value = config.storageDir;
document.getElementById("currentStorage").textContent =
"Current storage directory: " + config.storageDir;
document.getElementById("storageDir").value = config.storageDir;
} catch (error) {
console.error('Error fetching config:', error);
console.error("Error fetching config:", error);
}
}
document.getElementById('addTorrentForm').addEventListener('submit', async (e) => {
document
.getElementById("configForm")
.addEventListener("submit", async (e) => {
e.preventDefault();
const magnet = document.getElementById('magnet').value;
await fetch('/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ magnet })
});
document.getElementById('magnet').value = '';
fetchTorrents();
});
document.getElementById('configForm').addEventListener('submit', async (e) => {
e.preventDefault();
const storageDir = document.getElementById('storageDir').value;
const res = await fetch('/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ storageDir })
const storageDir = document.getElementById("storageDir").value;
const res = await fetch("/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ storageDir }),
});
const result = await res.json();
document.getElementById('currentStorage').textContent = 'Current storage directory: ' + result.storageDir;
document.getElementById("currentStorage").textContent =
"Current storage directory: " + result.storageDir;
bootstrap.Modal.getInstance(
document.getElementById("settingsModal")
).hide();
});
// Refresh the list and config every 5 seconds
// Initial fetch and periodic updates
fetchTorrents(currentPage);
fetchConfig();
setInterval(() => {
fetchTorrents();
fetchTorrents(currentPage);
fetchConfig();
}, 5000);
fetchTorrents();
fetchConfig();
});
</script>
<!-- Bootstrap Bundle JS -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
</body>
+35
View File
@@ -0,0 +1,35 @@
// 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 };
// Dispatch ready event after initialization
window.dispatchEvent(new Event("nostrClientReady"));
+268
View File
@@ -0,0 +1,268 @@
.hidden {
display: none !important;
}
.fixed {
position: fixed !important;
}
.inset-0 {
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.z-50 {
z-index: 50;
}
/* For the modal layout container */
.modal-container {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal-content {
background-color: #1a1a1a;
border-radius: 0.5rem;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
width: 90%;
max-width: 600px;
margin: 1rem;
}
/* On larger screens, increase the width */
@media (min-width: 1024px) {
.modal-content {
width: 50%;
max-width: 800px;
}
}
/* Constrain modal height */
.modal-content {
max-height: 90vh;
}
/* Example top sticky header gradient from black/80 to transparent */
.modal-header-gradient {
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), transparent);
}
/* Absolute overlay for darker background + blur */
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
z-index: 10;
}
/* For the body content area inside the modal */
.modal-body {
padding: 2rem;
flex: 1;
color: #e5e5e5;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* Footer area */
.modal-footer {
border-top: 1px solid #4b5563;
padding: 1rem 1.5rem;
display: flex;
justify-content: end;
background-color: #2d2d2d;
}
/* Example close button on top-right */
.modal-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
background-color: rgba(0, 0, 0, 0.5);
cursor: pointer;
}
/* Hover/focus states for the close button */
.modal-close-btn:hover {
background-color: rgba(0, 0, 0, 0.7);
}
.modal-close-btn:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.25);
}
/* Additional utility styles */
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.p-4 {
padding: 1rem;
}
.p-6 {
padding: 1.5rem;
}
.text-xl {
font-size: 1.25rem;
}
.font-bold {
font-weight: 700;
}
.text-white {
color: #fff;
}
.rounded-full {
border-radius: 9999px;
}
.profile-avatar {
width: 80px;
height: 80px;
}
/* Logout button styling */
#profileLogoutBtn {
background-color: #ef4444;
color: #ffffff;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
transition: background-color 0.2s;
border: none;
}
#profileLogoutBtn:hover {
background-color: #dc2626;
}
/* Custom settings modal styles */
.custom-modal-content {
background-color: #1a1a1a; /* Dark background */
border-radius: 0.5rem;
color: #e5e5e5; /* Light text color */
}
.custom-modal-header {
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), transparent);
border-bottom: none; /* Remove default border */
padding: 1.5rem; /* Increased padding */
}
.custom-modal-body {
padding: 2rem; /* Increased padding for better spacing */
display: flex;
flex-direction: column;
align-items: center; /* Center content horizontally */
}
.custom-modal-body .form-label {
color: #e5e5e5; /* Light text for labels */
}
.custom-modal-body .form-control {
background-color: #2d2d2d; /* Dark input background */
color: #e5e5e5; /* Light text in inputs */
border: 1px solid #4b5563; /* Subtle border */
}
.custom-modal-body .form-control::placeholder {
color: #9ca3af; /* Placeholder text color */
}
.custom-modal-body .btn-primary {
background-color: #2563eb; /* Bootstrap primary color */
border: none;
width: 100%; /* Full-width button */
}
.custom-modal-body .btn-primary:hover {
background-color: #1d4ed8; /* Darker on hover */
}
.custom-modal-body p {
color: #9ca3af; /* Light gray for current storage text */
}
/* Adjust modal dialog width */
.modal-dialog {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
@media (min-width: 1024px) {
.modal-dialog {
max-width: 800px;
}
}
.custom-btn-primary {
background-color: #ef4444; /* Red color */
border: none;
color: #ffffff;
width: 100%;
}
.custom-btn-primary:hover {
background-color: #dc2626; /* Darker red on hover */
}
.card-header {
padding: 1rem 1.25rem;
background-color: #f8f9fa;
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
min-height: 70px; /* Adjust based on your design */
}
.card-header h5 {
margin-bottom: 0; /* Ensure no extra bottom margin */
font-size: 1.25rem; /* Match Bootstrap h5 size */
}
#activeTorrentsHeader .btn {
font-size: 0.875rem; /* Smaller buttons */
padding: 0.25rem 0.5rem; /* Reduced padding */
}
#paginationContainer .btn {
font-size: 0.875rem; /* Smaller buttons for pagination */
padding: 0.25rem 0.5rem; /* Reduced padding */
}
#paginationContainer .d-flex {
margin-top: 0; /* Remove extra margin */
gap: 0.25rem; /* Space between pagination buttons */
}
+444 -181
View File
@@ -1,95 +1,60 @@
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 JSONStream from "JSONStream";
import { lock, unlock } from "proper-lockfile";
import os from "os";
import multer from "multer"; // Added for file uploads
// Set up Winston logger
// **Logger Setup**
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()],
});
// **App and Client Initialization**
const app = express();
const port = process.env.PORT || 3000;
const client = new WebTorrent();
// Increase max listeners for our client
const client = new WebTorrent({ maxWebConns: 200, utp: false });
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') };
// **Multer Configuration**
const upload = multer({ dest: "uploads/" }); // Temporary storage for uploaded files
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 and Global Variables**
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');
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 Functions**
async function pathExists(p) {
try {
await fsPromises.access(p);
@@ -99,228 +64,526 @@ async function pathExists(p) {
}
}
// GET /torrents - Return both active and paused torrents
app.get('/torrents', (req, res) => {
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 {
// Active torrents: those in the client
const active = client.torrents.map(t => ({
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 };
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 storageDir is empty, set a default based on the user's home directory
if (!config.storageDir || config.storageDir.trim() === "") {
config.storageDir = path.join(
os.homedir(),
"Documents",
"bitvid-seeder"
);
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf8");
} else {
config.storageDir = path.resolve(config.storageDir); // Ensure absolute path
}
}
} catch (err) {
logger.error("Error reading config file, using default config: " + err);
}
}
await loadConfig();
// **Load Torrents with Streaming**
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) => savedTorrents.push(torrent));
parser.on("end", () => resolve());
parser.on("error", (err) => reject(err));
stream.pipe(parser);
});
}
await loadSavedTorrentsFromFile();
// **Asynchronous Save Torrents with File Locking**
async function saveTorrents() {
await lock(TORRENTS_FILE);
try {
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);
}
}
// **Ensure Storage Directory Exists**
let STORAGE_DIR = config.storageDir;
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
// **Torrent Event Debugging**
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**
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 },
(torrentInstance) => {
attachTorrentDebugEvents(torrentInstance);
logger.info(
`Resumed seeding torrent: ${torrentInstance.infoHash}`
);
}
);
} else {
client.add(
torrent.magnet,
{ path: STORAGE_DIR },
(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 configuration
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" });
}
});
// List torrents with pagination
app.get("/torrents", (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const start = (page - 1) * limit;
const end = start + limit;
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,
magnet: t.magnetURI, // Added
}));
// 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,
magnet: s.magnet, // Added
}));
const torrents = [...active, ...paused];
logger.info('GET /torrents returning: ' + JSON.stringify(torrents));
res.json(torrents);
const allTorrents = [...active, ...paused];
const paginatedTorrents = allTorrents.slice(start, end);
res.json({
torrents: paginatedTorrents,
total: allTorrents.length,
page,
limit,
});
} catch (err) {
logger.error('Error listing torrents: ' + err);
res.status(500).json({ error: 'Error listing torrents' });
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,
});
}
});
// POST /pause/:infoHash - Pause a torrent (remove it from the active client and mark as paused)
app.post('/pause/:infoHash', (req, res) => {
// 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;
logger.debug(`Pausing torrent with infoHash: ${infoHash}`); // Added logging
// Validate infoHash
if (!infoHash || typeof infoHash !== "string") {
return res.status(400).json({ error: "Invalid 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 => {
if (err) {
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);
if (entry) entry.paused = true;
saveTorrents();
logger.info(`Torrent paused: ${infoHash}`);
res.json({ message: 'Torrent paused' });
try {
// Wrap client.remove in a promise to handle both synchronous and asynchronous errors
await new Promise((resolve, reject) => {
client.remove(infoHash, { destroyStore: false }, (err) => {
if (err) reject(err);
else resolve();
});
});
const entry = savedTorrents.find((s) => s.infoHash === infoHash);
if (entry) entry.paused = true;
await saveTorrents(); // Await the save operation
res.json({ message: "Torrent paused" });
} catch (err) {
logger.error("Error pausing torrent: " + err);
res.status(500).json({ error: "Error pausing torrent" });
}
});
// POST /resume/:infoHash - Resume a paused torrent (re-add it to the client)
app.post('/resume/:infoHash', async (req, res) => {
// Resume a 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(); // Await the save operation
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 => {
client.seed(torrentPath, { name: entry.name }, (torrent) => {
attachTorrentDebugEvents(torrent);
logger.info(`Torrent resumed (seeding from file): ${torrent.infoHash}`);
res.json({ message: 'Torrent resumed' });
res.json({ message: "Torrent resumed" });
});
} else {
client.add(entry.magnet, { path: STORAGE_DIR, keepSeeding: true }, torrent => {
client.add(torrent.magnet, { path: STORAGE_DIR }, (torrent) => {
attachTorrentDebugEvents(torrent);
logger.info(`Torrent resumed (downloading): ${torrent.infoHash}`);
res.json({ message: 'Torrent resumed' });
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) => {
// Update configuration with path validation
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);
config.storageDir = STORAGE_DIR;
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
const resolvedPath = path.resolve(storageDir);
if (!path.isAbsolute(resolvedPath)) {
throw new Error("Path must be absolute");
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify({ storageDir }, null, 2));
res.json({ message: 'Config updated', storageDir: STORAGE_DIR });
if (!fs.existsSync(resolvedPath)) {
fs.mkdirSync(resolvedPath, { recursive: true });
}
STORAGE_DIR = resolvedPath;
config.storageDir = resolvedPath;
await fsPromises.writeFile(
CONFIG_FILE,
JSON.stringify({ storageDir: resolvedPath }, null, 2)
);
res.json({ message: "Config updated", storageDir: resolvedPath });
} catch (err) {
logger.error('Error updating config: ' + err);
res.status(500).json({ error: 'Error updating config' });
logger.error("Error updating config: " + err.message);
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) => {
// 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 }, (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();
saveTorrents(); // Call 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) => {
// 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; // Temporary file path (e.g., uploads/...)
const fileName = req.file.originalname;
const destPath = path.join(STORAGE_DIR, fileName);
// Ensure the destination directory exists
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
// Copy the file to STORAGE_DIR (works across file systems)
await fsPromises.copyFile(tempFilePath, destPath);
// Delete the temporary file
await fsPromises.unlink(tempFilePath);
// Wait for the file to be accessible
let attempts = 0;
const maxAttempts = 5;
while (attempts < maxAttempts) {
try {
await fsPromises.access(destPath, fs.constants.R_OK);
break; // File is accessible, exit the loop
} catch (err) {
if (err.code === "EBUSY") {
attempts++;
logger.info(
`File ${destPath} is busy, retrying (${attempts}/${maxAttempts})...`
);
await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait 1 second
} else {
throw err; // Throw other errors (e.g., permissions)
}
}
}
if (attempts === maxAttempts) {
throw new Error(
`File ${destPath} remained locked after ${maxAttempts} attempts`
);
}
// Seed the file with WebTorrent
client.seed(destPath, { name: fileName }, (torrent) => {
attachTorrentDebugEvents(torrent);
logger.info(`Torrent added from file upload: ${torrent.infoHash}`);
// Generate magnet link and torrent data
const magnet = torrent.magnetURI;
const torrentData = {
magnet,
infoHash: torrent.infoHash,
name: fileName,
paused: false,
dateAdded: new Date().toISOString(),
size: torrent.length,
};
savedTorrents.push(torrentData);
saveTorrents(); // Call 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' });
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(); // Await the save operation
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" });
}
});
// Start the server
// **Serve Static Files**
app.use(express.static("public"));
// **Resource Usage Monitoring**
setInterval(() => {
const cpuUsage = os.loadavg()[0]; // 1-minute load average
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.paused).length
}`
);
}, 60000); // Log every minute
// **Start Server Function**
function startServer() {
const server = app.listen(port, () => {
logger.info(`Server running on http://localhost:${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);
return port; // Return the port immediately (it's assigned synchronously)
}
// **Conditionally Start Server**
if (process.argv[1] === fileURLToPath(import.meta.url)) {
startServer(); // Start server directly if run as `node server.js`
}
export { startServer }; // Export for Electron or other modules
-163
View File
@@ -1,163 +0,0 @@
[
{
"magnet": "magnet:?xt=urn:btih:0b0176838873aa20b1d16e15f77bd4e4aa36a706&dn=bitvid-logo-loop.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "0b0176838873aa20b1d16e15f77bd4e4aa36a706",
"name": "bitvid-logo-loop.mp4",
"paused": false
},
{
"magnet": "magnet:?xt=urn:btih:846b56e6ac935e02a6047dc9369129c3bf013822&dn=Alien+Plexus+in+Cinema+4D.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "846b56e6ac935e02a6047dc9369129c3bf013822",
"name": "Alien Plexus in Cinema 4D.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:a92964583d6bd03b5a420a474a96f2b47d19fd43&dn=SeedSigner_SingleSig_Guide.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "a92964583d6bd03b5a420a474a96f2b47d19fd43",
"name": "SeedSigner_SingleSig_Guide.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:733aadb6a5ecef19890a78fb2251761a5c31a2b3&dn=Cinema+4D+Modular+Moving+Belt+System.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "733aadb6a5ecef19890a78fb2251761a5c31a2b3",
"name": "Cinema 4D Modular Moving Belt System.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:75b295c6ab1c7c2d61860b7a6273692e82981ab8&dn=Brementown+Musicians+UB+Iwerks+ComiColor.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "75b295c6ab1c7c2d61860b7a6273692e82981ab8",
"name": "Brementown Musicians UB Iwerks ComiColor.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:88594aaacbde40ef3e2510c47374ec0aa396c08e&dn=bbb_sunflower_1080p_30fps_normal.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=http%3A%2F%2Fdistribution.bbb3d.renderfarming.net%2Fvideo%2Fmp4%2Fbbb_sunflower_1080p_30fps_normal.mp4",
"infoHash": "88594aaacbde40ef3e2510c47374ec0aa396c08e",
"name": "bbb_sunflower_1080p_30fps_normal.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:2fbfaa29267664d476dbdde7aba651848866ce26&dn=Cinema+4D+Demo+Reel+2016.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "2fbfaa29267664d476dbdde7aba651848866ce26",
"name": "Cinema 4D Demo Reel 2016.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:f1935383c133210aa91d4d0797ed00a37b71695f&dn=Low+Poly+Train+Animation.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "f1935383c133210aa91d4d0797ed00a37b71695f",
"name": "Low Poly Train Animation.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:836242885c0556348ab30843ae58f19d2062031f&dn=Modular+Conveyor+Belt+Cinema+4d.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "836242885c0556348ab30843ae58f19d2062031f",
"name": "Modular Conveyor Belt Cinema 4d.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:b0c791fc84709e8fc32139524e6dde1490688f7e&dn=Jet+Engine+In+Cinema+4D+with+TurbulenceFD.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "b0c791fc84709e8fc32139524e6dde1490688f7e",
"name": "Jet Engine In Cinema 4D with TurbulenceFD.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:d018504190917ae2e263ed59b78faed939ed8541&dn=Cinema+4D+Fire+Pit.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "d018504190917ae2e263ed59b78faed939ed8541",
"name": "Cinema 4D Fire Pit.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:34e609103acd9015e15abfb6b4a45787bf53e23d&dn=The+Grid+(Cinema+4D+Mograph).mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "34e609103acd9015e15abfb6b4a45787bf53e23d",
"name": "The Grid (Cinema 4D Mograph).mp4"
},
{
"magnet": "magnet:?xt=urn:btih:96846efcda07c86f07b13c7f9435a5074a2c5013&dn=Cinema+4D+Fire+Pit+Final+Render.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "96846efcda07c86f07b13c7f9435a5074a2c5013",
"name": "Cinema 4D Fire Pit Final Render.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:0ed417874545b09b95b9349c5d3cbadafa4232e8&dn=Alien+Planet+Ring+Animation+Cinema+4D.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "0ed417874545b09b95b9349c5d3cbadafa4232e8",
"name": "Alien Planet Ring Animation Cinema 4D.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:9465ae875afa17e59d273c9782c6b69e22c0ad36&dn=Alien+Planet+In+Cinema+4D.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "9465ae875afa17e59d273c9782c6b69e22c0ad36",
"name": "Alien Planet In Cinema 4D.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:84a66806f990a77474120977755c1aab943e32d1&dn=Low+Poly+Portrait+-+Speed+Art.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "84a66806f990a77474120977755c1aab943e32d1",
"name": "Low Poly Portrait - Speed Art.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:b81fb49e494974cc4e099d284db7d3724f16eeae&dn=_the+Oven+Balls_+Cinema+4D+Camera+Projection+Mapping.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "b81fb49e494974cc4e099d284db7d3724f16eeae",
"name": "_the Oven Balls_ Cinema 4D Camera Projection Mapping.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:78e1e2fc3ec7edeb99ac3235a3e57e73dd9503b3&dn=_The+Oven_++Test.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "78e1e2fc3ec7edeb99ac3235a3e57e73dd9503b3",
"name": "_The Oven_ Test.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:8e321597d1aaac1799219ac353cc8f64dfa76c54&dn=Mocha-Cola+Package+Design+Cinema+4D.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "8e321597d1aaac1799219ac353cc8f64dfa76c54",
"name": "Mocha-Cola Package Design Cinema 4D.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:b352b54254c97765f276e28ffa37ceff3d010187&dn=3D+Animated+Planet+Cloud+with+Cinema+4D+(no+plugins).mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "b352b54254c97765f276e28ffa37ceff3d010187",
"name": "3D Animated Planet Cloud with Cinema 4D (no plugins).mp4"
},
{
"magnet": "magnet:?xt=urn:btih:08ada5a7a6183aae1e09d831df6748d566095a10&dn=Sintel&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&tr=wss%3A%2F%2Ftracker.fastcast.nz&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F",
"infoHash": "08ada5a7a6183aae1e09d831df6748d566095a10",
"name": "Sintel"
},
{
"magnet": "magnet:?xt=urn:btih:12cbeee588f9c5486526759de8e4ee5c8135e214&dn=bb_minnie_the_moocher_512kb.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "12cbeee588f9c5486526759de8e4ee5c8135e214",
"name": "bb_minnie_the_moocher_512kb.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:c80d2fb6e86f3244a646a533b430b95f00224974&dn=Popeye_Assault_and_Flattery_512kb.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "c80d2fb6e86f3244a646a533b430b95f00224974",
"name": "Popeye_Assault_and_Flattery_512kb.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:9fb39a1a4a598271f6f6be7ab8fb3fa83ce3b488&dn=woody_woodpecker_pantry_panic_512kb.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "9fb39a1a4a598271f6f6be7ab8fb3fa83ce3b488",
"name": "woody_woodpecker_pantry_panic_512kb.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:93efbbf0d639545a130079e8acaa2d0c329ced86&dn=PopeyeAliBaba_512kb.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "93efbbf0d639545a130079e8acaa2d0c329ced86",
"name": "PopeyeAliBaba_512kb.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:5f665051d91d06dca147af6b9bfbcc43873b1640&dn=superman_the_mechanical_monsters_512kb.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "5f665051d91d06dca147af6b9bfbcc43873b1640",
"name": "superman_the_mechanical_monsters_512kb.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:940f031cef17dea3392b0a6ad454d9faac0a0e5f&dn=Popeye_meetsSinbadtheSailor_512kb.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "940f031cef17dea3392b0a6ad454d9faac0a0e5f",
"name": "Popeye_meetsSinbadtheSailor_512kb.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:742aadd563de11fb69938354e6ae492b1dd5a354&dn=Popeye_forPresident_512kb.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "742aadd563de11fb69938354e6ae492b1dd5a354",
"name": "Popeye_forPresident_512kb.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:52041e146267ec9fb63fc8441571a6e82fc57ad9&dn=spree_lunch_512kb.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "52041e146267ec9fb63fc8441571a6e82fc57ad9",
"name": "spree_lunch_512kb.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:3d0ecc6bc869dbea152a74ba0d94f9539da2cff1&dn=Popeye_Cookin_with_Gags_512kb.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "3d0ecc6bc869dbea152a74ba0d94f9539da2cff1",
"name": "Popeye_Cookin_with_Gags_512kb.mp4"
},
{
"magnet": "magnet:?xt=urn:btih:6a02592d2bbc069628cd5ed8a54f88ee06ac0ba5&dn=CosmosLaundromatFirstCycle&tr=http%3A%2F%2Fbt1.archive.org%3A6969%2Fannounce&tr=http%3A%2F%2Fbt2.archive.org%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=http%3A%2F%2Fia601508.us.archive.org%2F14%2Fitems%2F&ws=http%3A%2F%2Fia801508.us.archive.org%2F14%2Fitems%2F&ws=https%3A%2F%2Farchive.org%2Fdownload%2F",
"infoHash": "6a02592d2bbc069628cd5ed8a54f88ee06ac0ba5",
"name": "CosmosLaundromatFirstCycle"
},
{
"magnet": "magnet:?xt=urn:btih:02767050e0be2fd4db9a2ad6c12416ac806ed6ed&dn=tears_of_steel_1080p.webm&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com",
"infoHash": "02767050e0be2fd4db9a2ad6c12416ac806ed6ed",
"name": "tears_of_steel_1080p.webm"
}
]