diff --git a/README.md b/README.md index a6f5f8f..74e10ee 100644 --- a/README.md +++ b/README.md @@ -53,15 +53,11 @@ This will return a `YouTubeMetadata` object. ### Example 1 To play a YouTube video in AVPlayer: ```swift -let stream = try await YouTube(videoID: "QdBZY2fkU-0").streams - .filterVideoAndAudio() - .filter { $0.isNativelyPlayable } - .highestResolutionStream() - -let player = AVPlayer(url: stream!.url) +let item = try await YouTube(videoID: "QdBZY2fkU-0").playerItem() +let player = AVPlayer(playerItem: item) // -> Now present the player however you like ``` -The `isNativelyPlayable` parameter is used to filter only streams that are natively decodable on the current operating system and device. +YouTubeKit automatically combines separate video and audio streams when needed. Pass `maxResolution` to limit the selected video resolution, for example `playerItem(maxResolution: 1080)`. ### Example 2 @@ -97,6 +93,19 @@ let hlsManifestUrl = try await YouTube(videoID: "21X5lGlDOfg").livestreams ``` +### Example 5 +To get the highest-resolution natively playable video-only URL: +```swift +let stream = try await YouTube(videoID: "QdBZY2fkU-0").streams + .filterVideoOnly() + .filter { $0.isNativelyPlayable } + .highestResolutionStream() + +let videoURL = stream?.url +``` +The `isNativelyPlayable` property filters out streams whose codecs cannot be decoded natively on the current operating system and device. Video-only streams do not include audio. + + ## Remote Fallback With local YouTube extractors, there is the problem that YouTube might suddenly change their unofficial API, which can break your existing shipped app. It can take days or weeks for a user to update your app, rendering some features unusable for them in the meantime. To prevent this, YouTubeKit includes a feature that allows you to enable a remote fallback. As soon as local extraction fails, it switches to using a remote server running `youtube-dl`, that is updated frequently. Simply specify the `methods` YouTubeKit should use in priority order. The rest of the API remains exactly the same — everything is handled by the library. diff --git a/Sources/YouTubeKit/Errors.swift b/Sources/YouTubeKit/Errors.swift index 5ae1630..f4a9a9b 100644 --- a/Sources/YouTubeKit/Errors.swift +++ b/Sources/YouTubeKit/Errors.swift @@ -11,6 +11,7 @@ public enum YouTubeKitError: String, Error { case maxRetriesExceeded case htmlParseError case extractError + case compositionTrackCreationFailed case regexMatchError case videoUnavailable case videoAgeRestricted diff --git a/Sources/YouTubeKit/InnerTube.swift b/Sources/YouTubeKit/InnerTube.swift index b3ef421..b582b7b 100644 --- a/Sources/YouTubeKit/InnerTube.swift +++ b/Sources/YouTubeKit/InnerTube.swift @@ -195,6 +195,7 @@ class InnerTube { let videoId: String let title: String? let shortDescription: String? + let lengthSeconds: String? let thumbnail: Thumbnail struct Thumbnail: Decodable { diff --git a/Sources/YouTubeKit/Models/Stream.swift b/Sources/YouTubeKit/Models/Stream.swift index 2728de7..50a6ffe 100644 --- a/Sources/YouTubeKit/Models/Stream.swift +++ b/Sources/YouTubeKit/Models/Stream.swift @@ -19,6 +19,9 @@ public struct Stream: Sendable { public let bitrate: Int? public let averageBitrate: Int? + + let width: Int? + let height: Int? @available(*, deprecated, message: "Might be empty if using remote fetching method. Use `videoCodec`, `audioCodec` or `fileExtension` instead.") public let mimeType: String @@ -67,6 +70,8 @@ public struct Stream: Sendable { self.bitrate = format.bitrate self.averageBitrate = format.averageBitrate + self.width = format.width + self.height = format.height self.filesize = format.contentLength.flatMap { Int($0) } } @@ -88,6 +93,8 @@ public struct Stream: Sendable { self.bitrate = remoteStream.videoBitrate ?? remoteStream.audioBitrate self.averageBitrate = remoteStream.averageBitrate + self.width = remoteStream.width + self.height = remoteStream.height self.filesize = remoteStream.filesize // Backward compatibility for deprecated `subtype` and `mimeType` diff --git a/Sources/YouTubeKit/Models/YouTubeMetadata.swift b/Sources/YouTubeKit/Models/YouTubeMetadata.swift index 00d113e..5917592 100644 --- a/Sources/YouTubeKit/Models/YouTubeMetadata.swift +++ b/Sources/YouTubeKit/Models/YouTubeMetadata.swift @@ -15,6 +15,9 @@ public struct YouTubeMetadata: Sendable { /// The description of the YouTube video. public let description: String + + /// The duration of the YouTube video in seconds. + public let duration: TimeInterval /// The thumbnail image of the YouTube video, if available. public let thumbnail: Thumbnail? @@ -35,6 +38,7 @@ public struct YouTubeMetadata: Sendable { YouTubeMetadata( title: videoDetails.title ?? "", description: videoDetails.shortDescription ?? "", + duration: TimeInterval(videoDetails.lengthSeconds ?? "") ?? 0, thumbnail: videoDetails.thumbnail.thumbnails.map { YouTubeMetadata.Thumbnail(url: $0.url) }.last ) } @@ -51,6 +55,7 @@ public struct YouTubeMetadata: Sendable { return YouTubeMetadata( title: title, description: videoDetails.lazy.compactMap { $0.shortDescription }.first ?? "", + duration: videoDetails.lazy.compactMap { TimeInterval($0.lengthSeconds ?? "") }.first ?? 0, thumbnail: videoDetails.first(where: { !$0.thumbnail.thumbnails.isEmpty })?.thumbnail.thumbnails.map { YouTubeMetadata.Thumbnail(url: $0.url) }.last ) } diff --git a/Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift b/Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift new file mode 100644 index 0000000..e31d1a0 --- /dev/null +++ b/Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift @@ -0,0 +1,87 @@ +// +// HLSPlayerItem.swift +// YouTubeKit +// + +import AVFoundation +import Foundation + +#if !os(watchOS) +enum HLSPlayerItem { + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, *) + static func make(videoStream: Stream, audioStream: Stream, duration: TimeInterval) async throws -> AVPlayerItem { + async let videoIndex = MP4Index.load(from: videoStream.url) + async let audioIndex = MP4Index.load(from: audioStream.url) + let (loadedVideoIndex, loadedAudioIndex) = try await (videoIndex, audioIndex) + + let videoCodec = try codecIdentifier(videoStream.videoCodec) + let audioCodec = try codecIdentifier(audioStream.audioCodec) + let resolution = if let width = videoStream.width, let height = videoStream.height { + ",RESOLUTION=\(width)x\(height)" + } else { + "" + } + let bandwidth = (videoStream.bitrate ?? 5_000_000) + (audioStream.bitrate ?? 128_000) + let baseURL = "youtubekit-hls://playlist-\(UUID().uuidString.lowercased())" + let masterPlaylist = """ + #EXTM3U + #EXT-X-VERSION:7 + #EXT-X-INDEPENDENT-SEGMENTS + #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Default",DEFAULT=YES,AUTOSELECT=YES,URI="\(baseURL)/audio.m3u8" + #EXT-X-STREAM-INF:BANDWIDTH=\(bandwidth)\(resolution),CODECS="\(videoCodec),\(audioCodec)",AUDIO="audio" + \(baseURL)/video.m3u8 + + """ + + let asset = HLSURLAsset( + url: URL(string: "\(baseURL)/master.m3u8")!, + playlists: [ + "/master.m3u8": masterPlaylist, + "/video.m3u8": loadedVideoIndex.playlist(url: videoStream.url), + "/audio.m3u8": loadedAudioIndex.playlist(url: audioStream.url), + ] + ) + let item = AVPlayerItem(asset: asset) + item.forwardPlaybackEndTime = CMTime(seconds: duration, preferredTimescale: 1_000) + item.preferredForwardBufferDuration = 5 + return item + } + + private static func codecIdentifier(_ codec: VideoCodec?) throws -> String { + switch codec { + case .avc1(let version): "avc1.\(version)" + case .av1(let version): "av01.\(version)" + case .mp4v(let version): "mp4v.\(version)" + case .vp9(let version): "vp09.\(version)" + case .unknown(let codec): codec + case nil: throw HLSPlayerItemError.unsupportedCodec + } + } + + private static func codecIdentifier(_ codec: AudioCodec?) throws -> String { + switch codec { + case .mp4a(let version): "mp4a.\(version)" + case .ac3: "ac-3" + case .ec3: "ec-3" + case .opus: "opus" + case .unknown(let codec): codec + case nil: throw HLSPlayerItemError.unsupportedCodec + } + } +} + +enum HLSPlayerItemError: LocalizedError { + case invalidResponse + case malformedIndex + case unsupportedCodec + + var errorDescription: String? { + switch self { + case .invalidResponse: "The media server did not return the requested MP4 index range." + case .malformedIndex: "The MP4 stream did not contain a supported SIDX box." + case .unsupportedCodec: "The selected stream codec cannot be represented in HLS." + } + } +} +#endif diff --git a/Sources/YouTubeKit/PlayerItem/HLSURLAsset.swift b/Sources/YouTubeKit/PlayerItem/HLSURLAsset.swift new file mode 100644 index 0000000..51aff4b --- /dev/null +++ b/Sources/YouTubeKit/PlayerItem/HLSURLAsset.swift @@ -0,0 +1,61 @@ +// +// HLSURLAsset.swift +// YouTubeKit +// + +import AVFoundation +import Foundation + +#if !os(watchOS) +final class HLSURLAsset: AVURLAsset, @unchecked Sendable { + private let playlistLoader: HLSPlaylistResourceLoader + + init(url: URL, playlists: [String: String]) { + let playlistLoader = HLSPlaylistResourceLoader(playlists: playlists) + self.playlistLoader = playlistLoader + super.init(url: url, options: nil) + resourceLoader.setDelegate( + playlistLoader, + queue: DispatchQueue(label: "YouTubeKit.HLSPlaylistResourceLoader") + ) + } +} + +private final class HLSPlaylistResourceLoader: NSObject, AVAssetResourceLoaderDelegate { + private let playlists: [String: Data] + + init(playlists: [String: String]) { + self.playlists = playlists.mapValues { Data($0.utf8) } + } + + func resourceLoader( + _ resourceLoader: AVAssetResourceLoader, + shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest + ) -> Bool { + guard let url = loadingRequest.request.url, + let data = playlists[url.path] else { + return false + } + + loadingRequest.contentInformationRequest?.contentType = "public.m3u-playlist" + loadingRequest.contentInformationRequest?.contentLength = Int64(data.count) + loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = true + + if let dataRequest = loadingRequest.dataRequest { + guard dataRequest.currentOffset >= 0, + dataRequest.currentOffset <= Int64(data.count) else { + loadingRequest.finishLoading(with: HLSPlayerItemError.invalidResponse) + return true + } + let start = Int(dataRequest.currentOffset) + let end = start + min(dataRequest.requestedLength, data.count - start) + if start < end { + dataRequest.respond(with: data[start.. MP4Index { + var request = URLRequest(url: url) + request.setValue("bytes=0-1048575", forHTTPHeaderField: "Range") + let (data, response) = try await URLSession.shared.data(for: request) + guard let response = response as? HTTPURLResponse, + response.statusCode == 206 else { + throw HLSPlayerItemError.invalidResponse + } + return try MP4Index(data: data) + } + + init(data: Data) throws { + var boxOffset = 0 + + while boxOffset + 8 <= data.count { + let size32 = UInt64(try data.uint32(at: boxOffset)) + let type = try data.ascii(at: boxOffset + 4, length: 4) + let headerLength = size32 == 1 ? 16 : 8 + let boxSize = size32 == 1 ? try data.uint64(at: boxOffset + 8) : size32 + + guard boxSize >= UInt64(headerLength), + boxSize <= UInt64(Int.max), + boxOffset <= data.count - Int(boxSize) else { + throw HLSPlayerItemError.malformedIndex + } + + if type == "sidx" { + self = try MP4Index(sidx: data, offset: boxOffset, size: Int(boxSize), headerLength: headerLength) + return + } + + boxOffset += Int(boxSize) + } + + throw HLSPlayerItemError.malformedIndex + } + + private init(sidx data: Data, offset: Int, size: Int, headerLength: Int) throws { + let boxEnd = offset + size + var cursor = offset + headerLength + let version = try data.byte(at: cursor) + cursor += 4 // version and flags + cursor += 4 // reference ID + let timescale = try data.uint32(at: cursor) + cursor += 4 + guard timescale > 0 else { throw HLSPlayerItemError.malformedIndex } + + let firstOffset: UInt64 + if version == 0 { + cursor += 4 // earliest presentation time + firstOffset = UInt64(try data.uint32(at: cursor)) + cursor += 4 + } else if version == 1 { + cursor += 8 // earliest presentation time + firstOffset = try data.uint64(at: cursor) + cursor += 8 + } else { + throw HLSPlayerItemError.malformedIndex + } + + cursor += 2 // reserved + let referenceCount = Int(try data.uint16(at: cursor)) + cursor += 2 + guard referenceCount > 0, + cursor <= boxEnd, + referenceCount <= (boxEnd - cursor) / 12 else { + throw HLSPlayerItemError.malformedIndex + } + + let (firstSegmentOffset, firstOffsetOverflow) = UInt64(boxEnd).addingReportingOverflow(firstOffset) + guard !firstOffsetOverflow else { throw HLSPlayerItemError.malformedIndex } + var segmentOffset = firstSegmentOffset + var parsedSegments = [Segment]() + parsedSegments.reserveCapacity(referenceCount) + + for _ in 0..> 31 == 0 else { throw HLSPlayerItemError.malformedIndex } + let length = UInt64(reference & 0x7fff_ffff) + let duration = Double(try data.uint32(at: cursor)) / Double(timescale) + cursor += 8 // duration and SAP information + guard length > 0, duration > 0 else { throw HLSPlayerItemError.malformedIndex } + parsedSegments.append(Segment(offset: segmentOffset, length: length, duration: duration)) + let (nextSegmentOffset, segmentOffsetOverflow) = segmentOffset.addingReportingOverflow(length) + guard !segmentOffsetOverflow else { throw HLSPlayerItemError.malformedIndex } + segmentOffset = nextSegmentOffset + } + + initializationLength = UInt64(offset) + segments = parsedSegments + } + + func playlist(url: URL) -> String { + let targetDuration = max(1, Int(ceil(segments.map(\.duration).max() ?? 1))) + var lines = [ + "#EXTM3U", + "#EXT-X-VERSION:7", + "#EXT-X-TARGETDURATION:\(targetDuration)", + "#EXT-X-MEDIA-SEQUENCE:0", + "#EXT-X-PLAYLIST-TYPE:VOD", + "#EXT-X-MAP:URI=\"\(rangedURL(url, offset: 0, length: initializationLength))\"", + ] + + for segment in segments { + lines.append("#EXTINF:\(String(format: "%.6f", segment.duration)),") + lines.append(rangedURL(url, offset: segment.offset, length: segment.length)) + } + lines.append("#EXT-X-ENDLIST") + return lines.joined(separator: "\n") + "\n" + } + + private func rangedURL(_ url: URL, offset: UInt64, length: UInt64) -> String { + guard length > 0, + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url.absoluteString + } + let (endOffset, offsetOverflow) = offset.addingReportingOverflow(length - 1) + guard !offsetOverflow else { return url.absoluteString } + var queryItems = components.queryItems ?? [] + queryItems.removeAll { $0.name == "range" } + queryItems.append(URLQueryItem(name: "range", value: "\(offset)-\(endOffset)")) + components.queryItems = queryItems + return (components.url?.absoluteString ?? url.absoluteString) + .replacingOccurrences(of: "\"", with: "%22") + } +} + +private extension Data { + func byte(at offset: Int) throws -> UInt8 { + guard indices.contains(offset) else { throw HLSPlayerItemError.malformedIndex } + return self[offset] + } + + func uint16(at offset: Int) throws -> UInt16 { + guard offset >= 0, offset <= count - 2 else { throw HLSPlayerItemError.malformedIndex } + return (UInt16(self[offset]) << 8) | UInt16(self[offset + 1]) + } + + func uint32(at offset: Int) throws -> UInt32 { + guard offset >= 0, offset <= count - 4 else { throw HLSPlayerItemError.malformedIndex } + return (UInt32(self[offset]) << 24) + | (UInt32(self[offset + 1]) << 16) + | (UInt32(self[offset + 2]) << 8) + | UInt32(self[offset + 3]) + } + + func uint64(at offset: Int) throws -> UInt64 { + (UInt64(try uint32(at: offset)) << 32) | UInt64(try uint32(at: offset + 4)) + } + + func ascii(at offset: Int, length: Int) throws -> String { + guard offset >= 0, length >= 0, offset <= count - length else { + throw HLSPlayerItemError.malformedIndex + } + return String(decoding: self[offset..<(offset + length)], as: UTF8.self) + } +} +#endif diff --git a/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift b/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift new file mode 100644 index 0000000..1831b61 --- /dev/null +++ b/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift @@ -0,0 +1,124 @@ +// +// YouTube+PlayerItem.swift +// YouTubeKit +// +// Created by Alexander Eichhorn on 15.10.2024. +// + +import Foundation +import AVFoundation +import os.log + +@available(iOS 13.0, watchOS 6.0, tvOS 13.0, macOS 10.15, *) +extension YouTube { + + /// Returns video+audio `AVPlayerItem` for the highest resolution stream that is natively playable with potentially audio and video automatically combined. + /// This means it will have most of the time higher resolution and bitrate than from a single `.streams.filterVideoAndAudio()` stream alone. + /// - Parameter maxResolution: The maximum resolution of the video stream. If `nil`, the highest resolution stream is used. + @available(iOS 15.0, watchOS 8.0, tvOS 15.0, macOS 12.0, *) + public func playerItem(maxResolution: Int? = nil) async throws -> AVPlayerItem { + // For livestreams: use the direct url + let isLiveContent = (try? await isLiveContent) ?? false + if isLiveContent, let livestream = try await livestreams.first { + let playerItem = AVPlayerItem(url: livestream.url) +#if !os(watchOS) + if let maxResolution { + playerItem.preferredMaximumResolution = CGSize(width: CGFloat(maxResolution) * 16 / 9, height: CGFloat(maxResolution)) + } +#endif + return playerItem + } + + let streams = try await streams + let nativelyPlayableStreams = streams.filter(\.isNativelyPlayable) + + let videoStream = nativelyPlayableStreams + .filter { $0.fileExtension != .m3u8 } + .filterVideoOnly() + .filter(byResolution: { ($0 ?? .max) <= (maxResolution ?? .max) }) + .highestResolutionStream() + let audioStream = nativelyPlayableStreams.filterAudioOnly().highestAudioBitrateStream() + let bestCombinedStream = nativelyPlayableStreams + .filterVideoAndAudio() + .filter(byResolution: { ($0 ?? .max) <= (maxResolution ?? .max) }) + .highestResolutionStream() + + // prefer already combined streams if available + if let bestCombinedStream { + if videoStream == nil || (bestCombinedStream.videoResolution ?? 0) >= (videoStream?.videoResolution ?? 0) { + os_log("Using already combined stream for %{public}@", log: OSLog(category: "YouTube+PlayerItem"), type: .info, videoID) + return AVPlayerItem(asset: AVURLAsset(url: bestCombinedStream.url)) + } + } + + guard let videoStream, let audioStream else { + throw YouTubeKitError.extractError + } + + let metadataDuration = try await metadata?.duration + let videoDuration = metadataDuration.flatMap { $0 > 0 ? $0 : nil } + +#if !os(watchOS) + if videoStream.fileExtension == .mp4, + audioStream.fileExtension == .m4a, + let videoDuration { + do { + return try await HLSPlayerItem.make( + videoStream: videoStream, + audioStream: audioStream, + duration: videoDuration + ) + } catch let error as CancellationError { + throw error + } catch { + os_log("Couldn't create HLS player item for %{public}@: %{public}@. Falling back to AVMutableComposition.", log: OSLog(category: "YouTube+PlayerItem"), type: .info, videoID, error.localizedDescription) + } + } +#endif + + return try await compositionPlayerItem( + videoStream: videoStream, + audioStream: audioStream, + duration: videoDuration + ) + } + + @MainActor + @available(iOS 15.0, watchOS 8.0, tvOS 15.0, macOS 12.0, *) + private func compositionPlayerItem(videoStream: Stream, audioStream: Stream, duration: TimeInterval?) async throws -> AVPlayerItem { + + let composition = AVMutableComposition() + + // prepare video track + let videoAsset = AVURLAsset(url: videoStream.url) + async let videoAssetTrackTask = videoAsset.loadTracks(withMediaType: .video).first + + // prepare audio track + let audioAsset = AVURLAsset(url: audioStream.url) + async let audioAssetTrackTask = audioAsset.loadTracks(withMediaType: .audio).first + + // await video and audio track loading concurrently + guard let videoAssetTrack = try await videoAssetTrackTask, let audioAssetTrack = try await audioAssetTrackTask else { + throw YouTubeKitError.extractError + } + + // add video track + guard let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid), + let audioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else { + throw YouTubeKitError.compositionTrackCreationFailed + } + let videoTimeRange = if let duration { + CMTime(seconds: duration, preferredTimescale: 1_000) + } else { + try await videoAsset.load(.duration) + } + try videoTrack.insertTimeRange(CMTimeRange(start: .zero, duration: videoTimeRange), of: videoAssetTrack, at: .zero) + + // add audio track + try audioTrack.insertTimeRange(CMTimeRange(start: .zero, duration: videoTimeRange), of: audioAssetTrack, at: .zero) + + let playerItem = AVPlayerItem(asset: composition) + return playerItem + } + +} diff --git a/Sources/YouTubeKit/Remote/Models/RemoteStream.swift b/Sources/YouTubeKit/Remote/Models/RemoteStream.swift index 85b8b37..b4a9a9b 100644 --- a/Sources/YouTubeKit/Remote/Models/RemoteStream.swift +++ b/Sources/YouTubeKit/Remote/Models/RemoteStream.swift @@ -18,5 +18,7 @@ struct RemoteStream: Decodable { let averageBitrate: Int? let audioBitrate: Int? let videoBitrate: Int? + let width: Int? + let height: Int? let filesize: Int? } diff --git a/Sources/YouTubeKit/YouTube.swift b/Sources/YouTubeKit/YouTube.swift index a14c306..918a7db 100644 --- a/Sources/YouTubeKit/YouTube.swift +++ b/Sources/YouTubeKit/YouTube.swift @@ -111,6 +111,13 @@ public class YouTube { return _watchHTML! } } + + var isLiveContent: Bool { + get async throws { + let (status, _) = try Extraction.playabilityStatus(watchHTML: await watchHTML) + return status == .liveStream + } + } private var embedHTML: String { get async throws { @@ -146,10 +153,6 @@ public class YouTube { case .error: throw YouTubeKitError.videoUnavailable case .liveStream: - let streamingData = try await videoInfos.map { $0.streamingData } - if streamingData.allSatisfy({ $0?.hlsManifestUrl == nil }) { - throw YouTubeKitError.liveStreamError - } continue case .ok, .none: continue diff --git a/Tests/YouTubeKitTests/Extensions.swift b/Tests/YouTubeKitTests/Extensions.swift index ca351c1..43bdaad 100644 --- a/Tests/YouTubeKitTests/Extensions.swift +++ b/Tests/YouTubeKitTests/Extensions.swift @@ -6,6 +6,7 @@ // import XCTest +import Testing @testable import YouTubeKit //XCTAssertNoThrow(_ expression: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) @@ -22,3 +23,24 @@ func XCTAssertNoThrow(_ expression: @autoclosure () async throws -> T, _ mess func XCTAssertHighestResolutionStreamAtLeastHD(_ streams: [YouTubeKit.Stream], file: StaticString = #filePath, line: UInt = #line) { XCTAssert((streams.highestResolutionStream()?.videoResolution ?? 0) >= 720, file: file, line: line) } + +@available(iOS 13.0, watchOS 6.0, tvOS 13.0, macOS 10.15, *) +func XCTAssertLiveContent(_ youtube: YouTube, _ expected: Bool, file: StaticString = #filePath, line: UInt = #line) async { + do { + let isLiveContent = try await youtube.isLiveContent + XCTAssertEqual(isLiveContent, expected, file: file, line: line) + } catch { + XCTFail("Live content check threw error: \(error)", file: file, line: line) + } +} + +extension YouTube.ExtractionMethod: CustomTestStringConvertible { + public var testDescription: String { + switch self { +#if canImport(JavaScriptCore) + case .local: ".local" +#endif + case .remote: ".remote" + } + } +} diff --git a/Tests/YouTubeKitTests/PlayabilityTests.swift b/Tests/YouTubeKitTests/PlayabilityTests.swift index 0722943..9bea34b 100644 --- a/Tests/YouTubeKitTests/PlayabilityTests.swift +++ b/Tests/YouTubeKitTests/PlayabilityTests.swift @@ -26,8 +26,10 @@ final class PlayabilityTests: XCTestCase { let videoID = "V3dbG9pAi8I" #if canImport(JavaScriptCore) let youtubeLocal = YouTube(videoID: videoID, methods: [.local]) + await XCTAssertLiveContent(youtubeLocal, false) #endif let youtubeRemote = YouTube(videoID: videoID, methods: [.remote]) + await XCTAssertLiveContent(youtubeRemote, false) do { #if canImport(JavaScriptCore) @@ -53,8 +55,10 @@ final class PlayabilityTests: XCTestCase { let videoID = "njX2bu-_Vw4" #if canImport(JavaScriptCore) let youtubeLocal = YouTube(videoID: videoID, methods: [.local]) + await XCTAssertLiveContent(youtubeLocal, false) #endif let youtubeRemote = YouTube(videoID: videoID, methods: [.remote]) + await XCTAssertLiveContent(youtubeRemote, false) do { #if canImport(JavaScriptCore) @@ -74,4 +78,26 @@ final class PlayabilityTests: XCTestCase { } } + + func testAutoCombinedPlayerItemPlayability() async throws { + + let videoID = "Slj4-Sv-YNA" + +#if canImport(JavaScriptCore) + let youtubeLocal = YouTube(videoID: videoID, methods: [.local]) + await XCTAssertLiveContent(youtubeLocal, false) + let localPlayerItem = try await youtubeLocal.playerItem() + let localIsPlayable = try await localPlayerItem.asset.load(.isPlayable) + + XCTAssert(localIsPlayable, "Local player item should be playable") +#endif + + let youtubeRemote = YouTube(videoID: videoID, methods: [.remote]) + await XCTAssertLiveContent(youtubeRemote, false) + let remotePlayerItem = try await youtubeRemote.playerItem() + let remoteIsPlayable = try await remotePlayerItem.asset.load(.isPlayable) + + XCTAssert(remoteIsPlayable, "Remote player item should be playable") + } + } diff --git a/Tests/YouTubeKitTests/PlayerItemTests.swift b/Tests/YouTubeKitTests/PlayerItemTests.swift new file mode 100644 index 0000000..5ae1199 --- /dev/null +++ b/Tests/YouTubeKitTests/PlayerItemTests.swift @@ -0,0 +1,77 @@ +import AVFoundation +import Testing +@testable import YouTubeKit + +#if !os(watchOS) +struct PlayerItemTests { + + @available(iOS 16.0, tvOS 16.0, macOS 13.0, *) + @Test( + "Player item uses HLS and plays", + arguments: [YouTube.ExtractionMethod.local, .remote] + ) + @MainActor + func playerItemUsesHLSAndPlays(method: YouTube.ExtractionMethod) async throws { + let start = Date() + let youtube = YouTube(videoID: "Slj4-Sv-YNA", methods: [method]) + #expect(try await youtube.isLiveContent == false) + let item = try await youtube.playerItem() + let asset = try #require(item.asset as? AVURLAsset) + #expect(asset.url.scheme == "youtubekit-hls") + + let player = AVPlayer(playerItem: item) + player.play() + + while item.status == .unknown && Date().timeIntervalSince(start) < 20 { + try await Task.sleep(for: .milliseconds(50)) + } + #expect(item.status == .readyToPlay) + + player.playImmediately(atRate: 1) + while player.currentTime().seconds < 0.5 && Date().timeIntervalSince(start) < 20 { + try await Task.sleep(for: .milliseconds(50)) + } + + #expect(player.currentTime().seconds > 0.5) + #expect(item.error == nil) + #expect(item.presentationSize == CGSize(width: 1920, height: 1080)) + + let mediaTypes = item.tracks.compactMap { $0.assetTrack?.mediaType } + #expect(mediaTypes.contains(.video)) + #expect(mediaTypes.contains(.audio)) + + print("HLS player item ready and playing in \(Date().timeIntervalSince(start)) seconds") + } + + @available(iOS 16.0, tvOS 16.0, macOS 13.0, *) + @Test( + "Livestream player item uses native HLS and plays", + arguments: [YouTube.ExtractionMethod.local, .remote] + ) + @MainActor + func livestreamPlayerItemUsesNativeHLSAndPlays(method: YouTube.ExtractionMethod) async throws { + let youtube = YouTube(videoID: "tj4knR4r1UU", methods: [method]) + #expect(try await youtube.isLiveContent == true) + let item = try await youtube.playerItem(maxResolution: 720) + #expect(item.preferredMaximumResolution == CGSize(width: 1280, height: 720)) + let asset = try #require(item.asset as? AVURLAsset) + #expect(asset.url.absoluteString.contains(".m3u8")) + + let player = AVPlayer(playerItem: item) + let start = Date() + player.play() + + while item.status == .unknown && Date().timeIntervalSince(start) < 20 { + try await Task.sleep(for: .milliseconds(50)) + } + #expect(item.status == .readyToPlay) + + while player.timeControlStatus != .playing && Date().timeIntervalSince(start) < 20 { + try await Task.sleep(for: .milliseconds(50)) + } + #expect(player.timeControlStatus == .playing) + #expect(item.error == nil) + } + +} +#endif diff --git a/Tests/YouTubeKitTests/YouTubeKitTests.swift b/Tests/YouTubeKitTests/YouTubeKitTests.swift index ebf9727..e07a5d7 100644 --- a/Tests/YouTubeKitTests/YouTubeKitTests.swift +++ b/Tests/YouTubeKitTests/YouTubeKitTests.swift @@ -6,6 +6,7 @@ final class YouTubeKitTests: XCTestCase { func testVideoUnavailable() async { let youtube = YouTube(videoID: "cTsNJNx7plQ") + await XCTAssertLiveContent(youtube, false) do { try await youtube.checkAvailability() XCTFail("Expected throw") @@ -16,6 +17,7 @@ final class YouTubeKitTests: XCTestCase { func testVideoAvailable() async { let youtube = YouTube(videoID: "9bZkp7q19f0") + await XCTAssertLiveContent(youtube, false) do { try await youtube.checkAvailability() } catch let error { @@ -25,6 +27,7 @@ final class YouTubeKitTests: XCTestCase { func testSampleVideo1() async { let youtube = YouTube(videoID: "9bZkp7q19f0") + await XCTAssertLiveContent(youtube, false) do { let streams = try await youtube.streams XCTAssert(streams.count > 0) @@ -49,6 +52,7 @@ final class YouTubeKitTests: XCTestCase { func testSampleVideo2() async { let youtube = YouTube(videoID: "ZFoNBxpXen4") + await XCTAssertLiveContent(youtube, false) do { let streams = try await youtube.streams XCTAssert(streams.count > 0) @@ -71,6 +75,7 @@ final class YouTubeKitTests: XCTestCase { func testSampleVideo3() async { let youtube = YouTube(videoID: "dkpDjd2nHgo", methods: [.remote]) + await XCTAssertLiveContent(youtube, false) do { let streams = try await youtube.streams XCTAssert(streams.count > 0) @@ -79,7 +84,7 @@ final class YouTubeKitTests: XCTestCase { print(streams.count) //print(streams.filterAudioOnly().filter { $0.subtype == "mp4" }.highestAudioBitrateStream()?.url) //print(streams.filterVideoOnly().highestResolutionStream()) - print(streams.filter { $0.isProgressive && $0.fileExtension == .mp4 }.lowestResolutionStream()!) + //print(streams.filter { $0.isProgressive && $0.fileExtension == .mp4 }.lowestResolutionStream()!) XCTAssert(!streams.filterVideoOnly().isEmpty) XCTAssert(!streams.filterAudioOnly().isEmpty) @@ -95,6 +100,7 @@ final class YouTubeKitTests: XCTestCase { func testSampleVideo4() async { let youtube = YouTube(videoID: "NOid0U6GxUA") // non-music video + await XCTAssertLiveContent(youtube, false) do { let streams = try await youtube.streams XCTAssert(streams.count > 0) @@ -117,6 +123,7 @@ final class YouTubeKitTests: XCTestCase { func testSampleVideo5() async { let youtube = YouTube(videoID: "ObUBUKOn-bo") + await XCTAssertLiveContent(youtube, false) do { let streams = try await youtube.streams XCTAssert(streams.count > 0) @@ -135,6 +142,7 @@ final class YouTubeKitTests: XCTestCase { func testSampleVideo6() async { let youtube = YouTube(videoID: "jmIpv3ftEVM") + await XCTAssertLiveContent(youtube, false) do { let streams = try await youtube.streams XCTAssert(streams.count > 0) @@ -153,6 +161,7 @@ final class YouTubeKitTests: XCTestCase { func testSampleVideoAgeRestricted() async { let youtube = YouTube(videoID: "HtVdAasjOgU") // EX_8ZjT2sO4 + await XCTAssertLiveContent(youtube, false) do { let streams = try await youtube.streams XCTAssert(streams.count > 0) @@ -174,6 +183,7 @@ final class YouTubeKitTests: XCTestCase { func testSampleVideoAgeRestrictedRemote() async { let youtube = YouTube(videoID: "HtVdAasjOgU", methods: [.remote]) + await XCTAssertLiveContent(youtube, false) do { let streams = try await youtube.streams XCTAssert(streams.count > 0) @@ -193,6 +203,7 @@ final class YouTubeKitTests: XCTestCase { func testSampleVideoMadeForKids() async { let youtube = YouTube(videoID: "GObpYg_NjLQ") // "Made for kids" video + await XCTAssertLiveContent(youtube, false) do { let streams = try await youtube.streams XCTAssert(streams.count > 0) @@ -211,6 +222,7 @@ final class YouTubeKitTests: XCTestCase { func testSampleVideoMadeForKidsRemote() async { let youtube = YouTube(videoID: "GObpYg_NjLQ", methods: [.remote]) // "Made for kids" video + await XCTAssertLiveContent(youtube, false) do { let streams = try await youtube.streams XCTAssert(streams.count > 0) @@ -229,6 +241,7 @@ final class YouTubeKitTests: XCTestCase { func testLivestreamHlsManifestUrl() async { let youtube = YouTube(videoID: "tj4knR4r1UU") + await XCTAssertLiveContent(youtube, true) do { let livestreams = try await youtube.livestreams XCTAssert(livestreams.count > 0) @@ -242,6 +255,7 @@ final class YouTubeKitTests: XCTestCase { func testLivestreamHlsManifestUrlRemote() async { let youtube = YouTube(videoID: "tj4knR4r1UU", methods: [.remote]) + await XCTAssertLiveContent(youtube, true) do { let livestreams = try await youtube.livestreams XCTAssert(livestreams.count > 0) @@ -255,6 +269,7 @@ final class YouTubeKitTests: XCTestCase { func testRemoteExtraction() async { let youtube = YouTube(videoID: "dQw4w9WgXcQ", methods: [.remote]) + await XCTAssertLiveContent(youtube, false) do { let streams = try await youtube.streams XCTAssert(streams.count > 0) @@ -276,10 +291,12 @@ final class YouTubeKitTests: XCTestCase { func testMetadataForOnDemand() async { let youtube = YouTube(videoID: "ApM_KEr1ktQ") + await XCTAssertLiveContent(youtube, false) do { let metadata = try await youtube.metadata! XCTAssertEqual(metadata.title, "Le Maroc Vu du Ciel (Documentaire de Yann Arthus-Bertrand)") XCTAssertFalse(metadata.description.isEmpty) + XCTAssertEqual(metadata.duration, (60+19)*60+34) XCTAssert([URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/sddefault.jpg")!, URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/hqdefault.jpg")!, URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/hq720.jpg")!].contains(metadata.thumbnail!.url)) } catch let error { XCTFail("did throw error: \(error)") @@ -288,10 +305,12 @@ final class YouTubeKitTests: XCTestCase { func testMetadataForOnDemandWithRemote() async { let youtube = YouTube(videoID: "ApM_KEr1ktQ", methods: [.remote]) + await XCTAssertLiveContent(youtube, false) do { let metadata = try await youtube.metadata! XCTAssertEqual(metadata.title, "Le Maroc Vu du Ciel (Documentaire de Yann Arthus-Bertrand)") XCTAssertFalse(metadata.description.isEmpty) + XCTAssertEqual(metadata.duration, (60+19)*60+34) XCTAssert([URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/sddefault.jpg")!, URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/hqdefault.jpg")!, URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/hq720.jpg")!].contains(metadata.thumbnail!.url)) } catch let error { XCTFail("did throw error: \(error)") @@ -300,6 +319,7 @@ final class YouTubeKitTests: XCTestCase { func testMetadataForLive() async { let youtube = YouTube(videoID: "LuKwFajn37U") + await XCTAssertLiveContent(youtube, true) do { let metadata = try await youtube.metadata! XCTAssertEqual(metadata.title, "DW News livestream | Headline news from around the world")