From 0d16779ed423d1818417072198cd2374933838a7 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 10 Jun 2026 10:50:14 -0500 Subject: [PATCH 1/4] =?UTF-8?q?docs(research):=20play-count=20snapshot=20c?= =?UTF-8?q?ontract=20=E2=80=94=20playcounts,=20snapshots,=20deltas=20+=20p?= =?UTF-8?q?rovenance=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract for recoupable/chat#1791 (Apify-first measurement store): - GET /api/research/playcounts?album_id — latest snapshot counts per album track - POST /api/research/snapshots — async catalog/album/ISRC snapshot, cost estimate returned before execution (202), per-org cap (429) - GET /api/research/playcounts/deltas?isrc&since[&until] — snapshot deltas + annualized run-rate; empty deltas (not an error) when history insufficient - additive data_source/captured_at provenance on ResearchTrackStat and per-point data_source on historic history items - field is platform_displayed_play_count, never "streams" (scraped, non-royalty-bearing; caveat language in schema descriptions) Purely additive diff (438+/0-); mint openapi-check passes. Co-Authored-By: Claude Fable 5 --- api-reference/openapi/research.json | 438 +++++++++++++++++++ api-reference/research/playcounts-deltas.mdx | 4 + api-reference/research/playcounts.mdx | 4 + api-reference/research/snapshots.mdx | 4 + docs.json | 5 +- 5 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 api-reference/research/playcounts-deltas.mdx create mode 100644 api-reference/research/playcounts.mdx create mode 100644 api-reference/research/snapshots.mdx diff --git a/api-reference/openapi/research.json b/api-reference/openapi/research.json index 9e007455..7cd0e169 100644 --- a/api-reference/openapi/research.json +++ b/api-reference/openapi/research.json @@ -2795,6 +2795,202 @@ } } } + }, + "/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.", + "parameters": [ + { + "name": "album_id", + "in": "query", + "required": true, + "description": "Spotify album id.", + "schema": { + "type": "string", + "example": "5SKnXCvB4fcGSZu32o3LRY" + } + } + ], + "responses": { + "200": { + "description": "Latest snapshot counts for every track on the album.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchPlaycountsResponse" + } + } + } + }, + "400": { + "description": "Validation error — missing `album_id`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication failed — invalid or missing API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchErrorResponse" + } + } + } + }, + "404": { + "description": "No snapshot exists for this album yet — create one with `POST /api/research/snapshots`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchErrorResponse" + } + } + } + } + } + } + }, + "/api/research/snapshots": { + "post": { + "description": "Capture platform-displayed play counts for a whole catalog, album list, or ISRC list in one server-side snapshot. Executes asynchronously; one album call captures all of its tracks. The cost estimate is returned before any scraper spend.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchSnapshotRequest" + } + } + } + }, + "responses": { + "202": { + "description": "Snapshot accepted and queued.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchSnapshotResponse" + } + } + } + }, + "400": { + "description": "Validation error — provide exactly one of `catalog_id`, `album_ids`, `isrcs`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication failed — invalid or missing API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchErrorResponse" + } + } + } + }, + "429": { + "description": "Per-organization monthly snapshot cap reached.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchErrorResponse" + } + } + } + } + } + } + }, + "/api/research/playcounts/deltas": { + "get": { + "description": "Play-count change between snapshots for one recording, with an annualized run-rate. Requires at least two captures in the window; returns an empty `deltas` array (not an error) when history is insufficient.", + "parameters": [ + { + "name": "isrc", + "in": "query", + "required": true, + "description": "Track ISRC.", + "schema": { + "type": "string", + "example": "USUYG1069896" + } + }, + { + "name": "since", + "in": "query", + "required": true, + "description": "Window start (inclusive).", + "schema": { + "type": "string", + "format": "date", + "example": "2026-06-09" + } + }, + { + "name": "until", + "in": "query", + "required": false, + "description": "Window end (inclusive). Defaults to the latest capture.", + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "Per-platform deltas between the nearest captures.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchPlaycountDeltasResponse" + } + } + } + }, + "400": { + "description": "Validation error — missing `isrc` or `since`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication failed — invalid or missing API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchErrorResponse" + } + } + } + }, + "404": { + "description": "Unknown ISRC.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResearchErrorResponse" + } + } + } + } + } + } } }, "components": { @@ -4836,6 +5032,16 @@ "playlists_editorial_current": 12, "playlists_editorial_total": 58 } + }, + "data_source": { + "type": "string", + "description": "Provenance of the numbers: `apify_spotify_playcount` (platform-displayed count from the measurement store), `songstats` (Songstats backfill), or `granted_analytics` (customer-granted data).", + "example": "apify_spotify_playcount" + }, + "captured_at": { + "type": "string", + "format": "date-time", + "description": "When this entry's values were captured into the measurement store." } }, "additionalProperties": true @@ -4908,6 +5114,11 @@ }, "playlist_reach_current": { "type": "integer" + }, + "data_source": { + "type": "string", + "description": "Per-point provenance. Provenance of the numbers: `apify_spotify_playcount` (platform-displayed count from the measurement store), `songstats` (Songstats backfill), or `granted_analytics` (customer-granted data).", + "example": "songstats" } } } @@ -4916,6 +5127,233 @@ } }, "additionalProperties": true + }, + "ResearchPlaycount": { + "type": "object", + "description": "Latest platform-displayed play count for one track, from the most recent album snapshot.", + "properties": { + "isrc": { + "type": "string", + "nullable": true, + "description": "ISRC of the recording; `null` when the platform track id has no mapping yet.", + "example": "USUYG1069896" + }, + "spotify_track_id": { + "type": "string" + }, + "name": { + "type": "string", + "description": "Track title as displayed on the platform." + }, + "platform_displayed_play_count": { + "type": "integer", + "format": "int64", + "description": "Play count as displayed on the public platform page. Scraped, not a royalty-bearing stream count — calibrate against customer-granted analytics for transaction-grade numbers.", + "example": 1331384578 + }, + "captured_at": { + "type": "string", + "format": "date-time", + "description": "When the snapshot captured this count." + }, + "data_source": { + "type": "string", + "description": "Provenance of the numbers: `apify_spotify_playcount` (platform-displayed count from the measurement store), `songstats` (Songstats backfill), or `granted_analytics` (customer-granted data).", + "example": "apify_spotify_playcount" + } + } + }, + "ResearchPlaycountsResponse": { + "type": "object", + "description": "Latest snapshot play counts for every track on an album.", + "properties": { + "status": { + "type": "string", + "example": "success" + }, + "album": { + "type": "object", + "properties": { + "spotify_album_id": { + "type": "string", + "example": "5SKnXCvB4fcGSZu32o3LRY" + }, + "name": { + "type": "string", + "nullable": true + }, + "label": { + "type": "string", + "nullable": true + }, + "copyright": { + "type": "string", + "nullable": true, + "description": "℗-line as displayed on the platform at capture time — usable for ownership re-verification per snapshot." + } + } + }, + "playcounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResearchPlaycount" + } + } + } + }, + "ResearchSnapshotRequest": { + "type": "object", + "description": "Provide exactly one of `catalog_id`, `album_ids`, or `isrcs`.", + "required": [ + "platforms" + ], + "properties": { + "catalog_id": { + "type": "string", + "format": "uuid", + "description": "Catalog to snapshot; resolves to its tracks server-side." + }, + "album_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Spotify album ids to snapshot (one album call captures all of its tracks)." + }, + "isrcs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "ISRCs to snapshot; resolved to albums server-side where a mapping exists." + }, + "platforms": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Platforms to capture. Currently `spotify`.", + "example": [ + "spotify" + ] + }, + "schedule": { + "type": "string", + "enum": [ + "once", + "monthly" + ], + "default": "once", + "description": "`once` runs a single snapshot; `monthly` registers a recurring snapshot." + } + } + }, + "ResearchSnapshotResponse": { + "type": "object", + "description": "Snapshot accepted. Execution is asynchronous; the cost estimate is returned before any scraper spend.", + "properties": { + "status": { + "type": "string", + "example": "success" + }, + "snapshot_id": { + "type": "string", + "format": "uuid" + }, + "state": { + "type": "string", + "enum": [ + "queued" + ], + "example": "queued" + }, + "album_count": { + "type": "integer", + "description": "Albums the snapshot will capture.", + "example": 570 + }, + "estimated_cost_usd": { + "type": "number", + "description": "Estimated scraper cost for the full snapshot, returned before execution.", + "example": 1.71 + } + } + }, + "ResearchPlaycountDelta": { + "type": "object", + "description": "Change in a metric between the two snapshots nearest the requested window bounds.", + "properties": { + "platform": { + "type": "string", + "example": "spotify" + }, + "metric": { + "type": "string", + "example": "platform_displayed_play_count" + }, + "since": { + "type": "object", + "description": "Nearest capture at/after the `since` date.", + "properties": { + "captured_at": { + "type": "string", + "format": "date-time" + }, + "value": { + "type": "integer", + "format": "int64" + } + } + }, + "until": { + "type": "object", + "description": "Nearest capture at/before the `until` date (latest capture when `until` is omitted).", + "properties": { + "captured_at": { + "type": "string", + "format": "date-time" + }, + "value": { + "type": "integer", + "format": "int64" + } + } + }, + "delta": { + "type": "integer", + "format": "int64", + "description": "`until.value - since.value`." + }, + "days": { + "type": "integer", + "description": "Days between the two captures." + }, + "run_rate_annualized": { + "type": "integer", + "format": "int64", + "description": "`delta` scaled to 365 days — a TTM proxy once captures span a meaningful window." + } + } + }, + "ResearchPlaycountDeltasResponse": { + "type": "object", + "description": "Per-platform deltas for one recording. `deltas` is empty when fewer than two captures exist in the window — the request never blocks on missing history.", + "properties": { + "status": { + "type": "string", + "example": "success" + }, + "isrc": { + "type": "string", + "example": "USUYG1069896" + }, + "deltas": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResearchPlaycountDelta" + } + } + } } }, "responses": { diff --git a/api-reference/research/playcounts-deltas.mdx b/api-reference/research/playcounts-deltas.mdx new file mode 100644 index 00000000..ed0c4e72 --- /dev/null +++ b/api-reference/research/playcounts-deltas.mdx @@ -0,0 +1,4 @@ +--- +title: 'Play Count Deltas' +openapi: "/api-reference/openapi/research.json GET /api/research/playcounts/deltas" +--- diff --git a/api-reference/research/playcounts.mdx b/api-reference/research/playcounts.mdx new file mode 100644 index 00000000..0c0311dc --- /dev/null +++ b/api-reference/research/playcounts.mdx @@ -0,0 +1,4 @@ +--- +title: 'Play Counts' +openapi: "/api-reference/openapi/research.json GET /api/research/playcounts" +--- diff --git a/api-reference/research/snapshots.mdx b/api-reference/research/snapshots.mdx new file mode 100644 index 00000000..6f4438f3 --- /dev/null +++ b/api-reference/research/snapshots.mdx @@ -0,0 +1,4 @@ +--- +title: 'Snapshots' +openapi: "/api-reference/openapi/research.json POST /api/research/snapshots" +--- diff --git a/docs.json b/docs.json index 177bc5cb..cf55ca63 100644 --- a/docs.json +++ b/docs.json @@ -120,7 +120,10 @@ "api-reference/research/tracks", "api-reference/research/track", "api-reference/research/track-stats", - "api-reference/research/track-historic-stats" + "api-reference/research/track-historic-stats", + "api-reference/research/playcounts", + "api-reference/research/playcounts-deltas", + "api-reference/research/snapshots" ] }, { From add4bf253327c5341b28941b97fe79c5a7b06e52 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 10 Jun 2026 17:06:20 -0500 Subject: [PATCH 2/4] docs(research): align endpoint naming with /research conventions - album_id -> spotify_album_id on GET /playcounts (identifier params are vendor-prefixed: spotify_track_id, apple_music_track_id) - GET /playcounts/deltas -> GET /track/playcount-deltas: it takes an isrc and answers a per-recording question, so it belongs in the /track facet group beside /track/stats and /track/historic-stats (hyphen precedent: historic-stats) mint validate passes; nav groups deltas with the track facets. Co-Authored-By: Claude Fable 5 --- api-reference/openapi/research.json | 6 +++--- .../{playcounts-deltas.mdx => track-playcount-deltas.mdx} | 4 ++-- docs.json | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename api-reference/research/{playcounts-deltas.mdx => track-playcount-deltas.mdx} (55%) diff --git a/api-reference/openapi/research.json b/api-reference/openapi/research.json index 7cd0e169..e5c0a7a6 100644 --- a/api-reference/openapi/research.json +++ b/api-reference/openapi/research.json @@ -2801,7 +2801,7 @@ "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": "album_id", + "name": "spotify_album_id", "in": "query", "required": true, "description": "Spotify album id.", @@ -2823,7 +2823,7 @@ } }, "400": { - "description": "Validation error — missing `album_id`.", + "description": "Validation error — missing `spotify_album_id`.", "content": { "application/json": { "schema": { @@ -2912,7 +2912,7 @@ } } }, - "/api/research/playcounts/deltas": { + "/api/research/track/playcount-deltas": { "get": { "description": "Play-count change between snapshots for one recording, with an annualized run-rate. Requires at least two captures in the window; returns an empty `deltas` array (not an error) when history is insufficient.", "parameters": [ diff --git a/api-reference/research/playcounts-deltas.mdx b/api-reference/research/track-playcount-deltas.mdx similarity index 55% rename from api-reference/research/playcounts-deltas.mdx rename to api-reference/research/track-playcount-deltas.mdx index ed0c4e72..6c1123f1 100644 --- a/api-reference/research/playcounts-deltas.mdx +++ b/api-reference/research/track-playcount-deltas.mdx @@ -1,4 +1,4 @@ --- -title: 'Play Count Deltas' -openapi: "/api-reference/openapi/research.json GET /api/research/playcounts/deltas" +title: 'Track Play Count Deltas' +openapi: "/api-reference/openapi/research.json GET /api/research/track/playcount-deltas" --- diff --git a/docs.json b/docs.json index cf55ca63..425f2a51 100644 --- a/docs.json +++ b/docs.json @@ -121,8 +121,8 @@ "api-reference/research/track", "api-reference/research/track-stats", "api-reference/research/track-historic-stats", + "api-reference/research/track-playcount-deltas", "api-reference/research/playcounts", - "api-reference/research/playcounts-deltas", "api-reference/research/snapshots" ] }, From fa4bebbec6d6cd8c84636babe279979406bf3383 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 10 Jun 2026 17:12:38 -0500 Subject: [PATCH 3/4] docs(research): link snapshots page from track-playcount-deltas description Co-Authored-By: Claude Fable 5 --- api-reference/openapi/research.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api-reference/openapi/research.json b/api-reference/openapi/research.json index e5c0a7a6..b68f3112 100644 --- a/api-reference/openapi/research.json +++ b/api-reference/openapi/research.json @@ -2914,7 +2914,7 @@ }, "/api/research/track/playcount-deltas": { "get": { - "description": "Play-count change between snapshots for one recording, with an annualized run-rate. Requires at least two captures in the window; returns an empty `deltas` array (not an error) when history is insufficient.", + "description": "Play-count change between [snapshots](/api-reference/research/snapshots) for one recording, with an annualized run-rate. Requires at least two captures in the window; returns an empty `deltas` array (not an error) when history is insufficient — create captures with [POST /api/research/snapshots](/api-reference/research/snapshots).", "parameters": [ { "name": "isrc", From 9eeb97e6f9d3c479e34846dcf5382eae46b8bc47 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 10 Jun 2026 17:15:22 -0500 Subject: [PATCH 4/4] docs(research): link track-playcount-deltas from snapshots description (circular) Co-Authored-By: Claude Fable 5 --- api-reference/openapi/research.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api-reference/openapi/research.json b/api-reference/openapi/research.json index b68f3112..909d14a0 100644 --- a/api-reference/openapi/research.json +++ b/api-reference/openapi/research.json @@ -2857,7 +2857,7 @@ }, "/api/research/snapshots": { "post": { - "description": "Capture platform-displayed play counts for a whole catalog, album list, or ISRC list in one server-side snapshot. Executes asynchronously; one album call captures all of its tracks. The cost estimate is returned before any scraper spend.", + "description": "Capture platform-displayed play counts for a whole catalog, album list, or ISRC list in one server-side snapshot. Executes asynchronously; one album call captures all of its tracks. The cost estimate is returned before any scraper spend. Two snapshots over time yield per-track run-rates via [GET /api/research/track/playcount-deltas](/api-reference/research/track-playcount-deltas).", "requestBody": { "required": true, "content": {