Initial commit: immich-sdk v1.137.0

This commit is contained in:
Joakim Hulthe
2026-04-05 15:51:10 +00:00
commit 5855251f70
15 changed files with 4434 additions and 0 deletions

287
src/apis/albums.rs Normal file
View File

@@ -0,0 +1,287 @@
//! Albums API - Manage photo albums
use crate::{
Client,
error::Result,
models::{AlbumId, AlbumResponse, AssetId, CreateAlbumRequest, UpdateAlbumRequest},
};
/// API for managing albums
#[derive(Debug, Clone)]
pub struct AlbumsApi {
client: Client,
}
impl AlbumsApi {
/// Create a new albums API instance
pub const fn new(client: Client) -> Self {
Self { client }
}
/// List all albums
pub fn list(&self) -> ListAlbumsBuilder {
ListAlbumsBuilder::new(self.client.clone())
}
/// Get a single album by ID
pub fn get(&self, id: AlbumId) -> GetAlbumBuilder {
GetAlbumBuilder::new(self.client.clone(), id)
}
/// Create a new album
pub fn create(&self) -> CreateAlbumBuilder {
CreateAlbumBuilder::new(self.client.clone())
}
/// Update an album
pub fn update(&self, id: AlbumId) -> UpdateAlbumBuilder {
UpdateAlbumBuilder::new(self.client.clone(), id)
}
/// Delete an album
pub fn delete(&self, id: AlbumId) -> DeleteAlbumBuilder {
DeleteAlbumBuilder::new(self.client.clone(), id)
}
/// Add assets to an album
pub fn add_assets(&self, album_id: AlbumId) -> AddAssetsBuilder {
AddAssetsBuilder::new(self.client.clone(), album_id)
}
/// Remove assets from an album
pub fn remove_assets(&self, album_id: AlbumId) -> RemoveAssetsBuilder {
RemoveAssetsBuilder::new(self.client.clone(), album_id)
}
}
/// Builder for listing albums
#[derive(Debug)]
pub struct ListAlbumsBuilder {
client: Client,
}
impl ListAlbumsBuilder {
/// Create a new list builder
const fn new(client: Client) -> Self {
Self { client }
}
/// Execute the request
pub async fn execute(self) -> Result<Vec<AlbumResponse>> {
let req = self.client.get("/albums");
let response = self.client.execute(req.build()?).await?;
let albums: Vec<AlbumResponse> = response.json().await?;
Ok(albums)
}
}
/// Builder for getting a single album
#[derive(Debug)]
pub struct GetAlbumBuilder {
client: Client,
id: AlbumId,
}
impl GetAlbumBuilder {
/// Create a new get builder
const fn new(client: Client, id: AlbumId) -> Self {
Self { client, id }
}
/// Execute the request
pub async fn execute(self) -> Result<AlbumResponse> {
let path = format!("/albums/{}", self.id);
let req = self.client.get(&path);
let response = self.client.execute(req.build()?).await?;
let album: AlbumResponse = response.json().await?;
Ok(album)
}
}
/// Builder for creating an album
#[derive(Debug)]
pub struct CreateAlbumBuilder {
client: Client,
name: Option<String>,
asset_ids: Vec<AssetId>,
}
impl CreateAlbumBuilder {
/// Create a new create builder
const fn new(client: Client) -> Self {
Self {
client,
name: None,
asset_ids: Vec::new(),
}
}
/// Set the album name
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
/// Add asset IDs to include in the album
pub fn asset_ids(mut self, ids: impl IntoIterator<Item = AssetId>) -> Self {
self.asset_ids.extend(ids);
self
}
/// Execute the request
pub async fn execute(self) -> Result<AlbumResponse> {
let name = self.name.ok_or_else(|| {
crate::error::ImmichError::Validation("Album name is required".to_string())
})?;
let body = CreateAlbumRequest {
album_name: name,
asset_ids: self.asset_ids,
album_users: Vec::new(),
};
let req = self.client.post("/albums").json(&body);
let response = self.client.execute(req.build()?).await?;
let album: AlbumResponse = response.json().await?;
Ok(album)
}
}
/// Builder for updating an album
#[derive(Debug)]
pub struct UpdateAlbumBuilder {
client: Client,
id: AlbumId,
name: Option<String>,
description: Option<String>,
}
impl UpdateAlbumBuilder {
/// Create a new update builder
const fn new(client: Client, id: AlbumId) -> Self {
Self {
client,
id,
name: None,
description: None,
}
}
/// Set the album name
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
/// Set the album description
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
/// Execute the request
pub async fn execute(self) -> Result<AlbumResponse> {
let body = UpdateAlbumRequest {
album_name: self.name,
description: self.description,
};
let path = format!("/albums/{}", self.id);
let req = self.client.patch(&path).json(&body);
let response = self.client.execute(req.build()?).await?;
let album: AlbumResponse = response.json().await?;
Ok(album)
}
}
/// Builder for deleting an album
#[derive(Debug)]
pub struct DeleteAlbumBuilder {
client: Client,
id: AlbumId,
}
impl DeleteAlbumBuilder {
/// Create a new delete builder
const fn new(client: Client, id: AlbumId) -> Self {
Self { client, id }
}
/// Execute the request
pub async fn execute(self) -> Result<()> {
let path = format!("/albums/{}", self.id);
let req = self.client.delete(&path);
let _response = self.client.execute(req.build()?).await?;
Ok(())
}
}
/// Builder for adding assets to an album
#[derive(Debug)]
pub struct AddAssetsBuilder {
client: Client,
album_id: AlbumId,
asset_ids: Vec<AssetId>,
}
impl AddAssetsBuilder {
/// Create a new add assets builder
const fn new(client: Client, album_id: AlbumId) -> Self {
Self {
client,
album_id,
asset_ids: Vec::new(),
}
}
/// Add asset IDs to include
pub fn asset_ids(mut self, ids: impl IntoIterator<Item = AssetId>) -> Self {
self.asset_ids.extend(ids);
self
}
/// Execute the request
pub async fn execute(self) -> Result<AlbumResponse> {
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)
}
}
/// Builder for removing assets from an album
#[derive(Debug)]
pub struct RemoveAssetsBuilder {
client: Client,
album_id: AlbumId,
asset_ids: Vec<AssetId>,
}
impl RemoveAssetsBuilder {
/// Create a new remove assets builder
const fn new(client: Client, album_id: AlbumId) -> Self {
Self {
client,
album_id,
asset_ids: Vec::new(),
}
}
/// Add asset IDs to remove
pub fn asset_ids(mut self, ids: impl IntoIterator<Item = AssetId>) -> Self {
self.asset_ids.extend(ids);
self
}
/// Execute the request
pub async fn execute(self) -> Result<AlbumResponse> {
let path = format!("/albums/{}/assets", self.album_id);
let body = serde_json::json!({ "ids": self.asset_ids });
let req = self.client.delete(&path).json(&body);
let response = self.client.execute(req.build()?).await?;
let album: AlbumResponse = response.json().await?;
Ok(album)
}
}

