syno_photo_frame/
lib.rs

1//! # syno-photo-frame
2//!
3//! syno_photo_frame is a full-screen slideshow app for Synology Photos and Immich albums
4
5pub use {api_client::LoginError, rand::RandomImpl};
6
7use std::{
8    error::Error,
9    fmt::{Display, Formatter},
10    sync::mpsc::{self, Receiver, SyncSender},
11    thread::{self, Scope, ScopedJoinHandle},
12    time::Duration,
13};
14
15#[cfg(not(test))]
16use std::{thread::sleep as thread_sleep, time::Instant};
17#[cfg(test)]
18use {mock_instant::Instant, test_helpers::fake_sleep as thread_sleep};
19
20use anyhow::{Result, bail};
21use chrono::Locale;
22
23use crate::{
24    api_client::{ApiClient, immich_client::ImmichApiClient, syno_client::SynoApiClient},
25    cli::{Backend, Cli},
26    env::Env,
27    http::{CookieStore, HttpClient},
28    img::{DynamicImage, Framed},
29    metadata::FromEnv,
30    rand::Random,
31    sdl::{Sdl, TextureIndex},
32    slideshow::Slideshow,
33    update::UpdateNotification,
34};
35
36pub mod cli;
37pub mod env;
38pub mod error;
39pub mod http;
40pub mod logging;
41pub mod sdl;
42
43mod api_client;
44mod api_crates;
45mod asset;
46mod img;
47mod info_box;
48mod metadata;
49mod rand;
50mod slideshow;
51mod transition;
52mod update;
53
54#[cfg(test)]
55mod test_helpers;
56
57/// Slideshow loop
58pub fn run<H, R>(
59    cli: &Cli,
60    (http_client, cookie_store): (&H, &impl CookieStore),
61    sdl: &mut impl Sdl,
62    random: R,
63    this_crate_version: &str,
64    env: &impl Env,
65) -> Result<()>
66where
67    H: HttpClient + Sync,
68    R: Random + Send,
69{
70    let current_image = show_welcome_screen(cli, sdl)?;
71
72    thread::scope::<'_, _, Result<()>>(|thread_scope| {
73        let (update_check_sender, update_check_receiver) = mpsc::sync_channel(1);
74        if !cli.disable_update_check {
75            update::check_for_updates_thread(
76                http_client,
77                this_crate_version,
78                thread_scope,
79                update_check_sender,
80            );
81        }
82
83        select_backend_and_start_slideshow(
84            cli,
85            (http_client, cookie_store),
86            sdl,
87            random,
88            update_check_receiver,
89            current_image,
90            env,
91        )
92    })
93}
94
95fn show_welcome_screen(cli: &Cli, sdl: &mut impl Sdl) -> Result<DynamicImage> {
96    let welcome_img = if let Some(path) = &cli.splash {
97        let (w, h) = sdl.size();
98        match img::open(path) {
99            Ok(image) => image.resize_exact(w, h, image::imageops::FilterType::Nearest),
100            Err(error) => {
101                log::error!("Splashscreen {}: {error}", path.to_string_lossy());
102                asset::welcome_screen(sdl.size(), cli.rotation)?
103            }
104        }
105    } else {
106        asset::welcome_screen(sdl.size(), cli.rotation)?
107    };
108    sdl.update_texture(welcome_img.as_bytes(), TextureIndex::Current)?;
109    sdl.copy_texture_to_canvas(TextureIndex::Current)?;
110    sdl.present_canvas();
111    Ok(welcome_img)
112}
113
114fn select_backend_and_start_slideshow<H, R>(
115    cli: &Cli,
116    (http_client, cookie_store): (&H, &impl CookieStore),
117    sdl: &mut impl Sdl,
118    random: R,
119    update_check_receiver: Receiver<bool>,
120    current_image: DynamicImage,
121    env: &impl Env,
122) -> Result<()>
123where
124    H: HttpClient + Sync,
125    R: Random + Send,
126{
127    let backend = if matches!(cli.backend, Backend::Auto) {
128        api_client::detect_backend(&cli.share_link)?
129    } else {
130        cli.backend
131    };
132    match backend {
133        Backend::Synology => slideshow_loop(
134            cli,
135            SynoApiClient::build(http_client, cookie_store, &cli.share_link)?
136                .with_password(&cli.password),
137            sdl,
138            random,
139            update_check_receiver,
140            current_image,
141            env,
142        ),
143        Backend::Immich => slideshow_loop(
144            cli,
145            ImmichApiClient::build(http_client, &cli.share_link)?.with_password(&cli.password),
146            sdl,
147            random,
148            update_check_receiver,
149            current_image,
150            env,
151        ),
152        Backend::Auto => unreachable!(),
153    }
154}
155
156fn slideshow_loop<A, R>(
157    cli: &Cli,
158    api_client: A,
159    sdl: &mut impl Sdl,
160    random: R,
161    update_check_receiver: Receiver<bool>,
162    mut current_image: DynamicImage,
163    env: &impl Env,
164) -> Result<()>
165where
166    A: ApiClient + Send,
167    R: Random + Send,
168{
169    /* Load the first photo as soon as it's ready. */
170    let mut last_change = Instant::now() - cli.photo_change_interval;
171    let screen_size = sdl.size();
172    let mut update_notification = UpdateNotification::new(screen_size, cli.rotation)?;
173    let (photo_sender, photo_receiver) = mpsc::sync_channel(1);
174    const LOOP_SLEEP_DURATION: Duration = Duration::from_millis(100);
175
176    thread::scope::<'_, _, Result<()>>(|thread_scope| {
177        photo_fetcher_thread(
178            cli,
179            api_client,
180            screen_size,
181            random,
182            thread_scope,
183            photo_sender,
184            env,
185        )?;
186
187        let loop_result = loop {
188            sdl.handle_quit_event()?;
189
190            if let Ok(true) = update_check_receiver.try_recv() {
191                /* Overlay a notification on the currently displayed image when an update was
192                 * detected */
193                update_notification.is_visible = true;
194                update_notification.show_on_current_image(&mut current_image, sdl)?;
195            }
196
197            let elapsed_display_duration = Instant::now() - last_change;
198            if elapsed_display_duration < cli.photo_change_interval {
199                thread_sleep(LOOP_SLEEP_DURATION);
200                continue;
201            }
202
203            if let Ok(next_photo_result) = photo_receiver.try_recv() {
204                let mut next_photo = match next_photo_result {
205                    Ok(photo) => photo,
206                    Err(error) if error.is::<LoginError>() => {
207                        /* Login error terminates the main thread loop */
208                        break Err(error);
209                    }
210                    Err(error) => {
211                        /* Any non-login error gets logged and an error screen is displayed. */
212                        log::error!("{error}");
213                        DynamicImagePhoto::error(screen_size, cli.rotation)?
214                    }
215                };
216                if update_notification.is_visible {
217                    update_notification.overlay(&mut next_photo.image);
218                }
219                sdl.update_texture(next_photo.image.as_bytes(), TextureIndex::Next)?;
220                cli.transition.play(sdl)?;
221                overlay_info_box(sdl, &next_photo, cli)?;
222
223                last_change = Instant::now();
224
225                sdl.swap_textures();
226                current_image = next_photo.image;
227            } else {
228                /* next photo is still being fetched and processed, we have to wait for it */
229                thread_sleep(LOOP_SLEEP_DURATION);
230            }
231        };
232        if loop_result.is_err() {
233            /* Dropping the receiver terminates photo_fetcher_thread loop */
234            drop(photo_receiver);
235        }
236        loop_result
237    })
238}
239
240fn photo_fetcher_thread<'a, A, R>(
241    cli: &'a Cli,
242    api_client: A,
243    screen_size: (u32, u32),
244    random: R,
245    thread_scope: &'a Scope<'a, '_>,
246    photo_sender: SyncSender<Result<DynamicImagePhoto>>,
247    env: &impl Env,
248) -> Result<ScopedJoinHandle<'a, ()>>
249where
250    A: ApiClient + Send + 'a,
251    R: Random + Send + 'a,
252{
253    if !api_client.is_logged_in() {
254        api_client.login()?;
255    }
256    let mut slideshow = Slideshow::new(api_client, random, Locale::from_env(env))
257        .with_ordering(cli.order)
258        .with_random_start(cli.random_start)
259        .with_source_size(cli.source_size);
260    Ok(thread_scope.spawn(move || {
261        loop {
262            let photo_result = slideshow.get_next_photo().and_then(|photo| {
263                load_image_from_memory(&photo.bytes).map(|image| {
264                    DynamicImagePhoto::new(
265                        image.fit_to_screen_and_add_background(
266                            screen_size,
267                            cli.rotation,
268                            cli.background,
269                        ),
270                        photo.info,
271                    )
272                })
273            });
274            /* Blocks until photo is received by the main thread */
275            let send_result = photo_sender.send(photo_result);
276            if send_result.is_err() {
277                break;
278            }
279        }
280    }))
281}
282
283fn load_image_from_memory(bytes: &[u8]) -> Result<DynamicImage> {
284    img::load_from_memory(bytes)
285        /* Synology Photos API may respond with an http OK code and a JSON containing an
286         * error instead of image bytes in the response body. Log such responses for
287         * debugging. */
288        .or_else(|e| {
289            let is_json = serde_json::from_slice::<serde::de::IgnoredAny>(bytes).is_ok();
290            if !is_json {
291                return Err(e);
292            }
293            let json = String::from_utf8_lossy(bytes);
294            bail!("Failed to decode image bytes. Received the following data: {json}");
295        })
296}
297
298struct DynamicImagePhoto {
299    image: DynamicImage,
300    info: String,
301}
302
303impl DynamicImagePhoto {
304    fn new(image: DynamicImage, info: String) -> Self {
305        Self { image, info }
306    }
307
308    fn error(screen_size: (u32, u32), rotation: cli::Rotation) -> Result<Self> {
309        Ok(Self {
310            image: asset::error_screen(screen_size, rotation)?,
311            info: "".to_string(),
312        })
313    }
314}
315
316fn overlay_info_box(sdl: &mut impl Sdl, photo: &DynamicImagePhoto, cli: &Cli) -> Result<()> {
317    if cli.display_photo_info {
318        sdl.render_info_box(&photo.info, cli.rotation)?;
319        sdl.present_canvas();
320    }
321    Ok(())
322}
323
324#[derive(Clone, Debug)]
325pub struct QuitEvent;
326
327impl Display for QuitEvent {
328    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
329        write!(f, "Quit")
330    }
331}
332
333impl Error for QuitEvent {}
334
335#[cfg(test)]
336mod tests {
337    use bytes::Bytes;
338    use mock_instant::MockClock;
339    use syno_api::dto::{ApiResponse, Error, List};
340
341    use super::*;
342    use crate::{
343        api_client::syno_client::Login,
344        cli::Parser,
345        env::MockEnv,
346        http::{Jar, MockHttpResponse, StatusCode},
347        sdl::MockSdl,
348        test_helpers::{MockHttpClient, rand::FakeRandom},
349    };
350
351    #[test]
352    fn when_login_fails_with_api_error_then_loop_terminates() {
353        const SHARE_LINK: &str = "http://fake.dsm.addr/aa/sharing/FakeSharingId";
354        const EXPECTED_API_URL: &str = "http://fake.dsm.addr/aa/sharing/webapi/entry.cgi";
355
356        let mut client_stub = MockHttpClient::new();
357        client_stub
358            .expect_post()
359            .withf(|url, form, _| {
360                url == EXPECTED_API_URL && test_helpers::is_login_form(form, "FakeSharingId")
361            })
362            .returning(|_, _, _| {
363                let mut error_response = test_helpers::new_ok_response();
364                error_response
365                    .expect_json::<ApiResponse<Login>>()
366                    .return_once(|| {
367                        Ok(ApiResponse {
368                            success: false,
369                            error: Some(Error { code: 42 }),
370                            data: None,
371                        })
372                    });
373                Ok(error_response)
374            });
375        /* Avoid overflow when setting initial last_change */
376        const DISPLAY_INTERVAL: u64 = 30;
377        MockClock::set_time(Duration::from_secs(DISPLAY_INTERVAL));
378        let mut sdl_stub = MockSdl::new().with_default_expectations();
379        /* Hack: Break the loop eventually in case of assertion failure */
380        sdl_stub
381            .expect_handle_quit_event()
382            .times(..5000)
383            .returning(|| Ok(()));
384        let cli_command = format!(
385            "syno-photo-frame {SHARE_LINK} \
386             --interval {DISPLAY_INTERVAL} \
387             --disable-update-check \
388             --splash assets/test_loading.jpeg"
389        );
390
391        let result = run(
392            &Cli::parse_from(cli_command.split(' ')),
393            (&client_stub, &Jar::default()),
394            &mut sdl_stub,
395            FakeRandom::default(),
396            "1.2.3",
397            &MockEnv::default(),
398        );
399
400        assert!(result.is_err_and(|e| e.is::<LoginError>()));
401        client_stub.checkpoint();
402    }
403
404    #[test]
405    fn when_login_fails_with_http_error_then_loop_terminates() {
406        let mut client_stub = MockHttpClient::new();
407        client_stub.expect_post().returning(|_, _, _| {
408            let mut error_response = MockHttpResponse::new();
409            error_response
410                .expect_status()
411                .return_const(StatusCode::FORBIDDEN);
412            Ok(error_response)
413        });
414        /* Avoid overflow when setting initial last_change */
415        const DISPLAY_INTERVAL: u64 = 30;
416        MockClock::set_time(Duration::from_secs(DISPLAY_INTERVAL));
417        let mut sdl_stub = MockSdl::new().with_default_expectations();
418        /* Hack: Break the loop eventually in case of assertion failure */
419        sdl_stub
420            .expect_handle_quit_event()
421            .times(..5000)
422            .returning(|| Ok(()));
423        let cli_command = format!(
424            "syno-photo-frame http://fake.dsm.addr/aa/sharing/FakeSharingId \
425            --interval {DISPLAY_INTERVAL} \
426            --disable-update-check \
427            --splash assets/test_loading.jpeg"
428        );
429
430        let result = run(
431            &Cli::parse_from(cli_command.split(' ')),
432            (&client_stub, &Jar::default()),
433            &mut sdl_stub,
434            FakeRandom::default(),
435            "1.2.3",
436            &MockEnv::default(),
437        );
438
439        assert!(result.is_err_and(|e| e.is::<LoginError>()));
440        client_stub.checkpoint();
441    }
442
443    #[test]
444    fn when_getting_photo_fails_with_http_error_loop_continues() {
445        const SHARE_LINK: &str = "http://fake.dsm.addr/aa/sharing/FakeSharingId";
446
447        let mut client_stub = MockHttpClient::new();
448        client_stub
449            .expect_post()
450            .withf(|_, form, _| test_helpers::is_login_form(form, "FakeSharingId"))
451            .return_once(|_, _, _| Ok(test_helpers::new_success_response_with_json(Login {})));
452        client_stub
453            .expect_post()
454            .withf(|_, form, _| test_helpers::is_list_form(form))
455            .returning(|_, _, _| {
456                Ok(test_helpers::new_success_response_with_json(List {
457                    list: vec![
458                        test_helpers::new_photo_dto(1, "missing_photo1"),
459                        test_helpers::new_photo_dto(2, "photo2"),
460                    ],
461                }))
462            });
463        /* Simulate failing GET photo bytes request */
464        client_stub
465            .expect_get()
466            .withf(|_, form| {
467                test_helpers::is_get_photo_form(form, "FakeSharingId", "1", "missing_photo1", "xl")
468            })
469            .returning(|_, _| {
470                let mut error_response = MockHttpResponse::new();
471                error_response
472                    .expect_status()
473                    .return_const(StatusCode::NOT_FOUND);
474                Ok(error_response)
475            });
476        client_stub
477            .expect_get()
478            .withf(|_, form| {
479                test_helpers::is_get_photo_form(form, "FakeSharingId", "2", "photo2", "xl")
480            })
481            .returning(|_, _| {
482                let mut get_photo_response = test_helpers::new_ok_response();
483                get_photo_response
484                    .expect_bytes()
485                    .return_once(|| Ok(Bytes::from_static(&[])));
486                Ok(get_photo_response)
487            });
488
489        /* Avoid overflow when setting initial last_change */
490        const DISPLAY_INTERVAL: u64 = 30;
491        MockClock::set_time(Duration::from_secs(DISPLAY_INTERVAL));
492        let mut sdl_stub = MockSdl::new();
493        {
494            sdl_stub.expect_size().return_const((198, 102));
495            sdl_stub.expect_clear_canvas().return_const(());
496            sdl_stub
497                .expect_copy_texture_to_canvas()
498                .returning(|_| Ok(()));
499            sdl_stub.expect_fill_canvas().returning(|_| Ok(()));
500            sdl_stub.expect_present_canvas().return_const(());
501            sdl_stub.expect_update_texture().returning(|_, _| Ok(()));
502        }
503        sdl_stub.expect_swap_textures().returning(|| {
504            MockClock::advance(Duration::from_secs(1));
505        });
506        sdl_stub.expect_handle_quit_event().returning(|| {
507            /* Until swap_textures is called (with an error image) and advances the time, return
508             * Ok. Afterward, break the loop with a simulated Quit event to finish the test */
509            if MockClock::time() <= Duration::from_secs(DISPLAY_INTERVAL) {
510                Ok(())
511            } else {
512                Err(QuitEvent)
513            }
514        });
515        let cli_command = format!(
516            "syno-photo-frame {SHARE_LINK} \
517            --interval {DISPLAY_INTERVAL} \
518            --disable-update-check \
519            --transition none \
520            --splash assets/test_loading.jpeg"
521        );
522
523        // let _ = SimpleLogger::new().init(); /* cargo test -- --show-output */
524        let result = run(
525            &Cli::parse_from(cli_command.split(' ')),
526            (&client_stub, &Jar::default()),
527            &mut sdl_stub,
528            FakeRandom::default(),
529            "1.2.3",
530            &MockEnv::default().with_default_expectations(),
531        );
532
533        /* If failed request bubbled up its error and broke the main slideshow loop, we would
534         * observe it here as the error type would be different from Quit */
535        assert!(result.is_err_and(|e| e.is::<QuitEvent>()));
536        client_stub.checkpoint();
537    }
538
539    #[test]
540    fn when_getting_photo_fails_with_api_error_loop_continues() {
541        const SHARE_LINK: &str = "http://fake.dsm.addr/aa/sharing/FakeSharingId";
542
543        let mut client_stub = MockHttpClient::new();
544        client_stub
545            .expect_post()
546            .withf(|_, form, _| test_helpers::is_login_form(form, "FakeSharingId"))
547            .return_once(|_, _, _| Ok(test_helpers::new_success_response_with_json(Login {})));
548        client_stub
549            .expect_post()
550            .withf(|_, form, _| test_helpers::is_list_form(form))
551            .returning(|_, _, _| {
552                Ok(test_helpers::new_success_response_with_json(List {
553                    list: vec![
554                        test_helpers::new_photo_dto(1, "bad_photo1"),
555                        test_helpers::new_photo_dto(2, "photo2"),
556                    ],
557                }))
558            });
559        /* Simulate failing GET photo bytes request */
560        client_stub
561            .expect_get()
562            .withf(|_, form| {
563                test_helpers::is_get_photo_form(form, "FakeSharingId", "1", "bad_photo1", "xl")
564            })
565            .returning(|_, _| {
566                let mut error_response = MockHttpResponse::new();
567                error_response.expect_status().return_const(StatusCode::OK);
568                error_response
569                    .expect_bytes()
570                    .return_once(|| Ok(Bytes::from("{ \"bad\": \"data\" }")));
571                Ok(error_response)
572            });
573        client_stub
574            .expect_get()
575            .withf(|_, form| {
576                test_helpers::is_get_photo_form(form, "FakeSharingId", "2", "photo2", "xl")
577            })
578            .returning(|_, _| {
579                let mut get_photo_response = test_helpers::new_ok_response();
580                get_photo_response
581                    .expect_bytes()
582                    .return_once(|| Ok(Bytes::from_static(&[])));
583                Ok(get_photo_response)
584            });
585
586        /* Avoid overflow when setting initial last_change */
587        const DISPLAY_INTERVAL: u64 = 30;
588        MockClock::set_time(Duration::from_secs(DISPLAY_INTERVAL));
589        let mut sdl_stub = MockSdl::new();
590        {
591            sdl_stub.expect_size().return_const((198, 102));
592            sdl_stub.expect_clear_canvas().return_const(());
593            sdl_stub
594                .expect_copy_texture_to_canvas()
595                .returning(|_| Ok(()));
596            sdl_stub.expect_fill_canvas().returning(|_| Ok(()));
597            sdl_stub.expect_present_canvas().return_const(());
598            sdl_stub.expect_update_texture().returning(|_, _| Ok(()));
599        }
600        sdl_stub.expect_swap_textures().returning(|| {
601            MockClock::advance(Duration::from_secs(1));
602        });
603        sdl_stub.expect_handle_quit_event().returning(|| {
604            /* Until swap_textures is called (with an error image) and advances the time, return
605             * Ok. Afterward, break the loop with a simulated Quit event to finish the test */
606            if MockClock::time() <= Duration::from_secs(DISPLAY_INTERVAL) {
607                Ok(())
608            } else {
609                Err(QuitEvent)
610            }
611        });
612        let cli_command = format!(
613            "syno-photo-frame {SHARE_LINK} \
614            --interval {DISPLAY_INTERVAL} \
615            --disable-update-check \
616            --transition none \
617            --splash assets/test_loading.jpeg"
618        );
619
620        // let _ = SimpleLogger::new().init(); /* cargo test -- --show-output */
621        let result = run(
622            &Cli::parse_from(cli_command.split(' ')),
623            (&client_stub, &Jar::default()),
624            &mut sdl_stub,
625            FakeRandom::default(),
626            "1.2.3",
627            &MockEnv::default().with_default_expectations(),
628        );
629
630        /* If failed request bubbled up its error and broke the main slideshow loop, we would
631         * observe it here as the error type would be different from Quit */
632        assert!(result.is_err_and(|e| e.is::<QuitEvent>()));
633        client_stub.checkpoint();
634    }
635
636    impl MockSdl {
637        pub fn with_default_expectations(mut self) -> Self {
638            self.expect_size().return_const((198, 102));
639            self.expect_update_texture().returning(|_, _| Ok(()));
640            self.expect_copy_texture_to_canvas().returning(|_| Ok(()));
641            self.expect_fill_canvas().returning(|_| Ok(()));
642            self.expect_present_canvas().return_const(());
643            self
644        }
645    }
646
647    impl MockEnv {
648        pub fn with_default_expectations(mut self) -> Self {
649            self.expect_var().returning(|_| Ok("en_GB".to_string()));
650            self
651        }
652    }
653}