feat: Add support for syncing playlists
Signed-off-by: SindreKjelsrud <sindre@kjelsrud.dev>
This commit is contained in:
parent
f1b31fabda
commit
86d45f324d
8 changed files with 470 additions and 17 deletions
29
README.md
29
README.md
|
|
@ -5,6 +5,7 @@ A native Rust application to sync songs and albums from your self-hosted Navidro
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🎵 Sync songs and albums from Navidrome
|
- 🎵 Sync songs and albums from Navidrome
|
||||||
|
- 🎼 Sync playlists from Navidrome (as M3U8 files)
|
||||||
- 📱 Support for iPod stock OS
|
- 📱 Support for iPod stock OS
|
||||||
- 🎸 Support for Rockbox firmware
|
- 🎸 Support for Rockbox firmware
|
||||||
- 📊 Scrobble listening history to ListenBrainz
|
- 📊 Scrobble listening history to ListenBrainz
|
||||||
|
|
@ -69,9 +70,15 @@ cargo run -- sync --album "Album Name"
|
||||||
# Sync specific artist
|
# Sync specific artist
|
||||||
cargo run -- sync --artist "Artist Name"
|
cargo run -- sync --artist "Artist Name"
|
||||||
|
|
||||||
|
# Sync specific playlist only
|
||||||
|
cargo run -- sync --playlist "Playlist Name"
|
||||||
|
|
||||||
# List available albums
|
# List available albums
|
||||||
cargo run -- list-albums
|
cargo run -- list-albums
|
||||||
|
|
||||||
|
# List available playlists
|
||||||
|
cargo run -- list-playlists
|
||||||
|
|
||||||
# Check iPod connection
|
# Check iPod connection
|
||||||
cargo run -- check
|
cargo run -- check
|
||||||
|
|
||||||
|
|
@ -85,6 +92,28 @@ cargo run -- scrobble --clear
|
||||||
cargo run -- scrobble --clear --no-backup
|
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
|
### Scrobbling to ListenBrainz
|
||||||
|
|
||||||
NaviPod can read the `.scrobbler.log` file from your Rockbox iPod and submit your listening history to ListenBrainz:
|
NaviPod can read the `.scrobbler.log` file from your Rockbox iPod and submit your listening history to ListenBrainz:
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ firmware = "rockbox"
|
||||||
[sync]
|
[sync]
|
||||||
# Sync albums
|
# Sync albums
|
||||||
albums = true
|
albums = true
|
||||||
# Sync playlists (not yet implemented)
|
# Sync playlists from Navidrome to iPod (stored as M3U8 files in Playlists folder)
|
||||||
playlists = false
|
playlists = true
|
||||||
# Preferred audio format (mp3, flac, ogg, etc.)
|
# Preferred audio format (mp3, flac, ogg, etc.)
|
||||||
format = "mp3"
|
format = "mp3"
|
||||||
|
|
||||||
|
|
|
||||||
43
shell.nix
43
shell.nix
|
|
@ -14,7 +14,11 @@ pkgs.mkShell {
|
||||||
];
|
];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
echo "NaviPod Development Environment"
|
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ NaviPod Development Environment ║"
|
||||||
|
echo "║ Sync Navidrome to iPod | Scrobble to ListenBrainz ║"
|
||||||
|
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
# Verify Rust is available
|
# Verify Rust is available
|
||||||
if command -v rustc &> /dev/null; then
|
if command -v rustc &> /dev/null; then
|
||||||
|
|
@ -26,15 +30,34 @@ pkgs.mkShell {
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Welcome to NaviPod development shell!"
|
echo "📦 Build Commands:"
|
||||||
echo "Run 'cargo build' to build the project."
|
echo " cargo build - Build in debug mode"
|
||||||
echo "Run 'cargo run' to run the CLI."
|
echo " cargo build --release - Build optimized release"
|
||||||
echo "Run 'cargo check' to check for compilation errors."
|
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 ""
|
||||||
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"
|
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
src/ipod.rs
52
src/ipod.rs
|
|
@ -108,7 +108,7 @@ impl Ipod {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize_filename(&self, filename: &str) -> String {
|
pub fn sanitize_filename(&self, filename: &str) -> String {
|
||||||
self.sanitize_path(filename)
|
self.sanitize_path(filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,6 +139,56 @@ impl Ipod {
|
||||||
pub fn file_exists(&self, path: &Path) -> bool {
|
pub fn file_exists(&self, path: &Path) -> bool {
|
||||||
path.exists()
|
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<()> {
|
pub fn check_ipod(config: &IpodConfig) -> Result<()> {
|
||||||
|
|
|
||||||
40
src/lib.rs
40
src/lib.rs
|
|
@ -32,9 +32,14 @@ pub enum Commands {
|
||||||
/// Sync specific artist
|
/// Sync specific artist
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
artist: Option<String>,
|
artist: Option<String>,
|
||||||
|
/// Sync specific playlist by name
|
||||||
|
#[arg(long)]
|
||||||
|
playlist: Option<String>,
|
||||||
},
|
},
|
||||||
/// List available albums from Navidrome
|
/// List available albums from Navidrome
|
||||||
ListAlbums,
|
ListAlbums,
|
||||||
|
/// List available playlists from Navidrome
|
||||||
|
ListPlaylists,
|
||||||
/// Check iPod connection and status
|
/// Check iPod connection and status
|
||||||
Check,
|
Check,
|
||||||
/// Scrobble listening history from iPod to ListenBrainz
|
/// Scrobble listening history from iPod to ListenBrainz
|
||||||
|
|
@ -52,7 +57,7 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Sync { album, artist } => {
|
Commands::Sync { album, artist, playlist } => {
|
||||||
// Create multi-progress for TUI
|
// Create multi-progress for TUI
|
||||||
let multi = MultiProgress::new();
|
let multi = MultiProgress::new();
|
||||||
|
|
||||||
|
|
@ -86,7 +91,7 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run sync with progress
|
// 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
|
// Finish progress bars
|
||||||
pb.finish_with_message("Sync complete!");
|
pb.finish_with_message("Sync complete!");
|
||||||
|
|
@ -111,11 +116,40 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
|
||||||
|
|
||||||
println!("Fetching albums...");
|
println!("Fetching albums...");
|
||||||
let albums = navidrome.list_albums().await?;
|
let albums = navidrome.list_albums().await?;
|
||||||
println!("Available albums:");
|
println!("\nAvailable albums ({} total):", albums.len());
|
||||||
for album in albums {
|
for album in albums {
|
||||||
println!(" - {} by {}", album.name, album.artist);
|
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 => {
|
Commands::Check => {
|
||||||
ipod::check_ipod(&config.ipod)?;
|
ipod::check_ipod(&config.ipod)?;
|
||||||
println!("✓ iPod connection OK");
|
println!("✓ iPod connection OK");
|
||||||
|
|
|
||||||
|
|
@ -116,3 +116,4 @@ impl ListenBrainzClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
192
src/navidrome.rs
192
src/navidrome.rs
|
|
@ -36,6 +36,16 @@ pub struct Song {
|
||||||
pub cover_art_id: Option<String>,
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
struct LoginResponse {
|
struct LoginResponse {
|
||||||
#[serde(rename = "subsonic-response")]
|
#[serde(rename = "subsonic-response")]
|
||||||
|
|
@ -465,6 +475,188 @@ impl NavidromeClient {
|
||||||
Ok(())
|
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(¶ms).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(¶ms).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)> {
|
fn get_credentials(&self) -> Result<(&str, &str)> {
|
||||||
let creds = self
|
let creds = self
|
||||||
.auth_token
|
.auth_token
|
||||||
|
|
|
||||||
126
src/sync.rs
126
src/sync.rs
|
|
@ -12,14 +12,16 @@ pub async fn sync_content(
|
||||||
config: Config,
|
config: Config,
|
||||||
album_filter: Option<String>,
|
album_filter: Option<String>,
|
||||||
artist_filter: Option<String>,
|
artist_filter: Option<String>,
|
||||||
|
playlist_filter: Option<String>,
|
||||||
) -> Result<()> {
|
) -> 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(
|
pub async fn sync_content_with_progress(
|
||||||
config: Config,
|
config: Config,
|
||||||
album_filter: Option<String>,
|
album_filter: Option<String>,
|
||||||
artist_filter: Option<String>,
|
artist_filter: Option<String>,
|
||||||
|
playlist_filter: Option<String>,
|
||||||
progress_callback: Option<ProgressCallback>,
|
progress_callback: Option<ProgressCallback>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
info!("Starting sync...");
|
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
|
// Cleanup temp directory
|
||||||
if temp_dir.exists() {
|
if temp_dir.exists() {
|
||||||
std::fs::remove_dir_all(&temp_dir)?;
|
std::fs::remove_dir_all(&temp_dir)?;
|
||||||
|
|
@ -173,3 +219,81 @@ async fn sync_album(
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue