diff --git a/src/offlickr/fetch/runner.py b/src/offlickr/fetch/runner.py index 15e1a26..4f315c8 100644 --- a/src/offlickr/fetch/runner.py +++ b/src/offlickr/fetch/runner.py @@ -44,7 +44,12 @@ def _thumb_progress() -> None: for fave in model.get("faves", []): pid = fave["photo_id"] if pid in thumb_map: - fave["thumbnail_path"] = thumb_map[pid] + path, w, h = thumb_map[pid] + fave["thumbnail_path"] = path + if w: + fave["thumbnail_width"] = w + if h: + fave["thumbnail_height"] = h if include_avatars: nsids = list(model.get("users", {}).keys()) diff --git a/src/offlickr/fetch/thumbnails.py b/src/offlickr/fetch/thumbnails.py index 6087335..4510079 100644 --- a/src/offlickr/fetch/thumbnails.py +++ b/src/offlickr/fetch/thumbnails.py @@ -6,18 +6,39 @@ from pathlib import Path from typing import Any +from PIL import Image + from offlickr.fetch.client import FlickrClient from offlickr.issues import IssueCollector -_PREFERRED = ("Large Square", "Square", "Small") +# Prefer sizes that preserve natural aspect ratios; fall back to square crops. +_PREFERRED = ("Small 320", "Small", "Medium 640", "Medium", "Large Square", "Square") -def pick_thumb_url(sizes: list[dict[str, Any]]) -> str | None: - by_label: dict[str, str] = {str(s["label"]): str(s["source"]) for s in sizes} +def pick_thumb(sizes: list[dict[str, Any]]) -> tuple[str, int, int] | None: + """Return (url, width, height) for the best available size, or None.""" + by_label: dict[str, dict[str, Any]] = {str(s["label"]): s for s in sizes} for label in _PREFERRED: if label in by_label: - return by_label[label] - return str(sizes[0]["source"]) if sizes else None + s = by_label[label] + return str(s["source"]), int(s.get("width", 0)), int(s.get("height", 0)) + if sizes: + s = sizes[0] + return str(s["source"]), int(s.get("width", 0)), int(s.get("height", 0)) + return None + + +def pick_thumb_url(sizes: list[dict[str, Any]]) -> str | None: + result = pick_thumb(sizes) + return result[0] if result else None + + +def _read_image_size(path: Path) -> tuple[int, int]: + try: + with Image.open(path) as img: + return img.size + except Exception: + return 0, 0 def fetch_fave_thumbnails( @@ -27,27 +48,28 @@ def fetch_fave_thumbnails( *, on_progress: Callable[[], None] | None = None, collector: IssueCollector | None = None, -) -> dict[str, str]: - """Return {photo_id: relative_path} for successfully downloaded thumbnails.""" +) -> dict[str, tuple[str, int, int]]: + """Return {photo_id: (relative_path, width, height)} for downloaded thumbnails.""" thumbs_dir = output_dir / "fave-thumbs" thumbs_dir.mkdir(parents=True, exist_ok=True) - result: dict[str, str] = {} + result: dict[str, tuple[str, int, int]] = {} for photo_id in photo_ids: dest = thumbs_dir / f"{photo_id}.jpg" rel = f"fave-thumbs/{photo_id}.jpg" if dest.exists(): - result[photo_id] = rel + result[photo_id] = (rel, *_read_image_size(dest)) if on_progress: on_progress() continue try: sizes = client.get_photo_sizes(photo_id) - url = pick_thumb_url(sizes) - if url: + picked = pick_thumb(sizes) + if picked: + url, w, h = picked tmp = dest.with_suffix(".tmp") tmp.write_bytes(client.download(url)) tmp.rename(dest) - result[photo_id] = rel + result[photo_id] = (rel, w, h) except Exception as exc: dest.with_suffix(".tmp").unlink(missing_ok=True) if collector: diff --git a/src/offlickr/model.py b/src/offlickr/model.py index ccc0c95..e81a6ef 100644 --- a/src/offlickr/model.py +++ b/src/offlickr/model.py @@ -354,6 +354,8 @@ class Fave(BaseModel): photo_url_flickr: str | None = None owner_nsid: str | None = None thumbnail_path: str | None = None + thumbnail_width: int | None = None + thumbnail_height: int | None = None @classmethod def from_json(cls, data: dict[str, Any]) -> Fave: diff --git a/src/offlickr/themes/minimal-archive/static/style.css b/src/offlickr/themes/minimal-archive/static/style.css index af1a891..5e8384d 100644 --- a/src/offlickr/themes/minimal-archive/static/style.css +++ b/src/offlickr/themes/minimal-archive/static/style.css @@ -17,7 +17,7 @@ --font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Noto Sans Hebrew", sans-serif; --font-serif: Georgia, "Times New Roman", "Noto Serif", serif; - --thumb-h: 180px; + --thumb-h: 220px; } body { font-family: var(--font-ui); background: var(--bg); color: var(--fg); line-height: 1.6; } @@ -58,6 +58,7 @@ a:hover { text-decoration: underline; } /* Main content */ main { max-width: 1200px; margin: 0 auto; padding: 1.5rem; } +main.main--wide { max-width: none; padding: .75rem; } .page-title { font-family: var(--font-serif); font-size: 1.6rem; margin-bottom: 1rem; } /* Photostream grid — justified layout: tiles grow to fill each row */ @@ -73,7 +74,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 1.5rem; } background: var(--bg-muted); line-height: 0; flex-grow: 1; flex-shrink: 1; height: var(--thumb-h); - max-width: calc(var(--thumb-h) * 4); + max-width: calc(var(--thumb-h) * 6); } .grid-tile img { display: block; width: 100%; height: 100%; @@ -259,7 +260,8 @@ a.external-link::after { content: " ↗"; font-size: .75em; } .map-subtitle { color: var(--mid); margin-bottom: .5rem; } @media (max-width: 600px) { - :root { --thumb-h: 120px; } + :root { --thumb-h: 130px; } + main.main--wide { padding: .4rem; } .site-nav { gap: .75rem; } #search-input { width: 140px; } .photo-detail h1 { font-size: 1.4rem; } diff --git a/src/offlickr/themes/minimal-archive/templates/base.html.j2 b/src/offlickr/themes/minimal-archive/templates/base.html.j2 index ee48fc5..cb6ceeb 100644 --- a/src/offlickr/themes/minimal-archive/templates/base.html.j2 +++ b/src/offlickr/themes/minimal-archive/templates/base.html.j2 @@ -27,7 +27,8 @@ {% block photo_hero %}{% endblock %} -
{% block content %}{% endblock %}
+ {% block content %}{% endblock %} +{% block main_class %}{% endblock %} diff --git a/src/offlickr/themes/minimal-archive/templates/faves_index.html.j2 b/src/offlickr/themes/minimal-archive/templates/faves_index.html.j2 index 6d285fd..93a3667 100644 --- a/src/offlickr/themes/minimal-archive/templates/faves_index.html.j2 +++ b/src/offlickr/themes/minimal-archive/templates/faves_index.html.j2 @@ -1,5 +1,6 @@ {% extends "base.html.j2" %} {% set active = 'faves' %} +{% block main_class %}main--wide{% endblock %} {% block title %}Favorites — {{ account.screen_name }}{% endblock %} {% block content %}

