Compare commits

..

8 Commits

Author SHA1 Message Date
075bc855fd Add auth and oauth endpoints
Some checks failed
Integration Tests / integration-test (pull_request) Failing after 1s
2026-05-05 06:08:00 +00:00
Joakim Hulthe
17009cae28 Tweak AGENTS.md
Some checks failed
Integration Tests / integration-test (push) Failing after 2s
2026-04-14 20:32:50 +00:00
Joakim Hulthe
c39e0a5058 Fix examples and SDK for Immich v2 API compatibility
Some checks failed
Integration Tests / integration-test (push) Failing after 6s
- Fix AssetUploadStatus serialization to use snake_case (created/duplicate/rejected)
- Fix AssetOrder serialization to use lowercase (asc/desc)
- Add file_created_at/file_modified_at to UploadAssetBuilder
- Fix AddAssetsBuilder response type to Vec<Value> instead of AlbumResponse
- Fix ServerAbout model - version is String not ServerVersion struct
- Update upload_photos.rs to handle duplicate responses correctly
- Update server_info.rs to display new ServerAbout fields
- Update AGENTS.md with new example list
2026-04-14 20:18:41 +00:00
Joakim Hulthe
2e7db3b35a 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
2026-04-14 20:13:30 +00:00
Joakim Hulthe
c55d2b9080 Add search/metadata API endpoint
- Add MetadataSearchRequest, SearchResponse, SearchAssetResult,
  SearchAlbumResult, SearchFacet, SearchFacetCount models
- Create SearchApi with SearchMetadataBuilder supporting 35+ filters
- Support filtering by location, dates, camera info, favorites,
  tags, people, albums, text search, and more
- Integrate into Client with client.search().metadata() API
2026-04-14 19:35:06 +00:00
Joakim Hulthe
3284a18dcb Add OpenAPI specification 2026-04-14 15:38:12 +00:00
Joakim Hulthe
6820dd765d Wrap Client internals in Arc for cheap cloning 2026-04-14 15:35:57 +00:00
Joakim Hulthe
0d8042287c cargo fmt 2026-04-14 14:37:17 +00:00
31 changed files with 19322 additions and 72 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

140
AGENTS.md Normal file
View File

@@ -0,0 +1,140 @@
# 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>
```
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>` (Important!)
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...");

52
examples/oauth_login.rs Normal file
View File

@@ -0,0 +1,52 @@
use immich_sdk::{Client, Config};
use std::time::Duration;
use immich_sdk::models::{OAuthConfigDto, OAuthCallbackDto};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = Config::new("http://localhost:2283")
.with_api_key("your-api-key")
.with_timeout(Duration::from_secs(30));
let client = Client::new(config)?;
println!("Starting OAuth authorization process...");
// 1. Start OAuth authorization
// In a real scenario, this URL would be opened in a browser.
let auth_config = OAuthConfigDto {
redirect_uri: "http://localhost:8080/callback".to_string(),
code_challenge: None,
state: Some("random_state_string".to_string()),
};
let auth_response = client
.oauth()
.authorize(auth_config)
.await?;
let auth_url = auth_response.url;
println!("Please visit this URL to authorize: {}", auth_url);
// 2. Simulate the callback from the OAuth provider
// In a real scenario, your web server would receive this POST request.
let callback_data = OAuthCallbackDto {
url: "http://localhost:8080/callback".to_string(),
state: Some("random_state_string".to_string()),
code_verifier: Some("some_verifier".to_string()),
};
println!("Simulating OAuth callback with: {:?}", callback_data);
// 3. Finish OAuth process by exchanging the code for a session token
let login_response = client
.oauth()
.finish_oauth(callback_data)
.await?;
println!("Successfully logged in!");
println!("Access Token: {}", login_response.access_token);
println!("User ID: {}", login_response.user_id);
println!("User Email: {}", login_response.user_email);
Ok(())
}

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(())
}

50
examples/server_info.rs Normal file
View File

