diff --git a/Package.swift b/Package.swift index c30ecf2..468d56c 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.8 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/Sources/YouTubeKit/InnerTube.swift b/Sources/YouTubeKit/InnerTube.swift index d1e741e..b3ef421 100644 --- a/Sources/YouTubeKit/InnerTube.swift +++ b/Sources/YouTubeKit/InnerTube.swift @@ -20,11 +20,14 @@ class InnerTube { var playerParams: String? = nil var androidSdkVersion: Int? = nil + var deviceMake: String? = nil var deviceModel: String? = nil + var osName: String? = nil + var osVersion: String? = nil var context: Context { - let client = Context.ContextClient(clientName: name, clientVersion: version, clientScreen: screen, androidSdkVersion: androidSdkVersion, deviceModel: deviceModel) - let thirdParty = screen == "EMBED" ? Context.ThirdParty(embedUrl: "https://www.youtube.com/") : nil + let client = Context.ContextClient(clientName: name, clientVersion: version, clientScreen: screen, androidSdkVersion: androidSdkVersion, deviceMake: deviceMake, deviceModel: deviceModel, osName: osName, osVersion: osVersion) + let thirdParty = screen == "EMBED" ? Context.ThirdParty(embedUrl: "https://www.reddit.com/") : nil return Context(client: client, thirdParty: thirdParty) } @@ -46,7 +49,10 @@ class InnerTube { let clientVersion: String let clientScreen: String? let androidSdkVersion: Int? + let deviceMake: String? let deviceModel: String? + let osName: String? + let osVersion: String? } struct ThirdParty: Encodable { @@ -56,15 +62,16 @@ class InnerTube { // overview of clients: https://github.com/zerodytrash/YouTube-Internal-Clients private let defaultClients = [ - ClientType.web: Client(name: "WEB", version: "2.20260114.08.00", screen: nil, apiKey: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", internalID: 1, userAgent: "Mozilla/5.0"), + ClientType.web: Client(name: "WEB", version: "2.20260708.00.00", screen: nil, apiKey: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", internalID: 1, userAgent: nil), ClientType.webSafari: Client(name: "WEB", version: "2.20260114.08.00", screen: nil, apiKey: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", internalID: 1, userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Safari/605.1.15,gzip(gfe)"), ClientType.android: Client(name: "ANDROID", version: "20.10.38", screen: nil, apiKey: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w", internalID: 3, userAgent: "com.google.android.youtube/20.10.38 (Linux; U; Android 11) gzip", playerParams: "CgIQBg==", androidSdkVersion: 30), ClientType.androidSdkless: Client(name: "ANDROID", version: "21.02.35", screen: nil, apiKey: "", internalID: 3, userAgent: "com.google.android.youtube/21.02.35 (Linux; U; Android 11) gzip"), ClientType.androidMusic: Client(name: "ANDROID_MUSIC", version: "5.16.51", screen: nil, apiKey: "AIzaSyAOghZGza2MQSZkY_zfZ370N-PUdXEo8AI", internalID: 21, userAgent: "com.google.android.apps.youtube.music/5.16.51 (Linux; U; Android 11) gzip", playerParams: "CgIQBg==", androidSdkVersion: 30), // yt-dlp rolled this client back because newer versions can yield SABR-only streams. ClientType.androidVR: Client(name: "ANDROID_VR", version: "1.65.10", screen: nil, apiKey: "", internalID: 28, userAgent: "com.google.android.apps.youtube.vr.oculus/1.65.10 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip", androidSdkVersion: 32, deviceModel: "Quest 3"), - ClientType.webEmbed: Client(name: "WEB_EMBEDDED_PLAYER", version: "1.20260115.01.00", screen: "EMBED", apiKey: "", internalID: 56, userAgent: "Mozilla/5.0"), - ClientType.webCreator: Client(name: "WEB_CREATOR", version: "1.20250922.03.00", screen: nil, apiKey: "", internalID: 62, userAgent: nil), + ClientType.visionOS: Client(name: "VISIONOS", version: "1.02", screen: nil, apiKey: "", internalID: 101, userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15", deviceMake: "Apple", deviceModel: "RealityDevice17,1", osName: "visionOS", osVersion: "26.5.23O471"), + ClientType.webEmbed: Client(name: "WEB_EMBEDDED_PLAYER", version: "2.20260708.00.00", screen: "EMBED", apiKey: "", internalID: 56, userAgent: nil), + ClientType.webCreator: Client(name: "WEB_CREATOR", version: "1.20260708.06.00", screen: nil, apiKey: "", internalID: 62, userAgent: nil), ClientType.androidEmbed: Client(name: "ANDROID_EMBEDDED_PLAYER", version: "18.11.34", screen: "EMBED", apiKey: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", internalID: 3, userAgent: "com.google.android.youtube/18.11.34 (Linux; U; Android 11) gzip"), ClientType.tv: Client(name: "TVHTML5", version: "7.20250923.13.00", screen: nil, apiKey: "", internalID: 7, userAgent: "Mozilla/5.0 (ChromiumStylePlatform) Cobalt/25.lts.30.1034943-gold (unlike Gecko), Unknown_TV_Unknown_0/Unknown (Unknown, Unknown)"), ClientType.tvEmbed: Client(name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", version: "2.0", screen: "EMBED", apiKey: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", internalID: 85, userAgent: "Mozilla/5.0"), @@ -75,7 +82,7 @@ class InnerTube { ] enum ClientType: String { - case web, webSafari, android, androidSdkless, androidMusic, androidVR, webEmbed, webCreator, androidEmbed, tv, tvEmbed, ios, iosMusic, mediaConnectFrontend, mWeb + case web, webSafari, android, androidSdkless, androidMusic, androidVR, visionOS, webEmbed, webCreator, androidEmbed, tv, tvEmbed, ios, iosMusic, mediaConnectFrontend, mWeb } private var accessToken: String? diff --git a/Sources/YouTubeKit/YouTube.swift b/Sources/YouTubeKit/YouTube.swift index c3935b6..a14c306 100644 --- a/Sources/YouTubeKit/YouTube.swift +++ b/Sources/YouTubeKit/YouTube.swift @@ -120,6 +120,7 @@ public class YouTube { var request = URLRequest(url: embedURL) request.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent") request.setValue("en-US,en", forHTTPHeaderField: "accept-language") + request.setValue("https://www.reddit.com/", forHTTPHeaderField: "Referer") request.httpShouldHandleCookies = false let (data, _) = try await URLSession.shared.data(for: request) _embedHTML = String(data: data, encoding: .utf8) ?? "" @@ -318,6 +319,11 @@ public class YouTube { if !streamingData.isEmpty { return streamingData } else { + if let videoInfo = try? await loadAdditionalVideoInfos(forClient: .webEmbed), let streamingData = videoInfo.streamingData { + _videoInfos = [videoInfo] + return [streamingData] + } + try await bypassAgeGate() let streamingData = try await videoInfos.compactMap { $0.streamingData } if !streamingData.isEmpty { @@ -355,7 +361,7 @@ public class YouTube { let signatureTimestamp = try await signatureTimestamp let ytcfg = try await ytcfg - let innertubeClients: [InnerTube.ClientType] = [.androidVR, .webSafari, .web] + let innertubeClients: [InnerTube.ClientType] = [.visionOS, .web] let results: [Result] = await innertubeClients.concurrentMap { [videoID, useOAuth, allowOAuthCache] client in let innertube = InnerTube(client: client, signatureTimestamp: signatureTimestamp, ytcfg: ytcfg, useOAuth: useOAuth, allowCache: allowOAuthCache) @@ -402,7 +408,11 @@ public class YouTube { private func loadAdditionalVideoInfos(forClient client: InnerTube.ClientType) async throws -> InnerTube.VideoInfo { let signatureTimestamp = try await signatureTimestamp - let ytcfg = try await ytcfg + let ytcfg = if client == .webEmbed { + try await Extraction.extractYtCfg(from: embedHTML) + } else { + try await ytcfg + } let innertube = InnerTube(client: client, signatureTimestamp: signatureTimestamp, ytcfg: ytcfg, useOAuth: useOAuth, allowCache: allowOAuthCache) let videoInfo = try await innertube.player(videoID: videoID) diff --git a/Tests/YouTubeKitTests/YouTubeKitTests.swift b/Tests/YouTubeKitTests/YouTubeKitTests.swift index 5ca6958..ebf9727 100644 --- a/Tests/YouTubeKitTests/YouTubeKitTests.swift +++ b/Tests/YouTubeKitTests/YouTubeKitTests.swift @@ -36,7 +36,6 @@ final class YouTubeKitTests: XCTestCase { XCTAssert(!streams.filterVideoOnly().isEmpty) XCTAssert(!streams.filterAudioOnly().isEmpty) - XCTAssert(!streams.filterVideoAndAudio().isEmpty) XCTAssertHighestResolutionStreamAtLeastHD(streams) XCTAssertEqual(bestAudioStream?.url, bestAudioStreamLegacy?.url) @@ -61,7 +60,6 @@ final class YouTubeKitTests: XCTestCase { XCTAssert(!streams.filterVideoOnly().isEmpty) XCTAssert(!streams.filterAudioOnly().isEmpty) - XCTAssert(!streams.filterVideoAndAudio().isEmpty) XCTAssertHighestResolutionStreamAtLeastHD(streams) try await checkAllStreamReachability(streams) @@ -108,7 +106,6 @@ final class YouTubeKitTests: XCTestCase { XCTAssert(!streams.filterVideoOnly().isEmpty) XCTAssert(!streams.filterAudioOnly().isEmpty) - XCTAssert(!streams.filterVideoAndAudio().isEmpty) XCTAssertHighestResolutionStreamAtLeastHD(streams) try await checkAllStreamReachability(streams) @@ -127,7 +124,6 @@ final class YouTubeKitTests: XCTestCase { XCTAssert(!streams.filterVideoOnly().isEmpty) XCTAssert(!streams.filterAudioOnly().isEmpty) - XCTAssert(!streams.filterVideoAndAudio().isEmpty) XCTAssertHighestResolutionStreamAtLeastHD(streams) try await checkAllStreamReachability(streams) @@ -146,7 +142,6 @@ final class YouTubeKitTests: XCTestCase { XCTAssert(!streams.filterVideoOnly().isEmpty) XCTAssert(!streams.filterAudioOnly().isEmpty) - XCTAssert(!streams.filterVideoAndAudio().isEmpty) XCTAssertHighestResolutionStreamAtLeastHD(streams) try await checkAllStreamReachability(streams) @@ -168,7 +163,25 @@ final class YouTubeKitTests: XCTestCase { XCTAssert(!streams.filterVideoOnly().isEmpty) XCTAssert(!streams.filterAudioOnly().isEmpty) - XCTAssert(!streams.filterVideoAndAudio().isEmpty) + XCTAssertHighestResolutionStreamAtLeastHD(streams) + + try await checkAllStreamReachability(streams) + + } catch let error { + XCTFail("did throw error: \(error)") + } + } + + func testSampleVideoAgeRestrictedRemote() async { + let youtube = YouTube(videoID: "HtVdAasjOgU", methods: [.remote]) + do { + let streams = try await youtube.streams + XCTAssert(streams.count > 0) + checkStreams(streams) + print(streams.count) + + XCTAssert(!streams.filterVideoOnly().isEmpty) + XCTAssert(!streams.filterAudioOnly().isEmpty) XCTAssertHighestResolutionStreamAtLeastHD(streams) try await checkAllStreamReachability(streams) @@ -267,7 +280,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) - XCTAssert([URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/sddefault.jpg")!, URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/hqdefault.jpg")!].contains(metadata.thumbnail!.url)) + XCTAssert([URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/sddefault.jpg")!, URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/hqdefault.jpg")!, URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/hq720.jpg")!].contains(metadata.thumbnail!.url)) } catch let error { XCTFail("did throw error: \(error)") } @@ -279,7 +292,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) - XCTAssert([URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/sddefault.jpg")!, URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/hqdefault.jpg")!].contains(metadata.thumbnail!.url)) + XCTAssert([URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/sddefault.jpg")!, URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/hqdefault.jpg")!, URL(string: "https://i.ytimg.com/vi/ApM_KEr1ktQ/hq720.jpg")!].contains(metadata.thumbnail!.url)) } catch let error { XCTFail("did throw error: \(error)") }