257
src/apis/assets.rs Normal file
View File

@@ -0,0 +1,257 @@
//! Assets API - Manage photos and videos
use std::path::Path;
use crate::{
Client,
error::{ImmichError, Result},
models::{AssetId, AssetResponse, AssetUploadResponse, DeleteAssetsRequest},
};
/// 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())
}
}
/// 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 mut req = self.client.get("/assets");
if let Some(album_id) = self.album_id {
req = req.query(&[("albumId", album_id.to_string())]);
}
if let Some(is_favorite) = self.is_favorite {
req = req.query(&[("isFavorite", is_favorite.to_string())]);
}
if let Some(is_trashed) = self.is_trashed {
req = req.query(&[("isTrashed", is_trashed.to_string())]);
}
let response = self.client.execute(req.build()?).await?;
let assets: Vec<AssetResponse> = response.json().await?;
Ok(assets)
}
}
/// 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,
}
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,
}
}
/// 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
}
/// 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());
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(())
}
}

12
src/apis/mod.rs Normal file
View File

@@ -0,0 +1,12 @@
//! API modules for interacting with Immich endpoints
pub mod albums;
pub mod assets;
pub mod server;
pub mod timeline;
// Re-export main API modules
pub use albums::AlbumsApi;
pub use assets::AssetsApi;
pub use server::ServerApi;
pub use timeline::TimelineApi;

57
src/apis/server.rs Normal file
View File

