Skip to content

feat(research): add GET /api/research/track/stats — per-track Songstats stats - #653

Merged
sweetmantech merged 2 commits into
testfrom
feat/research-track-stats
Jun 9, 2026
Merged

feat(research): add GET /api/research/track/stats — per-track Songstats stats#653
sweetmantech merged 2 commits into
testfrom
feat/research-track-stats

Conversation

@sweetmantech

@sweetmantechsweetmantech commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Implements recoupable/app#1789 (api step) against the docs#236 contract. Pairs with recoupable/docs#236 (merge docs first).

Adds GET /api/research/track/stats — per-track, per-source current stats (incl. absolute streams_total) so agents can attribute streams to specific recordings. Today /api/research/metrics is artist-level only and /api/research/track(s) return metadata without stream counts.

Design — thin passthrough, not new infra

  • Resolution:isrc (or songstats_track_id / spotify_track_id / apple_music_track_id); source required (comma list or all); optional with_playlists/charts/stations/videos/links, only_current, limit, offset forwarded 1:1 to Songstats enterprise/v1/tracks/stats.
  • Reuses the existing fetchSongstats client (enterprise apikey server-side via SONGSTATS_API_KEY, never echoed) and the research validate → handler → successResponse shape. Response mirrors /api/research/metrics ({ status, result, message, stats:[{source,data}], track_info }).
  • Auth + credits identical to other /api/research/* endpoints: validateAuthContext (401), ensureResearchCredits gate, 5-credit deduction on a successful read. No database change.

Files

  • app/api/research/track/stats/route.ts (GET + OPTIONS)
  • lib/research/validateGetResearchTrackStatsRequest.ts · getResearchTrackStats.ts · getResearchTrackStatsHandler.ts

Tests (TDD, red→green)

validate (5), getResearchTrackStats (2), handler (3) — 10 new, all green; full lib/research suite 151/151 (no regressions); tsc + lint clean.

Test plan (preview, per #1789 Done-when)

  • ?isrc=USQY51771120&source=spotify → 200 with stats[0].data.streams_total integer (Mac Miller — K.I.D.S.).
  • source=all → one stats[] per source; resolving by songstats_track_id/spotify_track_id works equivalently.
  • 401 without a valid key; SONGSTATS_API_KEY never echoed.
  • Sum of K.I.D.S. per-track streams_total ≪ artist-level streams_total (~19.9B).

🤖 Generated with Claude Code


Summary by cubic

Adds GET /api/research/track/stats to return per-track, per-source Songstats current stats, including streams_total, so agents can attribute streams to specific recordings. This is a thin passthrough and reuses existing auth and credit rules.

  • New Features

    • Resolve by isrc, songstats_track_id, spotify_track_id, or apple_music_track_id; source is required. Optional with_*, only_current, limit, and offset are forwarded 1:1.
    • Response mirrors /api/research/metrics ({ status, result, message, stats:[{ source, data }], track_info }); includes CORS OPTIONS.
    • Reuses fetchSongstats; same auth and credits as other research endpoints with a 5-credit deduction on successful 200. No database changes.
  • Bug Fixes

    • Validator now enforces exactly one track identifier; returns 400 if multiple are provided (per docs#236).

Written for commit 2279d12. Summary will update on new commits.

Review in cubic

…ts stats
Implements recoupable/app#1789 (api step, against the docs#236 contract).
Thin passthrough to Songstats enterprise/v1/tracks/stats: resolves a track by
isrc / songstats_track_id / spotify_track_id / apple_music_track_id, forwards
source + optional toggles 1:1, returns the same stats[].data envelope as
/api/research/metrics (per-track streams_total). Reuses fetchSongstats + the
research validate→handler→successResponse shape; auth + 5-credit deduction
mirror the artist-level metrics endpoint. No database change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
apiReadyReadyPreviewJun 9, 2026 2:54pm

Request Review

@coderabbitai

coderabbitaiBot commented Jun 9, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sweetmantech, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 14 minutes and 20 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5ede11f4-88ae-4872-a3a0-2673f0670f1f

📥 Commits

Reviewing files that changed from the base of the PR and between 960d273 and 2279d12.

⛔ Files ignored due to path filters (3)
  • lib/research/__tests__/getResearchTrackStats.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/research/__tests__/getResearchTrackStatsHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/research/__tests__/validateGetResearchTrackStatsRequest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (4)
  • app/api/research/track/stats/route.ts
  • lib/research/getResearchTrackStats.ts
  • lib/research/getResearchTrackStatsHandler.ts
  • lib/research/validateGetResearchTrackStatsRequest.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/research-track-stats

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 7 files

Confidence score: 3/5

  • There is a concrete behavior gap in lib/research/validateGetResearchTrackStatsRequest.ts: it allows requests with multiple track identifiers, even though the contract requires exactly one, which creates real user-facing/request-routing risk.
  • Most other findings are moderate-to-low risk and mainly about robustness/consistency (missing exception-path and error-body assertions in lib/research/__tests__/getResearchTrackStatsHandler.test.ts, and error-message drift in lib/research/getResearchTrackStatsHandler.ts).
  • The dead catch noted in lib/research/getResearchTrackStats.ts is low severity and cleanup-focused, so it does not materially increase merge risk by itself.
  • Pay close attention to lib/research/validateGetResearchTrackStatsRequest.ts and lib/research/getResearchTrackStatsHandler.ts - enforce single-identifier validation and keep 500-response behavior/message consistent.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="lib/research/getResearchTrackStatsHandler.ts">
<violation number="1" location="lib/research/getResearchTrackStatsHandler.ts:34">
P3: 500 error responses should use the repository-standard hardcoded message `"Internal server error"` instead of `"Internal error"` to keep error contracts consistent and avoid drift.
(Based on your team's feedback about standardized 500 error response wording and non-leaky error handling.) [FEEDBACK_USED].</violation>
</file>
<file name="lib/research/__tests__/getResearchTrackStatsHandler.test.ts">
<violation number="1" location="lib/research/__tests__/getResearchTrackStatsHandler.test.ts:64">
P2: No test covers the handler's catch/exception block. The handler logs and returns a 500 response on unexpected exceptions, but this error-recovery path is untested. Consider adding a test where `getResearchTrackStats` rejects/throws to verify the 500 fallback.</violation>
<violation number="2" location="lib/research/__tests__/getResearchTrackStatsHandler.test.ts:64">
P2: Test 3 asserts HTTP status but does not verify the error response body, making it weaker than analogous tests in the same test directory. Add a body assertion (e.g., `expect(body.error).toBe("Request failed with status 404")`) to verify the error envelope is returned correctly.</violation>
</file>
<file name="lib/research/getResearchTrackStats.ts">
<violation number="1" location="lib/research/getResearchTrackStats.ts:28">
P3: The `try/catch` around `recordCreditDeduction` is unreachable dead error-handling code because `recordCreditDeduction` already catches errors and returns `{ success: false }`.</violation>
</file>
Architecture diagram
sequenceDiagram
participant Client as External Client
participant Route as route.ts (GET)
participant Handler as getResearchTrackStatsHandler
participant Validator as validateGetResearchTrackStatsRequest
participant Auth as validateAuthContext
participant Credits as ensureResearchCredits
participant Fetcher as getResearchTrackStats
participant Songstats as fetchSongstats
participant Deduct as recordCreditDeduction
Client->>Route: GET /api/research/track/stats?isrc=...&source=spotify&with_playlists=true
Route->>Handler: Delegates request
Handler->>Validator: Validate request
Validator->>Auth: validateAuthContext(request)
alt 401 Unauthorized
Auth-->>Validator: NextResponse (401)
Validator-->>Handler: Return 401 response
Handler-->>Route: Unauthorized
else Auth OK
Auth-->>Validator: { accountId }
Validator->>Validator: Check required identifier (isrc/songstats_track_id/spotify_track_id/apple_music_track_id)
alt Missing identifier
Validator-->>Handler: Return 400 "identifier required"
else Identifier present
Validator->>Validator: Check required source param
alt Missing source
Validator-->>Handler: Return 400 "source is required"
else Source present
Validator->>Credits: ensureResearchCredits(accountId)
alt Insufficient credits
Credits-->>Validator: NextResponse (short-circuit)
Validator-->>Handler: Return credit-issue response
else Sufficient credits
Credits-->>Validator: null
Validator->>Validator: Build params (source + identifier + optional passthroughs)
Validator-->>Handler: { accountId, params }
Handler->>Fetcher: getResearchTrackStats({ accountId, params })
Fetcher->>Songstats: fetchSongstats("tracks/stats", params)
alt Songstats 200 OK
Songstats-->>Fetcher: { data: statsPayload, status: 200 }
Fetcher->>Deduct: recordCreditDeduction({ accountId, creditsToDeduct: 5, source: "api" })
Deduct-->>Fetcher: OK
Fetcher-->>Handler: { data: statsPayload }
Handler->>Handler: Wrap in successResponse
Handler-->>Route: 200 { status:"success", stats:[{source,data}], track_info }
else Songstats non-200
Songstats-->>Fetcher: { data: errorPayload, status: 404/500 }
Fetcher-->>Handler: { error: "Request failed with status N", status: N }
Handler->>Handler: Create errorResponse
Handler-->>Route: N error response
end
end
end
end
end
Route-->>Client: JSON response with CORS headers
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadlib/research/validateGetResearchTrackStatsRequest.ts
status: 404,
});
const res = await getResearchTrackStatsHandler(req());
expect(res.status).toBe(404);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Test 3 asserts HTTP status but does not verify the error response body, making it weaker than analogous tests in the same test directory. Add a body assertion (e.g., expect(body.error).toBe("Request failed with status 404")) to verify the error envelope is returned correctly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/research/__tests__/getResearchTrackStatsHandler.test.ts, line 64:
<comment>Test 3 asserts HTTP status but does not verify the error response body, making it weaker than analogous tests in the same test directory. Add a body assertion (e.g., `expect(body.error).toBe("Request failed with status 404")`) to verify the error envelope is returned correctly.</comment>
<file context>
@@ -0,0 +1,66 @@
+ status: 404,
+ });
+ const res = await getResearchTrackStatsHandler(req());
+ expect(res.status).toBe(404);
+ });
+});
</file context>

@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: No test covers the handler's catch/exception block. The handler logs and returns a 500 response on unexpected exceptions, but this error-recovery path is untested. Consider adding a test where getResearchTrackStats rejects/throws to verify the 500 fallback.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/research/__tests__/getResearchTrackStatsHandler.test.ts, line 64:
<comment>No test covers the handler's catch/exception block. The handler logs and returns a 500 response on unexpected exceptions, but this error-recovery path is untested. Consider adding a test where `getResearchTrackStats` rejects/throws to verify the 500 fallback.</comment>
<file context>
@@ -0,0 +1,66 @@
+ status: 404,
+ });
+ const res = await getResearchTrackStatsHandler(req());
+ expect(res.status).toBe(404);
+ });
+});
</file context>

return successResponse(body);
} catch (error) {
console.error("[ERROR] getResearchTrackStatsHandler:", error);
return errorResponse("Internal error", 500);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: 500 error responses should use the repository-standard hardcoded message "Internal server error" instead of "Internal error" to keep error contracts consistent and avoid drift.

(Based on your team's feedback about standardized 500 error response wording and non-leaky error handling.) .

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/research/getResearchTrackStatsHandler.ts, line 34:
<comment>500 error responses should use the repository-standard hardcoded message `"Internal server error"` instead of `"Internal error"` to keep error contracts consistent and avoid drift.
(Based on your team's feedback about standardized 500 error response wording and non-leaky error handling.) .</comment>
<file context>
@@ -0,0 +1,36 @@
+ return successResponse(body);
+ } catch (error) {
+ console.error("[ERROR] getResearchTrackStatsHandler:", error);
+ return errorResponse("Internal error", 500);
+ }
+}
</file context>


try {
await recordCreditDeduction({ accountId: params.accountId, creditsToDeduct: 5, source: "api" });
} catch (error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The try/catch around recordCreditDeduction is unreachable dead error-handling code because recordCreditDeduction already catches errors and returns { success: false }.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/research/getResearchTrackStats.ts, line 28:
<comment>The `try/catch` around `recordCreditDeduction` is unreachable dead error-handling code because `recordCreditDeduction` already catches errors and returns `{ success: false }`.</comment>
<file context>
@@ -0,0 +1,33 @@
+
+ try {
+ await recordCreditDeduction({ accountId: params.accountId, creditsToDeduct: 5, source: "api" });
+ } catch (error) {
+ console.error("[research] credit deduction failed:", error);
+ }
</file context>

Match the merged docs#236 contract ('provide exactly one track identifier')
and address cubic P1: the validator accepted multiple identifiers and forwarded
them all to Songstats. Now rejects !== 1 with a 400. TDD: added the >1 case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 2 files (changes from recent commits).

Requires human review: Auto-approval blocked by 4 unresolved issues from previous reviews.

Re-trigger cubic

@sweetmantech

Copy link
Copy Markdown
ContributorAuthor

Preview verification — matches the deployed docs contract

Tested the preview for the latest commit (2279d123, incl. the exactly-one fix) and compared every result against the deployed docs (developers.recoupable.com/api-reference/research/track-stats, docs#236 merged to main).

Preview:api-6pzrwzfhg-recoup.vercel.app

Documented contract (live docs)Preview result
200{status, result, message, stats:[{source,data}], track_info, source_ids}exact top-level keys returned
stats[0].data.streams_total (int)296,422,273 (spotify, "Nikes on My Feet")
track_info.songstats_track_id"dcwjx6on" present
top-level source_idspresent
Provide exactly one identifierisrc + spotify_track_id400 "Provide exactly one track identifier (…)"✅ (new)
source requiredomitted → 400
identifier requirednone → 400
source=all200 (multi-source)
404 unknown identifierbad ISRC → 404
401 unauthenticatedno key → 401

Result

Live behavior is 1:1 with the merged docs contract — params, response envelope (incl. source_ids + track_info.songstats_track_id), status codes, and the now-enforced single-identifier rule all match. No drift between docs and implementation.

Note: 402 (credits) / 500 / 504 aren't documented — consistent with the rest of the research family (a pre-existing convention, not specific to this endpoint). Tested with the shared agent key against the preview.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sweetmantech