feat: Add scrobble-command to Listenbrainz
Signed-off-by: SindreKjelsrud <sindre@kjelsrud.dev>
This commit is contained in:
parent
49c4a79845
commit
f1b31fabda
10 changed files with 541 additions and 46 deletions
115
Cargo.lock
generated
115
Cargo.lock
generated
|
|
@ -35,6 +35,15 @@ version = "0.2.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
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]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "0.6.21"
|
version = "0.6.21"
|
||||||
|
|
@ -146,6 +155,12 @@ version = "1.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
|
|
@ -210,6 +225,19 @@ version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
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]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.53"
|
version = "4.5.53"
|
||||||
|
|
@ -798,6 +826,30 @@ dependencies = [
|
||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
|
|
@ -1127,6 +1179,7 @@ name = "navipod"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"config",
|
"config",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|
@ -1166,6 +1219,15 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "number_prefix"
|
name = "number_prefix"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
|
|
@ -2274,12 +2336,65 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.48.0"
|
version = "0.48.0"
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ clap = { version = "4.0", features = ["derive"] }
|
||||||
# TUI Progress bars
|
# TUI Progress bars
|
||||||
indicatif = "0.17"
|
indicatif = "0.17"
|
||||||
|
|
||||||
|
# Date/time parsing
|
||||||
|
chrono = "0.4"
|
||||||
|
|
||||||
# ID3 tag parsing
|
# ID3 tag parsing
|
||||||
id3 = "1.13"
|
id3 = "1.13"
|
||||||
|
|
||||||
|
|
|
||||||
90
README.md
90
README.md
|
|
@ -7,8 +7,10 @@ A native Rust application to sync songs and albums from your self-hosted Navidro
|
||||||
- 🎵 Sync songs and albums from Navidrome
|
- 🎵 Sync songs and albums from Navidrome
|
||||||
- 📱 Support for iPod stock OS
|
- 📱 Support for iPod stock OS
|
||||||
- 🎸 Support for Rockbox firmware
|
- 🎸 Support for Rockbox firmware
|
||||||
|
- 📊 Scrobble listening history to ListenBrainz
|
||||||
- ⚙️ Configurable sync options
|
- ⚙️ Configurable sync options
|
||||||
- 🔄 Incremental sync support
|
- 🔄 Incremental sync support
|
||||||
|
- 📈 Terminal progress bars with live status
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -38,6 +40,10 @@ cargo build --release
|
||||||
albums = true
|
albums = true
|
||||||
playlists = false
|
playlists = false
|
||||||
format = "mp3"
|
format = "mp3"
|
||||||
|
|
||||||
|
[listenbrainz]
|
||||||
|
# Optional - only needed for scrobbling to ListenBrainz
|
||||||
|
token = "your_listenbrainz_token"
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Alternatively, use environment variables (prefixed with `NAVIPOD__`):
|
3. Alternatively, use environment variables (prefixed with `NAVIPOD__`):
|
||||||
|
|
@ -53,37 +59,50 @@ cargo build --release
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### GUI Application
|
|
||||||
|
|
||||||
Launch the graphical interface:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
navipod-gui
|
# Sync all configured content (with progress bar)
|
||||||
```
|
cargo run -- sync
|
||||||
|
|
||||||
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 specific album
|
# 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
|
# List available albums
|
||||||
navipod list-albums
|
cargo run -- list-albums
|
||||||
|
|
||||||
# Check iPod connection
|
# 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
|
## Requirements
|
||||||
|
|
||||||
- Rust 1.70+ (and Cargo)
|
- Rust 1.70+ (and Cargo)
|
||||||
|
|
@ -111,30 +130,11 @@ nix-shell
|
||||||
# Build in release mode
|
# Build in release mode
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
# The binaries will be at:
|
# The binary will be at:
|
||||||
# - target/release/navipod (CLI)
|
# - target/release/navipod
|
||||||
# - target/release/navipod-gui (GUI)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running the GUI
|
# Install to your PATH (optional)
|
||||||
|
cargo install --path .
|
||||||
```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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,8 @@ playlists = false
|
||||||
# Preferred audio format (mp3, flac, ogg, etc.)
|
# Preferred audio format (mp3, flac, ogg, etc.)
|
||||||
format = "mp3"
|
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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,5 +35,6 @@ pkgs.mkShell {
|
||||||
echo " navipod sync [--album ALBUM] [--artist ARTIST] - Sync content to iPod"
|
echo " navipod sync [--album ALBUM] [--artist ARTIST] - Sync content to iPod"
|
||||||
echo " navipod list-albums - List available albums"
|
echo " navipod list-albums - List available albums"
|
||||||
echo " navipod check - Check iPod connection"
|
echo " navipod check - Check iPod connection"
|
||||||
|
echo " navipod scrobble [--clear] [--backup] - Sync scrobbles to ListenBrainz"
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ pub struct Config {
|
||||||
pub navidrome: NavidromeConfig,
|
pub navidrome: NavidromeConfig,
|
||||||
pub ipod: IpodConfig,
|
pub ipod: IpodConfig,
|
||||||
pub sync: SyncConfig,
|
pub sync: SyncConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub listenbrainz: Option<ListenBrainzConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -35,6 +37,11 @@ pub struct SyncConfig {
|
||||||
pub format: String,
|
pub format: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ListenBrainzConfig {
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn load() -> anyhow::Result<Self> {
|
pub fn load() -> anyhow::Result<Self> {
|
||||||
let config_path = Self::config_path()?;
|
let config_path = Self::config_path()?;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ pub enum NaviPodError {
|
||||||
|
|
||||||
#[error("Sync error: {0}")]
|
#[error("Sync error: {0}")]
|
||||||
Sync(String),
|
Sync(String),
|
||||||
|
|
||||||
|
#[error("ListenBrainz error: {0}")]
|
||||||
|
ListenBrainzError(String),
|
||||||
|
|
||||||
|
#[error("Scrobbler parse error: {0}")]
|
||||||
|
ScrobblerParseError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, NaviPodError>;
|
pub type Result<T> = std::result::Result<T, NaviPodError>;
|
||||||
|
|
|
||||||
85
src/lib.rs
85
src/lib.rs
|
|
@ -1,7 +1,9 @@
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod ipod;
|
pub mod ipod;
|
||||||
|
pub mod listenbrainz;
|
||||||
pub mod navidrome;
|
pub mod navidrome;
|
||||||
|
pub mod scrobbler;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
|
||||||
// Re-export Config so it can be used from main.rs
|
// Re-export Config so it can be used from main.rs
|
||||||
|
|
@ -10,6 +12,7 @@ pub use config::Config;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use chrono;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "navipod")]
|
#[command(name = "navipod")]
|
||||||
|
|
@ -34,6 +37,15 @@ pub enum Commands {
|
||||||
ListAlbums,
|
ListAlbums,
|
||||||
/// Check iPod connection and status
|
/// Check iPod connection and status
|
||||||
Check,
|
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<()> {
|
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)?;
|
ipod::check_ipod(&config.ipod)?;
|
||||||
println!("✓ iPod connection OK");
|
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(())
|
Ok(())
|
||||||
|
|
|
||||||
118
src/listenbrainz.rs
Normal file
118
src/listenbrainz.rs
Normal 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
155
src/scrobbler.rs
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue