feat: Add scrobble-command to Listenbrainz

Signed-off-by: SindreKjelsrud <sindre@kjelsrud.dev>
This commit is contained in:
SindreKjelsrud 2026-01-13 22:11:01 +01:00
parent 49c4a79845
commit f1b31fabda
Signed by: sidski
GPG key ID: D2BBDF3EDE6BA9A6
10 changed files with 541 additions and 46 deletions

115
Cargo.lock generated
View file

@ -35,6 +35,15 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.21"
@ -146,6 +155,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base64"
version = "0.21.7"
@ -210,6 +225,19 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "clap"
version = "4.5.53"
@ -798,6 +826,30 @@ dependencies = [
"tokio",
]
[[package]]
name = "iana-time-zone"
version = "0.1.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "2.1.1"
@ -1127,6 +1179,7 @@ name = "navipod"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"config",
"dirs",
@ -1166,6 +1219,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "number_prefix"
version = "0.4.0"
@ -2274,12 +2336,65 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.48.0"

View file

@ -43,6 +43,9 @@ clap = { version = "4.0", features = ["derive"] }
# TUI Progress bars
indicatif = "0.17"
# Date/time parsing
chrono = "0.4"
# ID3 tag parsing
id3 = "1.13"

View file

@ -7,8 +7,10 @@ A native Rust application to sync songs and albums from your self-hosted Navidro
- 🎵 Sync songs and albums from Navidrome
- 📱 Support for iPod stock OS
- 🎸 Support for Rockbox firmware
- 📊 Scrobble listening history to ListenBrainz
- ⚙️ Configurable sync options
- 🔄 Incremental sync support
- 📈 Terminal progress bars with live status
## Installation
@ -38,6 +40,10 @@ cargo build --release
albums = true
playlists = false
format = "mp3"
[listenbrainz]
# Optional - only needed for scrobbling to ListenBrainz
token = "your_listenbrainz_token"
```
3. Alternatively, use environment variables (prefixed with `NAVIPOD__`):
@ -53,37 +59,50 @@ cargo build --release
## Usage
### GUI Application
Launch the graphical interface:
```bash
navipod-gui
```
The GUI provides:
- Input fields for Navidrome URL, username, and password
- Firmware type selector (Stock/Rockbox)
- Mount point browser
- Real-time sync status and progress
- Error display
### Command Line
```bash
# Sync all configured content
navipod sync
# Sync all configured content (with progress bar)
cargo run -- sync
# Sync specific album
navipod sync --album "Album Name"
cargo run -- sync --album "Album Name"
# Sync specific artist
cargo run -- sync --artist "Artist Name"
# List available albums
navipod list-albums
cargo run -- list-albums
# Check iPod connection
navipod check
cargo run -- check
# Scrobble listening history to ListenBrainz
cargo run -- scrobble
# Scrobble and clear the log (with backup)
cargo run -- scrobble --clear
# Scrobble and clear without backup
cargo run -- scrobble --clear --no-backup
```
### Scrobbling to ListenBrainz
NaviPod can read the `.scrobbler.log` file from your Rockbox iPod and submit your listening history to ListenBrainz:
1. Get your ListenBrainz user token from https://listenbrainz.org/profile/
2. Add it to your `config.toml`:
```toml
[listenbrainz]
token = "your_token_here"
```
3. Run the scrobble command:
```bash
cargo run -- scrobble --clear
```
The `--clear` flag will remove the scrobbler log after successful submission (with automatic backup).
This is useful to avoid re-submitting the same listens next time.
## Requirements
- Rust 1.70+ (and Cargo)
@ -111,30 +130,11 @@ nix-shell
# Build in release mode
cargo build --release
# The binaries will be at:
# - target/release/navipod (CLI)
# - target/release/navipod-gui (GUI)
```
# The binary will be at:
# - target/release/navipod
### Running the GUI
```bash
# Make sure you're in nix-shell first:
nix-shell
# After building, run:
./target/release/navipod-gui
# Or use cargo run:
cargo run --bin navipod-gui
```
**Note:** If you encounter Wayland or X11 library errors:
1. Make sure you're in `nix-shell` (it provides all necessary libraries)
2. The GUI will automatically use X11 if Wayland is not available
3. If you still have issues, try:
```bash
WAYLAND_DISPLAY= WINIT_UNIX_BACKEND=x11 ./target/release/navipod-gui
# Install to your PATH (optional)
cargo install --path .
```
## Development

