AVURLAsset, когда у ответа нет заголовка Content-Lenght
Мое приложение для iOS использует AVPlayer для воспроизведения потокового аудио с моего сервера и сохранения его на устройстве. Я реализовал AVAssetResourceLoaderDelegate, чтобы я мог перехватить поток. Я меняю свою схему (с http
к фиктивной схеме, чтобы вызывался метод AVAssetResourceLoaderDelegate:
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool
Я следовал этому уроку:
http://blog.jaredsinclair.com/post/149892449150/implementing-avassetresourceloaderdelegate-a
Там я помещаю исходную схему обратно и создаю сеанс для извлечения аудио с сервера. Все отлично работает, когда мой сервер обеспечивает Content-Length
(размер аудио файла в байтах) заголовок для потокового аудио файла.
Но иногда я транслирую аудиофайлы, где не могу заранее указать их длину (скажем, в режиме реального времени подкаста). В этом случае AVURLAsset устанавливает длину в -1
и терпит неудачу с:
"Error Domain=AVFoundationErrorDomain Code=-11849 \"Operation Stopped\" UserInfo={NSUnderlyingError=0x61800004abc0 {Error Domain=NSOSStatusErrorDomain Code=-12873 \"(null)\"}, NSLocalizedFailureReason=This media may be damaged., NSLocalizedDescription=Operation Stopped}"
И я не могу обойти эту ошибку. Я попытался пойти по хакерскому пути, подделать Content-Length: 999999999
, но в этом случае, как только весь аудиопоток загружен, мой сеанс завершается неудачно с:
Loaded so far: 10349852 out of 99999999
The request timed out.
//Audio file got downloaded, its size is 10349852
//AVPlayer tries to get the next chunk and then fails with request times out
Кто-нибудь когда-нибудь сталкивался с этой проблемой раньше?
PS Если я сохраню оригинал http
схема в AVURLAsset, AVPlayer знает, как обрабатывать эту схему, поэтому он воспроизводит аудиофайл просто отлично (даже без Content-Length
), Я не знаю, как это происходит без провала. Кроме того, в этом случае мой AVAssetResourceLoaderDelegate никогда не используется, поэтому я не могу перехватить и скопировать содержимое аудиофайла в локальное хранилище.
Вот реализация:
import AVFoundation
@objc protocol CachingPlayerItemDelegate {
// called when file is fully downloaded
@objc optional func playerItem(playerItem: CachingPlayerItem, didFinishDownloadingData data: NSData)
// called every time new portion of data is received
@objc optional func playerItemDownloaded(playerItem: CachingPlayerItem, didDownloadBytesSoFar bytesDownloaded: Int, outOf bytesExpected: Int)
// called after prebuffering is finished, so the player item is ready to play. Called only once, after initial pre-buffering
@objc optional func playerItemReadyToPlay(playerItem: CachingPlayerItem)
// called when some media did not arrive in time to continue playback
@objc optional func playerItemDidStopPlayback(playerItem: CachingPlayerItem)
// called when deinit
@objc optional func playerItemWillDeinit(playerItem: CachingPlayerItem)
}
extension URL {
func urlWithCustomScheme(scheme: String) -> URL {
var components = URLComponents(url: self, resolvingAgainstBaseURL: false)
components?.scheme = scheme
return components!.url!
}
}
class CachingPlayerItem: AVPlayerItem {
class ResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSessionDelegate, URLSessionDataDelegate, URLSessionTaskDelegate {
var playingFromCache = false
var mimeType: String? // is used if we play from cache (with NSData)
var session: URLSession?
var songData: NSData?
var response: URLResponse?
var pendingRequests = Set<AVAssetResourceLoadingRequest>()
weak var owner: CachingPlayerItem?
//MARK: AVAssetResourceLoader delegate
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
if playingFromCache { // if we're playing from cache
// nothing to do here
} else if session == nil { // if we're playing from url, we need to download the file
let interceptedURL = loadingRequest.request.url!.urlWithCustomScheme(scheme: owner!.scheme!).deletingLastPathComponent()
startDataRequest(withURL: interceptedURL)
}
pendingRequests.insert(loadingRequest)
processPendingRequests()
return true
}
func startDataRequest(withURL url: URL) {
let request = URLRequest(url: url)
let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
configuration.timeoutIntervalForRequest = 60.0
configuration.timeoutIntervalForResource = 120.0
session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
let task = session?.dataTask(with: request)
task?.resume()
}
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
pendingRequests.remove(loadingRequest)
}
//MARK: URLSession delegate
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
(songData as! NSMutableData).append(data)
processPendingRequests()
owner?.delegate?.playerItemDownloaded?(playerItem: owner!, didDownloadBytesSoFar: songData!.length, outOf: Int(dataTask.countOfBytesExpectedToReceive))
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
completionHandler(URLSession.ResponseDisposition.allow)
songData = NSMutableData()
self.response = response
processPendingRequests()
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError err: Error?) {
if let error = err {
print(error.localizedDescription)
return
}
processPendingRequests()
owner?.delegate?.playerItem?(playerItem: owner!, didFinishDownloadingData: songData!)
}
//MARK:
func processPendingRequests() {
var requestsCompleted = Set<AVAssetResourceLoadingRequest>()
for loadingRequest in pendingRequests {
fillInContentInforation(contentInformationRequest: loadingRequest.contentInformationRequest)
let didRespondCompletely = respondWithDataForRequest(dataRequest: loadingRequest.dataRequest!)
if didRespondCompletely {
requestsCompleted.insert(loadingRequest)
loadingRequest.finishLoading()
}
}
for i in requestsCompleted {
pendingRequests.remove(i)
}
}
func fillInContentInforation(contentInformationRequest: AVAssetResourceLoadingContentInformationRequest?) {
// if we play from cache we make no URL requests, therefore we have no responses, so we need to fill in contentInformationRequest manually
if playingFromCache {
contentInformationRequest?.contentType = self.mimeType
contentInformationRequest?.contentLength = Int64(songData!.length)
contentInformationRequest?.isByteRangeAccessSupported = true
return
}
// have no response from the server yet
if response == nil {
return
}
let mimeType = response?.mimeType
contentInformationRequest?.contentType = mimeType
if response?.expectedContentLength != -1 {
contentInformationRequest?.contentLength = response!.expectedContentLength
contentInformationRequest?.isByteRangeAccessSupported = true
} else {
contentInformationRequest?.isByteRangeAccessSupported = false
}
}
func respondWithDataForRequest(dataRequest: AVAssetResourceLoadingDataRequest) -> Bool {
let requestedOffset = Int(dataRequest.requestedOffset)
let requestedLength = dataRequest.requestedLength
let startOffset = Int(dataRequest.currentOffset)
// Don't have any data at all for this request
if songData == nil || songData!.length < startOffset {
return false
}
// This is the total data we have from startOffset to whatever has been downloaded so far
let bytesUnread = songData!.length - Int(startOffset)
// Respond fully or whaterver is available if we can't satisfy the request fully yet
let bytesToRespond = min(bytesUnread, requestedLength + Int(requestedOffset))
dataRequest.respond(with: songData!.subdata(with: NSMakeRange(startOffset, bytesToRespond)))
let didRespondFully = songData!.length >= requestedLength + Int(requestedOffset)
return didRespondFully
}
deinit {
session?.invalidateAndCancel()
}
}
private var resourceLoaderDelegate = ResourceLoaderDelegate()
private var scheme: String?
private var url: URL!
weak var delegate: CachingPlayerItemDelegate?
// use this initializer to play remote files
init(url: URL) {
self.url = url
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
scheme = components.scheme
let asset = AVURLAsset(url: url.urlWithCustomScheme(scheme: "fakeScheme").appendingPathComponent("/test.mp3"))
asset.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.main)
super.init(asset: asset, automaticallyLoadedAssetKeys: nil)
resourceLoaderDelegate.owner = self
self.addObserver(self, forKeyPath: "status", options: NSKeyValueObservingOptions.new, context: nil)
NotificationCenter.default.addObserver(self, selector: #selector(didStopHandler), name:NSNotification.Name.AVPlayerItemPlaybackStalled, object: self)
}
// use this initializer to play local files
init(data: NSData, mimeType: String, fileExtension: String) {
self.url = URL(string: "whatever://whatever/file.\(fileExtension)")
resourceLoaderDelegate.songData = data
resourceLoaderDelegate.playingFromCache = true
resourceLoaderDelegate.mimeType = mimeType
let asset = AVURLAsset(url: url)
asset.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.main)
super.init(asset: asset, automaticallyLoadedAssetKeys: nil)
resourceLoaderDelegate.owner = self
self.addObserver(self, forKeyPath: "status", options: NSKeyValueObservingOptions.new, context: nil)
NotificationCenter.default.addObserver(self, selector: #selector(didStopHandler), name:NSNotification.Name.AVPlayerItemPlaybackStalled, object: self)
}
func download() {
if resourceLoaderDelegate.session == nil {
resourceLoaderDelegate.startDataRequest(withURL: url)
}
}
override init(asset: AVAsset, automaticallyLoadedAssetKeys: [String]?) {
fatalError("not implemented")
}
// MARK: KVO
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
delegate?.playerItemReadyToPlay?(playerItem: self)
}
// MARK: Notification handlers
func didStopHandler() {
delegate?.playerItemDidStopPlayback?(playerItem: self)
}
// MARK:
deinit {
NotificationCenter.default.removeObserver(self)
removeObserver(self, forKeyPath: "status")
resourceLoaderDelegate.session?.invalidateAndCancel()
delegate?.playerItemWillDeinit?(playerItem: self)
}
}
2 ответа
Вы не можете справиться с этой ситуацией, так как для iOS этот файл поврежден из-за неправильного заголовка. Система считает, что вы собираетесь воспроизводить обычный аудиофайл, но он не имеет полной информации об этом. Вы не знаете, какая продолжительность звука будет, только если у вас есть прямая трансляция. Прямая трансляция на iOS осуществляется по протоколу прямой трансляции HTTP. Ваш код iOS правильный. Вы должны изменить свой бэкэнд и предоставить список воспроизведения m3u8 для потоковой передачи аудио в режиме реального времени, затем iOS примет его в качестве потока в реальном времени, а аудиоплеер начнет воспроизведение треков.
Некоторая связанная информация может быть найдена здесь. Как разработчик iOS с хорошим опытом потоковой передачи аудио / видео, я могу сказать, что код для воспроизведения в реальном времени / VOD одинаков.
Но иногда я транслирую аудиофайлы, продолжительность которых не могу указать заранее (скажем, прямой эфир подкаста). В этом случае AVURLAsset устанавливает длину в -1 и терпит неудачу с
В этом случае вы должны позволить игроку повторно запросить эти данные позже и установить
renewalDate
свойство
contentInformationRequest
для данной части до некоторого момента в будущем, когда эти данные будут доступны.
Если это просто бесконечный прямой эфир, вы всегда указываете длину полученной части и устанавливаете новую
renewDate
на следующий цикл обновления (по моим наблюдениям изначально
AVPlayer
просто обновляет эти данные с фиксированным периодом времени, скажем, каждые 4-6 секунд). Сервер обычно предоставляет такую информацию с http-заголовком «Expires» . Вы можете сами положиться на эту информацию и реализовать что-то вроде этого (заимствовано из моего собственного вопроса на форуме разработчиков Apple ):
if let httpResonse = response as? HTTPURLResponse, let expirationValue = httpResonse.value(forHTTPHeaderField: "Expires") { let dateFormatter = DateFormatter() dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" if let expirationDate = dateFormatter.date(from: expirationValue) { let renewDate = max(expirationDate, Date(timeIntervalSinceNow: 8)) contentInformationRequest.renewalDate = renewDate } }
Эта линия позволяет
renewDate = max(expirationDate, Date(timeIntervalSinceNow: 8))
добавляет 8-секундный льготный период для загрузки видео проигрывателем. В противном случае он не поспевает за темпами обновлений, и видео загружается в плохом качестве.
Или просто периодически обновляйте его, если вы заранее знаете, что это активный ресурс без фиксированной длины, и ваш сервер не предоставляет необходимую информацию:
contentInformationRequest.renewalDate = Date(timeIntervalSinceNow: 8)