Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/offlickr/fetch/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
46 changes: 34 additions & 12 deletions src/offlickr/fetch/thumbnails.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/offlickr/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 5 additions & 3 deletions src/offlickr/themes/minimal-archive/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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 */
Expand All @@ -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%;
Expand Down Expand Up @@ -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; }
Expand Down
3 changes: 2 additions & 1 deletion src/offlickr/themes/minimal-archive/templates/base.html.j2
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
</nav>
</header>
{% block photo_hero %}{% endblock %}
<main>{% block content %}{% endblock %}</main>
<main{% if self.main_class() %} class="{{ self.main_class() }}"{% endif %}>{% block content %}{% endblock %}</main>
{% block main_class %}{% endblock %}
<footer class="site-footer">
<small>Generated by <a href="https://github.com/yaniv-golan/offlickr" rel="noopener noreferrer external">offlickr</a></small>
</footer>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
{% extends "base.html.j2" %}
{% set active = 'faves' %}
{% block main_class %}main--wide{% endblock %}
{% block title %}Favorites — {{ account.screen_name }}{% endblock %}
{% block content %}
<h1 class="page-title">Favorites</h1>
{% if faves %}
<div class="photostream-grid">
{% 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 %}
<a href="{{ fave.photo_url_short }}" rel="noopener noreferrer external"
class="grid-tile" style="flex-basis:var(--thumb-h)">
class="grid-tile" style="flex-basis:calc(var(--thumb-h) * {{ '%.4f' | format(ratio) }})">
<img src="{{ base_url }}{{ fave.thumbnail_path | e }}" alt="Favorited photo" loading="lazy">
</a>
{% else %}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 %}
<h1 class="page-title" dir="auto">{{ account.screen_name }}'s Photos</h1>
Expand Down
5 changes: 4 additions & 1 deletion tests/test_fetch/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"

Expand Down
61 changes: 41 additions & 20 deletions tests/test_fetch/test_thumbnails.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading