A tool to sync music with your favorite devices
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

*: abstract disk and iPod through the Device trait

Use iTunesDB instead of tunz.db for iPod management.

Gee Sawra f8f650ec 231abaa0

+469 -53
+158
src/device/disk.rs
··· 1 + use crate::{cmd::error, db, device::Device, ffmpeg}; 2 + use anyhow::{Context, Result, anyhow}; 3 + use fs_extra::file::{CopyOptions, copy_with_progress}; 4 + use std::{path::PathBuf, str::FromStr}; 5 + 6 + pub struct Disk { 7 + db_handle: db::Instance, 8 + root_path: PathBuf, 9 + } 10 + 11 + impl Disk { 12 + pub async fn new(path: PathBuf) -> Result<Self> { 13 + let db = db::Instance::new(&path.to_string_lossy(), true) 14 + .await 15 + .with_context(|| "Cannot open database instance")?; 16 + 17 + Ok(Self { 18 + root_path: path, 19 + db_handle: db, 20 + }) 21 + } 22 + } 23 + 24 + impl Device for Disk { 25 + fn typ() -> super::DeviceType { 26 + super::DeviceType::Disk 27 + } 28 + 29 + async fn set_transcoding_settings( 30 + &self, 31 + settings: Option<crate::ffmpeg::Quality>, 32 + ) -> Result<()> { 33 + match settings { 34 + Some(s) => { 35 + self.db_handle.set_transcoding(true).await?; 36 + self.db_handle 37 + .set_transcoding_settings( 38 + Some(s.codec.to_string()), 39 + Some(s.bitrate.to_string()), 40 + s.sampling_rate.map(|e| e.to_string()), 41 + ) 42 + .await?; 43 + 44 + Ok(()) 45 + } 46 + None => Ok(self.db_handle.set_transcoding(false).await?), 47 + } 48 + } 49 + 50 + async fn list_by_state( 51 + &self, 52 + state: crate::model::FileState, 53 + ) -> Result<Vec<crate::model::Track>> { 54 + self.db_handle 55 + .tracks_by_state(state) 56 + .await 57 + .map_err(|e| anyhow!(e)) 58 + } 59 + 60 + async fn add(&self, track: crate::model::Track) -> Result<()> { 61 + let transcoding_settings = self.db_handle.get_transcoding_settings().await?; 62 + 63 + let is_lossless = track.is_lossless(); 64 + 65 + let sp = PathBuf::from_str(&track.storage_path(self.root_path.to_string_lossy().as_ref())) 66 + .unwrap(); 67 + 68 + let parent = sp 69 + .parent() 70 + .with_context(|| "Cannot obtain base destination directory")?; 71 + 72 + let mut dest_track = track.clone(); 73 + dest_track.file_state = crate::model::FileState::Copying; 74 + self.db_handle 75 + .insert_track(&dest_track) 76 + .await 77 + .with_context(|| "Cannot insert in-progress copying track in destination database")?; 78 + 79 + std::fs::create_dir_all(parent).with_context(|| { 80 + format!( 81 + "Cannot create destination directory tree {}", 82 + parent.to_str().unwrap() 83 + ) 84 + })?; 85 + 86 + let track_dest_path = if let Some(ref ts) = transcoding_settings 87 + && is_lossless 88 + { 89 + let mut sp = sp.clone(); 90 + sp.set_extension(ts.codec.extension()); 91 + sp 92 + } else { 93 + sp 94 + }; 95 + 96 + let opts = CopyOptions::new().overwrite(true); 97 + 98 + async_std::task::spawn_blocking({ 99 + let track_for_thread = track.clone(); 100 + let ts_for_thread = transcoding_settings.clone(); 101 + 102 + move || { 103 + if let Some(ts) = ts_for_thread 104 + && is_lossless 105 + { 106 + log::info!("transcoding & copying {} ({})", track_for_thread, ts); 107 + log::debug!("transcoding path: {}", track_dest_path.display()); 108 + 109 + ffmpeg::convert(&track_for_thread, track_dest_path, &ts)?; 110 + 111 + return Ok(()); 112 + } 113 + 114 + log::info!("copying {}", track_for_thread); 115 + log::debug!("YOOO {}", track_dest_path.display()); 116 + 117 + match copy_with_progress( 118 + track_for_thread.file_path.clone(), 119 + track_dest_path.clone(), 120 + &opts, 121 + |_| {}, 122 + ) { 123 + std::result::Result::Ok(_) => {} 124 + Err(err) => { 125 + return Err(error::Error::CopyError(err)).with_context(|| { 126 + format!( 127 + "Cannot copy {} to {}", 128 + track_for_thread.file_path, 129 + track_dest_path.display() 130 + ) 131 + }); 132 + } 133 + }; 134 + 135 + Ok(()) 136 + } 137 + }) 138 + .await?; 139 + 140 + dest_track.file_state = crate::model::FileState::Copied; 141 + self.db_handle 142 + .insert_track(&dest_track) 143 + .await 144 + .with_context(|| "Cannot insert copy finished track in destination database")?; 145 + 146 + Ok(()) 147 + } 148 + 149 + async fn delete(&self, track: crate::model::Track) -> Result<()> { 150 + let track_storage_path = 151 + track.storage_path(&track.storage_path(self.root_path.to_string_lossy().as_ref())); 152 + 153 + self.db_handle.delete(track.id).await?; 154 + std::fs::remove_file(track_storage_path.clone()) 155 + .with_context(|| format!("Cannot delete file {}", track_storage_path.clone()))?; 156 + Ok(()) 157 + } 158 + }
+156
src/device/ipod.rs
··· 1 + use crate::{cmd::error, db, device::Device, ffmpeg, gpod}; 2 + use anyhow::{Context, anyhow}; 3 + use fs_extra::file::{CopyOptions, copy_with_progress}; 4 + use std::path::PathBuf; 5 + 6 + pub struct IPod { 7 + db_handle: db::Instance, 8 + mountpoint: PathBuf, 9 + } 10 + 11 + impl Device for IPod { 12 + fn typ() -> super::DeviceType { 13 + super::DeviceType::IPod 14 + } 15 + 16 + async fn set_transcoding_settings( 17 + &self, 18 + settings: Option<crate::ffmpeg::Quality>, 19 + ) -> anyhow::Result<()> { 20 + match settings { 21 + Some(s) => { 22 + match s.codec { 23 + ffmpeg::Codec::AAC | ffmpeg::Codec::MP3 => {} 24 + _ => return Err(anyhow!("only MP3 or AAC are supported for iPod targets")), 25 + }; 26 + 27 + self.db_handle.set_transcoding(true).await?; 28 + self.db_handle 29 + .set_transcoding_settings( 30 + Some(s.codec.to_string()), 31 + Some(s.bitrate.to_string()), 32 + s.sampling_rate.map(|e| e.to_string()), 33 + ) 34 + .await?; 35 + 36 + Ok(()) 37 + } 38 + None => Ok(self.db_handle.set_transcoding(false).await?), 39 + } 40 + } 41 + 42 + async fn list_by_state( 43 + &self, 44 + _: crate::model::FileState, // all tracks on iPods are always Copied. 45 + ) -> anyhow::Result<Vec<crate::model::Track>> { 46 + let ipm = gpod::Manager::new(self.mountpoint.to_string_lossy().to_string())?; 47 + Ok(ipm.tracks()?.collect()) 48 + } 49 + 50 + async fn add(&self, track: crate::model::Track) -> anyhow::Result<()> { 51 + let ipm = gpod::Manager::new(self.mountpoint.to_string_lossy().to_string())?; 52 + 53 + let ts = match self.db_handle.get_transcoding_settings().await? { 54 + Some(s) => s, 55 + None => ffmpeg::Quality { 56 + bitrate: ffmpeg::Bitrate::VBR("5".to_owned()), 57 + sampling_rate: Some(ffmpeg::SamplingRate::Rate441K), 58 + codec: ffmpeg::Codec::AAC, 59 + }, 60 + }; 61 + 62 + let mut fp: PathBuf = track.file_path.clone().into(); 63 + match track.is_lossless() { 64 + true => { 65 + fp.set_extension(ffmpeg::Codec::AAC.extension()); 66 + } 67 + false => (), 68 + } 69 + let dest_path = ipm.path_for_file(fp.as_path()).unwrap(); 70 + 71 + let is_lossless = track.is_lossless(); 72 + 73 + let parent = dest_path 74 + .parent() 75 + .with_context(|| "Cannot obtain base destination directory")?; 76 + 77 + std::fs::create_dir_all(parent).with_context(|| { 78 + format!( 79 + "Cannot create destination directory tree {}", 80 + parent.to_str().unwrap() 81 + ) 82 + })?; 83 + 84 + let track_dest_path = if is_lossless { 85 + let mut dest_path = dest_path.clone(); 86 + dest_path.set_extension(ts.codec.extension()); 87 + dest_path 88 + } else { 89 + dest_path 90 + }; 91 + 92 + let opts = CopyOptions::new().overwrite(true); 93 + 94 + let copy_res = async_std::task::spawn_blocking({ 95 + let track_for_thread = track.clone(); 96 + let track_dest_path = track_dest_path.clone(); 97 + 98 + move || { 99 + if is_lossless { 100 + log::info!("transcoding & copying {} ({})", track_for_thread, ts); 101 + 102 + return Ok(Some(ffmpeg::convert( 103 + &track_for_thread, 104 + track_dest_path, 105 + &ts, 106 + )?)); 107 + } 108 + 109 + log::info!("copying {}", track_for_thread); 110 + log::debug!("YOOO {}", track_dest_path.display()); 111 + 112 + match copy_with_progress( 113 + track_for_thread.file_path.clone(), 114 + track_dest_path.clone(), 115 + &opts, 116 + |_| {}, 117 + ) { 118 + std::result::Result::Ok(_) => {} 119 + Err(err) => { 120 + return Err(error::Error::CopyError(err)).with_context(|| { 121 + format!( 122 + "Cannot copy {} to {}", 123 + track_for_thread.file_path, 124 + track_dest_path.display() 125 + ) 126 + }); 127 + } 128 + }; 129 + 130 + Ok(None) 131 + } 132 + }) 133 + .await?; 134 + 135 + // now finalize the copy 136 + ipm.finalize_track( 137 + &track.clone(), 138 + track_dest_path.clone(), 139 + track.extension, 140 + 44100, 141 + 256, 142 + if let Some(r) = copy_res { 143 + r.replaygain.map(|e| e.soundcheck_value()) 144 + } else { 145 + None 146 + }, 147 + )?; 148 + 149 + Ok(()) 150 + } 151 + 152 + async fn delete(&self, _: crate::model::Track) -> anyhow::Result<()> { 153 + // TODO: implement deletion 154 + Ok(()) 155 + } 156 + }
+15
src/device/lib.rs
··· 1 + use crate::{ffmpeg, model}; 2 + use anyhow::Result; 3 + 4 + pub enum DeviceType { 5 + IPod, 6 + Disk, 7 + } 8 + 9 + pub trait Device { 10 + fn typ() -> DeviceType; 11 + async fn set_transcoding_settings(&self, settings: Option<ffmpeg::Quality>) -> Result<()>; 12 + async fn list_by_state(&self, state: model::FileState) -> Result<Vec<model::Track>>; 13 + async fn add(&self, track: model::Track) -> Result<()>; 14 + async fn delete(&self, track: model::Track) -> Result<()>; 15 + }
+5
src/device/mod.rs
··· 1 + mod disk; 2 + mod ipod; 3 + mod lib; 4 + 5 + pub use crate::device::lib::{Device, DeviceType};
+84 -7
src/gpod/gpod.rs
··· 5 5 ptr, 6 6 }; 7 7 8 - use crate::model::Track; 8 + use crate::model::{self, FileState, Track}; 9 9 10 10 use super::bindings::*; 11 11 use anyhow::{Context, Result, anyhow}; ··· 37 37 err_msg.to_string_lossy() 38 38 ); 39 39 } 40 - itdb_stop_sync(self.db_ptr); 41 40 } 42 41 } 43 42 } ··· 62 61 63 62 match db_ptr.is_null() { 64 63 true => Err(anyhow!("no database found")), 65 - false => { 66 - itdb_start_sync(db_ptr); 67 - 68 - Ok(Self { db_ptr, mountpoint }) 69 - } 64 + false => Ok(Self { db_ptr, mountpoint }), 70 65 } 71 66 } 72 67 } ··· 165 160 Ok(()) 166 161 } 167 162 } 163 + 164 + pub fn tracks(&self) -> Result<impl Iterator<Item = Track>> { 165 + unsafe { 166 + Ok(ITunesDBTrack { 167 + tl: (*itdb_playlist_mpl(self.db_ptr)).members as *mut GList, 168 + }) 169 + } 170 + } 171 + } 172 + 173 + struct ITunesDBTrack { 174 + tl: *mut GList, 175 + } 176 + 177 + impl Iterator for ITunesDBTrack { 178 + type Item = Track; 179 + 180 + fn next(&mut self) -> Option<Self::Item> { 181 + unsafe { 182 + if self.tl.is_null() { 183 + return None; 184 + } 185 + let it_track = (*self.tl).data as *mut Itdb_Track; 186 + self.tl = (*self.tl).next; 187 + 188 + let file_type = string((*it_track).filetype) 189 + .split("-") 190 + .collect::<Vec<&str>>() 191 + .first() 192 + .unwrap() 193 + .to_lowercase() 194 + .to_owned(); 195 + 196 + let mut track = Track { 197 + id: (*it_track).id as i64, 198 + track_id: Default::default(), 199 + title: string((*it_track).title), 200 + artist: string((*it_track).artist), 201 + album: string((*it_track).album), 202 + genre: string((*it_track).genre), 203 + track_len: (*it_track).tracklen as i64, 204 + year: (*it_track).year as usize, 205 + number: (*it_track).track_nr as i64, 206 + file_path: String::new(), 207 + disc_number: 0, 208 + disc_total: 0, 209 + file_state: FileState::Copied, 210 + extension: file_type, 211 + artwork_path: None, 212 + }; 213 + 214 + track.track_id = model::track_hash(&track); 215 + 216 + Some(track) 217 + } 218 + } 168 219 } 169 220 170 221 unsafe fn c_strdup(s: &str) -> *mut std::ffi::c_char { ··· 172 223 let c_str = std::ffi::CString::new(s).unwrap(); 173 224 // libc::strdup allocates memory (malloc) that libgpod can safely read and eventually free. 174 225 libc::strdup(c_str.as_ptr()) 226 + } 227 + } 228 + 229 + /// string returns the string at raw, or an empty string if null. 230 + fn string(raw: *mut u8) -> String { 231 + unsafe { 232 + if raw.is_null() { 233 + String::new() 234 + } else { 235 + let c_str = std::ffi::CStr::from_ptr(raw); // CStr, not CString! 236 + c_str.to_string_lossy().to_string() 237 + } 175 238 } 176 239 } 177 240 ··· 294 357 .to_string() 295 358 }) 296 359 } 360 + 361 + #[cfg(test)] 362 + mod tests { 363 + use super::*; 364 + 365 + #[test] 366 + fn list() { 367 + let m = Manager::new("/run/media/geesawra/IPOD/".to_owned()).unwrap(); 368 + 369 + for track in m.tracks().unwrap() { 370 + println!("yolooo {}", track); 371 + } 372 + } 373 + }
+49 -45
src/gtkgui/gtkgui.rs
··· 245 245 .modal(true) 246 246 .build(); 247 247 248 - if let Ok(file) = file_dialog 249 - .select_folder_future(Some(&adw_window)) 250 - .await 248 + if let Ok(file) = file_dialog.select_folder_future(Some(&adw_window)).await 251 249 { 252 250 if let Some(path) = file.path() { 253 251 let path_str = path.to_string_lossy().to_string(); 254 252 let window_weak = adw_window.downgrade(); 255 - load_library( 256 - window_weak, 257 - store_clone, 258 - db_state_clone, 259 - path_str, 260 - ) 261 - .await; 253 + load_library(window_weak, store_clone, db_state_clone, path_str) 254 + .await; 262 255 } 263 256 } 264 257 } ··· 400 393 fn show_busy_dialog( 401 394 parent: &impl IsA<gtk::Window>, 402 395 title: &str, 403 - ) -> ( 404 - adw::Window, 405 - gtk::Label, 406 - gtk::Label, 407 - gtk::Label, 408 - gtk::Label, 409 - ) { 396 + ) -> (adw::Window, gtk::Label, gtk::Label, gtk::Label, gtk::Label) { 410 397 let dialog = adw::Window::builder() 411 398 .transient_for(parent) 412 399 .modal(true) ··· 466 453 467 454 dialog.set_content(Some(&box_)); 468 455 dialog.present(); 469 - ( 470 - dialog, 471 - count_label, 472 - title_label, 473 - artist_label, 474 - album_label, 475 - ) 456 + (dialog, count_label, title_label, artist_label, album_label) 476 457 } 477 458 478 459 async fn perform_sync( ··· 495 476 let elapsed_weak = glib::SendWeakRef::from(elapsed_lbl.downgrade()); 496 477 let eta_weak = glib::SendWeakRef::from(eta_lbl.downgrade()); 497 478 let close_btn_weak = glib::SendWeakRef::from(close_btn.downgrade()); 498 - 479 + 499 480 // Connect close button 500 481 let dialog_weak_btn = dialog.downgrade(); 501 482 close_btn.connect_clicked(move |_| { ··· 548 529 let ab = album_weak_update.clone(); 549 530 let el = elapsed_weak_update.clone(); 550 531 let et = eta_weak_update.clone(); 551 - 532 + 552 533 let elapsed = start_time.elapsed(); 553 534 let elapsed_secs = elapsed.as_secs(); 554 535 let elapsed_str = format!("Elapsed: {:02}:{:02}", elapsed_secs / 60, elapsed_secs % 60); 555 - 536 + 556 537 let eta_str = if current > 0 { 557 538 let avg_ms = elapsed.as_millis() as u64 / current as u64; 558 539 let remaining = (total - current) as u64; ··· 564 545 }; 565 546 566 547 glib::MainContext::default().invoke(move || { 567 - let frac = if total > 0 { current as f64 / total as f64 } else { 0.0 }; 548 + let frac = if total > 0 { 549 + current as f64 / total as f64 550 + } else { 551 + 0.0 552 + }; 568 553 if let Some(p) = pb.upgrade() { 569 554 p.set_fraction(frac); 570 555 } ··· 590 575 }; 591 576 592 577 println!("Perform Sync: Diffing..."); 593 - 578 + 594 579 // Debug source count 595 - if let Ok(tracks) = source_db.tracks_by_state(crate::model::FileState::Copied).await { 580 + if let Ok(tracks) = source_db 581 + .tracks_by_state(crate::model::FileState::Copied) 582 + .await 583 + { 596 584 println!("Source DB has {} copied tracks", tracks.len()); 597 585 } else { 598 586 println!("Failed to get source tracks count"); ··· 602 590 Ok(to_copy_ids) => { 603 591 match sync::diff_databases(&source_db, &dest_db, None, false).await { 604 592 Ok(to_delete_ids) => { 605 - println!("Perform Sync: Copy {}, Delete {}", to_copy_ids.len(), to_delete_ids.len()); 593 + println!( 594 + "Perform Sync: Copy {}, Delete {}", 595 + to_copy_ids.len(), 596 + to_delete_ids.len() 597 + ); 606 598 let total = to_copy_ids.len() + to_delete_ids.len(); 607 599 let mut current = 0; 608 600 let start_time = std::time::Instant::now(); ··· 646 638 }) 647 639 }; 648 640 649 - let ipod_manager = if ipod_mode { 641 + let mut ipod_manager = if ipod_mode { 650 642 crate::gpod::Manager::new(dest_dir.clone()).ok() 651 643 } else { 652 644 None 653 645 }; 654 646 655 647 for track in tracks { 656 - update_ui( 648 + update_ui( 657 649 current, 658 650 total, 659 651 start_time, ··· 664 656 ); 665 657 666 658 let dest_path = if let Some(ref ipm) = ipod_manager { 667 - let mut fp: std::path::PathBuf = track.file_path.clone().into(); 659 + let mut fp: std::path::PathBuf = 660 + track.file_path.clone().into(); 668 661 if track.is_lossless() { 669 662 fp.set_extension(crate::ffmpeg::Codec::AAC.extension()); 670 663 } ··· 673 666 track.storage_path(&dest_dir).into() 674 667 }; 675 668 676 - match sync::copy(&track, &dest_db, dest_path.clone(), tr_settings.clone()) 677 - .await 669 + match sync::copy( 670 + &track, 671 + &dest_db, 672 + dest_path.clone(), 673 + tr_settings.clone(), 674 + ) 675 + .await 678 676 { 679 677 Ok((copied_track, tr_res)) => { 680 - if let Some(ref ipm) = ipod_manager { 678 + if let Some(ref mut ipm) = ipod_manager { 681 679 let _ = ipm.finalize_track( 682 680 &copied_track, 683 681 dest_path, ··· 693 691 Err(e) => eprintln!("Error copying track: {}", e), 694 692 } 695 693 current += 1; 696 - // Yield 694 + // Yield 697 695 glib::timeout_future(std::time::Duration::from_millis(1)).await; 698 696 } 699 697 } ··· 706 704 } 707 705 Err(e) => eprintln!("Error diffing (copy): {}", e), 708 706 } 709 - 707 + 710 708 println!("Perform Sync: Done."); 711 709 is_running.set(false); 712 - 710 + 713 711 let eta_weak_complete = eta_weak.clone(); 714 - 712 + 715 713 // Finish: Enable close button 716 714 glib::MainContext::default().invoke(move || { 717 715 if let Some(btn) = close_btn_weak.upgrade() { ··· 806 804 .label("ETA: --:--") 807 805 .css_classes(["caption"]) 808 806 .build(); 809 - 807 + 810 808 time_box.append(&elapsed_label); 811 809 time_box.append(&gtk::Separator::new(gtk::Orientation::Vertical)); 812 810 time_box.append(&eta_label); ··· 1214 1212 1215 1213 // Capture parent weak ref to avoid reference cycle 1216 1214 // (Parent -> Dialog (transient) -> Button -> Closure -> Parent) 1217 - let parent_window_ref = parent.as_ref().downcast_ref::<gtk::Window>().expect("Parent must be a gtk::Window"); 1215 + let parent_window_ref = parent 1216 + .as_ref() 1217 + .downcast_ref::<gtk::Window>() 1218 + .expect("Parent must be a gtk::Window"); 1218 1219 let parent_weak = parent_window_ref.downgrade(); 1219 1220 1220 1221 start_button.connect_clicked(move |_| { ··· 1222 1223 let source_db = { source_db_state.borrow().clone() }; 1223 1224 let dest_db = { dest_db_state.borrow().clone() }; 1224 1225 let dialog_weak = dialog_weak.clone(); 1225 - let ipod_mode = ipod_row_weak.upgrade().map(|r| r.is_active()).unwrap_or(false); 1226 + let ipod_mode = ipod_row_weak 1227 + .upgrade() 1228 + .map(|r| r.is_active()) 1229 + .unwrap_or(false); 1226 1230 let dest_dir = dest_row_weak 1227 1231 .upgrade() 1228 1232 .map(|r| r.subtitle().unwrap().to_string()) 1229 1233 .unwrap_or_default(); 1230 - 1234 + 1231 1235 // Upgrade weak ref immediately to keep it alive during setup 1232 1236 let parent_strong = parent_weak.upgrade(); 1233 1237
+1
src/main.rs
··· 2 2 mod cli; 3 3 mod cmd; 4 4 mod db; 5 + mod device; 5 6 mod ffmpeg; 6 7 mod filter; 7 8 mod fs;
+1 -1
src/model.rs
··· 159 159 } 160 160 } 161 161 162 - fn track_hash(track: &Track) -> String { 162 + pub fn track_hash(track: &Track) -> String { 163 163 let mut sb = string_builder::Builder::default(); 164 164 165 165 sb.append(track.artist.clone());