React+NextJS+SpotifyAPIで今聴いている曲を表示するコンポーネントを作った話〜SSR篇〜


みなさんこんにちは、あかつきゆいとです。

今回は前に続いて、SpotifyAPIを使った現在再生中の曲を自分のサイトに表示するコンポーネントを作成していきます。
前は認証まで終わらせましたね。
次は、SSRを使ったコンポーネントの解説です。

:::note info
この記事は2つに分かれています。
分割した理由は前の記事の最後に書いてます。
<https://qiita.com/yuito_it_/items/2f59e1dcc1fa626ae94b>
:::

:::note warn
なお、私がAppsRouterばっかり使っているので、PagesRouterの方法は今回は書いていません。
:::

## SpotifyAPIで現在再生中の曲を取得する

では、SpotifyAPIを実際に叩いて、現在再生中の曲を取得するライブラリを追記しましょう。
私は、`/src/lib/spotify.ts`に追記しました。

```typescript
async function refreshAccessTokenToSpotify() {
  const headers = {
    Authorization: "Basic " + basic_authorization,
    "Content-Type": "application/x-www-form-urlencoded",
  };
  const payload = qs.stringify({
    grant_type: "refresh_token",
    refresh_token: refresh_token,
  });

  try {
    const response = await axios.post(
      "https://accounts.spotify.com/api/token",
      payload,
      { headers }
    );
    const data = response.data;
    access_token = data.access_token;
    if (data.refresh_token) {
      refresh_token = data.refresh_token;
    }
    const tokens = JSON.stringify(
      {
        access_token: access_token,
        refresh_token: refresh_token,
      },
      null,
      " "
    );
    fs.writeFileSync(tokensPath, tokens);
  } catch (error) {
    const err = error as any;
    console.error(
      "Error refreshing access token:",
      err.response?.data || err.message
    );
    return 1;
  }
}

async function getNowPlaying() {
  const headers = { Authorization: `Bearer ${access_token}` };

  try {
    const response = await axios.get("https://api.spotify.com/v1/me/player", {
      headers,
    });

    if (response.status === 200) {
      const json = JSON.stringify(
        { data: response.data, timestamp: Date.now() },
        null,
        " "
      );
      if (response.data.device.is_private_session) {
        console.log("Private session, return null.");
        return null;
      }
      fs.writeFileSync(nowPlayingPath, json);
      return response.data;
    } else if (response.status === 204) {
      return null;
    }
  } catch (error: any) {
    if (error.response?.status === 401) {
      if (await refreshAccessTokenToSpotify() == 1) return null;
      return await getNowPlaying();
    } else {
      const err = error as any;
      console.error(
        "Error getting now playing:",
        err.response?.data || err.message
      );
    }
  }
}
```

さて、各コードの説明をしましょう。
このコードは大きく分けて2つに分かれています。
一つ目は、RefleshTokenを用いて新しいトークンを取得するコード、二つ目は、トークンを用いて現在再生中の曲を取得するスクリプトです。

RefleshTokenから新たなトークンを取得するコードの方は、codeからトークンを取得するスクリプトと対して変わり無いので、割愛します。
あとで差分出してみてください。理解するのは意外と簡単です。

まず、ここで、データをとってきます。
ヘッダーにはアクセストークンが割り当たっています。

```typescript
const response = await axios.get("https://api.spotify.com/v1/me/player", {
  headers,
});
```

お次に、ここで、データの振り分けをしています。
ステータスコードが200であれば、正常にデータが返ってきているので、returnで返却します。
なぜここでファイルに保存しているかという話は後でします。

204はデータが何もないことを示します。何も再生してないということですね。

また、is_private_sessionでプライベートセッションの振り分けをしています。
これをめんどくさがって抜かすとInternalErrorが出ます。

```typescript
if (response.status === 200) {
  const json = JSON.stringify(
    { data: response.data, timestamp: Date.now() },
    null,
    " "
  );
  if (response.data.device.is_private_session) {
    console.log("Private session, return null.");
    return null;
  }
  fs.writeFileSync(nowPlayingPath, json);
  return response.data;
} else if (response.status === 204) {
  return null;
}
```

エラーハンドリングの部分を解説します。
401が出た際には、トークンの有効期限が切れているということなので、リフレッシュトークンを用いて更新します。
それ以外の場合にはログを出して終了としています。

```typescript
if (error.response?.status === 401) {
  if (await refreshAccessTokenToSpotify() == 1) return null;
  return await getNowPlaying();
} else {
  const err = error as any;
  console.error(
    "Error getting now playing:",
    err.response?.data || err.message
  );
}
```

## キャッシュ

先ほどなぜファイルに入れるのだろうと思った方、いますよね？
そうなのです、そこなのです。

今回の実装では、ページが読み込まれるたびにデータを取得する構造ですが、このままだと、レート制限をもろに喰らってしまってしまいます。
なので、短時間であればキャッシュを返すようにしています。

