Spotify API + Next.jsで作る選曲支援Webアプリ

## 概要

Spotify APIの楽曲レコメンド機能を使って、DJ Mix用のプレイリストを作成できるWebアプリケーションを開発しました。まずはそのアプリケーションの内容をご紹介します。

実装はTypeScript + Next.jsで行いVercelにデプロイする形にしたところ、非常に高い開発体験が得られました。後半はSpotify APIの使い方を含めて、Next.jsでの実装やVercel上での設定について書いていきます。

## できたもの

[AUTOMISCE - Automate Your Mix with Spotify API](https://automisce.unronritaro.net/)

<img width="800" alt="top" src="https://automisce.unronritaro.net/top.png">

## 使い方

まず"Sign in with Spotify"ボタンでSpotifyにログインします。次に右側の検索欄で最初の曲を選んでプレイリストに追加します。するとその曲と似たテンポでかつテンション感が少し上の曲が"Upper Tracks"欄に、少し下の曲が"Downer Tracks"欄に表示されます。

<img width="800" alt="recommend" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/458395/40758bfb-650b-224e-09df-b3bb035ea3fe.png">

そこから次々と曲を選んでいくとプレイリストができるので、あとは名前を付けて保存すればSpotifyのアプリなどから聴くことができます。またWebアプリ内で楽曲を再生することもできます。[^1]

[^1]: PCのブラウザで開いた場合のみ楽曲の再生が可能です。スマートフォンのブラウザでは後述するWeb Playback SDKが対応していないため、再生ボタンを押すとSpotifyのアプリが開く仕様にしました。

想定しているのはDJがミックス用の曲を選曲しているシーンです。曲同士をシームレスにつなぎ合わせるために、テンポとdanceability(踊りやすさ)が似た曲をレコメンドしています。またミックスの中でダンスフロアを盛り上げたり落ち着けたりするのもDJの仕事なので、選んだ曲よりテンション感が少し高いものと低いものを分けてレコメンドしました。

## プロジェクト構成

- 言語: TypeScript
- フレームワーク: Next.js
- インフラ: Vercel (+ AWS Route 53)

リポジトリ: [yuki-oshima-revenant/spotify-mix-automation](https://github.com/yuki-oshima-revenant/spotify-mix-automation)

型が付いていないと禁断症状に陥るのでTypeScriptにしました。またフレームワークは最近業務でも使い始めたNext.jsを採用しています。今回のようにログイン処理などでちょっとしたAPI呼び出し用のバックエンドが必要な場合、Next.jsのAPI Routeはベストプラクティスに近い便利さがあると感じました。

DBなどが必要ない純粋なフロントエンドのみの構成なのでインフラはVercelで完結します。GitHubにプッシュしておいてVercel画面上で連携させるだけでデプロイ/ビルド/配信までが簡単に終わるのでとてつもなく良い開発体験が得られました。

ただし、ドメインをAWS Route53で取得していたのでDNSだけRoute53が関わりました。VercelとRoute53の連携も非常に簡単に行えました。

## 実装[^2]

[^2]: 本文中に示したコードは簡単のため一部省略しています。完全な実装はリポジトリのソースコードを参照してください。

### Spotifyへのログイン

Spotify APIを利用する際は、まず[Spotify for Developers](https://developer.spotify.com/dashboard/)にサインインします。その上でアプリケーションの設定を行います。[こちらのヘルプ](https://developer.spotify.com/documentation/general/guides/app-settings/)通りに進めました。

アプリケーションが作成できたら、ダッシュボードからそのアプリを選択して`EDIT SETTING`ボタンから各種設定を行います。認証後にリダイレクトされてくるURIをあらかじめ設定しておく必要があります。ローカルでの開発段階では`http://localhost:3000/api/auth/authorize`のように`localhost`を設定しておきましょう。実際のドメインで動作させる際は`https://automisce.unronritaro.net/api/auth/authorize`といったURIを追加します。

また同じくアプリケーションの詳細画面に表示される`Client ID`と`Client Secret`が認証処理に必要になります。(`Client Secret`は`SHOW CLIENT SECRET`をクリックすると表示)

ユーザー認証は以下の手順で行います。

1. ユーザーをアプリケーションから`https://accounts.spotify.com/authorize`にリダイレクトさせる
2. ユーザーが認証画面で権限を許可する
3. 設定したリダイレクトURIに`code`というパラメータ付きでリダイレクトされる
4. `code`、および`Client ID`、`Client Secret`を付けて`https://accounts.spotify.com/api/token`にリクエストを送る
5. アクセストークンが得られる

そのあとはアクセストークンを使って各APIからデータを取得することができます。基本的に[Authorization Guide](https://developer.spotify.com/documentation/general/guides/authorization-guide/)通りの実装です。

<img src="https://developer.spotify.com/assets/AuthG_AuthoriztionCode.png" alt="AuthG_AuthoriztionCode" width="800">
(Authorization Code Flow https://developer.spotify.com/documentation/general/guides/authorization-guide/)

今回のアプリケーションでは`Sign in with Spotify`ボタンを押すとリダイレクトするようにしました。その際に`Client ID`をパラメータにつけて送る必要があります。この値を環境変数から読み込むことにしました。

Next.jsは特に設定せずとも`.env.local`という名前の付いたファイルに`CLIENT_ID = 'xxxxx'`と書いて、プロジェクトルートディレクトリに置けばそれらを環境変数として読み込んでくれます。ただしそれはgetStaticPropsなどの`data fetching methods`やAPI Routeでのみ有効です。

> By default all environment variables loaded through .env.local are only available in the Node.js environment, meaning they won't be exposed to the browser.
> 
> https://nextjs.org/docs/basic-features/environment-variables

今回は`getStaticProps`のなかでリダイレクト用のURLを作ってそれをコンポーネントに渡すようにしました。

```tsx
// pages/index.tsx

const Index = ({ loginPath }: InferGetStaticPropsType<typeof getStaticProps>) => {
    
    const login = useCallback(() => {
        window.location.href = loginPath;
    }, [loginPath]);
    
    ...

    return (
        <button onClick={login}>
            Sign in with Spotify
        </button>
    )
});

export const getStaticProps: GetStaticProps = async () => {
    // https://accounts.spotify.com/authorizeへのリクエストパラメータに必要な項目を設定
    const scopes = ['streaming', 'user-read-email', 'user-read-private', 'playlist-modify-public', 'playlist-modify-private'];
    const params = new URLSearchParams();
    params.append('client_id', process.env.CLIENT_ID || '');
    params.append('response_type', 'code');
    params.append('redirect_uri', process.env.RETURN_TO || '');
    params.append('scope', scopes.join(' '));
    params.append('state', 'state');
    return {
        props: { loginPath: `https://accounts.spotify.com/authorize?${params.toString()}` }
    }
};

export default Index;
```

このリダイレクト先ページでユーザーが権限を許可すると、`redirect_uri`に指定したURL(`http://localhost:3000/api/auth/authorize`など)に`?code=xxxxxxx&state=state`というパラメータが付いたGETリクエストが行われます。

これをAPI Routeで受け取って、tokenの取得処理を行いましょう。

```ts
// pages/api/auth/authorize.ts

type SpotifyAuthApiResponse = {
    access_token: string,
    token_type: string,
    scope: string,
    expires_in: number,
    refresh_token: string
}

const authorize = async (req, res) => {
    const { code, state } = req.query;
    
    const params = new URLSearchParams();
    params.append('grant_type', 'authorization_code');
    params.append('code', code as string);
    params.append('redirect_uri', process.env.RETURN_TO as string);

    const response = await axios.post<SpotifyAuthApiResponse>(
        'https://accounts.spotify.com/api/token',
        params,
        {
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'Authorization': `Basic ${Buffer.from(`${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`, 'utf-8').toString('base64')}`
            }
        }
    );

    req.session.set('user', {
        accessToken: response.data.access_token,
    });

    res.status(200).redirect('/');
};

export default withSession(authorize);

```

URLパラメータから`code`を取り出して、`https://accounts.spotify.com/api/token`にリクエストする際のパラメータに入れます。またトークン取得用のAPIは`Authorization`ヘッダーに`Client ID`と`Client Secret`から作成した文字列をbase64でエンコードして送る必要があります。

ここで`process.env.CLIENT_SECRET`などはNext.jsのAPI Route、つまりサーバ側でのみアクセス可能です。API Routeはこうしてフロントエンド側に露出したくないコードを単一のプロジェクト内に記述する際に役立ちます。言い換えると、API Routeによってアプリケーションの一部の処理をユーザーから隠蔽することができるわけです。もしこれがなければ、Spotify APIのヘルプにあるようにExpressなどでwebサーバを立ち上げる必要があるでしょう。Next.jsのAPI Routeによってその手間が不要になっています。

こうして得たSpotify APIのアクセストークンはセッション(Cookie)に保存しておきます。Next.jsでセッションを使うライブラリはいくつかありますが、今回はシンプルにCookieでセッションを貼ることができればいいので[next-iron-session](https://github.com/vvo/next-iron-session)を利用しました。

最後にトップページにリダイレクトさせれば認証処理は完了です。

### Spotify APIの利用

あとはアクセストークンを使ってSpotify APIにアクセスしていきます。たとえば楽曲のレコメンドを行うAPI Routeは以下のようにSpotify APIを呼び出しています。[^3]

[^3]: Spotify APIの詳しい仕様: [Get Recommendations](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-recommendations) / [Get Audio Features for Several Tracks](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-several-audio-features)

```ts
// pages/api/track/recommend.ts
type RecommendType = 'upper' | 'downer';

type AudioFeature = {
    danceability: number,
    energy: number,
    id: string,
    instrumentalness: number,
    key: number,
    liveness: number,
    loudness: number,
    mode: number,
    tempo: number,
    valence: number,
    track_href: string
};

type SpotifyRecommendApiResponse = {
    tracks: TrackItem[]
};

type TrackItem = {
    album: { href: string, name: string, images: { url: string, height: number }[] },
    artists: { href: string, name: string }[],
    href: string,
    id: string,
    name: string,
    uri: string,
    duration_ms:number,
};

type SpotifyFeaturesApiResponse = {
    audio_features: AudioFeature[]
};

const getRecommendTracks = async (audioFeature: AudioFeature, accessToken: string, type: RecommendType) => {
    // レコメンド対象の曲と似たdanceabilityの曲が対象
    const minDanceability = audioFeature.danceability * 0.8;
    const maxDanceability = audioFeature.danceability * 1.2;
    
    // レコメンドのタイプが'upper'なら元の曲からenergyの少し高い曲が、'downer'なら少し低い曲が対象
    const minEnergy = type === 'upper' ? audioFeature.energy : audioFeature.energy * 0.8;
    const maxEnergy = type === 'upper' ? audioFeature.energy * 1.2 : audioFeature.energy;

    const recommendationsParams = new URLSearchParams();
    recommendationsParams.set('seed_tracks', audioFeature.id);
    // DJが曲同士をつなぎ合わせやすいように似たテンポの曲が対象
    recommendationsParams.set('min_tempo', (audioFeature.tempo * 0.9).toString());
    recommendationsParams.set('max_tempo', (audioFeature.tempo * 1.1).toString());
    recommendationsParams.set('min_danceability', (minDanceability).toString());
    recommendationsParams.set('max_danceability', (maxDanceability).toString());
    recommendationsParams.set('min_energy', (minEnergy).toString());
    recommendationsParams.set('max_energy', (maxEnergy).toString());


    const recommendationsResponse = await axios.get<SpotifyRecommendApiResponse>(
        `https://api.spotify.com/v1/recommendations?${recommendationsParams.toString()}`,
        {
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'Authorization': `Bearer ${accessToken}`
            }
        }
    );

    // recommendations APIで取得した楽曲の特徴情報を取得
    const featuresParams = new URLSearchParams();
    featuresParams.append('ids', recommendationsResponse.data.tracks.map((item => item.id)).join(','));
    const featuresResponse = await axios.get<SpotifyFeaturesApiResponse>(
        `https://api.spotify.com/v1/audio-features?${featuresParams.toString()}`,
        {
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'Authorization': `Bearer ${accessToken}`
            }
        }
    );
    const audioFeatures = featuresResponse.data.audio_features;
    
    return recommendationsResponse.data.tracks.map((item) => {
        const targetItemFeature = audioFeatures.find((feature) => (feature.id === item.id));
        return { ...item, audioFeatures: targetItemFeature };
    });
};
const recommendedTracks = async (req, res) => {
        // このAPIへのリクエストからレコメンドする元の曲の特徴情報を受け取る
        const audioFeature = req.body.track.audioFeatures;
        // セッションからアクセストークンを取り出す
        const accessToken = req.session.get('user').accessToken;

        const [upperTracks, downerTracks] = await Promise.all(
            [
                getRecommendTracks(audioFeature, accessToken, 'upper'),
                getRecommendTracks(audioFeature, accessToken, 'downer')
            ]
        );
        res.status(200)
        res.json({
            upperTracks,
            downerTracks
        });
    }
};
export default withSession(recommendedTracks);
```

基本的に、Spotify APIを呼び出す際は`Authorization`ヘッダーに`Bearer ${accessToken}`というようにアクセストークンを含めて送ります。

このAPI Routeはユーザーが検索してプレイリストに追加した楽曲のメタ情報をもとにSpotify APIがレコメンドした曲を取得して返しています。レコメンド元となる曲と

- danceabilityの値が近い
- energyの値が元の曲より少し高いか低い
- tempoの値が近い

ことを条件としてSpotify APIの楽曲レコメンドAPIを呼び出します。`seed_tracks`パラメータにレコメンド元の曲のidを入れておくだけでなく、こうしたパラメータを指定することで目的の曲に絞り込むことができるわけです。

### Web Playback SDK

ブラウザ上でのSpotifyの楽曲再生には[Web Playback SDK](https://developer.spotify.com/documentation/web-playback-sdk/)を利用しました。Reactでの実装はこちらのライブラリのソースコードが参考になります。[use-spotify-web-playback-sdk](https://github.com/niekert/use-spotify-web-playback-sdk)

SDK自体はCDNから読み込む必要がありますが、型情報は[@types/spotify-web-playback-sdk](https://www.npmjs.com/package/@types/spotify-web-playback-sdk)があるのでnpmでインストールすればOKです。

```bash
npm install --save-dev @types/spotify-web-playback-sdk
```

自分のアプリケーションでは以下のように実装しました。

```tsx
// pages/index.tsx

