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
469 lines
13 KiB
Rust
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 })
|
|
}
|
|
}
|