いまさらはじめるGo言語 – 後編:Cloud FunctionsでYoutubeの動画情報自動編集

こんにちは。てぃろです。

前編から大分間が開いてしまいましたが、やっと後編です。

今回はCloud FunctionsでYoutubeの動画情報を自動で編集することができるところまで作ります。

私自身がPS5でゲームをしているので、PS5でアップロードするだけで動画情報がすべて入力される、という形にします。ただ、基本的にはYoutubeにファイルがアップロードされた、というのをトリガーに動かす形なので、PCや他のデバイスでアップロードしたとしても動作させることができると思います。

以下では、GoをCloud Functionsにデプロイして外部からのリクエストで動作できるようになっていることを前提に進めます。そのやり方については前編で説明していますので、必要な方はそちらもご参照ください。

Youtube APIを使えるようにしよう

ではまず、Youtube APIを使えるようにしましょう。こちらについては他にわかりやすい記事を書いている方々がいるので、そちらの記事を参照してもらうのがよいでしょう。

必要なのはGoogle CloudのAPIサービスでYoutube Data API v3を有効にすることと、OAuth 2.0認証で使えるようにすることです。詳細は以下の記事を参照してください。

上記の記事を参考にして、クライアントIDとクライアントシークレットを取得できればOKです。

リフレッシュトークンを取得しよう

APIを実行するには、先ほど取得したクライアントIDとクライアントシークレットを使ってアクセストークンを取得することが必要なのですが、このアクセストークンの有効期限は1時間となっています。

今回の用途はゲームの動画をPS5でさくっと上げる、という形なのであまり頻繁にAPIリクエストを行うことはなく、APIリクエストの間隔は30分、長いと2時間程度になります。

そこまで長いとアクセストークンは1回のリクエストごとに失効してしまうことになるので、リフレッシュトークンでアクセストークンを毎回取得する必要が出てきます。加えて、今回作っているものは画面がないただの関数なのでOAuth2.0の認可を受けるためのGUIを毎回操作することはできません。

ならば、初めからリフレッシュトークンでアクセストークンを毎回取得してしまうように実装してしまいましょう。そのほうが実装が楽になりますので…!

ということで、まずは手動でリフレッシュトークンを取得していきます。その方法を書いた公式ドキュメントはコチラですが、初見では非常に読みにくいと思うので簡単に解説します。

また、この公式ドキュメントやここからの手順をしっかり理解するにはOAuth2.0の認可コードフローを理解する必要があります。理解を深めたい方はこちらの解説記事もご一読ください。

認可コードを取得する

以下のURLのパラメータに自分で取得したクライアントIDを入れてURLを作成します。ここで作ったURLをブラウザに入れてアクセスします。

https://accounts.google.com/o/oauth2/auth?
  client_id=<YOUR_OWN_CLIENT_ID>&
  redirect_uri=http%3A%2F%2Flocalhost%2Foauth2callback&
  scope=https://www.googleapis.com/auth/youtube&
  response_type=code&
  access_type=offline

すると、よくあるGoogleログインの画面が出てくるので、認可を与えたいアカウントを選んで認可を進めていきます。

認可が完了すると、アクセスに失敗したような画面が出てきます。そうしたらブラウザがアクセスしているURLを確認しましょう。以下のようなURLが表示されていると思います。この code のパラメータについている値が認可コードです。

http://localhost/oauth2callback?code=SINGLE_USE_CODE

これをしっかりとメモしておきましょう。

アクセストークンとリフレッシュトークンを取得する

次に認可コードを使って、以下のようにPOSTリクエストします。これにはPostmanなどのAPIアクセスのツールを使うのがオススメです。

例としてPostmanでのリクエストの設定画面をお見せします。

リクエストボディがjsonのREST APIではないという点に注意してください。ボディの形式は x-www-form-urlencoded になっている必要があります。

これでリクエストを実行すると、json形式でアクセストークンとリフレッシュトークンを取得することができます。

