Как воспроизвести видео M3U8 из кеша в iOS?

Я имею дело с видеофайлом M3U8, который пытаюсь воспроизвести после сохранения таких удаленных видео в моем приложении SwiftUI.

Основной подход, который я нашел на данный момент, — это принятый ответ в этой теме: Возможно ли кэшировать видео? IOS – Свифт

Проблема:
Однако когда я пытаюсь это реализовать, видео M3U8 не загружается.
Если бы я играл прямо из videoURL, то проблем не было бы и видео воспроизводилось. Но еще хотелось бы поиграть в нее из кеша.

VideoPlayer(player: player)
    .onAppear {
        CacheManager.shared.getFileWith(stringUrl: videoURL) { result in
            switch result {
            case .success(let url):
                self.player = AVPlayer(url: url)
                self.player?.play()
            case .failure:
                print("Error")
            }
        }
    }

Для контекста печать URL-адреса из CacheManager дает file:///var/mobile/Containers/Data/Application/2F12CCE9-1D0C-447B-9B96-9EC6F6BE1413/Library/Caches/filename (где имя файла — это фактическое имя файла).

А вот так выглядит видеоплеер при запуске приложения.

Как воспроизвести видео M3U8 из кеша в iOS?

Так есть ли проблема с моей реализацией кода? Действительно ли принятый ответ из старой темы недействителен/устарел и больше не работает с текущими версиями iOS? Есть ли у кого-нибудь альтернативный метод кэширования видео?


2
164
2

Ответы:

Чтобы воспроизведение заработало, вы можете попробовать выполнить следующие действия:

(1) Загрузите .m3u8 и связанные с ним .ts файлы, указанные в файле M3U8.

(2) Внутри M3U8 измените текст пути, удалив все подпапки.

например: если ваш путь M3U8: /cdn_url/video720/filename.ts просто удалите текст /cdn_url/video720/.

Тогда ваш M3U8 должен выглядеть примерно так (где меняется только собственный путь к файлу):

#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:30
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-INDEPENDENT-SEGMENTS
#EXTINF:30.0,
filename.ts
#EXT-X-ENDLIST

Также рекомендуется переименовать M3U8 и TS, указав соответствующее «имя» файла для идентификации видео в списке воспроизведения.

например: Сделайте это как Batman_ep_01.m3u8 и Batman_ep_01.ts

(3) Сохраните и протестируйте в AVPlayer.

А как насчет нескольких файлов TS в одном M3U8?
Он работает так же, как шаг (2), но вы просто помещаете M3U8 и связанные с ним файлы TS в уникальную подпапку. Что-то вроде ниже, где хранятся файлы m3u8 и ts.

например: /Library/Caches/some_Show_multi/


Решено

В итоге я в основном реализовал этот подход: https://developer.apple.com/documentation/avfoundation/offline_playback_and_storage/using_avfoundation_to_play_and_persist_http_live_streams.

В примере кода продемонстрирован метод загрузки содержимого HLS, однако видео сохранялись в общей папке, к которой пользователи могли получить доступ из настроек, а также использовали UserDefaults для хранения местоположений, обе функции мне не нужны, поскольку я хотел реализовать кеш, который загружает видео при их загрузке, чтобы удалить их позже.

Поэтому я изменил и упростил код следующим образом:

class Asset {
    var urlAsset: AVURLAsset
    var name: String
    
    init(urlAsset: AVURLAsset, name: String) {
        self.urlAsset = urlAsset
        self.name = name
    }
}

class AssetPersistenceManager: NSObject {
    static let shared = AssetPersistenceManager()
    private var assetDownloadURLSession: AVAssetDownloadURLSession!
    private var activeDownloadsMap = [AVAssetDownloadTask: Asset]()
    private var willDownloadToUrlMap = [AVAssetDownloadTask: URL]()
    
    private let fileManager = FileManager.default
    
    override private init() {
        super.init()

        // Create the configuration for the AVAssetDownloadURLSession.
        let backgroundConfiguration = URLSessionConfiguration.background(withIdentifier: "AAPL-Identifier")

        // Create the AVAssetDownloadURLSession using the configuration.
        assetDownloadURLSession = AVAssetDownloadURLSession(configuration: backgroundConfiguration,
                                                            assetDownloadDelegate: self,
                                                            delegateQueue: OperationQueue.main)
    }
    
    func downloadStream(for asset: Asset) async {
        guard let task = assetDownloadURLSession.makeAssetDownloadTask(
            asset: asset.urlAsset,
            assetTitle: asset.urlAsset.url.lastPathComponent,
            assetArtworkData: nil,
            options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 265_000]
        ) else { return }
        
        activeDownloadsMap[task] = asset
        
        task.resume()
    }
    
    func localAssetForStream(withName name: String) -> AVURLAsset? {
        let documentsUrl = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
        let localFileLocation = documentsUrl.appendingPathComponent(name)
        guard !fileManager.fileExists(atPath: localFileLocation.path)  else {
            return AVURLAsset(url: localFileLocation)
        }
        
        return nil
    }
    
    func cleanCache() {
        do {
            let documentsUrl = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
            let contents = try fileManager.contentsOfDirectory(at: documentsUrl, includingPropertiesForKeys: nil)
            for file in contents {
                do {
                    try fileManager.removeItem(at: file)
                }
                catch {
                    print("An error occured trying to delete the contents on disk for: \(file).")
                }
            }
        } catch {
            print("Failed to clean cache.")
        }
    }
}

extension AssetPersistenceManager: AVAssetDownloadDelegate {

    /// Tells the delegate that the task finished transferring data.
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        guard let task = task as? AVAssetDownloadTask,
            let asset = activeDownloadsMap.removeValue(forKey: task) else { return }

        guard let downloadURL = willDownloadToUrlMap.removeValue(forKey: task) else { return }

        if let error = error as NSError? {
            switch (error.domain, error.code) {
            case (NSURLErrorDomain, NSURLErrorCancelled):
                /*
                 This task was canceled, you should perform cleanup using the
                 URL saved from AVAssetDownloadDelegate.urlSession(_:assetDownloadTask:didFinishDownloadingTo:).
                 */
                guard let localFileLocation = localAssetForStream(withName: asset.name)?.url else { return }

                do {
                    try fileManager.removeItem(at: localFileLocation)
                } catch {
                    print("An error occured trying to delete the contents on disk for \(asset.name): \(error)")
                }

            case (NSURLErrorDomain, NSURLErrorUnknown):
                fatalError("Downloading HLS streams is not supported in the simulator.")

            default:
                fatalError("An unexpected error occured \(error.domain)")
            }
        } else {
            do {
                let documentsUrl = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
                let newURL = documentsUrl.appendingPathComponent(asset.name)
                try fileManager.moveItem(at: downloadURL, to: newURL)
            } catch {
                print("Failed to move downloaded file to temp directory.")
            }
        }
    }

    /// Method called when the an aggregate download task determines the location this asset will be downloaded to.
    func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) {
        willDownloadToUrlMap[assetDownloadTask] = location
    }
}

В частности, я изменил делегата для обработки завершения загрузки, чтобы впоследствии переместить файл в каталог /tmp, чтобы он больше не отображался в настройках. Теперь это позволяет мне асинхронно загружать HTTP-потоки и кэшировать их во временном каталоге для последующего извлечения.