Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions ADVANCED.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,44 @@ Set this as the fpcalc path in Picard's *Options → Fingerprinting*. Run `dupso
For headless servers (NAS, Raspberry Pi, OpenMediaVault), dupsonic includes a built-in web interface:

```bash
dupsonic serve # http://0.0.0.0:8080
dupsonic serve --bind 127.0.0.1:3000 # custom address/port
dupsonic serve # http://127.0.0.1:8080 (localhost only)
dupsonic serve --bind 0.0.0.0:8080 # expose on all interfaces
dupsonic serve --bind 0.0.0.0:8080 --allow-ip 192.168.1.0/24 # LAN only
```

Features:
### Access control

By default, the server binds to **localhost only** (`127.0.0.1:8080`). To expose it on the network, use `--bind`:

```bash
dupsonic serve --bind 0.0.0.0:8080
```

To restrict which IPs can connect, use `--allow-ip` (repeatable, supports CIDR notation for both IPv4 and IPv6):

```bash
dupsonic serve --bind 0.0.0.0:8080 --allow-ip 192.168.1.0/24
dupsonic serve --bind 0.0.0.0:8080 --allow-ip 10.0.0.5 --allow-ip 10.0.0.6
dupsonic serve --bind [::]:8080 --allow-ip fd00::/8 --allow-ip ::1
```

When `--allow-ip` is set, connections from non-matching IPs receive HTTP 403 Forbidden. When not set, all connections to the bound address are permitted.

Environment variables are also supported:

```bash
export DUPSONIC_BIND=0.0.0.0:8080
export DUPSONIC_ALLOW_IP=192.168.1.0/24,10.0.0.0/8
dupsonic serve
```

| Flag | Env var | Default | Description |
|------|---------|---------|-------------|
| `--bind` | `DUPSONIC_BIND` | `127.0.0.1:8080` | Address and port to listen on |
| `--allow-ip` | `DUPSONIC_ALLOW_IP` | *(none — allow all)* | Comma-separated IPs or CIDR ranges to allow |

### Features

- **Status dashboard** — file count, scan state
- **Scan** — enter a path or re-scan stored paths (pre-filled from previous scans)
- **Find duplicates** — view all groups with similarity %, classification badges, quality details (format, sample rate, bit depth, bitrate, size)
Expand Down
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,12 @@ ureq = { version = "3.0", features = ["json"] }
axum = "0.8"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }

# IP access control (CIDR parsing)
ipnet = "2.12"

# System trash (for web UI delete action)
trash = "5"

[dev-dependencies]
tempfile = "3.10"
tower = "0.5"
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@ sudo mv dupsonic /usr/local/bin/

Existing tools fail at cross-format duplicate detection:

- **Czkawka, dupeGuru** — compare metadata or file hashes only. Same song in FLAC and MP3? Not detected.
- **Duplicate Cleaner** — claims audio comparison but [struggles with cross-format matching](https://community.metabrainz.org/t/extremely-large-music-collection-needs-advice-on-what-dedupe-program-to-use/608781).
- **[Czkawka](https://github.com/qarmin/czkawka), [dupeGuru](https://github.com/arsenetar/dupeguru)** — compare metadata or file hashes only. Same song in FLAC and MP3? Not detected.
- **[Duplicate Cleaner](https://www.duplicatecleaner.com/)** — claims audio comparison but [struggles with cross-format matching](https://community.metabrainz.org/t/extremely-large-music-collection-needs-advice-on-what-dedupe-program-to-use/608781).
- **Manual comparison** — impossible with 10k+ files.

dupsonic fingerprints the actual audio using [Chromaprint](https://acoustid.org/chromaprint) (the same technology behind MusicBrainz Picard) and compares fingerprints to find duplicates.
dupsonic fingerprints the actual audio using [Chromaprint](https://acoustid.org/chromaprint) (the same technology behind [MusicBrainz Picard](https://picard.musicbrainz.org/)) and compares fingerprints to find duplicates.

## Supported formats

Expand All @@ -102,16 +102,18 @@ Designed for 100k+ file collections: parallel scanning, incremental cache, LSH-b
For headless servers (NAS, Raspberry Pi), dupsonic includes a built-in web interface:

```bash
dupsonic serve # http://0.0.0.0:8080
dupsonic serve # http://127.0.0.1:8080 (localhost)
dupsonic serve --bind 0.0.0.0:8080 # expose on network
dupsonic serve --bind 0.0.0.0:8080 --allow-ip 192.168.1.0/24 # restrict to LAN
```

Open from any browser on your network. Features: scan, find duplicates, view quality details, delete (moves to system trash with undo), exclude.

See [ADVANCED.md](ADVANCED.md#web-ui) for configuration options.
See [ADVANCED.md](ADVANCED.md#web-ui) for access control options (`--allow-ip`, `DUPSONIC_BIND`, `DUPSONIC_ALLOW_IP`).

## Similar projects

**[soundalike](https://codeberg.org/derat/soundalike)** by Daniel Erat — a mature Go tool using Chromaprint. Lightweight, has built-in move/delete commands. Requires external `fpcalc`, defaults to 15s fingerprints, no MusicBrainz integration.
**[soundalike](https://codeberg.org/derat/soundalike)** by Daniel Erat — a mature Go tool using Chromaprint. Lightweight, has built-in move/delete commands. Requires external `fpcalc`, defaults to 15s fingerprints, no [MusicBrainz](https://musicbrainz.org/) integration.

## Contributing

Expand Down
27 changes: 24 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,14 @@ enum Commands {
/// Start web UI server
Serve {
/// Address to bind to
#[arg(short, long, default_value = "0.0.0.0:8080")]
#[arg(short, long, default_value = "127.0.0.1:8080", env = "DUPSONIC_BIND")]
bind: String,

/// Allow connections from these IP addresses or CIDR ranges (repeatable).
/// When set, only matching IPs can access the server; others get 403.
/// Supports IPv4 and IPv6. Examples: 192.168.1.0/24, 10.0.0.5, ::1
#[arg(long, env = "DUPSONIC_ALLOW_IP", value_delimiter = ',')]
allow_ip: Vec<String>,
},
}

Expand Down Expand Up @@ -435,9 +441,24 @@ fn main() -> Result<()> {
clap_complete::generate(shell, &mut cmd, "dupsonic", &mut std::io::stdout());
return Ok(());
}
Commands::Serve { bind } => {
Commands::Serve { bind, allow_ip } => {
// Parse --allow-ip values into IpNet (single IPs become /32 or /128)
let allowed_ips: Vec<ipnet::IpNet> = allow_ip
.iter()
.map(|s| {
s.parse::<ipnet::IpNet>().unwrap_or_else(|_| {
// Try parsing as a single IP address (no prefix)
let addr: std::net::IpAddr = s.parse().unwrap_or_else(|_| {
eprintln!("Invalid IP or CIDR: {}", s);
std::process::exit(1);
});
ipnet::IpNet::from(addr)
})
})
.collect();

let rt = tokio::runtime::Runtime::new()?;
rt.block_on(dupsonic::web::serve(db, db_path, &bind))?;
rt.block_on(dupsonic::web::serve(db, db_path, &bind, &allowed_ips))?;
}
}

Expand Down
Loading
Loading