From 4ec6b1bd41b8e3b22389a16b91f3a6520cbe7b00 Mon Sep 17 00:00:00 2001 From: Power2All Date: Sat, 7 Mar 2026 11:57:50 +0100 Subject: [PATCH 01/24] Fixing some bugs --- Cargo.toml | 1 - README.md | 100 ++++++++++++- src/config/files.rs | 106 ++++++++++++++ src/config/impls/seeder_config.rs | 1 + src/config/impls/torrents_config.rs | 86 ++++++++++- src/config/mod.rs | 1 + src/config/structs/global_config.rs | 3 + src/config/structs/seeder_config.rs | 1 + src/config/structs/torrent_entry.rs | 6 +- src/main.rs | 1 + src/torrent/impls/torrent_builder.rs | 2 +- src/torrent/torrent.rs | 2 +- src/web/api.rs | 43 ++++-- src/web/app.js | 101 ++++++++++--- src/web/index.html | 103 +++++++------- src/web/style.css | 205 ++++++++++++++++++++++++++- tests/test_config.rs | 6 +- 17 files changed, 676 insertions(+), 92 deletions(-) create mode 100644 src/config/files.rs diff --git a/Cargo.toml b/Cargo.toml index 4720815..d36ba91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,3 @@ - [package] name = "bittseeder" version = "0.1.0" diff --git a/README.md b/README.md index 09bd8b9..ec8771f 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,13 @@ ![Test](https://github.com/Power2All/bittseeder/actions/workflows/rust.yml/badge.svg) []() []() +[](https://github.com/Power2All/bittseeder) A fast, unified Rust seeder that serves torrent data over **BitTorrent** (BT wire protocol) and **WebRTC** (RTC data channels) simultaneously — or either one on its own. BittSeeder handles both protocols from a single binary. Both share the same torrent file, piece data, tracker URL list, and upload counter. You choose the protocol globally or per-torrent via YAML or CLI. ---- +**✨ Recent Enhancements**: Private torrent support (BEP-27), cross-platform path normalization, persistent detail views, enhanced dark mode, and improved UI/UX. See [Recent Improvements](#recent-improvements) for details. ## Table of contents @@ -38,7 +39,27 @@ BittSeeder handles both protocols from a single binary. Both share the same torr - [Architecture overview](#architecture-overview) - [License](#license) ---- +## Recent Improvements + +### v0.1.1 Series Enhancements + +#### UI/UX Improvements +- **Persistent Detail Views**: Torrent detail dropdowns now stay open during real-time stats updates instead of closing every second +- **Cross-Platform Path Normalization**: All file paths display with forward slashes (`/`) on all operating systems for consistency +- **Enhanced Dark Mode**: Comprehensive theme support across detail rows, code blocks, expand buttons, and interactive elements +- **Reorganized Add Torrent Form**: Improved layout with logical field grouping, optimal sizing, and better visual hierarchy +- **Professional Button Styling**: Modern expand/collapse buttons with smooth animations and proper hover effects + +#### Backend Features +- **Private Torrent Support (BEP-27)**: Create private torrents with a single toggle; the private flag disables DHT and PEX for private trackers +- **Path Normalization Helper**: Automatic conversion of Windows backslashes to forward slashes in all API responses +- **Smart Table Updates**: Stats now update via targeted DOM manipulation instead of full table rebuilds for better performance + +#### Code Quality +- **Zero Clippy Warnings**: All code quality issues resolved +- **Comprehensive Testing**: All test fixtures updated for new features +- **Type Safety**: Proper handling of `Cow` and `String` types throughout the codebase + ## Requirements @@ -243,10 +264,18 @@ torrents: rtc_interval: 3 # seconds (converted to ms internally) enabled: true + - name: "Movie Directory" # Directories are automatically scanned + file: + - /data/movies/ # All files in this directory will be included + allowed_extensions: ["mp4", "mkv", "avi"] + trackers: + - http://tracker.example.com/announce + enabled: true + - name: "Re-seed from .torrent" torrent_file: /data/existing.torrent file: - - /data/existing_content/ + - /data/existing_content/ # Directory scanned automatically trackers: [] # read from .torrent file automatically enabled: true ``` @@ -281,7 +310,8 @@ torrents: | Key | Type | Default | Description | |---|---|---|---| | `name` | `string` | file name | Torrent display name | -| `file` | `[path]` | — | Files or directories to seed (required unless `torrent_file` is set) | +| `file` | `[path]` | — | Files or directories to seed (required unless `torrent_file` is set; directories are automatically scanned) | +| `allowed_extensions` | `[string]` | — | Only include files with these extensions when scanning directories (e.g. `["mp4", "mkv", "avi"]`) | | `trackers` | `[url]` | `[]` | Tracker announce URLs | | `torrent_file` | `path` | — | Existing `.torrent` to re-seed | | `magnet` | `string` | — | Magnet URI (tracker URLs extracted automatically) | @@ -293,8 +323,59 @@ torrents: | `ice` | `[url]` | *(global)* | Per-torrent ICE server list | | `rtc_interval` | `u64` | *(global)* | Per-torrent RTC signaling interval in **seconds** | | `enabled` | `bool` | `true` | Set `false` to skip this torrent without removing it | +| `private` | `bool` | `false` | **Private torrent flag** (BEP-27) — disables DHT and PEX for private trackers | + +### Directory Scanning & File Validation + +**Automatic directory scanning:** When a directory is specified in the `file` field, BittSeeder automatically scans it recursively and includes all found files in the torrent. + +**Example - Seed a directory:** + +```yaml +torrents: + - name: "Movies" + file: + - /movies/ + allowed_extensions: ["mp4", "mkv", "avi"] + enabled: true +``` + +**Example - Mix individual files and directories:** + +```yaml +torrents: + - name: "Media Collection" + file: + - /movies/blockbuster.mp4 + - /tv-shows/ # Directory - automatically scanned + - /music/playlist.m3u + allowed_extensions: ["mp4", "mkv", "avi", "mp3", "flac"] + enabled: true +``` + +**Extension filtering:** Use `allowed_extensions` to only include specific file types when scanning directories: -> **Protocol resolution order:** per-torrent `protocol` → CLI `--protocol` → YAML `config.protocol` → `both` +```yaml +torrents: + - name: "Music Library" + file: + - /music/ + allowed_extensions: + - "mp3" + - "flac" + - "wav" + enabled: true +``` + +**Automatic file validation:** Files are automatically validated at runtime when seeding starts. BittSeeder checks: +- ✅ File exists and is readable +- ✅ File is not empty +- ✅ File can be opened and read +- ✅ File data matches torrent piece hashes + +If validation fails, the torrent will not seed and an error is logged indicating which file and/or piece failed validation. + +**Source Folder fallback in web UI:** When adding torrents via the web UI, if no Data Path is specified, the system automatically uses the configured Source Folder from Global Settings → Network (the same folder used by Batch Add). This makes it easy to add multiple torrents from the same location. > **ICE resolution order:** per-torrent `ice` → YAML `config.rtc_ice_servers` → Google STUN x2 --- @@ -306,13 +387,19 @@ The web UI starts automatically on port `8090` (override with `--web-port` or `c Features: - Live **Peers** and **Upload Speed** charts (24 h / 48 h / 72 h window) - Per-torrent uploaded bytes and active peer count, updated every second via WebSocket +- **Persistent Detail Views** — expandable torrent details that stay open during real-time stats updates (no more flickering!) +- **Cross-Platform Path Display** — all file paths normalized to forward slashes for consistent display on Windows, Linux, and macOS - Add, edit, enable/disable, and delete torrents without restarting +- **Improved Form Layout** — reorganized Add Torrent form with logical field grouping and optimal sizing +- **Private Torrent Support** — create private torrents (BEP-27) with a single toggle; private flag disables DHT and PEX +- **Enhanced Dark Mode** — full theme support across all UI elements including detail rows, code blocks, and interactive elements - **Batch Add** — scan a configured source folder and register every top-level file/folder as a new torrent entry in one click - **Upload `.torrent`** — upload an existing `.torrent` file directly from your browser instead of typing a server-side path - **Upload Files / Folders** — upload any file or entire folder from your browser directly to the server. Files are transferred in chunks with per-chunk SHA-256 validation; a full-file SHA-256 hash check is performed on finalize. Upload progress and hash-verification progress are shown live -- Dark/light theme toggle +- Dark/light theme toggle with comprehensive theming - Live **Console** log viewer (last 10 000 lines, streaming via WebSocket) - Fully responsive — works on desktop, tablet, and mobile +- Fully responsive — works on desktop, tablet, and mobile ### Authentication @@ -552,6 +639,7 @@ BittSeeder implements the following [BitTorrent Enhancement Proposals](https://w | [BEP 15](https://www.bittorrent.org/beps/bep_0015.html) | UDP Tracker Protocol | Full connect/announce/stopped lifecycle over UDP (`udp://` tracker URLs) | | [BEP 19](https://www.bittorrent.org/beps/bep_0019.html) | WebSeed (GetRight style) | `url-list` field written to generated `.torrent` files when `--webseed` / `webseed` entries are configured | | [BEP 23](https://www.bittorrent.org/beps/bep_0023.html) | Tracker Returns Compact Peer Lists | Always requests `compact=1`; parses 6-byte compact IPv4 peer entries (4-byte IP + 2-byte port) | +| [BEP 27](https://www.bittorrent.org/beps/bep_0027.html) | Private Torrents | Private flag (`private:1`) in torrent info dict — disables DHT and PEX | | [BEP 52](https://www.bittorrent.org/beps/bep_0052.html) | The BitTorrent Protocol v2 | Full v2 torrent creation (SHA-256 piece hashing, per-file Merkle trees, `file tree` info structure); hybrid v1+v2 torrents; v2 magnet links (`xt=urn:btmh:1220…`) | --- diff --git a/src/config/files.rs b/src/config/files.rs new file mode 100644 index 0000000..6b4da0f --- /dev/null +++ b/src/config/files.rs @@ -0,0 +1,106 @@ +use std::path::PathBuf; +use std::fs::File; +use std::io::Read; + +pub fn validate_file(path: &PathBuf) -> Result<(), String> { + let metadata = match std::fs::metadata(path) { + Ok(meta) => meta, + Err(e) => return Err(format!("cannot read file metadata: {}", e)), + }; + if !metadata.is_file() { + return Err("not a regular file".to_string()); + } + if metadata.len() == 0 { + return Err("file is empty".to_string()); + } + let file = match File::open(path) { + Ok(f) => f, + Err(e) => return Err(format!("cannot open file: {}", e)), + }; + let mut buffer = [0u8; 4096]; + let mut handle = file; + match handle.read(&mut buffer) { + Ok(n) => { + if n == 0 { + return Err("cannot read from file".to_string()); + } + } + Err(e) => return Err(format!("file read error: {}", e)), + } + Ok(()) +} + +pub fn scan_folder_for_files( + folder: &str, + validate: bool, + allowed_extensions: Option<&[String]>, +) -> Result, String> { + let folder_path = PathBuf::from(folder); + if !folder_path.exists() || !folder_path.is_dir() { + return Err(format!("Scan folder does not exist or is not a directory: {}", folder)); + } + let mut found_files = Vec::new(); + let mut invalid_count = 0; + let mut skipped_count = 0; + let entries = match std::fs::read_dir(&folder_path) { + Ok(entries) => entries, + Err(e) => return Err(format!("Failed to read directory {}: {}", folder, e)), + }; + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.file_name() + .and_then(|n| n.to_str()) + .map(|s| s.starts_with('.')) + .unwrap_or(false) + { + continue; + } + if let Some(allowed) = allowed_extensions + && let Some(ext) = path.extension().and_then(|e| e.to_str()) + { + let ext_lower = ext.to_lowercase(); + if !allowed.iter().any(|a| a.to_lowercase() == ext_lower) { + skipped_count += 1; + continue; + } + } + if path.is_file() { + if validate { + match validate_file(&path) { + Ok(_) => found_files.push(path), + Err(e) => { + invalid_count += 1; + log::warn!("[Config] Skipping invalid file {}: {}", path.display(), e); + } + } + } else { + found_files.push(path); + } + } else if path.is_dir() { + match scan_folder_for_files(path.to_str().unwrap(), validate, allowed_extensions) { + Ok(sub_files) => found_files.extend(sub_files), + Err(e) => { + log::warn!("[Config] Failed to scan subdirectory {}: {}", path.display(), e); + } + } + } + } + if found_files.is_empty() { + let mut error_msg = format!("No valid files found in folder: {}", folder); + if invalid_count > 0 { + error_msg.push_str(&format!(" ({} invalid files skipped)", invalid_count)); + } + if skipped_count > 0 { + error_msg.push_str(&format!(" ({} files skipped by filters)", skipped_count)); + } + return Err(error_msg); + } + log::info!( + "[Config] Scanned folder '{}': found {} valid files, {} invalid, {} skipped", + folder, + found_files.len(), + invalid_count, + skipped_count + ); + Ok(found_files) +} \ No newline at end of file diff --git a/src/config/impls/seeder_config.rs b/src/config/impls/seeder_config.rs index 825d11c..14e6039 100644 --- a/src/config/impls/seeder_config.rs +++ b/src/config/impls/seeder_config.rs @@ -24,6 +24,7 @@ impl Default for SeederConfig { upload_limit: None, proxy: None, show_stats: true, + private: false, } } } \ No newline at end of file diff --git a/src/config/impls/torrents_config.rs b/src/config/impls/torrents_config.rs index f3faeb2..caca718 100644 --- a/src/config/impls/torrents_config.rs +++ b/src/config/impls/torrents_config.rs @@ -2,6 +2,7 @@ use crate::config::enums::seed_protocol::SeedProtocol; use crate::config::structs::proxy_config::ProxyConfig; use crate::config::structs::seeder_config::SeederConfig; use crate::config::structs::torrent_entry::TorrentEntry; +use crate::torrent::torrent::collect_dir_files; use crate::torrent::enums::torrent_version::TorrentVersion; use std::path::PathBuf; @@ -14,11 +15,91 @@ impl TorrentEntry { global_ice: &[String], global_rtc_interval_ms: u64, ) -> Result { + if let Some(ref torrent_path) = self.torrent_file + && !self.create_torrent + { + let path = PathBuf::from(torrent_path); + if !path.exists() { + return Err(format!("Torrent file does not exist: {}. Either create the torrent first or enable 'Create torrent if it doesn't exist'.", path.display())); + } + } if self.file.is_empty() && self.torrent_file.is_none() { return Err("torrent entry needs at least one file or a torrent_file path".to_string()); } - let file_paths: Vec = self.file.iter().map(PathBuf::from).collect(); - let out_file = self.out.as_ref().map(PathBuf::from); + let file_paths = if self.allowed_extensions.is_some() { + let mut filtered_files = Vec::new(); + for file_path in &self.file { + let path = PathBuf::from(file_path); + if path.is_dir() && path.exists() { + let mut dir_files: Vec<(PathBuf, Vec)> = Vec::new(); + match collect_dir_files(&path, &path, &mut dir_files) { + Ok(_) => { + for (file_path, _name_parts) in dir_files { + if let Some(allowed) = &self.allowed_extensions + && let Some(ext) = file_path.extension().and_then(|e| e.to_str()) + { + let ext_lower = ext.to_lowercase(); + if !allowed.iter().any(|a| a.to_lowercase() == ext_lower) { + log::debug!("[Config] Skipping {} (extension not allowed)", file_path.display()); + continue; + } + } + filtered_files.push(file_path); + } + } + Err(e) => { + log::warn!("[Config] Failed to scan directory {}: {} - skipping", path.display(), e); + } + } + } else { + if let Some(allowed) = &self.allowed_extensions + && let Some(ext) = path.extension().and_then(|e| e.to_str()) + { + let ext_lower = ext.to_lowercase(); + if !allowed.iter().any(|a| a.to_lowercase() == ext_lower) { + log::debug!("[Config] Skipping {} (extension not allowed)", path.display()); + continue; + } + } + filtered_files.push(path); + } + } + if filtered_files.is_empty() && self.torrent_file.is_none() && !self.file.is_empty() { + return Err("No files found after extension filtering. Check your allowed_extensions setting.".to_string()); + } + log::info!( + "[Config] Processed {} file(s): found {} after extension filtering", + self.file.len(), + filtered_files.len() + ); + filtered_files + } else { + let mut all_files = Vec::new(); + for file_path in &self.file { + let path = PathBuf::from(file_path); + if path.is_dir() && path.exists() { + let mut dir_files: Vec<(PathBuf, Vec)> = Vec::new(); + match collect_dir_files(&path, &path, &mut dir_files) { + Ok(_) => { + for (file_path, _name_parts) in dir_files { + all_files.push(file_path); + } + } + Err(e) => { + log::warn!("[Config] Failed to scan directory {}: {} - skipping", path.display(), e); + } + } + } else { + all_files.push(path); + } + } + all_files + }; + let out_file = if self.create_torrent { + self.torrent_file.as_ref().map(PathBuf::from) + } else { + None + }; let webseed_urls = self.webseed.clone().unwrap_or_default(); let protocol = self.protocol.clone().unwrap_or(global_protocol); let ice_servers = self.ice.clone().unwrap_or_else(|| { @@ -56,6 +137,7 @@ impl TorrentEntry { upload_limit: self.upload_limit, proxy: proxy.cloned(), show_stats: true, + private: self.private, }) } } \ No newline at end of file diff --git a/src/config/mod.rs b/src/config/mod.rs index 91b7e9a..29ca9e8 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,3 +1,4 @@ +pub mod files; pub mod enums; pub mod impls; pub mod structs; \ No newline at end of file diff --git a/src/config/structs/global_config.rs b/src/config/structs/global_config.rs index f4d613f..ebdb077 100644 --- a/src/config/structs/global_config.rs +++ b/src/config/structs/global_config.rs @@ -28,4 +28,7 @@ pub struct GlobalConfig { pub letsencrypt_http_port: Option, pub web_login_rate_limit: Option, pub totp_secret: Option, + pub validate_files: Option, + pub skip_invalid_files: Option, + pub allowed_extensions: Option>, } \ No newline at end of file diff --git a/src/config/structs/seeder_config.rs b/src/config/structs/seeder_config.rs index 0057870..d11bb61 100644 --- a/src/config/structs/seeder_config.rs +++ b/src/config/structs/seeder_config.rs @@ -21,4 +21,5 @@ pub struct SeederConfig { pub upload_limit: Option, pub proxy: Option, pub show_stats: bool, + pub private: bool, } \ No newline at end of file diff --git a/src/config/structs/torrent_entry.rs b/src/config/structs/torrent_entry.rs index 1b67fd4..2c2f978 100644 --- a/src/config/structs/torrent_entry.rs +++ b/src/config/structs/torrent_entry.rs @@ -10,7 +10,6 @@ fn default_true() -> bool { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TorrentEntry { - pub out: Option, pub name: Option, #[serde(default)] pub file: Vec, @@ -23,8 +22,13 @@ pub struct TorrentEntry { pub protocol: Option, pub version: Option, pub torrent_file: Option, + pub create_torrent: bool, pub magnet: Option, #[serde(default = "default_true")] pub enabled: bool, pub upload_limit: Option, + #[serde(default)] + pub allowed_extensions: Option>, + #[serde(default)] + pub private: bool, } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 2ef2986..dd329b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -332,6 +332,7 @@ async fn main() { upload_limit: None, proxy, show_stats: true, + private: false, }; println!("=== Seeder (BT+RTC) ==="); println!("Protocol: {}", match &protocol { diff --git a/src/torrent/impls/torrent_builder.rs b/src/torrent/impls/torrent_builder.rs index cbbad14..5f34cfb 100644 --- a/src/torrent/impls/torrent_builder.rs +++ b/src/torrent/impls/torrent_builder.rs @@ -11,7 +11,7 @@ use crate::torrent::torrent::{ collect_dir_files, parse_magnet, parse_torrent_meta, - torrent_creation_date + torrent_creation_date, }; use std::io; diff --git a/src/torrent/torrent.rs b/src/torrent/torrent.rs index 0a32143..d9ac8cd 100644 --- a/src/torrent/torrent.rs +++ b/src/torrent/torrent.rs @@ -729,7 +729,7 @@ pub fn build_v2_torrent_bencode( for url in webseed_urls { write_bencode_string(&mut out, url.as_bytes()); } - out.push(b'e'); + out.extend_from_slice(b"e"); } } out.push(b'e'); diff --git a/src/web/api.rs b/src/web/api.rs index c625f5e..b111589 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -34,13 +34,20 @@ use std::collections::{ }; use std::io; use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{ + AtomicU64, + Ordering +}; use std::time::{ Duration, Instant }; use tokio::sync::broadcast; +fn normalize_path(path: String) -> String { + path.replace(r#"\"#, r#"/"#) +} + const SESSION_TTL: Duration = Duration::from_secs(3600); pub fn verify_totp(secret_base32: &str, code: &str) -> bool { @@ -274,8 +281,8 @@ pub async fn browse(req: HttpRequest, query: Query, data: Data, body: Json) -> HttpResponse { continue; } let path = dir_entry.path(); - let path_str = path.to_string_lossy().into_owned(); + let path_str = normalize_path(path.to_string_lossy().into_owned()); if existing_paths.contains(&path_str) { skipped += 1; continue; @@ -709,7 +734,6 @@ pub async fn batch_add(req: HttpRequest, data: Data) -> HttpResponse { .to_string() }; file.torrents.push(TorrentEntry { - out: None, name: Some(torrent_name), file: vec![path_str], trackers: vec![], @@ -722,6 +746,9 @@ pub async fn batch_add(req: HttpRequest, data: Data) -> HttpResponse { magnet: None, enabled: true, upload_limit: None, + allowed_extensions: None, + create_torrent: false, + private: false, }); added += 1; } diff --git a/src/web/app.js b/src/web/app.js index 3e108be..7654e77 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -424,6 +424,27 @@ function updateChart() { } } +let expandedRows = new Set(); + +function updateTableStats() { + torrents.forEach((t, i) => { + const name = t.name || (t.file && t.file[0]) || t.torrent_file || t.magnet || 'torrent-' + i; + const st = stats[name] || {}; + const uploaded = st.uploaded !== undefined ? fmtBytes(st.uploaded) : '—'; + const peers = st.peer_count !== undefined ? st.peer_count : '—'; + const peersCell = document.getElementById(`peers-${i}`); + const uploadedCell = document.getElementById(`uploaded-${i}`); + if (peersCell) peersCell.textContent = peers; + if (uploadedCell) uploadedCell.textContent = uploaded; + }); + const totalPeers = Object.values(stats).reduce((sum, s) => sum + (s.peer_count || 0), 0); + const totalRate = Object.values(stats).reduce((sum, s) => sum + (s.uploaded || 0), 0); + if (!ws || ws.readyState !== WebSocket.OPEN) { + document.getElementById('status-text').textContent = + 'Peers: ' + totalPeers + ' Upload: ' + fmtBytes(totalRate) + '/s'; + } +} + function handleStatsMsg(msg) { wsData.push({ ts: msg.ts, peers: msg.peers, rate: msg.rate }); const cutoff = msg.ts - WS_MAX_HOURS * 3600; @@ -431,7 +452,7 @@ function handleStatsMsg(msg) { updateChart(); if (msg.torrents) { stats = msg.torrents; - renderTable(); + updateTableStats(); } document.getElementById('status-text').textContent = 'Peers: ' + msg.peers + ' Upload: ' + fmtBytes(msg.rate) + '/s'; @@ -500,11 +521,7 @@ async function loadStats() { const r = await apiFetch('/api/status'); const data = await r.json(); stats = data.torrents || {}; - if (!ws || ws.readyState !== WebSocket.OPEN) { - document.getElementById('status-text').textContent = - 'Active seeders: ' + Object.keys(stats).length; - } - renderTable(); + updateTableStats(); } catch(_) {} } @@ -528,15 +545,19 @@ function renderTable() { const proto = (t.protocol || 'both').toLowerCase(); const protoClass = proto === 'bt' ? 'proto-bt' : proto === 'rtc' ? 'proto-rtc' : 'proto-both'; const protoLabel = `${escHtml(proto)}`; - html += ` - - ${escHtml(name)} - ${peers} - ${uploaded} + ${peers} + ${uploaded} - + -
+
Protocol: ${protoLabel} Version: ${escHtml(version)} Enabled: ${enabledLabel} Upload Limit: ${limit} + ${t.file && t.file.length ? `Files: ${t.file.map(f => '' + escHtml(f) + '').join(', ')}` : ''} + ${t.torrent_file ? `Torrent: ${escHtml(t.torrent_file)}` : ''} + ${t.trackers && t.trackers.length ? `Trackers: ${t.trackers.map(tr => '' + escHtml(tr) + '').join(', ')}` : ''} + ${t.create_torrent ? `Create Torrent: Yes` : ''} + ${t.private ? `Private: Yes` : ''}
`; }); document.getElementById('torrent-tbody').innerHTML = html; } - function toggleDetail(i) { - const row = document.getElementById('detail-row-' + i); + const row = document.getElementById('detail-row-' + i); const icon = document.getElementById('expand-icon-' + i); - const open = row.style.display === 'none'; - row.style.display = open ? '' : 'none'; - icon.className = (open ? 'chevron down' : 'chevron right') + ' icon'; + const isOpen = row.style.display !== 'none'; + if (isOpen) { + row.style.display = 'none'; + icon.className = 'chevron right icon'; + expandedRows.delete(i); + } else { + row.style.display = ''; + icon.className = 'chevron down icon'; + expandedRows.add(i); + } icon.style.margin = '0'; } +function toggleCreateTorrent() { + const createTorrent = document.getElementById('f-create-torrent').checked; + const versionSelect = document.getElementById('f-version'); + const privateContainer = document.getElementById('f-private-container'); + if (createTorrent) { + versionSelect.disabled = false; + privateContainer.style.display = 'block'; + } else { + versionSelect.disabled = true; + privateContainer.style.display = 'none'; + } +} + function toggleAddForm() { const f = document.getElementById('add-form'); f.style.display = f.style.display === 'none' ? 'block' : 'none'; @@ -584,10 +629,10 @@ async function addTorrent() { const protocol = document.getElementById('f-protocol').value; const entry = { name: document.getElementById('f-name').value.trim() || null, - out: document.getElementById('f-out').value.trim() || null, file: path ? [path] : [], trackers, torrent_file: document.getElementById('f-torrent-file').value.trim() || null, + create_torrent: document.getElementById('f-create-torrent').checked, magnet: document.getElementById('f-magnet').value.trim() || null, enabled: document.getElementById('f-enabled').checked, upload_limit: parseInt(document.getElementById('f-upload-limit').value) || null, @@ -596,6 +641,11 @@ async function addTorrent() { protocol: protocol !== 'both' ? protocol : null, ice: iceServers, rtc_interval: rtcInterval, + private: document.getElementById('f-private').checked || false, + allowed_extensions: (() => { + const ext = document.getElementById('f-allowed-extensions').value.trim(); + return ext ? ext.split(',').map(s=>s.trim()).filter(Boolean) : null; + })(), }; try { const r = await apiFetch('/api/torrents', { @@ -603,6 +653,16 @@ async function addTorrent() { body: JSON.stringify(entry), }); if (r.ok) { + const data = await r.json(); + if (data.using_source_folder) { + const infoMsg = document.createElement('div'); + infoMsg.className = 'ui info message'; + infoMsg.style.marginTop = '12px'; + infoMsg.innerHTML = ` Using Source Folder from Global Settings: ${data.source_folder}`; + const form = document.getElementById('form-add'); + form.appendChild(infoMsg); + setTimeout(() => infoMsg.remove(), 5000); + } toggleAddForm(); await loadTorrents(); } else { @@ -612,7 +672,6 @@ async function addTorrent() { if (e.message !== 'Unauthorized') alert('Error: ' + e.message); } } - async function toggleEnabled(i) { const t = {...torrents[i], enabled: !torrents[i].enabled}; try { diff --git a/src/web/index.html b/src/web/index.html index 140a04e..60933d2 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -116,19 +116,11 @@