// ログイン情報を取得するAPI用のフック
// Web Playback SDKで使うため、セッションからアクセストークンを取得している
const { data: loginData, error: loginError, mutate: loginMutate } = useLoginApi();
// refにプレイヤーのインスタンスを格納しておく
const playerRef = useRef<Spotify.SpotifyPlayer | null>(null);
const [deviceId, setDeviceId] = useState<string>();

useEffect(() => {
    if (loginData && loginData.accessToken) {
        // window.onSpotifyWebPlaybackSDKReadyのコールバックを定義する
        // SDKが読み込まれたタイミングでこのコールバックが実行される
        window.onSpotifyWebPlaybackSDKReady = () => {
            const player = new Spotify.Player({
                name: 'AUTOMISCE Player',
                getOAuthToken: async (cb) => {
                    cb(loginData.accessToken as string);
                },
                volume: 0.5
            });
            player.addListener('ready', ({ device_id }) => {
            // ここで楽曲を再生する際に必要なdevice_idを取得してstateに格納しておく
                setDeviceId(device_id);
            });
            player.connect();
            playerRef.current = player;
        };
        if (!window.Spotify) {
            // Web Playback SDKを読み込む
            const scriptTag = document.createElement('script');
            scriptTag.src = 'https://sdk.scdn.co/spotify-player.js';
            document.head!.appendChild(scriptTag);
        }
    }
}, [loginData]);

