From 59f758050a37357426a38399512a0c3b2e6c7dd8 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 11 Jun 2026 06:21:45 -0500 Subject: [PATCH 1/2] docs(research): reconcile playcount contract with live api behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against live preview responses (api#657 + api#660 results tables): - track-stats: Apify-first sourcing model + upstream status passthrough in the description; captured_at scoped to store-served entries; store-served data carries only streams_total; minimal {result, stats} envelope note - track-historic-stats: store-only spotify history (snapshot + backfilled points, per-point provenance), partial + enqueue behavior, minimal envelope - playcounts: nullability was inverted — isrc is always present (mapped tracks only), spotify_track_id is the nullable field; album label/copyright honestly marked always-null until snapshots persist album metadata; mapped-tracks-only coverage stated - all four playcount-family endpoints: 402 documented with the real insufficient_credits body shape (checkoutUrl, remaining/required credits) mint validate passes. Co-Authored-By: Claude Fable 5 --- api-reference/openapi/research.json | 97 +++++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 13 deletions(-) diff --git a/api-reference/openapi/research.json b/api-reference/openapi/research.json index 909d14a0..a2ba469e 100644 --- a/api-reference/openapi/research.json +++ b/api-reference/openapi/research.json @@ -2503,7 +2503,7 @@ }, "/api/research/track/stats": { "get": { - "description": "Get per-track, per-source current stats by ISRC or track id — absolute `streams_total`, playlist reach, and chart counts.", + "description": "Get per-track, per-source current stats by ISRC or track id — absolute `streams_total`, playlist reach, and chart counts. Spotify entries are served Apify-first from Recoup's measurement store (platform-displayed play counts, refreshed within a freshness TTL); other sources via Songstats, whose upstream errors pass through as their status code (e.g. 429 when the Songstats quota is exhausted).", "parameters": [ { "name": "isrc", @@ -2665,13 +2665,23 @@ } } } + }, + "402": { + "description": "Insufficient research credits — the body includes a `checkoutUrl` to top up.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchInsufficientCreditsResponse" + } + } + } } } } }, "/api/research/track/historic-stats": { "get": { - "description": "Get historic per-track, per-source stats by ISRC or track id over a date range — a daily time-series of cumulative counters (e.g. `streams_total`). Diff the window endpoints for streams gained over a period.", + "description": "Get historic per-track, per-source stats by ISRC or track id over a date range — a daily time-series of cumulative counters (e.g. `streams_total`). Diff the window endpoints for streams gained over a period. Spotify history is served from the measurement store: snapshot captures plus backfilled Songstats points, labeled per point via `data_source`. Tracks without backfilled history return their snapshot-only series and are enqueued for the backfill worker — the request never blocks. Other sources via Songstats, whose upstream errors pass through as their status code.", "parameters": [ { "name": "isrc", @@ -2792,13 +2802,23 @@ } } } + }, + "402": { + "description": "Insufficient research credits — the body includes a `checkoutUrl` to top up.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchInsufficientCreditsResponse" + } + } + } } } } }, "/api/research/playcounts": { "get": { - "description": "Latest platform-displayed play counts for all tracks on an album, served from the measurement store (populated by snapshots). Counts are scraped from public pages — not royalty-bearing stream counts.", + "description": "Latest platform-displayed play counts for all tracks on an album, served from the measurement store (populated by snapshots). Counts are scraped from public pages — not royalty-bearing stream counts. Returns only tracks that have identifier mappings; unmapped tracks on the album are omitted until a snapshot maps them.", "parameters": [ { "name": "spotify_album_id", @@ -2851,6 +2871,16 @@ } } } + }, + "402": { + "description": "Insufficient research credits — the body includes a `checkoutUrl` to top up.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchInsufficientCreditsResponse" + } + } + } } } } @@ -2988,6 +3018,16 @@ } } } + }, + "402": { + "description": "Insufficient research credits — the body includes a `checkoutUrl` to top up.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchInsufficientCreditsResponse" + } + } + } } } } @@ -4970,7 +5010,7 @@ }, "ResearchTrackStatsResponse": { "type": "object", - "description": "Per-track current statistics for the requested `source`(s). Mirrors `ResearchMetricsResponse`, scoped to a single recording.", + "description": "Per-track current statistics for the requested `source`(s). Mirrors `ResearchMetricsResponse`, scoped to a single recording. When every requested source is store-served, the response is the minimal envelope `{ result, stats }` — `track_info`, `source_ids`, and `message` appear only when Songstats served part of the request.", "properties": { "status": { "type": "string", @@ -5012,7 +5052,7 @@ }, "data": { "type": "object", - "description": "Per-source counters for this track. `streams_total` is the absolute play count. Keys vary by `source`; optional `playlists[]` / `charts[]` / `reposters[]` arrays appear when the matching `with_*` toggle is set.", + "description": "Per-source counters for this track. `streams_total` is the absolute play count. Keys vary by `source`; optional `playlists[]` / `charts[]` / `reposters[]` arrays appear when the matching `with_*` toggle is set. Store-served (apify) entries carry only `streams_total` — a platform-displayed play count, not a royalty-bearing stream count.", "additionalProperties": true, "example": { "streams_total": 84213771, @@ -5041,14 +5081,14 @@ "captured_at": { "type": "string", "format": "date-time", - "description": "When this entry's values were captured into the measurement store." + "description": "When this entry's values were captured into the measurement store. Present only on store-served entries (`data_source: \"apify_spotify_playcount\"`)." } }, "additionalProperties": true }, "ResearchTrackHistoricStatsResponse": { "type": "object", - "description": "Historic per-track statistics for the requested `source`(s). Same envelope as `ResearchTrackStatsResponse`, but each source's `data` holds a `history` time-series.", + "description": "Historic per-track statistics for the requested `source`(s). Same envelope as `ResearchTrackStatsResponse`, but each source's `data` holds a `history` time-series. When only `spotify` is requested, the response is the minimal envelope `{ result, stats }` served entirely from the measurement store.", "properties": { "status": { "type": "string", @@ -5134,12 +5174,13 @@ "properties": { "isrc": { "type": "string", - "nullable": true, - "description": "ISRC of the recording; `null` when the platform track id has no mapping yet.", + "description": "ISRC of the recording. Only tracks with identifier mappings are returned, so this is always present.", "example": "USUYG1069896" }, "spotify_track_id": { - "type": "string" + "type": "string", + "nullable": true, + "description": "Spotify track id; `null` when the recording has an album mapping but no track-id mapping yet." }, "name": { "type": "string", @@ -5180,16 +5221,18 @@ }, "name": { "type": "string", - "nullable": true + "nullable": true, + "description": "Album name from the song record." }, "label": { "type": "string", - "nullable": true + "nullable": true, + "description": "Currently always `null` — populated once snapshots persist album metadata." }, "copyright": { "type": "string", "nullable": true, - "description": "℗-line as displayed on the platform at capture time — usable for ownership re-verification per snapshot." + "description": "℗-line for ownership re-verification. Currently always `null` — populated once snapshots persist album metadata." } } }, @@ -5354,6 +5397,34 @@ } } } + }, + "ResearchInsufficientCreditsResponse": { + "type": "object", + "description": "Returned (402) when the account lacks research credits and auto-recharge did not cover the call.", + "properties": { + "error": { + "type": "string", + "enum": [ + "insufficient_credits" + ] + }, + "remaining_credits": { + "type": "integer", + "example": 0 + }, + "required_credits": { + "type": "integer", + "example": 5 + }, + "checkoutUrl": { + "type": "string", + "description": "Stripe checkout link to top up credits." + }, + "declineReason": { + "type": "string", + "description": "Card decline reason when auto-recharge was attempted and failed." + } + } } }, "responses": { From dcc1511ef3b47ac98e5a7cb222d63eab9e9d4201 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 11 Jun 2026 06:27:14 -0500 Subject: [PATCH 2/2] =?UTF-8?q?docs(research):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20KISS=20descriptions,=20declared=20429s,=203.1=20nul?= =?UTF-8?q?lability,=20required=20402=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - revert the three endpoint descriptions to the reviewer's simple forms; the sourcing/passthrough facts move to structured response entries - declare 429 (ResearchErrorResponse) on track-stats + historic-stats — the contract-level home for upstream passthrough (CodeRabbit) - OpenAPI 3.1: type: [string, null] arrays replace nullable: true on this PR's fields (spotify_track_id, album.name/label/copyright) - required: [error, remaining_credits, required_credits, checkoutUrl] on ResearchInsufficientCreditsResponse, matching the builder Co-Authored-By: Claude Fable 5 --- api-reference/openapi/research.json | 58 +++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/api-reference/openapi/research.json b/api-reference/openapi/research.json index a2ba469e..c71afb4d 100644 --- a/api-reference/openapi/research.json +++ b/api-reference/openapi/research.json @@ -2503,7 +2503,7 @@ }, "/api/research/track/stats": { "get": { - "description": "Get per-track, per-source current stats by ISRC or track id — absolute `streams_total`, playlist reach, and chart counts. Spotify entries are served Apify-first from Recoup's measurement store (platform-displayed play counts, refreshed within a freshness TTL); other sources via Songstats, whose upstream errors pass through as their status code (e.g. 429 when the Songstats quota is exhausted).", + "description": "Get per-track, per-source current stats by ISRC or track id — absolute `streams_total`, playlist reach, and chart counts.", "parameters": [ { "name": "isrc", @@ -2675,13 +2675,23 @@ } } } + }, + "429": { + "description": "Upstream provider error passthrough — e.g. 429 when the Songstats quota is exhausted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchErrorResponse" + } + } + } } } } }, "/api/research/track/historic-stats": { "get": { - "description": "Get historic per-track, per-source stats by ISRC or track id over a date range — a daily time-series of cumulative counters (e.g. `streams_total`). Diff the window endpoints for streams gained over a period. Spotify history is served from the measurement store: snapshot captures plus backfilled Songstats points, labeled per point via `data_source`. Tracks without backfilled history return their snapshot-only series and are enqueued for the backfill worker — the request never blocks. Other sources via Songstats, whose upstream errors pass through as their status code.", + "description": "Get historic per-track, per-source stats by ISRC or track id over a date range — a daily time-series of cumulative counters (e.g. `streams_total`). Diff the window endpoints for streams gained over a period.", "parameters": [ { "name": "isrc", @@ -2812,13 +2822,23 @@ } } } + }, + "429": { + "description": "Upstream provider error passthrough — e.g. 429 when the Songstats quota is exhausted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchErrorResponse" + } + } + } } } } }, "/api/research/playcounts": { "get": { - "description": "Latest platform-displayed play counts for all tracks on an album, served from the measurement store (populated by snapshots). Counts are scraped from public pages — not royalty-bearing stream counts. Returns only tracks that have identifier mappings; unmapped tracks on the album are omitted until a snapshot maps them.", + "description": "Latest platform-displayed play counts for all tracks on an album, served from the measurement store (populated by snapshots). Counts are scraped from public pages — not royalty-bearing stream counts.", "parameters": [ { "name": "spotify_album_id", @@ -5178,8 +5198,10 @@ "example": "USUYG1069896" }, "spotify_track_id": { - "type": "string", - "nullable": true, + "type": [ + "string", + "null" + ], "description": "Spotify track id; `null` when the recording has an album mapping but no track-id mapping yet." }, "name": { @@ -5220,18 +5242,24 @@ "example": "5SKnXCvB4fcGSZu32o3LRY" }, "name": { - "type": "string", - "nullable": true, + "type": [ + "string", + "null" + ], "description": "Album name from the song record." }, "label": { - "type": "string", - "nullable": true, + "type": [ + "string", + "null" + ], "description": "Currently always `null` — populated once snapshots persist album metadata." }, "copyright": { - "type": "string", - "nullable": true, + "type": [ + "string", + "null" + ], "description": "℗-line for ownership re-verification. Currently always `null` — populated once snapshots persist album metadata." } } @@ -5424,7 +5452,13 @@ "type": "string", "description": "Card decline reason when auto-recharge was attempted and failed." } - } + }, + "required": [ + "error", + "remaining_credits", + "required_credits", + "checkoutUrl" + ] } }, "responses": {