@@ -0,0 +1,57 @@
//! Server API - Get server information
use crate::{
Client,
error::Result,
models::{ServerAbout, ServerFeatures, ServerVersion},
};
/// API for server information
#[derive(Debug, Clone)]
pub struct ServerApi {
client: Client,
}
impl ServerApi {
/// Create a new server API instance
pub const fn new(client: Client) -> Self {
Self { client }
}
/// Get server version
pub async fn version(&self) -> Result<ServerVersion> {
let req = self.client.get("/server/version");
let response = self.client.execute(req.build()?).await?;
let version: ServerVersion = response.json().await?;
Ok(version)
}
/// Get server features
pub async fn features(&self) -> Result<ServerFeatures> {
let req = self.client.get("/server/features");
let response = self.client.execute(req.build()?).await?;
let features: ServerFeatures = response.json().await?;
Ok(features)
}
/// Get server about info
pub async fn about(&self) -> Result<ServerAbout> {
let req = self.client.get("/server/about");
let response = self.client.execute(req.build()?).await?;
let about: ServerAbout = response.json().await?;
Ok(about)
}
/// Ping the server
pub async fn ping(&self) -> Result<String> {
let req = self.client.get("/server/ping");
let response = self.client.execute(req.build()?).await?;
let ping_response: serde_json::Value = response.json().await?;
// The ping endpoint typically returns {"res": "pong"}
let res = ping_response
.get("res")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
Ok(res.to_string())
}
}

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

