2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Denoで作る!】DiscordへのSpotifyプレイリスト楽曲追加通知【完全無料】

Last updated at Posted at 2025-12-01

この記事は「東葛.dev Advent Calendar 2025」3日目の記事です。
https://adventar.org/calendars/11653

東葛.devがなにかについては以下をご確認ください。
https://toukatsu.dev/

成果物

image.png
このような通知が(追加があった場合のみ)毎日10:00に投稿されます。

背景とモチベーション

課題

コミュニティでメンバーが自由に曲を追加できるSpotifyのプレイリストを運用していますが、更新があっても気づきにくいという問題がありました。せっかく曲を追加してもらっても、反応が遅れてしまうことが課題でした。

解決策

この課題を解決するため、曲が追加されたら自動的にDiscordへ通知するBotを開発することにしました。


技術スタック(選定理由付き)

本Botの実装には、Denoのエコシステムを活用し、無料かつ手軽にすべてを完結させることを目指しました。

技術 役割 選定理由
Deno 実装言語/ランタイム Denoのエコシステムで無料ですべて完結できそうだったため。独自の機能は少なく、Node.jsのように利用可能。
Deno Deploy (Classic) ホスティング先 Denoアプリケーションを実行するためのサーバーレスプラットフォーム。Cron機能を利用するためClassic環境を選択。
Deno Cron 定期実行管理 Denoランタイムに組み込まれたcronタスクスケジューラ。ローカルマシンを起動しっぱなしにする必要がないため。
Deno KV 曲の更新管理 Deno Deployで設定なしで手軽に利用できるKey Valueストア。前回のプレイリスト状態を保存するために利用。
Discord.js Discord APIラッパー Discord API操作の省力化のため。デファクトスタンダードとして採用。
Spotify API プレイリスト情報取得 要件上必須。公開プレイリストのため、認証は比較的容易(Client Credentials Flowを利用)。

技術スタック解説

  • Deno: 次世代JavaScript/TypeScriptランタイム
  • Deno Deploy: JavaScript/TypeScriptアプリを実行するためのサーバーレスプラットフォーム
  • Deno Cron: Deno Deployで設定不要で動作する、組み込みの定期実行スケジューラ
  • Deno KV: Deno Deployで設定不要で利用できる、シンプルで手軽なKey Valueストア

開発環境と依存関係

Denoの特徴として、npm install のような事前インストール作業は不要です。

パッケージのインストールと deno.lock

コード内で import ... from "npm:package-name" と記述するだけで、初回実行時にDenoが自動的にパッケージをダウンロード・キャッシュします。

また、依存関係のバージョンを固定・管理するために deno.lock ファイルが自動生成(または deno cache --lock-write で生成)されます。これにより、チーム開発やデプロイ環境でも同じバージョンのライブラリが使用されます。


実装の解説:3つのファイル構成

コードは、役割に応じて以下の3つのファイルに分割し、シンプルに実装しました。

  1. notifyDiscord.ts: Discordへの通知処理
  2. notifyNewMusic.ts: Spotifyの情報取得、変更チェック、通知のメインロジック
  3. main.ts: Deno Cronの設定とエントリポイント

1. Discordへの通知処理 (notifyDiscord.ts)

主にDiscord BotのRESTクライアントの初期化と、指定チャンネルへのEmbedメッセージ送信を担当します。
環境変数 DISCORD_BOT_TOKEN からトークンを読み込んでいます。

import { APIEmbed, REST, Routes } from "npm:discord.js";

const DISCORD_BOT_TOKEN = Deno.env.get("DISCORD_BOT_TOKEN") || "YOUR_BOT_TOKEN";

// RESTクライアントの初期化
export function initRestClient(): REST {
  const rest = new REST({ version: '10' }).setToken(DISCORD_BOT_TOKEN);
  console.log("RESTクライアントを初期化しました。API操作を開始します...");
  return rest;
}

/**
 * Discordの指定チャンネルにEmbedメッセージを送信する関数。
 * @param rest - discord.jsのRESTクライアントインスタンス。
 * @param channelId - メッセージを送信するチャンネルのID。
 * @param embed - 送信するAPIEmbedオブジェクト。
 */
