Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c82335e
first version of auto-fused player item
alexeichhorn Oct 17, 2024
fe9372e
YouTube+PlayerItem: prefer using already combined stream urls for gen…
alexeichhorn Oct 17, 2024
bc1b3ca
updated comment
alexeichhorn Oct 17, 2024
09202bd
added video duration in seconds as metadata
alexeichhorn Oct 17, 2024
f3126a6
YouTube+PlayerItem: using metadata video duration to limit playeritem…
alexeichhorn Oct 17, 2024
499e030
YouTube+PlayerItem: speed improvement when loading playerItem directly
alexeichhorn Oct 17, 2024
ae7d9ef
fixed playerItem when handling hls streams from remote method
alexeichhorn Oct 19, 2024
404b821
Merge main into feature/player-item
alexeichhorn Aug 18, 2026
bdf6f5b
add width and height fields to stream (including remote streams)
alexeichhorn Aug 19, 2026
6d563d0
speed up player items with HLS
alexeichhorn Aug 19, 2026
e00c2a0
move player item files into folder
alexeichhorn Aug 19, 2026
9670c70
namespace HLS player item creation
alexeichhorn Aug 19, 2026
1605560
split HLS player item implementation
alexeichhorn Aug 19, 2026
3a498eb
document player item usage
alexeichhorn Aug 19, 2026
24d9695
exclude HLS player item from watchOS
alexeichhorn Aug 19, 2026
4ac4dc3
Test for playerItem() for livestream urls
alexeichhorn Aug 28, 2026
0ae2571
Support livestream player items
alexeichhorn Aug 28, 2026
c8ea64e
Handle livestream check failures
alexeichhorn Aug 28, 2026
becdf89
Test player items with local and remote methods
alexeichhorn Aug 28, 2026
c54c9f6
Test live content detection
alexeichhorn Aug 28, 2026
b028641
Simplify extraction method test names
alexeichhorn Aug 28, 2026
f4dd2cd
Remove unsafe test print
alexeichhorn Aug 28, 2026
42ae4b3
Move extraction test names to extensions
alexeichhorn Aug 28, 2026
ef961d6
Compile playability tests for watchOS
alexeichhorn Aug 28, 2026
23ee5f6
Respect livestream max resolution
alexeichhorn Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions Sources/YouTubeKit/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public enum YouTubeKitError: String, Error {
case maxRetriesExceeded
case htmlParseError
case extractError
case compositionTrackCreationFailed
case regexMatchError
case videoUnavailable
case videoAgeRestricted
Expand Down
1 change: 1 addition & 0 deletions Sources/YouTubeKit/InnerTube.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ class InnerTube {
let videoId: String
let title: String?
let shortDescription: String?
let lengthSeconds: String?
let thumbnail: Thumbnail

struct Thumbnail: Decodable {
Expand Down
7 changes: 7 additions & 0 deletions Sources/YouTubeKit/Models/Stream.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }
}

Expand All @@ -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`
Expand Down
5 changes: 5 additions & 0 deletions Sources/YouTubeKit/Models/YouTubeMetadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
)
}
Expand All @@ -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
)
}
Expand Down
87 changes: 87 additions & 0 deletions Sources/YouTubeKit/PlayerItem/HLSPlayerItem.swift
Original file line number Diff line number Diff line change
@@ -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
61 changes: 61 additions & 0 deletions Sources/YouTubeKit/PlayerItem/HLSURLAsset.swift
Original file line number Diff line number Diff line change
@@ -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..<end])
}
}

loadingRequest.finishLoading()
return true
}
}
#endif
Loading
Loading