...

```

あとは`https://api.spotify.com/v1/me/player/play`APIに`device_id`と楽曲の`uri`(recommendations APIなどから取得可能)を送るとブラウザ上で再生が始まります。

一時停止などは`Spotify.SpotifyPlayer`インスタンスの`togglePlay`メソッドを使いましょう。

レコメンドされて出てきた曲の再生ボタンは以下のようになっています。

```tsx
// lib/component/TrackCard
// trackやsetState系は親コンポーネントから引数で取得

<AiFillPlayCircle
    className={styles.button}
    onClick={() => {
        // スマートフォンではSDKが動作しないのでopen.spotify.comのリンクを開く
        const userAgent = window.navigator.userAgent.toLowerCase();
        if (userAgent.indexOf('iphone') != -1 || userAgent.indexOf('ipad') != -1 || userAgent.indexOf('android') != -1) {
            window.open(`https://open.spotify.com/track/${track.id}`)
            return;
        }
        if (playingTrack?.id === track.id) {
            playerRef.current?.togglePlay();
        } else {
            playerRef.current?.pause();
            setPlayingTrack(track);
            try {
                // '/api/track/play'で'https://api.spotify.com/v1/me/player/play'にパラメータを送る
                axios.post('/api/track/play',
                    {
                        deviceId,
                        uris: [track.uri]
                    });
                setIsPlaying(true);
            } catch (e) {
                setPlayingTrack();
            }
        }
    }}
