箱推しのためのYoutube予定表

# 前置き
「あのVTuber事務所の配信スケジュール、一週間以上落ちてるんだよね」
「仕方ない、俺が作るか」
そんなノリで作ろうとしたら翌日には復旧した俺の気持ち。

# 要件
・YoutubeAPIのリクエストは日単位での回数制限があるため、一度取得した結果は保存しておく
・変更・削除に対応するため、再取得する際、前回の結果は削除する
・チャンネル数は20程度またはそれ以下を想定
・アクセス数もそんなに大量にない
・移行の容易性を考えデータはDBではなくファイルに保持
・ちゃんと配信予定（枠）を事前に作ってくれると効果大

# 作り方
## API keyの発行
今回はyoutube APIを使用しているのですが、使用にはAPI keyを発行する必要があります。
一日の使用回数に制限がありますが、googleアカウントがあれば無料で発行できます。
下記の記事に詳しい手順が記載してあります。
https://qiita.com/shinkai_/items/10a400c25de270cb02e4

## チャンネルの枠の一覧を取得
下記が公式リファレンスなのですが、ちょっと情報量としては心許ないです。
https://developers.google.com/youtube/v3/docs/search/list?hl=ja

#### リクエスト
※変数は${}で括ってます。
```
https://content-youtube.googleapis.com/youtube/v3/search?order=date&channelId=${ch_id}&part=id&part=snippet&key=${api_key}&eventType=upcoming&type=video
```
eventType=upcomingとtype=videoで予定のみに絞り込んでいます。