これでリフレッシュトークンを取得することができました!ついに動画情報の更新も実装していきましょう。

Youtube APIを使って動画情報を編集しよう

ここまでで動作環境は整いました。いよいよYoutube Data APIを使って説明文を書き換えるロジックを書いていきましょう。

とはいえ、まずはコードをご覧ください。そんなに長くないので全文お見せします。後述しますが、このコードのコールはIFTTTでやるので、いくらかIFTTTで実行されることを前提にしています。

package videoConverter

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"

	"github.com/GoogleCloudPlatform/functions-framework-go/functions"
)

const (
	TOKEN_ENDPOINT             = "https://accounts.google.com/o/oauth2/token"
	CLIENT_ID                  = "<YOUR_OWN_CLIENT_ID>"
	CLIENT_SECRET              = "<YOUR_OWN_CLIENT_SECRET>"
	REFRESH_TOKEN              = "<YOUR_OWN_REFRESH_TOKEN>"
	API_ENDPOINT               = "https://www.googleapis.com/youtube/v3/"
)

type FunctionsRequest struct {
	Url         string `json:"url"`
	PublishedAt string `json:"published_at"`
}

type RefreshResponse struct {
	AccessToken string `json:"access_token"`
	Expires     int    `json:"expires_in"`
	scope       string `json:"scope"`
	TokenType   string `json:"token_type"`
}

func init() {
	functions.HTTP("VideoConverter", videoConverter)
}

func refreshAccessToken() (string, error) {
	requestBody := fmt.Sprintf("client_id=%s&client_secret=%s&refresh_token=%s&grant_type=refresh_token", CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN)
	req, _ := http.NewRequest("POST", TOKEN_ENDPOINT, bytes.NewBuffer([]byte(requestBody)))
	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")

	client := &http.Client{}
	resp, err := client.Do(req)
	fmt.Println("refreshAccessToken response Status:", resp.Status)
	if err != nil {
		return "", err
	}

	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}

	jsonBytes := ([]byte)(body)
	data := new(RefreshResponse)
	err = json.Unmarshal(jsonBytes, data)
	if err != nil {
		return "", err
	}

	return data.AccessToken, nil
}

func getVideoSnippet(videoId string) string {
	now := time.Now()
	videoTitle := fmt.Sprintf("<YOUR_OWN_VIDEO_TITLE>")
	videoDescription := `<YOUR_OWN_VIDEO_DESCRIPTION>`
	categoryId := "10"

	requestBody := fmt.Sprintf(`{"id": "%s", "snippet": {"title": "%s", "description": "%s", "categoryId": "%s"}}`, videoId, videoTitle, videoDescription, categoryId)

	return requestBody
}

func updateVideoSnippet(videoId string, accsessToken string) ([]byte, error) {
	url := API_ENDPOINT + "videos?part=snippet"
	requestBody := getVideoSnippet(videoId)

	req, _ := http.NewRequest("PUT", url, bytes.NewBuffer([]byte(requestBody)))
	req.Header.Add("Authorization", "Bearer "+accsessToken)
	req.Header.Add("Content-Type", "application/json")

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return []byte{}, err
	}
	defer resp.Body.Close()
	fmt.Println("update snippet response Status:", resp.Status)
	body, err := io.ReadAll(resp.Body)

	return body, nil
}

func addVideoToPlaylist(videoId string, accsessToken string) ([]byte, error) {
	url := API_ENDPOINT + "playlistItems?part=snippet"
	requestBody := fmt.Sprintf(`{"snippet": {"playlistId": "<YOUR_OWN_PLAYLIST_ID>", "resourceId": {"kind": "youtube#video", "videoId": "%s"}}}`, videoId)

	req, _ := http.NewRequest("POST", url, bytes.NewBuffer([]byte(requestBody)))
	req.Header.Add("Authorization", "Bearer "+accsessToken)
	req.Header.Add("Content-Type", "application/json")

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return []byte{}, err
	}
	defer resp.Body.Close()
	fmt.Println("add video to playlist response Status:", resp.Status)
	body, err := io.ReadAll(resp.Body)

	fmt.Println(string(body))

	return body, nil
}

