feat: Add support for syncing playlists

Signed-off-by: SindreKjelsrud <sindre@kjelsrud.dev>
This commit is contained in:
SindreKjelsrud 2026-01-15 15:27:34 +01:00
parent f1b31fabda
commit 86d45f324d
Signed by: sidski
GPG key ID: D2BBDF3EDE6BA9A6
8 changed files with 470 additions and 17 deletions

View file

@ -5,6 +5,7 @@ A native Rust application to sync songs and albums from your self-hosted Navidro
## Features
- 🎵 Sync songs and albums from Navidrome
- 🎼 Sync playlists from Navidrome (as M3U8 files)
- 📱 Support for iPod stock OS
- 🎸 Support for Rockbox firmware
- 📊 Scrobble listening history to ListenBrainz
@ -69,9 +70,15 @@ cargo run -- sync --album "Album Name"
# Sync specific artist
cargo run -- sync --artist "Artist Name"
# Sync specific playlist only
cargo run -- sync --playlist "Playlist Name"
# List available albums
cargo run -- list-albums
# List available playlists
cargo run -- list-playlists
# Check iPod connection
cargo run -- check
@ -85,6 +92,28 @@ cargo run -- scrobble --clear
cargo run -- scrobble --clear --no-backup
```
### Syncing Playlists
NaviPod can sync your Navidrome playlists to your iPod as M3U8 files:
1. Enable playlist syncing in your `config.toml`:
```toml
[sync]
playlists = true
```
2. Run the sync command:
```bash
# Sync all playlists
cargo run -- sync
# Or sync a specific playlist only
cargo run -- sync --playlist "My Favorites"
```
3. Playlists will be created in the `Playlists` folder on your iPod
4. Only songs that are already on your iPod will be included in the playlists
**Note:** Playlists use M3U8 format (UTF-8 encoded) with relative paths, which works with both Rockbox and most other media players.
### Scrobbling to ListenBrainz
NaviPod can read the `.scrobbler.log` file from your Rockbox iPod and submit your listening history to ListenBrainz:

View file

@ -18,8 +18,8 @@ firmware = "rockbox"
[sync]
# Sync albums
albums = true
# Sync playlists (not yet implemented)
playlists = false
# Sync playlists from Navidrome to iPod (stored as M3U8 files in Playlists folder)
playlists = true
# Preferred audio format (mp3, flac, ogg, etc.)
format = "mp3"

View file

@ -14,7 +14,11 @@ pkgs.mkShell {
];
shellHook = ''
echo "NaviPod Development Environment"
echo ""
echo " NaviPod Development Environment "
echo " Sync Navidrome to iPod | Scrobble to ListenBrainz "
echo ""
echo ""
# Verify Rust is available
if command -v rustc &> /dev/null; then
@ -26,15 +30,34 @@ pkgs.mkShell {
fi
echo ""
echo "Welcome to NaviPod development shell!"
echo "Run 'cargo build' to build the project."
echo "Run 'cargo run' to run the CLI."
echo "Run 'cargo check' to check for compilation errors."
echo "📦 Build Commands:"
echo " cargo build - Build in debug mode"
echo " cargo build --release - Build optimized release"
echo " cargo check - Check for compilation errors"
echo " cargo run -- [COMMAND] - Build and run a command"
echo ""
echo "🎵 NaviPod Commands:"
echo " cargo run -- sync [OPTIONS]"
echo " --album <NAME> Sync specific album"
echo " --artist <NAME> Sync specific artist"
echo " --playlist <NAME> Sync specific playlist"
echo ""
echo " cargo run -- list-albums List all albums from Navidrome"
echo " cargo run -- list-playlists List all playlists from Navidrome"
echo " cargo run -- check Check iPod connection"
echo ""
echo " cargo run -- scrobble [OPTIONS]"
echo " --clear Clear scrobbler log after sync"
echo " --no-backup Skip backup when clearing"
echo ""
echo "📝 Configuration:"
echo " Edit config.toml to set your Navidrome URL, credentials,"
echo " iPod mount point, and ListenBrainz token."
echo ""
echo "💡 Quick Start:"
echo " 1. cp config.example.toml config.toml"
echo " 2. Edit config.toml with your settings"
echo " 3. cargo run -- sync"
echo ""
echo "Available commands:"
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

@ -108,7 +108,7 @@ impl Ipod {
.collect()
}
fn sanitize_filename(&self, filename: &str) -> String {
pub fn sanitize_filename(&self, filename: &str) -> String {
self.sanitize_path(filename)
}
@ -139,6 +139,56 @@ impl Ipod {
pub fn file_exists(&self, path: &Path) -> bool {
path.exists()
}
pub fn get_playlists_directory(&self) -> PathBuf {
match self.firmware {
FirmwareType::Stock => {
// Stock iPod might use iPod_Control/Playlists
self.mount_point.join("iPod_Control").join("Playlists")
}
FirmwareType::Rockbox => {
// Rockbox uses Playlists directory at the root
self.mount_point.join("Playlists")
}
}
}
pub fn ensure_playlists_directory(&self) -> Result<()> {
let playlists_dir = self.get_playlists_directory();
std::fs::create_dir_all(&playlists_dir)?;
Ok(())
}
pub fn write_playlist(&self, name: &str, songs: &[(String, String, String)]) -> Result<PathBuf> {
// songs: Vec<(artist, album, song_filename)>
self.ensure_playlists_directory()?;
let playlist_path = self.get_playlists_directory()
.join(format!("{}.m3u8", self.sanitize_filename(name)));
let mut content = String::new();
content.push_str("#EXTM3U\n");
for (artist, album, song_filename) in songs {
// Get relative path from playlists directory to song
let song_path = self.get_album_path(artist, album).join(song_filename);
// Calculate relative path from playlists directory to song
let playlists_dir = self.get_playlists_directory();
let relative_path = match pathdiff::diff_paths(&song_path, &playlists_dir) {
Some(path) => path.to_string_lossy().to_string(),
None => {
// Fallback to absolute path if relative path calculation fails
song_path.to_string_lossy().to_string()
}
};
content.push_str(&format!("{}\n", relative_path));
}
std::fs::write(&playlist_path, content)?;
Ok(playlist_path)
}
}
pub fn check_ipod(config: &IpodConfig) -> Result<()> {

View file

@ -32,9 +32,14 @@ pub enum Commands {
/// Sync specific artist
#[arg(long)]
artist: Option<String>,
/// Sync specific playlist by name
#[arg(long)]
playlist: Option<String>,
},
/// List available albums from Navidrome
ListAlbums,
/// List available playlists from Navidrome
ListPlaylists,
/// Check iPod connection and status
Check,
/// Scrobble listening history from iPod to ListenBrainz
@ -52,7 +57,7 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Sync { album, artist } => {
Commands::Sync { album, artist, playlist } => {
// Create multi-progress for TUI
let multi = MultiProgress::new();
@ -86,7 +91,7 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
});
// Run sync with progress
let result = sync::sync_content_with_progress(config, album, artist, Some(progress_callback)).await;
let result = sync::sync_content_with_progress(config, album, artist, playlist, Some(progress_callback)).await;
// Finish progress bars
pb.finish_with_message("Sync complete!");
@ -111,11 +116,40 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
println!("Fetching albums...");
let albums = navidrome.list_albums().await?;
println!("Available albums:");
println!("\nAvailable albums ({} total):", albums.len());
for album in albums {
println!(" - {} by {}", album.name, album.artist);
}
}
Commands::ListPlaylists => {
let mut navidrome = navidrome::NavidromeClient::new(&config.navidrome)?;
match navidrome
.authenticate(&config.navidrome.username, &config.navidrome.password)
.await
{
Ok(_) => {
println!("✓ Authentication successful");
}
Err(e) => {
eprintln!("✗ Authentication failed: {}", e);
return Err(anyhow::anyhow!("Authentication failed: {}", e));
}
}
println!("Fetching playlists...");
let playlists = navidrome.list_playlists().await?;
println!("\nAvailable playlists ({} total):", playlists.len());
for playlist in playlists {
let duration_mins = playlist.duration / 60;
println!(
" - {} ({} songs, {} min) - by {}",
playlist.name,
playlist.song_count,
duration_mins,
playlist.owner
);
}
}
Commands::Check => {
ipod::check_ipod(&config.ipod)?;
println!("✓ iPod connection OK");

View file

@ -116,3 +116,4 @@ impl ListenBrainzClient {
}
}

View file

@ -36,6 +36,16 @@ pub struct Song {
pub cover_art_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Playlist {
pub id: String,
pub name: String,
pub song_count: u32,
pub duration: u32,
pub public: bool,
pub owner: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct LoginResponse {
#[serde(rename = "subsonic-response")]
@ -465,6 +475,188 @@ impl NavidromeClient {
Ok(())
}
pub async fn list_playlists(&self) -> Result<Vec<Playlist>> {
let base_url = self.base_url.trim_end_matches('/');
let url = format!("{}/rest/getPlaylists.view", base_url);
let (username, password) = self.get_credentials()?;
let mut params = HashMap::new();
params.insert("f", "json");
params.insert("u", &username);
params.insert("p", &password);
params.insert("v", "1.16.0");
params.insert("c", "navipod");
let response = self.client.get(&url).query(&params).send().await?;
let status = response.status();
let text = response.text().await?;
if !status.is_success() {
return Err(NaviPodError::NavidromeApi(format!(
"HTTP error {}: {}",
status, text
)));
}
#[derive(Debug, Deserialize)]
struct PlaylistsResponse {
#[serde(rename = "subsonic-response")]
subsonic_response: PlaylistsSubsonicResponse,
}
#[derive(Debug, Deserialize)]
struct PlaylistsSubsonicResponse {
status: String,
playlists: Option<PlaylistsWrapper>,
}
#[derive(Debug, Deserialize)]
struct PlaylistsWrapper {
playlist: PlaylistOrArray,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PlaylistOrArray {
Multiple(Vec<PlaylistResponse>),
Single(PlaylistResponse),
}
impl PlaylistOrArray {
fn into_vec(self) -> Vec<PlaylistResponse> {
match self {
PlaylistOrArray::Single(p) => vec![p],
PlaylistOrArray::Multiple(p) => p,
}
}
}
#[derive(Debug, Deserialize)]
struct PlaylistResponse {
id: String,
name: String,
#[serde(rename = "songCount")]
song_count: u32,
duration: u32,
public: bool,
owner: String,
}
let playlists_resp: PlaylistsResponse = serde_json::from_str(&text)
.map_err(|e| NaviPodError::NavidromeApi(format!(
"Failed to parse playlists response: {}. Response: {}", e, text
)))?;
if playlists_resp.subsonic_response.status != "ok" {
return Err(NaviPodError::NavidromeApi(
"API returned non-ok status".to_string(),
));
}
let playlists = playlists_resp
.subsonic_response
.playlists
.map(|w| {
w.playlist
.into_vec()
.into_iter()
.map(|p| Playlist {
id: p.id,
name: p.name,
song_count: p.song_count,
duration: p.duration,
public: p.public,
owner: p.owner,
})
.collect()
})
.unwrap_or_default();
Ok(playlists)
}
pub async fn get_playlist_songs(&self, playlist_id: &str) -> Result<Vec<Song>> {
let base_url = self.base_url.trim_end_matches('/');
let url = format!("{}/rest/getPlaylist.view", base_url);
let (username, password) = self.get_credentials()?;
let mut params = HashMap::new();
params.insert("f", "json");
params.insert("u", &username);
params.insert("p", &password);
params.insert("v", "1.16.0");
params.insert("c", "navipod");
params.insert("id", playlist_id);
let response = self.client.get(&url).query(&params).send().await?;
let status = response.status();
let text = response.text().await?;
if !status.is_success() {
return Err(NaviPodError::NavidromeApi(format!(
"HTTP error {}: {}",
status, text
)));
}
#[derive(Debug, Deserialize)]
struct PlaylistSongsResponse {
#[serde(rename = "subsonic-response")]
subsonic_response: PlaylistSongsSubsonicResponse,
}
#[derive(Debug, Deserialize)]
struct PlaylistSongsSubsonicResponse {
status: String,
playlist: Option<PlaylistWithSongs>,
}
#[derive(Debug, Deserialize)]
struct PlaylistWithSongs {
#[serde(rename = "entry")]
entry: Option<SongOrArray>,
}
let playlist_resp: PlaylistSongsResponse = serde_json::from_str(&text)
.map_err(|e| NaviPodError::NavidromeApi(format!(
"Failed to parse playlist songs response: {}. Response: {}", e, text
)))?;
if playlist_resp.subsonic_response.status != "ok" {
return Err(NaviPodError::NavidromeApi(
"API returned non-ok status".to_string(),
));
}
let songs = playlist_resp
.subsonic_response
.playlist
.and_then(|p| p.entry)
.map(|e| e.into_vec())
.unwrap_or_default()
.into_iter()
.map(|s| Song {
id: s.id,
title: s.title,
artist: s.artist,
album: s.album,
album_id: s.album_id,
duration: s.duration,
bitrate: s.bitrate.unwrap_or(0),
size: s.size,
suffix: s.suffix,
path: s.path,
cover_art_id: s.cover_art_id,
})
.collect();
Ok(songs)
}
fn get_credentials(&self) -> Result<(&str, &str)> {
let creds = self
.auth_token

View file

@ -12,14 +12,16 @@ pub async fn sync_content(
config: Config,
album_filter: Option<String>,
artist_filter: Option<String>,
playlist_filter: Option<String>,
) -> Result<()> {
sync_content_with_progress(config, album_filter, artist_filter, None).await
sync_content_with_progress(config, album_filter, artist_filter, playlist_filter, None).await
}
pub async fn sync_content_with_progress(
config: Config,
album_filter: Option<String>,
artist_filter: Option<String>,
playlist_filter: Option<String>,
progress_callback: Option<ProgressCallback>,
) -> Result<()> {
info!("Starting sync...");
@ -96,6 +98,50 @@ pub async fn sync_content_with_progress(
}
}
// Sync playlists if enabled
if config.sync.playlists {
info!("Syncing playlists...");
ipod.ensure_playlists_directory()?;
let playlists = navidrome.list_playlists().await?;
// Filter playlists if specific playlist requested
let playlists_to_sync: Vec<_> = playlists
.into_iter()
.filter(|playlist| {
if let Some(ref filter) = playlist_filter {
playlist.name.to_lowercase().contains(&filter.to_lowercase())
} else {
true
}
})
.collect();
info!("Found {} playlist(s) to sync", playlists_to_sync.len());
for playlist in playlists_to_sync {
info!("Syncing playlist: {}", playlist.name);
if let Some(ref callback) = progress_callback {
callback(
format!("Playlist: {}", playlist.name),
"Loading...".to_string(),
0,
0,
);
}
match sync_playlist(&navidrome, &ipod, &playlist, progress_callback.as_ref()).await {
Ok(_) => {
info!("✓ Successfully synced playlist: {}", playlist.name);
}
Err(e) => {
warn!("✗ Failed to sync playlist {}: {}", playlist.name, e);
}
}
}
}
// Cleanup temp directory
if temp_dir.exists() {
std::fs::remove_dir_all(&temp_dir)?;
@ -173,3 +219,81 @@ async fn sync_album(
Ok(())
}
async fn sync_playlist(
navidrome: &NavidromeClient,
ipod: &Ipod,
playlist: &crate::navidrome::Playlist,
progress_callback: Option<&ProgressCallback>,
) -> Result<()> {
// Get songs in the playlist
let songs = navidrome.get_playlist_songs(&playlist.id).await?;
if songs.is_empty() {
info!(" Playlist is empty, skipping");
return Ok(());
}
info!(" Found {} song(s) in playlist", songs.len());
// Build list of songs with their paths for M3U
let mut playlist_entries = Vec::new();
for song in songs {
if let Some(callback) = progress_callback {
callback(
format!("Playlist: {}", playlist.name),
format!("Checking: {}", song.title),
0,
0,
);
}
// Get the artist from the song or use "Unknown Artist"
let artist = if !song.artist.is_empty() {
song.artist.clone()
} else {
"Unknown Artist".to_string()
};
// Get the album from the song or use "Unknown Album"
let album = if !song.album.is_empty() {
song.album.clone()
} else {
"Unknown Album".to_string()
};
let song_filename = format!("{}.{}", ipod.sanitize_filename(&song.title), song.suffix);
// Check if song exists on iPod
let song_path = ipod.get_song_path(&artist, &album, &song.title, &song.suffix);
if ipod.file_exists(&song_path) {
// Song exists, add to playlist
playlist_entries.push((artist, album, song_filename));
} else {
info!(" Song not on iPod, skipping from playlist: {}", song.title);
}
}
if playlist_entries.is_empty() {
info!(" No songs from playlist found on iPod, skipping playlist");
return Ok(());
}
// Write playlist file
if let Some(callback) = progress_callback {
callback(
format!("Playlist: {}", playlist.name),
"Writing playlist file...".to_string(),
0,
0,
);
}
let playlist_path = ipod.write_playlist(&playlist.name, &playlist_entries)?;
info!(" Wrote playlist to: {:?}", playlist_path);
info!(" Playlist contains {} song(s)", playlist_entries.len());
Ok(())
}