iOSのHLSダウロードとaggregateAssetDownloadTaskのassetTitleのバグについて

2019年5月11日 engineering

こんにちは、iOSエンジニアとしてSwiftを書いている @kz_morita です。

今回はiOSで動画ダウンロード機能を実装したときにハマったことについて書いていこうと思います。

TL;DR

  • HLSはAppleが提唱した動画配信用のプロトコル
  • HLSの動画もダウンロードすることができる
  • Appleの サンプルコード がある
  • aggregateAssetDownloadTask のassetTitleにマルチバイトを指定するとダウンロードできないケースがある

Http Live Streaming 動画をiOSアプリからダウンロードする

HLSとはなにか?

Http Live Streaming (HLS) とは、Appleから提唱された動画配信の仕組みでその名の通り動画のストリーミング再生に特化した動画配信技術です。

以下のような特徴があります。

  • HTTPサーバーを利用できる (CDNつかえる)
  • Live放送 / Ondemand放送 の両方に対応している
  • 帯域に応じて最適なストリームに切り替えられる
  • HTTPSを使用して暗号化とユーザ認証ができる

とくに最初のHTTPサーバーで配信することができるため、手軽に動画を配信することができるのが特徴です。

くわしくは以下のリンクを参照ください
https://developer.apple.com/streaming/

HLSをダウンロードする

HLSはストリーミング用のプロトコルで、 .m3u8 という拡張子をもった以下のファイルと、実際の動画をセグメントに分割した .ts ファイルが必要となります。

AppleのAVFoundationには、.m3u8 ファイルのURLを指定してダウンロードをする実装が用意されていて、そのサンプルコードもAppleが用意してくれているので、今回はこのファイルを元に説明していきます。

サンプルコードは以下からダウンロードできます。

Using AVFoundation to Play and Persist HTTP Live Streams

その中でも、とくにダウンロード周りの機能が実装されている、AssetPersistenceManagerについてみていきます。

必要そうなとこだけ、抜粋して下記に記載します。

class AssetPersistenceManager: NSObject {

    override private init() {
        super.init()

        // 1. backgroundでダウンロードするための設定と、URLSessionの初期化
        let backgroundConfiguration = URLSessionConfiguration.background(withIdentifier: "AAPL-Identifier")
        assetDownloadURLSession =
            AVAssetDownloadURLSession(configuration: backgroundConfiguration,
                                      assetDownloadDelegate: self, delegateQueue: OperationQueue.main)

    }

    // ストリームをダウンロードする
    func downloadStream(for asset: Asset) {

        // 2. ダウンロードタスクの生成
        let preferredMediaSelection = asset.urlAsset.preferredMediaSelection
        guard let task =
            assetDownloadURLSession.aggregateAssetDownloadTask(with: asset.urlAsset,
                                                               mediaSelections: [preferredMediaSelection],
                                                               assetTitle: asset.stream.name,
                                                               assetArtworkData: nil,
                                                               options:
                [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 265_000]) else { return }

        // 3. タスク実行
        task.taskDescription = asset.stream.name
        task.resume()
    }
}

// AVAssetDownloadDelegate
extension AssetPersistenceManager: AVAssetDownloadDelegate {
    
    // タスクのデータ転送が終了したときにcall
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {

        // ダウンロードエラーチェックや、ファイル保存などを行う 
    }

    // 一括ダウンロードタスクによって、ダウンロード先のPathが決定sれた時にCall
    func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask,
                    willDownloadTo location: URL) {

        // 上記 didCompleteWithError で参照できるようにダウンロード先Pathを保持する
    }

    // 子タスクの終了時に呼ばれる
    func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask,
                    didCompleteFor mediaSelection: AVMediaSelection) {

        // 続けてタスク実行させる
        aggregateAssetDownloadTask.resume()
    }

    // タスクの進行状況をsubscribeするためのmethod 
    func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask,
                    didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue],
                    timeRangeExpectedToLoad: CMTimeRange, for mediaSelection: AVMediaSelection) {
        // progress bar の更新などのために、進捗を通知する
    }
}

基本的には下記のながれで処理が進みます。

  1. URLSessionの初期化
  2. ダウンロードタスクの生成
  3. タスクの実行
  4. AVAssetDownloadDelegateの各メソッドが順番によばれる

ダウンロードが完了すると、 hogehoge.movpkg というディレクトリがダウンロードされています。

再生するときは、このファイルへのパスを指定して以下のようにすればダウンロードしたAssetを再生することができます。

let downloadFilePath = "hogehoge/movie.movpkg"
let urlAsset = AVURLAsset(url: downloadFilePath)
if urlAsset.isPlayable {
    self.playerItem = AVPlayerItem(asset: urlAsset)
}
self.avPlayer.replaceCurrentItem(with: self.playerItem)

参考にしたサイト

マルチバイト文字をassetTitleに指定するとダウンロードできないケースがある。

上記までの方法でなんとか動画ダウンロードまでできたのですが、いくつかの動画でなぜかダウンロードできないという現象がおきました。

結論から書くと、aggregateAssetDownloadTaskでダウンロードタスクを生成するときに、引数 assetTitle26文字以上のマルチバイト文字 をいれるとダウンロードに失敗します。

let task =
            assetDownloadURLSession.aggregateAssetDownloadTask(with: asset.urlAsset,
                                                               mediaSelections: [preferredMediaSelection],
                                                               assetTitle: asset.stream.name, // <= ここ!!
                                                               assetArtworkData: nil,
                                                               options:
                [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 265_000])

以下調査した文字たちです。

失敗ケース
ああああああああああああああああああああああああああ
ああああああああああああああああああああああああああa

成功ケース
あああああああああああああああああああああああああ
あああああああああああああああああああああああああa
あああああああああああああああああああああああああaaa

おそらくこれはAVFoundationのバグなんじゃないかなーと思ってます。

まとめ

ストリーミング用のプロトコルであるHLSのプレイリスト用のファイルm3u8を用いて動画をダウンロードする実装について簡単に説明しました。

iOSのAVFoundationにも標準でHLSをダウンロードする機能があるのでそれを用いるとストリーミングだけでなく、動画ダウンロードまで実装することができました。

途中いくつかハマったり思わぬバグに出会ったりして大変でしたが目的のダウンロード昨日は実現できたのでよかったです。