Skip to content

Commit f5de6fc

Browse files
authored
merge: feat: Integrate Currently Playing Spotify Feature (#8118)
## Description Fixes #7810 ### Summary This PR adds a new **"Currently Playing" Spotify Integration** that generates a dynamic SVG displaying the user's current Spotify track while following CommitPulse's existing design system and API architecture. ### Changes Made * Added Spotify OAuth integration with automatic access token refresh. * Added a dedicated Spotify service (`services/spotify/api.ts`) to fetch and normalize currently playing track data. * Implemented a new SVG generator (`lib/svg/spotify.ts`) for rendering the Spotify card. * Added a new API endpoint (`/api/spotify`) with parameter validation and appropriate cache/security headers. * Added `spotifyParamsSchema` and related types in `lib/validations.ts`. * Added optional Spotify environment variables to `.env.local.example`. * Added unit tests for the Spotify service and API endpoint. * Added `docs/SPOTIFY_SETUP.md` with setup instructions. * Updated `README.md` with the new feature and usage information. ### Testing * Verified Spotify-specific TypeScript checks. * Verified Spotify-specific ESLint issues were resolved. * Ran and passed Spotify service and API tests. * Confirmed existing lint/type issues reported outside of this feature are pre-existing and unrelated to this PR. ## Pillar * [ ] 🎨 Pillar 1 — New Theme Design * [ ] 📐 Pillar 2 — Geometric SVG Improvement * [ ] 🕐 Pillar 3 — Timezone Logic Optimization * [x] 🛠️ Other (Feature, API integration, documentation) ## Visual Preview > *Add a screenshot or GIF of the generated Spotify SVG here after testing locally.* ## Checklist before requesting a review: * [x] I have read the `CONTRIBUTING.md` file. * [x] I have tested these changes locally. * [x] I have run the required checks and resolved issues introduced by this PR. * [x] My commits follow the Conventional Commits format. * [x] I have updated `README.md`. * [x] I have starred the repository. * [x] I have made sure I have only one commit to merge in this PR. * [x] The SVG output follows the CommitPulse design language and quality standards. * [x] (Recommended) I joined the CommitPulse Discord community for contributor discussions.
2 parents 9841831 + b06cc39 commit f5de6fc

9 files changed

Lines changed: 631 additions & 0 deletions

File tree

.env.local.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,10 @@ NEXT_ALLOWED_DEV_ORIGINS=
5757
# Find your GitHub user ID at https://api.github.com/users/<username> (the "id" field).
5858
# Example: ENTERPRISE_ADMIN_GITHUB_IDS=12345678,87654321
5959
ENTERPRISE_ADMIN_GITHUB_IDS=
60+
61+
# Spotify Integration (Optional)
62+
# Required for the "Currently Playing" SVG feature.
63+
# Follow the setup guide in the documentation to get these credentials.
64+
SPOTIFY_CLIENT_ID=
65+
SPOTIFY_CLIENT_SECRET=
66+
SPOTIFY_REFRESH_TOKEN=

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ For advanced usage examples including custom gradient backgrounds, multi-user co
167167
CommitPulse transforms GitHub contribution data into visually engaging and highly customizable SVG badges.
168168

169169
- **🎨 Theme & Customization**: Multiple built-in themes, custom colors (`bg`, `accent`, `text`), dynamic font selection, adjustable dimensions, border radius, opacity, and system-aware `auto` light/dark theme.
170+
- **🎵 Spotify "Currently Playing"**: Showcase your current Spotify playback on your GitHub profile with a customizable, near real-time SVG card.
170171
- **📈 Contribution Analytics**: Current streak and longest streak tracking, monthly contribution summaries, historical year-by-year viewing, and custom grace period configurations.
171172
- **🔥 Visualization Modes**: Isometric 3D monolith rendering (with ghost city blueprint foundations), GitHub-style heatmap, monthly statistics view, and radar chart view.
172173
- **🌍 Localization & Accessibility**: Multi-language support (e.g. English, Hindi, Simplified Chinese, Portuguese), timezone-aware calculations, and high-contrast accessibility themes.
@@ -182,6 +183,7 @@ To keep the repository clean and readable, technical details have been modulariz
182183
- **[🎨 Customization Guide & Parameters](docs/customization.md)**: Explore the list of over 30 URL parameters including `theme`, `view` (e.g. `skyline`, `heatmap`, `radar`, `monthly`), `radius`, `grace`, `tz`, `entrance`, `versus`, and layout dimensions to style your monolith.
183184
- **[🏛️ Architecture & Design Philosophy](docs/architecture.md)**: Read about why we built isometric 3D monolith landscapes instead of flat meters, and check out our Next.js 16 Edge computing pipeline.
184185
- **[🚀 Self-Hosting & Deployment](docs/self_hosting.md)**: Step-by-step instructions to clone, configure `.env.local` with GitHub Personal Access Tokens (PAT), set up MongoDB tracking, and deploy to Vercel with one click.
186+
- **[🎵 Spotify Setup Guide](docs/SPOTIFY_SETUP.md)**: Instructions for setting up Spotify integration for the Currently Playing feature.
185187
- **[🤖 Automated Contributor Workflow](docs/contributor_workflow.md)**: Overview of GSSoC contribution automation, self-claiming comments `/claim`, anti-hoarding rules, stale unassign scripts, and Gemini AI-powered semantic issue duplication check.
186188
- **[🎯 Real-Time Accuracy & Caching](docs/accuracy.md)**: Deep dive into the "off-by-N contributions" problem and how CommitPulse solves it with UTC midnight CDN expiration and no-store GraphQL fetches.
187189
- **[❓ FAQ & Troubleshooting](docs/faq.md)**: Answers to common questions regarding timezone overrides, private contribution visibility, GitHub API rate limits, and troubleshooting.

app/api/spotify/route.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { GET } from './route';
3+
4+
vi.mock('../../../services/spotify/api', () => ({
5+
getCurrentlyPlaying: vi.fn(),
6+
}));
7+
8+
import { getCurrentlyPlaying } from '../../../services/spotify/api';
9+
10+
function makeRequest(params: Record<string, string> = {}): Request {
11+
const url = new URL('http://localhost/api/spotify');
12+
for (const [key, value] of Object.entries(params)) {
13+
url.searchParams.set(key, value);
14+
}
15+
return new Request(url.toString());
16+
}
17+
18+
beforeEach(() => {
19+
vi.clearAllMocks();
20+
});
21+
22+
describe('Spotify Route', () => {
23+
it('returns 400 for invalid width', async () => {
24+
const response = await GET(makeRequest({ width: 'invalid' }));
25+
expect(response.status).toBe(400);
26+
const body = await response.text();
27+
expect(body).toContain('width must be an integer');
28+
});
29+
30+
it('generates offline SVG when not playing', async () => {
31+
vi.mocked(getCurrentlyPlaying).mockResolvedValue({
32+
isPlaying: false,
33+
});
34+
35+
const response = await GET(makeRequest());
36+
expect(response.status).toBe(200);
37+
const svg = await response.text();
38+
expect(svg).toContain('Not Currently Playing');
39+
expect(svg).toContain('Spotify');
40+
});
41+
42+
it('generates playing SVG when track is playing', async () => {
43+
vi.mocked(getCurrentlyPlaying).mockResolvedValue({
44+
isPlaying: true,
45+
title: 'Bohemian Rhapsody',
46+
artist: 'Queen',
47+
album: 'A Night at the Opera',
48+
progressMs: 30000,
49+
durationMs: 354000,
50+
albumImageUrl: 'https://i.scdn.co/image/ab67616d0000b273e8b066f70c206551210d902b',
51+
});
52+
53+
// Mock fetch for the image base64
54+
global.fetch = vi.fn().mockResolvedValue({
55+
ok: true,
56+
headers: new Headers({ 'content-type': 'image/jpeg' }),
57+
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
58+
});
59+
60+
const response = await GET(makeRequest({ theme: 'dark' }));
61+
expect(response.status).toBe(200);
62+
const svg = await response.text();
63+
expect(svg).toContain('Bohemian Rhapsody');
64+
expect(svg).toContain('Queen');
65+
// base64 mock
66+
expect(svg).toContain('data:image/jpeg;base64,AAAAAAAAAAA=');
67+
});
68+
69+
it('handles custom styling parameters', async () => {
70+
vi.mocked(getCurrentlyPlaying).mockResolvedValue({
71+
isPlaying: false,
72+
});
73+
74+
const response = await GET(makeRequest({ bg: 'ff0000', text: '00ff00', width: '600' }));
75+
expect(response.status).toBe(200);
76+
const svg = await response.text();
77+
// width
78+
expect(svg).toContain('width="600"');
79+
// bg
80+
expect(svg).toContain('fill="#ff0000"');
81+
});
82+
});

app/api/spotify/route.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// app/api/spotify/route.ts
2+
3+
import { NextResponse } from 'next/server';
4+
import { getCurrentlyPlaying } from '@/services/spotify/api';
5+
import { generateSpotifySVG } from '@/lib/svg/spotify';
6+
import { spotifyParamsSchema, coerceQueryParams } from '@/lib/validations';
7+
import { optimizeSVG } from '@/lib/svg/optimizer';
8+
import crypto from 'crypto';
9+
10+
const SVG_CSP_HEADER =
11+
"default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src https://fonts.gstatic.com; img-src data:;";
12+
13+
/**
14+
* Fetch an image and convert it to a base64 data URI
15+
*/
16+
async function fetchImageAsBase64(url: string): Promise<string | null> {
17+
try {
18+
const response = await fetch(url, { cache: 'force-cache' });
19+
if (!response.ok) return null;
20+
const arrayBuffer = await response.arrayBuffer();
21+
const buffer = Buffer.from(arrayBuffer);
22+
const contentType = response.headers.get('content-type') || 'image/jpeg';
23+
return `data:${contentType};base64,${buffer.toString('base64')}`;
24+
} catch (error) {
25+
console.warn('Error fetching image for base64 encoding:', error);
26+
return null;
27+
}
28+
}
29+
30+
export async function GET(request: Request) {
31+
const { searchParams } = new URL(request.url);
32+
33+
const parseResult = spotifyParamsSchema.safeParse(coerceQueryParams(searchParams));
34+
35+
if (!parseResult.success) {
36+
const fieldErrors = parseResult.error.flatten();
37+
const firstError =
38+
Object.values(fieldErrors.fieldErrors).flat()[0] ??
39+
fieldErrors.formErrors[0] ??
40+
'Invalid parameters';
41+
42+
const errorSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="150" viewBox="0 0 400 150">
43+
<rect width="400" height="150" fill="#2d0000" rx="8"/>
44+
<text x="200" y="75" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${firstError}</text>
45+
</svg>`;
46+
47+
return new NextResponse(errorSvg, {
48+
status: 400,
49+
headers: {
50+
'Content-Type': 'image/svg+xml',
51+
'Cache-Control': 'no-store',
52+
'Content-Security-Policy': SVG_CSP_HEADER,
53+
},
54+
});
55+
}
56+
57+
const params = parseResult.data;
58+
59+
const trackData = await getCurrentlyPlaying();
60+
let imageBase64: string | null = null;
61+
62+
if (trackData.isPlaying && trackData.albumImageUrl) {
63+
imageBase64 = await fetchImageAsBase64(trackData.albumImageUrl);
64+
}
65+
66+
let svg = await generateSpotifySVG(trackData, params, imageBase64);
67+
68+
if (params.minify) {
69+
svg = optimizeSVG(svg);
70+
}
71+
72+
const isRefreshRequested = params.refresh || params.bypassCache;
73+
const cacheControl = isRefreshRequested
74+
? 'no-cache, no-store, must-revalidate'
75+
: 'public, max-age=30, s-maxage=30, stale-while-revalidate=30';
76+
77+
const etag = crypto.createHash('sha256').update(svg).digest('hex');
78+
const weakEtag = `W/"${etag}"`;
79+
const ifNoneMatch = request.headers.get('if-none-match');
80+
81+
if (ifNoneMatch) {
82+
const etags = ifNoneMatch.split(',').map((e) => e.trim());
83+
if (etags.includes(weakEtag) || etags.includes(`"${etag}"`)) {
84+
return new NextResponse(null, {
85+
status: 304,
86+
headers: {
87+
'Cache-Control': cacheControl,
88+
ETag: weakEtag,
89+
},
90+
});
91+
}
92+
}
93+
94+
return new NextResponse(svg, {
95+
headers: {
96+
'Content-Type': 'image/svg+xml; charset=utf-8',
97+
'Cache-Control': cacheControl,
98+
'Content-Security-Policy': SVG_CSP_HEADER,
99+
ETag: weakEtag,
100+
},
101+
});
102+
}

docs/SPOTIFY_SETUP.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Spotify "Currently Playing" Integration
2+
3+
CommitPulse allows you to show what you're currently listening to on Spotify, directly on your GitHub profile. This is an optional feature and requires you to set up a Spotify Developer app to securely fetch your playback data.
4+
5+
## Setup Instructions
6+
7+
### 1. Create a Spotify Developer App
8+
9+
1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) and log in.
10+
2. Click **Create an App**.
11+
3. Fill in the App name (e.g. "CommitPulse Github Profile") and App description.
12+
4. Set the **Redirect URI** to \`http://localhost:3000/api/auth/callback/spotify\` (or your production callback URL).
13+
5. Agree to the terms and click **Save**.
14+
6. Note down your **Client ID** and **Client Secret**.
15+
16+
### 2. Get a Refresh Token
17+
18+
Since the Spotify token expires every hour, we need a long-lived **Refresh Token** to automatically fetch new access tokens in the background.
19+
20+
You can easily get a Refresh Token using Spotify's authorization flow:
21+
22+
1. Copy this URL, replace `YOUR_CLIENT_ID` with your actual Client ID, and open it in your browser:
23+
\`\`\`
24+
https://accounts.spotify.com/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:3000/api/auth/callback/spotify&scope=user-read-currently-playing
25+
\`\`\`
26+
2. Agree to the permissions.
27+
3. You will be redirected to \`http://localhost:3000/...\` with a \`?code=...\` parameter in the URL. Copy this **code**.
28+
4. Run the following cURL command (replace \`YOUR_CLIENT_ID\`, \`YOUR_CLIENT_SECRET\`, and \`YOUR_CODE\`):
29+
\`\`\`bash
30+
curl -X POST https://accounts.spotify.com/api/token \
31+
-d "grant_type=authorization_code" \
32+
-d "code=YOUR_CODE" \
33+
-d "redirect_uri=http://localhost:3000/api/auth/callback/spotify" \
34+
-H "Authorization: Basic $(echo -n YOUR_CLIENT_ID:YOUR_CLIENT_SECRET | base64)" \
35+
-H "Content-Type: application/x-www-form-urlencoded"
36+
\`\`\`
37+
5. In the JSON response, copy the \`refresh_token\`.
38+
39+
### 3. Configure CommitPulse
40+
41+
Add these keys to your \`.env.local\` file in CommitPulse:
42+
43+
\`\`\`env
44+
SPOTIFY_CLIENT_ID=your_client_id
45+
SPOTIFY_CLIENT_SECRET=your_client_secret
46+
SPOTIFY_REFRESH_TOKEN=your_refresh_token
47+
\`\`\`
48+
49+
## Usage
50+
51+
Use the endpoint in your GitHub `README.md`:
52+
53+
\`\`\`markdown
54+
![Currently Playing](https://your-commitpulse-url.vercel.app/api/spotify?theme=dark)
55+
\`\`\`
56+
57+
You can customize the SVG using standard parameters:
58+
59+
- \`theme=github\`
60+
- \`bg=0d1117\`
61+
- \`accent=1db954\` (Spotify Green)

0 commit comments

Comments
 (0)