export async function sendDiscordNotification(
    rest: REST, 
    channelId: string, 
    embed: APIEmbed
): Promise<void> {
    try {
        await rest.post(
            Routes.channelMessages(channelId),
            { 
                body: { 
                    embeds: [embed] 
                } 
            }
        );
        console.log(`✅ 通知をチャンネルID ${channelId} へ送信しました。`);
    } catch (error) {
        console.error(`❌ チャンネルID ${channelId} へのメッセージ送信中にエラーが発生しました:`, error);
        throw error; // 呼び出し元にエラーを再スロー
    }
}

2. Spotifyの情報取得と変更検知 (notifyNewMusic.ts)

これがBotの中心となるロジックです。Spotify APIとDeno KVを連携させて、プレイリストの変更を検知します。

処理のポイント

  • 変更検知の効率化: Spotifyプレイリストは更新されるたびにスナップショットIDが更新されます。このIDをDeno KVに保存し、前回と最新のIDを比較することで、大まかな変更の有無を素早くチェックしています
  • 追加曲の特定: スナップショットIDが更新されていた場合、前回保存した全トラックIDリストと最新のIDリストを比較し、差分として追加された曲を特定します
  • エラー時の詳細ログ: 試行錯誤の影響で、Spotify API接続失敗時に詳細なエラーメッセージを出力するようにしています

処理の流れ

  1. アクセストークンの取得 (Client Credentials Flow)
  2. Deno KVから前回のスナップショットID (lastSnapshotId) を取得
  3. Spotifyから最新のプレイリスト情報 (latestData) を取得
  4. スナップショットIDを比較し、変更がなければ処理を終了
  5. 変更があった場合、Deno KVから前回時点の楽曲リスト (lastTrackIds) を取得
  6. 差分を計算し、addedTrackIdsを特定
  7. addedTrackIdsの詳細情報(曲名、アーティスト名など)をSpotify APIから取得(最大50件)
  8. 取得した情報を元にDiscord Embedを作成し、notifyDiscord.ts経由で通知を送信
  9. Deno KVへ最新のスナップショットIDと楽曲リストを保存(アトミック操作)
// 必要な型やライブラリをインポート
import { APIEmbed } from "npm:discord.js";
import { initRestClient, sendDiscordNotification } from "./notifyDiscord.ts";

const MESSAGE_TARGET_CHANNEL_ID = Deno.env.get("MESSAGE_TARGET_CHANNEL_ID") || "YOUR_TARGET_CHANNEL_ID";
const CLIENT_ID = Deno.env.get("SPOTIFY_CLIENT_ID")!;
const CLIENT_SECRET = Deno.env.get("SPOTIFY_CLIENT_SECRET")!;
const DETAIL_LINK = "この通知についての詳細が書かれたリンク";
const PLAYLIST_ID = "公開プレイリストID";

const KV_KEY_SNAPSHOT = ["spotify", PLAYLIST_ID, "snapshot_id"];
const KV_KEY_TRACKS = ["spotify", PLAYLIST_ID, "track_ids"];

interface SpotifyTrack {
    id: string;
    name: string;
    artists: { name: string }[];
    album: { name: string; };
}

// ----------------------------------------------------
// 認証処理: Client Credentials Flow
// ----------------------------------------------------
async function getAccessToken(): Promise<string> {
    const authHeader = btoa(`${CLIENT_ID}:${CLIENT_SECRET}`);
    const response = await fetch("[https://accounts.spotify.com/api/token](https://accounts.spotify.com/api/token)", {
        method: "POST",
        headers: {
            "Authorization": `Basic ${authHeader}`, 
            "Content-Type": "application/x-www-form-urlencoded",
        },
        body: "grant_type=client_credentials",
    });

    if (!response.ok) {
        const errorBody = await response.text();
        let errorMessage = `HTTP Status: ${response.status} (${response.statusText})`;
        try {
            const errorJson = JSON.parse(errorBody);
            errorMessage += `\nSpotify Error: ${errorJson.error_description || errorJson.error}`;
        } catch {
            errorMessage += `\nRaw Body: ${errorBody}`;
        }
        throw new Error(`Failed to get access token: ${errorMessage}`);
    }

    const data = await response.json();
    return data.access_token;
}

