immich-automated-selfie-tim.../src/immich_api/mod.rs

489 lines
15 KiB
Rust

//! Immich API client implementation.
use crate::config::ApiConfig;
use crate::error::{Error, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use urlencoding::encode as urlencode;
/// Client for interacting with the Immich API.
#[derive(Clone)]
pub struct ImmichClient {
client: Client,
base_url: String,
api_key: String,
}
/// Server information response.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerInfo {
pub version: String,
}
/// Person data. Obtained from the /people Immich endpoint.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Person {
pub id: String,
pub name: Option<String>,
}
/// Asset data from Immich. May contain one or more person inside. Obtained from /search/metadata Immich endpoint.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Asset {
pub id: String,
pub device_asset_id: Option<String>,
pub original_file_name: Option<String>,
pub file_created_at: Option<String>,
pub local_date_time: Option<String>,
pub people: Option<Vec<PersonWithFaces>>,
}
/// Person with face data.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PersonWithFaces {
pub id: String,
pub name: Option<String>,
pub faces: Option<Vec<FaceData>>,
}
/// Face data from Immich.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FaceData {
pub bounding_box_x1: f32,
pub bounding_box_y1: f32,
pub bounding_box_x2: f32,
pub bounding_box_y2: f32,
pub image_width: u32,
pub image_height: u32,
}
/// Search response from Immich.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchResponse {
pub assets: SearchAssets,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchAssets {
pub items: Vec<Asset>,
pub next_page: Option<String>,
}
/// Search parameters.
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub person_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub taken_after: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub taken_before: Option<String>,
#[serde(rename = "type")]
pub asset_type: String,
pub page: u32,
pub size: u32,
pub with_people: bool,
}
impl ImmichClient {
/// Create a new Immich client.
pub fn new(config: &ApiConfig) -> Result<Self> {
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()?;
Ok(Self {
client,
base_url: Self::sanitize_base_url(&config.base_url),
api_key: config.api_key.clone(),
})
}
/// Sanitize the base URL to ensure it ends with /api.
fn sanitize_base_url(url: &str) -> String {
let trimmed = url.trim_end_matches('/');
if trimmed.ends_with("/api") {
trimmed.to_string()
} else {
format!("{}/api", trimmed)
}
}
/// Validate the connection to Immich.
pub async fn validate_connection(&self) -> Result<ServerInfo> {
let url = format!("{}/server/about", self.base_url);
let response = self
.client
.get(&url)
.header("x-api-key", &self.api_key)
.send()
.await?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
return Err(Error::ImmichApi("Invalid API key".to_string()));
}
if !response.status().is_success() {
return Err(Error::ImmichApi(format!(
"Server returned status {}",
response.status()
)));
}
let info: ServerInfo = response.json().await?;
Ok(info)
}
/// Search for assets containing a specific person.
pub async fn get_assets_with_person(
&self,
person_id: &str,
taken_after: Option<&str>,
taken_before: Option<&str>,
) -> Result<Vec<Asset>> {
let mut all_assets = Vec::new();
let mut page = 1u32;
loop {
let params: SearchParams = SearchParams {
person_ids: Some(vec![person_id.to_string()]),
taken_after: taken_after.map(|s| s.to_string()),
taken_before: taken_before.map(|s| s.to_string()),
asset_type: "IMAGE".to_string(),
page,
size: 100,
with_people: true,
};
let url = format!("{}/search/metadata", self.base_url);
let response = self
.client
.post(&url)
.header("x-api-key", &self.api_key)
.json(&params)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(Error::ImmichApi(format!(
"Search failed with status {}: {}",
status, body
)));
}
let search_response: SearchResponse = response.json().await?;
all_assets.extend(search_response.assets.items);
if search_response.assets.next_page.is_none() {
break;
}
page += 1;
if page > 1000 {
tracing::warn!("Pagination limit reached (1000 pages), stopping asset fetch");
break;
}
}
tracing::info!("Found {} assets for person {}", all_assets.len(), person_id);
Ok(all_assets)
}
/// Maximum download size for a single asset (100 MB).
const MAX_DOWNLOAD_SIZE: u64 = 100 * 1024 * 1024;
/// Download an asset's original image.
pub async fn download_asset(&self, asset_id: &str) -> Result<bytes::Bytes> {
let encoded_id = urlencode(asset_id);
let url = format!("{}/assets/{}/original", self.base_url, encoded_id);
let response = self
.client
.get(&url)
.header("x-api-key", &self.api_key)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::ImmichApi(format!(
"Failed to download asset {}: {}",
asset_id,
response.status()
)));
}
// Check Content-Length before downloading to reject unexpectedly large files
if let Some(content_length) = response.content_length() {
if content_length > Self::MAX_DOWNLOAD_SIZE {
return Err(Error::ImmichApi(format!(
"Asset {} is too large ({} bytes, max {} bytes)",
asset_id,
content_length,
Self::MAX_DOWNLOAD_SIZE
)));
}
}
let bytes = response.bytes().await?;
Ok(bytes)
}
/// Download an asset's preview image (1440p JPEG pre-generated by Immich).
///
/// Much faster to download and decode than the original.
/// Falls back to the original if the preview is unavailable.
pub async fn download_asset_preview(&self, asset_id: &str) -> Result<bytes::Bytes> {
let encoded_id = urlencode(asset_id);
let url = format!("{}/assets/{}/thumbnail?size=preview", self.base_url, encoded_id);
let response = self
.client
.get(&url)
.header("x-api-key", &self.api_key)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::ImmichApi(format!(
"Failed to download preview for asset {}: {}",
asset_id,
response.status()
)));
}
let bytes = response.bytes().await?;
Ok(bytes)
}
/// Get all people from Immich.
pub async fn get_people(&self) -> Result<Vec<Person>> {
let url = format!("{}/people", self.base_url);
let response = self
.client
.get(&url)
.header("x-api-key", &self.api_key)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::ImmichApi(format!(
"Failed to get people: {}",
response.status()
)));
}
#[derive(Deserialize)]
struct PeopleResponse {
people: Vec<Person>,
}
let response: PeopleResponse = response.json().await?;
Ok(response.people)
}
/// Get a person's thumbnail image.
/// Returns the image bytes and content-type.
pub async fn get_person_thumbnail(&self, person_id: &str) -> Result<(bytes::Bytes, String)> {
let encoded_id = urlencode(person_id);
let url = format!("{}/people/{}/thumbnail", self.base_url, encoded_id);
// tracing::debug!("Fetching thumbnail from: {}", url);
let response = self
.client
.get(&url)
.header("x-api-key", &self.api_key)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::ImmichApi(format!(
"Failed to get thumbnail for person {}: {}",
person_id,
response.status()
)));
}
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("image/jpeg")
.to_string();
let bytes = response.bytes().await?;
Ok((bytes, content_type))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_base_url() {
// URL already ends with /api
assert_eq!(
ImmichClient::sanitize_base_url("http://localhost:2283/api"),
"http://localhost:2283/api"
);
// URL ends with /api/ (trailing slash)
assert_eq!(
ImmichClient::sanitize_base_url("http://localhost:2283/api/"),
"http://localhost:2283/api"
);
// URL without /api
assert_eq!(
ImmichClient::sanitize_base_url("http://localhost:2283"),
"http://localhost:2283/api"
);
// URL with trailing slash, no /api
assert_eq!(
ImmichClient::sanitize_base_url("http://localhost:2283/"),
"http://localhost:2283/api"
);
// URL with path but no /api
assert_eq!(
ImmichClient::sanitize_base_url("http://example.com/immich"),
"http://example.com/immich/api"
);
// URL with path and trailing slash
assert_eq!(
ImmichClient::sanitize_base_url("http://example.com/immich/"),
"http://example.com/immich/api"
);
}
#[test]
fn test_client_creation() {
let config = ApiConfig {
api_key: "test-key".to_string(),
base_url: "http://localhost:2283/api/".to_string(),
timeout_secs: 30,
};
let client = ImmichClient::new(&config);
assert!(client.is_ok());
}
#[test]
fn test_client_creation_without_api_suffix() {
// URL without /api should be sanitized automatically
let config = ApiConfig {
api_key: "test-key".to_string(),
base_url: "http://localhost:2283".to_string(),
timeout_secs: 30,
};
let client = ImmichClient::new(&config).unwrap();
assert_eq!(client.base_url, "http://localhost:2283/api");
}
/// Helper to create a client for the demo server
fn demo_client() -> ImmichClient {
let config = ApiConfig {
api_key: "1bpgd3LpG30Zr3IEPNV3sWhIEqMuUGmzK3jWNh59JU".to_string(), // Generate a new API key in the public Immich demo for debugging purposes.
base_url: "https://demo.immich.app/api".to_string(),
timeout_secs: 30,
};
ImmichClient::new(&config).expect("Failed to create client")
}
#[tokio::test]
#[ignore] // Run with: cargo test -- --ignored
async fn test_demo_validate_connection() {
let client = demo_client();
let result = client.validate_connection().await;
match &result {
Ok(info) => println!("Connected to Immich version: {}", info.version),
Err(e) => println!("Connection failed: {:?}", e),
}
assert!(result.is_ok(), "Failed to connect: {:?}", result.err());
}
#[tokio::test]
#[ignore]
async fn test_demo_get_people() {
let client = demo_client();
let result = client.get_people().await;
match &result {
Ok(people) => {
println!("Found {} people:", people.len());
for person in people.iter().take(10) {
println!(
" - {} ({})",
person.name.as_deref().unwrap_or("unnamed"),
person.id
);
}
}
Err(e) => println!("Failed to get people: {:?}", e),
}
assert!(result.is_ok(), "Failed to get people: {:?}", result.err());
}
#[tokio::test]
#[ignore]
async fn test_demo_search_assets_with_person() {
let client = demo_client();
// First get a person ID
let people = client.get_people().await.expect("Failed to get people");
if people.is_empty() {
println!("No people found, skipping asset search test");
return;
}
let person = &people[0];
println!(
"Searching assets for: {} ({})",
person.name.as_deref().unwrap_or("unnamed"),
person.id
);
let result = client.get_assets_with_person(&person.id, None, None).await;
match &result {
Ok(assets) => {
println!("Found {} assets for person", assets.len());
for asset in assets.iter().take(5) {
println!(" - {} (created: {:?})", asset.id, asset.file_created_at);
if let Some(people) = &asset.people {
for p in people {
if let Some(faces) = &p.faces {
println!(" Faces: {}", faces.len());
}
}
}
}
}
Err(e) => println!("Failed to search assets: {:?}", e),
}
assert!(
result.is_ok(),
"Failed to search assets: {:?}",
result.err()
);
}
}