Skip to main content

mimick/api_client/
upload_helpers.rs

1//! Upload-related helper functions: MIME detection, timezone fixup, file timestamps.
2//!
3//! Pure utility functions shared by the upload pipeline. Includes IANA
4//! timezone resolution from `/etc/localtime` or `$TZ`, ISO 8601
5//! formatting, and filesystem timestamp normalization.
6
7use std::path::Path;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use chrono::{SecondsFormat, TimeZone, Utc};
11use reqwest::Client;
12
13/// Resolve the standard MIME type for a given local file path.
14pub(super) fn mime_for_path(path: &Path) -> &'static str {
15    crate::media_kinds::mime_for_path(path)
16}
17
18/// Make a PUT request to the server to update the timezone offset of a specific asset.
19pub(super) async fn apply_asset_timezone_fixup(
20    client: &Client,
21    api_key: &str,
22    base_url: &str,
23    asset_id: &str,
24    time_zone: &str,
25) {
26    let url = format!("{}/api/assets", base_url);
27    let body = serde_json::json!({
28        "ids": [asset_id],
29        "timeZone": time_zone,
30    });
31
32    match client
33        .put(&url)
34        .header("x-api-key", api_key)
35        .header("Accept", "application/json")
36        .json(&body)
37        .send()
38        .await
39    {
40        Ok(resp) if resp.status().is_success() => {
41            log::debug!("Updated timezone for asset {} to {}", asset_id, time_zone);
42        }
43        Ok(resp) => {
44            let status = resp.status();
45            let body = resp.text().await.unwrap_or_default();
46            log::warn!(
47                "Uploaded asset {} but failed to update timezone [{}]: {}",
48                asset_id,
49                status,
50                body
51            );
52        }
53        Err(e) => {
54            log::warn!(
55                "Uploaded asset {} but timezone update request failed: {}",
56                asset_id,
57                e
58            );
59        }
60    }
61}
62
63/// Retrieve and normalize the creation and modification timestamps of a local file.
64pub(super) fn file_timestamps(meta: &std::fs::Metadata) -> (u64, u64) {
65    let now = SystemTime::now()
66        .duration_since(UNIX_EPOCH)
67        .unwrap_or_default()
68        .as_secs();
69
70    let created = meta.created().ok().and_then(system_time_to_unix_secs);
71    let modified = meta.modified().ok().and_then(system_time_to_unix_secs);
72    let (created, modified) = normalize_file_timestamps(created, modified, now);
73
74    (created, modified)
75}
76
77/// Convert a standard `SystemTime` to standard unix epoch seconds.
78pub(super) fn system_time_to_unix_secs(time: SystemTime) -> Option<u64> {
79    time.duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs())
80}
81
82/// Coalesce and normalize creation/modification timestamps using standard rules.
83pub(super) fn normalize_file_timestamps(
84    created: Option<u64>,
85    modified: Option<u64>,
86    now: u64,
87) -> (u64, u64) {
88    // Birth time is frequently the copy/import time on Linux and for moved files.
89    // Use the earliest available filesystem timestamp as the asset creation time so
90    // Immich's timeline is closer to the media's original timestamp.
91    let created = match (created, modified) {
92        (Some(created), Some(modified)) => created.min(modified),
93        (Some(created), None) => created,
94        (None, Some(modified)) => modified,
95        (None, None) => now,
96    };
97
98    let modified = modified.unwrap_or(created);
99
100    (created, modified)
101}
102
103/// Convert standard unix epoch seconds to standard UTC ISO 8601 formatted string.
104pub(super) fn unix_to_utc_iso8601(secs: u64) -> String {
105    Utc.timestamp_opt(secs as i64, 0)
106        .single()
107        .map(|dt| dt.to_rfc3339_opts(SecondsFormat::Millis, true))
108        .unwrap_or_else(|| "1970-01-01T00:00:00.000+00:00".to_string())
109}
110
111/// Resolve the user's current local timezone name (IANA format) from system files or environment.
112pub(super) fn local_timezone_name() -> Option<String> {
113    if let Ok(tz) = std::env::var("TZ") {
114        let tz = tz.trim().trim_start_matches(':');
115        if looks_like_iana_timezone(tz) {
116            return Some(tz.to_string());
117        }
118    }
119
120    if let Ok(target) = std::fs::read_link("/etc/localtime")
121        && let Some(path) = target.to_str()
122        && let Some((_, tz)) = path.split_once("/zoneinfo/")
123        && looks_like_iana_timezone(tz)
124    {
125        return Some(tz.to_string());
126    }
127
128    if let Ok(tz) = std::fs::read_to_string("/etc/timezone") {
129        let tz = tz.trim();
130        if looks_like_iana_timezone(tz) {
131            return Some(tz.to_string());
132        }
133    }
134
135    None
136}
137
138/// Check if the timezone name string strictly matches IANA zone style.
139pub(super) fn looks_like_iana_timezone(value: &str) -> bool {
140    !value.is_empty() && value.contains('/') && !value.contains(' ')
141}