// ----------------------------------------------------
// Spotify APIからプレイリストの情報を取得
// ----------------------------------------------------
async function fetchPlaylistData(accessToken: string, playlistId: string): Promise<{ snapshotId: string; trackIds: string[] }> {
    // 1. メタデータ (snapshot_id) の取得
    const metaRes = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}?fields=snapshot_id,tracks.total`, {
        headers: { "Authorization": `Bearer ${accessToken}` }
    });
    if (!metaRes.ok) throw new Error(`Failed to fetch playlist meta: ${metaRes.statusText}`);
    const metaData = await metaRes.json();
    
    // 2. トラックリストの取得 (ページネーションは省略)
    const tracksRes = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/tracks?fields=items(track(id))&limit=100`, {
        headers: { "Authorization": `Bearer ${accessToken}` }
    });
    if (!tracksRes.ok) throw new Error(`Failed to fetch playlist tracks: ${tracksRes.statusText}`);
    const tracksData = await tracksRes.json();
    
    const trackIds = tracksData.items
        .map((item: any) => item.track?.id)
        .filter((id: string) => id);

    return { snapshotId: metaData.snapshot_id, trackIds };
}

// ----------------------------------------------------
// メイン処理
// ----------------------------------------------------
export async function notifyNewMusics() {
    const kv = await Deno.openKv();
    console.log("--- Spotify プレイリスト変更チェックを開始 ---");
    
    let accessToken: string;
    try {
        accessToken = await getAccessToken();
        console.log("✅ アクセストークン取得成功");
    } catch (e) {
        console.error(e); return;
    }

    // 1. KVから前回のスナップショットIDを取得
    const snapshotResult = await kv.get<string>(KV_KEY_SNAPSHOT);
    const lastSnapshotId = snapshotResult.value;
    
    let latestData;
    try {
        latestData = await fetchPlaylistData(accessToken, PLAYLIST_ID);
    } catch (e) {
        console.error("❌ プレイリストデータの取得に失敗:", e); return;
    }

    // 2. スナップショットIDを比較して変更をチェック
    if (latestData.snapshotId === lastSnapshotId) {
        console.log("ℹ️ スナップショットIDが一致しました。プレイリストに変更はありません。");
        kv.close(); return;
    }

    console.log(`⚠️ プレイリストの変更を検出! (旧ID: ${lastSnapshotId || 'なし'}, 新ID: ${latestData.snapshotId})`);

    // 3. 変更があった場合、KVから前回のトラックIDリストを取得
    const tracksResult = await kv.get<string[]>(KV_KEY_TRACKS);
    const lastTrackIds = new Set(tracksResult.value || []);
    
    // 4. 差分を計算して、追加された曲を特定
    const addedTrackIds: string[] = [];
    for (const trackId of latestData.trackIds) {
        if (!lastTrackIds.has(trackId)) { 
            addedTrackIds.push(trackId);
        }
    }

    if (addedTrackIds.length > 0) {
        console.log(`🎉 新しく ${addedTrackIds.length} 曲が追加されました!`);
        
        // --- トラック詳細情報の取得 ---
        const MAX_IDS_PER_REQUEST = 50;
        const idsQuery = addedTrackIds.slice(0, MAX_IDS_PER_REQUEST).join(',');
        const encodedIdsQuery = encodeURIComponent(idsQuery);
        
        const tracksDetailRes = await fetch(`https://api.spotify.com/v1/tracks?ids=${encodedIdsQuery}`, {
            headers: { "Authorization": `Bearer ${accessToken}` }
        });
        
        if (tracksDetailRes.ok) {
            const tracksDetailData = await tracksDetailRes.json();
            
            // Discord Embed用に整形
            const fields = tracksDetailData.tracks.map((track: SpotifyTrack) => ({
                name: `🎧️ ${track.name}`,
                value: `🎤アーティスト名: ${track.artists.map(a => a.name).join(' & ')} 💿️アルバム: ${track.album.name}`,
                inline: false,
            })).slice(0, 10);

            const rest = initRestClient();
            const notificationEmbed: APIEmbed = {
                title: `📢 「東葛.devのお気に入り」新曲追加通知`,
                description: `新曲が${addedTrackIds.length}曲追加されました!ぜひ聞いてみてください。`,
                color: 0x5865F2,
                fields: fields,
                timestamp: new Date().toISOString(),
                footer: { text: DETAIL_LINK }
            };
            
            await sendDiscordNotification(rest, MESSAGE_TARGET_CHANNEL_ID, notificationEmbed);
        }
    } else {
        console.log("ℹ️ 追加された曲はありませんでした。(並び替えや削除があった可能性があります)");
    }

    // 5. Deno KVの情報を更新
    const commitResult = await kv.atomic()
        .set(KV_KEY_SNAPSHOT, latestData.snapshotId)
        .set(KV_KEY_TRACKS, latestData.trackIds)
        .commit();

    if (commitResult.ok) {
        console.log("✅ Deno KVに最新のプレイリスト情報を保存しました。");
    } else {
        console.error("❌ Deno KVの更新に失敗しました。");
    }

    kv.close();
    console.log("--- 処理を終了 ---");
}

