Discordにアニメの視聴ステータスを表示しよう

こんにちは！東京農工大学で情報工学を学んでいる者です ( [@nibs_tuat](https://x.com/nibs_tuat) )。
Qiita初投稿の記事となります。

この記事は [農工大アドベントカレンダー Advent Calendar 2024](https://qiita.com/advent-calendar/2024/tuat) の4日目のものです。

https://qiita.com/advent-calendar/2024/tuat

## はじめに
Discordには`Discord Rich Presence`と呼ばれる、アクティビティをステータスに表示できる機能があります。この機能によってプレイ中のゲームを表示したり、Spotifyなどのアプリケーションと連携することで、聞いている音楽を表示したりできます。

**「ここにアニメの視聴状態も表示したい！」** と思ったのがこの記事で紹介するものを制作した経緯です。

本記事では以下のように、Discordのステータスにdアニメストアで視聴中のアニメの情報を表示できるChrome拡張&ネイティブアプリケーション(Rust製)を紹介します。

![screenshot1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3668436/0eac98de-99e7-e02c-05cf-193f337f2bd2.png)

表示できる情報は以下の通りです。
- **タイトル**
- **エピソード / 総再生時間**
- **現在の再生時間**

アニメのサムネイルも表示しようと思えば簡単にできるのですが、著作権的に微妙そうなのと、少なからずdアニメストアのサーバーに負荷をかけることになってしまうので断念…
代わりに表示するものを検討中です。

すでに最低限動作するものはリリース済みで、プログラム等は全て以下のリポジトリにあります。

https://github.com/kitashimauni/d_anime_discord_presence

## 仕組み
次のような構成で動作します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3668436/eed7542b-b800-3774-92c1-61ce25a677b6.png)

1. **`content.js`**
dアニメストアの各再生ウィンドウで動作し、後ろで動いている`background.js`に再生・停止などの状態の変化とタイトルなどの情報を送信

2. **`background.js`**
`content.js`から送られてくる各ウィンドウの情報を管理し、いずれかのウィンドウが新たに再生状態になるとネイティブアプリケーションに情報を送信

3. **ネイティブアプリケーション(Native Messaging Host)**
Chrome拡張から情報を受け取り、Rustの`discord-sdk`でDiscord側にアクティビティを表示するための情報を送信

特殊な事情がない限り、Windows・MacOS・LinuxのいずれのOSでも動作する技術で構成されています(Linuxは動作未確認)。

また、YouTube・YouTube Musicで同様のサービスが存在しており、今回使用した技術の構成はこれを参考にしています。

https://github.com/XFG16/YouTubeDiscordPresence

## 準備
Discordにアクティビティを表示するため、あらかじめ [Discord Developer Portal](https://discord.com/developers/applications) でアプリケーションを登録し、`Application ID`を控えておきます。

## 詳細
簡単に各プログラムの説明をします。

### Chrome拡張

#### `manifest.json`
まず、以下のように`manifest.json`を作成しておきます。
`content_scripts`でdアニメストアの再生ページで`content.js`が動作するように指定し、`background`で`background.js`がバックグラウンドで動作できるように指定します。
また、ネイティブアプリケーションと通信できるようにするため、`permissions`に`nativeMessaging`を書いておきます。

```json:manifest.json
{
    "name": "dAnimeDiscordPresence",
    "version": "0.1.0",
    "manifest_version": 3,
    "content_scripts": [
        {
            "matches": [
                "https://animestore.docomo.ne.jp/animestore/sc_d_pc*"
            ],
            "js": [
                "content.js"
            ]
        }
    ],
    "permissions": [
        "nativeMessaging",
        "background",
    ],
    "background": {
        "service_worker": "background.js"
    }
}
```

#### `content.js`
各`content.js`では始めにランダムなUUIDが生成され、このUUIDは各データと一緒に`background.js`へ送信されます。`background.js`はこのUUIDを基に各ウィンドウから送られてくるデータを識別します。
`background.js`へのデータ送信は`chrome.runtime.sendMessage`を使用しており、データ送信は主に以下のタイミングで行われます。
1. ページロード時
1. 動画再生開始時
1. 動画再生停止時
1. ウィンドウ消去時

#### `background.js`
`background.js`は各`content.js`から送られてきたデータを管理し、ステータスの更新が必要な場合のみネイティブアプリケーションにjson形式でデータを送信します。
ネイティブアプリケーションへのデータ送信にはChrome拡張の**NativeMessaging**という機能を使用しています。この機能を使用すると、ネイティブアプリケーションの標準入出力を介してChrome拡張との通信が可能になります。

https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging

次のようにして簡単に接続することができ、接続後は`port.postMessage()`を呼び出すだけでデータを送信できます。

```js:background.js (一部抜粋)
// connect to native host
function assertNative() {
    if (!isNativeConnected) {
        if (port.disconnect) port.disconnect();
        port = chrome.runtime.connectNative("com.dadp.discord.presence");
        isNativeConnected = true;
        port.onDisconnect.addListener(() => {
            if (chrome.runtime.lastError) {
                isNativeConnected = false;
                console.log("Couldn't connect to native app");
                console.log(chrome.runtime.lastError);
            }
            console.log("Disconnect")
        })
    }
}
```

### ネイティブアプリケーション
ネイティブアプリケーションでは、Discordにデータを送信するためにRust用の`discord-sdk`を使用しています。

プログラム実行時に、このSDKとあらかじめ作成したDiscordアプリケーションのIDを使用してクライアントを作成します。この方法は[ライブラリのリポジトリに例が書かれています](https://github.com/EmbarkStudios/discord-sdk/blob/main/examples-shared/src/lib.rs)。

https://github.com/EmbarkStudios/discord-sdk


また、データの受信では先ほど述べたように、ネイティブアプリケーションは標準入出力を介して通信できるため実装時にChrome拡張を意識する必要はありません。ただし、データのフォーマットは決まっているため、それに合わせる必要があります。

以下のようなループを回すことでChrome拡張から受信したデータをDiscordに送信しています。

最初の32bitがデータ長を表しているので先にこれを読み取り、そのうえで受信したデータを読み込みます。受信するデータはjson形式の文字列となっているため、これをパースして指定された処理を行います。

```rs:main.rs (一部抜粋)
loop {
    // read JSON length
    let mut length_buffer = [0u8; 4];
    if reader.read_exact(&mut length_buffer).is_err() {
        break;
    }
    let data_length = u32::from_le_bytes(length_buffer) as usize;
    
    // read JSON data
    let mut json_buffer = vec![0u8; data_length];
    let _ = reader.read_exact(&mut json_buffer);
    
    let input = String::from_utf8(json_buffer).expect("Invalid UTF-8 data");
    
    let data: PresenceData = serde_json::from_str(&input).expect("Invalid JSON data");

    if data.message_type == CLAER_MESSAGE {
        tracing::info!(
            "cleared activity: {:?}",
            client.discord.clear_activity().await
        );
        continue;
    } else if data.message_type == DISCONNECT_MESSAGE {
        break;
    } else if data.message_type == UPDATE_MESSAGE {
        let rp = ds::activity::ActivityBuilder::default()
            .assets(
                ds::activity::Assets::default().large("tsumugi", Some("Watching anime")),
            )
            .kind(ds::activity::ActivityKind::Watching)
            .details(&data.title)
            .state(format!("{} / {}", &data.episodes, &data.total_duration))
            .start_timestamp((Local::now() - parse_time_string(&data.current_time)).timestamp());

        tracing::info!(
            "updated activity: {:?}",
            client.discord.update_activity(rp).await
        );
    }
}
```

最近になってDiscord側の仕様変更により`kind`を指定できるようになり、これを`Watching`とすることで「アニメを"**視聴中**"」と表示できるようになりました。

## おわりに
「Discordにアニメ視聴のステータスを表示したい」というモチベーションから制作したものでしたが、ちゃんと動いて満足しています。

また、いつか触れたいと思っていたChrome拡張とRustにも触れることができてよかったです。

改善点もまだまだあると思うので今後も改良していけたらと思います。