View file

@ -23,3 +23,8 @@ playlists = false
# Preferred audio format (mp3, flac, ogg, etc.)
format = "mp3"
[listenbrainz]
# Your ListenBrainz user token (get it from https://listenbrainz.org/profile/)
# This is optional - only needed if you want to scrobble from iPod to ListenBrainz
token = "your_listenbrainz_token_here"

View file

@ -35,5 +35,6 @@ pkgs.mkShell {
echo " navipod sync [--album ALBUM] [--artist ARTIST] - Sync content to iPod"
echo " navipod list-albums - List available albums"
echo " navipod check - Check iPod connection"
echo " navipod scrobble [--clear] [--backup] - Sync scrobbles to ListenBrainz"
'';
}

View file

@ -6,6 +6,8 @@ pub struct Config {
pub navidrome: NavidromeConfig,
pub ipod: IpodConfig,
pub sync: SyncConfig,
#[serde(default)]
pub listenbrainz: Option<ListenBrainzConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -35,6 +37,11 @@ pub struct SyncConfig {
pub format: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListenBrainzConfig {
pub token: String,
}
impl Config {
pub fn load() -> anyhow::Result<Self> {
let config_path = Self::config_path()?;

View file

@ -22,6 +22,12 @@ pub enum NaviPodError {
#[error("Sync error: {0}")]
Sync(String),
#[error("ListenBrainz error: {0}")]
ListenBrainzError(String),
#[error("Scrobbler parse error: {0}")]
ScrobblerParseError(String),
}
pub type Result<T> = std::result::Result<T, NaviPodError>;

View file

@ -1,7 +1,9 @@
pub mod config;
pub mod error;
pub mod ipod;
pub mod listenbrainz;
pub mod navidrome;
pub mod scrobbler;
pub mod sync;
// Re-export Config so it can be used from main.rs
@ -10,6 +12,7 @@ pub use config::Config;
use clap::{Parser, Subcommand};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::sync::Arc;
use chrono;
#[derive(Parser)]
#[command(name = "navipod")]
@ -34,6 +37,15 @@ pub enum Commands {
ListAlbums,
/// Check iPod connection and status
Check,
/// Scrobble listening history from iPod to ListenBrainz
Scrobble {
/// Clear scrobbler log after successful sync
#[arg(long, default_value_t = false)]
clear: bool,
/// Backup scrobbler log before clearing
#[arg(long, default_value_t = true)]
backup: bool,
},
}
pub async fn run(config: Config) -> anyhow::Result<()> {
@ -108,6 +120,79 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
ipod::check_ipod(&config.ipod)?;
println!("✓ iPod connection OK");
}
Commands::Scrobble { clear, backup } => {
// Check if ListenBrainz is configured
let lb_config = config.listenbrainz.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"ListenBrainz not configured. Add [listenbrainz] section with 'token' to your config.toml"
)
})?;
println!("Reading scrobbler log from iPod (mount: {:?})...", config.ipod.mount_point);
println!("Checking for scrobbler.log in:");
println!(" - {:?}", config.ipod.mount_point.join("scrobbler.log"));
println!(" - {:?}", config.ipod.mount_point.join(".scrobbler.log"));
println!(" - {:?}", config.ipod.mount_point.join(".rockbox/scrobbler.log"));
let listens = scrobbler::parse_scrobbler_log(&config.ipod)?;
if listens.is_empty() {
println!("\nNo listens found in scrobbler log.");
println!("Make sure:");
println!(" 1. Your iPod is mounted at the correct location");
println!(" 2. Rockbox scrobbling is enabled");
println!(" 3. You've played some songs since last scrobble");
return Ok(());
}
println!("Found {} valid listens to sync", listens.len());
// Show sample of first few listens for debugging
if !listens.is_empty() {
println!("\nSample of listens to be submitted:");
for (i, listen) in listens.iter().take(3).enumerate() {
let date = chrono::DateTime::from_timestamp(listen.timestamp, 0)
.map(|dt| dt.to_string())
.unwrap_or_else(|| format!("Invalid timestamp: {}", listen.timestamp));
println!(
" {}. {} - {} ({})",
i + 1,
listen.artist,
listen.track,
date
);
}
if listens.len() > 3 {
println!(" ... and {} more", listens.len() - 3);
}
println!();
}
// Validate token and submit
let lb_client = listenbrainz::ListenBrainzClient::new(lb_config.token.clone())?;
println!("Validating ListenBrainz token...");
lb_client.validate_token().await?;
println!("✓ Token valid");
println!("Submitting listens to ListenBrainz...");
lb_client.submit_listens(listens).await?;
println!("✓ Successfully submitted listens!");
// Backup if requested
if backup && clear {
println!("Backing up scrobbler log...");
let backup_path = scrobbler::backup_scrobbler_log(&config.ipod)?;
println!("✓ Backed up to {:?}", backup_path);
}
// Clear if requested
if clear {
println!("Clearing scrobbler log...");
scrobbler::clear_scrobbler_log(&config.ipod)?;
println!("✓ Scrobbler log cleared");
}
}
}
Ok(())

