AT Protocol初心者がアニメの視聴状況をPDSに保存できるツールを作ってみた

# はじめに

この記事は、[Bluesky Advent Calendar 2024](https://adventar.org/calendars/10752) 16日目の記事です。

https://adventar.org/calendars/10752

はじめまして、marilと申します。

今回は、ユーザーのPDSにアニメの視聴状況を保存できるツール「AniBlue」のベータ版としてひとまず動くものができたので、ご紹介させていただきます。

AT Protocolに関してまだまだ初心者なので、間違いや改善点等ありましたらご指摘いただけると助かります。

# 作ったもの

GitHub

https://github.com/marukun712/AniBlue

「AniBlue」は、アニメの視聴ステータス/お気に入りなどの情報を、すべてユーザーのPDSに保存することができるアニメ視聴管理ツールです。

アニメのタイトルやエピソードなどの情報の取得には、Annict APIを使用させていただきました。

https://developers.annict.com/

# 機能
選択したアニメを 
- 見たい
- 視聴中
- 視聴済み
- お気に入り

の状態に設定して、一覧で管理することができます。

また、画像のように視聴しているエピソードを記録することもできます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2204837/a0a45636-d547-c3ab-c672-56c209326d4c.png)

# Lexicon
アニメの視聴ステータスは、以下のようなlexiconで配列として保存されます。

```json
{
  "lexicon": 1,
  "id": "app.netlify.aniblue.status",
  "defs": {
    "main": {
      "type": "record",
      "description": "A record that stores the status of the anime.",
      "key": "literal:self",
      "record": {
        "type": "object",
        "required": ["status"],
        "properties": {
          "status": {
            "type": "array",
            "items": {
              "type": "ref",
              "ref": "#status"
            }
          }
        }
      }
    },
    "status": {
      "type": "object",
      "required": ["id", "title", "status", "episode_text"],
      "properties": {
        "id": {
          "type": "integer",
          "description": "Annict API ID for the anime"
        },
        "title": {
          "type": "string",
          "description": "Title of the anime"
        },
        "thumbnail": {
          "type": "string",
          "description": "URL of the anime thumbnail image"
        },
        "status": {
          "type": "string",
          "description": "Current watching status of the anime",
          "enum": ["watching", "watched", "pending"]
        },
        "episode_text": {
          "type": "string",
          "description": "Current number text of episode"
        },
        "favorite": {
          "type": "boolean",
          "description": "Favorite flag of the anime"
        }
      }
    }
  }
}
```

例えば、私のアカウントに登録されているレコードは、このようになっています。

https://atproto-browser.vercel.app/at/did:plc:hbpzfim6uqz522avxupaud5y/app.netlify.aniblue.status/self

このレコードを取得、recoilのatomとして定義します。
```jsx
export default function WorkDetail() {
  const { work, status, error } = useLoaderData<typeof loader>();

  const setAnimeState = useSetAnimeState();

  if (status) {
    setAnimeState(status.value.status);
  } else {
    setAnimeState([]);
  }
```
先にstateを更新して、そのstateをもとにPDS側も更新しています。
```jsx
    const updateAnimeState = async (newState: AnimeStatus[]) => {
      // ローカルのstateを更新
      setAnimeState(newState);

      // PDS側の更新
      const res = await fetch("/api/status/update", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          $type: "app.netlify.aniblue.status",
          status: newState,
        }),
      });

      const json = await res.json();

      if (!json.ok) {
        throw new Error("情報の更新に失敗しました");
      }
    };

    const handleStatusUpdate = async (status: Status) => {
      try {
        // prevStateが存在しない場合はレコードを新規作成
        const newState = prevState
          ? animeState.map((item) =>
              item.id === id ? { ...item, status } : item
            )
          : [
              ...animeState,
              {
                id,
                title,
                thumbnail: imageUrl,
                status,
                episode_text: "",
                favorite: false,
              },
            ];

        await updateAnimeState(newState);
```
```jsx
import { ActionFunction } from "@remix-run/node";
import {
  isRecord,
  validateRecord,
} from "~/generated/api/types/app/netlify/aniblue/status";
import { StatusAgent } from "~/lib/agent/statusAgent";
import { getSessionAgent } from "~/lib/auth/session";

export const action: ActionFunction = async ({ request }) => {
  const agent = await getSessionAgent(request);
  if (agent == null) return new Response(null, { status: 401 });

  const record = await request.json();

  const statusAgent = new StatusAgent(agent);

  //バリデーション
  if (isRecord(record) && validateRecord(record)) {
    await statusAgent.put(record, agent.assertDid);

    return { ok: true };
  }

  return new Response(null, { status: 500 });
};
```

# 認証
OAuthでの認証周りは、以下の記事を参考にして実装しました。

https://whtwnd.com/mkizka.dev/3l5wca2fyub2c

ただ、RemixとVercelの相性問題なのか、Vercelにデプロイすると```OAuthResolverError```が発生してしまったため、代替としてNetlifyにデプロイしています。

([issue](https://github.com/bluesky-social/atproto/issues/2926)によると、RemixのSingle Fetchに関連する問題っぽい?)

# 課題
- ```self``` レコードに配列で管理しているだけなので、ステータスの登録数が増えてくるとパフォーマンスに影響が出そう
FirehoseからDBを更新して管理するのが正解?

# おわりに
最後まで読んでくださり、ありがとうございました。

「AT Protocol、なんか面白そう！」という思い付きと勢い、アドカレまでに何か作りたいという気持ちだけで作ったアプリでしたが、沢山の方に使っていただけて感謝しています。

改善点などありましたら、お気軽にコメントや[@maril445.bsky.social](https://bsky.app/profile/maril445.bsky.social)までご連絡ください。