diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 0000000..ac07003 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,51 @@ +name: Integration Tests + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + integration-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-action@stable + + - name: Install Podman + run: | + sudo apt-get update + sudo apt-get install -y podman + sudo systemctl enable --now podman.socket + + - name: Install podman-compose + run: | + sudo pip3 install podman-compose + + - name: Start Immich + run: | + ./scripts/start-immich.sh + + - name: Seed test data + run: | + ./scripts/seed-data.sh + + - name: Run tests + run: | + source .env.test + cargo test + + - name: Run examples + run: | + source .env.test + cargo run --example basic_usage + + - name: Cleanup + if: always() + run: | + ./scripts/stop-immich.sh diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a2785fa --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,148 @@ +# Agent Instructions for immich-sdk + +## Build & Test Commands + +**Quick Checks:** +```bash +cargo check # Fast compilation check +cargo clippy # Run linter +cargo fmt # Format code +cargo test # Run all tests +cargo test # Run single test (e.g., cargo test test_client_creation) +``` + +**Full verification before commit:** +```bash +cargo check && cargo clippy && cargo test && cargo fmt +``` + +## Running Examples + +All examples should be run using the provided wrapper script: + +```bash +./scripts/run-example.sh +``` + +**Available examples:** +```bash +./scripts/run-example.sh basic_usage +./scripts/run-example.sh upload_photos +./scripts/run-example.sh download_asset +./scripts/run-example.sh thumbnail +``` + +The wrapper handles everything automatically: +- Starts Immich containers (if not running) +- Creates admin user and API key +- Seeds test data (photos, albums) +- Loads credentials +- Runs the example + +**When adding new examples:** +1. Read `IMMICH_URL` and `IMMICH_API_KEY` from environment variables +2. Test with: `./scripts/run-example.sh ` +3. Verify it works with the seeded test data (3 photos, 1 album) + +## Code Style Guidelines + +### Imports +- Group by: std, external crates, internal modules (crate::) +- Use `use crate::` for internal modules, not relative paths +- Example: + ```rust + use std::path::Path; + use std::sync::Arc; + + use crate::{ + Client, + error::{ImmichError, Result}, + models::AssetId, + }; + ``` + +### Types & Naming +- Prefer using newtypes or type aliases instead of Strings or integers + For example: `AssetId` (Uuid alias), not `String` for asset IDs +- Model types: PascalCase with `Response` or `Request` suffix +- Enum variants: Use serde `rename_all` attributes for API compatibility +- Builders: `FooBuilder` for constructing `Foo` operations +- Type aliases for IDs: `pub type AssetId = uuid::Uuid;` + +### Error Handling +- Use `crate::error::Result` alias +- Use `thiserror` for error enums +- Prefer `ImmichError::Validation(String)` for user input errors +- Convert external errors: `#[from] reqwest::Error` + +### API Design Patterns +- **Builder pattern**: All API operations use builders + ```rust + client.assets().upload().file("path").execute().await?; + ``` +- Cheap cloning: `Client` wraps data in `Arc` +- API modules take `Client` by value +- Store `Client` in builders, not references + +### OpenAPI +Refer to `openapi-spec.yaml` for documentation regarding immich API. +Note that some type definitions in this file are incorrect with regards to nullability. +The file is big, so prefer to use nushell to query the things you need: +```nu +# List all api paths +open openapi-spec.yaml | get paths | transpose key val | get key + +# Read the details about a particular path +open openapi-spec.yaml | get paths.'/workflows' | to yaml +``` + +### Documentation +- All public items must have doc comments +- Module-level docs explaining purpose +- Example code in docs should compile (use `no_run` or `ignore` if needed) +- Include `# Errors` section for fallible methods + +### Rust Edition Features +- Edition 2024 (use latest idioms) +- Minimum Rust version: 1.85 +- Use `const fn` where possible +- Prefer `impl Into` for string parameters iff the function requires ownership of the String + +## Project Structure + +``` +src/ + lib.rs # Re-exports, module declarations + client.rs # Client struct with Arc + error.rs # Error types with thiserror + models/ # Data models, types + apis/ # API modules (albums, assets, etc.) +examples/ # Usage examples +tests/ # Integration tests +``` + +## Lint Configuration + +From Cargo.toml - DO NOT SUPPRESS THESE: +- `unused_async = "deny"` - No async fn without await +- `wildcard_dependencies = "deny"` - No wildcard deps +- `non_ascii_idents = "forbid"` - ASCII only +- `rust_2018_idioms = "deny"` - Modern Rust idioms + +## Agile agentic workflow + +- You are responsible for high-level architecture. +- You may (and should) read code to understand the architecture. +- You should AVOID doing implementation work and testing. Use the `task` tool to spawn subagents for this. +- You should also use the `task` tool to spawn subagents to for code review. +- Prefer splitting subagent-tasks into small incremental bits of work. +- Subagents must NEVER exit with compilation errors, but warnings due to stubbed implementations (`todo!()`) are fine. + +## Never Do + +- Never use `unsafe` without asking first +- Never ignore clippy warnings (fix them) +- Never suppress warnings with `#[allow(...)]` +- Never use `panic!` or `unwrap()` in library code +- Never break the builder pattern chain +- Never write pointless tests to simply get more code coverage diff --git a/docker/podman-compose.yml b/docker/podman-compose.yml new file mode 100644 index 0000000..f88ceb1 --- /dev/null +++ b/docker/podman-compose.yml @@ -0,0 +1,63 @@ +name: immich + +services: + immich-server: + container_name: immich_server + image: ghcr.io/immich-app/immich-server:v2 + command: ['start.sh', 'immich'] + ports: + - 2283:2283 + environment: + UPLOAD_LOCATION: /usr/src/app/upload + DB_HOSTNAME: postgres + DB_USERNAME: postgres + DB_PASSWORD: postgres + DB_DATABASE_NAME: immich + REDIS_HOSTNAME: redis + DB_PORT: 5432 + volumes: + - uploads:/usr/src/app/upload + depends_on: + postgres: + condition: service_started + redis: + condition: service_started + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:2283/api/server/ping'] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + restart: unless-stopped + + postgres: + container_name: immich_postgres + image: tensorchord/pgvecto-rs:pg16-v0.3.0 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: immich + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres -d immich'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + restart: unless-stopped + + redis: + container_name: immich_redis + image: redis:7-alpine + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + restart: unless-stopped + +volumes: + uploads: + pgdata: diff --git a/examples/album_management.rs b/examples/album_management.rs new file mode 100644 index 0000000..2e89769 --- /dev/null +++ b/examples/album_management.rs @@ -0,0 +1,133 @@ +//! Example: Album management (CRUD operations) +//! +//! This example demonstrates creating, reading, updating, and deleting albums +//! +//! Environment variables: +//! - IMMICH_URL: The Immich server URL (defaults to http://localhost:2283) +//! - IMMICH_API_KEY: Your API key (required) + +use immich_sdk::Client; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Configure the client from environment variables + let url = std::env::var("IMMICH_URL").unwrap_or_else(|_| "http://localhost:2283".to_string()); + let api_key = + std::env::var("IMMICH_API_KEY").expect("IMMICH_API_KEY environment variable not set"); + + // Create a client + let client = Client::from_url(&url)?.with_api_key(api_key); + + println!("=== Album Management Example ===\n"); + + // List all albums + println!("1. Listing all albums..."); + let albums = client.albums().list().execute().await?; + println!(" Found {} albums", albums.len()); + + for album in &albums { + println!( + " - {} (ID: {}, {} assets)", + album.album_name, album.id, album.asset_count + ); + } + println!(); + + // Get details of the first album if one exists + let existing_album = albums.first().cloned(); + if let Some(ref album) = existing_album { + println!("2. Getting album details..."); + let details = client.albums().get(album.id).execute().await?; + println!(" Album: {}", details.album_name); + println!(" Description: {}", details.description); + println!(" Asset count: {}", details.asset_count); + println!(); + } + + // Create a new album + println!("3. Creating a new album..."); + let new_album = client + .albums() + .create() + .name("SDK Test Album") + .execute() + .await?; + println!( + " Created album: {} (ID: {})", + new_album.album_name, new_album.id + ); + println!(); + + // If we have assets, add some to the album + let asset_ids: Vec<_> = existing_album + .as_ref() + .map(|a| a.assets.iter().map(|asset| asset.id).collect()) + .unwrap_or_default(); + + if !asset_ids.is_empty() { + println!("4. Adding assets to the new album..."); + let asset_ids_to_add: Vec<_> = asset_ids.iter().take(2).copied().collect(); + let num_added = asset_ids_to_add.len(); + client + .albums() + .add_assets(new_album.id) + .asset_ids(asset_ids_to_add.clone()) + .execute() + .await?; + println!(" Added {} assets to the album", num_added); + + // Get updated album details + let updated_details = client.albums().get(new_album.id).execute().await?; + println!(" Album now has {} assets", updated_details.asset_count); + println!(); + + // Remove assets from the album + println!("5. Removing assets from the album..."); + client + .albums() + .remove_assets(new_album.id) + .asset_ids(asset_ids_to_add) + .execute() + .await?; + println!(" Removed {} assets from the album", num_added); + + let after_removal = client.albums().get(new_album.id).execute().await?; + println!(" Album now has {} assets", after_removal.asset_count); + println!(); + } else { + println!("4. Skipping asset operations (no existing assets found)"); + println!(); + } + + // Update the album + println!("6. Updating album name and description..."); + let updated = client + .albums() + .update(new_album.id) + .name("Updated SDK Album") + .description("This description was updated by the SDK example") + .execute() + .await?; + println!(" Updated album name: {}", updated.album_name); + println!(" Updated description: {}", updated.description); + println!(); + + // Delete the album + println!("7. Deleting the album..."); + client.albums().delete(new_album.id).execute().await?; + println!(" Album deleted successfully"); + println!(); + + // Verify deletion by listing albums again + println!("8. Verifying deletion..."); + let albums_after = client.albums().list().execute().await?; + let still_exists = albums_after.iter().any(|a| a.id == new_album.id); + if still_exists { + println!(" Warning: Album still exists after deletion attempt"); + } else { + println!(" Confirmed: Album no longer exists"); + } + + println!("\n=== Example completed successfully ==="); + Ok(()) +} diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index 09bf155..197f400 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -1,11 +1,20 @@ //! Basic usage example for immich-sdk +//! +//! This example uses environment variables for configuration: +//! - IMMICH_URL: The Immich server URL (defaults to http://localhost:2283) +//! - IMMICH_API_KEY: Your API key (required) use immich_sdk::Client; #[tokio::main] async fn main() -> Result<(), Box> { + // Configure the client from environment variables + let url = std::env::var("IMMICH_URL").unwrap_or_else(|_| "http://localhost:2283".to_string()); + let api_key = + std::env::var("IMMICH_API_KEY").expect("IMMICH_API_KEY environment variable not set"); + // Create a client - let client = Client::from_url("https://immich.example.com")?.with_api_key("your-api-key"); + let client = Client::from_url(&url)?.with_api_key(api_key); // Get server version let version = client.server().version().await?; diff --git a/examples/delete_assets.rs b/examples/delete_assets.rs new file mode 100644 index 0000000..a4bcebb --- /dev/null +++ b/examples/delete_assets.rs @@ -0,0 +1,67 @@ +//! Example: Delete assets +//! +//! This example demonstrates how to delete assets from Immich. +//! +//! WARNING: This will actually delete photos. Use with caution! +//! +//! This example uses environment variables for configuration: +//! - IMMICH_URL: The Immich server URL (defaults to http://localhost:2283) +//! - IMMICH_API_KEY: Your API key (required) +//! +//! IMPORTANT: Run this against a test instance only. This operation permanently +//! removes assets from the server. + +use immich_sdk::Client; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Configure the client from environment variables + let url = std::env::var("IMMICH_URL").unwrap_or_else(|_| "http://localhost:2283".to_string()); + let api_key = + std::env::var("IMMICH_API_KEY").expect("IMMICH_API_KEY environment variable not set"); + + // Create a client + let client = Client::from_url(&url)?.with_api_key(api_key); + + // List existing assets + let assets = client.assets().list().execute().await?; + println!("Found {} assets", assets.len()); + + if assets.is_empty() { + println!("No assets to delete. Run upload_photos first."); + return Ok(()); + } + + // Get the first asset details + let first_id = assets[0].id; + println!("Getting details for asset: {}", first_id); + + let asset = client.assets().get(first_id).execute().await?; + println!("Asset: {} ({})", asset.original_file_name, asset.id); + + // Delete a single asset + println!("\nDeleting single asset..."); + client.assets().delete().id(first_id).execute().await?; + println!("Asset deleted successfully"); + + // Batch delete (if there are more assets) + if assets.len() > 1 { + println!("\nBatch deleting remaining assets..."); + let ids_to_delete: Vec<_> = assets.iter().skip(1).take(2).map(|a| a.id).collect(); + + client + .assets() + .delete() + .ids(ids_to_delete) + .execute() + .await?; + + println!("Batch delete completed"); + } + + // Verify deletion + let remaining = client.assets().list().execute().await?; + println!("\nRemaining assets: {}", remaining.len()); + + Ok(()) +} diff --git a/examples/download_asset.rs b/examples/download_asset.rs index 9473765..79d8f63 100644 --- a/examples/download_asset.rs +++ b/examples/download_asset.rs @@ -1,15 +1,29 @@ //! Example: Download an asset from Immich +//! +//! This example uses environment variables for configuration: +//! - IMMICH_URL: The Immich server URL (defaults to http://localhost:2283) +//! - IMMICH_API_KEY: Your API key (required) use immich_sdk::Client; use std::fs; #[tokio::main] async fn main() -> Result<(), Box> { - // Create a client - let client = Client::from_url("https://immich.example.com")?.with_api_key("your-api-key"); + // Configure the client from environment variables + let url = std::env::var("IMMICH_URL").unwrap_or_else(|_| "http://localhost:2283".to_string()); + let api_key = + std::env::var("IMMICH_API_KEY").expect("IMMICH_API_KEY environment variable not set"); - // Asset ID to download (replace with a real asset ID) - let asset_id = "your-asset-id-here".parse()?; + // Create a client + let client = Client::from_url(&url)?.with_api_key(api_key); + + // Get first asset from the server + let assets = client.assets().list().execute().await?; + if assets.is_empty() { + println!("No assets found. Run upload_photos example first."); + return Ok(()); + } + let asset_id = assets[0].id; // Download the asset println!("Downloading asset..."); diff --git a/examples/search_metadata.rs b/examples/search_metadata.rs new file mode 100644 index 0000000..03ba6b9 --- /dev/null +++ b/examples/search_metadata.rs @@ -0,0 +1,224 @@ +//! Example: Search for assets using metadata filters +//! +//! This example demonstrates the powerful search capabilities of Immich +//! +//! This example uses environment variables for configuration: +//! - IMMICH_URL: The Immich server URL (defaults to http://localhost:2283) +//! - IMMICH_API_KEY: Your API key (required) + +use chrono::Duration; +use chrono::Utc; +use immich_sdk::Client; +use immich_sdk::models::AssetOrder; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Configure the client from environment variables + let url = std::env::var("IMMICH_URL").unwrap_or_else(|_| "http://localhost:2283".to_string()); + let api_key = + std::env::var("IMMICH_API_KEY").expect("IMMICH_API_KEY environment variable not set"); + + // Create a client + let client = Client::from_url(&url)?.with_api_key(api_key); + + // Get total asset count + let all_results = client.search().metadata().size(1).execute().await?; + println!("Total assets in library: {}", all_results.assets.total); + println!(); + + // ================================================================= + // Example 1: Search for favorite assets + // ================================================================= + println!("=== Example 1: Search for favorites ==="); + let favorites = client.search().metadata().favorite(true).execute().await?; + println!("Found {} favorite assets", favorites.assets.total); + + if favorites.assets.items.is_empty() { + println!("No favorite assets found. Try marking some photos as favorites in Immich."); + } else { + for asset in favorites.assets.items.iter().take(3) { + println!(" - {}: {}", asset.id, asset.original_file_name); + } + } + println!(); + + // ================================================================= + // Example 2: Search by asset type (images only) + // ================================================================= + println!("=== Example 2: Search for images only ==="); + let images = client + .search() + .metadata() + .asset_type(immich_sdk::models::AssetType::Image) + .size(5) + .execute() + .await?; + println!("Found {} image assets", images.assets.total); + + for asset in &images.assets.items { + println!(" - {}", asset.original_file_name); + } + println!(); + + // ================================================================= + // Example 3: Search by date range + // ================================================================= + // Search for assets created in the last 30 days + println!("=== Example 3: Search by date range (last 30 days) ==="); + let thirty_days_ago = Utc::now() - Duration::days(30); + let recent = client + .search() + .metadata() + .created_after(thirty_days_ago) + .execute() + .await?; + println!( + "Found {} assets created in the last 30 days", + recent.assets.total + ); + + if !recent.assets.items.is_empty() { + println!("Recent assets:"); + for asset in recent.assets.items.iter().take(3) { + println!( + " - {} (created: {})", + asset.original_file_name, + asset.created_at.format("%Y-%m-%d") + ); + } + } + println!(); + + // ================================================================= + // Example 4: Search by city (if photos have location data) + // ================================================================= + println!("=== Example 4: Search by city (location-based) ==="); + let city_results = client + .search() + .metadata() + .city("New York") + .size(10) + .execute() + .await?; + + if city_results.assets.total > 0 { + println!("Found {} assets from New York", city_results.assets.total); + for asset in &city_results.assets.items { + println!(" - {}", asset.original_file_name); + } + } else { + println!("No assets found from New York. Try searching for photos with GPS location data."); + } + println!(); + + // ================================================================= + // Example 5: Combined filters (favorites + date range) + // ================================================================= + println!("=== Example 5: Combined filters (favorites + recent) ==="); + let recent_favorites = client + .search() + .metadata() + .favorite(true) + .created_after(thirty_days_ago) + .execute() + .await?; + println!( + "Found {} favorite assets from the last 30 days", + recent_favorites.assets.total + ); + + if !recent_favorites.assets.items.is_empty() { + for asset in &recent_favorites.assets.items { + println!(" - {}", asset.original_file_name); + } + } + println!(); + + // ================================================================= + // Example 6: Search with pagination + // ================================================================= + println!("=== Example 6: Pagination ==="); + println!("Retrieving results with pagination (page 1, size 2)..."); + + let page1 = client + .search() + .metadata() + .page(1) + .size(2) + .order(AssetOrder::Desc) + .execute() + .await?; + + println!( + "Page 1: {} of {} assets", + page1.assets.count, page1.assets.total + ); + for asset in &page1.assets.items { + println!(" - {}", asset.original_file_name); + } + + // Check if there are more pages + if page1.assets.next_page.is_some() { + println!("More results available on next page!"); + + // Fetch page 2 + let page2 = client + .search() + .metadata() + .page(2) + .size(2) + .order(AssetOrder::Desc) + .execute() + .await?; + + println!( + "\nPage 2: {} of {} assets", + page2.assets.count, page2.assets.total + ); + for asset in &page2.assets.items { + println!(" - {}", asset.original_file_name); + } + } else if page1.assets.total > 0 { + println!("No more pages available."); + } + println!(); + + // ================================================================= + // Example 7: Search with ordering + // ================================================================= + println!("=== Example 7: Search with ordering (oldest first) ==="); + let oldest = client + .search() + .metadata() + .order(AssetOrder::Asc) + .size(5) + .execute() + .await?; + + println!("Oldest {} assets:", oldest.assets.items.len()); + for asset in &oldest.assets.items { + println!( + " - {} (created: {})", + asset.original_file_name, + asset.created_at.format("%Y-%m-%d") + ); + } + println!(); + + // ================================================================= + // Summary + // ================================================================= + println!("=== Search Summary ==="); + println!("Demonstrated search filters:"); + println!(" - Favorite status (is_favorite)"); + println!(" - Asset type (IMAGE/VIDEO)"); + println!(" - Date range (created_after, created_before)"); + println!(" - Location (city, country)"); + println!(" - Combined filters"); + println!(" - Pagination (page, size)"); + println!(" - Ordering (asc/desc)"); + println!(); + println!("Total assets in library: {}", all_results.assets.total); + + Ok(()) +} diff --git a/examples/server_info.rs b/examples/server_info.rs new file mode 100644 index 0000000..02ea818 --- /dev/null +++ b/examples/server_info.rs @@ -0,0 +1,43 @@ +//! Example: Server information +//! +//! This example demonstrates how to check server health and capabilities + +use immich_sdk::Client; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Configure the client from environment variables + let url = std::env::var("IMMICH_URL").unwrap_or_else(|_| "http://localhost:2283".to_string()); + let api_key = + std::env::var("IMMICH_API_KEY").expect("IMMICH_API_KEY environment variable not set"); + + let client = Client::from_url(&url)?.with_api_key(api_key); + + // Health check (ping) + println!("Checking server health..."); + match client.server().ping().await { + Ok(response) => println!("Server is healthy! Response: {}", response), + Err(e) => println!("Health check failed: {}", e), + } + + // Get version + println!("\nServer Version:"); + let version = client.server().version().await?; + println!(" Version: {}", version); + + // Get features + println!("\nServer Features:"); + let features = client.server().features().await?; + println!(" OAuth enabled: {}", features.oauth); + println!(" OAuth auto launch: {}", features.oauth_auto_launch); + println!(" Password login: {}", features.password_login); + println!(" Config file: {}", features.config_file); + + // Get about info + println!("\nServer About:"); + let about = client.server().about().await?; + println!(" Version: {}", about.version); + println!(" Version URL: {}", about.version_url); + + Ok(()) +} diff --git a/examples/thumbnail.rs b/examples/thumbnail.rs index 1717622..3bb742c 100644 --- a/examples/thumbnail.rs +++ b/examples/thumbnail.rs @@ -1,4 +1,8 @@ //! Example: Download asset thumbnails +//! +//! This example uses environment variables for configuration: +//! - IMMICH_URL: The Immich server URL (defaults to http://localhost:2283) +//! - IMMICH_API_KEY: Your API key (required) use image::GenericImageView; use immich_sdk::Client; @@ -7,11 +11,21 @@ use std::fs; #[tokio::main] async fn main() -> Result<(), Box> { - // Create a client - let client = Client::from_url("https://immich.example.com")?.with_api_key("your-api-key"); + // Configure the client from environment variables + let url = std::env::var("IMMICH_URL").unwrap_or_else(|_| "http://localhost:2283".to_string()); + let api_key = + std::env::var("IMMICH_API_KEY").expect("IMMICH_API_KEY environment variable not set"); - // Asset ID to download thumbnail for - let asset_id = "your-asset-id-here".parse()?; + // Create a client + let client = Client::from_url(&url)?.with_api_key(api_key); + + // Get first asset from the server + let assets = client.assets().list().execute().await?; + if assets.is_empty() { + println!("No assets found. Run upload_photos example first."); + return Ok(()); + } + let asset_id = assets[0].id; // Download thumbnail (small size, default) println!("Downloading thumbnail..."); diff --git a/examples/timeline_browsing.rs b/examples/timeline_browsing.rs new file mode 100644 index 0000000..6a4dd4d --- /dev/null +++ b/examples/timeline_browsing.rs @@ -0,0 +1,127 @@ +//! Example: Timeline browsing +//! +//! This example demonstrates Immich's unique time-based asset organization. +//! Immich groups photos by date into "time buckets", allowing efficient +//! browsing of large photo collections chronologically. +//! +//! Environment variables: +//! - IMMICH_URL: The Immich server URL (defaults to http://localhost:2283) +//! - IMMICH_API_KEY: Your API key (required) + +use immich_sdk::Client; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Configure the client from environment variables + let url = std::env::var("IMMICH_URL").unwrap_or_else(|_| "http://localhost:2283".to_string()); + let api_key = + std::env::var("IMMICH_API_KEY").expect("IMMICH_API_KEY environment variable not set"); + + // Create a client + let client = Client::from_url(&url)?.with_api_key(api_key); + + // Get all time buckets + // Time buckets are date-based groupings (e.g., "2024-01-15") + let buckets = client.timeline().buckets().execute().await?; + println!("Found {} time buckets", buckets.len()); + + if buckets.is_empty() { + println!("No time buckets found. Upload some photos first!"); + return Ok(()); + } + + // Show first few buckets + println!("\n--- First 5 Time Buckets ---"); + for (i, bucket) in buckets.iter().take(5).enumerate() { + println!( + "Bucket {}: {} - {} assets", + i + 1, + bucket.time_bucket, + bucket.count + ); + } + + // Get assets from the first bucket + if let Some(first_bucket) = buckets.first() { + println!( + "\n--- Fetching assets from bucket: {} ---", + first_bucket.time_bucket + ); + + let assets = client + .timeline() + .bucket(&first_bucket.time_bucket) + .execute() + .await?; + + println!("Found {} assets in this bucket", assets.id.len()); + + // The TimeBucketAssetResponse contains parallel arrays + // This is a memory-efficient design where each field is a column: + // - assets.id[i], assets.file_created_at[i], etc. all refer to the same asset + // - All arrays have the same length + println!("\n--- Sample assets from this bucket (showing up to 5) ---"); + for i in 0..assets.id.len().min(5) { + println!(" Asset {}:", i + 1); + println!(" ID: {}", assets.id[i]); + println!(" Created: {}", assets.file_created_at[i]); + println!(" Is Image: {}", assets.is_image[i]); + println!(" Is Favorite: {}", assets.is_favorite[i]); + println!(" Owner ID: {}", assets.owner_id[i]); + + // Optional fields (may be None) + if let Some(ref durations) = assets.duration { + if let Some(duration) = &durations[i] { + println!(" Duration: {}", duration); + } + } + if let Some(ref cities) = assets.city { + if let Some(city) = &cities[i] { + println!(" City: {}", city); + } + } + if let Some(ref countries) = assets.country { + if let Some(country) = &countries[i] { + println!(" Country: {}", country); + } + } + } + + // Demonstrate iterating through parallel arrays + println!("\n--- Understanding the parallel array structure ---"); + println!("The response contains these parallel arrays (all same length):"); + println!(" - id.len() = {}", assets.id.len()); + println!( + " - file_created_at.len() = {}", + assets.file_created_at.len() + ); + println!(" - is_image.len() = {}", assets.is_image.len()); + println!(" - is_favorite.len() = {}", assets.is_favorite.len()); + println!(" - owner_id.len() = {}", assets.owner_id.len()); + println!("\nIndex 'i' corresponds to the same asset across all arrays."); + println!("This columnar structure is memory-efficient for large collections."); + } + + // Demonstrate filtering with the timeline API + println!("\n--- Timeline API supports filtering ---"); + println!("You can filter time buckets with:"); + println!(" - album_id: Filter by specific album"); + println!(" - is_favorite: Show only favorited assets"); + println!(" - is_trashed: Include/exclude trashed assets"); + println!(" - visibility: Timeline, Archive, Hidden, or Locked"); + println!(" - with_partners: Include assets shared by partners"); + + // Example: Get only favorite buckets + let favorite_buckets = client + .timeline() + .buckets() + .is_favorite(true) + .execute() + .await?; + println!( + "\nFound {} time buckets with favorite assets", + favorite_buckets.len() + ); + + Ok(()) +} diff --git a/examples/upload_photos.rs b/examples/upload_photos.rs index c7e9f0c..7efc591 100644 --- a/examples/upload_photos.rs +++ b/examples/upload_photos.rs @@ -1,15 +1,38 @@ //! Example: Upload photos to Immich +//! +//! This example uses environment variables for configuration: +//! - IMMICH_URL: The Immich server URL (defaults to http://localhost:2283) +//! - IMMICH_API_KEY: Your API key (required) +//! +//! It also reads photos from test-data/sample-photos/ directory. use immich_sdk::Client; -use std::path::Path; #[tokio::main] async fn main() -> Result<(), Box> { - // Create a client - let client = Client::from_url("https://immich.example.com")?.with_api_key("your-api-key"); + // Configure the client from environment variables + let url = std::env::var("IMMICH_URL").unwrap_or_else(|_| "http://localhost:2283".to_string()); + let api_key = + std::env::var("IMMICH_API_KEY").expect("IMMICH_API_KEY environment variable not set"); - // Path to the photo - let photo_path = Path::new("/path/to/your/photo.jpg"); + // Create a client + let client = Client::from_url(&url)?.with_api_key(api_key); + + // Use test data photos + let photo_dir = std::path::Path::new("test-data/sample-photos"); + let photos: Vec<_> = std::fs::read_dir(photo_dir)? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().map(|e| e == "jpg").unwrap_or(false)) + .collect(); + + if photos.is_empty() { + println!("No photos found in test-data/sample-photos/"); + return Ok(()); + } + + // Upload first photo + let photo_path = &photos[0]; // Upload the photo println!("Uploading: {}", photo_path.display()); diff --git a/scripts/run-example.sh b/scripts/run-example.sh new file mode 100755 index 0000000..4bd5530 --- /dev/null +++ b/scripts/run-example.sh @@ -0,0 +1,42 @@ +#!/bin/bash +set -e + +EXAMPLE=$1 + +if [ -z "$EXAMPLE" ]; then + echo "Usage: $0 " + echo "Available examples:" + ls examples/*.rs | sed 's/examples\///' | sed 's/\.rs$//' | sed 's/^/ - /' + exit 1 +fi + +# Check if example exists +if [ ! -f "examples/${EXAMPLE}.rs" ]; then + echo "Error: Example 'examples/${EXAMPLE}.rs' not found" + exit 1 +fi + +cd "$(dirname "$0")/.." + +# Start immich if not running +if ! curl -s http://localhost:2283/api/server/ping > /dev/null 2>&1; then + echo "Immich is not running. Starting..." + ./scripts/start-immich.sh +fi + +# Seed data if not already done +if [ ! -f .seeded ]; then + echo "Seeding test data..." + ./scripts/seed-data.sh + touch .seeded +fi + +# Load environment variables +# set -a exports all variables defined from here on +set -a +source .env.test +set +a + +# Run the example +echo "Running example: $EXAMPLE" +cargo run --example "$EXAMPLE" diff --git a/scripts/seed-data.sh b/scripts/seed-data.sh new file mode 100755 index 0000000..2e1d9c7 --- /dev/null +++ b/scripts/seed-data.sh @@ -0,0 +1,175 @@ +#!/bin/bash +# +# Seed data script for Immich SDK testing +# Uploads sample photos to an Immich test instance +# + +set -e + +# Change to script directory +cd "$(dirname "$0")/.." + +# Load environment variables from .env.test if it exists +if [ -f ".env.test" ]; then + echo "Loading environment from .env.test" + source .env.test +fi + +# Check required environment variables +if [ -z "$IMMICH_URL" ]; then + echo "Error: IMMICH_URL environment variable is not set" + echo "Please set it or create a .env.test file with:" + echo " IMMICH_URL=http://localhost:2283" + echo " IMMICH_API_KEY=your-api-key" + exit 1 +fi + +if [ -z "$IMMICH_API_KEY" ]; then + echo "Error: IMMICH_API_KEY environment variable is not set" + echo "Please set it or create a .env.test file with:" + echo " IMMICH_URL=http://localhost:2283" + echo " IMMICH_API_KEY=your-api-key" + exit 1 +fi + +PHOTO_DIR="test-data/sample-photos" + +# Check if photo directory exists +if [ ! -d "$PHOTO_DIR" ]; then + echo "Warning: Photo directory $PHOTO_DIR does not exist" + echo "Creating directory..." + mkdir -p "$PHOTO_DIR" + echo "Please add sample .jpg files to $PHOTO_DIR and run again" + exit 0 +fi + +# Count jpg files +jpg_count=$(find "$PHOTO_DIR" -name "*.jpg" -type f 2>/dev/null | wc -l) + +if [ "$jpg_count" -eq 0 ]; then + echo "Warning: No .jpg files found in $PHOTO_DIR" + echo "Please add sample .jpg files and run again" + exit 0 +fi + +echo "======================================" +echo "Immich Seed Data Script" +echo "======================================" +echo "Target: $IMMICH_URL" +echo "Photos to upload: $jpg_count" +echo "======================================" +echo "" + +# Array to store uploaded asset IDs +declare -a asset_ids + +# Upload photos +for photo in "$PHOTO_DIR"/*.jpg; do + [ -f "$photo" ] || continue + + filename=$(basename "$photo") + device_asset_id=$(basename "$photo" .jpg) + + echo -n "Uploading $filename... " + + # Upload with asset data and capture response + response=$(curl -s -X POST "$IMMICH_URL/api/assets" \ + -H "x-api-key: $IMMICH_API_KEY" \ + -F "assetData=@$photo" \ + -F "deviceAssetId=$device_asset_id" \ + -F "deviceId=test-device" \ + -F "fileCreatedAt=2024-01-15T10:00:00.000Z" \ + -F "fileModifiedAt=2024-01-15T10:00:00.000Z" \ + -H "Accept: application/json" 2>/dev/null || true) + + # Extract asset ID from response if possible + asset_id=$(echo "$response" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4 || true) + + if [ -n "$asset_id" ]; then + asset_ids+=("$asset_id") + echo "OK (ID: ${asset_id:0:8}...)" + else + echo "OK" + fi +done + +echo "" +echo "======================================" +echo "Upload Summary" +echo "======================================" +echo "Photos uploaded: $jpg_count" + +# Create album and add assets if we have asset IDs +if [ ${#asset_ids[@]} -gt 0 ]; then + echo "" + echo "Creating album..." + + # Create album + album_response=$(curl -s -X POST "$IMMICH_URL/api/albums" \ + -H "x-api-key: $IMMICH_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"albumName":"SDK Test Album"}' 2>/dev/null || true) + + album_id=$(echo "$album_response" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4 || true) + + if [ -n "$album_id" ]; then + echo "Album created: SDK Test Album (ID: ${album_id:0:8}...)" + + # Add assets to album + echo "Adding photos to album..." + + # Build JSON array of asset IDs + asset_json="[" + first=true + for id in "${asset_ids[@]}"; do + if [ "$first" = true ]; then + first=false + else + asset_json+="," + fi + asset_json+="\"$id\"" + done + asset_json+="]" + + add_response=$(curl -s -X PUT "$IMMICH_URL/api/albums/$album_id/assets" \ + -H "x-api-key: $IMMICH_API_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"ids\":$asset_json}" 2>/dev/null || true) + + echo "Added ${#asset_ids[@]} photos to album" + + # Mark first photo as favorite + if [ ${#asset_ids[@]} -gt 0 ]; then + echo "" + echo "Marking first photo as favorite..." + + curl -s -X PUT "$IMMICH_URL/api/assets" \ + -H "x-api-key: $IMMICH_API_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"ids\":[\"${asset_ids[0]}\"],\"isFavorite\":true}" \ + 2>/dev/null > /dev/null || true + + echo "Marked as favorite" + fi + else + echo "Warning: Could not create album (album ID not found in response)" + fi +else + echo "Warning: No asset IDs captured - skipping album creation" +fi + +echo "" +echo "======================================" +echo "Seed complete!" +echo "======================================" +echo "Server: $IMMICH_URL" +echo "Photos uploaded: $jpg_count" +if [ ${#asset_ids[@]} -gt 0 ]; then + echo "Album: SDK Test Album" + echo "Favorites: 1" +fi +echo "" +echo "You can now run examples against this server:" +echo " cargo run --example list_assets" +echo " cargo run --example list_albums" +echo "======================================" diff --git a/scripts/start-immich.sh b/scripts/start-immich.sh new file mode 100755 index 0000000..43ac03d --- /dev/null +++ b/scripts/start-immich.sh @@ -0,0 +1,121 @@ +#!/bin/bash +set -e + +# Script to start Immich containers, create admin user, and generate API key +# This script is used for integration testing + +cd "$(dirname "$0")/.." + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Check dependencies +check_dependency() { + if ! command -v "$1" &> /dev/null; then + echo -e "${RED}Error: $1 is not installed${NC}" + exit 1 + fi +} + +echo "Checking dependencies..." +check_dependency "podman" +check_dependency "curl" +check_dependency "jq" + +# Check if podman-compose is available +if ! podman compose version &> /dev/null && ! command -v podman-compose &> /dev/null; then + echo -e "${RED}Error: podman-compose is not available${NC}" + echo "Please install podman-compose or ensure 'podman compose' is available" + exit 1 +fi + +# Start containers +echo "Starting Immich containers..." +podman compose -f docker/podman-compose.yml up -d + +# Wait for health check (max 120s) +echo "Waiting for Immich to be ready..." +READY=false +for i in {1..60}; do + if curl -s http://localhost:2283/api/server/ping > /dev/null 2>&1; then + echo -e "${GREEN}Immich is ready!${NC}" + READY=true + break + fi + echo "Attempt $i/60 - waiting for Immich..." + sleep 2 +done + +if [ "$READY" = false ]; then + echo -e "${RED}Error: Immich failed to become ready within 120 seconds${NC}" + exit 1 +fi + +# Admin credentials +ADMIN_EMAIL="admin@example.com" +ADMIN_PASSWORD="admin123" +ADMIN_NAME="Admin User" + +# Create admin user +echo "Creating admin user..." +curl -s -X POST http://localhost:2283/api/auth/admin-sign-up \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASSWORD\",\"name\":\"$ADMIN_NAME\"}" \ + || echo -e "${YELLOW}Admin may already exist, continuing...${NC}" + +# Login to get access token +echo "Logging in to get access token..." +LOGIN_RESPONSE=$(curl -s -X POST http://localhost:2283/api/auth/login \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASSWORD\"}") + +ACCESS_TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.accessToken') + +if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then + echo -e "${RED}Error: Failed to get access token${NC}" + echo "Response: $LOGIN_RESPONSE" + exit 1 +fi + +# Create API key +echo "Creating API key..." +API_KEY_RESPONSE=$(curl -s -X POST http://localhost:2283/api/api-keys \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -d '{"name":"sdk-test-key","permissions":["all"]}') + +API_KEY=$(echo "$API_KEY_RESPONSE" | jq -r '.secret') + +if [ -z "$API_KEY" ] || [ "$API_KEY" = "null" ]; then + echo -e "${RED}Error: Failed to create API key${NC}" + echo "Response: $API_KEY_RESPONSE" + exit 1 +fi + +# Save to .env.test +echo "Saving credentials to .env.test..." +cat > .env.test << EOF +IMMICH_URL=http://localhost:2283 +IMMICH_API_KEY=$API_KEY +EOF + +# Success message +echo -e "${GREEN}=======================================${NC}" +echo -e "${GREEN}Immich setup complete!${NC}" +echo -e "${GREEN}=======================================${NC}" +echo "" +echo "Credentials saved to .env.test:" +echo " IMMICH_URL: http://localhost:2283" +echo " IMMICH_API_KEY: ${API_KEY:0:10}... (truncated)" +echo "" +echo "Admin user:" +echo " Email: $ADMIN_EMAIL" +echo " Password: $ADMIN_PASSWORD" +echo "" +echo "You can now access Immich at: http://localhost:2283" +echo "" +echo "To stop the containers, run:" +echo " podman compose -f docker/podman-compose.yml down" diff --git a/scripts/stop-immich.sh b/scripts/stop-immich.sh new file mode 100755 index 0000000..147658d --- /dev/null +++ b/scripts/stop-immich.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +cd "$(dirname "$0")/.." + +echo "Stopping Immich containers and destroying volumes..." +podman compose -f docker/podman-compose.yml down -v + +# Clean up state files +rm -f .env.test .seeded + +echo "Immich stopped and cleaned up." diff --git a/src/apis/assets.rs b/src/apis/assets.rs index bcbe01e..f31d819 100644 --- a/src/apis/assets.rs +++ b/src/apis/assets.rs @@ -6,7 +6,7 @@ use std::path::Path; use crate::{ Client, error::{ImmichError, Result}, - models::{AssetId, AssetMediaSize, AssetResponse, AssetUploadResponse, DeleteAssetsRequest}, + models::{AssetId, AssetMediaSize, AssetResponse, AssetUploadResponse, DeleteAssetsRequest, MetadataSearchRequest, SearchResponse}, }; /// Response from downloading a thumbnail containing image data and metadata @@ -160,24 +160,26 @@ impl ListAssetsBuilder { /// Execute the request pub async fn execute(self) -> Result> { - let mut req = self.client.get("/assets"); + let req = self.client.post("/search/metadata"); + + let mut body = MetadataSearchRequest::default(); if let Some(album_id) = self.album_id { - req = req.query(&[("albumId", album_id.to_string())]); + body.album_ids = Some(vec![album_id]); } if let Some(is_favorite) = self.is_favorite { - req = req.query(&[("isFavorite", is_favorite.to_string())]); + body.is_favorite = Some(is_favorite); } if let Some(is_trashed) = self.is_trashed { - req = req.query(&[("isTrashed", is_trashed.to_string())]); + body.with_deleted = Some(is_trashed); } - let response = self.client.execute(req.build()?).await?; - let assets: Vec = response.json().await?; + let response = self.client.execute(req.json(&body).build()?).await?; + let search_result: SearchResponse = response.json().await?; - Ok(assets) + Ok(search_result.assets.items) } } diff --git a/test-data/.gitkeep b/test-data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test-data/sample-photos/README.md b/test-data/sample-photos/README.md new file mode 100644 index 0000000..a14a3c4 --- /dev/null +++ b/test-data/sample-photos/README.md @@ -0,0 +1,37 @@ +# Sample Photos + +This directory contains sample photos for testing the Immich SDK. + +## Photos + +### photo1.jpg +- **Source**: Unsplash +- **Photographer**: [Yannick Pulver](https://unsplash.com/@yanu) +- **URL**: https://unsplash.com/photos/photo-1506905925346-21bda4d32df4 +- **License**: Unsplash License (Free to use) +- **Description**: Swiss Alps mountain landscape + +### photo2.jpg +- **Source**: Unsplash +- **Photographer**: [Manja Vitolic](https://unsplash.com/@madhatterzone) +- **URL**: https://unsplash.com/photos/photo-1514888286974-6c03e2ca1dba +- **License**: Unsplash License (Free to use) +- **Description**: Cat portrait + +### photo3.jpg +- **Source**: Unsplash +- **Photographer**: [Pedro Lastra](https://unsplash.com/@peterlaster) +- **URL**: https://unsplash.com/photos/photo-1477959858617-67f85cf4f1df +- **License**: Unsplash License (Free to use) +- **Description**: Chicago cityscape aerial view + +## License Information + +All photos sourced from Unsplash are available under the [Unsplash License](https://unsplash.com/license): +- Free to use for commercial and non-commercial purposes +- No permission needed (though attribution is appreciated) +- Cannot be sold without significant modification + +## Usage + +These photos are intended for development and testing purposes only. diff --git a/test-data/sample-photos/photo1.jpg b/test-data/sample-photos/photo1.jpg new file mode 100644 index 0000000..de7378e Binary files /dev/null and b/test-data/sample-photos/photo1.jpg differ diff --git a/test-data/sample-photos/photo2.jpg b/test-data/sample-photos/photo2.jpg new file mode 100644 index 0000000..e419b67 Binary files /dev/null and b/test-data/sample-photos/photo2.jpg differ diff --git a/test-data/sample-photos/photo3.jpg b/test-data/sample-photos/photo3.jpg new file mode 100644 index 0000000..b7b84fe Binary files /dev/null and b/test-data/sample-photos/photo3.jpg differ