118
src/listenbrainz.rs Normal file
View file

@ -0,0 +1,118 @@
use crate::error::{NaviPodError, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
const LISTENBRAINZ_API_URL: &str = "https://api.listenbrainz.org/1";
#[derive(Debug, Clone)]
pub struct ListenBrainzClient {
client: Client,
token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Listen {
pub artist: String,
pub track: String,
pub album: Option<String>,
pub timestamp: i64,
}
#[derive(Serialize)]
struct SubmitListensPayload {
listen_type: String,
payload: Vec<ListenPayload>,
}
#[derive(Serialize)]
struct ListenPayload {
listened_at: i64,
track_metadata: TrackMetadata,
}
#[derive(Serialize)]
struct TrackMetadata {
artist_name: String,
track_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
release_name: Option<String>,
}
impl ListenBrainzClient {
pub fn new(token: String) -> Result<Self> {
let client = Client::builder()
.user_agent("NaviPod/0.1.0")
.build()?;
Ok(Self { client, token })
}
pub async fn submit_listens(&self, listens: Vec<Listen>) -> Result<()> {
if listens.is_empty() {
return Ok(());
}
// ListenBrainz has a limit of 1000 listens per request
const BATCH_SIZE: usize = 1000;
for chunk in listens.chunks(BATCH_SIZE) {
self.submit_batch(chunk).await?;
}
Ok(())
}
async fn submit_batch(&self, listens: &[Listen]) -> Result<()> {
let payload = SubmitListensPayload {
listen_type: "import".to_string(),
payload: listens
.iter()
.map(|listen| ListenPayload {
listened_at: listen.timestamp,
track_metadata: TrackMetadata {
artist_name: listen.artist.clone(),
track_name: listen.track.clone(),
release_name: listen.album.clone(),
},
})
.collect(),
};
let response = self
.client
.post(format!("{}/submit-listens", LISTENBRAINZ_API_URL))
.header("Authorization", format!("Token {}", self.token))
.json(&payload)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(NaviPodError::ListenBrainzError(format!(
"Failed to submit listens: {} - {}",
status, body
)));
}
Ok(())
}
pub async fn validate_token(&self) -> Result<()> {
let response = self
.client
.get(format!("{}/validate-token", LISTENBRAINZ_API_URL))
.header("Authorization", format!("Token {}", self.token))
.send()
.await?;
if !response.status().is_success() {
return Err(NaviPodError::ListenBrainzError(
"Invalid ListenBrainz token".to_string(),
));
}
Ok(())
}
}

155
src/scrobbler.rs Normal file
View file

@ -0,0 +1,155 @@
use crate::config::IpodConfig;
use crate::error::Result;
use crate::listenbrainz::Listen;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use tracing::{info, warn};
/// Parse Rockbox .scrobbler.log file
/// Format: #AUDIOSCROBBLER/1.1
/// Each line: artist\talbum\ttrack\ttrack_number\tduration\trating\ttimestamp\tmusicbrainz_id
pub fn parse_scrobbler_log(ipod_config: &IpodConfig) -> Result<Vec<Listen>> {
let log_path = get_scrobbler_log_path(ipod_config)?;
if !log_path.exists() {
info!("No scrobbler log found at {:?}", log_path);
return Ok(Vec::new());
}
let file = File::open(&log_path)?;
let reader = BufReader::new(file);
let mut listens = Vec::new();
let mut skipped_count = 0;
// ListenBrainz requires timestamps after 2002-10-01
const MIN_TIMESTAMP: i64 = 1033410600;
for (line_num, line) in reader.lines().enumerate() {
let line = line?;
// Skip header line
if line.starts_with('#') {
continue;
}
// Skip empty lines
if line.trim().is_empty() {
continue;
}
match parse_scrobbler_line(&line) {
Ok(listen) => {
// Filter out timestamps that are too old for ListenBrainz
if listen.timestamp < MIN_TIMESTAMP {
warn!(
"Skipping listen with timestamp {} ({}) - too old for ListenBrainz: {} - {}",
listen.timestamp,
chrono::DateTime::from_timestamp(listen.timestamp, 0)
.map(|dt| dt.to_string())
.unwrap_or_else(|| "invalid date".to_string()),
listen.artist,
listen.track
);
skipped_count += 1;
} else {
listens.push(listen);
}
}
Err(e) => {
warn!("Failed to parse line {}: {} - Error: {}", line_num + 1, line, e);
}
}
}
info!("Parsed {} valid listens from scrobbler log", listens.len());
if skipped_count > 0 {
info!("Skipped {} listens with invalid timestamps", skipped_count);
}
Ok(listens)
}
fn parse_scrobbler_line(line: &str) -> Result<Listen> {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 7 {
return Err(crate::error::NaviPodError::ScrobblerParseError(
format!("Not enough fields in scrobbler log line (got {}, need 7+): {}", parts.len(), line)
));
}
let artist = parts[0].to_string();
let album = if parts[1].is_empty() {
None
} else {
Some(parts[1].to_string())
};
let track = parts[2].to_string();
let timestamp_str = parts[6];
// Parse timestamp - Rockbox uses Unix timestamp format
let timestamp = timestamp_str.parse::<i64>().map_err(|e| {
crate::error::NaviPodError::ScrobblerParseError(format!(
"Failed to parse timestamp '{}' from line '{}': {}",
timestamp_str, line, e
))
})?;
Ok(Listen {
artist,
track,
album,
timestamp,
})
}
fn get_scrobbler_log_path(ipod_config: &IpodConfig) -> Result<PathBuf> {
// Try multiple possible locations for the scrobbler log
let possible_paths = vec![
ipod_config.mount_point.join("scrobbler.log"), // Root of mount
ipod_config.mount_point.join(".scrobbler.log"), // Hidden in root
ipod_config.mount_point.join(".rockbox/scrobbler.log"), // Rockbox subdirectory
];
// Return the first path that exists, or default to root location
for path in &possible_paths {
if path.exists() {
info!("Found scrobbler log at {:?}", path);
return Ok(path.clone());
}
}
// Default to root location if none found
info!("Scrobbler log not found in any of: {:?}", possible_paths);
Ok(ipod_config.mount_point.join("scrobbler.log"))
}
/// Clear the scrobbler log after successful sync
pub fn clear_scrobbler_log(ipod_config: &IpodConfig) -> Result<()> {
let log_path = get_scrobbler_log_path(ipod_config)?;
if log_path.exists() {
std::fs::remove_file(&log_path)?;
info!("Cleared scrobbler log at {:?}", log_path);
}
Ok(())
}
/// Backup the scrobbler log before clearing
pub fn backup_scrobbler_log(ipod_config: &IpodConfig) -> Result<PathBuf> {
let log_path = get_scrobbler_log_path(ipod_config)?;
if !log_path.exists() {
return Ok(log_path);
}
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
let backup_path = log_path.with_file_name(format!("scrobbler.log.backup.{}", timestamp));
std::fs::copy(&log_path, &backup_path)?;
info!("Backed up scrobbler log to {:?}", backup_path);
Ok(backup_path)
}