概要
Spotify APIの楽曲レコメンド機能を使って、DJ Mix用のプレイリストを作成できるWebアプリケーションを開発しました。まずはそのアプリケーションの内容をご紹介します。
実装はTypeScript + Next.jsで行いVercelにデプロイする形にしたところ、非常に高い開発体験が得られました。後半はSpotify APIの使い方を含めて、Next.jsでの実装やVercel上での設定について書いていきます。
できたもの
AUTOMISCE - Automate Your Mix with Spotify API
使い方
まず"Sign in with Spotify"ボタンでSpotifyにログインします。次に右側の検索欄で最初の曲を選んでプレイリストに追加します。するとその曲と似たテンポでかつテンション感が少し上の曲が"Upper Tracks"欄に、少し下の曲が"Downer Tracks"欄に表示されます。
そこから次々と曲を選んでいくとプレイリストができるので、あとは名前を付けて保存すれば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 ID
とClient Secret
が認証処理に必要になります。(Client Secret
はSHOW CLIENT SECRET
をクリックすると表示)
ユーザー認証は以下の手順で行います。
- ユーザーをアプリケーションから
https://accounts.spotify.com/authorize
にリダイレクトさせる - ユーザーが認証画面で権限を許可する
- 設定したリダイレクトURIに
code
というパラメータ付きでリダイレクトされる -
code
、およびClient ID
、Client Secret
を付けてhttps://accounts.spotify.com/api/token
にリクエストを送る - アクセストークンが得られる
そのあとはアクセストークンを使って各APIからデータを取得することができます。基本的にAuthorization Guide通りの実装です。
(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 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を利用しました。
最後にトップページにリダイレクトさせれば認証処理は完了です。
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/play
APIに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手帳
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
が追加したドメインです。
Route 53側は以下のように設定しています。「ホストゾーン」からレコードを作成してレコードタイプはCNAME、ルーティング先はcname.vercel-dns.com
を指定すればOKです。
Vercelは外部のドメインからのルーティングも簡単に設定できるよう設計されていると感じました。
環境変数
ローカルサーバで開発している間はClient ID
とClient Secret
を.env.localファイルに書いて読み込んでいましたが、このファイルをGitHubのリポジトリに含めるわけにはいきません。これらの値はVercelの環境変数として設定します。
プロジェクトの「Settings」>「Environment Variables」画面から設定します。
- 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でデプロイ、ドメイン/環境変数設定もストレスレス
-
PCのブラウザで開いた場合のみ楽曲の再生が可能です。スマートフォンのブラウザでは後述するWeb Playback SDKが対応していないため、再生ボタンを押すとSpotifyのアプリが開く仕様にしました。 ↩
-
本文中に示したコードは簡単のため一部省略しています。完全な実装はリポジトリのソースコードを参照してください。 ↩
-
Spotify APIの詳しい仕様: Get Recommendations / Get Audio Features for Several Tracks ↩