#### レスポンス
サンプルは2022/05/16時点での月紫アリアさんのチャンネルです。
毎週月曜に一週間分の予定を作るタイプの方なので、いつも見栄えのする検証に使わせて頂いています。感謝を込めて宣伝。
```
{
  "kind": "youtube#searchListResponse",
  "etag": "xx5NiXUjsXpks1JuRmEQ_i8t2GM",
  "nextPageToken": "CAUQAA",
  "regionCode": "JP",
  "pageInfo": {
    "totalResults": 7,
    "resultsPerPage": 5
  },
  "items": [
    {
      "kind": "youtube#searchResult",
      "etag": "aAckZdUnkx9QlNxuwUyU_5JA5NA",
      "id": {
        "kind": "youtube#video",
        "videoId": "96RcK_TkG-4"
      },
      "snippet": {
        "publishedAt": "2022-05-16T09:12:59Z",
        "channelId": "UC5XQhzMH08PgWa4Zp02Gcsw",
        "title": "【歌枠/singing】初見歓迎💜日曜日のかわいいおうた、聴きに来てよ～💜  karaoke【月紫アリア/新人Vtuber】",
        "description": "５月ボイス ♡ ⸝ ▶️ https://react.booth.pm/items/3799773 新衣装アリアと初めてのデートっ！ そのあとはおうちで…甘えても ...",
        "thumbnails": {
          "default": {
            "url": "https://i.ytimg.com/vi/96RcK_TkG-4/default_live.jpg",
            "width": 120,
            "height": 90
          },
          "medium": {
            "url": "https://i.ytimg.com/vi/96RcK_TkG-4/mqdefault_live.jpg",
            "width": 320,
            "height": 180
          },
          "high": {
            "url": "https://i.ytimg.com/vi/96RcK_TkG-4/hqdefault_live.jpg",
            "width": 480,
            "height": 360
          }
        },
        "channelTitle": "月紫アリアch / Tsukushi Aria",
        "liveBroadcastContent": "upcoming",
        "publishTime": "2022-05-16T09:12:59Z"
      }
    },
    {
      "kind": "youtube#searchResult",
      "etag": "1ku9uEzetM2hpoPBcTADzzdU8R0",
      "id": {
        "kind": "youtube#video",
        "videoId": "_UfGM-2wQq0"
      },
      "snippet": {
        "publishedAt": "2022-05-16T04:49:34Z",
        "channelId": "UC5XQhzMH08PgWa4Zp02Gcsw",
        "title": "【雀魂】ルーレットで決められた属性で麻雀🀄💜色んなアリアを見て…💜そしてボコす【月紫アリア/夢川かなう/姫熊りぼん/九楽ライ/新人Vtuber】",
        "description": "夢川かなう/Kanau ch @Himekuma Ribon Ch. 姫熊 りぼん @Kulaku Lie ch.九楽ライ すべてはノリ #ぴちくまねくろにーと ...",
        "thumbnails": {
          "default": {
            "url": "https://i.ytimg.com/vi/_UfGM-2wQq0/default_live.jpg",
            "width": 120,
            "height": 90
          },
          "medium": {
            "url": "https://i.ytimg.com/vi/_UfGM-2wQq0/mqdefault_live.jpg",
            "width": 320,
            "height": 180
          },
          "high": {
            "url": "https://i.ytimg.com/vi/_UfGM-2wQq0/hqdefault_live.jpg",
            "width": 480,
            "height": 360
          }
        },
        "channelTitle": "月紫アリアch / Tsukushi Aria",
        "liveBroadcastContent": "upcoming",
        "publishTime": "2022-05-16T04:49:34Z"
      }
    },
    {
      "kind": "youtube#searchResult",
      "etag": "qr8tKI6lbK9P68A3NwfXgbURYHo",
      "id": {
        "kind": "youtube#video",
        "videoId": "x09-gfZITm0"
      },
      "snippet": {
        "publishedAt": "2022-05-16T04:44:24Z",
        "channelId": "UC5XQhzMH08PgWa4Zp02Gcsw",
        "title": "【Escape Simulator】謎解きで脱出ゲーム🔓❕頭やわらかぁくするぞ～💜【月紫アリア/フンボルトペンギン/Vtuber】",
        "description": "初コラボ     @フンボルトペンギン / Humboldt Penguin ⋱⋰ ⋱⋰ ⋱⋰ ⋱⋰⋱⋰ ⋱⋰ ⸜ ♡ ５月ボイス ...",
        "thumbnails": {
          "default": {
            "url": "https://i.ytimg.com/vi/x09-gfZITm0/default_live.jpg",
            "width": 120,
            "height": 90
          },
          "medium": {
            "url": "https://i.ytimg.com/vi/x09-gfZITm0/mqdefault_live.jpg",
            "width": 320,
            "height": 180
          },
          "high": {
            "url": "https://i.ytimg.com/vi/x09-gfZITm0/hqdefault_live.jpg",
            "width": 480,
            "height": 360
          }
        },
        "channelTitle": "月紫アリアch / Tsukushi Aria",
        "liveBroadcastContent": "upcoming",
        "publishTime": "2022-05-16T04:44:24Z"
      }
    },
    {
      "kind": "youtube#searchResult",
      "etag": "FxMUGIK9_ZGVNfLvpZTb_SzE1jA",
      "id": {
        "kind": "youtube#video",
        "videoId": "TIdlzHDjVFQ"
      },
      "snippet": {
        "publishedAt": "2022-05-16T04:36:46Z",
        "channelId": "UC5XQhzMH08PgWa4Zp02Gcsw",
        "title": "【朝活】初見歓迎💜週の真ん中！可愛いアリアと気合い入れるぞ💜 good moning【月紫アリア/新人Vtuber】",
        "description": "５月ボイス ♡ ⸝ ▶️ https://react.booth.pm/items/3799773 新衣装アリアと初めてのデートっ！ そのあとはおうちで…甘えても ...",
        "thumbnails": {
          "default": {
            "url": "https://i.ytimg.com/vi/TIdlzHDjVFQ/default_live.jpg",
            "width": 120,
            "height": 90
          },
          "medium": {
            "url": "https://i.ytimg.com/vi/TIdlzHDjVFQ/mqdefault_live.jpg",
            "width": 320,
            "height": 180
          },
          "high": {
            "url": "https://i.ytimg.com/vi/TIdlzHDjVFQ/hqdefault_live.jpg",
            "width": 480,
            "height": 360
          }
        },
        "channelTitle": "月紫アリアch / Tsukushi Aria",
        "liveBroadcastContent": "upcoming",
        "publishTime": "2022-05-16T04:36:46Z"
      }
    },
    {
      "kind": "youtube#searchResult",
      "etag": "ISSqY8DCr-Lb8frnKTLV6dHd4Ss",
      "id": {
        "kind": "youtube#video",
        "videoId": "P4PdMaItZ1w"
      },
      "snippet": {
        "publishedAt": "2022-05-16T04:35:26Z",
        "channelId": "UC5XQhzMH08PgWa4Zp02Gcsw",
        "title": "【ASMR】心音耐久💜ぎゅってして寝たいから、こっちおいで？💜 whisper/heart beat/sleeping sound【月紫アリア/新人Vtuber】",
        "description": "すやすやしてってね～   #ASMR #バイノーラル #睡眠誘導 ⋱⋰ ⋱⋰ ⋱⋰ ⋱⋰⋱⋰ ⋱⋰ ⸜ ♡ ５月ボイス ...",
        "thumbnails": {
          "default": {
            "url": "https://i.ytimg.com/vi/P4PdMaItZ1w/default_live.jpg",
            "width": 120,
            "height": 90
          },
          "medium": {
            "url": "https://i.ytimg.com/vi/P4PdMaItZ1w/mqdefault_live.jpg",
            "width": 320,
            "height": 180
          },
          "high": {
            "url": "https://i.ytimg.com/vi/P4PdMaItZ1w/hqdefault_live.jpg",
            "width": 480,
            "height": 360
          }
        },
        "channelTitle": "月紫アリアch / Tsukushi Aria",
        "liveBroadcastContent": "upcoming",
        "publishTime": "2022-05-16T04:35:26Z"
      }
    }
  ]
}
```

