1use super::ImmichApiClient;
4
5#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
7#[serde(rename_all = "camelCase")]
8pub struct SharedLink {
9 pub id: String,
10 pub key: String,
12 #[serde(default)]
14 pub slug: Option<String>,
15 #[serde(rename = "type")]
17 pub link_type: String,
18 #[serde(default)]
19 pub description: Option<String>,
20 #[serde(default)]
22 pub password: Option<String>,
23 #[serde(default)]
24 pub expires_at: Option<String>,
25 #[serde(default)]
26 pub created_at: String,
27 #[serde(default)]
28 pub allow_download: bool,
29 #[serde(default)]
30 pub allow_upload: bool,
31 #[serde(default)]
32 pub show_metadata: bool,
33 #[serde(default)]
34 pub album: Option<SharedLinkAlbum>,
35 #[serde(default)]
36 pub assets: Vec<SharedLinkAsset>,
37}
38
39#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
40#[serde(rename_all = "camelCase")]
41pub struct SharedLinkAlbum {
42 pub id: String,
43 pub album_name: String,
44}
45
46#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
47pub struct SharedLinkAsset {
48 pub id: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct SharedLinkOptions {
54 pub description: String,
55 pub password: String,
57 pub expires_at: Option<String>,
59 pub allow_download: bool,
60 pub allow_upload: bool,
61 pub show_metadata: bool,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum SharedLinkTarget {
67 Album(String),
68 Assets(Vec<String>),
69}
70
71fn create_body(target: &SharedLinkTarget, options: &SharedLinkOptions) -> serde_json::Value {
73 let mut body = options_body(options);
74 match target {
75 SharedLinkTarget::Album(album_id) => {
76 body["type"] = "ALBUM".into();
77 body["albumId"] = album_id.clone().into();
78 }
79 SharedLinkTarget::Assets(asset_ids) => {
80 body["type"] = "INDIVIDUAL".into();
81 body["assetIds"] = asset_ids.clone().into();
82 }
83 }
84 if let Some(expires_at) = &options.expires_at {
85 body["expiresAt"] = expires_at.clone().into();
86 }
87 body
88}
89
90fn edit_body(options: &SharedLinkOptions) -> serde_json::Value {
93 let mut body = options_body(options);
94 body["changeExpiryTime"] = true.into();
95 if let Some(expires_at) = &options.expires_at {
96 body["expiresAt"] = expires_at.clone().into();
97 }
98 body
99}
100
101fn options_body(options: &SharedLinkOptions) -> serde_json::Value {
102 let mut body = serde_json::json!({
103 "description": options.description,
104 "allowDownload": options.allow_download,
105 "allowUpload": options.allow_upload,
106 "showMetadata": options.show_metadata,
107 });
108 if !options.password.is_empty() {
109 body["password"] = options.password.clone().into();
110 }
111 body
112}
113
114pub fn share_url(base: &str, link: &SharedLink) -> String {
116 let base = base.trim_end_matches('/');
117 match link.slug.as_deref().filter(|s| !s.is_empty()) {
118 Some(slug) => format!("{base}/s/{slug}"),
119 None => format!("{base}/share/{}", link.key),
120 }
121}
122
123impl ImmichApiClient {
124 pub async fn fetch_shared_links(&self) -> Result<Vec<SharedLink>, String> {
126 let resp = self
127 .send_json(reqwest::Method::GET, "/api/shared-links", None)
128 .await?;
129 let mut links: Vec<SharedLink> = resp.json().await.map_err(|err| err.to_string())?;
130 links.sort_by(|a, b| b.created_at.cmp(&a.created_at));
131 Ok(links)
132 }
133
134 pub async fn create_shared_link(
135 &self,
136 target: &SharedLinkTarget,
137 options: &SharedLinkOptions,
138 ) -> Result<SharedLink, String> {
139 let body = create_body(target, options);
140 let resp = self
141 .send_json(reqwest::Method::POST, "/api/shared-links", Some(body))
142 .await?;
143 resp.json().await.map_err(|err| err.to_string())
144 }
145
146 pub async fn edit_shared_link(
147 &self,
148 link_id: &str,
149 options: &SharedLinkOptions,
150 ) -> Result<SharedLink, String> {
151 let path = format!("/api/shared-links/{link_id}");
152 let resp = self
153 .send_json(reqwest::Method::PATCH, &path, Some(edit_body(options)))
154 .await?;
155 resp.json().await.map_err(|err| err.to_string())
156 }
157
158 pub async fn delete_shared_link(&self, link_id: &str) -> Result<(), String> {
159 let path = format!("/api/shared-links/{link_id}");
160 self.send_json(reqwest::Method::DELETE, &path, None)
161 .await
162 .map(|_| ())
163 }
164
165 pub async fn shared_link_base(&self) -> Result<String, String> {
168 #[derive(serde::Deserialize)]
169 #[serde(rename_all = "camelCase")]
170 struct ServerConfig {
171 #[serde(default)]
172 external_domain: String,
173 }
174 let configured = match self
175 .send_json(reqwest::Method::GET, "/api/server/config", None)
176 .await
177 {
178 Ok(resp) => resp
179 .json::<ServerConfig>()
180 .await
181 .map(|c| c.external_domain)
182 .unwrap_or_default(),
183 Err(_) => String::new(),
184 };
185 if !configured.trim().is_empty() {
186 return Ok(configured);
187 }
188 self.get_active_url()
189 .await
190 .ok_or_else(|| "No active connection".to_string())
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 fn options() -> SharedLinkOptions {
199 SharedLinkOptions {
200 description: "Trip".into(),
201 password: String::new(),
202 expires_at: None,
203 allow_download: true,
204 allow_upload: false,
205 show_metadata: true,
206 }
207 }
208
209 fn link(slug: Option<&str>) -> SharedLink {
210 serde_json::from_value(serde_json::json!({
211 "id": "l1",
212 "key": "abc123",
213 "slug": slug,
214 "type": "ALBUM",
215 "createdAt": "2026-10-01T00:00:00.000Z",
216 "allowDownload": true,
217 "allowUpload": false,
218 "showMetadata": true,
219 "album": { "id": "a1", "albumName": "Trips" },
220 "assets": []
221 }))
222 .unwrap()
223 }
224
225 #[test]
226 fn create_body_for_album_and_assets() {
227 let album = create_body(&SharedLinkTarget::Album("a1".into()), &options());
228 assert_eq!(album["type"], "ALBUM");
229 assert_eq!(album["albumId"], "a1");
230 assert!(album.get("expiresAt").is_none());
231 assert!(album.get("password").is_none());
232
233 let with_expiry = SharedLinkOptions {
234 expires_at: Some("2026-11-01T00:00:00Z".into()),
235 password: "secret".into(),
236 ..options()
237 };
238 let assets = create_body(&SharedLinkTarget::Assets(vec!["x".into()]), &with_expiry);
239 assert_eq!(assets["type"], "INDIVIDUAL");
240 assert_eq!(assets["assetIds"], serde_json::json!(["x"]));
241 assert_eq!(assets["expiresAt"], "2026-11-01T00:00:00Z");
242 assert_eq!(assets["password"], "secret");
243 }
244
245 #[test]
246 fn edit_body_clears_expiry_with_change_flag() {
247 let body = edit_body(&options());
248 assert_eq!(body["changeExpiryTime"], true);
249 assert!(body.get("expiresAt").is_none());
250 assert_eq!(body["allowDownload"], true);
251 }
252
253 #[test]
254 fn share_url_prefers_slug_and_trims_slash() {
255 assert_eq!(
256 share_url("https://photos.example.com/", &link(None)),
257 "https://photos.example.com/share/abc123"
258 );
259 assert_eq!(
260 share_url("https://photos.example.com", &link(Some("trips"))),
261 "https://photos.example.com/s/trips"
262 );
263 }
264
265 #[test]
266 fn shared_link_reads_album_name() {
267 assert_eq!(link(None).album.unwrap().album_name, "Trips");
268 }
269}