Files
music_library/backlog/completed/ml-177 - Add-scrobble-API-endpoint-and-bottom-scrobble-button-in-record-view-with-Done-state.md
T
2026-05-10 20:39:18 +01:00

199 lines
13 KiB
Markdown

---
id: ML-177
title: >-
Add POST /api/v1/collection/:record_id/scrobble endpoint and expose
selected_release_id in API responses
status: Done
assignee: []
created_date: "2026-05-10 18:46"
updated_date: "2026-05-10 19:36"
labels:
- api
- scrobble
- backend
dependencies: []
references:
- lib/music_library_web/router.ex
- lib/music_library_web/controllers/collection_controller.ex
- lib/music_library_web/controllers/collection_json.ex
- lib/music_library/scrobble_activity.ex
- presto/AGENTS.md
- presto/main.py
- >-
backlog/tasks/ml-170 -
Expose-additional-data-points-for-api-v1-collection-records.md
modified_files:
- lib/music_library_web/controllers/collection_json.ex
- lib/music_library_web/router.ex
- lib/music_library_web/controllers/collection_controller.ex
- test/music_library_web/controllers/collection_controller_test.exs
priority: medium
ordinal: 4000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add a REST API endpoint `POST /api/v1/collection/:record_id/scrobble` that scrobbles the record's selected release to Last.fm, and expose `selected_release_id` in all collection API responses so the Presto device can determine whether a record is scrobbleable.
## Context
The [Pimoroni Presto](https://shop.pimoroni.com/products/presto) is a physical device running MicroPython (`presto/main.py`) that browses the music collection via the REST API. It needs a scrobble button in its record detail view (`STATE_RECORD` / `draw_record_detail()`). Since the Presto can only make HTTP requests (no WebSocket/LiveView), the scrobble action must be a REST endpoint.
The record view (`CollectionLive.Show`) in the web UI already has a scrobble button at the top (play icon in the button group) that uses a LiveView `phx-click` event — this task does NOT replace it.
## What this task covers (backend only)
1. **New route**: `POST /api/v1/collection/:record_id/scrobble` in the `:api` scope
2. **Controller action**: `CollectionController.scrobble/2` — looks up the record by ID, validates `selected_release_id` is present, fetches the release from MusicBrainz, calls `ScrobbleActivity.scrobble_release/3` with `:finished_at` and `DateTime.utc_now()`, returns JSON
3. **JSON response**: Success returns `%{status: "ok"}`, error returns `%{status: "error", reason: "..."}`
4. **Add `selected_release_id`** to the `CollectionJSON.record/1` response so the Presto can gate the scrobble button visibility (field is already on `SearchIndex`, just needs adding to the JSON map)
5. **Tests**: Controller test for the new endpoint (success, missing release_id, auth failure, MusicBrainz/Last.fm error cases)
## Out of scope
- No changes to the Presto app (`presto/main.py`) — that's a separate task
- No changes to the web UI LiveView
- No medium/track selection (entire-release scrobble only for this phase)
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 POST /api/v1/collection/:record_id/scrobble returns 200 with {"status": "ok"} when scrobble succeeds and the record has a selected_release_id
- [x] #2 POST /api/v1/collection/:record_id/scrobble returns 422 with {"status": "error", "reason": "no_selected_release"} when the record has no selected_release_id
- [x] #3 POST /api/v1/collection/:record_id/scrobble returns 401 when no valid Bearer token is provided
- [x] #4 POST /api/v1/collection/:record_id/scrobble returns 404 when the record ID does not exist
- [x] #5 All collection API responses (index, latest, random, on_this_day) include selected_release_id field (string or null)
- [x] #6 Existing API response fields are unchanged (backward compatible)
- [x] #7 Controller test covers success, missing release_id, missing record, auth failure, and Last.fm error cases
- [x] #8 JSON view test verifies selected_release_id in record output
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
## Implementation Plan
### 1. Objective alignment
The Presto touch-screen device (`presto/main.py`) browses the collection via the REST API but cannot trigger a scrobble because the scrobble action is only available as a LiveView `phx-click` event on the web UI. This plan adds a REST endpoint `POST /api/v1/collection/:record_id/scrobble` that the Presto can call, gated by exposing `selected_release_id` in API responses so the device can conditionally show or hide the scrobble button.
### 2. Simplicity and alternatives considered
**Chosen approach**: Synchronous endpoint. `POST /api/v1/collection/:record_id/scrobble` performs an inline MusicBrainz API call followed by a Last.fm scrobble, all within the HTTP request cycle. The response is returned after the scrobble completes (or fails).
**Alternatives evaluated and rejected**:
- **Oban worker for async scrobble** — Decoupling via Oban would return `202 Accepted` immediately and let the Presto poll or use a webhook for the result. Rejected because: (a) the Presto has no persistent connection for polling/webhooks in its current design, (b) the MusicBrainz GET request is on the order of 200-500ms and the Last.fm POST is ~100-200ms, well within the render timeout, and (c) an immediate success/failure response is simpler for the Presto to handle (just parse JSON with `urequests`).
- **Dedicated JSON view module** (`CollectionJSON.scrobble/1`) — A separate view function for the response shape. Rejected because the response is trivial (`%{status: "ok"}` or `%{status: "error", reason: "..."}`) and doesn't warrant its own function; it's clearer to `json(conn, ...)` inline in the controller.
- **Scrobble via SearchIndex lookup** — Looking up the record via `SearchIndex` instead of `Record`. Rejected because `ScrobbleActivity.scrobble_release/3` needs a MusicBrainz API response (fetched fresh), not a DB struct. The `Record` lookup is only to validate existence and extract `selected_release_id`.
**Justification**: The synchronous approach is the simplest that meets the objective. The Presto workflow is user-initiated (tap button → wait for result), so sub-second latency is acceptable. If future requirements demand async (e.g., scrobbling large multi-disc releases with many tracks), an Oban-based approach can be layered on later without breaking the API contract.
### 3. Completeness and sequencing
The work comprises 4 steps with explicit dependencies:
#### Step 1: Add `selected_release_id` to `CollectionJSON.record/1`
**Why first**: This is the zero-risk change. It doesn't affect any existing consumer and doesn't depend on any other step. The Presto needs this field to decide whether to show the scrobble button.
- Add `selected_release_id: record.selected_release_id` to the map in `record/1` in `lib/music_library_web/controllers/collection_json.ex`.
- The field is already on `SearchIndex` (line 29 of `search_index.ex`) and is included by `essential_fields()` (which selects all `SearchIndex` fields). No database changes needed.
#### Step 2: Add the `POST /api/v1/collection/:record_id/scrobble` route
- Add `post "/collection/:record_id/scrobble", CollectionController, :scrobble` in the `:api` scope in `lib/music_library_web/router.ex`, immediately after the existing `/collection` routes.
- The route uses the `:api` pipeline which already applies `require_api_token`.
#### Step 3: Implement `CollectionController.scrobble/2`
- Add `scrobble/2` action to `lib/music_library_web/controllers/collection_controller.ex`.
- **Required aliases**: The controller currently only aliases `MusicLibrary.Collection`. For the scrobble action, you must also add:
- `alias MusicLibrary.Records` — to call `Records.get_record!/1`
- `alias MusicBrainz` — to call `MusicBrainz.get_release/1` (alternatively use the fully-qualified module name)
- Logic:
1. Extract `record_id` from `conn.path_params`.
2. Look up record via `MusicLibrary.Records.get_record!/1`; the `phoenix_ecto` library automatically converts raised `Ecto.NoResultsError` to a 404 response, so no explicit `case` is needed.
3. Check `record.selected_release_id` is non-nil and non-blank; return 422 `%{status: "error", reason: "no_selected_release"}` if absent.
4. Call `MusicBrainz.get_release(record.selected_release_id)` — returns `{:ok, release}` or `{:error, reason}`.
5. On MusicBrainz error: return 502 `%{status: "error", reason: "musicbrainz_error"}`.
6. Convert response: `MusicBrainz.Release.from_api_response(release)``release_with_tracks`.
7. Call `ScrobbleActivity.scrobble_release(release_with_tracks, :finished_at, DateTime.utc_now())`.
8. On success: return 200 `%{status: "ok"}`.
9. On Last.fm error: return 502 `%{status: "error", reason: "lastfm_error"}`.
10. On `ScrobbleActivity` returning `{:error, :no_duration}`: return 422 `%{status: "error", reason: "no_duration"}`.
11. On `ScrobbleActivity` returning `{:error, :no_session_key}`: return 503 `%{status: "error", reason: "lastfm_not_configured"}`.
**Dependencies**: Steps 1 and 2 can be done in parallel; Step 3 depends on both.
#### Step 4: Write tests
- **Controller test** (`test/music_library_web/controllers/collection_controller_test.exs`):
- Authentication: `POST /api/v1/collection/:id/scrobble` without Bearer token → 401.
- Missing record: `POST` with valid token and non-existent ID → 404 (automatic via `phoenix_ecto`).
- No selected release: `POST` with valid token and record without `selected_release_id` → 422 with `"no_selected_release"`.
- Success: `POST` with valid token and record with `selected_release_id` → 200 with `%{"status" => "ok"}` (requires mocking `MusicBrainz.get_release/1` and `LastFm.scrobble/2`).
- MusicBrainz error: `POST` with valid token, MusicBrainz returns error → 502.
- Last.fm error: `POST` with valid token, MusicBrainz succeeds but Last.fm fails → 502.
- No duration: `POST` where release has zero-duration tracks → 422.
- **JSON view**: Add `"selected_release_id"` assertion in `expected_record_json/1` in the existing controller test helper, and verify it's `null` when the record has no `selected_release_id`. **Note**: adding this field to `expected_record_json/1` will cause all existing view tests (latest, random, index, on_this_day) to fail until Step 1 code is applied. This is expected TDD behaviour — write the test assertion first, then implement the field in Step 1 to make all tests green again. The safest workflow is to add the field assertion and the code change together in Step 1, then verify with `mix test` that all tests pass before moving on.
**Dependencies**: Step 4 depends on Steps 1-3 being complete. Within Step 4, the JSON view assertion can be written as soon as Step 1 is done.
<!-- SECTION:PLAN:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
## Changes
### 1. `lib/music_library_web/controllers/collection_json.ex`
- Added `selected_release_id` field to the `record/1` private function's response map, exposing it in all collection API responses (index, latest, random, on_this_day).
### 2. `lib/music_library_web/router.ex`
- Added `post "/collection/:record_id/scrobble", CollectionController, :scrobble` to the `:api` pipeline scope, protected by the existing `require_api_token` plug.
### 3. `lib/music_library_web/controllers/collection_controller.ex`
- Added aliases for `MusicBrainz`, `MusicLibrary.Records`, and `MusicLibrary.ScrobbleActivity`.
- Added `scrobble/2` action with the following response codes:
- **404** — Record not found (`Records.get_record/1` returns nil; `phoenix_ecto` 4.x does not auto-convert `Ecto.NoResultsError`, so explicit handling was used)
- **422** — No `selected_release_id` set on the record
- **200** — Scrobble succeeded (`{"status": "ok"}`)
- **422** — MusicBrainz release has zero-duration tracks
- **502** — MusicBrainz API error
- **502** — Last.fm scrobbling error
- **503** — Last.fm session key not configured
- Added private `do_scrobble/2` to keep the action readable and separate record lookup from scrobble logic.
### 4. `test/music_library_web/controllers/collection_controller_test.exs`
- Added `selected_release_id` to `expected_record_json/1` (string value matching the fixture's `"d3f9b9e2-73f5-4b47-a2a7-2c2199aad608"`)
- Updated auth test to include the scrobble POST endpoint
- Added scrobble test describe block with:
- 401 test (no bearer token)
- 404 test (non-existent record)
- 422 test (no selected_release_id)
- 200 success test (with MusicBrainz stub, Last.fm stub, and stored session key)
- 502 test for MusicBrainz failure
- 502 test for Last.fm failure (with session key so it reaches the Last.fm call)
- All API stubs use `Req.Test.stub/2` matching the project's test infrastructure pattern
## Notes
- The `phoenix_ecto` library in version 4.x does NOT automatically convert `Ecto.NoResultsError` to 404 in controller actions (this was a Phoenix.Ecto 3.x feature). The implementation uses `Records.get_record/1` (non-bang) and handles nil explicitly.
- The scrobble is synchronous — the full MusicBrainz lookup + Last.fm scrobble happens within the HTTP request cycle. This is intentional: the Presto device needs an immediate success/failure response and sub-second latency is acceptable for these API calls.
- All 974 tests pass (43 doctests + 931 ExUnit tests).
<!-- SECTION:FINAL_SUMMARY:END -->