Notes on codes, projects and everything

Self-hosted photo album

My cloud storage is nearly exploding, and I am not in a good position to start subscribing for more storage (Yes, I am still #opentowork). Considering I just moved my domain settings to Cloudflare and started using Cloudflare tunnel, I figure I probably should just back up some of my photos, and host it on my workstation.

I don’t really need a fancy album, just something good enough for my partner and I. So it only needs to fulfil a very small list of requirements:

    1. Serve everything through FastAPI
    2. Index photos into JSON(line) files.
    3. Use React.js as frontend

    3 bullets, 3 components, simple enough. Firstly I started by writing a simple script to go through the folders of photos. While jotting down the list of photos into JSONlines, I also generate a thumbnail for each of them.

    from PIL import Image
    from pillow_heif import register_heif_opener
    from thumbnail import generate_thumbnail
    
    def thumbnail_make(path_io: tuple[Path, Path]) -> dict[str, str | bool | int]:
        path_input, path_output = path_io
    
        width, height = input_get_size(path_input)
        is_portrait = width < height
        is_video = False
    
        if path_input.name.split(".")[-1].lower() in ("mp4", "mov"):
            is_video = True
            generate_thumbnail(
                str(path_input),
                str(path_output),
                options={
                    "trim": True,
                    "height": DIM_LONG if is_portrait else DIM_SHORT,
                    "width": DIM_SHORT if is_portrait else DIM_LONG,
                    "quality": 100,
                    "type": "thumbnail",
                },
            )
        else:
            image = Image.open(path_input)
    
            image.thumbnail((DIM_SHORT, DIM_LONG) if is_portrait else (DIM_LONG, DIM_SHORT))
            image.save(path_output, quality=85)
    
        width_output, height_output = input_get_size(path_output)
    
        return {
            "file": path_input.name,
            "path": str(path_input),
            "is_portrait": is_portrait,
            "is_video": is_video,
            "width": width,
            "height": height,
            "width_thumbnail": width_output,
            "height_thumbnail": height_output,
            "thumbnail": str(path_output),
        }
    

    Then the FastAPI application only needs to be able to serve the generated thumbnail and original photos

    for album in scandir(Path("./data")):
        if album.name == "_meta":
            continue
    
        app.mount(
            f"/thumbnail/{album.name}",
            StaticFiles(directory=Path("./data/_meta") / album.name, html=True),
            name=f"photo-{album.name}",
        )
        app.mount(
            f"/photo/{album.name}",
            StaticFiles(directory=Path("./data") / album.name, html=True),
            name=f"photo-{album.name}",
        )
    

    And the API to retrieve albums and photos correspondingly

    @app.get("/api/album")
    async def album_list() -> list[dict[str, str]]:
        return [
            {"name": album.name, "url": f"/api/album/{album.name}"}
            for album in scandir(Path("./data"))
            if album.name != "_meta"
        ]
    
    
    @app.get("/api/album/{album_name}")
    async def photo_list(album_name: str) -> dict[str, Any]:
        result = {"name": album_name, "photos": []}
    
        with open(Path("./data/_meta") / album_name / "index.jsonlines") as index:
            for photo in map(json.loads, index):
                result["photos"].append(
                    {
                        "name": photo["file"],
                        "photo": f"/photo/{album_name}/{photo['file']}",
                        "download": f"/download/{album_name}/{photo['file']}",
                        "thumbnail": f"/thumbnail/{album_name}/{photo['file'].split('.')[0]}.jpg",
                        "size_thumbnail": (
                            photo["width_thumbnail"],
                            photo["height_thumbnail"],
                        ),
                        "size": (
                            photo["width"],
                            photo["height"],
                        ),
                        "is_portrait": photo["is_portrait"],
                        "is_video": photo["is_video"],
                    }
                )
    
        return result
    

    Nothing fancy, and tbh, pretty boring stuff. Only notable fixes I did for the frontend react.js application is subclassing StaticFiles to always load index.html regardless (something I must have copied from a stackoverflow QA somewhere).

    class SPAStaticFiles(StaticFiles):
        async def get_response(self, path: str, scope):
            try:
                return await super().get_response(path, scope)
            except (fastapi.HTTPException, StarletteHTTPException) as ex:
                if ex.status_code == 404:
                    return await super().get_response("index.html", scope)
                else:
                    raise ex
    

    Over at frontend, it is similar to how I code my recent projects (legislative data web viewer, and blockedornot). Eventually I discovered an album component, as well as a lightbox component. And after some cleaning up, I have an almost drop-in photo album up (managed by systemd), which took only a weekend to develop (could be faster if I didn’t spend so much time refactoring after getting things up and running).

    Related Posts Plugin for WordPress, Blogger...

    leave your comment

    name is required

    email is required

    have a blog?

    This blog uses scripts to assist and automate comment moderation, and the author of this blog post does not hold responsibility in the content of posted comments. Please note that activities such as flaming, ungrounded accusations as well as spamming will not be entertained.

    Click to change color scheme