HLSクライアントをgolangで書いた

# はじめに
この記事は[セーフィー株式会社 Advent Calendar 2022](https://qiita.com/advent-calendar/2022/safie) の12月12日の記事です.

弊社では監視カメラのクラウドサービスを提供しており、映像の配信も自分達で実装しています.
その種類は現在のところ3種類(HLS,WebRTC,YouTube Live)です.
今回はその中の1つである HLSのクライアントをgolangにて実装する機会があったため簡単に紹介します.

[実装したHLSクライアント](https://github.com/kawaway/hls_downloader) です.

# HLSとは
HLSは HTTP Live Streaming の略で、動画(映像・音声)をHTTP上でストリーミングするためのプロトコルです.
その基本的な仕組みは難しくなく、サーバーにて動画をある単位で分割し、クライアントはそれをダウンロードして再生するというものです.
その際、動画のファイル数や、再生時間等の情報が無いと、いつまでダウンロードすればいいかがわからないため、それらの情報をplaylistと呼ばれるテキストファイルに保存し、まずplaylistを取得してから、動画のダウンロードを行います.

```plantuml
@startuml

actor Client

Client -> Server : playlist.m3u8
Client <-- Server :
note right
playlistの中身
どのようなファイルがあるかを表す
stream_0.ts
stream_1.ts
stream_2.ts
end note
Client -> Server : stream_0.ts
Client <-- Server :
Client -> Server : stream_1.ts
Client <-- Server :
Client -> Server : stream_2.ts
Client <-- Server :

@enduml
```

# セーフィーのHLS
HLSには、オンデマンドストリーミングとライブストリーミングの2種類がありますが、弊社で採用しているのはライブストリーミングです.
また、HLSにはクライアントの表示サイズやネットワーク帯域等に応じて、ダウンロードする映像の品質を切り替える、アダプティブビットレートストリーミング機能もあります.
アダプティブビットレートストリーミング機能自体が魅力ですが、HLSのメリットはなんといっても対応デバイスが多いことだと考えています.
今回実装するHLSクライアントでは ライブストリーミング、単一ストリーミングのHLSクライアントです.

# モチベーション
OSSのHLSクライアント実装としては、例えばデファクトの hls.js や ffmpeg 等があります.
ブラウザ等の表示デバイスがある場合は hls.jsが選択肢に入ることが多いかと思いますが、
弊社のカメラの映像を取得して、自分達で映像の解析を行いたい場合、hls.jsは表示が前提で作られているため選択肢に入りません.
ではどうするかというと、ffmpeg 等が選択肢に入るかと思います.
ただffmpegは比較的大きな機能を持っているため、HLSのためだけにffmpegを採用すると多少過剰な気もしますし、ライセンスにより採用しづらい場合もあるかと思います.
このような状況だったため、単一のライブストリーミングのHLSであれば比較的簡単なため書いてみようと考えました.

# なぜ Goなのか
弊社ではバックエンドの大部分、特にAPIサーバーは python にて書かれています.
その理由は歴史的なものもありますが既存の資産が多いこと・pythonに慣れているメンバーが多いことが主な理由かと思っています.
一方、映像を扱う部分に関しては APIサーバーに比べてパフォーマンスが求められることが多いため 古くは java,C++等で書かれており、最近のものに関してはGo言語が採用されています.
またAPIサーバーに関しても部分的にGo言語を採用しているところです.
このような背景によりGo言語に触る機会が増えており、HLSの配信サーバーもGo言語で書かれているため、クライアントも golang にて書いてみるかと考えたのが理由です.

# 実際のplaylist

次に、弊社HLSサーバーから配信されたplaylistを確認し、中身について主要なものについて確認してみます.
詳細は、[RFC8216](https://www.rfc-editor.org/rfc/rfc8216.html) を参照ください.

```
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-ALLOW-CACHE:NO
#EXT-X-TARGETDURATION:3
#EXTINF:2.266667,
stream_0.ts
```


`#EXT-X-MEDIA-SEQUENCE`: プレイリストファイルに表示される最初のメディアセグメントのメディアシーケンス番号を示します.

この例だと0のため最初のメディアセグメントの番号は0であるということを表しています. 次のplaylistを取得した場合、典型的にはのEXT-X-MEDIA-SEQUENCEは1になります.

`#EXTINF:` : メディアセグメントの継続時間を表します。EXTINFタグの次の行がセグメントになり、その時間を表します。

この例だと セグメントファイル stream_0.ts の継続時間が 2.266667 secであることを表しています.

`#EXT-X-TARGETDURATION`: playlistに含まれるメディアセグメントの継続時間の最大値を表します.

この例だと、メディアセグメントが一つしかありませんが、3秒です.

# 詳細なシーケンス

HLSのシーケンス図は以下のようになります.
クライアントがサーバーにplaylist.m3u8 を取得すると、サーバーでは セグメントファイル(i.g. stream_0.ts) を生成し始めます.
そしてある程度セグメントが溜まった場合に(この例だと3つ)、playlist.m3u8 を返します.
playlistを取得したクライアントは、その内容をもとに3つのセグメントファイルを取得します。
以降playlistの取得と、セグメントファイルの取得を繰り返します.

```plantuml
@startuml
actor Client
Client -> Server : playlist.m3u8
Server -> Server : stream_0.ts 生成
Server -> Server : stream_1.ts 生成
Server -> Server : stream_2.ts 生成
note right
playlist生成完了
end note
Client <-- Server : stream_[0,1,2]を含むplaylist
Server -> Server : stream_3.ts 生成
Client -> Server : stream_0.ts
Client <-- Server :
Server -> Server : stream_4.ts 生成
Client -> Server : stream_1.ts
Client <-- Server :
Client -> Server : stream_2.ts
Client <-- Server :

Client -> Server : playlist
Client <-- Server : stream_[2,3,4]を含むplaylist

@enduml
```

# 実装する中ででた疑問
実装する中でplaylistの取得タイミングについて多少迷ったため取り上げます.

- playlistの取得が完了したらすぐ
  - 最も単純です.しかしplaylistに変更がない場合、ネットワーク帯域の圧迫やサーバーの負荷上昇が発生してしまいます。RFCにも頻繁なリロードはしてはならないとあります.
- playlistに含まれる全てのセグメントをダウンロードした場合
  - 全てのセグメントをダウンロードしていれば余計な負荷は発生しません.しかしこの方法だとplaylistの境界で動画が欠損する可能性があります.
- playlist中セグメント時間の合計の少し前
  - 最後に考えたのが、playlistに含まれるセグメントの合計時間の少し前に取得するというものです.そうすれば欠損はなくなると考えました.ただこの方法だと,取得したplaylistに既にダウンロードしたセグメントが含まれる場合があるため、それを除いたものにする必要があり、実装が面倒です.

ここまで考えRFCを確認してみたところ、しっかり記述されていました（初めから確認しましょう）.

具体的には、`EXT-X-TARGETDURATION` を使用します.

初回は `EXT-X-TARGETDURATION` を、playlistに変化があった場合は `EXT-X-TARGETDURATION/2` の間隔を開けてplaylistの取得を行います.
`EXT-X-TARGETDURATION` はpaylistに含まれるセグメントの最大の間隔のため、リクエスト頻度を低減しながら、単純なクライアントの実装となります.

対象のコードは以下の通りです
```golang
		nextDuration := time.Second * time.Duration(newPlaylist.targetDuration)
		if prevPlaylist != nil {
			if newPlaylist == prevPlaylist {
				// If there is no change in the playlist, do not acquire the segment and wait only for targetDuration
				to = *time.NewTimer(nextDuration)
				continue
			} else {
				// If there is a change in the playlist, acquire the segment and wait for targetDuration/2
				nextDuration /= 2
			}
		}
```

RFCすごい.きちんと読みましょう.

# 最後に
簡単でしたが以上で[Go言語で HLSクライアントを実装](https://github.com/kawaway/hls_downloader)してみた紹介を終わります.
HLSクライアントが必要な場合のご参考になれば幸いです.