Skip to main content

pictureframe_common/
lib.rs

1use std::fmt::Display;
2
3use chrono::NaiveDateTime;
4use reqwasm::http::Request;
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8/// Three minutes in seconds
9const THREE_MINS: u32 = 3 * 60;
10
11#[derive(Debug, Clone, Error)]
12pub enum ApiError {
13    #[error("Network error: {0}")]
14    Network(String),
15    #[error("HTTP {status} error: {message}")]
16    Http { status: u16, message: String },
17    #[error("Serialization error: {0}")]
18    Serialization(String),
19    #[error("Deserialization error: {0}")]
20    Deserialization(String),
21}
22
23#[derive(Debug, Serialize, Deserialize)]
24pub enum Update<T> {
25    Set(T),
26    Remove,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30pub struct AlbumID(pub i32);
31
32impl Display for AlbumID {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        write!(f, "{}", self.0)
35    }
36}
37
38impl From<i32> for AlbumID {
39    fn from(id: i32) -> Self {
40        Self(id)
41    }
42}
43
44#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
45pub struct PhotoID(pub i32);
46
47impl Display for PhotoID {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        write!(f, "{}", self.0)
50    }
51}
52
53impl From<i32> for PhotoID {
54    fn from(id: i32) -> Self {
55        Self(id)
56    }
57}
58
59#[derive(Debug)]
60pub struct UploadPhotoRequest {
61    title: Option<String>,
62    artist: Option<String>,
63    copyright: Option<String>,
64    date_taken: Option<NaiveDateTime>,
65    albums: Vec<AlbumID>,
66}
67
68#[derive(Debug, Serialize, Deserialize)]
69pub struct UpdatePhotoRequest {
70    pub title: Option<Update<String>>,
71    pub artist: Option<Update<String>>,
72    pub copyright: Option<Update<String>>,
73    pub date_taken: Option<Update<NaiveDateTime>>,
74}
75
76#[derive(Debug, Serialize, Deserialize)]
77pub struct CreateAlbumRequest {
78    pub name: String,
79    pub notes: Option<String>,
80}
81
82#[derive(Debug, Serialize, Deserialize)]
83pub struct UpdateAlbumRequest {
84    pub name: Option<String>,
85    pub notes: Option<Update<String>>,
86}
87
88/// Response struct for an album request
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct Album {
91    pub id: AlbumID,
92    pub name: String,
93    pub notes: Option<String>,
94    pub photos: Vec<PhotoID>,
95}
96
97/// Response struct for a photo request
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct Photo {
100    pub id: PhotoID,
101    pub url: String,
102    pub title: Option<String>,
103    pub notes: Option<String>,
104    pub artist: Option<String>,
105    pub copyright: Option<String>,
106    pub date_taken: Option<NaiveDateTime>,
107}
108
109#[derive(Debug, Serialize, Deserialize)]
110pub struct UpdateSettingsRequest {
111    pub current_album_id: Option<Update<AlbumID>>,
112    pub interval_seconds: Option<i32>,
113}
114
115#[derive(Debug, Serialize, Deserialize, Copy, Clone)]
116pub struct Interval(pub u32);
117
118impl Interval {
119    pub fn from_seconds(seconds: u32) -> Self {
120        Self(seconds)
121    }
122
123    pub fn seconds(&self) -> u32 {
124        self.0
125    }
126}
127
128impl Default for Interval {
129    fn default() -> Self {
130        Self(THREE_MINS)
131    }
132}
133
134impl From<i32> for Interval {
135    fn from(seconds: i32) -> Self {
136        Self(seconds as u32)
137    }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct Next {
142    pub photo: Photo,
143    pub interval: Interval,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct CurrentAlbum {
148    /// Current album to serve images from
149    pub album: AlbumID,
150    /// Index of currently displayed image in album
151    pub index: usize,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct RotationSettings {
156    pub current_album: Option<CurrentAlbum>,
157    /// Number of seconds until next image
158    pub interval: Interval,
159}
160
161impl Default for RotationSettings {
162    fn default() -> Self {
163        Self {
164            current_album: None,
165            interval: Interval::default(),
166        }
167    }
168}
169
170#[derive(Debug, Clone)]
171pub struct Client {
172    base_url: String,
173}
174
175impl Client {
176    /// Build a full URL from a relative path
177    fn build_url(&self, path: impl AsRef<str>) -> String {
178        format!("{}{}", self.base_url.trim_end_matches('/'), path.as_ref())
179    }
180
181    /// Generic GET request
182    async fn get<T>(&self, path: impl AsRef<str>) -> Result<T, ApiError>
183    where
184        T: for<'de> Deserialize<'de>,
185    {
186        let url = self.build_url(path);
187        let response = Request::get(&url)
188            .send()
189            .await
190            .map_err(|e| ApiError::Network(e.to_string()))?;
191
192        if !response.ok() {
193            return Err(ApiError::Http {
194                status: response.status(),
195                message: response.status_text(),
196            });
197        }
198
199        response
200            .json::<T>()
201            .await
202            .map_err(|e| ApiError::Deserialization(e.to_string()))
203    }
204
205    /// Generic POST request
206    async fn post<T, B>(&self, path: impl AsRef<str>, body: &B) -> Result<T, ApiError>
207    where
208        T: for<'de> Deserialize<'de>,
209        B: Serialize,
210    {
211        let url = self.build_url(path);
212        let body_json =
213            serde_json::to_string(body).map_err(|e| ApiError::Serialization(e.to_string()))?;
214
215        let response = Request::post(&url)
216            .header("Content-Type", "application/json")
217            .body(body_json)
218            .send()
219            .await
220            .map_err(|e| ApiError::Network(e.to_string()))?;
221
222        if !response.ok() {
223            return Err(ApiError::Http {
224                status: response.status(),
225                message: response.status_text(),
226            });
227        }
228
229        response
230            .json::<T>()
231            .await
232            .map_err(|e| ApiError::Deserialization(e.to_string()))
233    }
234
235    /// Generic PUT request
236    async fn put<T, B>(&self, path: impl AsRef<str>, body: &B) -> Result<T, ApiError>
237    where
238        T: for<'de> Deserialize<'de>,
239        B: Serialize,
240    {
241        let url = self.build_url(path);
242        let body_json =
243            serde_json::to_string(body).map_err(|e| ApiError::Serialization(e.to_string()))?;
244
245        let response = Request::put(&url)
246            .header("Content-Type", "application/json")
247            .body(body_json)
248            .send()
249            .await
250            .map_err(|e| ApiError::Network(e.to_string()))?;
251
252        if !response.ok() {
253            return Err(ApiError::Http {
254                status: response.status(),
255                message: response.status_text(),
256            });
257        }
258
259        response
260            .json::<T>()
261            .await
262            .map_err(|e| ApiError::Deserialization(e.to_string()))
263    }
264
265    /// Generic DELETE request
266    async fn delete<T>(&self, path: impl AsRef<str>) -> Result<T, ApiError>
267    where
268        T: for<'de> Deserialize<'de>,
269    {
270        let url = self.build_url(path);
271        let response = Request::delete(&url)
272            .send()
273            .await
274            .map_err(|e| ApiError::Network(e.to_string()))?;
275
276        if !response.ok() {
277            return Err(ApiError::Http {
278                status: response.status(),
279                message: response.status_text(),
280            });
281        }
282
283        response
284            .json::<T>()
285            .await
286            .map_err(|e| ApiError::Deserialization(e.to_string()))
287    }
288}
289
290/// Public API
291impl Client {
292    pub fn new(base_url: impl Into<String>) -> Self {
293        Self {
294            base_url: base_url.into(),
295        }
296    }
297
298    // ─────────────────────────────────────────────────────────────────────────
299    // Next (for viewer)
300    // ─────────────────────────────────────────────────────────────────────────
301
302    pub async fn get_next(&self) -> Result<Next, ApiError> {
303        self.get("/api/next").await
304    }
305
306    // ─────────────────────────────────────────────────────────────────────────
307    // Photos
308    // ─────────────────────────────────────────────────────────────────────────
309
310    pub async fn get_photos(&self) -> Result<Vec<Photo>, ApiError> {
311        self.get("/api/photos").await
312    }
313
314    pub async fn get_photo(&self, id: PhotoID) -> Result<Photo, ApiError> {
315        self.get(format!("/api/photos/{id}")).await
316    }
317
318    pub async fn update_photo(
319        &self,
320        id: PhotoID,
321        updates: &UpdatePhotoRequest,
322    ) -> Result<(), ApiError> {
323        self.put(format!("/api/photos/{id}"), updates).await
324    }
325
326    pub async fn delete_photo(&self, id: PhotoID) -> Result<(), ApiError> {
327        self.delete(format!("/api/photos/{id}")).await
328    }
329
330    // ─────────────────────────────────────────────────────────────────────────
331    // Albums
332    // ─────────────────────────────────────────────────────────────────────────
333
334    pub async fn get_albums(&self) -> Result<Vec<Album>, ApiError> {
335        self.get("/api/albums").await
336    }
337
338    pub async fn get_album(&self, id: AlbumID) -> Result<Album, ApiError> {
339        self.get(format!("/api/albums/{id}")).await
340    }
341
342    pub async fn create_album(&self, req: &CreateAlbumRequest) -> Result<Album, ApiError> {
343        self.post("/api/albums", req).await
344    }
345
346    pub async fn update_album(
347        &self,
348        id: AlbumID,
349        updates: &UpdateAlbumRequest,
350    ) -> Result<(), ApiError> {
351        self.put(format!("/api/albums/{id}"), updates).await
352    }
353
354    pub async fn delete_album(&self, id: AlbumID) -> Result<(), ApiError> {
355        self.delete(format!("/api/albums/{id}")).await
356    }
357
358    pub async fn add_photo_to_album(
359        &self,
360        album_id: AlbumID,
361        photo_id: PhotoID,
362    ) -> Result<(), ApiError> {
363        self.post(format!("/api/albums/{album_id}/photos/{photo_id}"), &()).await
364    }
365
366    pub async fn remove_photo_from_album(
367        &self,
368        album_id: AlbumID,
369        photo_id: PhotoID,
370    ) -> Result<(), ApiError> {
371        self.delete(format!("/api/albums/{album_id}/photos/{photo_id}")).await
372    }
373
374    // ─────────────────────────────────────────────────────────────────────────
375    // Settings
376    // ─────────────────────────────────────────────────────────────────────────
377
378    pub async fn get_settings(&self) -> Result<RotationSettings, ApiError> {
379        self.get("/api/settings").await
380    }
381
382    pub async fn update_settings(&self, updates: &UpdateSettingsRequest) -> Result<(), ApiError> {
383        self.put("/api/settings", updates).await
384    }
385}