Files
immich-sdk/src/apis/assets.rs
Joakim Hulthe c39e0a5058
Some checks failed
Integration Tests / integration-test (push) Failing after 6s
Fix examples and SDK for Immich v2 API compatibility
- 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

469 lines
13 KiB
Rust

//! Assets API - Manage photos and videos
use std::io::Cursor;
use std::path::Path;
use crate::{
Client,
error::{ImmichError, Result},
models::{
AssetId, AssetMediaSize, AssetResponse, AssetUploadResponse, DeleteAssetsRequest,
MetadataSearchRequest, SearchResponse,
},
};
/// Response from downloading a thumbnail containing image data and metadata
#[derive(Debug, Clone)]
pub struct ThumbnailResponse {
/// The image data as bytes
pub data: bytes::Bytes,
/// The MIME type of the image (e.g., "image/jpeg", "image/png", "image/webp")
pub content_type: String,
}
impl ThumbnailResponse {
/// Get the file extension based on content type
pub fn extension(&self) -> Option<&str> {
match self.content_type.as_str() {
"image/jpeg" | "image/jpg" => Some("jpg"),
"image/png" => Some("png"),
"image/webp" => Some("webp"),
"image/gif" => Some("gif"),
"image/tiff" => Some("tiff"),
_ => None,
}
}
/// Get the content type
pub fn content_type(&self) -> &str {
&self.content_type
}
/// Get the data
pub fn data(&self) -> &bytes::Bytes {
&self.data
}
/// Convert to owned bytes
pub fn into_data(self) -> bytes::Bytes {
self.data
}
/// Decode the image data into a DynamicImage
///
/// # Errors
/// Returns an error if the content type is not supported or if decoding fails
///
/// # Example
/// ```rust,ignore
/// use immich_sdk::Client;
///
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let client = Client::from_url("https://immich.example.com")?;
/// let asset_id = "your-asset-id".parse()?;
/// let response = client.assets().thumbnail(asset_id).execute().await?;
/// let image = response.decode()?;
/// println!("Image size: {}x{}", image.width(), image.height());
/// # Ok(())
/// # }
/// ```
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))
})?;
// Create reader with the format and decode
image::ImageReader::with_format(Cursor::new(&self.data), format)
.decode()
.map_err(|e| ImmichError::Image(e.to_string()))
}
}
/// API for managing assets (photos and videos)
#[derive(Debug, Clone)]
pub struct AssetsApi {
client: Client,
}
impl AssetsApi {
/// Create a new assets API instance
pub const fn new(client: Client) -> Self {
Self { client }
}
/// List assets with optional filters
pub fn list(&self) -> ListAssetsBuilder {
ListAssetsBuilder::new(self.client.clone())
}
/// Get a single asset by ID
pub fn get(&self, id: AssetId) -> GetAssetBuilder {
GetAssetBuilder::new(self.client.clone(), id)
}
/// Upload a new asset
pub fn upload(&self) -> UploadAssetBuilder {
UploadAssetBuilder::new(self.client.clone())
}
/// Delete assets
pub fn delete(&self) -> DeleteAssetsBuilder {
DeleteAssetsBuilder::new(self.client.clone())
}
/// Download an asset
pub fn download(&self, id: AssetId) -> DownloadAssetBuilder {
DownloadAssetBuilder::new(self.client.clone(), id)
}
/// Get asset thumbnail
pub fn thumbnail(&self, id: AssetId) -> ThumbnailBuilder {
ThumbnailBuilder::new(self.client.clone(), id)
}
}
/// Builder for listing assets
#[derive(Debug)]
pub struct ListAssetsBuilder {
client: Client,
album_id: Option<AssetId>,
is_favorite: Option<bool>,
is_trashed: Option<bool>,
}
impl ListAssetsBuilder {
/// Create a new list builder
const fn new(client: Client) -> Self {
Self {
client,
album_id: None,
is_favorite: None,
is_trashed: None,
}
}
/// Filter by album ID
pub fn with_album_id(mut self, album_id: AssetId) -> Self {
self.album_id = Some(album_id);
self
}
/// Filter by favorite status
pub fn is_favorite(mut self, favorite: bool) -> Self {
self.is_favorite = Some(favorite);
self
}
/// Filter by trash status
pub fn is_trashed(mut self, trashed: bool) -> Self {
self.is_trashed = Some(trashed);
self
}
/// Execute the request
pub async fn execute(self) -> Result<Vec<AssetResponse>> {
let req = self.client.post("/search/metadata");
let mut body = MetadataSearchRequest::default();
if let Some(album_id) = self.album_id {
body.album_ids = Some(vec![album_id]);
}
if let Some(is_favorite) = self.is_favorite {
body.is_favorite = Some(is_favorite);
}
if let Some(is_trashed) = self.is_trashed {
body.with_deleted = Some(is_trashed);
}
let response = self.client.execute(req.json(&body).build()?).await?;
let search_result: SearchResponse = response.json().await?;
Ok(search_result.assets.items)
}
}
/// Builder for getting a single asset
#[derive(Debug)]
pub struct GetAssetBuilder {
client: Client,
id: AssetId,
}
impl GetAssetBuilder {
/// Create a new get builder
const fn new(client: Client, id: AssetId) -> Self {
Self { client, id }
}
/// Execute the request
pub async fn execute(self) -> Result<AssetResponse> {
let path = format!("/assets/{}", self.id);
let req = self.client.get(&path);
let response = self.client.execute(req.build()?).await?;
let asset: AssetResponse = response.json().await?;
Ok(asset)
}
}
/// Builder for uploading an asset
#[derive(Debug)]
pub struct UploadAssetBuilder {
client: Client,
file_path: Option<String>,
device_asset_id: Option<String>,
device_id: Option<String>,
is_favorite: bool,
file_created_at: Option<String>,
file_modified_at: Option<String>,
}
impl UploadAssetBuilder {
/// Create a new upload builder
const fn new(client: Client) -> Self {
Self {
client,
file_path: None,
device_asset_id: None,
device_id: None,
is_favorite: false,
file_created_at: None,
file_modified_at: None,
}
}
/// Set the file path to upload
pub fn file(mut self, path: impl AsRef<Path>) -> Self {
self.file_path = Some(path.as_ref().to_string_lossy().to_string());
self
}
/// Set the device asset ID (required)
pub fn device_asset_id(mut self, id: impl Into<String>) -> Self {
self.device_asset_id = Some(id.into());
self
}
/// Set the device ID (required)
pub fn device_id(mut self, id: impl Into<String>) -> Self {
self.device_id = Some(id.into());
self
}
/// Mark as favorite
pub fn favorite(mut self) -> Self {
self.is_favorite = true;
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
.file_path
.ok_or_else(|| ImmichError::Validation("File path is required".to_string()))?;
// Read file
let file_content = tokio::fs::read(&file_path).await?;
let file_name = std::path::Path::new(&file_path)
.file_name()
.ok_or_else(|| ImmichError::Validation("Invalid file path".to_string()))?
.to_string_lossy();
// Build multipart form
let mut form = reqwest::multipart::Form::new().part(
"assetData",
reqwest::multipart::Part::bytes(file_content).file_name(file_name.to_string()),
);
if let Some(device_asset_id) = self.device_asset_id {
form = form.text("deviceAssetId", device_asset_id);
}
if let Some(device_id) = self.device_id {
form = form.text("deviceId", device_id);
}
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?;
Ok(result)
}
}
/// Builder for deleting assets
#[derive(Debug)]
pub struct DeleteAssetsBuilder {
client: Client,
ids: Vec<AssetId>,
}
impl DeleteAssetsBuilder {
/// Create a new delete builder
const fn new(client: Client) -> Self {
Self {
client,
ids: Vec::new(),
}
}
/// Add an asset ID to delete
pub fn id(mut self, id: AssetId) -> Self {
self.ids.push(id);
self
}
/// Add multiple asset IDs to delete
pub fn ids(mut self, ids: impl IntoIterator<Item = AssetId>) -> Self {
self.ids.extend(ids);
self
}
/// Execute the deletion
pub async fn execute(self) -> Result<()> {
if self.ids.is_empty() {
return Err(ImmichError::Validation(
"At least one asset ID is required".to_string(),
));
}
let body = DeleteAssetsRequest {
ids: self.ids,
force: false,
};
let req = self.client.delete("/assets").json(&body);
let _response = self.client.execute(req.build()?).await?;
Ok(())
}
}
/// Builder for downloading an asset
#[derive(Debug)]
pub struct DownloadAssetBuilder {
client: Client,
id: AssetId,
edited: bool,
}
impl DownloadAssetBuilder {
/// Create a new download builder
const fn new(client: Client, id: AssetId) -> Self {
Self {
client,
id,
edited: false,
}
}
/// Download the edited version if available
pub fn edited(mut self) -> Self {
self.edited = true;
self
}
/// Execute the download
pub async fn execute(self) -> Result<bytes::Bytes> {
let path = format!("/assets/{}/original", self.id);
let mut req = self.client.get(&path);
if self.edited {
req = req.query(&[("edited", "true")]);
}
let response = self.client.execute(req.build()?).await?;
let bytes = response.bytes().await?;
Ok(bytes)
}
}
/// Builder for getting asset thumbnail
#[derive(Debug)]
pub struct ThumbnailBuilder {
client: Client,
id: AssetId,
size: Option<AssetMediaSize>,
edited: bool,
}
impl ThumbnailBuilder {
/// Create a new thumbnail builder
const fn new(client: Client, id: AssetId) -> Self {
Self {
client,
id,
size: None,
edited: false,
}
}
/// Set the thumbnail size
pub fn size(mut self, size: AssetMediaSize) -> Self {
self.size = Some(size);
self
}
/// Get the edited version if available
pub fn edited(mut self) -> Self {
self.edited = true;
self
}
/// Execute the request
pub async fn execute(self) -> Result<ThumbnailResponse> {
let path = format!("/assets/{}/thumbnail", self.id);
let mut req = self.client.get(&path);
if let Some(ref size) = self.size {
let size_str = match size {
AssetMediaSize::Original => "original",
AssetMediaSize::Fullsize => "fullsize",
AssetMediaSize::Preview => "preview",
AssetMediaSize::Thumbnail => "thumbnail",
};
req = req.query(&[("size", size_str)]);
}
if self.edited {
req = req.query(&[("edited", "true")]);
}
let response = self.client.execute(req.build()?).await?;
// Get content type from response headers
let content_type = response
.headers()
.get("content-type")
.and_then(|ct| ct.to_str().ok())
.map(String::from)
.unwrap_or_else(|| "application/octet-stream".to_string());
let data = response.bytes().await?;
Ok(ThumbnailResponse { data, content_type })
}
}