1pub 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
57pub 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 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 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 break Err(error);
209 }
210 Err(error) => {
211 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 thread_sleep(LOOP_SLEEP_DURATION);
230 }
231 };
232 if loop_result.is_err() {
233 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 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 .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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}