#### ざっくりレスポンス解説
totalResults: 7
resultsPerPage: 5
となっているので、ページに続きがあるようです。
必要に応じて「maxResults」パラメータを追加するか、「pageToken」を使ってループさせましょう。

`items`：枠の配列
`items[].id.videoId`：動画のID
　https://www.youtube.com/watch?v=${videoId}
　で動画へのリンクになる。
`items[].snippet.publishedAt`：登録日時 更新の場合にどうなるかは不明
`items[].snippet.publishTime`：publishedAtとの違いは不明
`items[].snippet.title`：動画・配信のタイトル
`items[].snippet.description`：いわゆる概要欄 80文字くらいで省略される
`items[].snippet.thumbnails`：サムネイル 大きさによって3つに分かれている

## 枠の詳細を取得
一覧だけでもそれなりの情報は得られますが、肝心な配信・公開日時は別のAPIで取得しなければならないようです。
勿論、リファレンスも一応あります。
https://developers.google.com/youtube/v3/docs/videos/list?hl=ja

#### リクエスト
先のAPIで取得した`items[].id.videoId`を使用して検索します。
```
https://www.googleapis.com/youtube/v3/videos?key=${apikey}&id=${videoId}&part=liveStreamingDetails
```

#### レスポンス
```
{
  "kind": "youtube#videoListResponse",
  "etag": "3VLGoEdDbvn1ADPHHeO4ZEmq2ik",
  "items": [
    {
      "kind": "youtube#video",
      "etag": "cWvV7JhHFmOqagMLX8GHXKBuIoE",
      "id": "TIdlzHDjVFQ",
      "liveStreamingDetails": {
        "scheduledStartTime": "2022-05-17T22:00:00Z",
        "activeLiveChatId": "Cg0KC1RJZGx6SERqVkZRKicKGFVDNVhRaHpNSDA4UGdXYTRacDAyR2NzdxILVElkbHpIRGpWRlE"
      }
    }
  ],
  "pageInfo": {
    "totalResults": 1,
    "resultsPerPage": 1
  }
}
```

videoIdを配列で指定したり未指定だと`items`が複数になりますが、今回は単一のvideoIdを指定しているので、`items`は常に0-1件です。
`items[0].liveStreamingDetails.scheduledStartTime`が配信開始日時になります。