@@ -0,0 +1,395 @@
//! Timeline API - Get time-bucketed views of assets
use crate::{
Client,
error::Result,
models::{AssetId, AssetOrder, AssetVisibility, TimeBucketAssetResponse, TimeBucketResponse},
};
/// API for timeline operations
#[derive(Debug, Clone)]
pub struct TimelineApi {
client: Client,
}
impl TimelineApi {
/// Create a new timeline API instance
pub const fn new(client: Client) -> Self {
Self { client }
}
/// List all time buckets
///
/// Retrieves a list of all minimal time buckets for organizing assets by date.
pub fn buckets(&self) -> ListTimeBucketsBuilder {
ListTimeBucketsBuilder::new(self.client.clone())
}
/// Get assets in a specific time bucket
///
/// Retrieves all asset IDs and metadata for a given time bucket.
pub fn bucket(&self, time_bucket: impl Into<String>) -> GetTimeBucketBuilder {
GetTimeBucketBuilder::new(self.client.clone(), time_bucket.into())
}
}
/// Builder for listing time buckets
#[derive(Debug)]
pub struct ListTimeBucketsBuilder {
client: Client,
album_id: Option<AssetId>,
bbox: Option<String>,
is_favorite: Option<bool>,
is_trashed: Option<bool>,
order: Option<AssetOrder>,
person_id: Option<AssetId>,
tag_id: Option<AssetId>,
user_id: Option<AssetId>,
visibility: Option<AssetVisibility>,
with_coordinates: Option<bool>,
with_partners: Option<bool>,
with_stacked: Option<bool>,
}
impl ListTimeBucketsBuilder {
/// Create a new list time buckets builder
const fn new(client: Client) -> Self {
Self {
client,
album_id: None,
bbox: None,
is_favorite: None,
is_trashed: None,
order: None,
person_id: None,
tag_id: None,
user_id: None,
visibility: None,
with_coordinates: None,
with_partners: None,
with_stacked: None,
}
}
/// Filter by album ID
pub fn album_id(mut self, album_id: AssetId) -> Self {
self.album_id = Some(album_id);
self
}
/// Filter by bounding box coordinates (west,south,east,north in WGS84)
pub fn bbox(mut self, bbox: impl Into<String>) -> Self {
self.bbox = Some(bbox.into());
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
}
/// Set the sort order (ASC for oldest first, DESC for newest first)
pub fn order(mut self, order: AssetOrder) -> Self {
self.order = Some(order);
self
}
/// Filter by person ID (face recognition)
pub fn person_id(mut self, person_id: AssetId) -> Self {
self.person_id = Some(person_id);
self
}
/// Filter by tag ID
pub fn tag_id(mut self, tag_id: AssetId) -> Self {
self.tag_id = Some(tag_id);
self
}
/// Filter by user ID
pub fn user_id(mut self, user_id: AssetId) -> Self {
self.user_id = Some(user_id);
self
}
/// Filter by visibility status
pub fn visibility(mut self, visibility: AssetVisibility) -> Self {
self.visibility = Some(visibility);
self
}
/// Include location data in the response
pub fn with_coordinates(mut self) -> Self {
self.with_coordinates = Some(true);
self
}
/// Include assets shared by partners
pub fn with_partners(mut self) -> Self {
self.with_partners = Some(true);
self
}
/// Include stacked assets (only primary assets from stacks when true)
pub fn with_stacked(mut self) -> Self {
self.with_stacked = Some(true);
self
}
/// Execute the request
pub async fn execute(self) -> Result<Vec<TimeBucketResponse>> {
let mut req = self.client.get("/timeline/buckets");
if let Some(album_id) = self.album_id {
req = req.query(&[("albumId", album_id.to_string())]);
}
if let Some(ref bbox) = self.bbox {
req = req.query(&[("bbox", bbox.as_str())]);
}
if let Some(is_favorite) = self.is_favorite {
req = req.query(&[("isFavorite", is_favorite.to_string())]);
}
if let Some(is_trashed) = self.is_trashed {
req = req.query(&[("isTrashed", is_trashed.to_string())]);
}
if let Some(ref order) = self.order {
let order_str = match order {
AssetOrder::Asc => "ASC",
AssetOrder::Desc => "DESC",
};
req = req.query(&[("order", order_str)]);
}
if let Some(person_id) = self.person_id {
req = req.query(&[("personId", person_id.to_string())]);
}
if let Some(tag_id) = self.tag_id {
req = req.query(&[("tagId", tag_id.to_string())]);
}
if let Some(user_id) = self.user_id {
req = req.query(&[("userId", user_id.to_string())]);
}
if let Some(ref visibility) = self.visibility {
let visibility_str = match visibility {
AssetVisibility::Timeline => "timeline",
AssetVisibility::Archived => "archive",
AssetVisibility::Hidden => "hidden",
AssetVisibility::Locked => "locked",
};
req = req.query(&[("visibility", visibility_str)]);
}
if let Some(true) = self.with_coordinates {
req = req.query(&[("withCoordinates", "true")]);
}
if let Some(true) = self.with_partners {
req = req.query(&[("withPartners", "true")]);
}
if let Some(true) = self.with_stacked {
req = req.query(&[("withStacked", "true")]);
}
let response = self.client.execute(req.build()?).await?;
let buckets: Vec<TimeBucketResponse> = response.json().await?;
Ok(buckets)
}
}
/// Builder for getting a specific time bucket
#[derive(Debug)]
pub struct GetTimeBucketBuilder {
client: Client,
time_bucket: String,
album_id: Option<AssetId>,
bbox: Option<String>,
is_favorite: Option<bool>,
is_trashed: Option<bool>,
order: Option<AssetOrder>,
person_id: Option<AssetId>,
tag_id: Option<AssetId>,
user_id: Option<AssetId>,
visibility: Option<AssetVisibility>,
with_coordinates: Option<bool>,
with_partners: Option<bool>,
with_stacked: Option<bool>,
}
impl GetTimeBucketBuilder {
/// Create a new get time bucket builder
fn new(client: Client, time_bucket: String) -> Self {
Self {
client,
time_bucket,
album_id: None,
bbox: None,
is_favorite: None,
is_trashed: None,
order: None,
person_id: None,
tag_id: None,
user_id: None,
visibility: None,
with_coordinates: None,
with_partners: None,
with_stacked: None,
}
}
/// Filter by album ID
pub fn album_id(mut self, album_id: AssetId) -> Self {
self.album_id = Some(album_id);
self
}
/// Filter by bounding box coordinates (west,south,east,north in WGS84)
pub fn bbox(mut self, bbox: impl Into<String>) -> Self {
self.bbox = Some(bbox.into());
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
}
/// Set the sort order (ASC for oldest first, DESC for newest first)
pub fn order(mut self, order: AssetOrder) -> Self {
self.order = Some(order);
self
}
/// Filter by person ID (face recognition)
pub fn person_id(mut self, person_id: AssetId) -> Self {
self.person_id = Some(person_id);
self
}
/// Filter by tag ID
pub fn tag_id(mut self, tag_id: AssetId) -> Self {
self.tag_id = Some(tag_id);
self
}
/// Filter by user ID
pub fn user_id(mut self, user_id: AssetId) -> Self {
self.user_id = Some(user_id);
self
}
/// Filter by visibility status
pub fn visibility(mut self, visibility: AssetVisibility) -> Self {
self.visibility = Some(visibility);
self
}
/// Include location data in the response
pub fn with_coordinates(mut self) -> Self {
self.with_coordinates = Some(true);
self
}
/// Include assets shared by partners
pub fn with_partners(mut self) -> Self {
self.with_partners = Some(true);
self
}
/// Include stacked assets (only primary assets from stacks when true)
pub fn with_stacked(mut self) -> Self {
self.with_stacked = Some(true);
self
}
/// Execute the request
pub async fn execute(self) -> Result<TimeBucketAssetResponse> {
let mut req = self
.client
.get("/timeline/bucket")
.query(&[("timeBucket", &self.time_bucket)]);
if let Some(album_id) = self.album_id {
req = req.query(&[("albumId", album_id.to_string())]);
}
if let Some(ref bbox) = self.bbox {
req = req.query(&[("bbox", bbox.as_str())]);
}
if let Some(is_favorite) = self.is_favorite {
req = req.query(&[("isFavorite", is_favorite.to_string())]);
}
if let Some(is_trashed) = self.is_trashed {
req = req.query(&[("isTrashed", is_trashed.to_string())]);
}
if let Some(ref order) = self.order {
let order_str = match order {
AssetOrder::Asc => "ASC",
AssetOrder::Desc => "DESC",
};
req = req.query(&[("order", order_str)]);
}
if let Some(person_id) = self.person_id {
req = req.query(&[("personId", person_id.to_string())]);
}
if let Some(tag_id) = self.tag_id {
req = req.query(&[("tagId", tag_id.to_string())]);
}
if let Some(user_id) = self.user_id {
req = req.query(&[("userId", user_id.to_string())]);
}
if let Some(ref visibility) = self.visibility {
let visibility_str = match visibility {
AssetVisibility::Timeline => "timeline",
AssetVisibility::Archived => "archive",
AssetVisibility::Hidden => "hidden",
AssetVisibility::Locked => "locked",
};
req = req.query(&[("visibility", visibility_str)]);
}
if let Some(true) = self.with_coordinates {
req = req.query(&[("withCoordinates", "true")]);
}
if let Some(true) = self.with_partners {
req = req.query(&[("withPartners", "true")]);
}
if let Some(true) = self.with_stacked {
req = req.query(&[("withStacked", "true")]);
}
let response = self.client.execute(req.build()?).await?;
let bucket: TimeBucketAssetResponse = response.json().await?;
Ok(bucket)
}
}