/>
```

曲のプレイヤーにあるシークバーは少し工夫が必要でした。こちらの記事を参考に実装しています。[audioプレイヤーをシークバーで操作する - ゆったりWeb手帳](https://blog.zatsuzen.com/javascript/audio-seekbar/)

<img alt="player" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/458395/75d5765f-f474-5f3f-aadc-7611206230f3.png"/>

100ミリ秒ごと(適当)に`Spotify.SpotifyPlayer`インスタンスの`getCurrentState`から再生位置を取得して、stateに保持しておきます。その値に従ってcssスタイルの`background-size`を変更することで、背景色によって再生位置を表示することができるようになります。またシークバーの任意の場所をクリックされたときに、その位置の横幅全体から見た割合を計算してその位置に曲の再生位置を変更します。再生位置の変更は`Spotify.SpotifyPlayer`インスタンスの`seek`メソッドを使います。


```tsx
// pages/index.tsx

    const [playingTrackPosition, setPlayingTrackPosition] = useState<number>(0);
    const seekbarRef = useRef<HTMLDivElement | null>(null);

    useEffect(() => {
        if (isPlaying) {
            const timeout = setInterval(async () => {
                const palyerState = await playerRef.current?.getCurrentState();
                setPlayingTrackPosition(palyerState?.position || 0);
            }, 100);
            return () => { clearInterval(timeout) };
        }
    }, [isPlaying]);

    return (
        ...
        <div
            className={styles.seekbar}
            ref={seekbarRef}
            style={{ backgroundSize: `${(playingTrackPosition / playingTrack.duration_ms) * 100}%` }}
            onClick={(e) => {
                const mouse = e.pageX;
                const rect = seekbarRef.current?.getBoundingClientRect();
                if (rect) {
                    const position = rect.left + window.pageXOffset;
                    const offset = mouse - position;
                    const width = rect?.right - rect?.left;
                    playerRef.current?.seek(Math.round(playingTrack.duration_ms * (offset / width)))
                }
            }}
        />
        ...
    );