Favorites

@@ -7,8 +8,9 @@
{% for fave in faves %} {% if fave.thumbnail_path %} + {% set ratio = (fave.thumbnail_width / fave.thumbnail_height) if (fave.thumbnail_width and fave.thumbnail_height) else 1.0 %} + class="grid-tile" style="flex-basis:calc(var(--thumb-h) * {{ '%.4f' | format(ratio) }})"> Favorited photo {% else %} diff --git a/src/offlickr/themes/minimal-archive/templates/photostream.html.j2 b/src/offlickr/themes/minimal-archive/templates/photostream.html.j2 index 273385f..a59cf01 100644 --- a/src/offlickr/themes/minimal-archive/templates/photostream.html.j2 +++ b/src/offlickr/themes/minimal-archive/templates/photostream.html.j2 @@ -1,5 +1,6 @@ {% extends "base.html.j2" %} {% set active = 'photos' %} +{% block main_class %}main--wide{% endblock %} {% block title %}{{ account.screen_name }}'s Photos{% endblock %} {% block content %}

{{ account.screen_name }}'s Photos

diff --git a/tests/test_fetch/test_runner.py b/tests/test_fetch/test_runner.py index 4148f3e..ed9f220 100644 --- a/tests/test_fetch/test_runner.py +++ b/tests/test_fetch/test_runner.py @@ -34,7 +34,8 @@ def test_run_fetch_external_updates_thumbnail_path(tmp_path: Path) -> None: respx.get(_REST_RE).mock(side_effect=[ httpx.Response(200, json={ "stat": "ok", - "sizes": {"size": [{"label": "Large Square", "source": _THUMB_URL}]}, + "sizes": {"size": [{"label": "Small 320", "source": _THUMB_URL, + "width": "320", "height": "213"}]}, }), httpx.Response(200, json={ "stat": "ok", @@ -49,6 +50,8 @@ def test_run_fetch_external_updates_thumbnail_path(tmp_path: Path) -> None: model = json.loads((output_dir / "data" / "model.json").read_text()) assert model["faves"][0]["thumbnail_path"] == "fave-thumbs/98765432.jpg" + assert model["faves"][0]["thumbnail_width"] == 320 + assert model["faves"][0]["thumbnail_height"] == 213 assert model["users"]["99@N00"]["avatar_path"] == "avatars/99@N00.jpg" assert model["users"]["99@N00"]["screen_name"] == "flickruser" diff --git a/tests/test_fetch/test_thumbnails.py b/tests/test_fetch/test_thumbnails.py index 8cffebc..b6dcf08 100644 --- a/tests/test_fetch/test_thumbnails.py +++ b/tests/test_fetch/test_thumbnails.py @@ -6,58 +6,79 @@ import httpx import respx +from PIL import Image from offlickr.fetch.client import FlickrClient -from offlickr.fetch.thumbnails import fetch_fave_thumbnails, pick_thumb_url +from offlickr.fetch.thumbnails import fetch_fave_thumbnails, pick_thumb, pick_thumb_url from offlickr.issues import IssueCollector _REST_RE = re.compile(r"https://api\.flickr\.com/services/rest/.*") -def test_pick_thumb_url_prefers_large_square() -> None: +def test_pick_thumb_prefers_small_320_over_large_square() -> None: sizes = [ - {"label": "Small", "source": "https://x.com/s.jpg"}, - {"label": "Large Square", "source": "https://x.com/q.jpg"}, - {"label": "Square", "source": "https://x.com/sq.jpg"}, + {"label": "Large Square", "source": "https://x.com/q.jpg", "width": "150", "height": "150"}, + {"label": "Small 320", "source": "https://x.com/n.jpg", "width": "320", "height": "213"}, + {"label": "Small", "source": "https://x.com/s.jpg", "width": "240", "height": "160"}, ] - assert pick_thumb_url(sizes) == "https://x.com/q.jpg" + url, w, h = pick_thumb(sizes) + assert url == "https://x.com/n.jpg" + assert w == 320 + assert h == 213 -def test_pick_thumb_url_falls_back_to_square() -> None: - sizes = [{"label": "Square", "source": "https://x.com/sq.jpg"}] - assert pick_thumb_url(sizes) == "https://x.com/sq.jpg" +def test_pick_thumb_returns_dimensions() -> None: + sizes = [{"label": "Small", "source": "https://x.com/s.jpg", "width": "240", "height": "160"}] + url, w, h = pick_thumb(sizes) + assert (w, h) == (240, 160) -def test_pick_thumb_url_returns_none_for_empty() -> None: - assert pick_thumb_url([]) is None +def test_pick_thumb_falls_back_to_large_square() -> None: + sizes = [{"label": "Large Square", "source": "https://x.com/q.jpg", "width": "150", "height": "150"}] + url, w, h = pick_thumb(sizes) + assert url == "https://x.com/q.jpg" + + +def test_pick_thumb_returns_none_for_empty() -> None: + assert pick_thumb([]) is None + + +def test_pick_thumb_url_backward_compat() -> None: + sizes = [{"label": "Small", "source": "https://x.com/s.jpg", "width": "240", "height": "160"}] + assert pick_thumb_url(sizes) == "https://x.com/s.jpg" @respx.mock -def test_fetch_fave_thumbnails_downloads_and_returns_paths(tmp_path: Path) -> None: +def test_fetch_fave_thumbnails_returns_path_and_dimensions(tmp_path: Path) -> None: respx.get(_REST_RE).mock( return_value=httpx.Response(200, json={ "stat": "ok", - "sizes": {"size": [{"label": "Large Square", "source": "https://live.staticflickr.com/x/111_a_q.jpg"}]}, + "sizes": {"size": [ + {"label": "Small 320", "source": "https://live.staticflickr.com/x/111_n.jpg", + "width": "320", "height": "213"}, + ]}, }) ) - respx.get("https://live.staticflickr.com/x/111_a_q.jpg").mock( + respx.get("https://live.staticflickr.com/x/111_n.jpg").mock( return_value=httpx.Response(200, content=b"JPEG_DATA") ) with FlickrClient("key") as client: result = fetch_fave_thumbnails(["111"], client, tmp_path) - assert result == {"111": "fave-thumbs/111.jpg"} + assert result == {"111": ("fave-thumbs/111.jpg", 320, 213)} assert (tmp_path / "fave-thumbs" / "111.jpg").read_bytes() == b"JPEG_DATA" @respx.mock -def test_fetch_fave_thumbnails_skips_existing(tmp_path: Path) -> None: +def test_fetch_fave_thumbnails_reads_dimensions_from_cached_file(tmp_path: Path) -> None: (tmp_path / "fave-thumbs").mkdir() - (tmp_path / "fave-thumbs" / "111.jpg").write_bytes(b"OLD") + img = Image.new("RGB", (320, 213), color=(100, 100, 100)) + img.save(tmp_path / "fave-thumbs" / "111.jpg") # No HTTP mock — if it makes a request, respx will raise with FlickrClient("key") as client: result = fetch_fave_thumbnails(["111"], client, tmp_path) - assert result == {"111": "fave-thumbs/111.jpg"} - assert (tmp_path / "fave-thumbs" / "111.jpg").read_bytes() == b"OLD" + assert result["111"][0] == "fave-thumbs/111.jpg" + assert result["111"][1] == 320 + assert result["111"][2] == 213 @respx.mock @@ -67,7 +88,7 @@ def test_fetch_fave_thumbnails_skips_on_api_error(tmp_path: Path) -> None: ) with FlickrClient("key") as client: result = fetch_fave_thumbnails(["999"], client, tmp_path) - assert result == {} # silently skipped + assert result == {} @respx.mock