Compare commits
22 Commits
0c36aaeb76
...
v0.2.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
93e6a7939f
|
|||
|
004fa17f42
|
|||
|
e20481b957
|
|||
|
52a9d5e43d
|
|||
|
f7f97d084a
|
|||
|
95c1164366
|
|||
|
ca89ba18f1
|
|||
|
81a837de99
|
|||
|
f63a8d7647
|
|||
|
24d758da1c
|
|||
|
5ae88de22f
|
|||
|
82faef4cba
|
|||
|
2396795617
|
|||
|
f2c6be22cb
|
|||
|
60664a084c
|
|||
|
5a49b3e0b1
|
|||
|
aff6acbec1
|
|||
|
63eabb82ca
|
|||
|
69a9f8e0f8
|
|||
|
2d0345eb86
|
|||
|
69621c22c7
|
|||
|
f3b8190e68
|
5
.copr/Makefile
Normal file
5
.copr/Makefile
Normal file
@@ -0,0 +1,5 @@
|
||||
srpm:
|
||||
./dist.sh
|
||||
rpmbuild -bs monsoon.spec \
|
||||
--define "_sourcedir $(CURDIR)" \
|
||||
--define "_srcrpmdir $(outdir)"
|
||||
151
.gitea/workflows/ci.yml
Normal file
151
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,151 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Format, lint, build, test
|
||||
runs-on: fedora
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --check --all
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace -- -D warnings
|
||||
|
||||
- name: Build (Rust)
|
||||
run: cargo build --workspace
|
||||
|
||||
- name: Test (Rust)
|
||||
run: cargo test --workspace
|
||||
|
||||
- name: Install frontend deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build desktop frontend
|
||||
run: pnpm build
|
||||
|
||||
- name: Install web frontend deps
|
||||
run: cd monsoon-web && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build web frontend
|
||||
run: cd monsoon-web && pnpm build
|
||||
|
||||
- name: Validate desktop file
|
||||
run: desktop-file-validate data/cafe.lair.monsoon.desktop
|
||||
|
||||
- name: Validate AppStream metadata
|
||||
run: appstreamcli validate --no-net data/cafe.lair.monsoon.metainfo.xml
|
||||
|
||||
rpm:
|
||||
name: Build SRPM
|
||||
runs-on: fedora
|
||||
needs: check
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
else
|
||||
VERSION=$(sed -n '/\[workspace\.package\]/,/\[/{ s/^version *= *"\(.*\)"/\1/p }' Cargo.toml)
|
||||
fi
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Building version: ${VERSION}"
|
||||
|
||||
- name: Stamp version into all manifests
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||
# Cargo workspace version (single source of truth for Rust crates)
|
||||
sed -i '/\[workspace\.package\]/,/\[/{ s/^version = ".*"/version = "'"${VERSION}"'"/ }' Cargo.toml
|
||||
# Tauri
|
||||
sed -i 's/"version": "[^"]*"/"version": "'"${VERSION}"'"/' src-tauri/tauri.conf.json
|
||||
# Frontend package.json files
|
||||
sed -i 's/"version": "[^"]*"/"version": "'"${VERSION}"'"/' package.json
|
||||
sed -i 's/"version": "[^"]*"/"version": "'"${VERSION}"'"/' monsoon-web/package.json
|
||||
# RPM spec
|
||||
sed -i "s/^Version:.*/Version: ${VERSION}/" monsoon.spec
|
||||
echo "Stamped version ${VERSION} into all manifests"
|
||||
|
||||
- name: Pre-build frontends
|
||||
run: |
|
||||
set -ex
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
ls -la dist/
|
||||
cd monsoon-web
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
ls -la dist/
|
||||
cd ..
|
||||
|
||||
- name: Generate source tarball
|
||||
run: |
|
||||
set -ex
|
||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||
# Verify dist/ exists before tarring
|
||||
ls -la dist/index.html
|
||||
ls -la monsoon-web/dist/index.html
|
||||
# Include pre-built dist/ and monsoon-web/dist/ so the RPM build
|
||||
# doesn't need Node.js/pnpm. Write to /tmp to avoid "file changed
|
||||
# as we read it" from tar writing into the directory it's reading.
|
||||
tar czf /tmp/monsoon-${VERSION}.tar.gz \
|
||||
--transform "s,^\.,monsoon-${VERSION}," \
|
||||
--exclude='./target' \
|
||||
--exclude='./node_modules' \
|
||||
--exclude='./monsoon-web/node_modules' \
|
||||
--exclude='./vendor' \
|
||||
--exclude='./.git' \
|
||||
--exclude='*.tar.gz' \
|
||||
--exclude='*.src.rpm' \
|
||||
.
|
||||
mv /tmp/monsoon-${VERSION}.tar.gz .
|
||||
# Verify frontend assets are in the tarball
|
||||
tar tzf monsoon-${VERSION}.tar.gz | grep "dist/index.html"
|
||||
|
||||
- name: Vendor Rust dependencies
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||
cargo vendor vendor/
|
||||
tar czf monsoon-${VERSION}-vendor.tar.gz vendor/
|
||||
rm -rf vendor/
|
||||
|
||||
- name: Build SRPM
|
||||
run: |
|
||||
rpmbuild -bs monsoon.spec \
|
||||
--define "_sourcedir $(pwd)" \
|
||||
--define "_srcrpmdir $(pwd)"
|
||||
|
||||
- name: Upload SRPM artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: srpm
|
||||
path: '*.src.rpm'
|
||||
|
||||
copr:
|
||||
name: Publish to COPR
|
||||
runs-on: fedora
|
||||
needs: rpm
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- name: Download SRPM
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: srpm
|
||||
|
||||
- name: Configure copr-cli
|
||||
run: |
|
||||
mkdir -p ~/.config
|
||||
echo "${{ secrets.COPR_CONFIG }}" > ~/.config/copr
|
||||
|
||||
- name: Submit build to COPR
|
||||
run: copr-cli build monsoon *.src.rpm
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,8 @@
|
||||
/target
|
||||
/dist
|
||||
/node_modules
|
||||
/vendor
|
||||
*.tar.gz
|
||||
*.src.rpm
|
||||
*.log
|
||||
.env
|
||||
|
||||
@@ -7,6 +7,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "GPL-3.0-or-later"
|
||||
repository = "https://git.lair.cafe/monsoon/monsoon"
|
||||
|
||||
65
README.md
65
README.md
@@ -1,11 +1,21 @@
|
||||
# Monsoon
|
||||
|
||||
[](https://copr.fedorainfracloud.org/coprs/grenade/monsoon/package/monsoon/)
|
||||
|
||||
A fast BitTorrent client for the GNOME desktop and headless servers, built on the [Vortex](https://github.com/Nehliin/vortex) io-uring engine.
|
||||
|
||||
Two interfaces:
|
||||
- **Desktop app** -- Tauri + Svelte GUI with GNOME integration
|
||||
- **Headless server** -- REST API + WebSocket + web GUI for remote/LAN deployment
|
||||
|
||||
## Install from COPR (Fedora)
|
||||
|
||||
```bash
|
||||
sudo dnf copr enable grenade/monsoon
|
||||
sudo dnf install monsoon # desktop app
|
||||
sudo dnf install monsoon-server # headless server
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Torrent List
|
||||
@@ -20,46 +30,39 @@ Two interfaces:
|
||||
### Files
|
||||

|
||||
|
||||
## Dependencies
|
||||
## Building from Source
|
||||
|
||||
### Fedora
|
||||
### Dependencies (Fedora)
|
||||
|
||||
```bash
|
||||
sudo dnf install gtk3-devel webkit2gtk4.1-devel libsoup3-devel \
|
||||
sudo dnf install rust cargo gcc nodejs pnpm \
|
||||
gtk3-devel webkit2gtk4.1-devel libsoup3-devel \
|
||||
pango-devel gdk-pixbuf2-devel glib2-devel libappindicator-gtk3-devel
|
||||
```
|
||||
|
||||
The GTK dependencies are only needed for the desktop app. The headless server has no system library requirements beyond a working Rust toolchain.
|
||||
The GTK dependencies are only needed for the desktop app. The headless server only requires `rust`, `cargo`, and `gcc`.
|
||||
|
||||
### Toolchain
|
||||
|
||||
- Rust 1.85+ (edition 2024)
|
||||
- Node.js 18+
|
||||
- [pnpm](https://pnpm.io)
|
||||
- [Tauri CLI](https://tauri.app): `cargo install tauri-cli` (desktop app only)
|
||||
|
||||
## Desktop App
|
||||
|
||||
### Development
|
||||
### Desktop App
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cargo tauri dev
|
||||
pnpm install && pnpm build # build frontend
|
||||
cargo build --release -p monsoon # build desktop binary (embeds frontend)
|
||||
```
|
||||
|
||||
### Release
|
||||
For development with hot-reload: `cargo install tauri-cli && cargo tauri dev`
|
||||
|
||||
### Headless Server
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cargo tauri build
|
||||
cd monsoon-web && pnpm install && pnpm build && cd ..
|
||||
cargo build --release -p monsoon-server
|
||||
```
|
||||
|
||||
The release binary is at `target/release/monsoon` with the frontend assets embedded.
|
||||
|
||||
### Install
|
||||
### Manual Install
|
||||
|
||||
```bash
|
||||
sudo install -Dm755 target/release/monsoon /usr/local/bin/monsoon
|
||||
sudo install -Dm755 target/release/monsoon-server /usr/local/bin/monsoon-server
|
||||
sudo install -Dm644 data/cafe.lair.monsoon.desktop /usr/share/applications/cafe.lair.monsoon.desktop
|
||||
sudo install -Dm644 data/cafe.lair.monsoon.metainfo.xml /usr/share/metainfo/cafe.lair.monsoon.metainfo.xml
|
||||
sudo install -Dm644 src-tauri/icons/icon.png /usr/share/icons/hicolor/256x256/apps/cafe.lair.monsoon.png
|
||||
@@ -70,18 +73,11 @@ sudo gtk-update-icon-cache /usr/share/icons/hicolor/
|
||||
|
||||
## Headless Server
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
cd monsoon-web && pnpm install && pnpm build && cd ..
|
||||
cargo build --release -p monsoon-server
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# With web GUI (run from the repo root, or set MONSOON_WEB_DIR)
|
||||
cargo run --bin monsoon-server
|
||||
monsoon-server
|
||||
|
||||
# Or with explicit web asset path
|
||||
MONSOON_WEB_DIR=/path/to/monsoon-web/dist monsoon-server
|
||||
@@ -89,6 +85,13 @@ MONSOON_WEB_DIR=/path/to/monsoon-web/dist monsoon-server
|
||||
|
||||
The server listens on `0.0.0.0:3000` by default. Open `http://server-ip:3000` in a browser for the web GUI.
|
||||
|
||||
### Systemd
|
||||
|
||||
```bash
|
||||
sudo install -Dm644 data/monsoon-server.service /usr/lib/systemd/system/monsoon-server.service
|
||||
sudo systemctl enable --now monsoon-server
|
||||
```
|
||||
|
||||
### REST API
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
@@ -138,12 +141,14 @@ Configure in `~/.config/monsoon/config.toml`:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
listen_addr = "0.0.0.0:3000"
|
||||
max_concurrent_downloads = 3
|
||||
min_peers_before_queue = 2
|
||||
seed_completed_by_default = true
|
||||
webhook_url = "http://localhost:8080/hook"
|
||||
```
|
||||
|
||||
- **listen_addr** -- HTTP API and web GUI listen address (default `0.0.0.0:3000`)
|
||||
- **max_concurrent_downloads** -- new torrents are queued when all slots are full; the next queued torrent auto-starts when a slot frees up
|
||||
- **min_peers_before_queue** -- if a torrent has fewer peers than this and download speed is below 1 KiB/s for 30 consecutive seconds, it rotates to the back of the queue
|
||||
- **seed_completed_by_default** -- when `false`, completed torrents are paused instead of seeding
|
||||
|
||||
17
data/monsoon-server.service
Normal file
17
data/monsoon-server.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Monsoon BitTorrent Server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/monsoon-server
|
||||
User=monsoon
|
||||
Group=monsoon
|
||||
Environment=MONSOON_WEB_DIR=/usr/share/monsoon/web
|
||||
StateDirectory=monsoon
|
||||
ConfigurationDirectory=monsoon
|
||||
CacheDirectory=monsoon
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
23
dist.sh
Executable file
23
dist.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
VERSION=$(sed -n '/\[workspace\.package\]/,/\[/{ s/^version *= *"\(.*\)"/\1/p }' Cargo.toml)
|
||||
NAME=monsoon
|
||||
|
||||
echo "Packaging ${NAME}-${VERSION}..."
|
||||
|
||||
# Source tarball from git
|
||||
git archive --prefix=${NAME}-${VERSION}/ HEAD | gzip > ${NAME}-${VERSION}.tar.gz
|
||||
echo " Created ${NAME}-${VERSION}.tar.gz"
|
||||
|
||||
# Vendored Rust dependencies (includes git deps like vortex)
|
||||
cargo vendor vendor/
|
||||
tar czf ${NAME}-${VERSION}-vendor.tar.gz vendor/
|
||||
rm -rf vendor/
|
||||
echo " Created ${NAME}-${VERSION}-vendor.tar.gz"
|
||||
|
||||
echo ""
|
||||
echo "To build SRPM:"
|
||||
echo " rpmbuild -bs ${NAME}.spec \\"
|
||||
echo " --define \"_sourcedir \$(pwd)\" \\"
|
||||
echo " --define \"_srcrpmdir \$(pwd)\""
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "monsoon-core"
|
||||
version = "0.1.0"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ pub struct MonsoonConfig {
|
||||
pub server: ServerConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct ServerConfig {
|
||||
/// HTTP API listen address (default "0.0.0.0:3000")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -40,18 +40,6 @@ pub struct ServerConfig {
|
||||
pub webhook_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
listen_addr: None,
|
||||
max_concurrent_downloads: None,
|
||||
min_peers_before_queue: None,
|
||||
seed_completed_by_default: None,
|
||||
webhook_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||
pub struct PathsConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::Arc;
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use crossbeam_channel::Sender;
|
||||
@@ -94,7 +94,7 @@ impl ManagedTorrent {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_details(&self, download_dir: &PathBuf) -> TorrentDetails {
|
||||
pub fn to_details(&self, download_dir: &Path) -> TorrentDetails {
|
||||
TorrentDetails {
|
||||
id: self.id.clone(),
|
||||
name: self.snapshot.name.clone(),
|
||||
@@ -148,11 +148,11 @@ impl TorrentManager {
|
||||
let (event_tx, event_rx) = crossbeam_channel::unbounded();
|
||||
|
||||
let mut builder = Dht::builder();
|
||||
if dht_cache_path.exists() {
|
||||
if let Ok(list) = std::fs::read_to_string(&dht_cache_path) {
|
||||
let cached_nodes: Vec<String> = list.lines().map(|l| l.to_string()).collect();
|
||||
builder.extra_bootstrap(&cached_nodes);
|
||||
}
|
||||
if dht_cache_path.exists()
|
||||
&& let Ok(list) = std::fs::read_to_string(&dht_cache_path)
|
||||
{
|
||||
let cached_nodes: Vec<String> = list.lines().map(|l| l.to_string()).collect();
|
||||
builder.extra_bootstrap(&cached_nodes);
|
||||
}
|
||||
let dht = builder.build().unwrap_or_else(|e| {
|
||||
log::error!("Failed to build DHT client with cache: {e}");
|
||||
@@ -305,15 +305,12 @@ impl TorrentManager {
|
||||
}
|
||||
|
||||
pub fn remove_torrent(&mut self, id: &str) -> Result<(), anyhow::Error> {
|
||||
let was_downloading = self
|
||||
.torrents
|
||||
.get(id)
|
||||
.is_some_and(|t| {
|
||||
matches!(
|
||||
t.snapshot.state,
|
||||
TorrentState::Downloading | TorrentState::DownloadingMetadata
|
||||
)
|
||||
});
|
||||
let was_downloading = self.torrents.get(id).is_some_and(|t| {
|
||||
matches!(
|
||||
t.snapshot.state,
|
||||
TorrentState::Downloading | TorrentState::DownloadingMetadata
|
||||
)
|
||||
});
|
||||
|
||||
if let Some(mut t) = self.torrents.remove(id) {
|
||||
self.registry.remove(&hex::encode(t.info_hash));
|
||||
@@ -395,15 +392,15 @@ impl TorrentManager {
|
||||
}
|
||||
|
||||
let id = self.queue.remove(0);
|
||||
if let Some(t) = self.torrents.get_mut(&id) {
|
||||
if t.snapshot.state == TorrentState::Queued {
|
||||
let _ = t.cmd_tx.send(Command::Resume);
|
||||
t.dht_paused.store(false, Ordering::Relaxed);
|
||||
t.snapshot.state = TorrentState::Downloading;
|
||||
t.snapshot.stall_ticks = 0;
|
||||
log::info!("Starting queued torrent: {}", t.snapshot.name);
|
||||
return Some(id);
|
||||
}
|
||||
if let Some(t) = self.torrents.get_mut(&id)
|
||||
&& t.snapshot.state == TorrentState::Queued
|
||||
{
|
||||
let _ = t.cmd_tx.send(Command::Resume);
|
||||
t.dht_paused.store(false, Ordering::Relaxed);
|
||||
t.snapshot.state = TorrentState::Downloading;
|
||||
t.snapshot.stall_ticks = 0;
|
||||
log::info!("Starting queued torrent: {}", t.snapshot.name);
|
||||
return Some(id);
|
||||
}
|
||||
// If that one wasn't valid, try the next
|
||||
self.start_next_queued()
|
||||
@@ -439,10 +436,10 @@ impl TorrentManager {
|
||||
t.snapshot.download_speed = *download_throughput;
|
||||
t.snapshot.upload_speed = *upload_throughput;
|
||||
t.snapshot.num_peers = *num_peers;
|
||||
if let Some(total) = t.snapshot.total_pieces {
|
||||
if total > 0 {
|
||||
t.snapshot.progress = *pieces_completed as f64 / total as f64;
|
||||
}
|
||||
if let Some(total) = t.snapshot.total_pieces
|
||||
&& total > 0
|
||||
{
|
||||
t.snapshot.progress = *pieces_completed as f64 / total as f64;
|
||||
}
|
||||
|
||||
// Stall detection: track consecutive ticks with low speed + low peers
|
||||
@@ -461,30 +458,34 @@ impl TorrentManager {
|
||||
}
|
||||
|
||||
// Check if this torrent should be rotated out
|
||||
let should_rotate = self
|
||||
.torrents
|
||||
.get(id)
|
||||
.is_some_and(|t| {
|
||||
t.snapshot.stall_ticks >= STALL_TICKS_THRESHOLD
|
||||
&& !self.queue.is_empty()
|
||||
&& matches!(
|
||||
t.snapshot.state,
|
||||
TorrentState::Downloading | TorrentState::DownloadingMetadata
|
||||
)
|
||||
});
|
||||
let should_rotate = self.torrents.get(id).is_some_and(|t| {
|
||||
t.snapshot.stall_ticks >= STALL_TICKS_THRESHOLD
|
||||
&& !self.queue.is_empty()
|
||||
&& matches!(
|
||||
t.snapshot.state,
|
||||
TorrentState::Downloading | TorrentState::DownloadingMetadata
|
||||
)
|
||||
});
|
||||
|
||||
if should_rotate {
|
||||
let id_owned = id.clone();
|
||||
log::info!(
|
||||
"Torrent {} stalled for {} ticks, rotating to queue",
|
||||
self.torrents.get(id).map(|t| t.snapshot.name.as_str()).unwrap_or("?"),
|
||||
self.torrents
|
||||
.get(id)
|
||||
.map(|t| t.snapshot.name.as_str())
|
||||
.unwrap_or("?"),
|
||||
STALL_TICKS_THRESHOLD
|
||||
);
|
||||
self.queue_torrent(&id_owned);
|
||||
self.start_next_queued();
|
||||
}
|
||||
}
|
||||
AggregatedEvent::StateChanged { id, new_state, name } => {
|
||||
AggregatedEvent::StateChanged {
|
||||
id,
|
||||
new_state,
|
||||
name,
|
||||
} => {
|
||||
if let Some(t) = self.torrents.get_mut(id) {
|
||||
t.snapshot.state = new_state.clone();
|
||||
if let Some(n) = name {
|
||||
@@ -533,13 +534,13 @@ impl TorrentManager {
|
||||
let id_owned = id.clone();
|
||||
self.queue_torrent(&id_owned);
|
||||
self.start_next_queued();
|
||||
} else if let Some(t) = self.torrents.get(id) {
|
||||
if t.snapshot.crash_count >= 3 {
|
||||
log::error!(
|
||||
"Torrent {} crashed 3 times, leaving stopped",
|
||||
t.snapshot.name
|
||||
);
|
||||
}
|
||||
} else if let Some(t) = self.torrents.get(id)
|
||||
&& t.snapshot.crash_count >= 3
|
||||
{
|
||||
log::error!(
|
||||
"Torrent {} crashed 3 times, leaving stopped",
|
||||
t.snapshot.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -594,15 +595,16 @@ impl TorrentManager {
|
||||
// Try to load metadata from download_dir/{info_hash} (saved by MetadataComplete)
|
||||
let metadata_path = download_dir.join(&entry.info_hash);
|
||||
// Try to load metadata from saved file or original source
|
||||
let loaded_metadata = lava_torrent::torrent::v1::Torrent::read_from_file(&metadata_path)
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
if let TorrentSource::TorrentFile { path } = &entry.source {
|
||||
lava_torrent::torrent::v1::Torrent::read_from_file(path).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let loaded_metadata =
|
||||
lava_torrent::torrent::v1::Torrent::read_from_file(&metadata_path)
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
if let TorrentSource::TorrentFile { path } = &entry.source {
|
||||
lava_torrent::torrent::v1::Torrent::read_from_file(path).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let result = if let Some(metadata) = loaded_metadata {
|
||||
let name = metadata.name.clone();
|
||||
@@ -714,6 +716,7 @@ fn extract_trackers(metadata: Option<&TorrentMetadata>) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// Runs a single torrent inside a thread::scope, owning the heapless Queue on the stack.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_torrent_scope(
|
||||
torrent_id: TorrentId,
|
||||
peer_id: PeerId,
|
||||
@@ -790,7 +793,7 @@ fn forward_event(
|
||||
torrent_id: &str,
|
||||
event: TorrentEvent,
|
||||
tx: &Sender<AggregatedEvent>,
|
||||
download_dir: &PathBuf,
|
||||
download_dir: &Path,
|
||||
) {
|
||||
match event {
|
||||
TorrentEvent::TorrentMetrics {
|
||||
@@ -831,7 +834,7 @@ fn forward_event(
|
||||
}
|
||||
TorrentEvent::MetadataComplete(metadata) => {
|
||||
// Persist metadata so we can resume without re-fetching
|
||||
let root = download_dir.clone();
|
||||
let root = download_dir.to_path_buf();
|
||||
let info_hash = metadata.info_hash();
|
||||
let meta_clone = metadata.clone();
|
||||
std::thread::spawn(move || {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "monsoon-server"
|
||||
version = "0.1.0"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ use monsoon_core::types::{TorrentDetails, TorrentInfo};
|
||||
use serde::Deserialize;
|
||||
use vortex_bittorrent::State as BtState;
|
||||
|
||||
use crate::ws::ws_handler;
|
||||
use crate::AppState;
|
||||
use crate::ws::ws_handler;
|
||||
|
||||
pub fn router(state: Arc<AppState>) -> Router {
|
||||
let api = Router::new()
|
||||
@@ -36,8 +36,9 @@ pub fn router(state: Arc<AppState>) -> Router {
|
||||
// index.html for SPA client-side routing.
|
||||
if let Some(web_dir) = find_web_dir() {
|
||||
log::info!("Serving web GUI from {}", web_dir.display());
|
||||
let serve = tower_http::services::ServeDir::new(&web_dir)
|
||||
.fallback(tower_http::services::ServeFile::new(web_dir.join("index.html")));
|
||||
let serve = tower_http::services::ServeDir::new(&web_dir).fallback(
|
||||
tower_http::services::ServeFile::new(web_dir.join("index.html")),
|
||||
);
|
||||
api.fallback_service(serve)
|
||||
} else {
|
||||
log::warn!("No web GUI found. API-only mode.");
|
||||
@@ -55,12 +56,12 @@ fn find_web_dir() -> Option<std::path::PathBuf> {
|
||||
}
|
||||
}
|
||||
// 2. ../monsoon-web/dist relative to the binary
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(parent) = exe.parent() {
|
||||
let p = parent.join("../monsoon-web/dist");
|
||||
if p.join("index.html").exists() {
|
||||
return Some(p);
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe()
|
||||
&& let Some(parent) = exe.parent()
|
||||
{
|
||||
let p = parent.join("../monsoon-web/dist");
|
||||
if p.join("index.html").exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
// 3. monsoon-web/dist relative to cwd (development)
|
||||
@@ -103,8 +104,7 @@ async fn add_torrent(
|
||||
|
||||
let id = match req {
|
||||
AddTorrentRequest::Magnet { magnet } => {
|
||||
let info = parse_magnet_link(&magnet)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
let info = parse_magnet_link(&magnet).map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
|
||||
let source = TorrentSource::MagnetLink {
|
||||
magnet: magnet.clone(),
|
||||
@@ -120,9 +120,8 @@ async fn add_torrent(
|
||||
let total_pieces = metadata.pieces.len();
|
||||
let total_size = metadata.length as u64;
|
||||
let metadata_ref = metadata.clone();
|
||||
let bt_state =
|
||||
BtState::from_metadata_and_root(metadata, download_dir, bt_config)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let bt_state = BtState::from_metadata_and_root(metadata, download_dir, bt_config)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
mgr.add_torrent(
|
||||
name,
|
||||
bt_state,
|
||||
@@ -138,17 +137,21 @@ async fn add_torrent(
|
||||
}
|
||||
}
|
||||
AddTorrentRequest::TorrentFile { path } => {
|
||||
let metadata = lava_torrent::torrent::v1::Torrent::read_from_file(&path)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid torrent file: {e}")))?;
|
||||
let metadata =
|
||||
lava_torrent::torrent::v1::Torrent::read_from_file(&path).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Invalid torrent file: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let name = metadata.name.clone();
|
||||
let total_pieces = metadata.pieces.len();
|
||||
let total_size = metadata.length as u64;
|
||||
let metadata_ref = metadata.clone();
|
||||
|
||||
let bt_state =
|
||||
BtState::from_metadata_and_root(metadata, download_dir, bt_config)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let bt_state = BtState::from_metadata_and_root(metadata, download_dir, bt_config)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let source = TorrentSource::TorrentFile {
|
||||
path: PathBuf::from(&path),
|
||||
@@ -177,31 +180,45 @@ async fn upload_torrent(
|
||||
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
if field.name() == Some("file") {
|
||||
let data = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to read upload: {e}")))?;
|
||||
let data = field.bytes().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Failed to read upload: {e}"),
|
||||
)
|
||||
})?;
|
||||
file_data = Some(data.to_vec());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let data = file_data.ok_or((StatusCode::BAD_REQUEST, "No file field in upload".to_string()))?;
|
||||
let data = file_data.ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"No file field in upload".to_string(),
|
||||
))?;
|
||||
|
||||
// Write to a temp file so lava_torrent can read it
|
||||
let data_dir = {
|
||||
let mgr = state.manager.lock().unwrap();
|
||||
mgr.download_dir.parent().unwrap_or(&mgr.download_dir).to_path_buf()
|
||||
mgr.download_dir
|
||||
.parent()
|
||||
.unwrap_or(&mgr.download_dir)
|
||||
.to_path_buf()
|
||||
};
|
||||
let temp_path = data_dir.join(format!(".upload-{}.torrent", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&temp_path, &data)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to write temp file: {e}")))?;
|
||||
std::fs::write(&temp_path, &data).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to write temp file: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let metadata = lava_torrent::torrent::v1::Torrent::read_from_file(&temp_path)
|
||||
.map_err(|e| {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
(StatusCode::BAD_REQUEST, format!("Invalid torrent file: {e}"))
|
||||
})?;
|
||||
let metadata = lava_torrent::torrent::v1::Torrent::read_from_file(&temp_path).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Invalid torrent file: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let name = metadata.name.clone();
|
||||
let total_pieces = metadata.pieces.len();
|
||||
@@ -212,8 +229,8 @@ async fn upload_torrent(
|
||||
let download_dir = mgr.download_dir.clone();
|
||||
let bt_config = mgr.bt_config;
|
||||
|
||||
let bt_state = BtState::from_metadata_and_root(metadata, download_dir, bt_config)
|
||||
.map_err(|e| {
|
||||
let bt_state =
|
||||
BtState::from_metadata_and_root(metadata, download_dir, bt_config).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
})?;
|
||||
@@ -222,7 +239,14 @@ async fn upload_torrent(
|
||||
path: temp_path.clone(),
|
||||
};
|
||||
let id = mgr
|
||||
.add_torrent(name, bt_state, Some(total_size), Some(total_pieces), source, Some(&metadata_ref))
|
||||
.add_torrent(
|
||||
name,
|
||||
bt_state,
|
||||
Some(total_size),
|
||||
Some(total_pieces),
|
||||
source,
|
||||
Some(&metadata_ref),
|
||||
)
|
||||
.map_err(|e| {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
|
||||
@@ -52,33 +52,24 @@ async fn main() -> anyhow::Result<()> {
|
||||
// broadcasts JSON to WebSocket clients, fires webhooks.
|
||||
let aggregator_state = Arc::clone(&state);
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
match event_rx.recv() {
|
||||
Ok(event) => {
|
||||
// Update manager snapshot
|
||||
if let Ok(mut mgr) = aggregator_state.manager.lock() {
|
||||
mgr.apply_event(&event);
|
||||
}
|
||||
|
||||
let json = event_to_json(&event);
|
||||
|
||||
// Fire webhook on torrent completion
|
||||
if let AggregatedEvent::StateChanged {
|
||||
ref new_state, ..
|
||||
} = event
|
||||
{
|
||||
if *new_state == monsoon_core::types::TorrentState::Seeding {
|
||||
if let Some(ref url) = webhook_url {
|
||||
fire_webhook(url, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast to WebSocket clients
|
||||
let _ = aggregator_state.event_tx.send(json);
|
||||
}
|
||||
Err(_) => break,
|
||||
while let Ok(event) = event_rx.recv() {
|
||||
// Update manager snapshot
|
||||
if let Ok(mut mgr) = aggregator_state.manager.lock() {
|
||||
mgr.apply_event(&event);
|
||||
}
|
||||
|
||||
let json = event_to_json(&event);
|
||||
|
||||
// Fire webhook on torrent completion
|
||||
if let AggregatedEvent::StateChanged { ref new_state, .. } = event
|
||||
&& *new_state == monsoon_core::types::TorrentState::Seeding
|
||||
&& let Some(ref url) = webhook_url
|
||||
{
|
||||
fire_webhook(url, &json);
|
||||
}
|
||||
|
||||
// Broadcast to WebSocket clients
|
||||
let _ = aggregator_state.event_tx.send(json);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -113,7 +104,11 @@ fn event_to_json(event: &AggregatedEvent) -> String {
|
||||
"numPeers": num_peers,
|
||||
})
|
||||
.to_string(),
|
||||
AggregatedEvent::StateChanged { id, new_state, name } => serde_json::json!({
|
||||
AggregatedEvent::StateChanged {
|
||||
id,
|
||||
new_state,
|
||||
name,
|
||||
} => serde_json::json!({
|
||||
"type": "stateChanged",
|
||||
"id": id,
|
||||
"newState": new_state,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::ws::Message;
|
||||
use axum::{
|
||||
extract::{State, WebSocketUpgrade},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum::extract::ws::Message;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["esbuild"]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
|
||||
121
monsoon.spec
Normal file
121
monsoon.spec
Normal file
@@ -0,0 +1,121 @@
|
||||
Name: monsoon
|
||||
Version: 0.1.0
|
||||
Release: 1%{?dist}
|
||||
Summary: A fast BitTorrent client powered by io-uring
|
||||
|
||||
License: GPL-3.0-or-later
|
||||
URL: https://git.lair.cafe/monsoon/monsoon
|
||||
Source0: %{name}-%{version}.tar.gz
|
||||
Source1: %{name}-%{version}-vendor.tar.gz
|
||||
|
||||
ExclusiveArch: x86_64
|
||||
|
||||
BuildRequires: rust >= 1.85
|
||||
BuildRequires: cargo
|
||||
BuildRequires: gcc
|
||||
BuildRequires: webkit2gtk4.1-devel
|
||||
BuildRequires: gtk3-devel
|
||||
BuildRequires: libsoup3-devel
|
||||
BuildRequires: pango-devel
|
||||
BuildRequires: gdk-pixbuf2-devel
|
||||
BuildRequires: glib2-devel
|
||||
BuildRequires: libappindicator-gtk3-devel
|
||||
BuildRequires: desktop-file-utils
|
||||
BuildRequires: libappstream-glib
|
||||
BuildRequires: systemd-rpm-macros
|
||||
|
||||
%description
|
||||
Monsoon is a modern BitTorrent client for the GNOME desktop,
|
||||
built on the high-performance Vortex bittorrent engine which uses
|
||||
Linux io-uring for efficient I/O. It supports magnet links,
|
||||
DHT peer discovery, and simultaneous multi-torrent management.
|
||||
|
||||
%package server
|
||||
Summary: Monsoon headless BitTorrent server with REST API
|
||||
Requires(pre): shadow-utils
|
||||
|
||||
%description server
|
||||
Headless BitTorrent daemon with REST API, WebSocket event streaming,
|
||||
and web GUI for remote torrent management. Designed for deployment
|
||||
on LAN servers to offload torrent downloading with completion
|
||||
notifications for post-download automation.
|
||||
|
||||
%prep
|
||||
%autosetup
|
||||
tar xf %{SOURCE1}
|
||||
mkdir -p .cargo
|
||||
cat > .cargo/config.toml << 'EOF'
|
||||
[source.crates-io]
|
||||
replace-with = "vendored-sources"
|
||||
|
||||
[source."git+https://github.com/grenade/vortex.git?branch=fix-double-panic"]
|
||||
git = "https://github.com/grenade/vortex.git"
|
||||
branch = "fix-double-panic"
|
||||
replace-with = "vendored-sources"
|
||||
|
||||
[source.vendored-sources]
|
||||
directory = "vendor"
|
||||
EOF
|
||||
|
||||
%build
|
||||
# Prevent brp-mangle-shebangs from misinterpreting Rust #![...] attributes
|
||||
# in vendored sources as shebangs
|
||||
%global __brp_mangle_shebangs_exclude_from /usr/src/debug
|
||||
|
||||
# Frontends are pre-built and included in the source tarball
|
||||
# (dist/ for desktop, monsoon-web/dist/ for server web GUI)
|
||||
# custom-protocol feature tells Tauri to embed frontend assets into the
|
||||
# binary. Without it, the app expects a local Vite dev server at :5173.
|
||||
cargo build --release -p monsoon --features custom-protocol
|
||||
cargo build --release -p monsoon-server
|
||||
|
||||
%install
|
||||
# Desktop app
|
||||
install -Dm755 target/release/monsoon %{buildroot}%{_bindir}/monsoon
|
||||
install -Dm644 data/cafe.lair.monsoon.desktop %{buildroot}%{_datadir}/applications/cafe.lair.monsoon.desktop
|
||||
install -Dm644 data/cafe.lair.monsoon.metainfo.xml %{buildroot}%{_metainfodir}/cafe.lair.monsoon.metainfo.xml
|
||||
install -Dm644 src-tauri/icons/icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/cafe.lair.monsoon.png
|
||||
install -Dm644 src-tauri/icons/128x128.png %{buildroot}%{_datadir}/icons/hicolor/128x128/apps/cafe.lair.monsoon.png
|
||||
install -Dm644 src-tauri/icons/32x32.png %{buildroot}%{_datadir}/icons/hicolor/32x32/apps/cafe.lair.monsoon.png
|
||||
|
||||
# Server
|
||||
install -Dm755 target/release/monsoon-server %{buildroot}%{_bindir}/monsoon-server
|
||||
install -Dm644 data/monsoon-server.service %{buildroot}%{_unitdir}/monsoon-server.service
|
||||
install -dm755 %{buildroot}%{_datadir}/monsoon/web
|
||||
cp -r monsoon-web/dist/* %{buildroot}%{_datadir}/monsoon/web/
|
||||
|
||||
%check
|
||||
desktop-file-validate %{buildroot}%{_datadir}/applications/cafe.lair.monsoon.desktop
|
||||
appstream-util validate-relax --nonet %{buildroot}%{_metainfodir}/cafe.lair.monsoon.metainfo.xml
|
||||
|
||||
%pre server
|
||||
getent group monsoon >/dev/null || groupadd -r monsoon
|
||||
getent passwd monsoon >/dev/null || useradd -r -g monsoon -d /var/lib/monsoon -s /sbin/nologin monsoon
|
||||
|
||||
%post server
|
||||
%systemd_post monsoon-server.service
|
||||
|
||||
%preun server
|
||||
%systemd_preun monsoon-server.service
|
||||
|
||||
%postun server
|
||||
%systemd_postun_with_restart monsoon-server.service
|
||||
|
||||
%files
|
||||
%license LICENSE
|
||||
%doc README.md
|
||||
%{_bindir}/monsoon
|
||||
%{_datadir}/applications/cafe.lair.monsoon.desktop
|
||||
%{_metainfodir}/cafe.lair.monsoon.metainfo.xml
|
||||
%{_datadir}/icons/hicolor/*/apps/cafe.lair.monsoon.png
|
||||
|
||||
%files server
|
||||
%license LICENSE
|
||||
%doc README.md
|
||||
%{_bindir}/monsoon-server
|
||||
%{_unitdir}/monsoon-server.service
|
||||
%{_datadir}/monsoon/
|
||||
|
||||
%changelog
|
||||
* Sun Apr 05 2026 Rob Thijssen <grenade@rob.tn> - 0.1.0-1
|
||||
- Initial package
|
||||
@@ -9,6 +9,9 @@
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["esbuild"]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
[package]
|
||||
name = "monsoon"
|
||||
version = "0.1.0"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
|
||||
@@ -32,8 +32,15 @@ pub fn add_torrent_file(
|
||||
let source = TorrentSource::TorrentFile {
|
||||
path: PathBuf::from(&path),
|
||||
};
|
||||
mgr.add_torrent(name, state, Some(total_size), Some(total_pieces), source, Some(&metadata_ref))
|
||||
.map_err(|e| format!("Failed to add torrent: {e}"))
|
||||
mgr.add_torrent(
|
||||
name,
|
||||
state,
|
||||
Some(total_size),
|
||||
Some(total_pieces),
|
||||
source,
|
||||
Some(&metadata_ref),
|
||||
)
|
||||
.map_err(|e| format!("Failed to add torrent: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -65,8 +72,15 @@ pub fn add_magnet_link(
|
||||
let metadata_ref = metadata.clone();
|
||||
let state = BtState::from_metadata_and_root(metadata, download_dir, bt_config)
|
||||
.map_err(|e| format!("Failed to initialize torrent state: {e}"))?;
|
||||
mgr.add_torrent(name, state, Some(total_size), Some(total_pieces), source, Some(&metadata_ref))
|
||||
.map_err(|e| format!("Failed to add torrent: {e}"))
|
||||
mgr.add_torrent(
|
||||
name,
|
||||
state,
|
||||
Some(total_size),
|
||||
Some(total_pieces),
|
||||
source,
|
||||
Some(&metadata_ref),
|
||||
)
|
||||
.map_err(|e| format!("Failed to add torrent: {e}"))
|
||||
} else {
|
||||
let state = BtState::unstarted(info.info_hash, download_dir, bt_config);
|
||||
let name = info.name.unwrap_or_else(|| hex::encode(info.info_hash));
|
||||
@@ -76,30 +90,21 @@ pub fn add_magnet_link(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn remove_torrent(
|
||||
id: String,
|
||||
manager: State<'_, Mutex<TorrentManager>>,
|
||||
) -> Result<(), String> {
|
||||
pub fn remove_torrent(id: String, manager: State<'_, Mutex<TorrentManager>>) -> Result<(), String> {
|
||||
log::info!("Removing torrent: {id}");
|
||||
let mut mgr = manager.lock().map_err(|e| e.to_string())?;
|
||||
mgr.remove_torrent(&id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pause_torrent(
|
||||
id: String,
|
||||
manager: State<'_, Mutex<TorrentManager>>,
|
||||
) -> Result<(), String> {
|
||||
pub fn pause_torrent(id: String, manager: State<'_, Mutex<TorrentManager>>) -> Result<(), String> {
|
||||
log::info!("Pausing torrent: {id}");
|
||||
let mut mgr = manager.lock().map_err(|e| e.to_string())?;
|
||||
mgr.pause_torrent(&id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn resume_torrent(
|
||||
id: String,
|
||||
manager: State<'_, Mutex<TorrentManager>>,
|
||||
) -> Result<(), String> {
|
||||
pub fn resume_torrent(id: String, manager: State<'_, Mutex<TorrentManager>>) -> Result<(), String> {
|
||||
log::info!("Resuming torrent: {id}");
|
||||
let mut mgr = manager.lock().map_err(|e| e.to_string())?;
|
||||
mgr.resume_torrent(&id).map_err(|e| e.to_string())
|
||||
@@ -116,9 +121,7 @@ pub fn get_torrent_details(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_torrents(
|
||||
manager: State<'_, Mutex<TorrentManager>>,
|
||||
) -> Result<Vec<TorrentInfo>, String> {
|
||||
pub fn get_torrents(manager: State<'_, Mutex<TorrentManager>>) -> Result<Vec<TorrentInfo>, String> {
|
||||
let mgr = manager.lock().map_err(|e| e.to_string())?;
|
||||
Ok(mgr.get_torrents())
|
||||
}
|
||||
|
||||
@@ -72,79 +72,73 @@ pub fn run() {
|
||||
// Event aggregator thread: drains events from all torrent scopes
|
||||
// and emits them as Tauri events to the frontend.
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
match event_rx.recv() {
|
||||
Ok(event) => {
|
||||
// Single lock: update snapshot and read state for emit
|
||||
let emit_data = if let Ok(mut mgr) =
|
||||
app_handle.state::<Mutex<TorrentManager>>().lock()
|
||||
{
|
||||
mgr.apply_event(&event);
|
||||
match &event {
|
||||
AggregatedEvent::Metrics { id, .. } => {
|
||||
mgr.torrents.get(id).map(|t| {
|
||||
let info = t.to_info();
|
||||
(info.state, info.progress)
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Emit to frontend (outside lock)
|
||||
match &event {
|
||||
AggregatedEvent::Metrics {
|
||||
id,
|
||||
pieces_completed: _,
|
||||
download_throughput,
|
||||
upload_throughput,
|
||||
num_peers,
|
||||
} => {
|
||||
let (state, progress) = emit_data.unwrap_or((
|
||||
monsoon_core::types::TorrentState::Downloading,
|
||||
0.0,
|
||||
));
|
||||
let _ = app_handle.emit(
|
||||
"torrent-metrics",
|
||||
TorrentMetricsEvent {
|
||||
id: id.clone(),
|
||||
state,
|
||||
progress,
|
||||
download_speed: *download_throughput,
|
||||
upload_speed: *upload_throughput,
|
||||
num_peers: *num_peers,
|
||||
},
|
||||
);
|
||||
}
|
||||
AggregatedEvent::StateChanged { id, new_state, name } => {
|
||||
let _ = app_handle.emit(
|
||||
"torrent-state-changed",
|
||||
TorrentStateChangedEvent {
|
||||
id: id.clone(),
|
||||
old_state: new_state.clone(),
|
||||
new_state: new_state.clone(),
|
||||
name: name.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
AggregatedEvent::MetadataComplete { id, metadata } => {
|
||||
let _ = app_handle.emit(
|
||||
"torrent-state-changed",
|
||||
TorrentStateChangedEvent {
|
||||
id: id.clone(),
|
||||
old_state:
|
||||
monsoon_core::types::TorrentState::DownloadingMetadata,
|
||||
new_state:
|
||||
monsoon_core::types::TorrentState::Downloading,
|
||||
name: Some(metadata.name.clone()),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
while let Ok(event) = event_rx.recv() {
|
||||
// Single lock: update snapshot and read state for emit
|
||||
let emit_data = if let Ok(mut mgr) =
|
||||
app_handle.state::<Mutex<TorrentManager>>().lock()
|
||||
{
|
||||
mgr.apply_event(&event);
|
||||
match &event {
|
||||
AggregatedEvent::Metrics { id, .. } => mgr.torrents.get(id).map(|t| {
|
||||
let info = t.to_info();
|
||||
(info.state, info.progress)
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Emit to frontend (outside lock)
|
||||
match &event {
|
||||
AggregatedEvent::Metrics {
|
||||
id,
|
||||
pieces_completed: _,
|
||||
download_throughput,
|
||||
upload_throughput,
|
||||
num_peers,
|
||||
} => {
|
||||
let (state, progress) = emit_data
|
||||
.unwrap_or((monsoon_core::types::TorrentState::Downloading, 0.0));
|
||||
let _ = app_handle.emit(
|
||||
"torrent-metrics",
|
||||
TorrentMetricsEvent {
|
||||
id: id.clone(),
|
||||
state,
|
||||
progress,
|
||||
download_speed: *download_throughput,
|
||||
upload_speed: *upload_throughput,
|
||||
num_peers: *num_peers,
|
||||
},
|
||||
);
|
||||
}
|
||||
AggregatedEvent::StateChanged {
|
||||
id,
|
||||
new_state,
|
||||
name,
|
||||
} => {
|
||||
let _ = app_handle.emit(
|
||||
"torrent-state-changed",
|
||||
TorrentStateChangedEvent {
|
||||
id: id.clone(),
|
||||
old_state: new_state.clone(),
|
||||
new_state: new_state.clone(),
|
||||
name: name.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
AggregatedEvent::MetadataComplete { id, metadata } => {
|
||||
let _ = app_handle.emit(
|
||||
"torrent-state-changed",
|
||||
TorrentStateChangedEvent {
|
||||
id: id.clone(),
|
||||
old_state:
|
||||
monsoon_core::types::TorrentState::DownloadingMetadata,
|
||||
new_state: monsoon_core::types::TorrentState::Downloading,
|
||||
name: Some(metadata.name.clone()),
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(_) => break, // All senders dropped
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user