diff --git a/README.md b/README.md index 83f37c3..3e5fdf0 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Wait for the build to finish. A new file Display help message: ``` -./synology-photos-slideshow.pex +./synology-photos-slideshow.pex -h ``` Run the app in X server: @@ -127,7 +127,7 @@ xset -dpms setxkbmap -option terminate:ctrl_alt_bksp # Start Synology Photos slideshow app -/home/pi/synology-photos-slideshow/synology-photos-slideshow.pex "SHARING_LINK" 30 > /tmp/synology-photos-slideshow.log 2>&1 +/home/pi/synology-photos-slideshow/synology-photos-slideshow.pex "SHARING_LINK" --interval 30 > /tmp/synology-photos-slideshow.log 2>&1 ``` The output of the program will be written to @@ -166,6 +166,14 @@ solution, e.g. for Raspberry Pi Zero I'm using [Witty Pi 3 Mini](https://www.adafruit.com/product/5038). +### Start Slideshow From Random Photo + +If the album is very large, and the startup-shutdown schedule is +short, potentially the slideshow might never display some of the later +photos in the album. The `--random-start` option solves the problem by +starting the slideshow at randomly picked photo. + + ### Auto Brightness For my digital photo frame project I attached a light sensor to Pi's diff --git a/main.py b/main.py index 484efcf..d45b12c 100644 --- a/main.py +++ b/main.py @@ -1,15 +1,17 @@ #!/usr/bin/env python3 +import argparse import datetime +from random import randrange import sys import tkinter.ttk import typing -import httpx import PIL.ImageTk +import httpx -import image_processor import get_photo_thread +import image_processor import slideshow import synology_photos_client @@ -19,16 +21,19 @@ class App(tkinter.Tk): def __init__( self, - slideshow_: slideshow.Slideshow, - photo_change_interval_in_seconds: int, - datetime_now: typing.Callable[[], datetime.datetime]): + photos_client: synology_photos_client.PhotosClient, + photo_change_interval_in_seconds: float, + datetime_now: typing.Callable[[], datetime.datetime], + randrange_: typing.Callable[[int, int], int]): super(App, self).__init__() self.attributes("-fullscreen", True) - self._slideshow = slideshow_ + self._photos_client = photos_client self._photo_change_interval = datetime.timedelta(seconds=photo_change_interval_in_seconds) self._datetime_now = datetime_now + self._randrange = randrange_ + self._slideshow: slideshow.Slideshow = None self._image_processor = image_processor.ImageProcessor((self.winfo_screenwidth(), self.winfo_screenheight())) self._label = tkinter.ttk.Label(self, background="black") self._label.pack(side="bottom", fill="both", expand=1) @@ -36,7 +41,16 @@ def __init__( self._label["foreground"] = "white" self._photo: typing.Optional[PIL.ImageTk.PhotoImage] = None - def start_slideshow(self) -> "App": + def start_slideshow(self, start_from_random_photo: bool) -> "App": + initial_album_offset = 0 + if start_from_random_photo: + try: + item_count = self._photos_client.get_album_contents_count() + initial_album_offset = self._randrange(0, item_count) + except Exception as error: + eprint(f"[{self._datetime_now()}] Error (cannot start from random photo): {error}") + self._slideshow = slideshow.Slideshow(self._photos_client, initial_album_offset) + self._monitor(datetime.datetime.min, self._start_get_next_photo_thread()) return self @@ -57,7 +71,6 @@ def schedule_next_iteration() -> None: if thread.is_failed(): # Getting next photo failed self._show_error(thread.error) - print(f"[{self._datetime_now()}] {thread.error}") # Retry after regular photo change interval (here expressed in milliseconds) self.after( self._photo_change_interval.seconds * 1000, @@ -86,45 +99,57 @@ def _show_image(self, photo_image: PIL.ImageTk.PhotoImage) -> None: self._label["image"] = self._photo def _show_error(self, error: Exception) -> None: + eprint(f"[{self._datetime_now()}] {error}") self._label["image"] = b"" self._label["anchor"] = "center" self._label["text"] = str(error) -help_message = f"""Provide the following arguments: - share_link [REQUIRED] Link to a publicly shared album on Synology Photos. - Note that the album's privacy settings must be set to Public - and link password protection must be disabled. - interval [OPTIONAL] Photo change interval in seconds. - Must be a positive number. - If not specified photos will change every 20 seconds - -Example: - {sys.argv[0]} https://my.nas/is/sharing/ABcd1234Z 30 -""" +def eprint(*args, **kwargs): + print(*args, file=sys.stderr, **kwargs) + + +def parse_arguments(argv: [str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(prog="synology-photos-slideshow.pex", + description="Synology Photos album slideshow", + epilog="Find new versions and more information on " + "https://github.com/Caleb9/synology-photos-slideshow") + parser.add_argument("share_link", + help="Link to a publicly shared album on Synology Photos. " + "Note that the album's privacy settings must be set to Public " + "and link password protection must be disabled.") + + def valid_interval(x) -> float: + try: + if float(x) < 1: + raise argparse.ArgumentTypeError("%s is not greater or equal to 1" % x) + return float(x) + except ValueError: + raise argparse.ArgumentTypeError("%s is not a number" % x) + + parser.add_argument("-i", + "--interval", + help="Photo change interval in seconds. Must be greater or equal to 1. " + "If not specified photos will change every 20 seconds", + type=valid_interval, + default=20) + parser.add_argument("--random-start", + help="Initialize slideshow at randomly selected photo", + action="store_true") + return parser.parse_args(argv) def main(argv: [str]) -> None: - if len(argv) < 1: - print(help_message) - sys.exit(1) - share_link = argv[0] - photo_change_interval_in_seconds = 20 - if len(argv) > 1: - photo_change_interval_in_seconds = int(argv[1]) - if photo_change_interval_in_seconds < 1: - print("Invalid interval value. Must be a positive number") - sys.exit(2) + args = parse_arguments(argv) with httpx.Client(timeout=20) as http_client: - App( - slideshow.Slideshow( - synology_photos_client.PhotosClient( - http_client, - share_link)), - photo_change_interval_in_seconds, - datetime.datetime.now) \ - .start_slideshow() \ + photos_client = synology_photos_client.PhotosClient(http_client, + args.share_link) + App(photos_client, + args.interval, + datetime.datetime.now, + randrange) \ + .start_slideshow(args.random_start) \ .mainloop() diff --git a/requirements/dev.txt b/requirements/dev.txt index f52fb04..fe2213c 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -26,11 +26,11 @@ idna==3.4 # rfc3986 packaging==23.0 # via build -pex==2.1.121 +pex==2.1.126 # via -r requirements/dev.in pillow==9.4.0 # via -r requirements/common.in -pip-tools==6.12.1 +pip-tools==6.12.3 # via -r requirements/dev.in pyproject-hooks==1.0.0 # via build diff --git a/slideshow.py b/slideshow.py index 38b5d04..ed1456d 100644 --- a/slideshow.py +++ b/slideshow.py @@ -6,14 +6,20 @@ class Slideshow: def __init__( self, - photos_client: synology_photos_client.PhotosClient): + photos_client: synology_photos_client.PhotosClient, + initial_album_offset = 0): self._photos_client = photos_client + self._initial_album_offset = initial_album_offset self._album_offset = 0 self._photos_batch: list[synology_photos_client.PhotosClient.PhotoDto] = [] self._batch_photo_index = 0 def get_next_photo(self) -> bytes: - if self._slideshow_ended(): + if self._initial_album_offset is not None: + self._album_offset = self._initial_album_offset + # We only use initial album offset for first run through the album. + self._initial_album_offset = None + elif self._slideshow_ended(): self._album_offset = 0 if self._need_next_batch(): self._photos_batch = self._photos_client.get_album_contents(self._album_offset, self._PHOTOS_BATCH_SIZE) diff --git a/synology_photos_client.py b/synology_photos_client.py index 6fef979..a5abb51 100644 --- a/synology_photos_client.py +++ b/synology_photos_client.py @@ -23,6 +23,23 @@ class PhotoDto(typing.TypedDict): id: int thumbnail: "PhotosClient.Thumbnail" + def get_album_contents_count(self) -> int: + self._get_sharing_sid_cookie() + + data = { + "api": "SYNO.Foto.Browse.Album", + "method": "get", + "version": 1, + } + response = self._http_client.post(self._api_url, + data=data, + headers=[("X-SYNO-SHARING", self._passphrase)]) + response_content = response.json() + if not response_content["success"]: + raise Exception(f"Getting album contents count resulted with API error {response_content['error']}") + + return response_content["data"]["list"][0]["item_count"] + def get_album_contents(self, offset: int, limit: int) -> list[PhotoDto]: self._get_sharing_sid_cookie()