```typescript
export async function setSpotifyStatus() {
  let now_playing_temp = null;
  if (fs.existsSync(nowPlayingPath)) {
    now_playing_temp = JSON.parse(fs.readFileSync(nowPlayingPath, "utf8"));
  }
  if (!access_token) {
    await getFirstAccessTokenToSpotify();
  }
  if (now_playing_temp && Date.now() - now_playing_temp.timestamp < 5000) {
    console.log("Return cahce...");
    return now_playing_temp.data;
  }
  const now_playing = await getNowPlaying();

  if (now_playing) {
    if (now_playing.currently_playing_type === "track") {
      console.log("Now playing:", now_playing.item.name);
    }
  } else {
    console.log("Not playing anything.");
  }
  return now_playing;
}
```

## これを呼び出してコンポーネントを作成する

では、これを呼び出してコンポーネントを作成しましょう。

全体像はこんな感じです。

```tsx
import { setSpotifyStatus } from "@/lib/spotify";
import Image from 'next/image';
import { FaSpotify } from "react-icons/fa6";

export const dynamic = 'force-dynamic';

async function NowPlayingWidget() {
    const nowPlaying = await setSpotifyStatus();

    if (!nowPlaying) return;
    if (nowPlaying.currently_playing_type=="ad") return;

    let artists = nowPlaying.item.artists.map((artist: { name: string; }) => artist.name).join(", ");
    if (artists.length > 10) {
        artists = artists.slice(0, 10) + "...";
    }

    return (
        <div className="flex flex-row w-full gap-3 max-h-100 items-center justify-center">
            <Image src={nowPlaying.item.album.images[0].url} alt={nowPlaying.item.name} unoptimized width={100} height={100} className="max-h-full"/>
            <div className="flex flex-col grow max-h-100">
                <div>
                    <h3 className="md:text-md text-sm flex flex-row items-center gap-1"><FaSpotify/>Spotify - Now Playing</h3>
                    <h2 className="md:text-xl">{nowPlaying.item.name}</h2>
                    <h4 className="text-sm">{artists}</h4>
                </div>
                <div className="w-full bg-gray-200 rounded-full h-1.5 mb-4 dark:bg-gray-700">
                    <div
                        className={`bg-blue-600 h-1.5 rounded-full dark:bg-blue-500`}
                        style={{ width: `${(nowPlaying.progress_ms / nowPlaying.item.duration_ms) * 100}%` }}
                    />
                </div>
            </div>
        </div>
    );
}

export default NowPlayingWidget;
```

説明していきましょう。

importに関しては、アイコンとさっきのライブラリですね。

```ts
import { setSpotifyStatus } from "@/lib/spotify";
import Image from 'next/image';
import { FaSpotify } from "react-icons/fa6";
```

この一文が今回の肝です。
ちょっと込み入った話になるので、後で詳しく説明します。

```typescript
export const dynamic = 'force-dynamic';
```

まずここでライブラリを用いてデータを取得してきます。
artistsはartistsが配列で、合作などの時に使えます。

```typescript
const nowPlaying = await setSpotifyStatus();

if (!nowPlaying) return;
if (nowPlaying.currently_playing_type=="ad") return;
let artists = nowPlaying.item.artists.map((artist: { name: string; }) => artist.name).join(", ");
if (artists.length > 10) {
    artists = artists.slice(0, 10) + "...";
}
```

ここで、再生画面を表示しています。
もう見てもらったらわかるかと思いますので、大体は割愛します。
`${(nowPlaying.progress_ms / nowPlaying.item.duration_ms) * 100}%`だけ解説すると、これはプログレスバーを表示しています。

```tsx
return (
    <div className="flex flex-row w-full gap-3 max-h-100 items-center justify-center">
        <Image src={nowPlaying.item.album.images[0].url} alt={nowPlaying.item.name} unoptimized width={100} height={100} className="max-h-full"/>
        <div className="flex flex-col grow max-h-100">
            <div>
                <h3 className="md:text-md text-sm flex flex-row items-center gap-1"><FaSpotify/>Spotify - Now Playing</h3>
                <h2 className="md:text-xl">{nowPlaying.item.name}</h2>
                <h4 className="text-sm">{artists}</h4>
            </div>
            <div className="w-full bg-gray-200 rounded-full h-1.5 mb-4 dark:bg-gray-700">
                <div
                    className={`bg-blue-600 h-1.5 rounded-full dark:bg-blue-500`}
                    style={{ width: `${(nowPlaying.progress_ms / nowPlaying.item.duration_ms) * 100}%` }}
                />
            </div>
        </div>
    </div>
);
```

## 強制SSR

先ほどのこの一文ですね。

```typescript
export const dynamic = 'force-dynamic';
```

これが何を表しているかという話ですが、AppRouterで追加された、Route Segment Configという機能らしいです。
デフォルトのautoに設定していると静的レンダリングになってしまいます。
今回は表示される都度データを取得するので、動的にレンダリングしなければなりません。
ですので、force-dynamicを指定し、SSRに強制しています。

これがないと、next buildをしたときに一度だけデータを取得してそこからデータが変わらなくなってしまいます。

## まとめ

今回のコードはSSRがとても重要でしたね。
全てのコードは、ぼくのサイトのリポにありますので、気になった方は見てみてください。
(ちなみに、リファクタリングできる場所があるのはわかっていますが暇がなくて治してません。)

<https://github.com/yuito-it/yuito.work>

ではでは...