From 0ac02c34c6dfdd3f6ff7657497fa97a6da34dd13 Mon Sep 17 00:00:00 2001 From: Marc Plano-Lesay Date: Tue, 3 Dec 2024 16:32:05 +1100 Subject: [PATCH] Add a album auto-create command --- Cargo.lock | 10 +++ Cargo.toml | 1 + src/actions/add_assets_to_album.rs | 58 ++++++++++++++ src/actions/create_album.rs | 68 ++++++++++++++++ src/actions/fetch_library_assets.rs | 42 ++++++++++ src/actions/mod.rs | 3 + src/args.rs | 7 ++ src/commands/auto_create_albums.rs | 118 ++++++++++++++++++++++++++++ src/commands/list_assets.rs | 1 + src/commands/mod.rs | 1 + src/main.rs | 4 + src/models/asset.rs | 3 + src/models/library.rs | 5 +- src/utils/assets.rs | 4 +- 14 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 src/actions/add_assets_to_album.rs create mode 100644 src/actions/create_album.rs create mode 100644 src/actions/fetch_library_assets.rs create mode 100644 src/commands/auto_create_albums.rs diff --git a/Cargo.lock b/Cargo.lock index f932c1b..912e97f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1312,6 +1312,7 @@ dependencies = [ "figment_file_provider_adapter", "httpmock", "log", + "multimap", "openapiv3", "pretty_env_logger", "prettyplease", @@ -1559,6 +1560,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "multimap" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" +dependencies = [ + "serde", +] + [[package]] name = "native-tls" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index f558d59..60c5e21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ directories = "5.0.1" figment = { version = "0.10.19", features = ["env", "toml"] } figment_file_provider_adapter = "0.1.1" log = "0.4.22" +multimap = "0.10.0" pretty_env_logger = "0.5.0" progenitor-client = "0.8.0" readonly = "0.2.12" diff --git a/src/actions/add_assets_to_album.rs b/src/actions/add_assets_to_album.rs new file mode 100644 index 0000000..78dffcd --- /dev/null +++ b/src/actions/add_assets_to_album.rs @@ -0,0 +1,58 @@ +use color_eyre::eyre::Result; +use log::info; + +use crate::{ + context::Context, + models::{album::Album, asset::Asset}, + types::BulkIdsDto, +}; + +use super::action::Action; + +pub struct AddAssetsToAlbum { + album: Album, + assets: Vec, +} + +pub struct AddAssetsToAlbumArgs { + pub album: Album, + pub assets: Vec, +} + +impl Action for AddAssetsToAlbum { + type Input = AddAssetsToAlbumArgs; + type Output = (); + + fn new(input: Self::Input) -> Self { + Self { + album: input.album, + assets: input.assets, + } + } + + fn describe(&self) -> String { + format!( + "Adding {} assets to album {}", + self.assets.len(), + self.album.name, + ) + } + + async fn execute(&self, ctx: &Context) -> Result { + info!("{}", self.describe()); + + if !ctx.dry_run { + ctx.client + .add_assets_to_album( + &self.album.id, + None, + &BulkIdsDto { + ids: self.assets.iter().map(|asset| asset.id).collect(), + }, + ) + .await?; + } + + Ok(()) + } +} diff --git a/src/actions/create_album.rs b/src/actions/create_album.rs new file mode 100644 index 0000000..2263ff2 --- /dev/null +++ b/src/actions/create_album.rs @@ -0,0 +1,68 @@ +use chrono::Utc; +use color_eyre::eyre::Result; +use log::info; +use uuid::Uuid; + +use crate::{ + context::Context, + models::{album::Album, asset::Asset}, + types::CreateAlbumDto, +}; + +use super::action::Action; + +pub struct CreateAlbum { + name: String, + assets: Vec, +} + +#[derive(Debug, Default)] +pub struct CreateAlbumArgs { + pub name: String, + pub assets: Vec, +} + +impl Action for CreateAlbum { + type Input = CreateAlbumArgs; + type Output = Album; + + fn new(input: Self::Input) -> Self { + Self { + name: input.name, + assets: input.assets, + } + } + + fn describe(&self) -> String { + format!( + "Creating album {} with {} assets", + self.name, + self.assets.len() + ) + } + + async fn execute(&self, ctx: &Context) -> Result { + info!("{}", self.describe()); + + if !ctx.dry_run { + Ok(ctx + .client + .create_album(&CreateAlbumDto { + album_name: self.name.clone(), + asset_ids: self.assets.iter().map(|asset| asset.id).collect(), + album_users: vec![], + description: None, + }) + .await? + .clone() + .into()) + } else { + Ok(Album { + id: Uuid::new_v4(), + name: self.name.clone(), + assets_count: self.assets.len() as u32, + updated_at: Utc::now(), + }) + } + } +} diff --git a/src/actions/fetch_library_assets.rs b/src/actions/fetch_library_assets.rs new file mode 100644 index 0000000..990da5c --- /dev/null +++ b/src/actions/fetch_library_assets.rs @@ -0,0 +1,42 @@ +use color_eyre::eyre::Result; + +use crate::{ + context::Context, + models::{asset::Asset, library::Library}, + utils::assets::{fetch_all_assets, AssetQuery}, +}; + +use super::action::Action; + +pub struct FetchLibraryAssets { + library: Library, +} + +impl Action for FetchLibraryAssets { + type Input = Library; + type Output = Vec; + + fn new(input: Self::Input) -> Self { + Self { library: input } + } + + fn describe(&self) -> String { + // TODO: derive Display for Library and use this here + format!("Fetching all assets for library {}", self.library.name) + } + + async fn execute(&self, ctx: &Context) -> Result { + let query = AssetQuery { + library: Some(self.library.clone()), + ..Default::default() + }; + + let assets: Vec<_> = fetch_all_assets(query, &ctx.client) + .await? + .into_iter() + .map(Asset::from) + .collect(); + + Ok(assets) + } +} diff --git a/src/actions/mod.rs b/src/actions/mod.rs index 6ba3fd6..68d4a40 100644 --- a/src/actions/mod.rs +++ b/src/actions/mod.rs @@ -1,9 +1,12 @@ pub mod action; +pub mod add_assets_to_album; pub mod confirm; +pub mod create_album; pub mod delete_album; pub mod fetch_album_assets; pub mod fetch_all_albums; pub mod fetch_all_libraries; pub mod fetch_all_persons; +pub mod fetch_library_assets; pub mod scan_library; pub mod update_person_date_of_birth; diff --git a/src/args.rs b/src/args.rs index 3be7512..4dc08d0 100644 --- a/src/args.rs +++ b/src/args.rs @@ -78,6 +78,13 @@ pub(crate) enum Commands { #[derive(Serialize, Subcommand)] pub(crate) enum AlbumsCommands { + /// Create albums automatically from external libaries folder structure + #[serde(rename = "auto-create")] + AutoCreate { + /// String to use when composing album names from nested folder structures + #[arg(long, default_value = " / ")] + album_name_separator: String, + }, /// Delete all albums #[serde(rename = "delete")] Delete {}, diff --git a/src/commands/auto_create_albums.rs b/src/commands/auto_create_albums.rs new file mode 100644 index 0000000..f9fc061 --- /dev/null +++ b/src/commands/auto_create_albums.rs @@ -0,0 +1,118 @@ +use std::path::{Path, PathBuf}; + +use crate::{ + actions::{ + action::Action, + add_assets_to_album::{AddAssetsToAlbum, AddAssetsToAlbumArgs}, + create_album::{CreateAlbum, CreateAlbumArgs}, + fetch_all_albums::FetchAllAlbums, + fetch_all_libraries::FetchAllLibraries, + fetch_library_assets::FetchLibraryAssets, + }, + context::Context, + models::{asset::Asset, library::Library}, +}; +use color_eyre::eyre::Result; +use log::*; +use multimap::MultiMap; + +pub async fn auto_create_albums(ctx: Context, separator: String) -> Result<()> { + let albums = FetchAllAlbums::new(()).execute(&ctx).await?; + let libraries = FetchAllLibraries::new(()).execute(&ctx).await?; + let mut assets: Vec = vec![]; + + for library in &libraries { + assets.extend( + FetchLibraryAssets::new(library.clone()) + .execute(&ctx) + .await? + .drain(0..), + ); + } + + let mut sorted_assets = MultiMap::new(); + + for asset in &assets { + let path = extract_path(asset, &libraries); + if let Some(path) = path { + let asset_albums = extract_album_names(path, separator.clone()); + for album in asset_albums { + sorted_assets.insert(album, asset.clone()); + } + } + } + + debug!("Generated albums: {:?}", sorted_assets.keys()); + + let missing_albums: Vec<_> = sorted_assets + .keys() + .filter(|k| !albums.iter().any(|album| album.name == **k)) + .cloned() + .collect(); + + info!("Creating missing albums: {:?}", missing_albums); + + for missing_album in &missing_albums { + let assets = sorted_assets + .remove(&missing_album.to_string()) + .unwrap_or(vec![]); + CreateAlbum::new(CreateAlbumArgs { + name: missing_album.to_string(), + assets, + }) + .execute(&ctx) + .await?; + } + + for (album, assets) in sorted_assets { + let existing_album = albums + .iter() + .find(|existing_album| existing_album.name == album); + + // Albums not present in albums were created just above with all the assets, so it's fine + // to skip. On the other hand, Immich won't re-add the same asset multiple times to the + // same album, so it's safe to re-add. + if let Some(existing_album) = existing_album { + info!("Adding {} assets to {}", assets.len(), album); + AddAssetsToAlbum::new(AddAssetsToAlbumArgs { + album: existing_album.clone(), + assets, + }) + .execute(&ctx) + .await?; + } + } + + Ok(()) +} + +fn extract_path(asset: &Asset, libraries: &[Library]) -> Option { + for library in libraries { + for import_path in &library.import_paths { + if asset.original_path.starts_with(import_path) { + return asset + .original_path + .strip_prefix(import_path) + .ok() + .and_then(Path::parent) + .map(Path::to_path_buf); + } + } + } + + None +} + +fn extract_album_names(folder_path: PathBuf, separator: String) -> Vec { + let mut components = vec![]; + let mut current_component = String::new(); + + for component in folder_path.components() { + let component_str = component.as_os_str().to_str().unwrap(); + current_component += component_str; + components.push(current_component.clone()); + current_component += &separator; + } + + components +} diff --git a/src/commands/list_assets.rs b/src/commands/list_assets.rs index 383ec70..4e8ef69 100644 --- a/src/commands/list_assets.rs +++ b/src/commands/list_assets.rs @@ -24,6 +24,7 @@ pub async fn list_assets(ctx: Context, offline: bool) -> Result<()> { let query = AssetQuery { is_offline: if offline { Some(true) } else { None }, with_exif: true, + ..Default::default() }; let mut assets: Vec<_> = fetch_all_assets(query, &ctx.client) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index d65cdbd..de28f43 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod auto_create_albums; pub mod delete_albums; pub mod delete_assets; pub mod list_albums; diff --git a/src/main.rs b/src/main.rs index 145b627..a1fa2f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ use args::{AlbumsCommands, AssetsCommands, LibrariesCommands, PeopleCommands, Se use clap::Parser; use color_eyre::eyre::{Result, WrapErr}; use color_eyre::Section; +use commands::auto_create_albums::auto_create_albums; use commands::delete_albums::delete_albums; use commands::delete_assets::delete_assets; use commands::list_albums::list_albums; @@ -79,6 +80,9 @@ async fn main() -> Result<()> { match &args.command { Commands::Albums { albums_command } => match albums_command { + AlbumsCommands::AutoCreate { + album_name_separator, + } => auto_create_albums(ctx, album_name_separator.to_string()).await, AlbumsCommands::Delete {} => delete_albums(ctx).await, AlbumsCommands::List {} => list_albums(ctx).await, }, diff --git a/src/models/asset.rs b/src/models/asset.rs index 668d877..367c91f 100644 --- a/src/models/asset.rs +++ b/src/models/asset.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use uuid::Uuid; use crate::types::AssetResponseDto; @@ -5,12 +6,14 @@ use crate::types::AssetResponseDto; #[derive(Debug, Clone)] pub struct Asset { pub id: Uuid, + pub original_path: PathBuf, } impl From for Asset { fn from(value: AssetResponseDto) -> Self { Self { id: Uuid::parse_str(&value.id).expect("Unable to parse an asset's UUID"), + original_path: PathBuf::from(value.original_path), } } } diff --git a/src/models/library.rs b/src/models/library.rs index 82b4439..c5b9b99 100644 --- a/src/models/library.rs +++ b/src/models/library.rs @@ -10,6 +10,7 @@ pub struct Library { pub asset_count: i64, pub updated_at: DateTime, pub refreshed_at: Option>, + pub import_paths: Vec, } impl From for Library { @@ -20,6 +21,7 @@ impl From for Library { asset_count: value.asset_count, updated_at: value.updated_at, refreshed_at: value.refreshed_at, + import_paths: value.import_paths, } } } @@ -38,7 +40,7 @@ mod tests { name: String::from("Christmas photos"), asset_count: 1246, exclusion_patterns: vec![], - import_paths: vec![], + import_paths: vec![String::from("/foo"), String::from("/mnt/bar")], owner_id: String::from("abc123"), updated_at: DateTime::::from_str("2024-11-17T12:55:12Z").unwrap(), created_at: DateTime::::from_str("2023-10-17T12:55:12Z").unwrap(), @@ -61,6 +63,7 @@ mod tests { library.refreshed_at, Some(DateTime::::from_str("2024-11-17T12:53:12Z").unwrap()) ); + assert_eq!(library.import_paths, vec!["/foo", "/mnt/bar"]); } #[test] diff --git a/src/utils/assets.rs b/src/utils/assets.rs index f6878c1..b24a8f3 100644 --- a/src/utils/assets.rs +++ b/src/utils/assets.rs @@ -1,4 +1,5 @@ use crate::{ + models::library::Library, types::{AssetResponseDto, MetadataSearchDto}, Client, }; @@ -8,6 +9,7 @@ use log::debug; #[derive(Debug, Default)] pub struct AssetQuery { + pub library: Option, pub is_offline: Option, pub with_exif: bool, } @@ -46,7 +48,7 @@ pub async fn fetch_all_assets(query: AssetQuery, client: &Client) -> Result