13 KiB
id, title, status, assignee, created_date, updated_date, labels, dependencies, references, modified_files, priority, ordinal
| id | title | status | assignee | created_date | updated_date | labels | dependencies | references | modified_files | priority | ordinal | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ML-177 | Add POST /api/v1/collection/:record_id/scrobble endpoint and expose selected_release_id in API responses | Done | 2026-05-10 18:46 | 2026-05-10 19:36 |
|
|
|
medium | 4000 |
Description
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 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)
- New route:
POST /api/v1/collection/:record_id/scrobblein the:apiscope - Controller action:
CollectionController.scrobble/2— looks up the record by ID, validatesselected_release_idis present, fetches the release from MusicBrainz, callsScrobbleActivity.scrobble_release/3with:finished_atandDateTime.utc_now(), returns JSON - JSON response: Success returns
%{status: "ok"}, error returns%{status: "error", reason: "..."} - Add
selected_release_idto theCollectionJSON.record/1response so the Presto can gate the scrobble button visibility (field is already onSearchIndex, just needs adding to the JSON map) - 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)
Acceptance Criteria
- #1 POST /api/v1/collection/:record_id/scrobble returns 200 with {"status": "ok"} when scrobble succeeds and the record has a selected_release_id
- #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
- #3 POST /api/v1/collection/:record_id/scrobble returns 401 when no valid Bearer token is provided
- #4 POST /api/v1/collection/:record_id/scrobble returns 404 when the record ID does not exist
- #5 All collection API responses (index, latest, random, on_this_day) include selected_release_id field (string or null)
- #6 Existing API response fields are unchanged (backward compatible)
- #7 Controller test covers success, missing release_id, missing record, auth failure, and Last.fm error cases
- #8 JSON view test verifies selected_release_id in record output
Implementation Plan
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 Acceptedimmediately 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 withurequests). -
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 tojson(conn, ...)inline in the controller. -
Scrobble via SearchIndex lookup — Looking up the record via
SearchIndexinstead ofRecord. Rejected becauseScrobbleActivity.scrobble_release/3needs a MusicBrainz API response (fetched fresh), not a DB struct. TheRecordlookup is only to validate existence and extractselected_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_idto the map inrecord/1inlib/music_library_web/controllers/collection_json.ex. - The field is already on
SearchIndex(line 29 ofsearch_index.ex) and is included byessential_fields()(which selects allSearchIndexfields). No database changes needed.
Step 2: Add the POST /api/v1/collection/:record_id/scrobble route
- Add
post "/collection/:record_id/scrobble", CollectionController, :scrobblein the:apiscope inlib/music_library_web/router.ex, immediately after the existing/collectionroutes. - The route uses the
:apipipeline which already appliesrequire_api_token.
Step 3: Implement CollectionController.scrobble/2
- Add
scrobble/2action tolib/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 callRecords.get_record!/1alias MusicBrainz— to callMusicBrainz.get_release/1(alternatively use the fully-qualified module name)
- Logic:
- Extract
record_idfromconn.path_params. - Look up record via
MusicLibrary.Records.get_record!/1; thephoenix_ectolibrary automatically converts raisedEcto.NoResultsErrorto a 404 response, so no explicitcaseis needed. - Check
record.selected_release_idis non-nil and non-blank; return 422%{status: "error", reason: "no_selected_release"}if absent. - Call
MusicBrainz.get_release(record.selected_release_id)— returns{:ok, release}or{:error, reason}. - On MusicBrainz error: return 502
%{status: "error", reason: "musicbrainz_error"}. - Convert response:
MusicBrainz.Release.from_api_response(release)→release_with_tracks. - Call
ScrobbleActivity.scrobble_release(release_with_tracks, :finished_at, DateTime.utc_now()). - On success: return 200
%{status: "ok"}. - On Last.fm error: return 502
%{status: "error", reason: "lastfm_error"}. - On
ScrobbleActivityreturning{:error, :no_duration}: return 422%{status: "error", reason: "no_duration"}. - On
ScrobbleActivityreturning{:error, :no_session_key}: return 503%{status: "error", reason: "lastfm_not_configured"}.
- Extract
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/scrobblewithout Bearer token → 401. - Missing record:
POSTwith valid token and non-existent ID → 404 (automatic viaphoenix_ecto). - No selected release:
POSTwith valid token and record withoutselected_release_id→ 422 with"no_selected_release". - Success:
POSTwith valid token and record withselected_release_id→ 200 with%{"status" => "ok"}(requires mockingMusicBrainz.get_release/1andLastFm.scrobble/2). - MusicBrainz error:
POSTwith valid token, MusicBrainz returns error → 502. - Last.fm error:
POSTwith valid token, MusicBrainz succeeds but Last.fm fails → 502. - No duration:
POSTwhere release has zero-duration tracks → 422.
- Authentication:
-
JSON view: Add
"selected_release_id"assertion inexpected_record_json/1in the existing controller test helper, and verify it'snullwhen the record has noselected_release_id. Note: adding this field toexpected_record_json/1will 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 withmix testthat 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.
Final Summary
Changes
1. lib/music_library_web/controllers/collection_json.ex
- Added
selected_release_idfield to therecord/1private 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, :scrobbleto the:apipipeline scope, protected by the existingrequire_api_tokenplug.
3. lib/music_library_web/controllers/collection_controller.ex
- Added aliases for
MusicBrainz,MusicLibrary.Records, andMusicLibrary.ScrobbleActivity. - Added
scrobble/2action with the following response codes:- 404 — Record not found (
Records.get_record/1returns nil;phoenix_ecto4.x does not auto-convertEcto.NoResultsError, so explicit handling was used) - 422 — No
selected_release_idset 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
- 404 — Record not found (
- Added private
do_scrobble/2to keep the action readable and separate record lookup from scrobble logic.
4. test/music_library_web/controllers/collection_controller_test.exs
- Added
selected_release_idtoexpected_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/2matching the project's test infrastructure pattern
Notes
- The
phoenix_ectolibrary in version 4.x does NOT automatically convertEcto.NoResultsErrorto 404 in controller actions (this was a Phoenix.Ecto 3.x feature). The implementation usesRecords.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).