232
src/client.rs Normal file
View File

@@ -0,0 +1,232 @@
//! Client for interacting with the Immich API
use crate::apis::{AlbumsApi, AssetsApi, ServerApi, TimelineApi};
use crate::error::{ImmichError, Result};
use std::time::Duration;
/// Configuration for the Immich client
#[derive(Debug, Clone)]
pub struct Config {
/// Base URL of the Immich server
pub base_url: String,
/// API key for authentication
pub api_key: Option<String>,
/// Request timeout
pub timeout: Duration,
/// User agent string
pub user_agent: String,
}
impl Default for Config {
fn default() -> Self {
Self {
base_url: String::new(),
api_key: None,
timeout: Duration::from_secs(30),
user_agent: format!("immich-sdk/{} (Rust)", env!("CARGO_PKG_VERSION")),
}
}
}
impl Config {
/// Create a new config with the given base URL
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
..Default::default()
}
}
/// Set the API key
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
/// Set the timeout
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// Set a custom user agent
pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
}
/// Client for making requests to the Immich API
#[derive(Debug, Clone)]
pub struct Client {
config: Config,
http: reqwest::Client,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Config) -> Result<Self> {
// Validate base URL
let base_url = if config.base_url.ends_with('/') {
config.base_url.trim_end_matches('/').to_string()
} else {
config.base_url.clone()
};
if base_url.is_empty() {
return Err(ImmichError::Config("Base URL cannot be empty".to_string()));
}
// Parse to validate URL
let _ = url::Url::parse(&base_url)?;
let http = reqwest::Client::builder()
.timeout(config.timeout)
.user_agent(&config.user_agent)
.build()?;
Ok(Self {
config: Config { base_url, ..config },
http,
})
}
/// Create a client from a base URL string
pub fn from_url(base_url: impl Into<String>) -> Result<Self> {
Self::new(Config::new(base_url))
}
/// 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
}
/// Get the configuration
pub fn config(&self) -> &Config {
&self.config
}
/// Get the base URL
pub fn base_url(&self) -> &str {
&self.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()
}
/// Get the underlying HTTP client
pub fn http(&self) -> &reqwest::Client {
&self.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);
// Add API key authentication
if let Some(ref api_key) = self.config.api_key {
builder = builder.header("x-api-key", api_key);
}
builder
}
/// 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?;
if response.status().is_success() {
Ok(response)
} else {
Err(ImmichError::from_response(response).await)
}
}
/// Make a GET request
pub fn get(&self, path: &str) -> reqwest::RequestBuilder {
self.request(reqwest::Method::GET, path)
}
/// Make a POST request
pub fn post(&self, path: &str) -> reqwest::RequestBuilder {
self.request(reqwest::Method::POST, path)
}
/// Make a PUT request
pub fn put(&self, path: &str) -> reqwest::RequestBuilder {
self.request(reqwest::Method::PUT, path)
}
/// Make a DELETE request
pub fn delete(&self, path: &str) -> reqwest::RequestBuilder {
self.request(reqwest::Method::DELETE, path)
}
/// Make a PATCH request
pub fn patch(&self, path: &str) -> reqwest::RequestBuilder {
self.request(reqwest::Method::PATCH, path)
}
/// Access the albums API
pub fn albums(&self) -> AlbumsApi {
AlbumsApi::new(self.clone())
}
/// Access the assets API
pub fn assets(&self) -> AssetsApi {
AssetsApi::new(self.clone())
}
/// Access the server API
pub fn server(&self) -> ServerApi {
ServerApi::new(self.clone())
}
/// Access the timeline API
pub fn timeline(&self) -> TimelineApi {
TimelineApi::new(self.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_builder() {
let config = Config::new("https://example.com")
.with_api_key("test-key")
.with_timeout(Duration::from_secs(60));
assert_eq!(config.base_url, "https://example.com");
assert_eq!(config.api_key, Some("test-key".to_string()));
assert_eq!(config.timeout, Duration::from_secs(60));
}
#[test]
fn test_client_creation() {
let client = Client::from_url("https://example.com").unwrap();
assert_eq!(client.base_url(), "https://example.com");
assert!(!client.has_api_key());
let config = Config::new("https://example.com").with_api_key("test-key");
let client = Client::new(config).unwrap();
assert!(client.has_api_key());
}
#[test]
fn test_url_normalization() {
let client = Client::from_url("https://example.com/").unwrap();
assert_eq!(client.base_url(), "https://example.com");
}
#[test]
fn test_empty_url_error() {
let result = Client::from_url("");
assert!(result.is_err());
}
}

113
src/error.rs Normal file
View File

@@ -0,0 +1,113 @@
//! Error types for the Immich SDK
use thiserror::Error;
/// Main error type for the Immich SDK
#[derive(Error, Debug)]
pub enum ImmichError {
/// HTTP request failed
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
/// Authentication failed
#[error("Authentication failed: {0}")]
Authentication(String),
/// Resource not found
#[error("Resource not found: {0}")]
NotFound(String),
/// Rate limit exceeded
#[error("Rate limit exceeded. Retry after: {0:?}")]
RateLimited(Option<std::time::Duration>),
/// API returned an error
#[error("API error: {status} - {message}")]
Api {
/// HTTP status code
status: reqwest::StatusCode,
/// Error message from API
message: String,
},
/// Serialization/deserialization error
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
/// URL parsing error
#[error("URL error: {0}")]
Url(#[from] url::ParseError),
/// Invalid configuration
#[error("Invalid configuration: {0}")]
Config(String),
/// Validation error
#[error("Validation error: {0}")]
Validation(String),
/// File I/O error
#[error("File error: {0}")]
File(#[from] std::io::Error),
/// Unknown error
#[error("Unknown error: {0}")]
Unknown(String),
}
/// Result type alias for Immich SDK
pub type Result<T> = std::result::Result<T, ImmichError>;
/// HTTP status code mapping for common errors
impl ImmichError {
/// Create an error from an HTTP response
pub async fn from_response(response: reqwest::Response) -> Self {
let status = response.status();
let message = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
match status {
reqwest::StatusCode::UNAUTHORIZED => ImmichError::Authentication(message),
reqwest::StatusCode::NOT_FOUND => ImmichError::NotFound(message),
reqwest::StatusCode::TOO_MANY_REQUESTS => {
// Parse retry-after header if present
let retry_after = None; // Could parse header here
ImmichError::RateLimited(retry_after)
}
_ => ImmichError::Api { status, message },
}
}
/// Check if the error is a rate limit error
pub fn is_rate_limited(&self) -> bool {
matches!(self, ImmichError::RateLimited(_))
}
/// Check if the error is an authentication error
pub fn is_auth_error(&self) -> bool {
matches!(self, ImmichError::Authentication(_))
}
/// Check if the error is a "not found" error
pub fn is_not_found(&self) -> bool {
matches!(self, ImmichError::NotFound(_))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_types() {
let auth_err = ImmichError::Authentication("Invalid API key".to_string());
assert!(auth_err.is_auth_error());
assert!(!auth_err.is_not_found());
let not_found_err = ImmichError::NotFound("Asset not found".to_string());
assert!(not_found_err.is_not_found());
assert!(!not_found_err.is_auth_error());
}
}

58
src/lib.rs Normal file
View File

@@ -0,0 +1,58 @@
//! # Immich SDK
//!
//! A modern Rust SDK for the [Immich](https://immich.app/) photo and video management server.
//!
//! ## Features
//!
//! - **Async-first**: Built on `tokio` and `reqwest` for modern async Rust
//! - **Builder pattern**: Ergonomic API with fluent builders
//! - **Type-safe**: Strongly typed models
//! - **Error handling**: Comprehensive error types with `thiserror`
//!
//! ## Quick Start
//!
//! ```rust,no_run
//! use immich_sdk::Client;
//!
//! #[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");
//!
//! // List albums
//! let albums = client.albums().list().execute().await?;
//! println!("Found {} albums", albums.len());
//!
//! Ok(())
//! }
//! ```
//!
//! ## Authentication
//!
//! The SDK supports API key authentication:
//!
//! ```rust,ignore
//! use immich_sdk::Client;
//!
//! let client = Client::from_url("https://immich.example.com")?
//! .with_api_key("your-api-key-here");
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
#![warn(missing_docs)]
pub mod apis;
pub mod client;
pub mod error;
pub mod models;
// Re-export main types
pub use client::{Client, Config};
pub use error::{ImmichError, Result};
// Re-export models
pub use models::*;
/// Immich API version this SDK targets
pub const IMMICH_API_VERSION: &str = env!("CARGO_PKG_VERSION");

353
src/models/mod.rs Normal file
View File

@@ -0,0 +1,353 @@
//! Data models for the Immich API
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Asset ID type alias
pub type AssetId = Uuid;
/// Album ID type alias
pub type AlbumId = Uuid;
/// User ID type alias
pub type UserId = Uuid;
/// Asset response from the API
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssetResponse {
/// Asset ID
pub id: AssetId,
/// Device asset ID
pub device_asset_id: String,
/// Device ID
pub device_id: String,
/// Asset type (IMAGE or VIDEO)
#[serde(rename = "type")]
pub asset_type: AssetType,
/// Original file name
pub original_file_name: String,
/// Original mime type
pub original_mime_type: String,
/// File size in bytes
pub exif_info: Option<ExifInfo>,
/// Whether asset is a favorite
pub is_favorite: bool,
/// Whether asset is archived
pub is_archived: bool,
/// Whether asset is trashed
pub is_trashed: bool,
/// Created at timestamp
pub created_at: DateTime<Utc>,
/// Updated at timestamp
pub updated_at: DateTime<Utc>,
}
/// Asset type enumeration
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "UPPERCASE")]
pub enum AssetType {
/// Image file
Image,
/// Video file
Video,
}
/// Asset visibility enumeration
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AssetVisibility {
/// Visible in timeline
Timeline,
/// Archived
Archived,
/// Hidden
Hidden,
/// Locked
Locked,
}
/// EXIF information for an asset
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExifInfo {
/// File size in bytes
pub file_size_in_byte: Option<i64>,
/// Image dimensions
pub exif_image_height: Option<i32>,
/// Image width
pub exif_image_width: Option<i32>,
/// Orientation
pub orientation: Option<String>,
/// Date taken
pub date_time_original: Option<DateTime<Utc>>,
/// GPS latitude
pub latitude: Option<f64>,
/// GPS longitude
pub longitude: Option<f64>,
/// Camera make
pub make: Option<String>,
/// Camera model
pub model: Option<String>,
}
/// Album response from the API
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AlbumResponse {
/// Album ID
pub id: AlbumId,
/// Album name
pub album_name: String,
/// Album description
pub description: String,
/// Album cover thumbnail asset ID
pub album_thumbnail_asset_id: Option<AssetId>,
/// Number of assets in album
pub asset_count: i64,
/// Assets in the album
pub assets: Vec<AssetResponse>,
/// Created at timestamp
pub created_at: DateTime<Utc>,
/// Updated at timestamp
pub updated_at: DateTime<Utc>,
/// Owner ID
pub owner_id: UserId,
}
/// User response from the API
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserResponse {
/// User ID
pub id: UserId,
/// User email
pub email: String,
/// User name
pub name: String,
/// Whether user is admin
pub is_admin: bool,
/// Whether user has OAuth enabled
pub oauth_enabled: bool,
/// Storage usage in bytes
pub storage_usage_in_bytes: i64,
/// Created at timestamp
pub created_at: DateTime<Utc>,
}
/// Server version information
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerVersion {
/// Major version
pub major: i32,
/// Minor version
pub minor: i32,
/// Patch version
pub patch: i32,
}
impl std::fmt::Display for ServerVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
/// Server features information
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerFeatures {
/// Whether OAuth is enabled
pub oauth: bool,
/// Whether OAuth auto launch is enabled
pub oauth_auto_launch: bool,
/// Whether password login is enabled
pub password_login: bool,
/// Whether config file is present
pub config_file: bool,
}
/// Server about information
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerAbout {
/// Version information
pub version: ServerVersion,
/// Version string
pub version_url: String,
}
/// Create album request
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct CreateAlbumRequest {
/// Album name
pub album_name: String,
/// Asset IDs to add to album
pub asset_ids: Vec<AssetId>,
/// User IDs to share with
pub album_users: Vec<AlbumUserCreate>,
}
/// Album user creation info
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AlbumUserCreate {
/// User ID
pub user_id: UserId,
/// Role (viewer or editor)
pub role: AlbumUserRole,
}
/// Album user role
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AlbumUserRole {
/// Can only view
Viewer,
/// Can edit
Editor,
}
/// Update album request
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAlbumRequest {
/// Album name
pub album_name: Option<String>,
/// Description
pub description: Option<String>,
}
/// Asset upload response
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssetUploadResponse {
/// Upload status
pub status: AssetUploadStatus,
/// Asset ID if successful
pub id: Option<AssetId>,
/// Duplicate ID if duplicate
pub duplicate: Option<AssetId>,
}
/// Asset upload status
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AssetUploadStatus {
/// Upload created new asset
Created,
/// Asset already exists
Duplicate,
/// Upload rejected
Rejected,
}
/// Delete assets request
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteAssetsRequest {
/// Asset IDs to delete
pub ids: Vec<AssetId>,
/// Force delete (skip trash)
pub force: bool,
}
/// API key response
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiKeyResponse {
/// Key ID
pub id: String,
/// Key name
pub name: String,
/// Created at
pub created_at: DateTime<Utc>,
/// Updated at
pub updated_at: DateTime<Utc>,
}
/// Asset order enumeration
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AssetOrder {
/// Oldest first
Asc,
/// Newest first
Desc,
}
/// Time bucket response
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TimeBucketResponse {
/// Number of assets in this time bucket
pub count: i64,
/// Time bucket identifier in YYYY-MM-DD format
pub time_bucket: String,
}
/// Time bucket asset response - contains arrays of asset data
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TimeBucketAssetResponse {
/// Array of city names extracted from EXIF GPS data
pub city: Vec<Option<String>>,
/// Array of country names extracted from EXIF GPS data
pub country: Vec<Option<String>>,
/// Array of video durations in HH:MM:SS format (null for images)
pub duration: Vec<Option<String>>,
/// Array of file creation timestamps in UTC
pub file_created_at: Vec<String>,
/// Array of asset IDs in the time bucket
pub id: Vec<String>,
/// Array indicating whether each asset is favorited
pub is_favorite: Vec<bool>,
/// Array indicating whether each asset is an image (false for videos)
pub is_image: Vec<bool>,
/// Array indicating whether each asset is in the trash
pub is_trashed: Vec<bool>,
/// Array of latitude coordinates extracted from EXIF GPS data
pub latitude: Vec<Option<f64>>,
/// Array of live photo video asset IDs (null for non-live photos)
pub live_photo_video_id: Vec<Option<String>>,
/// Array of UTC offset hours at the time each photo was taken
pub local_offset_hours: Vec<f64>,
/// Array of longitude coordinates extracted from EXIF GPS data
pub longitude: Vec<Option<f64>>,
/// Array of owner IDs for each asset
pub owner_id: Vec<String>,
/// Array of projection types for 360° content
pub projection_type: Vec<Option<String>>,
/// Array of aspect ratios (width/height) for each asset
pub ratio: Vec<f64>,
/// Array of stack information as [stackId, assetCount] tuples
pub stack: Vec<Option<Vec<String>>>,
/// Array of BlurHash strings for generating asset previews
pub thumbhash: Vec<Option<String>>,
/// Array of visibility statuses for each asset
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");
}
}