216
Help us understand the problem. What are the problem?

posted at

updated at

Organization

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

top

使い方

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

recommend

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

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

プロジェクト構成

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

リポジトリ: 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

Spotifyへのログイン

Spotify APIを利用する際は、まずSpotify for Developersにサインインします。その上でアプリケーションの設定を行います。こちらのヘルプ通りに進めました。

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

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

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

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

そのあとはアクセストークンを使って各APIからデータを取得することができます。基本的にAuthorization Guide通りの実装です。

AuthG_AuthoriztionCode
(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を作ってそれをコンポーネントに渡すようにしました。

// 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の取得処理を行いましょう。

// 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 IDClient 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を利用しました。

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

Spotify APIの利用

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

// 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を利用しました。Reactでの実装はこちらのライブラリのソースコードが参考になります。use-spotify-web-playback-sdk

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

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

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

// 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/playAPIにdevice_idと楽曲のuri(recommendations APIなどから取得可能)を送るとブラウザ上で再生が始まります。

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

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

// 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手帳

player

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

// 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へのデプロイ自体はログインしてGitHubのリポジトリを連携するだけで終わり非常に快適です。ここではドメインと環境変数の設定について書いていきます。

ドメイン

自分はドメイン(unronritaro.net)をAWS Route53で保有しているので、そのサブドメインをVercelにルーティングしようと思いました。その手順はこの記事の通りです。VercelでホスティングしているサイトにRoute53で取得したドメインをサブドメインとして設定する | DevelopersIO

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

vercel_domain

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

route53_domain

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

環境変数

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

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

vevrcel_env

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

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

まとめ

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

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

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

  3. Spotify APIの詳しい仕様: Get Recommendations / Get Audio Features for Several Tracks 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
216
Help us understand the problem. What are the problem?