1use gtk::prelude::*;
10use libadwaita as adw;
11
12use parking_lot::Mutex;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, Ordering};
15use tokio::sync::mpsc;
16
17mod api_client;
18mod app_context;
19mod autostart;
20mod cache_manager;
21mod config;
22mod diagnostics;
23mod library;
24mod logging;
25mod media_kinds;
26mod monitor;
27mod notifications;
28mod profile;
29mod queue_manager;
30mod remote_sync;
31mod runtime_env;
32mod sanitize;
33mod settings_window;
34mod sidecar;
35mod startup_scan;
36mod state_manager;
37mod sync_index;
38mod tray_icon;
39mod util;
40mod watch_path_display;
41
42use api_client::ImmichApiClient;
43use app_context::AppContext;
44use config::{Config, best_matching_watch_entry};
45use library::state::LibraryState;
46use library::thumbnail_cache::ThumbnailCache;
47use monitor::{Monitor, MonitorEvent};
48use queue_manager::{EnvironmentPolicy, FileTask, QueueManager};
49use settings_window::build_settings_window;
50use startup_scan::queue_unsynced_files;
51use state_manager::{AppState, StateManager};
52use sync_index::{ShardedSyncIndex, SyncDecision, SyncTarget};
53use tray_icon::build_tray;
54
55use flexi_logger::{Cleanup, Criterion, Duplicate, FileSpec, Logger, Naming, WriteMode};
56
57static APP_CONTEXT: std::sync::OnceLock<Arc<AppContext>> = std::sync::OnceLock::new();
59
60fn consume_flag(flag: &parking_lot::Mutex<bool>) -> bool {
63 let mut f = flag.lock();
64 if *f {
65 *f = false;
66 true
67 } else {
68 false
69 }
70}
71
72#[tokio::main]
73async fn main() {
74 let log_dir = profile::cache_dir()
76 .unwrap_or_else(|| std::path::PathBuf::from("/tmp").join(profile::dir_segment()));
77
78 let default_log_spec = if profile::name().is_some() {
80 "mimick=debug,info"
81 } else {
82 "info"
83 };
84 let _logger = Logger::try_with_env_or_str(default_log_spec)
85 .expect("Failed to parse log level")
86 .log_to_file(
87 FileSpec::default()
88 .directory(log_dir)
89 .basename("mimick")
90 .suppress_timestamp() .suffix("log"),
92 )
93 .format_for_files(logging::detailed_plain_format)
94 .format_for_stdout(logging::detailed_colored_format)
95 .rotate(
96 Criterion::Size(2_000_000),
97 Naming::Numbers,
98 Cleanup::KeepLogFiles(5),
99 )
100 .duplicate_to_stdout(Duplicate::All)
102 .write_mode(WriteMode::Direct)
103 .start()
104 .expect("Failed to initialize logger");
105
106 if let Some(name) = profile::name() {
107 log::info!(
108 "Active profile: {} (state dirs use segment '{}')",
109 name,
110 profile::dir_segment()
111 );
112 }
113
114 gtk::gio::resources_register_include!("mimick.gresource")
115 .expect("Failed to register bundled GResource");
116
117 let app = adw::Application::builder()
118 .application_id(profile::application_id())
119 .flags(gtk::gio::ApplicationFlags::HANDLES_COMMAND_LINE)
120 .build();
121
122 let is_primary_instance = Arc::new(AtomicBool::new(false));
123 let is_primary_instance_clone = is_primary_instance.clone();
124
125 let shared_state: Arc<parking_lot::Mutex<AppState>> = Arc::new(parking_lot::Mutex::new({
126 let mut saved = StateManager::new().read_state();
127 saved.total_queued = saved.processed_count;
130 saved.queue_size = 0;
131 saved.failed_count = 0; saved.current_file = None;
133
134 for status in saved.folder_statuses.values_mut() {
135 status.pending_count = 0;
136 }
137 saved.reset_runtime_state();
138
139 AppState {
141 status: "idle".to_string(),
142 active_workers: 0,
143 ..saved
144 }
145 }));
146
147 let shared_state_startup = shared_state.clone();
148
149 app.connect_startup(move |app| {
152 is_primary_instance_clone.store(true, Ordering::SeqCst);
153
154 log::info!("Mimick primary instance initializing");
155 adw::StyleManager::default().set_color_scheme(adw::ColorScheme::Default);
157
158 crate::library::style::ensure_registered();
160
161 thread_local! {
162 static APP_HOLD: std::cell::RefCell<Option<gtk::gio::ApplicationHoldGuard>> = const { std::cell::RefCell::new(None) };
163 }
164 APP_HOLD.with(|hold| {
165 *hold.borrow_mut() = Some(app.hold());
166 });
167
168 let config = Config::new();
170 let watch_folder_count = config.data.watch_paths.len();
171 let live_watch_paths = Arc::new(Mutex::new(config.data.watch_paths.clone()));
172 log::info!(
173 "Config: internal={} external={} paths={:?}",
174 config.data.internal_url,
175 config.data.external_url,
176 config.watch_path_strings(),
177 );
178
179 {
180 let mut state = shared_state_startup.lock();
181 state.watched_folder_count = watch_folder_count;
182 }
183
184 let background_sync_enabled = config.data.background_sync_enabled;
185
186 let api_key = config.get_api_key().unwrap_or_default();
187 let runtime_internal_url = if config.data.internal_url_enabled {
188 config.data.internal_url.clone()
189 } else {
190 String::new()
191 };
192 let runtime_external_url = if config.data.external_url_enabled {
193 config.data.external_url.clone()
194 } else {
195 String::new()
196 };
197 if api_key.is_empty()
198 && (!runtime_internal_url.is_empty() || !runtime_external_url.is_empty())
199 {
200 log::warn!(
201 "Server URL is configured but no API key was found. \
202 Library data will not load until an API key is set in Settings."
203 );
204 }
205
206 let api_client = Arc::new(ImmichApiClient::new(
207 runtime_internal_url,
208 runtime_external_url,
209 api_key,
210 ));
211 let sync_index = Arc::new(ShardedSyncIndex::new());
212
213 let sync_index_flusher = sync_index.clone();
214 tokio::spawn(async move {
215 loop {
216 tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
217 let _ = sync_index_flusher.flush();
218 }
219 });
220
221 let qm = Arc::new(QueueManager::new(
222 api_client.clone(),
223 config.data.upload_concurrency.max(1) as usize,
224 shared_state_startup.clone(),
225 sync_index.clone(),
226 EnvironmentPolicy {
227 pause_on_metered_network: config.data.pause_on_metered_network,
228 pause_on_battery_power: config.data.pause_on_battery_power,
229 quiet_hours_start: config.data.quiet_hours_start,
230 quiet_hours_end: config.data.quiet_hours_end,
231 },
232 ));
233
234 crate::notifications::set_enabled(config.data.notifications_enabled);
236
237 crate::library::set_raw_cache_enabled(config.data.raw_decode_cache_enabled);
239 crate::library::set_raw_full_decode(config.data.raw_full_decode);
240
241 let (tx, mut rx) = mpsc::channel(32);
243 let monitor_paths = if background_sync_enabled {
244 config.data.watch_paths.clone()
245 } else {
246 Vec::new()
247 };
248 let monitor = Monitor::new(monitor_paths, background_sync_enabled);
249 let monitor_handle = Arc::new(monitor.start(tx));
250 if background_sync_enabled {
251 log::info!("File monitor started");
252 } else {
253 log::info!("Background sync is disabled; monitor started with no active watches");
254 }
255
256 let startup_qm = qm.clone();
257 let startup_paths = config.data.watch_paths.clone();
258 let startup_sync_index = sync_index.clone();
259 let (manual_sync_tx, mut manual_sync_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
260 let thumbnail_cache = Arc::new(ThumbnailCache::new(api_client.clone()));
261 let cache_cap_mb = config.data.cache_disk_cap_mb;
265 tokio::spawn(async move {
266 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
267 let cap_bytes = (cache_cap_mb as u64).saturating_mul(1024 * 1024);
268 let _ = tokio::task::spawn_blocking(move || {
269 cache_manager::prune_all_blocking(cap_bytes);
270 })
271 .await;
272 });
273 let library_state = Arc::new(parking_lot::Mutex::new(LibraryState::new()));
274 let shared_config = Arc::new(parking_lot::RwLock::new(config));
275 let ctx = Arc::new(AppContext {
276 config: shared_config.clone(),
277 state: shared_state_startup.clone(),
278 api_client: api_client.clone(),
279 queue_manager: qm.clone(),
280 monitor_handle: monitor_handle.clone(),
281 sync_index: sync_index.clone(),
282 live_watch_paths: live_watch_paths.clone(),
283 sync_now_tx: manual_sync_tx.clone(),
284 thumbnail_cache,
285 library_state,
286 library_timeline_active: std::sync::atomic::AtomicBool::new(false),
287 current_user_id: Arc::new(parking_lot::Mutex::new(None)),
288 expected_self_deletions: Arc::new(app_context::RecentSelfPaths::default()),
289 expected_self_downloads: Arc::new(app_context::RecentSelfPaths::default()),
290 reconcile_locks: Arc::new(app_context::ReconcileLocks::default()),
291 pending_deletions: Arc::new(app_context::PendingDeletions::default()),
292 });
293 let _ = APP_CONTEXT.set(ctx.clone());
294
295 let qm_clone = qm.clone();
296 let live_watch_paths_for_queue = live_watch_paths.clone();
297 let deletion_ctx = ctx.clone();
298 tokio::spawn(async move {
299 while let Some(event) = rx.recv().await {
300 match event {
301 MonitorEvent::Ready { path, checksum, sidecar_path } => {
302 if deletion_ctx.expected_self_downloads.consume(&path) {
303 continue;
304 }
305
306 let (album_id, album_name, watch_path, folder_rules) = {
307 let path_configs = live_watch_paths_for_queue.lock();
308 best_matching_watch_entry(std::path::Path::new(&path), &path_configs)
309 .map(|entry| match entry {
310 config::WatchPathEntry::WithConfig {
311 album_id,
312 album_name,
313 rules,
314 ..
315 } => (
316 album_id.clone(),
317 album_name.clone(),
318 entry.path().to_string(),
319 rules.clone(),
320 ),
321 config::WatchPathEntry::Simple(_) => {
322 (None, None, entry.path().to_string(), Default::default())
323 }
324 })
325 .unwrap_or((None, None, String::new(), Default::default()))
326 };
327
328 let global_xmp = deletion_ctx.config.read().data.upload_xmp_sidecars;
330 let sidecar_path = if folder_rules.xmp_sidecar_enabled(global_xmp) {
331 sidecar_path
332 } else {
333 None
334 };
335
336 let target = SyncTarget {
337 album_name: album_name.clone(),
338 album_id: album_id.clone(),
339 };
340 let (reassociate_only, task_checksum) = match deletion_ctx
341 .sync_index
342 .sync_decision(std::path::Path::new(&path), &target)
343 {
344 Ok(SyncDecision::UpToDate) => {
345 log::debug!("Skipping unchanged file: {}", path);
346 continue;
347 }
348 Ok(SyncDecision::NeedsReassociate) => (
349 true,
350 deletion_ctx
351 .sync_index
352 .stored_checksum(&path)
353 .unwrap_or(checksum),
354 ),
355 Ok(SyncDecision::NeedsUpload) => (false, checksum),
356 Err(err) => {
357 log::warn!(
358 "Could not inspect sync index for '{}': {}; queuing anyway",
359 path,
360 err
361 );
362 (false, checksum)
363 }
364 };
365
366 log::info!("Queuing: {} (sha1={})", path, task_checksum);
367
368 let _ = qm_clone
369 .add_to_queue(FileTask {
370 path,
371 watch_path,
372 checksum: task_checksum,
373 album_id,
374 album_name,
375 reassociate_only,
376 skip_album: false,
377 sidecar_path,
378 })
379 .await;
380 }
381 MonitorEvent::Deleted { path } => {
382 if deletion_ctx.expected_self_deletions.consume(&path) {
383 continue;
384 }
385 if let Some(request) =
386 remote_sync::build_local_deletion_request(deletion_ctx.clone(), path).await
387 {
388 remote_sync::trash_remote_after_local_delete(deletion_ctx.clone(), request).await;
389 }
390 }
391 }
392 }
393 });
394
395 if background_sync_enabled {
397 let shared_state_startup_task = shared_state_startup.clone();
398 let startup_api = ctx.api_client.clone();
399 let startup_ctx = ctx.clone();
400 let catchup_mode = shared_config.read().data.startup_catchup_mode.clone();
401 tokio::spawn(async move {
402 queue_unsynced_files(
403 startup_paths,
404 startup_qm,
405 startup_sync_index,
406 startup_api,
407 catchup_mode,
408 shared_state_startup_task,
409 startup_ctx,
410 )
411 .await;
412 });
413 } else {
414 log::info!("Background sync is disabled; skipping startup catch-up scan");
415 }
416
417 if background_sync_enabled {
418 let reconciler_ctx = ctx.clone();
419 tokio::spawn(async move {
420 remote_sync::run_album_reconciler(reconciler_ctx).await;
421 });
422 }
423
424 let startup_state = shared_state_startup.clone();
425 let status_api = ctx.api_client.clone();
426 tokio::spawn(async move {
427 let connected = status_api.check_connection().await;
428 let route = status_api.active_route_label().await;
429 let latest_issue = status_api.latest_issue().await;
430
431 let mut state = startup_state.lock();
432 state.active_server_route = route;
433 if connected {
434 state.last_error = None;
435 state.last_error_guidance = None;
436 } else if let Some(issue) = latest_issue {
437 state.last_error = Some(issue.summary);
438 state.last_error_guidance = Some(issue.guidance);
439 }
440 });
441
442 let manual_qm = qm.clone();
443 let manual_sync_index = sync_index.clone();
444 let manual_sync_api = ctx.api_client.clone();
445 let shared_state_manual_task = shared_state_startup.clone();
446 let manual_config = shared_config.clone();
447 let manual_ctx = ctx.clone();
448 tokio::spawn(async move {
449 while manual_sync_rx.recv().await.is_some() {
450 let (watch_paths, catchup_mode) = {
451 let cfg = manual_config.read();
452 (cfg.data.watch_paths.clone(), cfg.data.startup_catchup_mode.clone())
453 };
454 queue_unsynced_files(
455 watch_paths,
456 manual_qm.clone(),
457 manual_sync_index.clone(),
458 manual_sync_api.clone(),
459 catchup_mode,
460 shared_state_manual_task.clone(),
461 manual_ctx.clone(),
462 )
463 .await;
464 }
465 });
466
467 let app_clone2 = app.clone();
468 let app_clone3 = app.clone();
469
470 let settings_flag = Arc::new(parking_lot::Mutex::new(false));
473 let settings_flag_writer = settings_flag.clone(); let library_flag = Arc::new(parking_lot::Mutex::new(false));
475 let library_flag_writer = library_flag.clone();
476 let quit_flag = Arc::new(parking_lot::Mutex::new(false));
477 let quit_flag_writer = quit_flag.clone(); let pause_flag = Arc::new(parking_lot::Mutex::new(false));
479 let pause_flag_writer = pause_flag.clone();
480 let sync_now_flag = Arc::new(parking_lot::Mutex::new(false));
481 let sync_now_flag_writer = sync_now_flag.clone();
482
483 glib::timeout_add_local(std::time::Duration::from_millis(250), move || {
486 if consume_flag(&settings_flag) {
487 let ctx = APP_CONTEXT
488 .get()
489 .cloned()
490 .expect("App context should be initialized before opening settings");
491 open_settings_window_now(&app_clone2, ctx);
492 }
493
494 if consume_flag(&library_flag) {
495 let ctx = APP_CONTEXT
496 .get()
497 .cloned()
498 .expect("App context should be initialized before opening library");
499 open_library_window_now(&app_clone2, ctx);
500 }
501
502 if consume_flag(&quit_flag) {
503 app_clone3.quit();
504 return glib::ControlFlow::Break;
505 }
506
507 if consume_flag(&pause_flag) {
508 let qm = &APP_CONTEXT
509 .get()
510 .expect("App context should be initialized before pause handling")
511 .queue_manager;
512 let paused = !qm.is_paused();
513 let reason = if paused {
514 Some("Paused by user".to_string())
515 } else {
516 None
517 };
518 qm.set_paused(paused, reason);
519 }
520
521 if consume_flag(&sync_now_flag) {
522 let tx = &APP_CONTEXT
523 .get()
524 .expect("App context should be initialized before manual sync handling")
525 .sync_now_tx;
526 let _ = tx.send(());
527 }
528
529 glib::ControlFlow::Continue
530 });
531
532 let tray_library_enabled = shared_config.read().data.library_view_enabled;
535 tokio::spawn(async move {
536 log::info!("Starting system tray");
537 match build_tray(tray_library_enabled).await {
538 Ok(handles) => {
539 let crate::tray_icon::TrayHandles {
540 handle: _handle,
541 mut settings_rx,
542 mut library_rx,
543 mut quit_rx,
544 mut pause_rx,
545 mut sync_now_rx,
546 } = handles;
547 loop {
548 tokio::select! {
549 res = settings_rx.changed() => {
550 if res.is_err() {
551 break;
552 }
553 if *settings_rx.borrow() {
554 *settings_flag_writer.lock() = true;
555 }
556 }
557 res = library_rx.changed() => {
558 if res.is_err() {
559 break;
560 }
561 if *library_rx.borrow() {
562 *library_flag_writer.lock() = true;
563 }
564 }
565 res = quit_rx.changed() => {
566 if res.is_err() {
567 break;
568 }
569 if *quit_rx.borrow() {
570 *quit_flag_writer.lock() = true;
571 }
572 }
573 res = pause_rx.changed() => {
574 if res.is_err() {
575 break;
576 }
577 if *pause_rx.borrow() {
578 *pause_flag_writer.lock() = true;
579 }
580 }
581 res = sync_now_rx.changed() => {
582 if res.is_err() {
583 break;
584 }
585 if *sync_now_rx.borrow() {
586 *sync_now_flag_writer.lock() = true;
587 }
588 }
589 }
590 }
591 }
592 Err(e) => log::warn!("System tray failed to start: {:?}", e),
593 }
594 });
595 });
596
597 app.connect_command_line(move |app, cmdline| {
599 let argv: Vec<String> = cmdline
600 .arguments()
601 .iter()
602 .filter_map(|a| a.to_str().map(|s| s.to_string()))
603 .collect();
604
605 let quit_requested = argv.contains(&"--quit".to_string());
606 if quit_requested {
607 app.quit();
608 return 0.into();
609 }
610
611 let ctx_early = APP_CONTEXT.get().cloned();
612 let want_settings = argv.contains(&"--settings".to_string());
613 let want_library = argv.contains(&"--library".to_string());
614 let want_upload = argv.contains(&"--upload".to_string());
615 let setup_required = ctx_early
616 .as_ref()
617 .map(|c| c.config.read().get_api_key().unwrap_or_default().is_empty())
618 .unwrap_or(true);
619 let secondary_activation = cmdline.is_remote();
620
621 let ctx_lookup = || {
622 APP_CONTEXT
623 .get()
624 .cloned()
625 .expect("App context should be initialized before command-line activation")
626 };
627
628 let file_args: Vec<std::path::PathBuf> = argv
630 .iter()
631 .skip(1) .filter(|a| !a.starts_with("--"))
633 .map(std::path::PathBuf::from)
634 .filter(|p| crate::media_kinds::is_supported_path(p))
635 .collect();
636
637 if !file_args.is_empty() && !setup_required {
638 crate::library::staging_view::build_staging_window(
640 app,
641 ctx_lookup(),
642 file_args,
643 want_upload,
644 );
645 } else if want_settings || setup_required {
646 open_settings_window_now(app, ctx_lookup());
647 } else if want_library {
648 open_library_window_now(app, ctx_lookup());
649 } else if secondary_activation
650 || !ctx_early
651 .as_ref()
652 .map(|c| c.config.read().data.background_sync_enabled)
653 .unwrap_or(false)
654 {
655 open_default_window(app, ctx_lookup());
656 }
657
658 app.activate();
659 0.into()
660 });
661
662 app.connect_activate(move |_app| {
663 log::debug!("App activated");
664 });
665
666 log::info!("GTK application starting up");
667
668 if is_primary_instance.load(Ordering::SeqCst) {
669 let quit_requested = Arc::new(AtomicBool::new(false));
670 let qr_signal = quit_requested.clone();
671 tokio::spawn(async move {
672 let mut sigterm =
673 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
674 Ok(s) => s,
675 Err(err) => {
676 log::warn!("Could not install SIGTERM handler: {}", err);
677 return;
678 }
679 };
680 tokio::select! {
681 res = tokio::signal::ctrl_c() => {
682 if let Err(err) = res {
683 log::warn!("SIGINT handler error: {}", err);
684 return;
685 }
686 log::info!("Received SIGINT; requesting graceful shutdown.");
687 }
688 _ = sigterm.recv() => {
689 log::info!("Received SIGTERM; requesting graceful shutdown.");
690 }
691 }
692 qr_signal.store(true, Ordering::SeqCst);
693 });
694
695 let app_for_quit = app.clone();
696 glib::timeout_add_local(std::time::Duration::from_millis(200), move || {
697 if quit_requested.load(Ordering::SeqCst) {
698 app_for_quit.quit();
699 glib::ControlFlow::Break
700 } else {
701 glib::ControlFlow::Continue
702 }
703 });
704 }
705
706 app.run();
707
708 if is_primary_instance.load(Ordering::SeqCst) {
710 if let Some(ctx) = APP_CONTEXT.get() {
711 ctx.queue_manager
712 .shutdown(std::time::Duration::from_secs(5))
713 .await;
714 ctx.queue_manager.flush_retries();
715 if let Err(err) = ctx.sync_index.flush() {
716 log::warn!("Failed to flush sync index on shutdown: {}", err);
717 }
718 }
719 let state = APP_CONTEXT
720 .get()
721 .map(|ctx| ctx.state.lock().clone())
722 .unwrap_or_else(|| shared_state.lock().clone());
723 StateManager::new().write_state(state);
724 log::info!("Mimick exiting");
725 }
726}
727
728fn open_default_window(app: &adw::Application, ctx: Arc<AppContext>) {
730 if let Some(win) = find_window(app, "mimick-library-window")
731 .or_else(|| find_window(app, "mimick-settings-window"))
732 {
733 win.present();
734 return;
735 }
736 if ctx.config.read().data.library_view_enabled {
737 open_library_window_now(app, ctx);
738 } else {
739 open_settings_window_now(app, ctx);
740 }
741}
742
743fn open_settings_window_now(app: &adw::Application, ctx: Arc<AppContext>) {
745 if let Some(win) = find_window(app, "mimick-settings-window") {
746 win.present();
747 return;
748 }
749 log::debug!("Opening settings window");
750 build_settings_window(app, ctx);
751}
752
753fn open_library_window_now(app: &adw::Application, ctx: Arc<AppContext>) {
755 if let Some(win) = find_window(app, "mimick-library-window") {
756 win.present();
757 return;
758 }
759 if !ctx.config.read().data.library_view_enabled {
760 log::info!("Library view is disabled in settings; opening Settings instead");
761 open_settings_window_now(app, ctx);
762 return;
763 }
764 log::debug!("Opening library window");
765 library::build_library_window(app, ctx);
766}
767
768fn find_window(app: &adw::Application, name: &str) -> Option<gtk::Window> {
770 app.windows().into_iter().find(|w| w.widget_name() == name)
771}
772
773#[cfg(test)]
774mod tests {
775 use super::*;
776 use crate::config::{FolderRules, WatchPathEntry};
777
778 #[test]
779 fn test_live_queue_matching_prefers_most_specific_watch_path() {
780 let entries = vec![
781 WatchPathEntry::WithConfig {
782 path: "/home/user/Pictures".into(),
783 album_id: Some("root-album".into()),
784 album_name: Some("Pictures".into()),
785 rules: FolderRules::default(),
786 },
787 WatchPathEntry::WithConfig {
788 path: "/home/user/Pictures/Trips".into(),
789 album_id: Some("trips-album".into()),
790 album_name: Some("Trips".into()),
791 rules: FolderRules::default(),
792 },
793 ];
794
795 let matched = best_matching_watch_entry(
796 std::path::Path::new("/home/user/Pictures/Trips/day1/photo.jpg"),
797 &entries,
798 )
799 .unwrap();
800
801 let config::WatchPathEntry::WithConfig { album_id, .. } = matched else {
802 panic!("expected configured watch entry");
803 };
804 assert_eq!(album_id.as_deref(), Some("trips-album"));
805 assert_eq!(matched.album_name(), Some("Trips"));
806 }
807}