From c82335e8b86b53c2b192a84df72254a19d2aeb27 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 18 Oct 2024 00:33:38 +0200 Subject: [PATCH 01/24] first version of auto-fused player item --- Sources/YouTubeKit/YouTube+PlayerItem.swift | 46 ++++++++++++++++++++ Tests/YouTubeKitTests/PlayabilityTests.swift | 18 ++++++++ 2 files changed, 64 insertions(+) create mode 100644 Sources/YouTubeKit/YouTube+PlayerItem.swift diff --git a/Sources/YouTubeKit/YouTube+PlayerItem.swift b/Sources/YouTubeKit/YouTube+PlayerItem.swift new file mode 100644 index 0000000..6b3fc7b --- /dev/null +++ b/Sources/YouTubeKit/YouTube+PlayerItem.swift @@ -0,0 +1,46 @@ +// +// YouTube+PlayerItem.swift +// YouTubeKit +// +// Created by Alexander Eichhorn on 15.10.2024. +// + +import Foundation +import AVFoundation + +@available(iOS 13.0, watchOS 6.0, tvOS 13.0, macOS 10.15, *) +extension YouTube { + + /// Returns `AVPlayerItem` for the highest resolution stream that is natively playable with potentially audio and video automatically combined. + /// - Parameter maxResolution: The maximum resolution of the video stream. If `nil`, the highest resolution stream is used. + @MainActor + @available(iOS 15.0, watchOS 8.0, tvOS 15.0, macOS 12.0, *) + public func playerItem(maxResolution: Int? = nil) async throws -> AVPlayerItem { + let streams = try await streams + + let composition = AVMutableComposition() + + guard let videoStream = streams.filter({ $0.isNativelyPlayable }).filterVideoOnly().filter(byResolution: { ($0 ?? .max) <= (maxResolution ?? .max) }).highestResolutionStream(), + let audioStream = streams.filter({ $0.isNativelyPlayable }).filterAudioOnly().highestAudioBitrateStream() else { + throw YouTubeKitError.extractError + } + + // Add video track + let videoAsset = AVURLAsset(url: videoStream.url) + let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) + let videoAssetTrack = try await videoAsset.loadTracks(withMediaType: .video).first + let videoTimeRange = try await videoAsset.load(.duration) + try videoTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: videoTimeRange), of: videoAssetTrack!, at: .zero) + + // Add audio track + let audioAsset = AVURLAsset(url: audioStream.url) + let audioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) + let audioAssetTrack = try await audioAsset.load(.tracks).first { $0.mediaType == .audio } + let audioTimeRange = try await audioAsset.load(.duration) + try audioTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: audioTimeRange), of: audioAssetTrack!, at: .zero) + + let playerItem = AVPlayerItem(asset: composition) + return playerItem + } + +} diff --git a/Tests/YouTubeKitTests/PlayabilityTests.swift b/Tests/YouTubeKitTests/PlayabilityTests.swift index 6121ec6..5c8b5b0 100644 --- a/Tests/YouTubeKitTests/PlayabilityTests.swift +++ b/Tests/YouTubeKitTests/PlayabilityTests.swift @@ -60,4 +60,22 @@ final class PlayabilityTests: XCTestCase { } } + + func testAutoCombinedPlayerItemPlayability() async throws { + + let videoID = "njX2bu-_Vw4" + let youtubeLocal = YouTube(videoID: videoID, methods: [.local]) + let youtubeRemote = YouTube(videoID: videoID, methods: [.remote]) + + let localPlayerItem = try await youtubeLocal.playerItem() + let remotePlayerItem = try await youtubeRemote.playerItem() + + let localIsPlayable = try await localPlayerItem.asset.load(.isPlayable) + let remoteIsPlayable = try await remotePlayerItem.asset.load(.isPlayable) + + XCTAssert(localIsPlayable, "Local player item should be playable") + XCTAssert(remoteIsPlayable, "Remote player item should be playable") + + } + } From fe9372e71f4e62ec16a347861120cc5ee6e86b5f Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 18 Oct 2024 00:41:15 +0200 Subject: [PATCH 02/24] YouTube+PlayerItem: prefer using already combined stream urls for generating player items --- Sources/YouTubeKit/YouTube+PlayerItem.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/YouTubeKit/YouTube+PlayerItem.swift b/Sources/YouTubeKit/YouTube+PlayerItem.swift index 6b3fc7b..91a5d3d 100644 --- a/Sources/YouTubeKit/YouTube+PlayerItem.swift +++ b/Sources/YouTubeKit/YouTube+PlayerItem.swift @@ -7,6 +7,7 @@ import Foundation import AVFoundation +import os.log @available(iOS 13.0, watchOS 6.0, tvOS 13.0, macOS 10.15, *) extension YouTube { @@ -25,6 +26,14 @@ extension YouTube { throw YouTubeKitError.extractError } + // prefer already combined streams if available + if let bestCombinedStream = streams.filter({ $0.isNativelyPlayable }).filterVideoAndAudio().filter(byResolution: { ($0 ?? .max) <= (maxResolution ?? .max) }).highestResolutionStream() { + if (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)) + } + } + // Add video track let videoAsset = AVURLAsset(url: videoStream.url) let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) From bc1b3caf36668e71dbb8814d51226c1317ff2f74 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 18 Oct 2024 00:46:24 +0200 Subject: [PATCH 03/24] updated comment --- Sources/YouTubeKit/YouTube+PlayerItem.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/YouTubeKit/YouTube+PlayerItem.swift b/Sources/YouTubeKit/YouTube+PlayerItem.swift index 91a5d3d..dc41125 100644 --- a/Sources/YouTubeKit/YouTube+PlayerItem.swift +++ b/Sources/YouTubeKit/YouTube+PlayerItem.swift @@ -12,7 +12,8 @@ import os.log @available(iOS 13.0, watchOS 6.0, tvOS 13.0, macOS 10.15, *) extension YouTube { - /// Returns `AVPlayerItem` for the highest resolution stream that is natively playable with potentially audio and video automatically combined. + /// 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. @MainActor @available(iOS 15.0, watchOS 8.0, tvOS 15.0, macOS 12.0, *) From 09202bd9159b7fb1e539acee00ebe3a4949cbb01 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 18 Oct 2024 00:59:35 +0200 Subject: [PATCH 04/24] added video duration in seconds as metadata --- Sources/YouTubeKit/InnerTube.swift | 1 + Sources/YouTubeKit/Models/YouTubeMetadata.swift | 4 ++++ Tests/YouTubeKitTests/YouTubeKitTests.swift | 2 ++ 3 files changed, 7 insertions(+) diff --git a/Sources/YouTubeKit/InnerTube.swift b/Sources/YouTubeKit/InnerTube.swift index c50bab7..99e6f96 100644 --- a/Sources/YouTubeKit/InnerTube.swift +++ b/Sources/YouTubeKit/InnerTube.swift @@ -165,6 +165,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/YouTubeMetadata.swift b/Sources/YouTubeKit/Models/YouTubeMetadata.swift index 9d2bd5c..a67a77c 100644 --- a/Sources/YouTubeKit/Models/YouTubeMetadata.swift +++ b/Sources/YouTubeKit/Models/YouTubeMetadata.swift @@ -15,6 +15,9 @@ public struct YouTubeMetadata { /// 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 { YouTubeMetadata( title: videoDetails.title, description: videoDetails.shortDescription, + duration: TimeInterval(videoDetails.lengthSeconds) ?? 0, thumbnail: videoDetails.thumbnail.thumbnails.map { YouTubeMetadata.Thumbnail(url: $0.url) }.last ) } diff --git a/Tests/YouTubeKitTests/YouTubeKitTests.swift b/Tests/YouTubeKitTests/YouTubeKitTests.swift index 19f9ec8..c80bcd3 100644 --- a/Tests/YouTubeKitTests/YouTubeKitTests.swift +++ b/Tests/YouTubeKitTests/YouTubeKitTests.swift @@ -220,6 +220,7 @@ final class YouTubeKitTests: XCTestCase { 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) XCTAssertEqual(metadata.thumbnail!.url, URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/sddefault.jpg")) } catch let error { XCTFail("did throw error: \(error)") @@ -232,6 +233,7 @@ final class YouTubeKitTests: XCTestCase { 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) XCTAssertEqual(metadata.thumbnail!.url, URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/sddefault.jpg")) } catch let error { XCTFail("did throw error: \(error)") From f3126a64e750d8cd1879e331261e7ad9f60a5ba0 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 18 Oct 2024 01:08:13 +0200 Subject: [PATCH 05/24] YouTube+PlayerItem: using metadata video duration to limit playeritem video duration --- Sources/YouTubeKit/YouTube+PlayerItem.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/YouTubeKit/YouTube+PlayerItem.swift b/Sources/YouTubeKit/YouTube+PlayerItem.swift index dc41125..3b31a8b 100644 --- a/Sources/YouTubeKit/YouTube+PlayerItem.swift +++ b/Sources/YouTubeKit/YouTube+PlayerItem.swift @@ -34,20 +34,26 @@ extension YouTube { return AVPlayerItem(asset: AVURLAsset(url: bestCombinedStream.url)) } } + + let videoDuration = try await metadata?.duration + let videoDurationTime = videoDuration.map { CMTime(value: CMTimeValue($0 * 1000), timescale: 1000) } // Add video track let videoAsset = AVURLAsset(url: videoStream.url) let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) let videoAssetTrack = try await videoAsset.loadTracks(withMediaType: .video).first - let videoTimeRange = try await videoAsset.load(.duration) + let videoTimeRange = if let videoDurationTime { + videoDurationTime + } else { + try await videoAsset.load(.duration) + } try videoTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: videoTimeRange), of: videoAssetTrack!, at: .zero) // Add audio track let audioAsset = AVURLAsset(url: audioStream.url) let audioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) let audioAssetTrack = try await audioAsset.load(.tracks).first { $0.mediaType == .audio } - let audioTimeRange = try await audioAsset.load(.duration) - try audioTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: audioTimeRange), of: audioAssetTrack!, at: .zero) + try audioTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: videoTimeRange), of: audioAssetTrack!, at: .zero) let playerItem = AVPlayerItem(asset: composition) return playerItem From 499e030727019ddcfb842c88d80c8a055d82aa9c Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 18 Oct 2024 01:26:05 +0200 Subject: [PATCH 06/24] YouTube+PlayerItem: speed improvement when loading playerItem directly --- Sources/YouTubeKit/YouTube+PlayerItem.swift | 23 ++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/Sources/YouTubeKit/YouTube+PlayerItem.swift b/Sources/YouTubeKit/YouTube+PlayerItem.swift index 3b31a8b..a7f6a45 100644 --- a/Sources/YouTubeKit/YouTube+PlayerItem.swift +++ b/Sources/YouTubeKit/YouTube+PlayerItem.swift @@ -38,22 +38,31 @@ extension YouTube { let videoDuration = try await metadata?.duration let videoDurationTime = videoDuration.map { CMTime(value: CMTimeValue($0 * 1000), timescale: 1000) } - // Add video track + // 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 let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) - let videoAssetTrack = try await videoAsset.loadTracks(withMediaType: .video).first let videoTimeRange = if let videoDurationTime { videoDurationTime } else { try await videoAsset.load(.duration) } - try videoTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: videoTimeRange), of: videoAssetTrack!, at: .zero) + try videoTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: videoTimeRange), of: videoAssetTrack, at: .zero) - // Add audio track - let audioAsset = AVURLAsset(url: audioStream.url) + // add audio track let audioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) - let audioAssetTrack = try await audioAsset.load(.tracks).first { $0.mediaType == .audio } - try audioTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: videoTimeRange), of: audioAssetTrack!, at: .zero) + try audioTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: videoTimeRange), of: audioAssetTrack, at: .zero) let playerItem = AVPlayerItem(asset: composition) return playerItem From ae7d9ef1671202ff075bca9695323d4d44c7bb8d Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sat, 19 Oct 2024 13:15:27 +0200 Subject: [PATCH 07/24] fixed playerItem when handling hls streams from remote method --- Sources/YouTubeKit/YouTube+PlayerItem.swift | 2 +- Tests/YouTubeKitTests/PlayabilityTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/YouTubeKit/YouTube+PlayerItem.swift b/Sources/YouTubeKit/YouTube+PlayerItem.swift index a7f6a45..c39d0ca 100644 --- a/Sources/YouTubeKit/YouTube+PlayerItem.swift +++ b/Sources/YouTubeKit/YouTube+PlayerItem.swift @@ -22,7 +22,7 @@ extension YouTube { let composition = AVMutableComposition() - guard let videoStream = streams.filter({ $0.isNativelyPlayable }).filterVideoOnly().filter(byResolution: { ($0 ?? .max) <= (maxResolution ?? .max) }).highestResolutionStream(), + guard let videoStream = streams.filter({ $0.isNativelyPlayable && $0.url.pathExtension != "m3u8" }).filterVideoOnly().filter(byResolution: { ($0 ?? .max) <= (maxResolution ?? .max) }).highestResolutionStream(), let audioStream = streams.filter({ $0.isNativelyPlayable }).filterAudioOnly().highestAudioBitrateStream() else { throw YouTubeKitError.extractError } diff --git a/Tests/YouTubeKitTests/PlayabilityTests.swift b/Tests/YouTubeKitTests/PlayabilityTests.swift index 5c8b5b0..7d6b967 100644 --- a/Tests/YouTubeKitTests/PlayabilityTests.swift +++ b/Tests/YouTubeKitTests/PlayabilityTests.swift @@ -63,7 +63,7 @@ final class PlayabilityTests: XCTestCase { func testAutoCombinedPlayerItemPlayability() async throws { - let videoID = "njX2bu-_Vw4" + let videoID = "Slj4-Sv-YNA" let youtubeLocal = YouTube(videoID: videoID, methods: [.local]) let youtubeRemote = YouTube(videoID: videoID, methods: [.remote]) From bdf6f5bcac914a348f3c18540e0fffca29b5581e Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 10:57:25 +0200 Subject: [PATCH 08/24] add width and height fields to stream (including remote streams) --- Sources/YouTubeKit/Models/Stream.swift | 7 +++++++ Sources/YouTubeKit/Remote/Models/RemoteStream.swift | 2 ++ 2 files changed, 9 insertions(+) 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/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? } From 6d563d0ed03ba9fe9538d352149454e1b5126d36 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 19:51:44 +0200 Subject: [PATCH 09/24] speed up player items with HLS --- Sources/YouTubeKit/Errors.swift | 1 + Sources/YouTubeKit/HLSPlayerItem.swift | 305 ++++++++++++++++++++ Sources/YouTubeKit/YouTube+PlayerItem.swift | 73 +++-- Tests/YouTubeKitTests/PlayerItemTests.swift | 40 +++ 4 files changed, 402 insertions(+), 17 deletions(-) create mode 100644 Sources/YouTubeKit/HLSPlayerItem.swift create mode 100644 Tests/YouTubeKitTests/PlayerItemTests.swift 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/HLSPlayerItem.swift b/Sources/YouTubeKit/HLSPlayerItem.swift new file mode 100644 index 0000000..b586c86 --- /dev/null +++ b/Sources/YouTubeKit/HLSPlayerItem.swift @@ -0,0 +1,305 @@ +// +// HLSPlayerItem.swift +// YouTubeKit +// + +import AVFoundation +import Foundation + +@available(iOS 15.0, watchOS 8.0, tvOS 15.0, macOS 12.0, *) +func makeHLSPlayerItem(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 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 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 + } +} + +private 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." + } + } +} + +private 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) + } +} diff --git a/Sources/YouTubeKit/YouTube+PlayerItem.swift b/Sources/YouTubeKit/YouTube+PlayerItem.swift index c39d0ca..f8d1d7c 100644 --- a/Sources/YouTubeKit/YouTube+PlayerItem.swift +++ b/Sources/YouTubeKit/YouTube+PlayerItem.swift @@ -15,28 +15,65 @@ 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. - @MainActor @available(iOS 15.0, watchOS 8.0, tvOS 15.0, macOS 12.0, *) public func playerItem(maxResolution: Int? = nil) async throws -> AVPlayerItem { let streams = try await streams + let nativelyPlayableStreams = streams.filter(\.isNativelyPlayable) - let composition = AVMutableComposition() - - guard let videoStream = streams.filter({ $0.isNativelyPlayable && $0.url.pathExtension != "m3u8" }).filterVideoOnly().filter(byResolution: { ($0 ?? .max) <= (maxResolution ?? .max) }).highestResolutionStream(), - let audioStream = streams.filter({ $0.isNativelyPlayable }).filterAudioOnly().highestAudioBitrateStream() else { - throw YouTubeKitError.extractError - } + 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 = streams.filter({ $0.isNativelyPlayable }).filterVideoAndAudio().filter(byResolution: { ($0 ?? .max) <= (maxResolution ?? .max) }).highestResolutionStream() { - if (bestCombinedStream.videoResolution ?? 0) >= (videoStream.videoResolution ?? 0) { + 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 videoStream.fileExtension == .mp4, + audioStream.fileExtension == .m4a, + let videoDuration { + do { + return try await makeHLSPlayerItem( + 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) + } + } + + 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 videoDuration = try await metadata?.duration - let videoDurationTime = videoDuration.map { CMTime(value: CMTimeValue($0 * 1000), timescale: 1000) } + let composition = AVMutableComposition() // prepare video track let videoAsset = AVURLAsset(url: videoStream.url) @@ -52,17 +89,19 @@ extension YouTube { } // add video track - let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) - let videoTimeRange = if let videoDurationTime { - videoDurationTime + 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) + try videoTrack.insertTimeRange(CMTimeRange(start: .zero, duration: videoTimeRange), of: videoAssetTrack, at: .zero) // add audio track - let audioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) - try audioTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: videoTimeRange), of: audioAssetTrack, at: .zero) + try audioTrack.insertTimeRange(CMTimeRange(start: .zero, duration: videoTimeRange), of: audioAssetTrack, at: .zero) let playerItem = AVPlayerItem(asset: composition) return playerItem diff --git a/Tests/YouTubeKitTests/PlayerItemTests.swift b/Tests/YouTubeKitTests/PlayerItemTests.swift new file mode 100644 index 0000000..f156d28 --- /dev/null +++ b/Tests/YouTubeKitTests/PlayerItemTests.swift @@ -0,0 +1,40 @@ +import AVFoundation +import Testing +@testable import YouTubeKit + +struct PlayerItemTests { + + @available(iOS 16.0, watchOS 9.0, tvOS 16.0, macOS 13.0, *) + @Test("Player item uses HLS and plays") + @MainActor + func playerItemUsesHLSAndPlays() async throws { + let start = Date() + let item = try await YouTube(videoID: "Slj4-Sv-YNA", methods: [.local]).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") + } + +} From e00c2a0fe29fee2d331e31d1b7d9cf2c371a88f3 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 19:56:53 +0200 Subject: [PATCH 10/24] move player item files into folder --- Sources/YouTubeKit/{ => PlayerItem}/HLSPlayerItem.swift | 0 Sources/YouTubeKit/{ => PlayerItem}/YouTube+PlayerItem.swift | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename Sources/YouTubeKit/{ => PlayerItem}/HLSPlayerItem.swift (100%) rename Sources/YouTubeKit/{ => PlayerItem}/YouTube+PlayerItem.swift (100%) diff --git a/Sources/YouTubeKit/HLSPlayerItem.swift b/Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift similarity index 100% rename from Sources/YouTubeKit/HLSPlayerItem.swift rename to Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift diff --git a/Sources/YouTubeKit/YouTube+PlayerItem.swift b/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift similarity index 100% rename from Sources/YouTubeKit/YouTube+PlayerItem.swift rename to Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift From 9670c70edacb7a973b073eb33ec771428df46157 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 20:04:47 +0200 Subject: [PATCH 11/24] namespace HLS player item creation --- .../YouTubeKit/PlayerItem/HLSPlayerItem.swift | 112 +++++++++--------- .../PlayerItem/YouTube+PlayerItem.swift | 2 +- 2 files changed, 58 insertions(+), 56 deletions(-) diff --git a/Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift b/Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift index b586c86..92d1a8f 100644 --- a/Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift +++ b/Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift @@ -6,65 +6,67 @@ import AVFoundation import Foundation -@available(iOS 15.0, watchOS 8.0, tvOS 15.0, macOS 12.0, *) -func makeHLSPlayerItem(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 { - "" +enum HLSPlayerItem { + + @available(iOS 15.0, watchOS 8.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 } - 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 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: 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 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 + 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 + } } } diff --git a/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift b/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift index f8d1d7c..4427ef7 100644 --- a/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift +++ b/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift @@ -50,7 +50,7 @@ extension YouTube { audioStream.fileExtension == .m4a, let videoDuration { do { - return try await makeHLSPlayerItem( + return try await HLSPlayerItem.make( videoStream: videoStream, audioStream: audioStream, duration: videoDuration From 160556015532b19216bcca662579a907437ed418 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 20:23:12 +0200 Subject: [PATCH 12/24] split HLS player item implementation --- .../YouTubeKit/PlayerItem/HLSPlayerItem.swift | 224 +----------------- .../YouTubeKit/PlayerItem/HLSURLAsset.swift | 59 +++++ Sources/YouTubeKit/PlayerItem/MP4Index.swift | 176 ++++++++++++++ 3 files changed, 236 insertions(+), 223 deletions(-) create mode 100644 Sources/YouTubeKit/PlayerItem/HLSURLAsset.swift create mode 100644 Sources/YouTubeKit/PlayerItem/MP4Index.swift diff --git a/Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift b/Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift index 92d1a8f..792c036 100644 --- a/Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift +++ b/Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift @@ -70,7 +70,7 @@ enum HLSPlayerItem { } } -private enum HLSPlayerItemError: LocalizedError { +enum HLSPlayerItemError: LocalizedError { case invalidResponse case malformedIndex case unsupportedCodec @@ -83,225 +83,3 @@ private enum HLSPlayerItemError: LocalizedError { } } } - -private 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) - } -} diff --git a/Sources/YouTubeKit/PlayerItem/HLSURLAsset.swift b/Sources/YouTubeKit/PlayerItem/HLSURLAsset.swift new file mode 100644 index 0000000..ff38cda --- /dev/null +++ b/Sources/YouTubeKit/PlayerItem/HLSURLAsset.swift @@ -0,0 +1,59 @@ +// +// HLSURLAsset.swift +// YouTubeKit +// + +import AVFoundation +import Foundation + +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) + } +} From 3a498eb8e9f12a43684132100684ade546228a34 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 22:54:32 +0200 Subject: [PATCH 13/24] document player item usage --- README.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) 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. From 24d969500d30fd9457fbbec0b8833bf59fbd8936 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Thu, 20 Aug 2026 00:41:17 +0200 Subject: [PATCH 14/24] exclude HLS player item from watchOS --- Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift | 4 +++- Sources/YouTubeKit/PlayerItem/HLSURLAsset.swift | 2 ++ Sources/YouTubeKit/PlayerItem/MP4Index.swift | 4 +++- Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift | 2 ++ Tests/YouTubeKitTests/PlayerItemTests.swift | 4 +++- 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift b/Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift index 792c036..e31d1a0 100644 --- a/Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift +++ b/Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift @@ -6,9 +6,10 @@ import AVFoundation import Foundation +#if !os(watchOS) enum HLSPlayerItem { - @available(iOS 15.0, watchOS 8.0, tvOS 15.0, macOS 12.0, *) + @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) @@ -83,3 +84,4 @@ enum HLSPlayerItemError: LocalizedError { } } } +#endif diff --git a/Sources/YouTubeKit/PlayerItem/HLSURLAsset.swift b/Sources/YouTubeKit/PlayerItem/HLSURLAsset.swift index ff38cda..51aff4b 100644 --- a/Sources/YouTubeKit/PlayerItem/HLSURLAsset.swift +++ b/Sources/YouTubeKit/PlayerItem/HLSURLAsset.swift @@ -6,6 +6,7 @@ import AVFoundation import Foundation +#if !os(watchOS) final class HLSURLAsset: AVURLAsset, @unchecked Sendable { private let playlistLoader: HLSPlaylistResourceLoader @@ -57,3 +58,4 @@ private final class HLSPlaylistResourceLoader: NSObject, AVAssetResourceLoaderDe return true } } +#endif diff --git a/Sources/YouTubeKit/PlayerItem/MP4Index.swift b/Sources/YouTubeKit/PlayerItem/MP4Index.swift index fac93bc..3d549af 100644 --- a/Sources/YouTubeKit/PlayerItem/MP4Index.swift +++ b/Sources/YouTubeKit/PlayerItem/MP4Index.swift @@ -5,7 +5,8 @@ import Foundation -@available(iOS 15.0, watchOS 8.0, tvOS 15.0, macOS 12.0, *) +#if !os(watchOS) +@available(iOS 15.0, tvOS 15.0, macOS 12.0, *) struct MP4Index { struct Segment { let offset: UInt64 @@ -174,3 +175,4 @@ private extension Data { 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 index 4427ef7..92aa8d5 100644 --- a/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift +++ b/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift @@ -46,6 +46,7 @@ extension YouTube { 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 { @@ -61,6 +62,7 @@ extension YouTube { 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, diff --git a/Tests/YouTubeKitTests/PlayerItemTests.swift b/Tests/YouTubeKitTests/PlayerItemTests.swift index f156d28..d6943a5 100644 --- a/Tests/YouTubeKitTests/PlayerItemTests.swift +++ b/Tests/YouTubeKitTests/PlayerItemTests.swift @@ -2,9 +2,10 @@ import AVFoundation import Testing @testable import YouTubeKit +#if !os(watchOS) struct PlayerItemTests { - @available(iOS 16.0, watchOS 9.0, tvOS 16.0, macOS 13.0, *) + @available(iOS 16.0, tvOS 16.0, macOS 13.0, *) @Test("Player item uses HLS and plays") @MainActor func playerItemUsesHLSAndPlays() async throws { @@ -38,3 +39,4 @@ struct PlayerItemTests { } } +#endif From 4ac4dc32bd8466d8503e3ce018f79757b87e89c7 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 28 Aug 2026 15:40:26 +0200 Subject: [PATCH 15/24] Test for playerItem() for livestream urls --- Tests/YouTubeKitTests/PlayerItemTests.swift | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Tests/YouTubeKitTests/PlayerItemTests.swift b/Tests/YouTubeKitTests/PlayerItemTests.swift index d6943a5..c85965d 100644 --- a/Tests/YouTubeKitTests/PlayerItemTests.swift +++ b/Tests/YouTubeKitTests/PlayerItemTests.swift @@ -38,5 +38,29 @@ struct PlayerItemTests { 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") + @MainActor + func livestreamPlayerItemUsesNativeHLSAndPlays() async throws { + let item = try await YouTube(videoID: "tj4knR4r1UU", methods: [.local]).playerItem() + 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 From 0ae2571acfa6c72329891af54fb4097834be6a6f Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 28 Aug 2026 16:53:53 +0200 Subject: [PATCH 16/24] Support livestream player items --- .../YouTubeKit/PlayerItem/YouTube+PlayerItem.swift | 4 ++++ Sources/YouTubeKit/YouTube.swift | 11 +++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift b/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift index 92aa8d5..e6e1311 100644 --- a/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift +++ b/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift @@ -17,6 +17,10 @@ extension YouTube { /// - 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 { + if try await isLiveContent, let livestream = try await livestreams.first { + return AVPlayerItem(url: livestream.url) + } + let streams = try await streams let nativelyPlayableStreams = streams.filter(\.isNativelyPlayable) 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 From c8ea64e6173cc99b1d0715e7c38a227bd88433ee Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 28 Aug 2026 16:58:28 +0200 Subject: [PATCH 17/24] Handle livestream check failures --- Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift b/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift index e6e1311..f30f15a 100644 --- a/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift +++ b/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift @@ -17,7 +17,9 @@ extension YouTube { /// - 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 { - if try await isLiveContent, let livestream = try await livestreams.first { + // For livestreams: use the direct url + let isLiveContent = (try? await isLiveContent) ?? false + if isLiveContent, let livestream = try await livestreams.first { return AVPlayerItem(url: livestream.url) } From becdf89693668c7be61bc9565a5e242198ff89ed Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 28 Aug 2026 17:32:24 +0200 Subject: [PATCH 18/24] Test player items with local and remote methods --- Tests/YouTubeKitTests/PlayerItemTests.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Tests/YouTubeKitTests/PlayerItemTests.swift b/Tests/YouTubeKitTests/PlayerItemTests.swift index c85965d..b4d1600 100644 --- a/Tests/YouTubeKitTests/PlayerItemTests.swift +++ b/Tests/YouTubeKitTests/PlayerItemTests.swift @@ -6,11 +6,14 @@ import Testing struct PlayerItemTests { @available(iOS 16.0, tvOS 16.0, macOS 13.0, *) - @Test("Player item uses HLS and plays") + @Test( + "Player item uses HLS and plays", + arguments: [YouTube.ExtractionMethod.local, .remote] + ) @MainActor - func playerItemUsesHLSAndPlays() async throws { + func playerItemUsesHLSAndPlays(method: YouTube.ExtractionMethod) async throws { let start = Date() - let item = try await YouTube(videoID: "Slj4-Sv-YNA", methods: [.local]).playerItem() + let item = try await YouTube(videoID: "Slj4-Sv-YNA", methods: [method]).playerItem() let asset = try #require(item.asset as? AVURLAsset) #expect(asset.url.scheme == "youtubekit-hls") @@ -39,10 +42,13 @@ struct PlayerItemTests { } @available(iOS 16.0, tvOS 16.0, macOS 13.0, *) - @Test("Livestream player item uses native HLS and plays") + @Test( + "Livestream player item uses native HLS and plays", + arguments: [YouTube.ExtractionMethod.local, .remote] + ) @MainActor - func livestreamPlayerItemUsesNativeHLSAndPlays() async throws { - let item = try await YouTube(videoID: "tj4knR4r1UU", methods: [.local]).playerItem() + func livestreamPlayerItemUsesNativeHLSAndPlays(method: YouTube.ExtractionMethod) async throws { + let item = try await YouTube(videoID: "tj4knR4r1UU", methods: [method]).playerItem() let asset = try #require(item.asset as? AVURLAsset) #expect(asset.url.absoluteString.contains(".m3u8")) From c54c9f6adfec03f5ad767d960746b871e37fea4f Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 28 Aug 2026 18:57:29 +0200 Subject: [PATCH 19/24] Test live content detection --- Tests/YouTubeKitTests/Extensions.swift | 10 ++++++++++ Tests/YouTubeKitTests/PlayabilityTests.swift | 6 ++++++ Tests/YouTubeKitTests/PlayerItemTests.swift | 8 ++++++-- Tests/YouTubeKitTests/YouTubeKitTests.swift | 18 ++++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/Tests/YouTubeKitTests/Extensions.swift b/Tests/YouTubeKitTests/Extensions.swift index ca351c1..e7b6ada 100644 --- a/Tests/YouTubeKitTests/Extensions.swift +++ b/Tests/YouTubeKitTests/Extensions.swift @@ -22,3 +22,13 @@ 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) + } +} diff --git a/Tests/YouTubeKitTests/PlayabilityTests.swift b/Tests/YouTubeKitTests/PlayabilityTests.swift index 1aea529..0258325 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) @@ -80,6 +84,8 @@ final class PlayabilityTests: XCTestCase { let videoID = "Slj4-Sv-YNA" let youtubeLocal = YouTube(videoID: videoID, methods: [.local]) let youtubeRemote = YouTube(videoID: videoID, methods: [.remote]) + await XCTAssertLiveContent(youtubeLocal, false) + await XCTAssertLiveContent(youtubeRemote, false) let localPlayerItem = try await youtubeLocal.playerItem() let remotePlayerItem = try await youtubeRemote.playerItem() diff --git a/Tests/YouTubeKitTests/PlayerItemTests.swift b/Tests/YouTubeKitTests/PlayerItemTests.swift index b4d1600..e247af5 100644 --- a/Tests/YouTubeKitTests/PlayerItemTests.swift +++ b/Tests/YouTubeKitTests/PlayerItemTests.swift @@ -13,7 +13,9 @@ struct PlayerItemTests { @MainActor func playerItemUsesHLSAndPlays(method: YouTube.ExtractionMethod) async throws { let start = Date() - let item = try await YouTube(videoID: "Slj4-Sv-YNA", methods: [method]).playerItem() + 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") @@ -48,7 +50,9 @@ struct PlayerItemTests { ) @MainActor func livestreamPlayerItemUsesNativeHLSAndPlays(method: YouTube.ExtractionMethod) async throws { - let item = try await YouTube(videoID: "tj4knR4r1UU", methods: [method]).playerItem() + let youtube = YouTube(videoID: "tj4knR4r1UU", methods: [method]) + #expect(try await youtube.isLiveContent == true) + let item = try await youtube.playerItem() let asset = try #require(item.asset as? AVURLAsset) #expect(asset.url.absoluteString.contains(".m3u8")) diff --git a/Tests/YouTubeKitTests/YouTubeKitTests.swift b/Tests/YouTubeKitTests/YouTubeKitTests.swift index 3efcb42..7790f28 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) @@ -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,6 +291,7 @@ 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)") @@ -289,6 +305,7 @@ 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)") @@ -302,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") From b02864107361f9f4d73943f92753b233e1ff0a8c Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 28 Aug 2026 19:03:34 +0200 Subject: [PATCH 20/24] Simplify extraction method test names --- Tests/YouTubeKitTests/PlayerItemTests.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Tests/YouTubeKitTests/PlayerItemTests.swift b/Tests/YouTubeKitTests/PlayerItemTests.swift index e247af5..e8041d9 100644 --- a/Tests/YouTubeKitTests/PlayerItemTests.swift +++ b/Tests/YouTubeKitTests/PlayerItemTests.swift @@ -73,4 +73,13 @@ struct PlayerItemTests { } } + +extension YouTube.ExtractionMethod: CustomTestStringConvertible { + public var testDescription: String { + switch self { + case .local: ".local" + case .remote: ".remote" + } + } +} #endif From f4dd2cdc8f9d29c5ac1139708dddbbc29d86ee84 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 28 Aug 2026 19:04:06 +0200 Subject: [PATCH 21/24] Remove unsafe test print --- Tests/YouTubeKitTests/YouTubeKitTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/YouTubeKitTests/YouTubeKitTests.swift b/Tests/YouTubeKitTests/YouTubeKitTests.swift index 7790f28..e07a5d7 100644 --- a/Tests/YouTubeKitTests/YouTubeKitTests.swift +++ b/Tests/YouTubeKitTests/YouTubeKitTests.swift @@ -84,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) From 42ae4b3d4aed77ea8b348036c7a3b954b4a3ca71 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 28 Aug 2026 19:20:35 +0200 Subject: [PATCH 22/24] Move extraction test names to extensions --- Tests/YouTubeKitTests/Extensions.swift | 12 ++++++++++++ Tests/YouTubeKitTests/PlayerItemTests.swift | 9 --------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Tests/YouTubeKitTests/Extensions.swift b/Tests/YouTubeKitTests/Extensions.swift index e7b6ada..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) @@ -32,3 +33,14 @@ func XCTAssertLiveContent(_ youtube: YouTube, _ expected: Bool, file: StaticStri 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/PlayerItemTests.swift b/Tests/YouTubeKitTests/PlayerItemTests.swift index e8041d9..e247af5 100644 --- a/Tests/YouTubeKitTests/PlayerItemTests.swift +++ b/Tests/YouTubeKitTests/PlayerItemTests.swift @@ -73,13 +73,4 @@ struct PlayerItemTests { } } - -extension YouTube.ExtractionMethod: CustomTestStringConvertible { - public var testDescription: String { - switch self { - case .local: ".local" - case .remote: ".remote" - } - } -} #endif From ef961d6576645f47ea4c5462b7ac1bfa92d1b7fb Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 28 Aug 2026 21:40:29 +0200 Subject: [PATCH 23/24] Compile playability tests for watchOS --- Tests/YouTubeKitTests/PlayabilityTests.swift | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Tests/YouTubeKitTests/PlayabilityTests.swift b/Tests/YouTubeKitTests/PlayabilityTests.swift index 0258325..9bea34b 100644 --- a/Tests/YouTubeKitTests/PlayabilityTests.swift +++ b/Tests/YouTubeKitTests/PlayabilityTests.swift @@ -82,20 +82,22 @@ final class PlayabilityTests: XCTestCase { func testAutoCombinedPlayerItemPlayability() async throws { let videoID = "Slj4-Sv-YNA" + +#if canImport(JavaScriptCore) let youtubeLocal = YouTube(videoID: videoID, methods: [.local]) - let youtubeRemote = YouTube(videoID: videoID, methods: [.remote]) await XCTAssertLiveContent(youtubeLocal, false) - await XCTAssertLiveContent(youtubeRemote, false) - let localPlayerItem = try await youtubeLocal.playerItem() - let remotePlayerItem = try await youtubeRemote.playerItem() - let localIsPlayable = try await localPlayerItem.asset.load(.isPlayable) - let remoteIsPlayable = try await remotePlayerItem.asset.load(.isPlayable) XCTAssert(localIsPlayable, "Local player item should be playable") - XCTAssert(remoteIsPlayable, "Remote 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") } } From 23ee5f685dbf7f5fdb39a471356d2f35c9f8947a Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sat, 29 Aug 2026 19:25:53 +0200 Subject: [PATCH 24/24] Respect livestream max resolution --- Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift | 8 +++++++- Tests/YouTubeKitTests/PlayerItemTests.swift | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift b/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift index f30f15a..1831b61 100644 --- a/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift +++ b/Sources/YouTubeKit/PlayerItem/YouTube+PlayerItem.swift @@ -20,7 +20,13 @@ extension YouTube { // For livestreams: use the direct url let isLiveContent = (try? await isLiveContent) ?? false if isLiveContent, let livestream = try await livestreams.first { - return AVPlayerItem(url: livestream.url) + 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 diff --git a/Tests/YouTubeKitTests/PlayerItemTests.swift b/Tests/YouTubeKitTests/PlayerItemTests.swift index e247af5..5ae1199 100644 --- a/Tests/YouTubeKitTests/PlayerItemTests.swift +++ b/Tests/YouTubeKitTests/PlayerItemTests.swift @@ -52,7 +52,8 @@ struct PlayerItemTests { 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() + 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"))