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 library, make sync, update, add work

Gee Sawra f95b6a84 6ce59f9b

+1345 -511
+15 -152
src/cmd/add.rs
··· 1 - use std::collections::hash_set; 2 - 3 1 use crate::cmd::*; 4 2 use crate::*; 5 3 use anyhow::{Context, Result}; 6 4 use clap::Args as ClapArgs; 7 - use futures::{executor::block_on, future::try_join_all}; 8 - use model::FileState; 5 + use futures::future::try_join_all; 9 6 10 7 #[derive(ClapArgs, Debug)] 11 8 pub struct Args { ··· 54 51 .await 55 52 .with_context(|| "Cannot open local database instance")?; 56 53 57 - let sources = match update { 58 - false => args.sources.unwrap(), 59 - true => db 60 - .directories() 61 - .await 62 - .with_context(|| "Cannot fetch track directories from database")?, 63 - }; 64 - 65 - let mut tracks = vec![]; 66 - 67 - for source in &sources { 68 - for i in db 69 - .track_paths_from_dir(source.clone()) 70 - .await 71 - .with_context(|| "Cannot fetch track paths from directory")? 72 - { 73 - tracks.push(i) 74 - } 75 - } 76 - 77 - let tracks_set: hash_set::HashSet<String> = tracks.into_iter().collect(); 78 - 79 - let res = try_join_all( 80 - sources 81 - .into_iter() 82 - .map(|source| { 83 - traverse_and_add_param(&db, source, { 84 - let tracks_set = tracks_set.clone(); 85 - 86 - move |path, db| match update { 87 - false => add_dupe_checker(path, db), 88 - true => Ok(!tracks_set.contains(path)), 89 - } 90 - }) 91 - }) 92 - .collect::<Vec<_>>(), 93 - ) 94 - .await?; 54 + let totals = if update { 55 + crate::library::update(&db, |_| {}).await? 56 + } else { 57 + let sources = args.sources.unwrap(); 58 + let res = try_join_all( 59 + sources 60 + .into_iter() 61 + .map(|source| crate::library::scan_source(&db, source)) 62 + .collect::<Vec<_>>(), 63 + ) 64 + .await?; 95 65 96 - let totals = res.iter().fold((0, 0), |acc, r| (acc.0 + r.0, acc.1 + r.1)); 66 + res.iter().fold((0, 0), |acc, r| (acc.0 + r.0, acc.1 + r.1)) 67 + }; 97 68 98 69 match totals.1 { 99 70 0 => log::info!("Imported {} tracks", totals.0), ··· 107 78 }, 108 79 }; 109 80 110 - if update { 111 - // look for files in db that are not on the filesystem anymore 112 - let track_iter = db 113 - .tracks_iter() 114 - .await 115 - .with_context(|| "Cannot create an iterator for existing tracks in database")?; 116 - 117 - while let Ok(track) = track_iter.recv().await { 118 - let track = track?; 119 - 120 - let tp = std::path::Path::new(&track.file_path); 121 - 122 - match tp.exists() { 123 - true => {} 124 - false => { 125 - db.delete(track.id) 126 - .await 127 - .with_context(|| "Cannot delete track from database.")?; 128 - } 129 - } 130 - } 131 - } 132 - 133 81 Ok(()) 134 - } 135 - 136 - fn add_dupe_checker(path: &String, db: &db::Instance) -> Result<bool> { 137 - block_on(async { 138 - if db.exists(path.clone()).await? { 139 - return Ok(true); 140 - } 141 - 142 - return Ok(false); 143 - }) 144 - } 145 - 146 - pub(crate) async fn traverse_and_add_param<F>( 147 - db: &db::Instance, 148 - path: String, 149 - dupe_checker: F, 150 - ) -> Result<(u64, u64)> 151 - where 152 - F: FnOnce(&String, &db::Instance) -> Result<bool> + Clone, 153 - { 154 - log::info!("scanning {}", path); 155 - 156 - let paths = fs::traverse(&path).await; 157 - 158 - let mut new_tracks = 0; 159 - let mut duplicate = 0; 160 - 161 - while let Ok(p) = paths.recv().await { 162 - let p = p.context(format!("yolo"))?.clone(); 163 - 164 - let dc = dupe_checker.clone(); 165 - if dc(&p, db)? { 166 - duplicate += 1; 167 - continue; 168 - } 169 - 170 - let tags = match lofty::read_from_path(p.clone()) { 171 - Ok(t) => t, 172 - Err(e) => { 173 - log::warn!("could not read tags from {}: {}", p.clone(), e); 174 - continue; 175 - } 176 - }; 177 - 178 - let p_path = std::path::PathBuf::from(p.clone()); 179 - let p_path = p_path.parent().unwrap(); 180 - 181 - let mut track: model::Track = model::RawTrack { 182 - tags, 183 - path: p, 184 - artwork_path: find_cover_image(&p_path), 185 - } 186 - .try_into()?; 187 - track.file_state = FileState::Copied; 188 - 189 - log::info!("new track: {}", track); 190 - 191 - db.insert_track(&track) 192 - .await 193 - .with_context(|| format!("Cannot write track data to database"))?; 194 - 195 - new_tracks += 1; 196 - } 197 - 198 - db.insert_directory(path).await?; 199 - 200 - Ok((new_tracks, duplicate)) 201 - } 202 - 203 - pub fn find_cover_image(base_dir: &std::path::Path) -> Option<String> { 204 - const SEARCH_NAMES: &[&str] = &["Cover", "cover", "folder"]; 205 - const SEARCH_EXTENSIONS: &[&str] = &["png", "jpeg", "jpg"]; 206 - 207 - for &name in SEARCH_NAMES { 208 - for &ext in SEARCH_EXTENSIONS { 209 - let file_name = format!("{}.{}", name, ext); 210 - let full_path = base_dir.join(file_name); 211 - 212 - if full_path.is_file() { 213 - return Some(full_path.to_string_lossy().to_string()); 214 - } 215 - } 216 - } 217 - 218 - None 219 - } 82 + }
+10 -321
src/cmd/sync.rs
··· 1 1 use crate::cmd::*; 2 2 use crate::db; 3 - use crate::ffmpeg; 4 - use crate::ffmpeg::TranscodeResult; 5 - use crate::gpod; 6 - use crate::model; 3 + use crate::sync; 7 4 use anyhow::Ok; 8 5 use anyhow::anyhow; 9 6 use anyhow::{Context, Result}; 10 7 use clap::Args as ClapArgs; 11 - use fs_extra::file::{CopyOptions, copy_with_progress}; 12 - use futures::StreamExt; 13 - use futures::TryStreamExt; 14 - use futures::stream; 15 - use std::collections::hash_set; 16 - use std::path::PathBuf; 17 8 18 9 #[derive(ClapArgs)] 19 10 pub struct Args { ··· 102 93 }; 103 94 104 95 // find any filtered tracks that were already copied 105 - reverse_diff.append(&mut diff_databases(&local_db, &dest_db, filters.as_ref(), false).await?); 96 + reverse_diff.append( 97 + &mut sync::diff_databases(&local_db, &dest_db, filters.as_ref(), false).await?, 98 + ); 106 99 107 100 // now filter out all tracks to copy by using the filters 108 - let diff = filter_tracks_by_id(filters.as_ref(), &local_db, diff).await?; 101 + let diff = sync::filter_tracks_by_id(filters.as_ref(), &local_db, diff).await?; 109 102 110 103 if args.dry_run { 111 104 dry_run_copy(&local_db, &dest_dir, diff, filters.as_ref()).await?; ··· 114 107 } 115 108 116 109 if !args.no_delete { 117 - run_delete(&dest_db, &dest_dir, reverse_diff, filters.as_ref()).await? 110 + sync::run_delete(&dest_db, &dest_dir, reverse_diff, filters.as_ref()).await? 118 111 } 119 112 120 113 log::info!("using {} threads", threads); 121 114 122 - run_copy( 115 + sync::run_copy( 123 116 &local_db, 124 117 &dest_db, 125 118 &dest_dir, ··· 133 126 Ok(()) 134 127 } 135 128 136 - async fn diff_databases( 137 - source: &db::Instance, 138 - destination: &db::Instance, 139 - filters: Option<&Vec<crate::filter::ScriptRuntime>>, 140 - delete: bool, 141 - ) -> Result<Vec<String>> { 142 - let local_tracks = source.tracks_by_state(model::FileState::Copied).await?; 143 - 144 - let local_tracks = filter_tracks(local_tracks, filters, delete)?; 145 - 146 - let dest_tracks = destination 147 - .tracks_by_state(model::FileState::Copied) 148 - .await?; 149 - 150 - let src_ids: hash_set::HashSet<String> = local_tracks 151 - .clone() 152 - .into_iter() 153 - .map(|t| t.track_id) 154 - .collect(); 155 - 156 - let dst_ids: hash_set::HashSet<String> = dest_tracks 157 - .clone() 158 - .into_iter() 159 - .map(|t| t.track_id) 160 - .collect(); 161 - 162 - log::debug!("local {} dest {}", src_ids.len(), dst_ids.len()); 163 - 164 - let d: hash_set::HashSet<&String> = dst_ids.difference(&src_ids).collect(); 165 - 166 - Ok(d.into_iter().map(|e| e.clone()).collect()) 167 - } 168 - 169 - async fn filter_tracks_by_id( 170 - filters: Option<&Vec<crate::filter::ScriptRuntime>>, 171 - db: &db::Instance, 172 - ids: Vec<String>, 173 - ) -> Result<Vec<String>> { 174 - let tracks = db.tracks_by_id(ids).await?; 175 - 176 - let tracks = filter_tracks(tracks, filters, false)?; 177 - 178 - Ok(tracks.into_iter().map(|t| t.track_id).collect()) 179 - } 180 - 181 - fn filter_tracks( 182 - raw_tracks: Vec<model::Track>, 183 - filters: Option<&Vec<crate::filter::ScriptRuntime>>, 184 - delete: bool, 185 - ) -> Result<Vec<model::Track>> { 186 - if let Some(filters) = filters { 187 - let mut filter_res = vec![]; 188 - 189 - for f in filters { 190 - let base_tracks = raw_tracks 191 - .clone() 192 - .into_iter() 193 - .map(|t| Into::<model::BaseTrack>::into(t)) 194 - .collect(); 195 - 196 - filter_res = f.run(base_tracks)?; 197 - } 198 - 199 - return Ok(raw_tracks 200 - .into_iter() 201 - .enumerate() 202 - .filter_map(|elem| { 203 - let (idx, track) = elem; 204 - 205 - if filter_res[idx] && !delete { 206 - return None; 207 - } 208 - 209 - return Some(track); 210 - }) 211 - .collect()); 212 - } 213 - 214 - Ok(raw_tracks) 215 - } 216 - 217 - async fn run_copy( 218 - local_db: &db::Instance, 219 - dest_db: &db::Instance, 220 - dest_dir: &String, 221 - diff: Vec<String>, 222 - filters: Option<&Vec<crate::filter::ScriptRuntime>>, 223 - threads: usize, 224 - ipod: bool, 225 - ) -> Result<()> { 226 - let tr_settings = dest_db.get_transcoding_settings().await?; 227 - 228 - let tracks = filter_tracks( 229 - local_db 230 - .tracks_by_id(diff) 231 - .await 232 - .with_context(|| "Cannot get tracks from local database")?, 233 - filters, 234 - false, 235 - )?; 236 - 237 - if ipod { 238 - return copy_ipod(dest_db, dest_dir, threads, tracks).await; 239 - } 240 - 241 - stream::iter(tracks) 242 - .map(|track| { 243 - let trs = tr_settings.clone(); 244 - let dest_path: PathBuf = track.storage_path(dest_dir).into(); 245 - async move { copy(&track, dest_db, dest_path, trs).await } 246 - }) 247 - .buffer_unordered(threads) 248 - .try_for_each(|_| async move { Ok(()) }) 249 - .await 250 - } 251 - 252 - async fn copy_ipod( 253 - dest_db: &db::Instance, 254 - dest_dir: &String, 255 - threads: usize, 256 - tracks: Vec<model::Track>, 257 - ) -> Result<()> { 258 - let ipod_manager = gpod::Manager::new(dest_dir.clone())?; 259 - 260 - let transcoding = Some(ffmpeg::Quality { 261 - bitrate: ffmpeg::Bitrate::VBR("5".to_owned()), 262 - sampling_rate: Some(ffmpeg::SamplingRate::Rate441K), 263 - codec: ffmpeg::Codec::AAC, 264 - }); 265 - 266 - let converted: Vec<_> = stream::iter(tracks) 267 - .map(|track| { 268 - let trs = transcoding.clone(); 269 - let ipm = ipod_manager.clone(); 270 - 271 - async move { 272 - let mut fp: PathBuf = track.file_path.clone().into(); 273 - match track.is_lossless() { 274 - true => { 275 - fp.set_extension(ffmpeg::Codec::AAC.extension()); 276 - } 277 - false => (), 278 - } 279 - let dest_path = ipm.path_for_file(fp.as_path()).unwrap(); 280 - let (copied_track, tr) = copy(&track, dest_db, dest_path.clone(), trs).await?; 281 - 282 - Ok((copied_track, dest_path, tr.unwrap())) 283 - } 284 - }) 285 - .buffer_unordered(threads) 286 - .try_collect() 287 - .await?; 288 - 289 - log::info!("finalizing..."); 290 - for (track, path, trans_res) in converted { 291 - ipod_manager.finalize_track( 292 - &track.clone(), 293 - path.clone(), 294 - track.extension, 295 - 44100, 296 - 256, 297 - trans_res.replaygain.map(|r| r.soundcheck_value()), 298 - )?; 299 - } 300 - 301 - return Ok(()); 302 - } 303 - 304 129 async fn dry_run_copy( 305 130 local_db: &db::Instance, 306 131 dest_dir: &String, 307 132 diff: Vec<String>, 308 133 filters: Option<&Vec<crate::filter::ScriptRuntime>>, 309 134 ) -> Result<()> { 310 - let tracks = filter_tracks( 135 + let tracks = sync::filter_tracks( 311 136 local_db 312 137 .tracks_by_id(diff) 313 138 .await ··· 331 156 diff: Vec<String>, 332 157 filters: Option<&Vec<crate::filter::ScriptRuntime>>, 333 158 ) -> Result<()> { 334 - let tracks = filter_tracks( 159 + let tracks = sync::filter_tracks( 335 160 dest_db 336 161 .tracks_by_id(diff) 337 162 .await ··· 347 172 } 348 173 349 174 Ok(()) 350 - } 351 - 352 - async fn run_delete( 353 - dest_db: &db::Instance, 354 - dest_dir: &String, 355 - diff: Vec<String>, 356 - filters: Option<&Vec<crate::filter::ScriptRuntime>>, 357 - ) -> Result<()> { 358 - let diff_len = diff.len(); 359 - 360 - if diff_len == 0 { 361 - return Ok(()); 362 - } 363 - 364 - let tracks = filter_tracks( 365 - dest_db 366 - .tracks_by_id(diff) 367 - .await 368 - .with_context(|| "Cannot get tracks from destination database")?, 369 - filters, 370 - true, 371 - )?; 372 - 373 - for track in tracks { 374 - log::info!("deleting {}", track); 375 - delete(track, &dest_db, &dest_dir).await?; 376 - } 377 - 378 - Ok(()) 379 - } 380 - 381 - async fn delete(track: model::Track, dest_db: &db::Instance, dest_dir: &String) -> Result<()> { 382 - let track_storage_path = track.storage_path(&dest_dir); 383 - 384 - dest_db.delete(track.id).await?; 385 - std::fs::remove_file(track_storage_path.clone()) 386 - .with_context(|| format!("Cannot delete file {}", track_storage_path.clone()))?; 387 - Ok(()) 388 - } 389 - 390 - async fn copy( 391 - track: &model::Track, 392 - dest_db: &db::Instance, 393 - dest_fname: PathBuf, 394 - transcoding_settings: Option<ffmpeg::Quality>, 395 - ) -> Result<(model::Track, Option<TranscodeResult>)> { 396 - // We replicate src database on dest 1:1 397 - // We should create the same logical database (tracks with the same tags have the same hash) 398 - // ignoring the format. 399 - // When the copy is done we should return the newly-created track. 400 - 401 - let is_lossless = track.is_lossless(); 402 - 403 - let sp = std::path::Path::new(&dest_fname); 404 - 405 - let parent = sp 406 - .parent() 407 - .with_context(|| "Cannot obtain base destination directory")?; 408 - 409 - let mut dest_track = track.clone(); 410 - dest_track.file_state = crate::model::FileState::Copying; 411 - dest_db 412 - .insert_track(&dest_track) 413 - .await 414 - .with_context(|| "Cannot insert in-progress copying track in destination database")?; 415 - 416 - std::fs::create_dir_all(parent).with_context(|| { 417 - format!( 418 - "Cannot create destination directory tree {}", 419 - parent.to_str().unwrap() 420 - ) 421 - })?; 422 - 423 - let track_dest_path = if let Some(ref ts) = transcoding_settings 424 - && is_lossless 425 - { 426 - let mut dest_fname = dest_fname.clone(); 427 - dest_fname.set_extension(ts.codec.extension()); 428 - dest_fname 429 - } else { 430 - dest_fname 431 - }; 432 - 433 - let opts = CopyOptions::new().overwrite(true); 434 - 435 - let maybe_transcode_result = async_std::task::spawn_blocking({ 436 - let track_for_thread = track.clone(); 437 - let ts_for_thread = transcoding_settings.clone(); 438 - 439 - move || { 440 - if let Some(ts) = ts_for_thread 441 - && is_lossless 442 - { 443 - log::info!("transcoding & copying {} ({})", track_for_thread, ts); 444 - log::debug!("transcoding path: {}", track_dest_path.display()); 445 - 446 - return Ok(Some(ffmpeg::convert( 447 - &track_for_thread, 448 - track_dest_path, 449 - &ts, 450 - )?)); 451 - } 452 - 453 - log::info!("copying {}", track_for_thread); 454 - log::debug!("YOOO {}", track_dest_path.display()); 455 - 456 - match copy_with_progress( 457 - track_for_thread.file_path.clone(), 458 - track_dest_path.clone(), 459 - &opts, 460 - |_| {}, 461 - ) { 462 - std::result::Result::Ok(_) => {} 463 - Err(err) => { 464 - return Err(error::Error::CopyError(err)).with_context(|| { 465 - format!( 466 - "Cannot copy {} to {}", 467 - track_for_thread.file_path, 468 - track_dest_path.display() 469 - ) 470 - }); 471 - } 472 - }; 473 - 474 - Ok(None) 475 - } 476 - }) 477 - .await?; 478 - 479 - dest_track.file_state = crate::model::FileState::Copied; 480 - dest_db 481 - .insert_track(&dest_track) 482 - .await 483 - .with_context(|| "Cannot insert copy finished track in destination database")?; 484 - 485 - Ok((track.clone(), maybe_transcode_result)) 486 - } 175 + }
+834 -38
src/gtkgui/gtkgui.rs
··· 14 14 use crate::db::{self, Instance}; 15 15 use crate::ffmpeg::{Codec, SamplingRate}; 16 16 use crate::model::Track; 17 + use crate::sync; 17 18 18 19 pub async fn run() -> Result<()> { 19 20 let app = adw::Application::builder() ··· 33 34 34 35 let db_state = Rc::new(RefCell::new(None)); 35 36 36 - // Setup Actions 37 - setup_actions(app, db_state.clone()); 38 - 39 37 let window = adw::ApplicationWindow::builder() 40 38 .application(app) 41 39 .title("Tunz Library") ··· 44 42 .build(); 45 43 46 44 let store = gtk::gio::ListStore::new::<BoxedAnyObject>(); 45 + 46 + // Setup Actions 47 + setup_actions(app, db_state.clone(), store.clone()); 47 48 48 49 // Initially Create SortListModel without a sorter 49 50 let sort_model = SortListModel::new(Some(store.clone()), None::<gtk::Sorter>); ··· 106 107 107 108 // Menu Setup 108 109 let menu = gio::Menu::new(); 110 + menu.append(Some("Open Library"), Some("app.open")); 109 111 menu.append(Some("Sources"), Some("app.sources")); 110 112 menu.append(Some("Update"), Some("app.update")); 111 113 menu.append(Some("Sync"), Some("app.sync")); ··· 128 130 window.present(); 129 131 130 132 let window_weak = window.downgrade(); 133 + // Cast to gtk::Window weak ref for generic usage if needed, or just use as is. 134 + // Downgrading ApplicationWindow yields WeakRef<ApplicationWindow>. 135 + // load_library expects generic WeakRef or similar. 136 + // Let's make load_library take a WeakRef<ApplicationWindow> or handle it. 137 + 131 138 glib::MainContext::default().spawn_local(async move { 132 139 let db_dir = db::default_database_dir(); 133 140 let db_dir_str = db_dir.to_str().expect("Invalid path"); 141 + // Cast window_weak to gtk::Window weak ref? 142 + // Actually, we can just pass the generic weak ref if we type it right, or just pass it. 143 + // Let's implement load_library to take generic IsA<Window> weak ref? 144 + // WeakRef doesn't implement IsA. 145 + // We can cast the weak ref. 146 + let window_weak_cast = window_weak.clone(); 134 147 135 - match Instance::new(db_dir_str, false).await { 136 - Ok(instance) => { 137 - *db_state.borrow_mut() = Some(instance.clone()); 148 + load_library(window_weak_cast, store, db_state, db_dir_str.to_string()).await; 149 + }); 150 + } 151 + 152 + async fn load_library( 153 + window_weak: glib::WeakRef<adw::ApplicationWindow>, 154 + store: gio::ListStore, 155 + db_state: Rc<RefCell<Option<Instance>>>, 156 + path: String, 157 + ) { 158 + // Clear existing 159 + store.remove_all(); 160 + 161 + match Instance::new(&path, false).await { 162 + Ok(instance) => { 163 + *db_state.borrow_mut() = Some(instance.clone()); 164 + 165 + if let Some(window) = window_weak.upgrade() { 166 + window.set_title(Some(&format!("Tunz Library - {}", path))); 167 + } 138 168 139 - match instance.tracks_iter().await { 140 - Ok(rx) => { 141 - let mut buffer = Vec::new(); 142 - let batch_size = 1000; 169 + if let Err(e) = populate_store(&store, &instance).await { 170 + if let Some(window) = window_weak.upgrade() { 171 + show_error_dialog(&window, &format!("Failed to fetch tracks: {}", e)); 172 + } 173 + } 143 174 144 - while let Ok(res_track) = rx.recv().await { 145 - if let Ok(track) = res_track { 146 - buffer.push(BoxedAnyObject::new(track)); 147 - if buffer.len() >= batch_size { 148 - store.splice(store.n_items(), 0, &buffer); 149 - buffer.clear(); 150 - } 151 - } 152 - } 153 - if !buffer.is_empty() { 154 - store.splice(store.n_items(), 0, &buffer); 155 - } 156 - } 157 - Err(e) => { 175 + match instance.directories().await { 176 + Ok(dirs) => { 177 + if dirs.is_empty() { 158 178 if let Some(window) = window_weak.upgrade() { 159 - show_error_dialog(&window, &format!("Failed to fetch tracks: {}", e)); 179 + show_sources_dialog(&window, db_state, store); 160 180 } 161 181 } 162 182 } 183 + Err(e) => eprintln!("Failed to check directories: {}", e), 163 184 } 164 - Err(e) => { 165 - if let Some(window) = window_weak.upgrade() { 166 - show_error_dialog(&window, &format!("Failed to open database: {}", e)); 167 - } 185 + } 186 + Err(e) => { 187 + if let Some(window) = window_weak.upgrade() { 188 + show_error_dialog(&window, &format!("Failed to open database: {}", e)); 168 189 } 169 190 } 170 - }); 191 + } 171 192 } 172 193 173 - fn setup_actions(app: &adw::Application, db_state: Rc<RefCell<Option<Instance>>>) { 194 + async fn populate_store(store: &gio::ListStore, instance: &Instance) -> Result<()> { 195 + store.remove_all(); 196 + let rx = instance.tracks_iter().await?; 197 + let mut buffer = Vec::new(); 198 + let batch_size = 1000; 199 + 200 + while let Ok(res_track) = rx.recv().await { 201 + if let Ok(track) = res_track { 202 + buffer.push(BoxedAnyObject::new(track)); 203 + if buffer.len() >= batch_size { 204 + store.splice(store.n_items(), 0, &buffer); 205 + buffer.clear(); 206 + } 207 + } 208 + } 209 + if !buffer.is_empty() { 210 + store.splice(store.n_items(), 0, &buffer); 211 + } 212 + Ok(()) 213 + } 214 + 215 + fn setup_actions( 216 + app: &adw::Application, 217 + db_state: Rc<RefCell<Option<Instance>>>, 218 + store: gio::ListStore, 219 + ) { 174 220 let action_quit = gio::SimpleAction::new("quit", None); 175 221 let app_weak = app.downgrade(); 176 222 action_quit.connect_activate(move |_, _| { ··· 180 226 }); 181 227 app.add_action(&action_quit); 182 228 229 + let action_open = gio::SimpleAction::new("open", None); 230 + let app_weak = app.downgrade(); 231 + let db_state_clone = db_state.clone(); 232 + let store_clone = store.clone(); 233 + action_open.connect_activate(move |_, _| { 234 + let app_weak = app_weak.clone(); 235 + let db_state_clone = db_state_clone.clone(); 236 + let store_clone = store_clone.clone(); 237 + glib::MainContext::default().spawn_local(async move { 238 + if let Some(app) = app_weak.upgrade() { 239 + if let Some(window) = app.active_window() { 240 + // We need to cast window to adw::ApplicationWindow because load_library expects it 241 + // The window created in build_ui is adw::ApplicationWindow. 242 + if let Ok(adw_window) = window.downcast::<adw::ApplicationWindow>() { 243 + let file_dialog = FileDialog::builder() 244 + .title("Open Library Directory") 245 + .modal(true) 246 + .build(); 247 + 248 + if let Ok(file) = file_dialog 249 + .select_folder_future(Some(&adw_window)) 250 + .await 251 + { 252 + if let Some(path) = file.path() { 253 + let path_str = path.to_string_lossy().to_string(); 254 + 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; 262 + } 263 + } 264 + } 265 + } 266 + } 267 + }); 268 + }); 269 + app.add_action(&action_open); 270 + 183 271 let action_sources = gio::SimpleAction::new("sources", None); 184 272 let app_weak = app.downgrade(); 185 273 let db_state_clone = db_state.clone(); 274 + let store_clone = store.clone(); 186 275 action_sources.connect_activate(move |_, _| { 187 276 if let Some(app) = app_weak.upgrade() { 188 277 if let Some(window) = app.active_window() { 189 - show_sources_dialog(&window, db_state_clone.clone()); 278 + show_sources_dialog(&window, db_state_clone.clone(), store_clone.clone()); 190 279 } 191 280 } 192 281 }); 193 282 app.add_action(&action_sources); 194 283 195 284 let action_update = gio::SimpleAction::new("update", None); 196 - action_update.connect_activate(|_, _| { 197 - println!("Update clicked"); 285 + let app_weak = app.downgrade(); 286 + let db_state_clone = db_state.clone(); 287 + let store_clone = store.clone(); 288 + action_update.connect_activate(move |_, _| { 289 + let app_weak = app_weak.clone(); 290 + let db_state_clone = db_state_clone.clone(); 291 + let store_clone = store_clone.clone(); 292 + glib::MainContext::default().spawn_local(async move { 293 + let (busy_win, count_weak, title_weak, artist_weak, album_weak) = 294 + if let Some(app) = app_weak.upgrade() { 295 + if let Some(window) = app.active_window() { 296 + let (win, c, t, a, al) = show_busy_dialog(&window, "Updating Library"); 297 + ( 298 + Some(win), 299 + Some(c.downgrade()), 300 + Some(t.downgrade()), 301 + Some(a.downgrade()), 302 + Some(al.downgrade()), 303 + ) 304 + } else { 305 + (None, None, None, None, None) 306 + } 307 + } else { 308 + (None, None, None, None, None) 309 + }; 310 + 311 + let instance = { db_state_clone.borrow().clone() }; 312 + if let Some(instance) = instance { 313 + let count = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); 314 + let count_clone = count.clone(); 315 + 316 + let count_weak_send = count_weak.map(|w| glib::SendWeakRef::from(w)); 317 + let title_weak_send = title_weak.map(|w| glib::SendWeakRef::from(w)); 318 + let artist_weak_send = artist_weak.map(|w| glib::SendWeakRef::from(w)); 319 + let album_weak_send = album_weak.map(|w| glib::SendWeakRef::from(w)); 320 + 321 + let cb = move |track: &crate::model::Track| { 322 + if let (Some(wc), Some(wt), Some(wa), Some(wal)) = ( 323 + count_weak_send.as_ref(), 324 + title_weak_send.as_ref(), 325 + artist_weak_send.as_ref(), 326 + album_weak_send.as_ref(), 327 + ) { 328 + let current = 329 + count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; 330 + let msg_count = format!("Tracks found: {}", current); 331 + let msg_title = track.title.clone(); 332 + let msg_artist = track.artist.clone(); 333 + let msg_album = track.album.clone(); 334 + 335 + let wc = wc.clone(); 336 + let wt = wt.clone(); 337 + let wa = wa.clone(); 338 + let wal = wal.clone(); 339 + 340 + glib::MainContext::default().invoke(move || { 341 + if let Some(lbl) = wc.upgrade() { 342 + lbl.set_label(&msg_count); 343 + } 344 + if let Some(lbl) = wt.upgrade() { 345 + lbl.set_label(&msg_title); 346 + } 347 + if let Some(lbl) = wa.upgrade() { 348 + lbl.set_label(&msg_artist); 349 + } 350 + if let Some(lbl) = wal.upgrade() { 351 + lbl.set_label(&msg_album); 352 + } 353 + }); 354 + } 355 + }; 356 + 357 + match crate::library::update(&instance, cb).await { 358 + Ok((added, _)) => { 359 + println!("Update finished. Added {} tracks.", added); 360 + if let Err(e) = populate_store(&store_clone, &instance).await { 361 + if let Some(app) = app_weak.upgrade() { 362 + if let Some(window) = app.active_window() { 363 + show_error_dialog( 364 + &window, 365 + &format!("Failed to refresh view: {}", e), 366 + ); 367 + } 368 + } 369 + } 370 + } 371 + Err(e) => { 372 + if let Some(app) = app_weak.upgrade() { 373 + if let Some(window) = app.active_window() { 374 + show_error_dialog(&window, &format!("Update failed: {}", e)); 375 + } 376 + } 377 + } 378 + } 379 + } 380 + if let Some(busy) = busy_win { 381 + busy.close(); 382 + } 383 + }); 198 384 }); 199 385 app.add_action(&action_update); 200 386 ··· 211 397 app.add_action(&action_sync); 212 398 } 213 399 214 - fn show_sources_dialog(parent: &impl IsA<gtk::Window>, db_state: Rc<RefCell<Option<Instance>>>) { 400 + fn show_busy_dialog( 401 + parent: &impl IsA<gtk::Window>, 402 + title: &str, 403 + ) -> ( 404 + adw::Window, 405 + gtk::Label, 406 + gtk::Label, 407 + gtk::Label, 408 + gtk::Label, 409 + ) { 410 + let dialog = adw::Window::builder() 411 + .transient_for(parent) 412 + .modal(true) 413 + .title(title) 414 + .default_width(400) 415 + .default_height(250) 416 + .build(); 417 + 418 + let box_ = gtk::Box::builder() 419 + .orientation(gtk::Orientation::Vertical) 420 + .spacing(12) 421 + .margin_top(24) 422 + .margin_bottom(24) 423 + .margin_start(24) 424 + .margin_end(24) 425 + .valign(gtk::Align::Center) 426 + .build(); 427 + 428 + let spinner = gtk::Spinner::builder() 429 + .spinning(true) 430 + .halign(gtk::Align::Center) 431 + .valign(gtk::Align::Center) 432 + .width_request(48) 433 + .height_request(48) 434 + .margin_bottom(12) 435 + .build(); 436 + 437 + let count_label = gtk::Label::builder() 438 + .label("Tracks found: 0") 439 + .css_classes(["title-3"]) 440 + .build(); 441 + 442 + let title_label = gtk::Label::builder() 443 + .label("-") 444 + .ellipsize(gtk::pango::EllipsizeMode::End) 445 + .max_width_chars(40) 446 + .build(); 447 + let artist_label = gtk::Label::builder() 448 + .label("-") 449 + .ellipsize(gtk::pango::EllipsizeMode::End) 450 + .max_width_chars(40) 451 + .css_classes(["dim-label"]) 452 + .build(); 453 + let album_label = gtk::Label::builder() 454 + .label("-") 455 + .ellipsize(gtk::pango::EllipsizeMode::End) 456 + .max_width_chars(40) 457 + .css_classes(["dim-label"]) 458 + .build(); 459 + 460 + box_.append(&spinner); 461 + box_.append(&count_label); 462 + box_.append(&gtk::Separator::new(gtk::Orientation::Horizontal)); 463 + box_.append(&title_label); 464 + box_.append(&artist_label); 465 + box_.append(&album_label); 466 + 467 + dialog.set_content(Some(&box_)); 468 + dialog.present(); 469 + ( 470 + dialog, 471 + count_label, 472 + title_label, 473 + artist_label, 474 + album_label, 475 + ) 476 + } 477 + 478 + async fn perform_sync( 479 + parent: impl IsA<gtk::Window>, 480 + source_db: Instance, 481 + dest_db: Instance, 482 + dest_dir: String, 483 + ipod_mode: bool, 484 + ) { 485 + println!("Perform Sync: Showing dialog"); 486 + let (dialog, pb, status, title_lbl, artist_lbl, album_lbl, elapsed_lbl, eta_lbl, close_btn) = 487 + show_sync_progress_dialog(&parent, "Syncing..."); 488 + 489 + // UI Weak refs for async 490 + let pb_weak = glib::SendWeakRef::from(pb.downgrade()); 491 + let status_weak = glib::SendWeakRef::from(status.downgrade()); 492 + let title_weak = glib::SendWeakRef::from(title_lbl.downgrade()); 493 + let artist_weak = glib::SendWeakRef::from(artist_lbl.downgrade()); 494 + let album_weak = glib::SendWeakRef::from(album_lbl.downgrade()); 495 + let elapsed_weak = glib::SendWeakRef::from(elapsed_lbl.downgrade()); 496 + let eta_weak = glib::SendWeakRef::from(eta_lbl.downgrade()); 497 + let close_btn_weak = glib::SendWeakRef::from(close_btn.downgrade()); 498 + 499 + // Connect close button 500 + let dialog_weak_btn = dialog.downgrade(); 501 + close_btn.connect_clicked(move |_| { 502 + if let Some(d) = dialog_weak_btn.upgrade() { 503 + d.close(); 504 + } 505 + }); 506 + 507 + // Timer for elapsed time 508 + let start_time = std::time::Instant::now(); 509 + let elapsed_weak_timer = elapsed_weak.clone(); 510 + let is_running = Rc::new(std::cell::Cell::new(true)); 511 + let is_running_clone = is_running.clone(); 512 + 513 + glib::timeout_add_local(std::time::Duration::from_millis(500), move || { 514 + if !is_running_clone.get() { 515 + return glib::ControlFlow::Break; 516 + } 517 + if let Some(lbl) = elapsed_weak_timer.upgrade() { 518 + let elapsed = start_time.elapsed(); 519 + let elapsed_secs = elapsed.as_secs(); 520 + let formatted = format!("Elapsed: {:02}:{:02}", elapsed_secs / 60, elapsed_secs % 60); 521 + lbl.set_label(&formatted); 522 + glib::ControlFlow::Continue 523 + } else { 524 + glib::ControlFlow::Break 525 + } 526 + }); 527 + 528 + // Update helper 529 + let pb_weak_update = pb_weak.clone(); 530 + let status_weak_update = status_weak.clone(); 531 + let title_weak_update = title_weak.clone(); 532 + let artist_weak_update = artist_weak.clone(); 533 + let album_weak_update = album_weak.clone(); 534 + let elapsed_weak_update = elapsed_weak.clone(); 535 + let eta_weak_update = eta_weak.clone(); 536 + 537 + let update_ui = move |current: usize, 538 + total: usize, 539 + start_time: std::time::Instant, 540 + stat: String, 541 + t: String, 542 + a: String, 543 + al: String| { 544 + let pb = pb_weak_update.clone(); 545 + let st = status_weak_update.clone(); 546 + let ti = title_weak_update.clone(); 547 + let ar = artist_weak_update.clone(); 548 + let ab = album_weak_update.clone(); 549 + let el = elapsed_weak_update.clone(); 550 + let et = eta_weak_update.clone(); 551 + 552 + let elapsed = start_time.elapsed(); 553 + let elapsed_secs = elapsed.as_secs(); 554 + let elapsed_str = format!("Elapsed: {:02}:{:02}", elapsed_secs / 60, elapsed_secs % 60); 555 + 556 + let eta_str = if current > 0 { 557 + let avg_ms = elapsed.as_millis() as u64 / current as u64; 558 + let remaining = (total - current) as u64; 559 + let eta_ms = avg_ms * remaining; 560 + let eta_secs = eta_ms / 1000; 561 + format!("ETA: {:02}:{:02}", eta_secs / 60, eta_secs % 60) 562 + } else { 563 + "ETA: --:--".to_string() 564 + }; 565 + 566 + glib::MainContext::default().invoke(move || { 567 + let frac = if total > 0 { current as f64 / total as f64 } else { 0.0 }; 568 + if let Some(p) = pb.upgrade() { 569 + p.set_fraction(frac); 570 + } 571 + if let Some(l) = st.upgrade() { 572 + l.set_label(&stat); 573 + } 574 + if let Some(l) = ti.upgrade() { 575 + l.set_label(&t); 576 + } 577 + if let Some(l) = ar.upgrade() { 578 + l.set_label(&a); 579 + } 580 + if let Some(l) = ab.upgrade() { 581 + l.set_label(&al); 582 + } 583 + if let Some(l) = el.upgrade() { 584 + l.set_label(&elapsed_str); 585 + } 586 + if let Some(l) = et.upgrade() { 587 + l.set_label(&eta_str); 588 + } 589 + }); 590 + }; 591 + 592 + println!("Perform Sync: Diffing..."); 593 + 594 + // Debug source count 595 + if let Ok(tracks) = source_db.tracks_by_state(crate::model::FileState::Copied).await { 596 + println!("Source DB has {} copied tracks", tracks.len()); 597 + } else { 598 + println!("Failed to get source tracks count"); 599 + } 600 + 601 + match sync::diff_databases(&dest_db, &source_db, None, false).await { 602 + Ok(to_copy_ids) => { 603 + match sync::diff_databases(&source_db, &dest_db, None, false).await { 604 + Ok(to_delete_ids) => { 605 + println!("Perform Sync: Copy {}, Delete {}", to_copy_ids.len(), to_delete_ids.len()); 606 + let total = to_copy_ids.len() + to_delete_ids.len(); 607 + let mut current = 0; 608 + let start_time = std::time::Instant::now(); 609 + 610 + // Deletion 611 + match dest_db.tracks_by_id(to_delete_ids).await { 612 + Ok(tracks) => { 613 + if let Ok(tracks) = sync::filter_tracks(tracks, None, true) { 614 + for track in tracks { 615 + update_ui( 616 + current, 617 + total, 618 + start_time, 619 + "Deleting...".to_string(), 620 + track.title.clone(), 621 + track.artist.clone(), 622 + track.album.clone(), 623 + ); 624 + 625 + let _ = sync::delete(track, &dest_db, &dest_dir).await; 626 + current += 1; 627 + // Yield 628 + glib::timeout_future(std::time::Duration::from_millis(1)).await; 629 + } 630 + } 631 + } 632 + Err(e) => eprintln!("Error fetching delete tracks: {}", e), 633 + } 634 + 635 + // Copying 636 + match source_db.tracks_by_id(to_copy_ids).await { 637 + Ok(tracks) => { 638 + if let Ok(tracks) = sync::filter_tracks(tracks, None, false) { 639 + let tr_settings = if !ipod_mode { 640 + dest_db.get_transcoding_settings().await.ok().flatten() 641 + } else { 642 + Some(crate::ffmpeg::Quality { 643 + bitrate: crate::ffmpeg::Bitrate::VBR("5".to_owned()), 644 + sampling_rate: Some(crate::ffmpeg::SamplingRate::Rate441K), 645 + codec: crate::ffmpeg::Codec::AAC, 646 + }) 647 + }; 648 + 649 + let ipod_manager = if ipod_mode { 650 + crate::gpod::Manager::new(dest_dir.clone()).ok() 651 + } else { 652 + None 653 + }; 654 + 655 + for track in tracks { 656 + update_ui( 657 + current, 658 + total, 659 + start_time, 660 + "Copying...".to_string(), 661 + track.title.clone(), 662 + track.artist.clone(), 663 + track.album.clone(), 664 + ); 665 + 666 + let dest_path = if let Some(ref ipm) = ipod_manager { 667 + let mut fp: std::path::PathBuf = track.file_path.clone().into(); 668 + if track.is_lossless() { 669 + fp.set_extension(crate::ffmpeg::Codec::AAC.extension()); 670 + } 671 + ipm.path_for_file(fp.as_path()).unwrap() 672 + } else { 673 + track.storage_path(&dest_dir).into() 674 + }; 675 + 676 + match sync::copy(&track, &dest_db, dest_path.clone(), tr_settings.clone()) 677 + .await 678 + { 679 + Ok((copied_track, tr_res)) => { 680 + if let Some(ref ipm) = ipod_manager { 681 + let _ = ipm.finalize_track( 682 + &copied_track, 683 + dest_path, 684 + copied_track.extension.clone(), 685 + 44100, 686 + 256, 687 + tr_res 688 + .and_then(|r| r.replaygain) 689 + .map(|r| r.soundcheck_value()), 690 + ); 691 + } 692 + } 693 + Err(e) => eprintln!("Error copying track: {}", e), 694 + } 695 + current += 1; 696 + // Yield 697 + glib::timeout_future(std::time::Duration::from_millis(1)).await; 698 + } 699 + } 700 + } 701 + Err(e) => eprintln!("Error fetching copy tracks: {}", e), 702 + } 703 + } 704 + Err(e) => eprintln!("Error diffing (delete): {}", e), 705 + } 706 + } 707 + Err(e) => eprintln!("Error diffing (copy): {}", e), 708 + } 709 + 710 + println!("Perform Sync: Done."); 711 + is_running.set(false); 712 + 713 + let eta_weak_complete = eta_weak.clone(); 714 + 715 + // Finish: Enable close button 716 + glib::MainContext::default().invoke(move || { 717 + if let Some(btn) = close_btn_weak.upgrade() { 718 + btn.set_sensitive(true); 719 + btn.grab_focus(); 720 + } 721 + if let Some(l) = status_weak.upgrade() { 722 + l.set_label("Finished"); 723 + } 724 + if let Some(p) = pb_weak.upgrade() { 725 + p.set_fraction(1.0); 726 + } 727 + if let Some(l) = eta_weak_complete.upgrade() { 728 + l.set_visible(false); 729 + } 730 + }); 731 + } 732 + 733 + fn show_sync_progress_dialog( 734 + parent: &impl IsA<gtk::Window>, 735 + title: &str, 736 + ) -> ( 737 + adw::Window, 738 + gtk::ProgressBar, 739 + gtk::Label, 740 + gtk::Label, 741 + gtk::Label, 742 + gtk::Label, 743 + gtk::Label, 744 + gtk::Label, 745 + gtk::Button, 746 + ) { 747 + let dialog = adw::Window::builder() 748 + .transient_for(parent) 749 + .modal(true) 750 + .title(title) 751 + .default_width(400) 752 + .default_height(350) 753 + .build(); 754 + 755 + let header_bar = adw::HeaderBar::new(); 756 + dialog.set_content(None::<&gtk::Widget>); 757 + 758 + let box_ = gtk::Box::builder() 759 + .orientation(gtk::Orientation::Vertical) 760 + .spacing(24) 761 + .margin_top(24) 762 + .margin_bottom(24) 763 + .margin_start(24) 764 + .margin_end(24) 765 + .valign(gtk::Align::Center) 766 + .build(); 767 + 768 + let progress_bar = gtk::ProgressBar::builder() 769 + .show_text(true) 770 + .margin_bottom(24) 771 + .build(); 772 + 773 + let status_label = gtk::Label::builder() 774 + .label("Preparing...") 775 + .css_classes(["title-3"]) 776 + .build(); 777 + 778 + let title_label = gtk::Label::builder() 779 + .label("-") 780 + .ellipsize(gtk::pango::EllipsizeMode::End) 781 + .max_width_chars(40) 782 + .build(); 783 + let artist_label = gtk::Label::builder() 784 + .label("-") 785 + .ellipsize(gtk::pango::EllipsizeMode::End) 786 + .max_width_chars(40) 787 + .css_classes(["dim-label"]) 788 + .build(); 789 + let album_label = gtk::Label::builder() 790 + .label("-") 791 + .ellipsize(gtk::pango::EllipsizeMode::End) 792 + .max_width_chars(40) 793 + .css_classes(["dim-label"]) 794 + .build(); 795 + 796 + let time_box = gtk::Box::builder() 797 + .orientation(gtk::Orientation::Horizontal) 798 + .spacing(12) 799 + .halign(gtk::Align::Center) 800 + .build(); 801 + let elapsed_label = gtk::Label::builder() 802 + .label("Elapsed: 00:00") 803 + .css_classes(["caption"]) 804 + .build(); 805 + let eta_label = gtk::Label::builder() 806 + .label("ETA: --:--") 807 + .css_classes(["caption"]) 808 + .build(); 809 + 810 + time_box.append(&elapsed_label); 811 + time_box.append(&gtk::Separator::new(gtk::Orientation::Vertical)); 812 + time_box.append(&eta_label); 813 + 814 + box_.append(&status_label); 815 + box_.append(&progress_bar); 816 + box_.append(&gtk::Separator::new(gtk::Orientation::Horizontal)); 817 + box_.append(&title_label); 818 + box_.append(&artist_label); 819 + box_.append(&album_label); 820 + box_.append(&time_box); 821 + 822 + // Close button at bottom 823 + let close_button = gtk::Button::builder() 824 + .label("Close") 825 + .css_classes(["suggested-action"]) 826 + .sensitive(false) 827 + .halign(gtk::Align::Center) 828 + .margin_top(12) 829 + .build(); 830 + box_.append(&close_button); 831 + 832 + let toolbar_view = adw::ToolbarView::builder().content(&box_).build(); 833 + toolbar_view.add_top_bar(&header_bar); 834 + 835 + dialog.set_content(Some(&toolbar_view)); 836 + dialog.present(); 837 + ( 838 + dialog, 839 + progress_bar, 840 + status_label, 841 + title_label, 842 + artist_label, 843 + album_label, 844 + elapsed_label, 845 + eta_label, 846 + close_button, 847 + ) 848 + } 849 + 850 + fn show_sources_dialog( 851 + parent: &impl IsA<gtk::Window>, 852 + db_state: Rc<RefCell<Option<Instance>>>, 853 + store: gio::ListStore, 854 + ) { 215 855 let dialog = adw::Window::builder() 216 856 .transient_for(parent) 217 857 .modal(true) ··· 219 859 .default_width(500) 220 860 .default_height(400) 221 861 .build(); 862 + 863 + let dirty = Rc::new(std::cell::Cell::new(false)); 222 864 223 865 let header_bar = adw::HeaderBar::new(); 224 866 ··· 240 882 241 883 dialog.set_content(Some(&toolbar_view)); 242 884 885 + // Handle close request 886 + let dirty_clone = dirty.clone(); 887 + let db_state_clone = db_state.clone(); 888 + let store_clone = store.clone(); 889 + 890 + dialog.connect_close_request(move |dialog_win| { 891 + if dirty_clone.get() { 892 + let db_state_clone = db_state_clone.clone(); 893 + let store_clone = store_clone.clone(); 894 + let parent = dialog_win.transient_for(); 895 + 896 + glib::MainContext::default().spawn_local(async move { 897 + let (busy_win, count_weak, title_weak, artist_weak, album_weak) = 898 + if let Some(parent) = parent { 899 + let (win, c, t, a, al) = show_busy_dialog(&parent, "Updating Library"); 900 + ( 901 + Some(win), 902 + Some(c.downgrade()), 903 + Some(t.downgrade()), 904 + Some(a.downgrade()), 905 + Some(al.downgrade()), 906 + ) 907 + } else { 908 + (None, None, None, None, None) 909 + }; 910 + 911 + let instance = { db_state_clone.borrow().clone() }; 912 + if let Some(instance) = instance { 913 + println!("Sources changed, updating library..."); 914 + 915 + let count = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); 916 + let count_clone = count.clone(); 917 + 918 + let count_weak_send = count_weak.map(|w| glib::SendWeakRef::from(w)); 919 + let title_weak_send = title_weak.map(|w| glib::SendWeakRef::from(w)); 920 + let artist_weak_send = artist_weak.map(|w| glib::SendWeakRef::from(w)); 921 + let album_weak_send = album_weak.map(|w| glib::SendWeakRef::from(w)); 922 + 923 + let cb = move |track: &crate::model::Track| { 924 + if let (Some(wc), Some(wt), Some(wa), Some(wal)) = ( 925 + count_weak_send.as_ref(), 926 + title_weak_send.as_ref(), 927 + artist_weak_send.as_ref(), 928 + album_weak_send.as_ref(), 929 + ) { 930 + let current = 931 + count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; 932 + let msg_count = format!("Tracks found: {}", current); 933 + let msg_title = track.title.clone(); 934 + let msg_artist = track.artist.clone(); 935 + let msg_album = track.album.clone(); 936 + 937 + let wc = wc.clone(); 938 + let wt = wt.clone(); 939 + let wa = wa.clone(); 940 + let wal = wal.clone(); 941 + 942 + glib::MainContext::default().invoke(move || { 943 + if let Some(lbl) = wc.upgrade() { 944 + lbl.set_label(&msg_count); 945 + } 946 + if let Some(lbl) = wt.upgrade() { 947 + lbl.set_label(&msg_title); 948 + } 949 + if let Some(lbl) = wa.upgrade() { 950 + lbl.set_label(&msg_artist); 951 + } 952 + if let Some(lbl) = wal.upgrade() { 953 + lbl.set_label(&msg_album); 954 + } 955 + }); 956 + } 957 + }; 958 + 959 + if let Err(e) = crate::library::update(&instance, cb).await { 960 + eprintln!("Auto-update failed: {}", e); 961 + } else { 962 + let _ = populate_store(&store_clone, &instance).await; 963 + } 964 + } 965 + 966 + if let Some(busy) = busy_win { 967 + busy.close(); 968 + } 969 + }); 970 + } 971 + glib::Propagation::Proceed 972 + }); 973 + 243 974 // Load sources 244 975 let clamp_weak = clamp.downgrade(); 245 976 let dialog_weak = dialog.downgrade(); 246 977 let db_state_clone = db_state.clone(); 978 + let dirty_clone = dirty.clone(); 979 + 247 980 glib::MainContext::default().spawn_local(async move { 248 981 if let (Some(clamp), Some(dialog)) = (clamp_weak.upgrade(), dialog_weak.upgrade()) { 249 - refresh_sources_list(&clamp, &dialog, db_state_clone).await; 982 + refresh_sources_list(&clamp, &dialog, db_state_clone, dirty_clone).await; 250 983 } 251 984 }); 252 985 ··· 257 990 clamp: &adw::Clamp, 258 991 window: &impl IsA<gtk::Window>, 259 992 db_state: Rc<RefCell<Option<Instance>>>, 993 + dirty: Rc<std::cell::Cell<bool>>, 260 994 ) { 261 995 let group = adw::PreferencesGroup::new(); 262 996 group.set_title("Configured Sources"); ··· 290 1024 let window_weak = window_obj.downgrade(); 291 1025 let clamp_weak = clamp.downgrade(); 292 1026 let db_state_clone = db_state.clone(); 1027 + let dirty_clone = dirty.clone(); 293 1028 294 1029 add_row.connect_activated(move |_| { 295 1030 let window_weak = window_weak.clone(); 296 1031 let clamp_weak = clamp_weak.clone(); 297 1032 let db_state_clone = db_state_clone.clone(); 1033 + let dirty_clone = dirty_clone.clone(); 298 1034 299 1035 glib::MainContext::default().spawn_local(async move { 300 1036 // Upgrade to gtk::Window to use with FileDialog ··· 321 1057 &format!("Failed to insert directory: {}", e), 322 1058 ); 323 1059 } else { 1060 + dirty_clone.set(true); 324 1061 if let Some(clamp) = clamp_weak.upgrade() { 325 - refresh_sources_list(&clamp, &dialog, db_state_clone).await; 1062 + refresh_sources_list( 1063 + &clamp, 1064 + &dialog, 1065 + db_state_clone, 1066 + dirty_clone, 1067 + ) 1068 + .await; 326 1069 } 327 1070 } 328 1071 } ··· 340 1083 341 1084 fn show_sync_dialog( 342 1085 parent: &impl IsA<gtk::Window>, 343 - _source_db_state: Rc<RefCell<Option<Instance>>>, 1086 + source_db_state: Rc<RefCell<Option<Instance>>>, 344 1087 ) { 345 1088 let dialog = adw::Window::builder() 346 1089 .transient_for(parent) ··· 386 1129 .sensitive(false) 387 1130 .build(); 388 1131 transcode_row.add_prefix(&gtk::Image::from_icon_name("emblem-system-symbolic")); 1132 + 1133 + let transcode_row_weak = transcode_row.downgrade(); 1134 + ipod_row.connect_active_notify(move |row| { 1135 + if let Some(tr) = transcode_row_weak.upgrade() { 1136 + tr.set_sensitive(!row.is_active()); 1137 + } 1138 + }); 389 1139 390 1140 // Callbacks 391 1141 let dialog_weak = dialog.downgrade(); ··· 454 1204 } 455 1205 }); 456 1206 group.add(&transcode_row); 1207 + 1208 + // Start Button Logic 1209 + let source_db_state = source_db_state.clone(); 1210 + let dest_db_state = dest_db_state.clone(); 1211 + let dialog_weak = dialog.downgrade(); 1212 + let ipod_row_weak = ipod_row.downgrade(); 1213 + let dest_row_weak = dest_row.downgrade(); 1214 + 1215 + // Capture parent weak ref to avoid reference cycle 1216 + // (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"); 1218 + let parent_weak = parent_window_ref.downgrade(); 1219 + 1220 + start_button.connect_clicked(move |_| { 1221 + println!("Start button clicked"); 1222 + let source_db = { source_db_state.borrow().clone() }; 1223 + let dest_db = { dest_db_state.borrow().clone() }; 1224 + let dialog_weak = dialog_weak.clone(); 1225 + let ipod_mode = ipod_row_weak.upgrade().map(|r| r.is_active()).unwrap_or(false); 1226 + let dest_dir = dest_row_weak 1227 + .upgrade() 1228 + .map(|r| r.subtitle().unwrap().to_string()) 1229 + .unwrap_or_default(); 1230 + 1231 + // Upgrade weak ref immediately to keep it alive during setup 1232 + let parent_strong = parent_weak.upgrade(); 1233 + 1234 + if let (Some(source_db), Some(dest_db)) = (source_db, dest_db) { 1235 + println!("DBs loaded, closing dialog"); 1236 + // Close the config dialog 1237 + if let Some(dialog) = dialog_weak.upgrade() { 1238 + dialog.close(); 1239 + } 1240 + 1241 + glib::MainContext::default().spawn_local(async move { 1242 + if let Some(parent) = parent_strong { 1243 + println!("Starting perform_sync"); 1244 + perform_sync(parent, source_db, dest_db, dest_dir, ipod_mode).await; 1245 + } else { 1246 + eprintln!("Cannot start sync: No parent window found (weak upgrade failed)."); 1247 + } 1248 + }); 1249 + } else { 1250 + eprintln!("Source or Dest DB missing"); 1251 + } 1252 + }); 457 1253 458 1254 page.add(&group); 459 1255
+169
src/library.rs
··· 1 + use crate::{db, fs, model}; 2 + use anyhow::{Context, Result}; 3 + use futures::{executor::block_on, future::try_join_all}; 4 + use std::collections::hash_set; 5 + 6 + pub async fn update<P>(db: &db::Instance, progress_cb: P) -> Result<(u64, u64)> 7 + where 8 + P: Fn(&model::Track) + Clone + Send + Sync + 'static, 9 + { 10 + let sources = db 11 + .directories() 12 + .await 13 + .with_context(|| "Cannot fetch track directories from database")?; 14 + 15 + let mut tracks = vec![]; 16 + 17 + for source in &sources { 18 + for i in db 19 + .track_paths_from_dir(source.clone()) 20 + .await 21 + .with_context(|| "Cannot fetch track paths from directory")? 22 + { 23 + tracks.push(i) 24 + } 25 + } 26 + 27 + let tracks_set: hash_set::HashSet<String> = tracks.into_iter().collect(); 28 + 29 + let res = try_join_all( 30 + sources 31 + .into_iter() 32 + .map(|source| { 33 + let cb = progress_cb.clone(); 34 + traverse_and_add_param( 35 + db, 36 + source, 37 + { 38 + let tracks_set = tracks_set.clone(); 39 + 40 + move |path, _| Ok(tracks_set.contains(path)) 41 + }, 42 + Some(cb), 43 + ) 44 + }) 45 + .collect::<Vec<_>>(), 46 + ) 47 + .await?; 48 + 49 + let totals = res.iter().fold((0, 0), |acc, r| (acc.0 + r.0, acc.1 + r.1)); 50 + 51 + // Cleanup removed files 52 + let track_iter = db 53 + .tracks_iter() 54 + .await 55 + .with_context(|| "Cannot create an iterator for existing tracks in database")?; 56 + 57 + while let Ok(track) = track_iter.recv().await { 58 + let track = track?; 59 + let tp = std::path::Path::new(&track.file_path); 60 + if !tp.exists() { 61 + db.delete(track.id) 62 + .await 63 + .with_context(|| "Cannot delete track from database.")?; 64 + } 65 + } 66 + 67 + Ok(totals) 68 + } 69 + 70 + pub async fn scan_source(db: &db::Instance, source: String) -> Result<(u64, u64)> { 71 + traverse_and_add_param( 72 + db, 73 + source.clone(), 74 + move |path, db| add_dupe_checker(path, db), 75 + None::<fn(&model::Track)>, 76 + ) 77 + .await 78 + } 79 + 80 + fn add_dupe_checker(path: &String, db: &db::Instance) -> Result<bool> { 81 + block_on(async { 82 + if db.exists(path.clone()).await? { 83 + return Ok(true); 84 + } 85 + 86 + return Ok(false); 87 + }) 88 + } 89 + 90 + pub(crate) async fn traverse_and_add_param<F, P>( 91 + db: &db::Instance, 92 + path: String, 93 + dupe_checker: F, 94 + progress_cb: Option<P>, 95 + ) -> Result<(u64, u64)> 96 + where 97 + F: FnOnce(&String, &db::Instance) -> Result<bool> + Clone, 98 + P: Fn(&model::Track) + Send + Sync + 'static, 99 + { 100 + log::info!("scanning {}", path); 101 + 102 + let paths = fs::traverse(&path).await; 103 + 104 + let mut new_tracks = 0; 105 + let mut duplicate = 0; 106 + 107 + while let Ok(p) = paths.recv().await { 108 + let p = p.context(format!("yolo"))?.clone(); 109 + 110 + let dc = dupe_checker.clone(); 111 + if dc(&p, db)? { 112 + duplicate += 1; 113 + continue; 114 + } 115 + 116 + let tags = match lofty::read_from_path(p.clone()) { 117 + Ok(t) => t, 118 + Err(e) => { 119 + log::warn!("could not read tags from {}: {}", p.clone(), e); 120 + continue; 121 + } 122 + }; 123 + 124 + let p_path = std::path::PathBuf::from(p.clone()); 125 + let p_path = p_path.parent().unwrap(); 126 + 127 + let mut track: model::Track = model::RawTrack { 128 + tags, 129 + path: p, 130 + artwork_path: find_cover_image(&p_path), 131 + } 132 + .try_into()?; 133 + track.file_state = model::FileState::Copied; 134 + 135 + log::info!("new track: {}", track); 136 + 137 + if let Some(ref cb) = progress_cb { 138 + cb(&track); 139 + } 140 + 141 + db.insert_track(&track) 142 + .await 143 + .with_context(|| format!("Cannot write track data to database"))?; 144 + 145 + new_tracks += 1; 146 + } 147 + 148 + db.insert_directory(path).await?; 149 + 150 + Ok((new_tracks, duplicate)) 151 + } 152 + 153 + pub fn find_cover_image(base_dir: &std::path::Path) -> Option<String> { 154 + const SEARCH_NAMES: &[&str] = &["Cover", "cover", "folder"]; 155 + const SEARCH_EXTENSIONS: &[&str] = &["png", "jpeg", "jpg"]; 156 + 157 + for &name in SEARCH_NAMES { 158 + for &ext in SEARCH_EXTENSIONS { 159 + let file_name = format!("{}.{}", name, ext); 160 + let full_path = base_dir.join(file_name); 161 + 162 + if full_path.is_file() { 163 + return Some(full_path.to_string_lossy().to_string()); 164 + } 165 + } 166 + } 167 + 168 + None 169 + }
+2
src/main.rs
··· 8 8 mod gpod; 9 9 mod gtkgui; 10 10 mod gui; 11 + mod library; 11 12 mod model; 13 + mod sync; 12 14 13 15 #[async_std::main] 14 16 async fn main() -> anyhow::Result<()> {
+315
src/sync.rs
··· 1 + use crate::cmd::error; 2 + use crate::db; 3 + use crate::ffmpeg; 4 + use crate::ffmpeg::TranscodeResult; 5 + use crate::gpod; 6 + use crate::model; 7 + use anyhow::{Context, Ok, Result}; 8 + use fs_extra::file::{CopyOptions, copy_with_progress}; 9 + use futures::{StreamExt, TryStreamExt, stream}; 10 + use std::collections::hash_set; 11 + use std::path::PathBuf; 12 + 13 + pub async fn diff_databases( 14 + source: &db::Instance, 15 + destination: &db::Instance, 16 + filters: Option<&Vec<crate::filter::ScriptRuntime>>, 17 + delete: bool, 18 + ) -> Result<Vec<String>> { 19 + let local_tracks = source.tracks_by_state(model::FileState::Copied).await?; 20 + 21 + let local_tracks = filter_tracks(local_tracks, filters, delete)?; 22 + 23 + let dest_tracks = destination 24 + .tracks_by_state(model::FileState::Copied) 25 + .await?; 26 + 27 + let src_ids: hash_set::HashSet<String> = local_tracks 28 + .clone() 29 + .into_iter() 30 + .map(|t| t.track_id) 31 + .collect(); 32 + 33 + let dst_ids: hash_set::HashSet<String> = dest_tracks 34 + .clone() 35 + .into_iter() 36 + .map(|t| t.track_id) 37 + .collect(); 38 + 39 + log::debug!("local {} dest {}", src_ids.len(), dst_ids.len()); 40 + 41 + let d: hash_set::HashSet<&String> = dst_ids.difference(&src_ids).collect(); 42 + 43 + Ok(d.into_iter().map(|e| e.clone()).collect()) 44 + } 45 + 46 + pub async fn filter_tracks_by_id( 47 + filters: Option<&Vec<crate::filter::ScriptRuntime>>, 48 + db: &db::Instance, 49 + ids: Vec<String>, 50 + ) -> Result<Vec<String>> { 51 + let tracks = db.tracks_by_id(ids).await?; 52 + 53 + let tracks = filter_tracks(tracks, filters, false)?; 54 + 55 + Ok(tracks.into_iter().map(|t| t.track_id).collect()) 56 + } 57 + 58 + pub fn filter_tracks( 59 + raw_tracks: Vec<model::Track>, 60 + filters: Option<&Vec<crate::filter::ScriptRuntime>>, 61 + delete: bool, 62 + ) -> Result<Vec<model::Track>> { 63 + if let Some(filters) = filters { 64 + let mut filter_res = vec![]; 65 + 66 + for f in filters { 67 + let base_tracks = raw_tracks 68 + .clone() 69 + .into_iter() 70 + .map(|t| Into::<model::BaseTrack>::into(t)) 71 + .collect(); 72 + 73 + filter_res = f.run(base_tracks)?; 74 + } 75 + 76 + return Ok(raw_tracks 77 + .into_iter() 78 + .enumerate() 79 + .filter_map(|elem| { 80 + let (idx, track) = elem; 81 + 82 + if filter_res[idx] && !delete { 83 + return None; 84 + } 85 + 86 + return Some(track); 87 + }) 88 + .collect()); 89 + } 90 + 91 + Ok(raw_tracks) 92 + } 93 + 94 + pub async fn run_copy( 95 + local_db: &db::Instance, 96 + dest_db: &db::Instance, 97 + dest_dir: &String, 98 + diff: Vec<String>, 99 + filters: Option<&Vec<crate::filter::ScriptRuntime>>, 100 + threads: usize, 101 + ipod: bool, 102 + ) -> Result<()> { 103 + let tr_settings = dest_db.get_transcoding_settings().await?; 104 + 105 + let tracks = filter_tracks( 106 + local_db 107 + .tracks_by_id(diff) 108 + .await 109 + .with_context(|| "Cannot get tracks from local database")?, 110 + filters, 111 + false, 112 + )?; 113 + 114 + if ipod { 115 + return copy_ipod(dest_db, dest_dir, threads, tracks).await; 116 + } 117 + 118 + stream::iter(tracks) 119 + .map(|track| { 120 + let trs = tr_settings.clone(); 121 + let dest_path: PathBuf = track.storage_path(dest_dir).into(); 122 + async move { copy(&track, dest_db, dest_path, trs).await } 123 + }) 124 + .buffer_unordered(threads) 125 + .try_for_each(|_| async move { Ok(()) }) 126 + .await 127 + } 128 + 129 + pub async fn copy_ipod( 130 + dest_db: &db::Instance, 131 + dest_dir: &String, 132 + threads: usize, 133 + tracks: Vec<model::Track>, 134 + ) -> Result<()> { 135 + let ipod_manager = gpod::Manager::new(dest_dir.clone())?; 136 + 137 + let transcoding = Some(ffmpeg::Quality { 138 + bitrate: ffmpeg::Bitrate::VBR("5".to_owned()), 139 + sampling_rate: Some(ffmpeg::SamplingRate::Rate441K), 140 + codec: ffmpeg::Codec::AAC, 141 + }); 142 + 143 + let converted: Vec<_> = stream::iter(tracks) 144 + .map(|track| { 145 + let trs = transcoding.clone(); 146 + let ipm = ipod_manager.clone(); 147 + 148 + async move { 149 + let mut fp: PathBuf = track.file_path.clone().into(); 150 + match track.is_lossless() { 151 + true => { 152 + fp.set_extension(ffmpeg::Codec::AAC.extension()); 153 + } 154 + false => (), 155 + } 156 + let dest_path = ipm.path_for_file(fp.as_path()).unwrap(); 157 + let (copied_track, tr) = copy(&track, dest_db, dest_path.clone(), trs).await?; 158 + 159 + Ok((copied_track, dest_path, tr.unwrap())) 160 + } 161 + }) 162 + .buffer_unordered(threads) 163 + .try_collect() 164 + .await?; 165 + 166 + log::info!("finalizing..."); 167 + for (track, path, trans_res) in converted { 168 + ipod_manager.finalize_track( 169 + &track.clone(), 170 + path.clone(), 171 + track.extension, 172 + 44100, 173 + 256, 174 + trans_res.replaygain.map(|r| r.soundcheck_value()), 175 + )?; 176 + } 177 + 178 + return Ok(()); 179 + } 180 + 181 + pub async fn run_delete( 182 + dest_db: &db::Instance, 183 + dest_dir: &String, 184 + diff: Vec<String>, 185 + filters: Option<&Vec<crate::filter::ScriptRuntime>>, 186 + ) -> Result<()> { 187 + let diff_len = diff.len(); 188 + 189 + if diff_len == 0 { 190 + return Ok(()); 191 + } 192 + 193 + let tracks = filter_tracks( 194 + dest_db 195 + .tracks_by_id(diff) 196 + .await 197 + .with_context(|| "Cannot get tracks from destination database")?, 198 + filters, 199 + true, 200 + )?; 201 + 202 + for track in tracks { 203 + log::info!("deleting {}", track); 204 + delete(track, &dest_db, &dest_dir).await?; 205 + } 206 + 207 + Ok(()) 208 + } 209 + 210 + pub async fn delete(track: model::Track, dest_db: &db::Instance, dest_dir: &String) -> Result<()> { 211 + let track_storage_path = track.storage_path(&dest_dir); 212 + 213 + dest_db.delete(track.id).await?; 214 + std::fs::remove_file(track_storage_path.clone()) 215 + .with_context(|| format!("Cannot delete file {}", track_storage_path.clone()))?; 216 + Ok(()) 217 + } 218 + 219 + pub async fn copy( 220 + track: &model::Track, 221 + dest_db: &db::Instance, 222 + dest_fname: PathBuf, 223 + transcoding_settings: Option<ffmpeg::Quality>, 224 + ) -> Result<(model::Track, Option<TranscodeResult>)> { 225 + // We replicate src database on dest 1:1 226 + // We should create the same logical database (tracks with the same tags have the same hash) 227 + // ignoring the format. 228 + // When the copy is done we should return the newly-created track. 229 + 230 + let is_lossless = track.is_lossless(); 231 + 232 + let sp = std::path::Path::new(&dest_fname); 233 + 234 + let parent = sp 235 + .parent() 236 + .with_context(|| "Cannot obtain base destination directory")?; 237 + 238 + let mut dest_track = track.clone(); 239 + dest_track.file_state = crate::model::FileState::Copying; 240 + dest_db 241 + .insert_track(&dest_track) 242 + .await 243 + .with_context(|| "Cannot insert in-progress copying track in destination database")?; 244 + 245 + std::fs::create_dir_all(parent).with_context(|| { 246 + format!( 247 + "Cannot create destination directory tree {}", 248 + parent.to_str().unwrap() 249 + ) 250 + })?; 251 + 252 + let track_dest_path = if let Some(ref ts) = transcoding_settings 253 + && is_lossless 254 + { 255 + let mut dest_fname = dest_fname.clone(); 256 + dest_fname.set_extension(ts.codec.extension()); 257 + dest_fname 258 + } else { 259 + dest_fname 260 + }; 261 + 262 + let opts = CopyOptions::new().overwrite(true); 263 + 264 + let maybe_transcode_result = async_std::task::spawn_blocking({ 265 + let track_for_thread = track.clone(); 266 + let ts_for_thread = transcoding_settings.clone(); 267 + 268 + move || { 269 + if let Some(ts) = ts_for_thread 270 + && is_lossless 271 + { 272 + log::info!("transcoding & copying {} ({})", track_for_thread, ts); 273 + log::debug!("transcoding path: {}", track_dest_path.display()); 274 + 275 + return Ok(Some(ffmpeg::convert( 276 + &track_for_thread, 277 + track_dest_path, 278 + &ts, 279 + )?)); 280 + } 281 + 282 + log::info!("copying {}", track_for_thread); 283 + log::debug!("YOOO {}", track_dest_path.display()); 284 + 285 + match copy_with_progress( 286 + track_for_thread.file_path.clone(), 287 + track_dest_path.clone(), 288 + &opts, 289 + |_| {}, 290 + ) { 291 + std::result::Result::Ok(_) => {} 292 + Err(err) => { 293 + return Err(error::Error::CopyError(err)).with_context(|| { 294 + format!( 295 + "Cannot copy {} to {}", 296 + track_for_thread.file_path, 297 + track_dest_path.display() 298 + ) 299 + }); 300 + } 301 + }; 302 + 303 + Ok(None) 304 + } 305 + }) 306 + .await?; 307 + 308 + dest_track.file_state = crate::model::FileState::Copied; 309 + dest_db 310 + .insert_track(&dest_track) 311 + .await 312 + .with_context(|| "Cannot insert copy finished track in destination database")?; 313 + 314 + Ok((track.clone(), maybe_transcode_result)) 315 + }