【Zoom API】クラウド録画をPHPで一括ダウンロードする

この記事は、[「Qiita Engineer Festa 2022」](https://qiita.com/official-campaigns/engineer-festa/2022)に参加するために執筆した記事です。

[**Zoom API/SDKを使ってみよう！ - Qiita**](https://qiita.com/official-events/86d8555146e1b14fef24)
'**Zoom APIを実装してみた**'のテーマで執筆します！

# 環境
- PHP8.0
- Composer 2.3.5

# こんな人に読んでほしい
- Zoomにクラウド録画が溜まってきた
- 期間を指定して、ローカルに一括でダウンロードしたい
- ダウンロード時にフォルダ作成、ファイル名もわかりやすく付けたい

# ごあいさつ
はじめまして。KeiOTNです。福岡の小さな会社でコード書いたり事務作業したり何でも屋さんしています。プログラミング歴は約1年。実は、Qiita記事を投稿するのは初めてです。
「アウトプットしなければ、、」と思いつつきちんと記事にまとめることをしてこなかったので、今回[「Qiita Engineer Festa 2022」](https://qiita.com/official-campaigns/engineer-festa/2022)に参加するため初めて筆を取りました。拙い文章になりますが、情報少なく困った内容だったのでシェアしたいと思います。

Zoomのクラウド録画を利用されている方は多いと思います。簡単に利用できて便利なのですが、日々の利用でどんどん溜まってきますよね。
契約プランによりますが、クラウド録画容量には制限があります。今回、そのデータをローカル(実際にはHDDを使用)に移動させ、クラウド録画からは削除する必要がありました。
もちろんZoomUIから”ダウンロード”ボタンがあり、ローカルにダウンロードすることもできますが、大量の録画を1件ずつ作業して要望に対応するのが大変だったためAPIを利用しました。

# ZoomUIの仕様
- ミーティング1件分が複数ファイルに分かれる
- フォルダは作成されない
- ミーティング名はファイル名に反映されない


例えば、あるミーティングの録画1件分をダウンロードすると、以下のようになります。
![スクリーンショット 2022-06-21 8.58.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1704912/8bcac066-7e0a-4276-53dc-8d24671c8cee.png)
音声のみ、スピーカービュー動画、ギャラリービュー動画、チャット等に分かれています。ミーティングの進行内容によって、13種類のファイルのうち使用されたものが作成されます。
![スクリーンショット 2022-06-21 9.11.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1704912/ac083e23-6be7-a6aa-5aec-a147dff7eb83.png)
参考: https://marketplace.zoom.us/docs/api-reference/zoom-api/methods/#operation/recordingGet


# 要望
- 一括でダウンロードするなら年月のフォルダに分類したい
- ミーティング1件分はひとつのフォルダにまとめたい
- ファイル名をわかりやすく命名したい
```php:最終的なフォルダ構成
Zoomの録画
	∟2022
		∟2022-04
			∟定例ミーティング202204011500
				∟(audio_only)定例ミーティング202204011500.m4a
				∟(shared_screen_with_gallery_view)定例ミーティング202204011500.mp4
				∟(shared_screen_with_speaker_view)定例ミーティング202204011500.mp4
```

# ダウンロードの基本
curl部分だけ知りたい人もいるかなと思い、記事を分けました。以下をベースに進めていきますので、お手数ですが一旦記事を移ってご一読ください。

https://qiita.com/KeiOTN/items/99ecb8f9a49dba38ce9b


# 期間の指定
なかなか本題に移れなくてすみません。
APIには1ヶ月分ずつの問い合わせしなければなりません。例えば「2021年4月から2022年3月までの録画データをダウンロードしたい」というとき、1ヶ月ずつfromとtoデータが必要になります。
そのため、開始年月YYYY-mmと終了年月YYYY-mmを指定すれば、配列を返してくれる関数を自作しました。こちらも別記事にしました。読まなくても進めます。(ひとつのテーマでいくつも記事書けますね🤣)

https://qiita.com/KeiOTN/items/501b88081d4ffa90f1e9

# 準備
Guzzleのインストール
```php:
composer require guzzlehttp/guzzle:^7.2
```
参考: https://qiita.com/gentuki/items/d64f15447cb556eafd3b

# いよいよ本題
以下の情報は、上述の**ダウンロードの基本**で取得済の前提です。
```php:
$user_id = 'ユーザーIDを記入';
$JWT_token = 'JWTトークンを記入';
```
全体像はこんな感じになります。
```php:main.php
require('vendor/autoload.php');
date_default_timezone_set('Asia/Tokyo');

use GuzzleHttp\Client;

$zoom = new Zoom();
$zoom->run();


class Zoom {

    public function run()
    {
		$from = '2021-04';
		$to = '2022-03';
        $hdd = new HDD(); //後述:保存先により変更してください
        $user_id = 'ユーザーIDを記入';
        $periods = Period::getPeriods($from, $to);

        //指定期間分の保存用ディレクトリ作成
        Mkdir::makeDir($user_id, $periods);

        //1ヶ月ずつ繰り返し
        foreach ($periods as $period) {
            //Zoomのミーティンングを一覧で取得
            $search = $this->getMeetingRecords($user_id, $period);
            //録画0件だったら
            if ($search['total_records'] == 0) {
                continue;
            }
            //ミーティンングごとに繰り返し
            $meetings = $search['meetings'];
            foreach ($meetings as $meeting) {
                //ミーティングフォルダ名
                $titleWithTime = $meeting['topic']) . '_' . $meeting['start_time'];
                //保存先ディレクトリを作成
                $dirPath = HDD::makeMeetingDir($meeting['start_time'], $titleWithTime, $user_id);
                $recordFiles = $meeting['recording_files'];
                //ファイル種別ごとに繰り返し
                foreach ($recordFiles as $recordFile) {
										//ファイル名を生成(拡張子付き)
                    $fileName = '(' . $recordFile['recording_type'] . ')' . $titleWithTime . '.' . $recordFile['file_extension'];
	                //保存
					HDD::storeFile($dirPath, $recordFile['download_url'], $fileName);
                }
            }
        }
    }

    /**
    * 録画をAPIから取得
    *
    **/
    private function getMeetingRecords($user_id, $period) 
    {
        $method = 'GET';
        $path = 'users/' . $user_id . '/recordings?page_size=300&mc=false&trash=false&from=' . $period[0] . '&to=' . $period[1];
        $client_params = [
            'base_uri' => 'https://api.zoom.us/v2/',
        ];
        $result = $this->sendRequest($method, $path, $client_params);
        return $result;
    }

    private function sendRequest($method, $path, $client_params)
    {
         $client = new Client($client_params);
         $JWT_token = 'JWTトークンを記入';
         $response = $client->request($method, 
                            $path, 
                            [
                            'headers' => [
                                'Content-Type' => 'application/json',
                                'Authorization' => 'Bearer ' . $JWT_token,
                            ]
                            ]);
         $result_json = $response->getBody()->getContents();
         $result = json_decode($result_json, true);
         return $result;
    }
}

/**
* 保存関連
*
**/
class HDD
{
    /**
    * ミーティング1件分のディレクトリを作成
    *
    **/  
    public static function makeMeetingDir($startTime, $titleWithTime, $user_id)
    {  
	     //日付
         $date = substr($startTime, 0, 10);
         //年
         $year = substr($startTime, 0, 4);
         //月
         $month = substr($startTime, 5, 2); 
         //ファイルpath
         $path =  'Zoomの録画/' . $year . '/' . $year . '-' . $month . '/' . $titleWithTime;

		 //ディレクトリがなければ作る
         if (!is_dir($path)) {
             mkdir($path, 0777, true);
         }
             return $path;
         }
    }

    /**
    * ファイルをアップロードする
    *
    **/
    public static function storeFile($dirPath, $downloadUrl, $fileName)
    {
        //同一ファイル名あった場合
        if (file_exists($dirPath . '/' . $fileName)) {
            return '同一ファイルあり';
        }
 
		$JWT_token = 'JWTトークンを記入';
        $url = $downloadUrl . '?access_token=' . $JWT_token;
        exec("curl -L '" . $url  . "' -o '" . $dirPath . '/' . $fileName . "'");
        return 'SUCCESS';      
    }
}

/**
* 期間の配列を生成
*
**/
class Period
{
    public static function getPeriods($from, $to)
	{
		//from月の初日
		$firstDayOfDateFrom = $from . '-01';
		//fom月の最終日
		$lastDayOfDateFrom = date('Y-m-d', strtotime(substr($dateFrom, 0, 7) . 'last day of this month')); 
			
		$array = [[$firstDayOfDateFrom, $lastDayOfDateFrom]];
			
		if ($from == $to) {
			//指定期間が1ヶ月のみだった場合
			return $array;
		} else {
			$dateArray = Period::createMonthlyPeriod($array, $firstDayOfDateFrom, $to);
			return $dateArray;
		}
	}
		
	private static function createMonthlyPeriod(&$dateArray, $firstDayOfPreviousMonth, $dateTo)
	{
		//指定期間の最後の月でなくなるまで再帰
		if (substr($firstDayOfPreviousMonth, 0, 7) !== $dateTo) { 
		   $firstDayOfNextMonth = date('Y-m-d', strtotime($firstDayOfPreviousMonth . '+1 month'));
		   $lastDayOfNextMonth = date('Y-m-d', strtotime(substr($firstDayOfNextMonth, 0, 7) . 'last day of this month'));
		   $dateArray[] = [$firstDayOfNextMonth, $lastDayOfNextMonth];
		   Period::createMonthlyPeriod($dateArray, $firstDayOfNextMonth, $dateTo);
		}
		    return $dateArray;
		}
}

/**
* 保存用の年月ディレクトリを一括作成
*　　一括生成するので　ミーティング０件の月は空フォルダになります
**/
class Mkdir
{
    public static function makeDir($user_id, $periods)
    {
        foreach ($periods as $period) {
            $year = substr($period[0], 0, 4);
            $year_month = substr($period[0], 0, 7);
						//年
            $year = substr($startTime, 0, 4);
            //月
            $year_month = substr($startTime, 0, 4) . '-' . substr($startTime, 5, 2); 
            
            // ファイルpath
            $path =  'Zoomの録画/' . $year . '/' . $year_month . '/';
            if (!is_dir($path)) {
                mkdir($path, 0777, true);
        }
    }
}
```

複数ファイルのものを記事のために1ファイルにまとめたので、間違いありましたらご指摘ください。(何度も同じこと書いてる認識はあるので修正したい)

また、この記事では触れていませんが実装では以下のことにも配慮しました。
- ダウンロードした録画のログ出力
- ファイル名生成時に空白、改行を削除(ミーティングタイトルには改行も含まれる)
- ISO時刻を日本時刻に変更
- ダウンロードした動画はクラウドから削除
- 削除ログ出力
- 入力値バリデーション
- エラー捕捉

あと、PHPの前にGASでトライして、できたぜ！！ってときに仕組み上の越えられない壁にぶち当たり2週間を無駄にした経験もあるので、需要があればこの辺も今後のネタにしていきたいと思います。

# さいごに
イベントにあたりZoomの方も読んでくださっている？と思うので、Zoomサポートの感動体験をお伝えしたいと思います！

実装中に、リファレンスを参照してもわからないことがありました。Zoomのchat botが目にとまり、「所詮botでしょう」とあまり期待せずに利用しました。
選択式の質問に答え、求める答えに辿りつかなかったところ、数秒でカスタマーサービスの方が登場。(「人間ですよ」と自己紹介してくれました笑)

質問をお伝えすると「技術的な内容なのでテクニカルサービスをここに呼びますね」と、また数秒で担当者を召喚。「調べるので3-5分お待ちください」と、本当に5分以内に求めていた回答をいただくことができました。
やりとりは英語ですが、私の読解が遅いことを伝えるとわかりやすい英語に言い直していただいたり、参照リンクやスクショを貼ってくれたりと懇切丁寧な対応をしていただきました。こんなにもスピーディーかつ丁寧な対応は初めてで、素晴らしいUXでした✨

Zoom様、その節は本当にありがとうございました。今後ともどうぞよろしくお願いいたします！

拙い記事をここまで読んでくださった方々も、ありがとうございました！
これを機に、アウトプットも頑張りますので暖かく見守っていただけると幸いです😊