@@ -0,0 +1,50 @@
//! 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);
println!(" Licensed: {}", about.licensed);
if let Some(ref build) = about.build {
println!(" Build: {}", build);
}
if let Some(ref repository) = about.repository {
println!(" Repository: {}", repository);
}
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());
@@ -37,6 +60,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
// Create an album and add the uploaded asset
// Note: For duplicates, the ID is returned even though status is duplicate
if let Some(asset_id) = result.id {
let album = client
.albums()
@@ -45,7 +69,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.execute()
.await?;
client
let _add_result = client
.albums()
.add_assets(album.id)
.asset_ids([asset_id])

16981
openapi-spec.yaml Normal file

File diff suppressed because it is too large Load Diff

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

@@ -241,13 +241,13 @@ impl AddAssetsBuilder {
}
/// Execute the request
pub async fn execute(self) -> Result<AlbumResponse> {
pub async fn execute(self) -> Result<Vec<serde_json::Value>> {
let path = format!("/albums/{}/assets", self.album_id);
let body = serde_json::json!({ "ids": self.asset_ids });
let req = self.client.put(&path).json(&body);
let response = self.client.execute(req.build()?).await?;
let album: AlbumResponse = response.json().await?;
Ok(album)
let result: Vec<serde_json::Value> = response.json().await?;
Ok(result)
}
}

View File

@@ -6,7 +6,10 @@ 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
@@ -66,10 +69,9 @@ impl ThumbnailResponse {
/// ```
pub fn decode(&self) -> Result<image::DynamicImage> {
// Get image format from content type
let format = image::ImageFormat::from_mime_type(&self.content_type)
.ok_or_else(|| ImmichError::Image(
format!("Unsupported content type: {}", self.content_type)
))?;
let format = image::ImageFormat::from_mime_type(&self.content_type).ok_or_else(|| {
ImmichError::Image(format!("Unsupported content type: {}", self.content_type))
})?;
// Create reader with the format and decode
image::ImageReader::with_format(Cursor::new(&self.data), format)
@@ -161,24 +163,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)
}
}
@@ -214,6 +218,8 @@ pub struct UploadAssetBuilder {
device_asset_id: Option<String>,
device_id: Option<String>,
is_favorite: bool,
file_created_at: Option<String>,
file_modified_at: Option<String>,
}
impl UploadAssetBuilder {
@@ -225,6 +231,8 @@ impl UploadAssetBuilder {
device_asset_id: None,
device_id: None,
is_favorite: false,
file_created_at: None,
file_modified_at: None,
}
}
@@ -252,6 +260,18 @@ impl UploadAssetBuilder {
self
}
/// Set file creation timestamp (ISO 8601 format)
pub fn file_created_at(mut self, timestamp: impl Into<String>) -> Self {
self.file_created_at = Some(timestamp.into());
self
}
/// Set file modification timestamp (ISO 8601 format)
pub fn file_modified_at(mut self, timestamp: impl Into<String>) -> Self {
self.file_modified_at = Some(timestamp.into());
self
}
/// Execute the upload
pub async fn execute(self) -> Result<AssetUploadResponse> {
let file_path = self
@@ -281,6 +301,11 @@ impl UploadAssetBuilder {
form = form.text("isFavorite", self.is_favorite.to_string());
// Add file timestamps (required by Immich API v2)
let now = chrono::Utc::now().to_rfc3339();
form = form.text("fileCreatedAt", self.file_created_at.unwrap_or_else(|| now.clone()));
form = form.text("fileModifiedAt", self.file_modified_at.unwrap_or(now));
let req = self.client.post("/assets").multipart(form);
let response = self.client.execute(req.build()?).await?;
let result: AssetUploadResponse = response.json().await?;

55
src/apis/auth.rs Normal file
View File

@@ -0,0 +1,55 @@
//! Authentication API
use crate::{
Client,
error::Result,
models::{
AuthStatusResponseDto, LoginCredentialDto, LoginResponseDto, LogoutResponseDto,
ValidateAccessTokenResponseDto,
},
};
/// API for authentication
#[derive(Debug, Clone)]
pub struct AuthApi {
client: Client,
}
impl AuthApi {
/// Create a new auth API instance
pub const fn new(client: Client) -> Self {
Self { client }
}
/// Login with username and password
pub async fn login(&self, credentials: LoginCredentialDto) -> Result<LoginResponseDto> {
let req = self.client.post("/auth/login").json(&credentials);
let response = self.client.execute(req.build()?).await?;
let login_response: LoginResponseDto = response.json().await?;
Ok(login_response)
}
/// Logout the current user and invalidate the session token
pub async fn logout(&self) -> Result<LogoutResponseDto> {
let req = self.client.post("/auth/logout");
let response = self.client.execute(req.build()?).await?;
let logout_response: LogoutResponseDto = response.json().await?;
Ok(logout_response)
}
/// Get information about the current session
pub async fn get_auth_status(&self) -> Result<AuthStatusResponseDto> {
let req = self.client.get("/auth/status");
let response = self.client.execute(req.build()?).await?;
let auth_status: AuthStatusResponseDto = response.json().await?;
Ok(auth_status)
}
/// Validate the current authorization method is still valid
pub async fn validate_access_token(&self) -> Result<ValidateAccessTokenResponseDto> {
let req = self.client.post("/auth/validateToken");
let response = self.client.execute(req.build()?).await?;
let validate_response: ValidateAccessTokenResponseDto = response.json().await?;
Ok(validate_response)
}
}

View File

@@ -2,11 +2,17 @@
pub mod albums;
pub mod assets;
pub mod search;
pub mod server;
pub mod timeline;
pub mod auth;
pub mod oauth;
// Re-export main API modules
pub use albums::AlbumsApi;
pub use assets::AssetsApi;
pub use search::SearchApi;
pub use server::ServerApi;
pub use timeline::TimelineApi;
pub use auth::AuthApi;
pub use oauth::OAuthApi;

61
src/apis/oauth.rs Normal file
View File

@@ -0,0 +1,61 @@
//! OAuth API
use crate::{
Client,
error::Result,
models::{
OAuthAuthorizeResponseDto, OAuthCallbackDto, OAuthConfigDto, UserAdminResponseDto,
LoginResponseDto,
},
};
/// API for OAuth
#[derive(Debug, Clone)]
pub struct OAuthApi {
client: Client,
}
impl OAuthApi {
/// Create a new oauth API instance
pub const fn new(client: Client) -> Self {
Self { client }
}
/// Start OAuth authorization
pub async fn authorize(&self, config: OAuthConfigDto) -> Result<OAuthAuthorizeResponseDto> {
let req = self.client.post("/oauth/authorize").json(&config);
let response = self.client.execute(req.build()?).await?;
let auth_response: OAuthAuthorizeResponseDto = response.json().await?;
Ok(auth_response)
}
/// Finish OAuth authorization
pub async fn finish_oauth(&self, callback_data: OAuthCallbackDto) -> Result<LoginResponseDto> {
let req = self.client.post("/oauth/callback").json(&callback_data);
let response = self.client.execute(req.build()?).await?;
let login_response: LoginResponseDto = response.json().await?;
Ok(login_response)
}
/// Link an OAuth account
pub async fn link_oauth_account(&self, callback_data: OAuthCallbackDto) -> Result<UserAdminResponseDto> {
let req = self.client.post("/oauth/link").json(&callback_data);
let response = self.client.execute(req.build()?).await?;
let user_admin_response: UserAdminResponseDto = response.json().await?;
Ok(user_admin_response)
}
/// Redirect OAuth to mobile
pub async fn redirect_oauth_to_mobile(&self) -> Result<()> {
let req = self.client.get("/oauth/mobile-redirect");
self.client.execute(req.build()?).await?;
Ok(())
}
/// Unlink an OAuth account
pub async fn unlink_oauth_account(&self) -> Result<()> {
let req = self.client.post("/oauth/unlink");
self.client.execute(req.build()?).await?;
Ok(())
}
}

395
src/apis/search.rs Normal file
View File

@@ -0,0 +1,395 @@
//! Search API - Search for assets and albums by metadata
use crate::{
Client,
error::Result,
models::{
AssetId, AssetOrder, AssetType, AssetVisibility, MetadataSearchRequest, SearchResponse,
},
};
use chrono::{DateTime, Utc};
/// API for searching assets and albums
#[derive(Debug, Clone)]
pub struct SearchApi {
client: Client,
}
impl SearchApi {
/// Create a new search API instance
pub const fn new(client: Client) -> Self {
Self { client }
}
/// Start a metadata search with the builder pattern
pub fn metadata(&self) -> SearchMetadataBuilder {
SearchMetadataBuilder::new(self.client.clone())
}
}
/// Builder for the metadata search endpoint
#[derive(Debug)]
pub struct SearchMetadataBuilder {
client: Client,
album_ids: Vec<AssetId>,
checksum: Option<String>,
city: Option<String>,
country: Option<String>,
created_after: Option<DateTime<Utc>>,
created_before: Option<DateTime<Utc>>,
description: Option<String>,
device_asset_id: Option<String>,
device_id: Option<String>,
is_favorite: Option<bool>,
is_motion: Option<bool>,
is_not_in_album: Option<bool>,
is_offline: Option<bool>,
lens_model: Option<String>,
library_id: Option<AssetId>,
make: Option<String>,
model: Option<String>,
ocr: Option<String>,
order: Option<AssetOrder>,
original_file_name: Option<String>,
page: Option<u32>,
person_ids: Vec<AssetId>,
rating: Option<i32>,
size: Option<u32>,
state: Option<String>,
tag_ids: Vec<AssetId>,
taken_after: Option<DateTime<Utc>>,
taken_before: Option<DateTime<Utc>>,
asset_type: Option<AssetType>,
updated_after: Option<DateTime<Utc>>,
updated_before: Option<DateTime<Utc>>,
visibility: Option<AssetVisibility>,
with_deleted: Option<bool>,
with_exif: Option<bool>,
with_people: Option<bool>,
with_stacked: Option<bool>,
}
impl SearchMetadataBuilder {
/// Create a new metadata search builder
const fn new(client: Client) -> Self {
Self {
client,
album_ids: Vec::new(),
checksum: None,
city: None,
country: None,
created_after: None,
created_before: None,
description: None,
device_asset_id: None,
device_id: None,
is_favorite: None,
is_motion: None,
is_not_in_album: None,
is_offline: None,
lens_model: None,
library_id: None,
make: None,
model: None,
ocr: None,
order: None,
original_file_name: None,
page: None,
person_ids: Vec::new(),
rating: None,
size: None,
state: None,
tag_ids: Vec::new(),
taken_after: None,
taken_before: None,
asset_type: None,
updated_after: None,
updated_before: None,
visibility: None,
with_deleted: None,
with_exif: None,
with_people: None,
with_stacked: None,
}
}
/// Filter by album ID (can be called multiple times to filter by multiple albums)
pub fn album_id(mut self, id: AssetId) -> Self {
self.album_ids.push(id);
self
}
/// Filter by file checksum
pub fn checksum(mut self, checksum: impl Into<String>) -> Self {
self.checksum = Some(checksum.into());
self
}
/// Filter by city name
pub fn city(mut self, city: impl Into<String>) -> Self {
self.city = Some(city.into());
self
}
/// Filter by country name
pub fn country(mut self, country: impl Into<String>) -> Self {
self.country = Some(country.into());
self
}
/// Filter by creation date after
pub fn created_after(mut self, after: DateTime<Utc>) -> Self {
self.created_after = Some(after);
self
}
/// Filter by creation date before
pub fn created_before(mut self, before: DateTime<Utc>) -> Self {
self.created_before = Some(before);
self
}
/// Filter by description text
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
/// Filter by device asset ID
pub fn device_asset_id(mut self, id: impl Into<String>) -> Self {
self.device_asset_id = Some(id.into());
self
}
/// Filter by device ID
pub fn device_id(mut self, id: impl Into<String>) -> Self {
self.device_id = Some(id.into());
self
}
/// Filter by favorite status
pub fn favorite(mut self, is_favorite: bool) -> Self {
self.is_favorite = Some(is_favorite);
self
}
/// Filter by motion photo status
pub fn motion(mut self, is_motion: bool) -> Self {
self.is_motion = Some(is_motion);
self
}
/// Filter assets not in any album
pub fn not_in_album(mut self) -> Self {
self.is_not_in_album = Some(true);
self
}
/// Filter by offline status
pub fn offline(mut self, is_offline: bool) -> Self {
self.is_offline = Some(is_offline);
self
}
/// Filter by lens model
pub fn lens_model(mut self, model: impl Into<String>) -> Self {
self.lens_model = Some(model.into());
self
}
/// Filter by library ID
pub fn library_id(mut self, id: AssetId) -> Self {
self.library_id = Some(id);
self
}
/// Filter by camera make
pub fn make(mut self, make: impl Into<String>) -> Self {
self.make = Some(make.into());
self
}
/// Filter by camera model
pub fn model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
/// Filter by OCR text content
pub fn ocr(mut self, text: impl Into<String>) -> Self {
self.ocr = Some(text.into());
self
}
/// Set sort order
pub fn order(mut self, order: AssetOrder) -> Self {
self.order = Some(order);
self
}
/// Filter by original file name
pub fn file_name(mut self, name: impl Into<String>) -> Self {
self.original_file_name = Some(name.into());
self
}
/// Set page number
pub fn page(mut self, page: u32) -> Self {
self.page = Some(page);
self
}
/// Filter by person ID (can be called multiple times to filter by multiple people)
pub fn person_id(mut self, id: AssetId) -> Self {
self.person_ids.push(id);
self
}
/// Filter by rating (1-5, or -1 deprecated)
pub fn rating(mut self, rating: i32) -> Self {
self.rating = Some(rating);
self
}
/// Set number of results (1-1000, default 100)
pub fn size(mut self, size: u32) -> Self {
self.size = Some(size);
self
}
/// Filter by state/province name
pub fn state(mut self, state: impl Into<String>) -> Self {
self.state = Some(state.into());
self
}
/// Filter by tag ID (can be called multiple times to filter by multiple tags)
pub fn tag_id(mut self, id: AssetId) -> Self {
self.tag_ids.push(id);
self
}
/// Filter by taken date after
pub fn taken_after(mut self, after: DateTime<Utc>) -> Self {
self.taken_after = Some(after);
self
}
/// Filter by taken date before
pub fn taken_before(mut self, before: DateTime<Utc>) -> Self {
self.taken_before = Some(before);
self
}
/// Filter by asset type
pub fn asset_type(mut self, asset_type: AssetType) -> Self {
self.asset_type = Some(asset_type);
self
}
/// Filter by update date after
pub fn updated_after(mut self, after: DateTime<Utc>) -> Self {
self.updated_after = Some(after);
self
}
/// Filter by update date before
pub fn updated_before(mut self, before: DateTime<Utc>) -> Self {
self.updated_before = Some(before);
self
}
/// Filter by visibility
pub fn visibility(mut self, visibility: AssetVisibility) -> Self {
self.visibility = Some(visibility);
self
}
/// Include deleted assets in results
pub fn with_deleted(mut self) -> Self {
self.with_deleted = Some(true);
self
}
/// Include EXIF data in response
pub fn with_exif(mut self) -> Self {
self.with_exif = Some(true);
self
}
/// Include people data in response
pub fn with_people(mut self) -> Self {
self.with_people = Some(true);
self
}
/// Include stacked assets
pub fn with_stacked(mut self) -> Self {
self.with_stacked = Some(true);
self
}
/// Build the request and execute it
///
/// # Errors
/// Returns an error if the HTTP request fails, if the response cannot be deserialized,
/// or if authentication is missing/invalid
pub async fn execute(self) -> Result<SearchResponse> {
let request = MetadataSearchRequest {
album_ids: if self.album_ids.is_empty() {
None
} else {
Some(self.album_ids)
},
checksum: self.checksum,
city: self.city,
country: self.country,
created_after: self.created_after,
created_before: self.created_before,
description: self.description,
device_asset_id: self.device_asset_id,
device_id: self.device_id,
is_favorite: self.is_favorite,
is_motion: self.is_motion,
is_not_in_album: self.is_not_in_album,
is_offline: self.is_offline,
lens_model: self.lens_model,
library_id: self.library_id,
make: self.make,
model: self.model,
ocr: self.ocr,
order: self.order,
original_file_name: self.original_file_name,
page: self.page,
person_ids: if self.person_ids.is_empty() {
None
} else {
Some(self.person_ids)
},
rating: self.rating,
size: self.size,
state: self.state,
tag_ids: if self.tag_ids.is_empty() {
None
} else {
Some(self.tag_ids)
},
taken_after: self.taken_after,
taken_before: self.taken_before,
asset_type: self.asset_type,
updated_after: self.updated_after,
updated_before: self.updated_before,
visibility: self.visibility,
with_deleted: self.with_deleted,
with_exif: self.with_exif,
with_people: self.with_people,
with_stacked: self.with_stacked,
};
let req = self.client.post("/search/metadata").json(&request);
let response = self.client.execute(req.build()?).await?;
let result: SearchResponse = response.json().await?;
Ok(result)
}
}

View File

@@ -1,7 +1,8 @@
//! Client for interacting with the Immich API
use crate::apis::{AlbumsApi, AssetsApi, ServerApi, TimelineApi};
use crate::apis::{AlbumsApi, AssetsApi, SearchApi, ServerApi, TimelineApi, AuthApi, OAuthApi};
use crate::error::{ImmichError, Result};
use std::sync::Arc;
use std::time::Duration;
/// Configuration for the Immich client
@@ -56,13 +57,22 @@ impl Config {
}
}
/// Client for making requests to the Immich API
#[derive(Debug, Clone)]
pub struct Client {
/// Internal client data wrapped in Arc for cheap cloning
#[derive(Debug)]
struct ClientInner {
config: Config,
http: reqwest::Client,
}
/// Client for making requests to the Immich API
///
/// This struct is cheap to clone - it uses an internal Arc to share the underlying
/// HTTP client and configuration.
#[derive(Debug, Clone)]
pub struct Client {
inner: Arc<ClientInner>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Config) -> Result<Self> {
@@ -86,8 +96,10 @@ impl Client {
.build()?;
Ok(Self {
config: Config { base_url, ..config },
http,
inner: Arc::new(ClientInner {
config: Config { base_url, ..config },
http,
}),
})
}
@@ -97,38 +109,46 @@ impl Client {
}
/// Set the API key
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
self.config.api_key = Some(api_key.into());
self
pub fn with_api_key(self, api_key: impl Into<String>) -> Self {
// We need to create a new ClientInner since we can't modify Arc contents
let mut config = self.inner.config.clone();
config.api_key = Some(api_key.into());
Self {
inner: Arc::new(ClientInner {
config,
http: self.inner.http.clone(),
}),
}
}
/// Get the configuration
pub fn config(&self) -> &Config {
&self.config
&self.inner.config
}
/// Get the base URL
pub fn base_url(&self) -> &str {
&self.config.base_url
&self.inner.config.base_url
}
/// Check if the client has an API key configured
pub fn has_api_key(&self) -> bool {
self.config.api_key.is_some()
self.inner.config.api_key.is_some()
}
/// Get the underlying HTTP client
pub fn http(&self) -> &reqwest::Client {
&self.http
&self.inner.http
}
/// Create an authenticated request builder
pub fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
let url = format!("{}/api{}", self.config.base_url, path);
let mut builder = self.http.request(method, &url);
let url = format!("{}/api{}", self.inner.config.base_url, path);
let mut builder = self.inner.http.request(method, &url);
// Add API key authentication
if let Some(ref api_key) = self.config.api_key {
if let Some(ref api_key) = self.inner.config.api_key {
builder = builder.header("x-api-key", api_key);
}
@@ -137,7 +157,7 @@ impl Client {
/// Execute a request and handle common error cases
pub async fn execute(&self, request: reqwest::Request) -> Result<reqwest::Response> {
let response = self.http.execute(request).await?;
let response = self.inner.http.execute(request).await?;
if response.status().is_success() {
Ok(response)
@@ -181,6 +201,11 @@ impl Client {
AssetsApi::new(self.clone())
}
/// Access the search API
pub fn search(&self) -> SearchApi {
SearchApi::new(self.clone())
}
/// Access the server API
pub fn server(&self) -> ServerApi {
ServerApi::new(self.clone())
@@ -190,6 +215,16 @@ impl Client {
pub fn timeline(&self) -> TimelineApi {
TimelineApi::new(self.clone())
}
/// Access the auth API
pub fn auth(&self) -> AuthApi {
AuthApi::new(self.clone())
}
/// Access the oauth API
pub fn oauth(&self) -> OAuthApi {
OAuthApi::new(self.clone())
}
}
#[cfg(test)]

View File

@@ -186,10 +186,66 @@ pub struct ServerFeatures {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerAbout {
/// Version information
pub version: ServerVersion,
/// Version string
/// Server version (e.g., "v2.7.5")
pub version: String,
/// URL to version information
pub version_url: String,
/// Build identifier
#[serde(skip_serializing_if = "Option::is_none")]
pub build: Option<String>,
/// Build URL
#[serde(skip_serializing_if = "Option::is_none")]
pub build_url: Option<String>,
/// Build image name
#[serde(skip_serializing_if = "Option::is_none")]
pub build_image: Option<String>,
/// Build image URL
#[serde(skip_serializing_if = "Option::is_none")]
pub build_image_url: Option<String>,
/// ExifTool version
#[serde(skip_serializing_if = "Option::is_none")]
pub exiftool: Option<String>,
/// FFmpeg version
#[serde(skip_serializing_if = "Option::is_none")]
pub ffmpeg: Option<String>,
/// ImageMagick version
#[serde(skip_serializing_if = "Option::is_none")]
pub imagemagick: Option<String>,
/// libvips version
#[serde(skip_serializing_if = "Option::is_none")]
pub libvips: Option<String>,
/// Node.js version
#[serde(skip_serializing_if = "Option::is_none")]
pub nodejs: Option<String>,
/// Repository name
#[serde(skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
/// Repository URL
#[serde(skip_serializing_if = "Option::is_none")]
pub repository_url: Option<String>,
/// Source commit hash
#[serde(skip_serializing_if = "Option::is_none")]
pub source_commit: Option<String>,
/// Source reference (branch/tag)
#[serde(skip_serializing_if = "Option::is_none")]
pub source_ref: Option<String>,
/// Source URL
#[serde(skip_serializing_if = "Option::is_none")]
pub source_url: Option<String>,
/// Third-party bug/feature URL
#[serde(skip_serializing_if = "Option::is_none")]
pub third_party_bug_feature_url: Option<String>,
/// Third-party documentation URL
#[serde(skip_serializing_if = "Option::is_none")]
pub third_party_documentation_url: Option<String>,
/// Third-party source URL
#[serde(skip_serializing_if = "Option::is_none")]
pub third_party_source_url: Option<String>,
/// Third-party support URL
#[serde(skip_serializing_if = "Option::is_none")]
pub third_party_support_url: Option<String>,
/// Whether the server is licensed
pub licensed: bool,
}
/// Create album request
@@ -248,7 +304,7 @@ pub struct AssetUploadResponse {
/// Asset upload status
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[serde(rename_all = "snake_case")]
pub enum AssetUploadStatus {
/// Upload created new asset
Created,
@@ -284,7 +340,7 @@ pub struct ApiKeyResponse {
/// Asset order enumeration
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[serde(rename_all = "lowercase")]
pub enum AssetOrder {
/// Oldest first
Asc,
@@ -344,24 +400,305 @@ pub struct TimeBucketAssetResponse {
pub visibility: Vec<AssetVisibility>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_asset_type_serialization() {
let asset_type = AssetType::Image;
let json = serde_json::to_string(&asset_type).unwrap();
assert_eq!(json, r#""IMAGE""#);
}
#[test]
fn test_server_version_display() {
let version = ServerVersion {
major: 1,
minor: 137,
patch: 0,
};
assert_eq!(version.to_string(), "1.137.0");
}
/// Metadata search request for filtering assets
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct MetadataSearchRequest {
/// Filter by album IDs
#[serde(skip_serializing_if = "Option::is_none")]
pub album_ids: Option<Vec<AssetId>>,
/// Filter by file checksum
#[serde(skip_serializing_if = "Option::is_none")]
pub checksum: Option<String>,
/// Filter by city name
#[serde(skip_serializing_if = "Option::is_none")]
pub city: Option<String>,
/// Filter by country name
#[serde(skip_serializing_if = "Option::is_none")]
pub country: Option<String>,
/// Filter by creation date (after)
#[serde(skip_serializing_if = "Option::is_none")]
pub created_after: Option<DateTime<Utc>>,
/// Filter by creation date (before)
#[serde(skip_serializing_if = "Option::is_none")]
pub created_before: Option<DateTime<Utc>>,
/// Filter by description text
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Filter by device asset ID
#[serde(skip_serializing_if = "Option::is_none")]
pub device_asset_id: Option<String>,
/// Device ID to filter by
#[serde(skip_serializing_if = "Option::is_none")]
pub device_id: Option<String>,
/// Filter by favorite status
#[serde(skip_serializing_if = "Option::is_none")]
pub is_favorite: Option<bool>,
/// Filter by motion photo status
#[serde(skip_serializing_if = "Option::is_none")]
pub is_motion: Option<bool>,
/// Filter assets not in any album
#[serde(skip_serializing_if = "Option::is_none")]
pub is_not_in_album: Option<bool>,
/// Filter by offline status
#[serde(skip_serializing_if = "Option::is_none")]
pub is_offline: Option<bool>,
/// Filter by lens model
#[serde(skip_serializing_if = "Option::is_none")]
pub lens_model: Option<String>,
/// Library ID to filter by
#[serde(skip_serializing_if = "Option::is_none")]
pub library_id: Option<AssetId>,
/// Filter by camera make
#[serde(skip_serializing_if = "Option::is_none")]
pub make: Option<String>,
/// Filter by camera model
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Filter by OCR text content
#[serde(skip_serializing_if = "Option::is_none")]
pub ocr: Option<String>,
/// Sort order
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<AssetOrder>,
/// Filter by original file name
#[serde(skip_serializing_if = "Option::is_none")]
pub original_file_name: Option<String>,
/// Page number (default: 1)
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<u32>,
/// Filter by person IDs
#[serde(skip_serializing_if = "Option::is_none")]
pub person_ids: Option<Vec<AssetId>>,
/// Filter by rating [1-5], or null for unrated (-1 deprecated)
#[serde(skip_serializing_if = "Option::is_none")]
pub rating: Option<i32>,
/// Number of results to return (default: 100)
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<u32>,
/// Filter by state/province name
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
/// Filter by tag IDs
#[serde(skip_serializing_if = "Option::is_none")]
pub tag_ids: Option<Vec<AssetId>>,
/// Filter by taken date (after)
#[serde(skip_serializing_if = "Option::is_none")]
pub taken_after: Option<DateTime<Utc>>,
/// Filter by taken date (before)
#[serde(skip_serializing_if = "Option::is_none")]
pub taken_before: Option<DateTime<Utc>>,
/// Asset type filter
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub asset_type: Option<AssetType>,
/// Filter by update date (after)
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_after: Option<DateTime<Utc>>,
/// Filter by update date (before)
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_before: Option<DateTime<Utc>>,
/// Filter by visibility
#[serde(skip_serializing_if = "Option::is_none")]
pub visibility: Option<AssetVisibility>,
/// Include deleted assets
#[serde(skip_serializing_if = "Option::is_none")]
pub with_deleted: Option<bool>,
/// Include EXIF data in response
#[serde(skip_serializing_if = "Option::is_none")]
pub with_exif: Option<bool>,
/// Include people data in response
#[serde(skip_serializing_if = "Option::is_none")]
pub with_people: Option<bool>,
/// Include stacked assets
#[serde(skip_serializing_if = "Option::is_none")]
pub with_stacked: Option<bool>,
}
/// Represents a facet value count
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchFacetCount {
/// Number of assets with this facet value
pub count: i64,
/// Facet value
pub value: String,
}
/// Represents a search facet
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchFacet {
/// Facet counts
pub counts: Vec<SearchFacetCount>,
/// Facet field name
pub field_name: String,
}
/// Paginated asset search results
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchAssetResult {
/// Number of assets in this page
pub count: i64,
/// Facet information
pub facets: Vec<SearchFacet>,
/// The actual assets
pub items: Vec<AssetResponse>,
/// Next page token
pub next_page: Option<String>,
/// Total number of matching assets
pub total: i64,
}
/// Paginated album search results
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchAlbumResult {
/// Number of albums in this page
pub count: i64,
/// Facet information
pub facets: Vec<SearchFacet>,
/// The actual albums
pub items: Vec<AlbumResponse>,
/// Total number of matching albums
pub total: i64,
}
/// Combined search response
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchResponse {
/// Album results
pub albums: SearchAlbumResult,
/// Asset results
pub assets: SearchAssetResult,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LoginCredentialDto {
pub email: String,
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LoginResponseDto {
pub access_token: String,
pub is_admin: bool,
pub is_onboarded: bool,
pub name: String,
pub profile_image_path: String,
pub should_change_password: bool,
pub user_email: String,
pub user_id: UserId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LogoutResponseDto {
pub redirect_uri: String,
pub successful: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthStatusResponseDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
pub is_elevated: bool,
pub password: bool,
pub pin_code: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub pin_expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ValidateAccessTokenResponseDto {
pub auth_status: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OAuthConfigDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub code_challenge: Option<String>,
pub redirect_uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OAuthAuthorizeResponseDto {
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OAuthCallbackDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub code_verifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserAdminResponseDto {
pub avatar_color: UserAvatarColor,
pub created_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTime<Utc>>,
pub email: String,
pub id: UserId,
pub is_admin: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub license: Option<UserLicense>,
pub name: String,
pub oauth_id: String,
pub profile_changed_at: DateTime<Utc>,
pub profile_image_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub quota_size_in_bytes: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quota_usage_in_bytes: Option<i64>,
pub should_change_password: bool,
pub status: UserStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub storage_label: Option<String>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum UserAvatarColor {
Primary,
Pink,
Red,
Yellow,
Blue,
Green,
Purple,
Orange,
Gray,
Amber,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserLicense {
pub activated_at: DateTime<Utc>,
pub activation_key: String,
pub license_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum UserStatus {
Active,
Removing,
Deleted,
}

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