## サンプルプログラム
まずバッチを一定周期で実行することでデータを取得し、'./files/schedule'に保存します。
チャンネルIDの一覧は`./files/ch_list.txt`に改行区切りで保存しています。
実行周期を短くすることでyoutubeの最新情報とのタイムラグを軽減できますが、短くしすぎるとAPIの実行回数が上限に達しやすくなります。
```get_schedule_data.php
<?php
date_default_timezone_set('Asia/Tokyo');
$apikey = "hoge";

// 前回実行時のファイルを削除
foreach(glob("./files/schedule/*") as $val) {
    unlink($val);
}

// チャンネルidの一覧を取得
$ch_ids = explode("\r\n", file_get_contents('./files/ch_list.txt'));

foreach($ch_ids as $ch_id){
    if(empty($ch_id)){
        continue;
    }
    // チャンネル毎に枠の一覧を取得
    $upcomings = json_decode(`curl -XGET "https://content-youtube.googleapis.com/youtube/v3/search?order=date&channelId=$ch_id&part=id&part=snippet&key=$apikey&eventType=upcoming&type=video&maxResults=10"`, true);
    foreach($upcomings['items'] as $item){
        $videoid = $item['id']['videoId'];
        // 枠の詳細を取得
        $video = json_decode(`curl -XGET "https://www.googleapis.com/youtube/v3/videos?key=$apikey&id=$videoid&part=liveStreamingDetails"`, true);
        $item['video'] = $video;
        
        file_put_contents("./files/schedule/" . $item['id']['videoId'] . ".json", json_encode($item));
    }
}
```

保存した情報をWEBで参照します。
サンプルでは、24時間前（配信中の枠を考慮）～7日後を表示対象に、予定のない日は非表示にしています。
```schedule.php
<?php
date_default_timezone_set('Asia/Tokyo');

// 1日前～7日後
$min_time = (time() - (24 * 60 * 60));
$max_time = (time() + (7 * 24 * 60 * 60));

$items = [];
foreach(glob("./files/schedule/*.json") as $file){
    $item = json_decode(file_get_contents($file), true);
    $scheduledStartTime = $item['video']['items'][0]['liveStreamingDetails']['scheduledStartTime'];
    
    $start_time = strtotime($scheduledStartTime);
    if($min_time < $start_time && $start_time < $max_time){
        $start_date = date('Y/m/d', strtotime($scheduledStartTime));
    
        $videoid = $item['id']['videoId'];
        //$sortkey = $scheduledStartTime . '_' . $item['snippet']['channelId'];
        $sortkey = $scheduledStartTime;
        
        if(!isset($items[$start_date])){
            $items[$start_date] = [];
        }
        $items[$start_date][$sortkey] = $item;
    }
}

// 配信開始時刻・チャンネルID順でソート
$sorted_items = [];
foreach($items as $key => $item){
    ksort($item);
    $sorted_items[$key] = $item;
}
// 日付順でソート
ksort($sorted_items);

?>
<html lang="ja">
<head>
<meta charset="UTF-8">
</head>
<body>
<table style="border:solid 1px">
    <thead>
        <tr>
<?php foreach($sorted_items as $key => $item){ ?>
            <td style="border:solid 1px"><?= $key ?></td>
<?php } ?>
        </tr>
    </thead>
    <tbody>
        <tr>
<?php foreach($sorted_items as $item){ ?>
            <td style="vertical-align:top;border:solid 1px">
    <?php foreach($item as $column){ ?>
        <?= $column['snippet']['channelTitle'] ?><br>
        <?= $column['snippet']['title'] ?><br>
        <?= date('Y/m/d H:i:s',strtotime($column['video']['items'][0]['liveStreamingDetails']['scheduledStartTime'])); ?><br>
                <a href="https://www.youtube.com/watch?v=<?=$column['id']['videoId'] ?>"><img src="<?= $column['snippet']['thumbnails']['default']['url'] ?>"><br></a>
                <br>
    <?php } ?>
            </td>
<?php } ?>
        </tr>
    </tbody>
</table>
</body>
</html>
```

# 課題
複数推しのファンや新興の事務所運営さん等に提供したいが、オンプレにするかAWSサーバーでも立ててクラウド提供するか。
・オンプレの場合は自前でサーバーを用意する必要があるため、導入の敷居がやや高くなる。
・クラウドの場合はAPI実行回数の制約がある。利用者のAPIkeyを使わせてもらう手もあるが、人様のkeyを預かれるほどセキュリティを強固にするのも面倒臭い。

# 参考
https://schedule.hololive.tv/
大手の割にはシンプルですが、これくらい余計な情報はないほうがいいのかもしれません。

https://schedule.v-react.com/
概要欄を自動スクロールで流しているのがオシャレですね。