Add integration testing infrastructure with Podman Compose

- Add docker/podman-compose.yml for Immich server (no ML)
- Add test-data/sample-photos/ with 3 public domain images
- Add scripts/start-immich.sh for container startup + admin/API key creation
- Add scripts/seed-data.sh for uploading test photos and creating albums
- Add scripts/stop-immich.sh for cleanup with volume destruction
- Add scripts/run-example.sh wrapper (fixed env var export)
- Update examples to use IMMICH_URL and IMMICH_API_KEY env vars
- Add .github/workflows/integration-test.yml for CI
- Update assets.list() to use /search/metadata endpoint
- Update AGENTS.md with example running instructions
- Add 5 new examples: search_metadata, album_management, timeline_browsing,
  delete_assets, server_info
This commit is contained in:
Joakim Hulthe
2026-04-14 19:56:41 +00:00
parent c55d2b9080
commit 2e7db3b35a
22 changed files with 1327 additions and 22 deletions

51
.github/workflows/integration-test.yml vendored Normal file
View File

@@ -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

148
AGENTS.md Normal file
View File

@@ -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 <test_name> # 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 <example_name>
```
**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 <name>`
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<T>` 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<ClientInner>`
- 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<String>` 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<ClientInner>
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

63
docker/podman-compose.yml Normal file
View File

@@ -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:

View File

@@ -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<dyn std::error::Error>> {
// 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(())
}

View File

@@ -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<dyn std::error::Error>> {
// 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?;

67
examples/delete_assets.rs Normal file
View File

@@ -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<dyn std::error::Error>> {
// 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(())
}

View File

@@ -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<dyn std::error::Error>> {
// 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...");

224
examples/search_metadata.rs Normal file
View File

@@ -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<dyn std::error::Error>> {
// 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(())
}

43
examples/server_info.rs Normal file
View File

@@ -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<dyn std::error::Error>> {
// 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(())
}

View File

@@ -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<dyn std::error::Error>> {
// 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...");

View File

@@ -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<dyn std::error::Error>> {
// 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(())
}

View File

@@ -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<dyn std::error::Error>> {
// 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());

42
scripts/run-example.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
set -e
EXAMPLE=$1
if [ -z "$EXAMPLE" ]; then
echo "Usage: $0 <example_name>"
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"

175
scripts/seed-data.sh Executable file
View File

@@ -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 "======================================"

121
scripts/start-immich.sh Executable file
View File

@@ -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"

12
scripts/stop-immich.sh Executable file
View File

@@ -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."

View File

@@ -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<Vec<AssetResponse>> {
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<AssetResponse> = 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)
}
}

0
test-data/.gitkeep Normal file
View File

View File

@@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB