Skip to content

Commit

Permalink
feat(spotify): integrate Spotify API for show recently played tracks
Browse files Browse the repository at this point in the history
  • Loading branch information
chimpdev committed Nov 6, 2024
1 parent 0d9f22f commit 872610c
Show file tree
Hide file tree
Showing 13 changed files with 261 additions and 6 deletions.
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_REDIRECT_URL=
SPOTIFY_REFRESH_TOKEN=
3 changes: 2 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ module.exports = {
],
'no-unneeded-ternary': 'error',
'no-multi-spaces': 'error',
'security/detect-object-injection': 'off'
'security/detect-object-injection': 'off',
'no-var': 'off'
},
overrides: [
{
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,26 @@ cd bencan.net
pnpm install
```

4. Start the server:
5. Rename the `.env.example` file to `.env` and fill in the configuration values:

```env
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_REDIRECT_URL=
SPOTIFY_REFRESH_TOKEN=
```

> [!NOTE]
> - To get the Spotify API credentials, you need to create a Spotify Developer account and create a new application. After creating the application, you will get the client ID and client secret. Don't forget to set the redirect URL to `https://YOUR_DOMAIN/api/spotify/callback` where `YOUR_DOMAIN` is the domain where you are hosting the website.
> - To get the Spotify refresh token, after you have set the client ID, client secret, and redirect URL, skip this step and start the server. The server will automatically start at `http://localhost:3000`. Visit `http://localhost:3000/api/spotify` and log in with your Spotify account. After logging in, you will be redirected to `http://localhost:3000/api/spotify/callback`. The refresh token will be displayed on the page. Copy the refresh token and paste it in the `.env` file. After pasting the refresh token, restart the server. With this implementation, every 1 hour the server will automatically get a new access token using the refresh token and display your recently played tracks on the website. If you want to get rid of "recently played tracks" functionality, you can just use empty .env file.
6. Start the server:

```bash
pnpm start
```

5. The server should now be running on `http://localhost:300`.
5. The server should now be running on `http://localhost:3000`.

## Contributing

Expand Down
31 changes: 31 additions & 0 deletions app/api/spotify/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import btoa from 'btoa';
import type { SpotifyTokenResponse } from '@/types';

export async function GET(request: NextRequest) {
if (process.env.NODE_ENV !== 'development') return NextResponse.json({ error: 'not_implemented' }, { status: 501 });

const url = new URL(request.url);

const code = url.searchParams.get('code');
if (!code) return NextResponse.json({ error: 'missing_params' }, { status: 400 });

const response = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${btoa(`${process.env.SPOTIFY_CLIENT_ID}:${process.env.SPOTIFY_CLIENT_SECRET}`)}`
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: process.env.SPOTIFY_REDIRECT_URL
})
});

if (!response.ok) return NextResponse.json({ error: 'failed_to_get_token' }, { status: 400 });

const data: SpotifyTokenResponse = await response.json();

return NextResponse.json(data.access_token);
}
19 changes: 19 additions & 0 deletions app/api/spotify/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';

const CLIENT_ID = process.env.SPOTIFY_CLIENT_ID;
const REDIRECT_URI = process.env.SPOTIFY_REDIRECT_URL;

export async function GET(): Promise<Response> {
if (process.env.NODE_ENV !== 'development') return NextResponse.json({ error: 'not_implemented' }, { status: 501 });

const scope = 'user-read-recently-played';

const url = new URL('https://accounts.spotify.com/authorize');

url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('scope', scope);
url.searchParams.set('redirect_uri', REDIRECT_URI);

return NextResponse.redirect(url.toString());
}
24 changes: 23 additions & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import InlineQuote from '@/components/inline-quote';
import Projects from '@/components/projects';
import Works from '@/components/works';
import Blogs from '@/components/blogs';
import Songs from '@/components/songs';
import { Suspense } from 'react';
import { LuLoader } from 'react-icons/lu';

Expand Down Expand Up @@ -54,7 +55,7 @@ export default function Page() {

<div className='flex flex-col gap-y-4'>
<h1 className='font-bricolageGrotesque font-medium text-secondary'>
My Blog Posts
Blog Posts
</h1>

<Suspense
Expand All @@ -69,6 +70,27 @@ export default function Page() {
</Suspense>
</div>

<div className='flex flex-col gap-y-4'>
<h1 className='font-bricolageGrotesque font-medium text-secondary'>
Daily Songs
</h1>

<p className='text-sm text-secondary'>
I listen to music while working. View the songs I listened to recently.
</p>

<Suspense
fallback={
<div className='flex items-center gap-x-2 text-xs text-tertiary'>
<LuLoader className='animate-spin' />
You want coffee or tea while waiting? 🍵☕
</div>
}
>
<Songs />
</Suspense>
</div>

<div className='flex flex-col gap-y-4'>
<h1 className='font-bricolageGrotesque font-medium text-secondary'>
More
Expand Down
73 changes: 73 additions & 0 deletions components/songs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import getNewAccessToken from '@/utils/getSpotifyAccessToken';
import Image from 'next/image';
import Link from 'next/link';

export default async function Songs() {
if (!process.env.SPOTIFY_CLIENT_ID || !process.env.SPOTIFY_CLIENT_SECRET || !process.env.SPOTIFY_REFRESH_TOKEN) {
console.warn('Environment variables SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, and SPOTIFY_REFRESH_TOKEN are required to render the recently played songs component');

return (
<span className='text-tertiary'>
No data available
</span>
);
}

const accessToken = await getNewAccessToken();

const response = await fetch('https://api.spotify.com/v1/me/player/recently-played?limit=5', {
headers: {
Authorization: `Bearer ${accessToken}`
},
next: {
revalidate: 3600
}
});

if (!response.ok) throw new Error('Failed to get recently played tracks');

const tracks: SpotifyApi.UsersRecentlyPlayedTracksResponse = await response.json();

if (!tracks.items.length) return (
<span className='text-tertiary'>
No data available
</span>
);

return tracks.items
.sort((a, b) => new Date(b.played_at).getTime() - new Date(a.played_at).getTime())
.map(({ played_at, track }) => (
<Link
href={track.album.external_urls.spotify}
key={track.id}
className='group relative z-10 flex w-full items-center gap-x-4'
>
<div className='absolute left-0 top-0 -z-10 h-[130%] w-[105%] translate-x-[-2.5%] translate-y-[-12%] rounded-xl bg-secondary opacity-0 transition-opacity duration-75 group-hover:opacity-100' />

<Image
src={track.album.images[0].url}
height={48}
width={48}
alt={track.name}
className='rounded-lg'
/>

<div className='flex flex-col'>
<h2 className='font-medium text-primary'>
{track.name}
</h2>

<div className='flex items-center text-sm text-tertiary'>
{track.artists.map(({ name }) => name).join(', ')}

<span className='mx-1'></span>

{new Date(played_at).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
})}
</div>
</div>
</Link>
));
}
5 changes: 4 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import createMDX from '@next/mdx';

const nextConfig: NextConfig = {
reactStrictMode: false,
pageExtensions: ['tsx', 'mdx'],
pageExtensions: ['tsx', 'mdx', 'ts', 'js'],
images: {
remotePatterns: [
{
hostname: 'images.unsplash.com'
},
{
hostname: 'i.scdn.co'
}
]
}
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@
"@mdx-js/react": "^3.1.0",
"@next/mdx": "^15.0.2",
"@radix-ui/react-tooltip": "^1.1.3",
"@types/btoa": "^1.2.5",
"@types/mdx": "^2.0.13",
"@types/spotify-api": "^0.0.25",
"btoa": "^1.2.1",
"clsx": "^2.1.1",
"dedent": "^1.5.3",
"next": "^15.0.2",
Expand Down
28 changes: 28 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions types/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: 'development' | 'production';
SPOTIFY_CLIENT_ID: string;
SPOTIFY_CLIENT_SECRET: string;
SPOTIFY_REDIRECT_URL: string;
SPOTIFY_REFRESH_TOKEN: string;
}
}
}

export {};
10 changes: 9 additions & 1 deletion types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,12 @@ declare module 'next' {
export interface Metadata {
date?: string;
}
}
}

export type SpotifyTokenResponse = {
access_token: string;
token_type: string;
scope: string;
expires_in: number;
refresh_token: string;
};
37 changes: 37 additions & 0 deletions utils/getSpotifyAccessToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Retrieves a new Spotify access token using the refresh token.
*
* @returns {Promise<string>} A promise that resolves to the new access token.
* @throws {Error} If any of the required environment variables are missing or if the request fails.
*
* Environment Variables:
* - `SPOTIFY_CLIENT_ID`: The Spotify client ID.
* - `SPOTIFY_CLIENT_SECRET`: The Spotify client secret.
* - `SPOTIFY_REFRESH_TOKEN`: The Spotify refresh token.
*/
export default async function getNewAccessToken(): Promise<string> {
if (!process.env.SPOTIFY_CLIENT_ID || !process.env.SPOTIFY_CLIENT_SECRET || !process.env.SPOTIFY_REFRESH_TOKEN) {
throw new Error('Missing environment variables.');
}

const response = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${btoa(`${process.env.SPOTIFY_CLIENT_ID}:${process.env.SPOTIFY_CLIENT_SECRET}`)}`
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: process.env.SPOTIFY_REFRESH_TOKEN
}),
next: {
revalidate: 3500
}
});

if (!response.ok) throw new Error('Failed to get new access token.');

const data = await response.json();

return data.access_token;
}

0 comments on commit 872610c

Please sign in to comment.