3. Deno Cronの設定 (main.ts)

notifyNewMusics関数を定期的に実行するための設定です。
毎日朝10:00 JSTにチェックが走るように設定しています。(UTC表記では午前1時)

import { notifyNewMusics } from "./notifyNewMusic.ts";

Deno.cron(
    "Spotify Add New Music Notification",
    "0 1 * * *", // UTC 01:00 = JST 10:00 
    async () => {
        console.log("--- Deno Cron 実行開始 (JST 10:00) ---");
        await notifyNewMusics();
        console.log("--- Deno Cron 実行終了 ---");
    }
);

ローカル実行とユーティリティ

Botをローカル環境で動作確認する際の手順と、開発に便利なツールを紹介します。

環境変数の設定と実行

ローカルで実行する場合は、必要な環境変数を設定した上で以下のコマンドを実行します。必要な権限フラグ(--allow-netなど)を付与する必要があります。

deno run --allow-net --allow-env --allow-sys notifyNewMusic.ts

おまけ:ローカル開発用のKVリセットツール

ローカル実行時、Deno KVはローカルファイルにデータを永続化します。
「プレイリストに変更があった時」の挙動を何度もテストしたい場合、KVに保存されたスナップショットIDをリセットする必要があります。

以下のスクリプト(scripts/kvReset.ts)を使用することで、ローカルのKVデータを全消去できます。

// scripts/kvReset.ts

async function resetKvData() {
    console.log("--- Deno KVデータリセットを開始 ---");
    
    // KVデータベースを開く
    const kv = await Deno.openKv();
    
    let count = 0;

    // 全てのキーを検索 (プレフィックスを何も指定しない)
    const entries = kv.list({ prefix: [] });
    
    console.log("検索された全てのキーを削除キューに追加中...");

    for await (const entry of entries) {
        // キーとその値を削除
        await kv.delete(entry.key);
        console.log(`- 削除キー: ${entry.key.join(', ')}`);
        count++;
    }

    if (count === 0) {
        console.log("ℹ️ 削除対象のデータは見つかりませんでした。");
        kv.close();
        return;
    }

    console.log(`✅ 完了: 合計 ${count} 件のデータが削除されました。`);
    
    kv.close();
}

// 実行
resetKvData();

実行コマンド:

deno run --allow-sys --unstable-kv scripts/kvReset.ts

デプロイ環境のセットアップ(環境変数)

Deno Deploy環境では、以下の環境変数の設定が必要です。
また新Deno DeployではCronが利用できないのでClassicを利用してください。
Deno Deploy Classic:https://docs.deno.com/deploy/classic/

  • DISCORD_BOT_TOKEN: Discord Botの認証トークン
  • MESSAGE_TARGET_CHANNEL_ID: 通知を送信するDiscordチャンネルのID
  • SPOTIFY_CLIENT_ID: Spotify APIのクライアントID
  • SPOTIFY_CLIENT_SECRET: Spotify APIのクライアントシークレット

このBotにより、コミュニティのプレイリスト更新に素早く気づき、より活発な反応ができるようになりました。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?