ML-185.2: use API-sized Presto covers

This commit is contained in:
Claudio Ortolina
2026-05-15 22:56:22 +01:00
parent b4710e10c8
commit 3a7a89002a
7 changed files with 120 additions and 84 deletions
@@ -1,10 +1,10 @@
---
id: ML-185
title: Simplify Presto artwork handling
status: To Do
status: Done
assignee: []
created_date: "2026-05-15 21:19"
updated_date: "2026-05-15 21:40"
updated_date: "2026-05-15 21:53"
labels:
- api
- presto
@@ -43,11 +43,11 @@ Target state: each API record representation exposes four named cover variants:
<!-- AC:BEGIN -->
- [ ] #1 Collection API record JSON exposes four named cover variants: original, large, medium, and small.
- [ ] #2 The small cover is 80 px wide for the Presto record-list cover, the medium cover is 460 px wide for the Presto record-detail cover, the large cover is 1000 px wide, and original is unscaled.
- [ ] #3 The Presto application consumes the API-provided small and medium cover URLs directly and no longer contains client-side cover resizing or JPEG scale-selection logic.
- [ ] #4 Presto documentation and test fixtures describe the new cover contract and no longer refer to the legacy cover URL fields.
- [ ] #5 Relevant Phoenix and Presto tests are updated and pass.
- [x] #1 Collection API record JSON exposes four named cover variants: original, large, medium, and small.
- [x] #2 The small cover is 80 px wide for the Presto record-list cover, the medium cover is 460 px wide for the Presto record-detail cover, the large cover is 1000 px wide, and original is unscaled.
- [x] #3 The Presto application consumes the API-provided small and medium cover URLs directly and no longer contains client-side cover resizing or JPEG scale-selection logic.
- [x] #4 Presto documentation and test fixtures describe the new cover contract and no longer refer to the legacy cover URL fields.
- [x] #5 Relevant Phoenix and Presto tests are updated and pass.
<!-- AC:END -->
## Implementation Plan
@@ -59,3 +59,25 @@ Target state: each API record representation exposes four named cover variants:
3. Update documentation, fixtures, and smoke/contract tests so the API and device assumptions stay aligned.
4. Run the focused controller tests and Presto syntax/smoke tests; note that physical Presto behavior is not verified unless the device deployment task is run on hardware.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Both implementation subtasks are now Done. `ML-185.1` updated the collection API to emit `covers.original`, `covers.large`, `covers.medium`, and `covers.small`. `ML-185.2` updated the Presto app to consume `covers.small` and `covers.medium`, use an 80 px row cover and centered 460 px detail cover, and remove JPEG resizing/scale-selection logic. Focused Phoenix controller tests and Presto syntax/smoke tests passed. Physical Presto hardware was not tested.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Completed the artwork handling simplification across the collection API and Presto client.
The API now exposes named cover variants: `original` unscaled, `large` at 1000 px, `medium` at 460 px, and `small` at 80 px. The legacy flat cover fields were removed from the collection JSON contract.
The Presto app now consumes `covers.small` for record rows and `covers.medium` for detail pages, centers the 460 px detail cover in the 480 px display, and no longer performs JPEG dimension probing or scale-selection resizing on-device. Documentation, guidance, mock fixtures, and smoke tests were updated for the new contract.
Tests run across the subtasks: `mix test test/music_library_web/controllers/collection_controller_test.exs`; Presto syntax check; `mise run test` from `presto/`. Physical Presto hardware was not tested.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -1,10 +1,11 @@
---
id: ML-185.2
title: Use API-sized covers in Presto
status: To Do
assignee: []
status: Done
assignee:
- Codex
created_date: "2026-05-15 21:22"
updated_date: "2026-05-15 21:40"
updated_date: "2026-05-15 21:53"
labels:
- presto
- artwork
@@ -39,12 +40,12 @@ Context for implementer: `presto/main.py` currently renders record-list covers a
<!-- AC:BEGIN -->
- [ ] #1 Record-list rows fetch and draw the API-provided 80 px small cover variant.
- [ ] #2 Record-detail pages fetch and draw the API-provided 460 px medium cover variant centered within the 480 px display.
- [ ] #3 Presto image drawing no longer selects JPEG scale flags or resizes covers to fit a bounding box.
- [ ] #4 Missing or failed cover downloads still render placeholders, and drag redraws still avoid network fetches.
- [ ] #5 Presto README, Presto guidance, and smoke-test fixtures match the new named cover contract.
- [ ] #6 The Presto syntax check and smoke tests pass, with physical device verification only claimed if run on the device.
- [x] #1 Record-list rows fetch and draw the API-provided 80 px small cover variant.
- [x] #2 Record-detail pages fetch and draw the API-provided 460 px medium cover variant centered within the 480 px display.
- [x] #3 Presto image drawing no longer selects JPEG scale flags or resizes covers to fit a bounding box.
- [x] #4 Missing or failed cover downloads still render placeholders, and drag redraws still avoid network fetches.
- [x] #5 Presto README, Presto guidance, and smoke-test fixtures match the new named cover contract.
- [x] #6 The Presto syntax check and smoke tests pass, with physical device verification only claimed if run on the device.
<!-- AC:END -->
## Implementation Plan
@@ -58,3 +59,25 @@ Context for implementer: `presto/main.py` currently renders record-list covers a
5. Update `presto/README.md`, `presto/AGENTS.md`, and test mock records to describe and exercise the new contract.
6. Run `python3 -c "import py_compile; py_compile.compile('main.py', cfile='/tmp/main.pyc', doraise=True)"` from `presto/` and `mise run test` for the Presto smoke suite.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Updated the Presto cover path to consume only the collection API `covers` object: row covers use `covers.small`, detail covers use `covers.medium`, and the detail cover layout is now 460 px wide and centered at x=10 on the 480 px display. Removed JPEG dimension probing and scale-selection logic so `draw_jpeg` decodes API-sized JPEGs at the requested position and uses the provided dimensions only for placeholder fallback. Preserved row/detail byte cache separation and drag-time placeholder behavior. Ran `python3 -c "import py_compile; py_compile.compile('main.py', cfile='/tmp/main.pyc', doraise=True)"` and `mise run test` from `presto/`; both passed. Physical Presto hardware was not tested.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Updated the Presto app to trust the API-provided cover sizes. Record rows now read `covers.small`, record detail pages read `covers.medium`, and the detail cover is 460 px wide and centered within the 480 px display.
Removed the JPEG dimension probing and scale-selection code from `draw_jpeg`; it now decodes API-sized JPEGs directly and only uses placeholder dimensions when a fetch or decode fails. Existing row/detail image caches and no-network-during-drag behavior are preserved.
Updated Presto guidance, README API examples, and smoke-test fixtures for the new cover contract, and added a focused smoke test assertion for the expected 80 px row cover and 460 px centered detail cover.
Tests run: Presto syntax check and `mise run test` from `presto/` (8 passed). Physical device behavior was not verified on hardware.
<!-- SECTION:FINAL_SUMMARY:END -->
+3 -2
View File
@@ -72,7 +72,8 @@ _These rules apply to any scrollable view (day list, detail page, or future addi
## Image Handling
- Row thumbnails prefer `micro_cover_url``mini_cover_url``thumb_url`. Detail/large cover prefers `thumb_url``mini_cover_url``micro_cover_url`.
- Row thumbnails use `covers.small` from the API. Detail covers use `covers.medium`.
- The API provides display-ready JPEG sizes: `covers.small` is 80px and `covers.medium` is 460px. Do not resize covers on the device.
- Cache downloaded image bytes on the record dict. Use separate cache keys for different sizes (e.g., `_thumb_data` for rows, `_detail_thumb_data` for detail view). Never fetch images during drag — show placeholders instead and repaint real covers on release.
- JPEG is the practical default. PNG has worse memory characteristics; raw/RGB565 needs a confirmed stable blit API on the target firmware.
@@ -99,7 +100,7 @@ GET https://music-library.claudio-ortolina.org/api/v1/collection/on_this_day?dat
Authorization: Bearer <API_TOKEN>
```
Fields used by the app: `title`, `artists`, `format`, `release_date`, `genres`, `record_type`, `purchased_at`, `micro_cover_url`, `mini_cover_url`, `thumb_url`, `selected_release_id`.
Fields used by the app: `title`, `artists`, `format`, `release_date`, `genres`, `record_type`, `purchased_at`, `covers.small`, `covers.medium`, `selected_release_id`.
POST /api/v1/collection/:record_id/scrobble
Authorization: Bearer <API_TOKEN>
+10 -6
View File
@@ -102,7 +102,7 @@ The Presto runs `main.py` on boot. The app will:
### Record detail view
- Tap any **record row** in the day view to open its detail page
- Shows a **larger cover image** (uses `thumb_url`, 480px source), the full title, artists, genres, record type, format, release year, and purchase date
- Shows a **larger cover image** (uses `covers.medium`, 460px source), the full title, artists, genres, record type, format, release year, and purchase date
- Drag vertically to scroll if the content is taller than the screen
- The header bar stays fixed at the top while the cover and info scroll underneath
- Tap **< Back** to return to the day view
@@ -207,9 +207,12 @@ Response format:
"selected_release_id": "abc-123-uuid",
"title": "Album Title",
"artists": ["Artist Name"],
"micro_cover_url": "https://.../api/v1/assets/thumb.jpg?width=40",
"mini_cover_url": "https://.../api/v1/assets/thumb.jpg?width=150",
"thumb_url": "https://.../api/v1/assets/thumb.jpg?width=480",
"covers": {
"original": "https://.../api/v1/assets/original",
"large": "https://.../api/v1/assets/large-1000",
"medium": "https://.../api/v1/assets/medium-460",
"small": "https://.../api/v1/assets/small-80"
},
"release_date": "1973-03-01",
"genres": ["Rock"],
"format": "Vinyl",
@@ -220,8 +223,9 @@ Response format:
}
```
The Presto client prefers `mini_cover_url` for the 80×80 full-resolution row thumbnail, then falls back to `micro_cover_url` and `thumb_url`.
The detail view prefers `thumb_url` for the full-size cover, falling back to `mini_cover_url` and `micro_cover_url`.
The Presto client uses `covers.small` for the 80×80 full-resolution row thumbnail.
The detail view uses `covers.medium` for the 460×460 cover centered in the 480×480 display.
The app does not resize covers on the device; it expects the API to provide display-ready JPEGs.
## License
+29 -59
View File
@@ -117,7 +117,7 @@ DAY_HEADER_TEXT_H = HEADER_TEXT_H
DAY_COUNT_TEXT_H = TEXT_H
# Detail view
DETAIL_COVER_SIZE = px(240)
DETAIL_COVER_SIZE = px(230)
DETAIL_COVER_X = (WIDTH - DETAIL_COVER_SIZE) // 2
DETAIL_COVER_Y = DAY_HEADER_Y + DAY_HEADER_H + px(12)
DETAIL_INFO_GAP = px(4)
@@ -832,19 +832,6 @@ def show_search_results(app, recs, had_error):
draw_search_results(app)
# ============================================================================
# JPEG RENDERING
# ============================================================================
def _jpeg_scale_options():
"""Return JPEG scale flags paired with their output-size divisors."""
full = getattr(_jpegdec_lib, "JPEG_SCALE_FULL", 0)
half = getattr(_jpegdec_lib, "JPEG_SCALE_HALF", 1)
quarter = getattr(_jpegdec_lib, "JPEG_SCALE_QUARTER", 2)
eighth = getattr(_jpegdec_lib, "JPEG_SCALE_EIGHTH", 3)
return ((full, 1), (half, 2), (quarter, 4), (eighth, 8))
def _close_jpeg(jpeg):
"""Release a jpegdec object if the firmware exposes close()."""
if jpeg is None:
@@ -856,18 +843,17 @@ def _close_jpeg(jpeg):
pass
def draw_jpeg(data, x, y, max_w, max_h):
"""Decode and draw a JPEG image, scaled to fit within max_w x max_h
and centered in the bounding box.
def draw_jpeg(data, x, y, placeholder_w, placeholder_h):
"""Decode and draw an API-sized JPEG image at x, y.
Uses jpegdec module (standard on Pimoroni firmware).
Falls back to a placeholder rectangle on failure.
"""
if data is None:
_draw_placeholder(x, y, max_w, max_h)
_draw_placeholder(x, y, placeholder_w, placeholder_h)
return
# Try jpegdec module with smart scaling
# Try jpegdec module.
if _HAS_JPEGDEC:
jpeg = None
try:
@@ -877,32 +863,11 @@ def draw_jpeg(data, x, y, max_w, max_h):
except Exception:
jpeg.open_RAM(data)
# Attempt smart scaling: get dimensions, pick best scale, center
try:
img_h = jpeg.get_height()
img_w = jpeg.get_width()
# Try scales from largest to smallest; use the first that fits.
scale = None
divisor = 1
for scale_flag, scale_divisor in _jpeg_scale_options():
if img_w // scale_divisor <= max_w and img_h // scale_divisor <= max_h:
scale = scale_flag
divisor = scale_divisor
break
if scale is None:
scale, divisor = _jpeg_scale_options()[-1]
sw = img_w // divisor
sh = img_h // divisor
ox = x + (max_w - sw) // 2
oy = y + (max_h - sh) // 2
jpeg.decode(ox, oy, scale)
_close_jpeg(jpeg)
return
except Exception:
pass
# Fallback: decode at quarter scale (works for most cover art)
jpeg.decode(x, y, getattr(_jpegdec_lib, "JPEG_SCALE_QUARTER", 2))
jpeg.decode(x, y)
except TypeError:
# Older firmware expects a third full-size decode argument.
jpeg.decode(x, y, 0)
_close_jpeg(jpeg)
return
except Exception:
@@ -927,7 +892,7 @@ def draw_jpeg(data, x, y, max_w, max_h):
pass
# All methods failed — draw placeholder
_draw_placeholder(x, y, max_w, max_h)
_draw_placeholder(x, y, placeholder_w, placeholder_h)
def _draw_placeholder(x, y, w, h):
@@ -1608,7 +1573,7 @@ def _prepare_record_for_display(rec):
def preload_record_thumbnails(app, recs):
"""Fetch thumbnail bytes before the first draw of a record list."""
for rec in recs:
if rec.get("micro_cover_url", ""):
if _record_thumbnail_url(rec):
_record_thumbnail_data(app, rec)
@@ -1681,12 +1646,21 @@ def _record_thumbnail_data(app, rec):
def _record_thumbnail_url(rec):
"""Return the preferred cover URL for a record row."""
return (
rec.get("mini_cover_url", "")
or rec.get("micro_cover_url", "")
or rec.get("thumb_url", "")
)
"""Return the API-sized cover URL for a record row."""
return _record_cover_url(rec, "small")
def _record_detail_cover_url(rec):
"""Return the API-sized cover URL for a record detail page."""
return _record_cover_url(rec, "medium")
def _record_cover_url(rec, size):
"""Return a named cover URL from the API covers object."""
covers = rec.get("covers", {})
if not covers:
return ""
return covers.get(size, "") or ""
def _draw_record_row(app, rec, y):
@@ -2023,13 +1997,9 @@ def _draw_detail_cover(app, rec, y):
if y >= HEIGHT:
return
thumb_url = (
rec.get("thumb_url", "")
or rec.get("mini_cover_url", "")
or rec.get("micro_cover_url", "")
)
cover_url = _record_detail_cover_url(rec)
if not thumb_url:
if not cover_url:
_draw_placeholder(DETAIL_COVER_X, y,
DETAIL_COVER_SIZE, DETAIL_COVER_SIZE)
return
@@ -2042,7 +2012,7 @@ def _draw_detail_cover(app, rec, y):
return
if data is None and not rec.get("_detail_thumb_failed", False):
data = fetch_thumbnail(app, thumb_url)
data = fetch_thumbnail(app, cover_url)
if data is None:
rec["_detail_thumb_failed"] = True
else:
+6
View File
@@ -105,6 +105,12 @@ def make_mock_record(index=0):
"genres": ["Jazz", "Modal"],
"record_type": "LP",
"purchased_at": "2024-03-15",
"covers": {
"original": f"https://example.com/covers/original-{index}.jpg",
"large": f"https://example.com/covers/large-{index}.jpg",
"medium": f"https://example.com/covers/medium-{index}.jpg",
"small": f"https://example.com/covers/small-{index}.jpg",
},
# Pre-computed display strings (row view)
"_display_title": title,
"_display_artists": artist,
+11 -1
View File
@@ -96,6 +96,16 @@ class TestSmokeScreens:
main_module.draw_record_detail(app)
self._screenshot(main_module, "record-detail")
def test_cover_contract_uses_api_sized_images(self, main_module):
"""Cover helpers use the named API sizes expected by the display."""
rec = make_mock_record(0)
assert main_module.THUMB_SIZE == 80
assert main_module.DETAIL_COVER_SIZE == 460
assert main_module.DETAIL_COVER_X == 10
assert main_module._record_thumbnail_url(rec).endswith("/small-0.jpg")
assert main_module._record_detail_cover_url(rec).endswith("/medium-0.jpg")
def test_search_input_renders(self, main_module):
"""Search input screen with on-screen keyboard."""
app = self._make_app(main_module)
@@ -113,7 +123,7 @@ class TestSmokeScreens:
app.search.results = recs
# Pre-compute content height (prepare_record_list won't trigger
# network calls because _thumb_failed=True and _thumb_url="").
# network calls because _thumb_failed=True).
app.search.content_height = main_module.prepare_record_list(app, recs)
main_module.draw_search_results(app)