```

### デプロイ

[Vercel](https://vercel.com/)へのデプロイ自体はログインしてGitHubのリポジトリを連携するだけで終わり非常に快適です。ここではドメインと環境変数の設定について書いていきます。

#### ドメイン

自分はドメイン(`unronritaro.net`)をAWS Route53で保有しているので、そのサブドメインをVercelにルーティングしようと思いました。その手順はこの記事の通りです。[VercelでホスティングしているサイトにRoute53で取得したドメインをサブドメインとして設定する | DevelopersIO](https://dev.classmethod.jp/articles/vercel-custom-domain-route53/)

Vercel上ではGitHubと連携したプロジェクトの「Settings」>「Domains」画面で以下のように設定します。`automisce.unronritaro.net`が追加したドメインです。

<img width="800" alt="vercel_domain" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/458395/92bfc49f-6ac8-8d72-d651-89aec053c689.png">

Route 53側は以下のように設定しています。「ホストゾーン」からレコードを作成してレコードタイプはCNAME、ルーティング先は`cname.vercel-dns.com`を指定すればOKです。

<img width="800" alt="route53_domain" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/458395/252b9363-788a-a939-30c4-7a3e1e07eb60.png">

Vercelは外部のドメインからのルーティングも簡単に設定できるよう設計されていると感じました。

#### 環境変数

ローカルサーバで開発している間は`Client ID`と`Client Secret`を.env.localファイルに書いて読み込んでいましたが、このファイルをGitHubのリポジトリに含めるわけにはいきません。これらの値はVercelの環境変数として設定します。

プロジェクトの「Settings」>「Environment Variables」画面から設定します。

<img width="800" alt="vevrcel_env" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/458395/839f903a-9473-3b5e-09fd-82165226e552.png">

- typeは「Secret」を選択
- nameは`client_id`など任意に設定
- valueは「Create new Secret for…」を選択して表示されるモーダルに入力
- 適用する環境は「Production」「Preview」が選ばれたデフォルトのままでOK

注意点として、画面上から設定した環境変数を適用するためにはもう一度ビルドする必要があります。「Deployments」画面から最新のデプロイのメニューを開き、「Redeploy」を選べば再ビルドされます。

## まとめ

- できたもの: [AUTOMISCE - Automate Your Mix with Spotify API](https://automisce.unronritaro.net/)
- Next.jsのAPI RouteはSpotify APIなど共通鍵を使うAPI呼び出しに使うとGood
- Vercelでデプロイ、ドメイン/環境変数設定もストレスレス