use chrono::{DateTime, Utc};
use futures::StreamExt;
use reqwest::header::{HeaderMap, HeaderValue, InvalidHeaderValue};
use reqwest::multipart::{Form, Part};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use url::Url;
use crate::error::{ImmichError, Result};
use crate::models::{AssetResponse, DuplicateGroup};
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UploadResponse {
pub id: String,
#[serde(default)]
pub duplicate: bool,
}
#[derive(Debug, Clone)]
pub struct ImmichClient {
client: reqwest::Client,
base_url: Url,
}
impl ImmichClient {
pub fn new(base_url: &str, api_key: &str) -> Result<Self> {
if api_key.is_empty() {
return Err(ImmichError::InvalidApiKey);
}
let base_url = Url::parse(base_url)?;
let mut headers = HeaderMap::new();
let header_value = HeaderValue::from_str(api_key).map_err(|_: InvalidHeaderValue| {
ImmichError::InvalidApiKey
})?;
headers.insert("x-api-key", header_value);
let client = reqwest::Client::builder()
.default_headers(headers)
.timeout(Duration::from_secs(30))
.build()?;
Ok(Self { client, base_url })
}
pub async fn get_duplicates(&self) -> Result<Vec<DuplicateGroup>> {
let url = self.base_url.join("/api/duplicates")?;
let response = self.client.get(url).send().await?;
self.handle_response(response).await
}
pub async fn get_all_assets(&self) -> Result<Vec<AssetResponse>> {
const PAGE_SIZE: usize = 1000;
let mut all_assets = Vec::new();
let mut page: usize = 1;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AssetSearchResult {
items: Vec<AssetResponse>,
next_page: Option<String>,
}
#[derive(Deserialize)]
struct SearchResponse {
assets: AssetSearchResult,
}
let url = self.base_url.join("/api/search/metadata")?;
loop {
let body = serde_json::json!({
"page": page,
"size": PAGE_SIZE,
"withExif": true
});
let response = self.client.post(url.clone()).json(&body).send().await?;
let search_result: SearchResponse = self.handle_response(response).await?;
if search_result.assets.items.is_empty() {
break;
}
let non_trashed: Vec<AssetResponse> = search_result
.assets
.items
.into_iter()
.filter(|a| !a.is_trashed)
.collect();
all_assets.extend(non_trashed);
if search_result.assets.next_page.is_none() {
break;
}
page += 1;
}
Ok(all_assets)
}
pub async fn get_asset(&self, asset_id: &str) -> Result<AssetResponse> {
let url = self.base_url.join(&format!("/api/assets/{}", asset_id))?;
let response = self.client.get(url).send().await?;
self.handle_response(response).await
}
pub async fn download_asset(&self, asset_id: &str, path: &Path) -> Result<u64> {
let url = self
.base_url
.join(&format!("/api/assets/{}/original", asset_id))?;
let response = self.client.get(url).send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(ImmichError::Api {
status: status.as_u16(),
message: body,
});
}
let mut file = tokio::fs::File::create(path).await?;
let mut stream = response.bytes_stream();
let mut bytes_written: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
file.write_all(&chunk).await?;
bytes_written += chunk.len() as u64;
}
file.flush().await?;
Ok(bytes_written)
}
pub async fn delete_assets(&self, asset_ids: &[String], force: bool) -> Result<()> {
#[derive(Serialize)]
struct DeleteRequest<'a> {
ids: &'a [String],
force: bool,
}
let url = self.base_url.join("/api/assets")?;
let body = DeleteRequest {
ids: asset_ids,
force,
};
let response = self.client.delete(url).json(&body).send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(ImmichError::Api {
status: status.as_u16(),
message: body,
});
}
Ok(())
}
pub async fn update_asset_metadata(
&self,
asset_id: &str,
latitude: Option<f64>,
longitude: Option<f64>,
date_time_original: Option<&str>,
description: Option<&str>,
) -> Result<()> {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct UpdateRequest<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
latitude: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
longitude: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
date_time_original: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<&'a str>,
}
let url = self.base_url.join(&format!("/api/assets/{}", asset_id))?;
let body = UpdateRequest {
latitude,
longitude,
date_time_original,
description,
};
let response = self.client.put(url).json(&body).send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(ImmichError::Api {
status: status.as_u16(),
message: body,
});
}
Ok(())
}
pub async fn upload_asset(&self, file_path: &Path) -> Result<UploadResponse> {
let file_content = tokio::fs::read(file_path).await?;
let original_filename = file_path
.file_name()
.and_then(|n| n.to_str())
.map(|name| {
if name.len() > 37 && name.chars().nth(36) == Some('_') {
let prefix = &name[..36];
if prefix.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
return name[37..].to_string();
}
}
name.to_string()
})
.unwrap_or_else(|| "unknown".to_string());
let file_time = tokio::fs::metadata(file_path)
.await
.ok()
.and_then(|m| m.modified().ok())
.map(DateTime::<Utc>::from)
.unwrap_or_else(Utc::now);
let file_time_str = file_time.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let mime_type = match file_path.extension().and_then(|e| e.to_str()) {
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("heic") | Some("heif") => "image/heic",
Some("mp4") => "video/mp4",
Some("mov") => "video/quicktime",
Some("avi") => "video/x-msvideo",
Some("webm") => "video/webm",
_ => "application/octet-stream",
};
let file_part = Part::bytes(file_content)
.file_name(original_filename.clone())
.mime_str(mime_type)?;
let form = Form::new()
.part("assetData", file_part)
.text("deviceAssetId", format!("restore-{}", uuid::Uuid::new_v4()))
.text("deviceId", "immich-dupes-restore")
.text("fileCreatedAt", file_time_str.clone())
.text("fileModifiedAt", file_time_str);
let url = self.base_url.join("/api/assets")?;
let response = self.client.post(url).multipart(form).send().await?;
let status = response.status();
if status.is_success() {
Ok(response.json().await?)
} else {
let body = response.text().await.unwrap_or_default();
Err(ImmichError::Api {
status: status.as_u16(),
message: body,
})
}
}
async fn handle_response<T: DeserializeOwned>(
&self,
response: reqwest::Response,
) -> Result<T> {
let status = response.status();
if status.is_success() {
Ok(response.json().await?)
} else {
let body = response.text().await.unwrap_or_default();
Err(ImmichError::Api {
status: status.as_u16(),
message: body,
})
}
}
}