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:
- Serve everything through FastAPI
- Index photos into JSON(line) files.
- 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).
