Skip to content

Commit

Permalink
Added support for getting all subscriptions of an account.
Browse files Browse the repository at this point in the history
  • Loading branch information
b5i committed Jul 2, 2024
1 parent 5960b78 commit ce434f9
Show file tree
Hide file tree
Showing 5 changed files with 281 additions and 2 deletions.
8 changes: 8 additions & 0 deletions Sources/YouTubeKit/HeaderTypes+RawRepresentable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ extension HeaderTypes: RawRepresentable {
return "videoCaptionsHeaders"
case .trendingVideosHeaders:
return "trendingVideosHeaders"
case .usersSubscriptionsHeaders:
return "usersSubscriptionsHeaders"
case .usersSubscriptionsContinuationHeaders:
return "usersSubscriptionsContinuationHeaders"
case .usersSubscriptionsFeedHeaders:
return "usersSubscriptionsFeedHeaders"
case .usersSubscriptionsFeedContinuationHeaders:
return "usersSubscriptionsFeedContinuationHeaders"
case .customHeaders(let stringIdentifier):
return stringIdentifier
}
Expand Down
12 changes: 12 additions & 0 deletions Sources/YouTubeKit/HeaderTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ public enum HeaderTypes: Codable, Sendable {
/// Get a user's library.
case usersLibraryHeaders

/// Get a user's subscriptions.
case usersSubscriptionsHeaders

/// Get a user's subscriptions continuation.
case usersSubscriptionsContinuationHeaders

/// Get a users's subscriptions feed.
case usersSubscriptionsFeedHeaders

/// Get a users's subscriptions feed continuation.
case usersSubscriptionsFeedContinuationHeaders

/// Get all playlists where a video could be added, also includes the info whether the video is already in the playlist or not.
/// - Parameter browseId: The video's id to check, should be taken from ``YTVideo/videoId``.
case usersAllPlaylistsHeaders
Expand Down
138 changes: 138 additions & 0 deletions Sources/YouTubeKit/YouTubeModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,14 @@ public class YouTubeModel {
return videoCaptionsHeaders()
case .trendingVideosHeaders:
return getTrendingVideosHeaders()
case .usersSubscriptionsHeaders:
return getUsersSubscriptionsHeaders()
case .usersSubscriptionsContinuationHeaders:
return getUsersSubscriptionsContinuationHeaders()
case .usersSubscriptionsFeedHeaders:
return getUsersSubscriptionsFeedHeaders()
case .usersSubscriptionsFeedContinuationHeaders:
return getUsersSubscriptionsFeedContinuationHeaders()
case .customHeaders(let stringIdentifier):
if let headersGenerator = customHeadersFunctions[stringIdentifier] {
return headersGenerator()
Expand Down Expand Up @@ -1359,6 +1367,136 @@ public class YouTubeModel {
)
}
}

/// Get headers to get the user's subscriptions
/// - Returns: The headers for this request.
func getUsersSubscriptionsHeaders() -> HeadersList {
if let headers = self.customHeaders[.usersSubscriptionsHeaders] {
return headers
} else {
return HeadersList(
url: URL(string: "https://www.youtube.com/youtubei/v1/browse")!,
method: .POST,
headers: [
.init(name: "Accept", content: "*/*"),
.init(name: "Accept-Encoding", content: "gzip, deflate, br"),
.init(name: "Host", content: "www.youtube.com"),
.init(name: "User-Agent", content: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15"),
.init(name: "Accept-Language", content: "\(self.selectedLocale);q=0.9"),
.init(name: "Origin", content: "https://www.youtube.com/"),
.init(name: "Referer", content: "https://www.youtube.com/"),
.init(name: "Content-Type", content: "application/json"),
.init(name: "X-Origin", content: "https://www.youtube.com")
],
addQueryAfterParts: [],
httpBody: [
"{\"context\":{\"client\":{\"hl\":\"\(self.selectedLocaleLanguageCode)\",\"gl\":\"\(self.selectedLocaleCountryCode.uppercased())\",\"userAgent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15,gzip(gfe)\",\"clientName\":\"WEB\",\"clientVersion\":\"2.20230120.00.00\",\"acceptHeader\":\"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\"mainAppWebInfo\":{\"webDisplayMode\":\"WEB_DISPLAY_MODE_BROWSER\",\"isWebNativeShareAvailable\":true}},\"user\":{\"lockedSafetyMode\":false},\"request\":{\"useSsl\":true,\"internalExperimentFlags\":[],\"consistencyTokenJars\":[]}},\"browseId\":\"FEchannels\"}"
],
parameters: [
.init(name: "prettyPrint", content: "false")
]
)
}
}

/// Get headers to get the continuation of a `getUsersSubscriptionsHeaders()` ("more results" button).
/// - Returns: The headers for this request.
func getUsersSubscriptionsContinuationHeaders() -> HeadersList {
if let headers = self.customHeaders[.usersSubscriptionsContinuationHeaders] {
return headers
} else {
return HeadersList(
url: URL(string: "https://www.youtube.com/youtubei/v1/browse")!,
method: .POST,
headers: [
.init(name: "Accept", content: "*/*"),
.init(name: "Accept-Encoding", content: "gzip, deflate, br"),
.init(name: "Host", content: "www.youtube.com"),
.init(name: "User-Agent", content: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15"),
.init(name: "Accept-Language", content: "\(self.selectedLocale);q=0.9"),
.init(name: "Origin", content: "https://www.youtube.com/"),
.init(name: "Referer", content: "https://www.youtube.com/"),
.init(name: "Content-Type", content: "application/json"),
.init(name: "X-Origin", content: "https://www.youtube.com")
],
addQueryAfterParts: [
.init(index: 0, encode: false, content: .continuation)
],
httpBody: [
"{\"context\":{\"client\":{\"hl\":\"\(self.selectedLocaleLanguageCode)\",\"gl\":\"\(self.selectedLocaleCountryCode.uppercased())\",\"userAgent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15,gzip(gfe)\",\"clientName\":\"WEB\",\"clientVersion\":\"2.20230120.00.00\",\"acceptHeader\":\"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\"mainAppWebInfo\":{\"webDisplayMode\":\"WEB_DISPLAY_MODE_BROWSER\",\"isWebNativeShareAvailable\":true}},\"user\":{\"lockedSafetyMode\":false},\"request\":{\"useSsl\":true,\"internalExperimentFlags\":[],\"consistencyTokenJars\":[]}},\"continuation\":\"",
"\"}"
],
parameters: [
.init(name: "prettyPrint", content: "false")
]
)
}
}

/// Get headers to get the user's subscriptions
/// - Returns: The headers for this request.
func getUsersSubscriptionsFeedHeaders() -> HeadersList {
if let headers = self.customHeaders[.usersSubscriptionsFeedHeaders] {
return headers
} else {
return HeadersList(
url: URL(string: "https://www.youtube.com/youtubei/v1/browse")!,
method: .POST,
headers: [
.init(name: "Accept", content: "*/*"),
.init(name: "Accept-Encoding", content: "gzip, deflate, br"),
.init(name: "Host", content: "www.youtube.com"),
.init(name: "User-Agent", content: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15"),
.init(name: "Accept-Language", content: "\(self.selectedLocale);q=0.9"),
.init(name: "Origin", content: "https://www.youtube.com/"),
.init(name: "Referer", content: "https://www.youtube.com/"),
.init(name: "Content-Type", content: "application/json"),
.init(name: "X-Origin", content: "https://www.youtube.com")
],
addQueryAfterParts: [],
httpBody: [
"{\"context\":{\"client\":{\"hl\":\"\(self.selectedLocaleLanguageCode)\",\"gl\":\"\(self.selectedLocaleCountryCode.uppercased())\",\"userAgent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15,gzip(gfe)\",\"clientName\":\"WEB\",\"clientVersion\":\"2.20230120.00.00\",\"acceptHeader\":\"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\"mainAppWebInfo\":{\"webDisplayMode\":\"WEB_DISPLAY_MODE_BROWSER\",\"isWebNativeShareAvailable\":true}},\"user\":{\"lockedSafetyMode\":false},\"request\":{\"useSsl\":true,\"internalExperimentFlags\":[],\"consistencyTokenJars\":[]}},\"browseId\":\"FEsubscriptions\"}"
],
parameters: [
.init(name: "prettyPrint", content: "false")
]
)
}
}

/// Get headers to get the continuation of a `getUsersSubscriptionsHeaders()` ("more results" button).
/// - Returns: The headers for this request.
func getUsersSubscriptionsFeedContinuationHeaders() -> HeadersList {
if let headers = self.customHeaders[.usersSubscriptionsFeedContinuationHeaders] {
return headers
} else {
return HeadersList(
url: URL(string: "https://www.youtube.com/youtubei/v1/browse")!,
method: .POST,
headers: [
.init(name: "Accept", content: "*/*"),
.init(name: "Accept-Encoding", content: "gzip, deflate, br"),
.init(name: "Host", content: "www.youtube.com"),
.init(name: "User-Agent", content: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15"),
.init(name: "Accept-Language", content: "\(self.selectedLocale);q=0.9"),
.init(name: "Origin", content: "https://www.youtube.com/"),
.init(name: "Referer", content: "https://www.youtube.com/"),
.init(name: "Content-Type", content: "application/json"),
.init(name: "X-Origin", content: "https://www.youtube.com")
],
addQueryAfterParts: [
.init(index: 0, encode: false, content: .continuation)
],
httpBody: [
"{\"context\":{\"client\":{\"hl\":\"\(self.selectedLocaleLanguageCode)\",\"gl\":\"\(self.selectedLocaleCountryCode.uppercased())\",\"userAgent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15,gzip(gfe)\",\"clientName\":\"WEB\",\"clientVersion\":\"2.20230120.00.00\",\"acceptHeader\":\"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\"mainAppWebInfo\":{\"webDisplayMode\":\"WEB_DISPLAY_MODE_BROWSER\",\"isWebNativeShareAvailable\":true}},\"user\":{\"lockedSafetyMode\":false},\"request\":{\"useSsl\":true,\"internalExperimentFlags\":[],\"consistencyTokenJars\":[]}},\"continuation\":\"",
"\"}"
],
parameters: [
.init(name: "prettyPrint", content: "false")
]
)
}
}
}

#if swift(>=5.10)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//
// AccountSubscriptionsResponse.swift
//
//
// Created by Antoine Bollengier on 02.07.2024.
// Copyright © 2024 Antoine Bollengier (github.com/b5i). All rights reserved.
//

import Foundation

/// A response to get all the channels the YouTubeModel's account is subscribed to.
public struct AccountSubscriptionsResponse: AuthenticatedContinuableResponse {
public static let headersType: HeaderTypes = .usersSubscriptionsHeaders

public static let parametersValidationList: ValidationList = [:]

public var isDisconnected: Bool = true

public var results: [YTChannel] = []

public var continuationToken: String? = nil

public var visitorData: String? = nil // will never be filled

public static func decodeJSON(json: JSON) throws -> AccountSubscriptionsResponse {
var toReturn = AccountSubscriptionsResponse()

guard !(json["responseContext"]["mainAppWebResponseContext"]["loggedOut"].bool ?? true) else { return toReturn }

toReturn.isDisconnected = false

guard let tab = json["contents"]["twoColumnBrowseResultsRenderer"]["tabs"].arrayValue.first(where: {
return $0["tabRenderer"]["selected"].boolValue
}), tab["tabRenderer"]["tabIdentifier"].string == "FEchannels" else {
throw ResponseExtractionError(reponseType: Self.self, stepDescription: "Error while trying the get the tab of the channels.")
}

for section in tab["tabRenderer"]["content"]["sectionListRenderer"]["contents"].arrayValue {
if section["itemSectionRenderer"].exists() {
toReturn.results.append(contentsOf: self.getChannelsFromItemSectionRenderer(section["itemSectionRenderer"]))
} else if section["continuationItemRenderer"].exists() {
toReturn.continuationToken = section["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]["token"].string
}
}

return toReturn
}

/// Struct representing the continuation ("load more videos" button)
public struct Continuation: AuthenticatedResponse, ResponseContinuation {
public static let headersType: HeaderTypes = .usersSubscriptionsContinuationHeaders

public static let parametersValidationList: ValidationList = [.continuation: .existenceValidator]

public var isDisconnected: Bool = true

/// Continuation token used to fetch more channels, nil if there is no more channels to fetch.
public var continuationToken: String?

/// Array of history blocks.
public var results: [YTChannel] = []

public static func decodeJSON(json: JSON) -> AccountSubscriptionsResponse.Continuation {
var toReturn = Continuation()

guard !(json["responseContext"]["mainAppWebResponseContext"]["loggedOut"].bool ?? true) else { return toReturn }

toReturn.isDisconnected = false

for continuationAction in json["onResponseReceivedActions"].arrayValue where continuationAction["appendContinuationItemsAction"].exists() {
for continuationItem in continuationAction["appendContinuationItemsAction"]["continuationItems"].arrayValue {
if continuationItem["itemSectionRenderer"].exists() {
toReturn.results.append(contentsOf: getChannelsFromItemSectionRenderer(continuationItem["itemSectionRenderer"]))
} else if continuationItem["continuationItemRenderer"].exists() {
toReturn.continuationToken = continuationItem["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]["token"].string
}
}
}

return toReturn
}
}

private static func getChannelsFromItemSectionRenderer(_ json: JSON) -> [YTChannel] {
var toReturn: [YTChannel] = []
for itemSectionContents in json["contents"].arrayValue {
for channelJSON in itemSectionContents["shelfRenderer"]["content"]["expandedShelfContentsRenderer"]["items"].arrayValue {
guard let channel = YTChannel.decodeJSON(json: channelJSON["channelRenderer"]) else { continue }
toReturn.append(channel)
}
}

return toReturn
}
}
30 changes: 28 additions & 2 deletions Tests/YouTubeKitTests/YouTubeKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -927,7 +927,7 @@ final class YouTubeKitTests: XCTestCase {
let historyResponse = try await HistoryResponse.sendThrowingRequest(youtubeModel: YTM, data: [:], useCookies: true)

XCTAssertNotNil(historyResponse.title, TEST_NAME + "Checking if historyResponse.title has been extracted.")
XCTAssertNotEqual(historyResponse.results.count, 0, TEST_NAME + "Checking if historyResponse.videosAndTime is not empty.")
XCTAssertNotEqual(historyResponse.results.count, 0, TEST_NAME + "Checking if historyResponse.results is not empty.")

guard let firstVideoToken = (historyResponse.results.first?.contentsArray.first(where: {$0 as? HistoryResponse.HistoryBlock.VideoWithToken != nil}) as! HistoryResponse.HistoryBlock.VideoWithToken).suppressToken else { XCTFail(TEST_NAME + "Could not find a video with a suppressToken in the history"); return }

Expand All @@ -940,7 +940,7 @@ final class YouTubeKitTests: XCTestCase {
if historyResponse.continuationToken != nil {
let continuationResponse = try await historyResponse.fetchContinuation(youtubeModel: YTM)

XCTAssertNotEqual(continuationResponse.results.count, 0, TEST_NAME + "Checking if continuationResponse.historyParts is not empty.")
XCTAssertNotEqual(continuationResponse.results.count, 0, TEST_NAME + "Checking if continuationResponse.results is not empty.")

if let shortsBlock = (continuationResponse.results.first?.contentsArray.first(where: {$0 as? HistoryResponse.HistoryBlock.ShortsBlock != nil}) as? HistoryResponse.HistoryBlock.ShortsBlock) {
XCTAssertNotNil(shortsBlock.suppressTokens.compactMap({$0}), TEST_NAME + "Checking if suppressTokens of ShortBlock is not full of nil values.")
Expand Down Expand Up @@ -1127,4 +1127,30 @@ final class YouTubeKitTests: XCTestCase {
XCTAssertEqual(secondBaseTrendingResponse.currentContentIdentifier, mainTabName, TEST_NAME + "currentContentIdentifier is not equal to mainTabName after second request.")
XCTAssertEqual(baseTrendingResponse.categoriesContentsStore[mainTabName], secondBaseTrendingResponse.categoriesContentsStore[mainTabName], TEST_NAME + "baseTrendingResponse.categoriesContentsStore[tabName] is not equal to secondBaseTrendingResponse[tabName] after merging.")
}

func testAccountSubscriptionsResponse() async throws {
let TEST_NAME = "Test: testAccountSubscriptionsResponse() -> "
guard cookies != "" else { return }
YTM.cookies = cookies

var accountSubscriptionsResponse = try await AccountSubscriptionsResponse.sendThrowingRequest(youtubeModel: YTM, data: [:], useCookies: true)

XCTAssert(!accountSubscriptionsResponse.isDisconnected, TEST_NAME + "Account is disconnected.")
XCTAssertNil(accountSubscriptionsResponse.visitorData, TEST_NAME + "visitorData is not nil (but should never be extracted).")

XCTAssertNotEqual(accountSubscriptionsResponse.results.count, 0, TEST_NAME + "Checking if accountSubscriptionsResponse.results is not empty.")

if accountSubscriptionsResponse.continuationToken != nil {
let continuationResponse = try await accountSubscriptionsResponse.fetchContinuation(youtubeModel: YTM)

XCTAssertNotEqual(continuationResponse.results.count, 0, TEST_NAME + "Checking if continuationResponse.results is not empty.")

let oldChannelsCount = accountSubscriptionsResponse.results.count

accountSubscriptionsResponse.mergeContinuation(continuationResponse)

XCTAssertEqual(accountSubscriptionsResponse.results.count, oldChannelsCount + continuationResponse.results.count, TEST_NAME + "accountSubscriptionsResponse.results.count is not equal to oldChannelsCount + continuationResponse.results.count")
XCTAssertEqual(accountSubscriptionsResponse.continuationToken, continuationResponse.continuationToken, TEST_NAME + "continuationToken hasn't been merged.")
}
}
}

0 comments on commit ce434f9

Please sign in to comment.