func videoConverter(w http.ResponseWriter, r *http.Request) {

	// Check http method
	if r.Method != "POST" {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}

	// Refresh access token
	accsessToken, err := refreshAccessToken()
	if err != nil {
		fmt.Println(err)
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	// Get RequestData
	body, err := io.ReadAll(r.Body)
	defer r.Body.Close()
	fmt.Println("body:", string(body))

	// Parse RequestData
	jsonBytes := ([]byte)(body)
	data := new(FunctionsRequest)
	if err = json.Unmarshal(jsonBytes, data); err != nil {
		fmt.Println(err)
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	fmt.Println("data:", data)

	// Get videoId
	dataStrings := strings.Split(data.Url, "?v=")
	if len(dataStrings) != 2 {
		fmt.Println("invalid url: %s", data.Url)
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	videoId := dataStrings[1]

	// Update video snippet
	resp, err := updateVideoSnippet(videoId, accsessToken)
	if err != nil {
		fmt.Fprint(w, err)
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	// Add video to playlist
	_, err = addVideoToPlaylist(videoId, accsessToken)
	if err != nil {
		fmt.Fprint(w, err)
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	w.Write(resp)
	w.WriteHeader(http.StatusOK)
	return
}

あまりよくはないですが、今回はクライアントIDなどを固定値として埋め込む形で実装しています。理想的にはSecret Managerを利用するなどしたほうがいいでしょう。

ここからは、上記ソースコードのポイントを解説していきます。

アクセストークンの取得

実態は39行目に実装した関数です。ここではPostmanでアクセストークンを取得していたときのようにPOSTリクエストを実行してアクセストークンの取得を試みています。

こちらでも Content-typeapplication/x-www-form-urlencoded にしてリクエストすることが必要です。レスポンスはjsonなのでそのパースをしてアクセストークンを取得しましょう。

動画のURL取得

Youtubeの動画情報を更新するためには、動画のIDを知る必要があります。そのIDは動画のURLに含まれているので、そこから取得することができます。その動画のURLはIFTTTで取得することができるので、Cloud Functionsのリクエストから取得するようにします。

152行目から実装している箇所がそれに該当します。単純にリクエストパラメータで送られてきたURLから ?v= 以降の部分をIDとして抜き出している形です。

ここで取得したIDを使って、YoutubeのAPIをコールします。

動画情報の更新

実態は78行目に実装した関数です。ここで実行しているAPIの仕様の公式ドキュメントはコチラです。

個人的にリクエストボディがパッと見ではちょっとわかりづらかったのですが、要するに以下のようなjsonで送るとよいようです。

{
  "id": "<VIDEO_ID>", 
  "snippet": {
    "title": "<YOUR_OWN_VIDEO_TITLE>",
    "description": "<YOUR_OWN_VIDEO_DESCRIPTION>",
    "categoryId": "<YOUR_OWN_VIDEO_CATEGORY>"
   }
}

ドキュメントにはtags[]も送れると書いてあったのですが、今回試した感じではうまくタグの更新をすることができませんでした。jsonの作り方が悪かったのか、中身の文字列がよくなかったのかそこまではわかっていません…。

また、ここで扱うYoutubeのAPIはREST API形式なので、Content-typeapplication/json で送るようにしましょう。

動画を再生リストに追加する

実態は101行目に実装した関数です。ここで実行しているAPI仕様の公式ドキュメントはコチラです。

こちらも少しリクエストボディがわかりにくかったのですが、以下のようなjsonだと更新できました。

{
  "snippet": {
    "playlistId": "<YOUR_OWN_PLAYLIST_ID>",
    "resourceId": {
      "kind": "youtube#video",
      "videoId": "<VIDEO_ID>"
    }
  }
}

PlaylistのIDも再生リストにアクセスしたときのURLの末尾に ?list= というパラメータでくっついているのでこちらを入力しましょう。resourceId.kindyoutube#video で固定値です。

解説すべきポイント以上かと思います。

ちなみに、Cloud FunctionsのレスポンスはIFTTTで利用することは想定していないのでデバッグ目的で利用できる程度に最低限の実装にしています。

Cloud Functionsを動画アップロード完了時に実行しよう

処理の実装は完了したので、最後にCloud Functionsを自動で実行するためのトリガーを設定しましょう。

今回やりたいことは新しい動画をYoutubeにアップロードしたときに説明文を書き換えることです。つまり、Functionsは動画がアップロードされたことをトリガーとして動作するようにします。Functionsをトリガーするということは、FunctionsのURLをコールする必要があるということを意味します

以前、以下のような記事でZapierでの自動実行を紹介しましたが、Zapierでは有料アカウントでしかwebhook(URLのコール)が使えないようでした。

そこで、今回は無料アカウントのままでもwebhookが使えるIFTTTを使っていくことにします。

設定は以下のようにしていきます。

まず、Youtubeのチャンネルに動画がアップロードされたことをトリガーとして登録します。もちろんここで設定するためのチャンネルは事前に作っておく必要があります。

ここの設定はYoutubeチャンネルさえ持っていれば迷うことはないでしょう。トリガーにしたいチャンネルを間違いなく登録するようにしてください。

次にWebhookの設定です。ヘッダーの設定は本記事の本筋とは関係ないところなので無視してください。

ポイントは、 Content-typeapplication/json にして、Bodyを正しいjson形式にすることです。またUrlやPublishedAtなど、IFTTTで使用可能になっているトリガーで受け取った変数をjsonに指定することで、アップロードされた動画のIDを送ることができるようになっています。

以上で設定は完了です!

あとは動画をアップロードしてみて、トリガーが発動するかを確認してください。ただし、このトリガーは動画のHD処理が終わってから発動するようなので、動画の長さにもよると思いますが、アップロードをはじめてから30分~1時間程度時間がかかります。

Cloud functionsの動作だけ確認したいなら、既存の動画のURLを使ってPostmanでリクエストして動かしてみることをオススメします。

IFTTTのトリガーについては、手動で動かすことはできないので時間がかかりますので、時間のあるときに気長に試してみてください。

最後に

前編と後編の2記事に分けて、Youtubeの動画の情報を自動で更新する関数を作ってきました。

Youtuberのように動画アップロードにフルコミットできない人にとっては需要があるんじゃないかと思っていますが、まだまだ機能的に足りてもいないと思います。

特に、動画の情報がいまのままでは固定値でしか入れられないことがよくないのではないかと思います。個人的にはとりあえず固定値のままでもいいのですが、やはり自動生成のようなことはしてみたいですよね。

動画を解析して適切なタイトルや説明の自動生成して、より動画の内容をよく表してくれたり、タグ付けやプレイリストへの自動登録、SEO最適化などなどできることはいくらかありそうです。

それもこれも動画の解析がどこまで自動でできるかにかかっています。

動画情報の解析ができるなら、自動切り抜きや無音部分のカットなどもできそうな気がします。無音部分のカットについてはPythonのコードも出回っているみたいなので、そういったものを応用すれば狙った場面の自動切り抜きとかもできるかもしれないですね。


今回はGoの勉強も兼ねて進めてきました。これで基本は大体身についた気がしますが、もう少し深い知識もほしいという場合に備えてオススメの書籍も聞いておきました。(まだ読んでない)

実用 Go言語 ―システム開発の現場で知っておきたいアドバイス

Go開発者で有名なFutureの渋川さんが書かれたというので期待できるとのことでした。実際レビューの星もすごい高いです。持論ですが本を読むときには事前の課題意識や原体験があると効果的だと思っているので、もう少しGoの経験をしてから読もうかと思います。