├── .spi.yml ├── .gitignore ├── CONTRIBUTING.md ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ ├── YouTubeKitTests.xcscheme │ ├── YouTubeKit-Package.xcscheme │ └── YouTubeKit.xcscheme ├── Sources └── YouTubeKit │ ├── BaseEnums │ ├── YTLikeStatus.swift │ └── YTPrivacy.swift │ ├── YouTubeResponseTypes │ ├── AuthenticatedResponses │ │ ├── HistoryActions │ │ │ ├── HistoryBlockContent.swift │ │ │ ├── RemoveVideoFromHistroryResponse.swift │ │ │ └── HistoryReponse+removeVideo.swift │ │ ├── SimpleOperationAuthenticatedResponse.swift │ │ ├── VideosActions │ │ │ ├── LikeVideoResponse.swift │ │ │ ├── DislikeVideoResponse.swift │ │ │ └── RemoveLikeFromVideoResponse.swift │ │ ├── CommentsActions │ │ │ ├── ReplyCommentResponse.swift │ │ │ ├── RemoveDislikeCommentResponse.swift │ │ │ ├── LikeCommentResponse.swift │ │ │ ├── DislikeCommentResponse.swift │ │ │ ├── EditCommentResponse.swift │ │ │ ├── RemoveLikeCommentResponse.swift │ │ │ ├── EditReplyCommandResponse.swift │ │ │ ├── DeleteCommentResponse.swift │ │ │ └── CreateCommentResponse.swift │ │ ├── PlaylistsActions │ │ │ ├── RemoveVideoByIdFromPlaylistResponse.swift │ │ │ ├── RemoveVideoFromPlaylistResponse.swift │ │ │ ├── DeletePlaylistResponse.swift │ │ │ ├── MoveVideoInPlaylistResponse.swift │ │ │ ├── CreatePlaylistResponse.swift │ │ │ ├── AddVideoToPlaylistResponse.swift │ │ │ └── AllPossibleHostPlaylistsResponse.swift │ │ ├── ChannelsActions │ │ │ ├── SubscribeChannelResponse.swift │ │ │ └── UnsubscribeChannelResponse.swift │ │ ├── AccountResponses │ │ │ ├── AccountPlaylistsResponse.swift │ │ │ ├── AccountInfosResponse.swift │ │ │ ├── AccountSubscriptionsResponse.swift │ │ │ └── AccountSubscriptionsFeedResponse.swift │ │ └── AuthenticatedResponse.swift │ ├── VideoInfos │ │ ├── MediaType.swift │ │ ├── CommentTranslationResponse.swift │ │ ├── DownloadFormat.swift │ │ ├── VideoDownloadFormat.swift │ │ ├── AudioOnlyFormat.swift │ │ └── VideoCaptionsResponse.swift │ ├── Search │ │ ├── YTSearchResult+decodeJSON.swift │ │ ├── YTSearchResult+filterTypes.swift │ │ ├── YTSearchResult+canBeDecoded.swift │ │ ├── YTSearchResultType.swift │ │ └── YTSearchResult.swift │ ├── ChannelInfos │ │ ├── ChannelContent+canDecode.swift │ │ ├── ChannelContent+decodeJSONFromTab.swift │ │ ├── ChannelContent+getContinuationFromTab.swift │ │ ├── ListableChannelContent+addChannelInfos.swift │ │ ├── ListableChannelContent.swift │ │ └── ChannelContent.swift │ ├── AutoCompletion │ │ └── AutoCompletionResponse.swift │ ├── Home │ │ └── HomeScreenResponse.swift │ └── Trending │ │ └── TrendingVideosResponse.swift │ ├── ErrorHandling │ ├── ErrorTypes │ │ ├── NetworkError.swift │ │ ├── BadRequestDataError.swift │ │ └── ResponseExtractionError.swift │ ├── ParameterValidator.swift │ └── ParameterValidator+commonValidators.swift │ ├── HeaderTypes+Hashable.swift │ ├── BaseProtocols │ ├── Continuation │ │ ├── ContinuableResponse+mergeContinuation.swift │ │ ├── ResponseContinuation.swift │ │ ├── AuthenticatedContinuableResponse.swift │ │ ├── AuthenticatedContinuableResponse+fetchContinuation.swift │ │ ├── ContinuableResponse.swift │ │ └── ContinuableResponse+fetchContinuation.swift │ ├── Video │ │ ├── YouTubeVideo+getCaptions.swift │ │ ├── YouTubeVideo+fetchMoreInfos.swift │ │ ├── YouTubeVideo+fetchStreamingInfos.swift │ │ ├── YouTubeVideo+fetchAllPossibleHostPlaylists.swift │ │ ├── YouTubeVideo+fetchStreamingInfosWithDownloadFormats.swift │ │ └── YouTubeVideo+likeActions.swift │ └── Channel │ │ ├── YouTubeChannel+fetchInfos.swift │ │ ├── YouTubeChannel.swift │ │ └── YouTubeChannel+subscribeActions.swift │ ├── [URLQueryItem]+makeUnique.swift │ ├── BaseStructs │ ├── YTCaption.swift │ ├── YTPlaylist+canShowBeDecoded.swift │ ├── YTLittleChannelInfos.swift │ ├── YTPlaylist+decodeShowFromJSON.swift │ ├── YTVideo+decodeVideoFromPlaylist.swift │ ├── YTComment.swift │ ├── YTVideo+decodeShortFromJSON.swift │ ├── YTThumbnail.swift │ ├── YTPlaylist+fetchVideos.swift │ ├── YTChannel.swift │ └── YTPlaylist.swift │ ├── URL+AppendQueryItems.swift │ ├── Utils │ └── String+ytkRegexMatches.swift │ ├── Logging │ ├── RequestLogger+defaultImplementations.swift │ ├── RequestLog.swift │ └── RequestsLogger.swift │ └── HeaderTypes+RawRepresentable.swift ├── .github ├── workflows │ └── swift.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── LICENSE ├── Package.swift └── Examples └── SearchView.swift /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [ YouTubeKit ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | As YouTubeKit is part of a school project, contributions (except minor ones) won't be accepted. Other ways of contributing, like raising issues, are welcomed! If you plan to do a pull request, make sure to run the tests with your cookies and see if they all pass. 4 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseEnums/YTLikeStatus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTLikeStatus.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 02.07.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | /// Enum representing the different "appreciation" status of an account for an item (e.g. a video or a comment). 10 | public enum YTLikeStatus: Sendable, Codable { 11 | case liked 12 | case disliked 13 | case nothing 14 | } 15 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/HistoryActions/HistoryBlockContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HistoryBlockContent.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 16.03.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A protocol describing some content from an ``HistoryResponse/HistoryBlock`` 12 | public protocol HistoryBlockContent: Hashable, Sendable { 13 | var id: Int { get } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/VideoInfos/MediaType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaType.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 20.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Different types of medias. 11 | public enum MediaType: Sendable { 12 | /// Video media, usually includes the audio inside. 13 | case video 14 | 15 | /// Only audio media. 16 | case audio 17 | } 18 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/SimpleOperationAuthenticatedResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SimpleOperationAuthenticatedResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 03.07.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | public protocol SimpleActionAuthenticatedResponse: AuthenticatedResponse { 10 | /// Boolean indicating whether the action was successful. 11 | var success: Bool { get } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseEnums/YTPrivacy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTPrivacy.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 27.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Enum representing the various privacy levels of YouTube like videos or playlists. 11 | public enum YTPrivacy: String, Codable, Hashable, CaseIterable, Sendable { 12 | case `private` = "PRIVATE" 13 | case `public` = "PUBLIC" 14 | case unlisted = "UNLISTED" 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: macos-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Build 20 | run: swift build -v 21 | - name: Run tests 22 | run: swift test -v 23 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/ErrorHandling/ErrorTypes/NetworkError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkError.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 01.03.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A struct representing a network error. 12 | public struct NetworkError: Error { 13 | public let code: Int 14 | public let message: String 15 | 16 | public init(code: Int, message: String) { 17 | self.code = code 18 | self.message = message 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/HeaderTypes+Hashable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeaderTypes+Hashable.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 19.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | extension HeaderTypes: Hashable { 11 | public static func == (lhs: HeaderTypes, rhs: HeaderTypes) -> Bool { 12 | return lhs.rawValue == rhs.rawValue 13 | } 14 | 15 | public func hash(into hasher: inout Hasher) { 16 | hasher.combine(rawValue) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseProtocols/Continuation/ContinuableResponse+mergeContinuation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContinuableResponse+mergeContinuation.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 17.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension ContinuableResponse { 12 | mutating func mergeContinuation(_ continuation: Continuation) { 13 | self.continuationToken = continuation.continuationToken 14 | self.results.append(contentsOf: continuation.results) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve YouTubeKit 4 | title: "[BUG] - Brief description of the bug" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Provide a piece of code (should work alone) that shows explicitly where the bug occurs. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Additional context** 20 | Add any other context about the problem here. 21 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/Search/YTSearchResult+decodeJSON.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTSearchResult+decodeJSON.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 19.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension YTSearchResult { 11 | /// Decode JSON from raw data. 12 | /// - Parameter data: raw data to be decoded. 13 | /// - Returns: An instance of the YTSearchResult. 14 | static func decodeJSON(data: Data) -> Self? { 15 | return decodeJSON(json: JSON(data)) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/Search/YTSearchResult+filterTypes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTSearchResult+filterTypes.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 19.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension [YTSearchResult] { 11 | /// Making easier to filter item types of your array 12 | func filterTypes(acceptedTypes: [YTSearchResultType] = YTSearchResultType.allCases) -> [any YTSearchResult] { 13 | return self.filter({acceptedTypes.contains(type(of: $0).type)}) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/[URLQueryItem]+makeUnique.swift: -------------------------------------------------------------------------------- 1 | // 2 | // [URLQueryItem]+makeUnique.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 28.08.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension [URLQueryItem] { 12 | /// Remove headers that have the same name but keep the first item with that name. 13 | func makeUnique() -> [URLQueryItem] { 14 | var uniqueItems: [URLQueryItem] = [] 15 | for item in self { 16 | guard !uniqueItems.contains(item) else { continue } 17 | uniqueItems.append(item) 18 | } 19 | return uniqueItems 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseProtocols/Continuation/ResponseContinuation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResponseContinuation.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 12.07.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A protocol describing the continuation of a response. 11 | public protocol ResponseContinuation: YouTubeResponse { 12 | associatedtype ResultsType 13 | 14 | /// Continuation token used to fetch more results, nil if there is no more results to fetch. 15 | var continuationToken: String? { get set } 16 | 17 | /// Results of the continuation search. 18 | var results: [ResultsType] { get set } 19 | } 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for YouTubeKit 4 | title: "[Feature] - Short description of the feature" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/ChannelInfos/ChannelContent+canDecode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChannelContent+canDecode.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 23.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension ChannelContent { 11 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use canDecode(json: JSON) instead. You can convert your Data into JSON by calling the JSON(_ data: Data) initializer.") // deprecated as you can't really find some tab JSON in raw data. 12 | static func canDecode(data: Data) -> Bool { 13 | return canDecode(json: JSON(data)) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseStructs/YTCaption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTCaption.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 27.06.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct YTCaption: Sendable { 12 | public var languageCode: String 13 | 14 | public var languageName: String 15 | 16 | public var url: URL 17 | 18 | public var isTranslated: Bool 19 | 20 | public init(languageCode: String, languageName: String, url: URL, isTranslated: Bool) { 21 | self.languageCode = languageCode 22 | self.languageName = languageName 23 | self.url = url 24 | self.isTranslated = isTranslated 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseStructs/YTPlaylist+canShowBeDecoded.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTPlaylist+canShowBeDecoded.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 25.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension YTPlaylist { 11 | /// Indicate if a certain JSON can be decoded as a YouTube "show". 12 | /// - Parameter json: the JSON should be the **inside** of a dictionnary called "gridShowRenderer" by YouTube's API. 13 | /// - Returns: a boolean indicating whether it can be decoded as a show or not. 14 | static func canShowBeDecoded(json: JSON) -> Bool { 15 | return json["navigationEndpoint", "browseEndpoint", "browseId"].string != nil 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/ErrorHandling/ErrorTypes/BadRequestDataError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BadRequestDataError.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 01.03.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A struct representing the error returned when calling ``YouTubeResponse/validateRequest(data:)`` 12 | public struct BadRequestDataError: Error { 13 | /// An array of validation errors. 14 | public let parametersValidatorErrors: [ParameterValidator.TypedValidationError] 15 | 16 | /// - Parameter parametersValidatorErrors: An array of validation errors. 17 | public init(parametersValidatorErrors: [ParameterValidator.TypedValidationError]) { 18 | self.parametersValidatorErrors = parametersValidatorErrors 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/ErrorHandling/ErrorTypes/ResponseExtractionError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResponseExtractionError.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 17.06.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A struct representing a network error. 12 | public struct ResponseExtractionError: Error { 13 | public let reponseType: any YouTubeResponse.Type 14 | public let stepDescription: String 15 | 16 | public init(reponseType: any YouTubeResponse.Type, stepDescription: String) { 17 | self.reponseType = reponseType 18 | self.stepDescription = stepDescription 19 | } 20 | 21 | public var localizedDescription: String { 22 | return "Failed to extract response of type \(reponseType) at critical step: \(stepDescription)." 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/ChannelInfos/ChannelContent+decodeJSONFromTab.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChannelContent+decodeJSONFromTab.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 23.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension ChannelContent { 11 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use decodeJSONFromTab(_ json: JSON, channelInfos: YTLittleChannelInfos?) instead. You can convert your Data into JSON by calling the JSON(_ data: Data) initializer.") // deprecated as you can't really find some tab JSON in raw data. 12 | static func decodeJSONFromTab(_ tab: Data, channelInfos: YTLittleChannelInfos?) -> Self? { 13 | return decodeJSONFromTab(JSON(tab), channelInfos: channelInfos) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseStructs/YTLittleChannelInfos.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTLittleChannelInfos.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 20.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Structure representing the base informations about a YouTube channel, including its name and ID. Basic implementation of the ``YouTubeChannel`` protocol. 11 | public struct YTLittleChannelInfos: Codable, YouTubeChannel, Hashable, Sendable { 12 | public init(channelId: String, name: String? = nil, thumbnails: [YTThumbnail] = []) { 13 | self.channelId = channelId 14 | self.name = name 15 | self.thumbnails = thumbnails 16 | } 17 | 18 | public var channelId: String 19 | 20 | public var name: String? 21 | 22 | public var thumbnails: [YTThumbnail] = [] 23 | } 24 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/ChannelInfos/ChannelContent+getContinuationFromTab.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChannelContent+getContinuationFromTab.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 25.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension ChannelContent { 11 | static func getContinuationFromTab(json: JSON) -> String? { 12 | guard let videosArray = json["tabRenderer", "content", "richGridRenderer", "contents"].array else { return nil } 13 | 14 | /// The token is generally at the end so we reverse it 15 | for element in videosArray.reversed() { 16 | if let token = element["continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token"].string { 17 | return token 18 | } 19 | } 20 | return nil 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/ChannelInfos/ListableChannelContent+addChannelInfos.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListableChannelContent+addChannelInfos.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 11.03.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension ListableChannelContent { 12 | mutating func addChannelInfos(_ channelInfos: YTLittleChannelInfos) { 13 | for (offset, item) in items.enumerated() { 14 | if var castedItem = (item as? YTVideo), castedItem.channel == nil { 15 | castedItem.channel = channelInfos 16 | items[offset] = castedItem 17 | } else if var castedItem = (item as? YTPlaylist), castedItem.channel == nil { 18 | castedItem.channel = channelInfos 19 | items[offset] = castedItem 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/VideosActions/LikeVideoResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LikeVideoResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 16.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct LikeVideoResponse: AuthenticatedResponse { 12 | public static let headersType: HeaderTypes = .likeVideoHeaders 13 | 14 | public static let parametersValidationList: ValidationList = [.query: .videoIdValidator] 15 | 16 | public var isDisconnected: Bool = true 17 | 18 | public static func decodeJSON(json: JSON) -> LikeVideoResponse { 19 | var toReturn = LikeVideoResponse() 20 | 21 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 22 | 23 | toReturn.isDisconnected = false 24 | 25 | return toReturn 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/Search/YTSearchResult+canBeDecoded.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTSearchResult+canBeDecoded.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 22.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension YTSearchResult { 11 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use canBeDecoded(json: JSON) instead. You can convert your Data into JSON by calling the JSON(_ data: Data) initializer.") // deprecated as you can't really find some result JSON in raw data. 12 | /// Method indicating whether some Data can be converted to this type of ``YTSearchResult``. 13 | /// - Parameter data: the data to be checked. 14 | /// - Returns: a boolean indicating if the conversion is possible. 15 | static func canBeDecoded(data: Data) -> Bool { 16 | return canBeDecoded(json: JSON(data)) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/VideosActions/DislikeVideoResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DislikeVideoResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 16.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct DislikeVideoResponse: AuthenticatedResponse { 12 | public static let headersType: HeaderTypes = .dislikeVideoHeaders 13 | 14 | public static let parametersValidationList: ValidationList = [.query: .videoIdValidator] 15 | 16 | public var isDisconnected: Bool = true 17 | 18 | public static func decodeJSON(json: JSON) -> DislikeVideoResponse { 19 | var toReturn = DislikeVideoResponse() 20 | 21 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 22 | 23 | toReturn.isDisconnected = false 24 | 25 | return toReturn 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/VideosActions/RemoveLikeFromVideoResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoveLikeFromVideoResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 16.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct RemoveLikeFromVideoResponse: AuthenticatedResponse { 12 | public static let headersType: HeaderTypes = .removeLikeStatusFromVideoHeaders 13 | 14 | public static let parametersValidationList: ValidationList = [.query: .videoIdValidator] 15 | 16 | public var isDisconnected: Bool = true 17 | 18 | public static func decodeJSON(json: JSON) -> RemoveLikeFromVideoResponse { 19 | var toReturn = RemoveLikeFromVideoResponse() 20 | 21 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 22 | 23 | toReturn.isDisconnected = false 24 | 25 | return toReturn 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 - 2025 Antoine Bollengier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/ChannelInfos/ListableChannelContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListableChannelContent.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 15.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Protocol to group ChannelContent structures that have a list of items where their types are some YTSearchResult. 12 | public protocol ListableChannelContent: ChannelContent { 13 | /// Array listing the YTSearchResult types present in ``ListableChannelContent/items``. 14 | static var itemsTypes: [any YTSearchResult.Type] { get } 15 | 16 | /// Items contained in channel's tab. Contains only results of types 17 | var items: [any YTSearchResult] { get set } 18 | 19 | /// A function that will add their channel's information to every item in ``items`` (if not already present). The default implementation will do that for items that are of type ``YTVideo`` or ``YTPlaylist``. 20 | mutating func addChannelInfos(_ channelInfos: YTLittleChannelInfos) 21 | } 22 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/Search/YTSearchResultType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTSearchResultType.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 19.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// The string value of the YTSearchResultTypes are the HTML renderer values in YouTube's API response 11 | public enum YTSearchResultType: String, Codable, CaseIterable, Hashable, Sendable { 12 | /// Types represents the string value of their distinguished JSON dictionnary's name. 13 | case video = "videoRenderer" 14 | case channel = "channelRenderer" 15 | case playlist = "playlistRenderer" 16 | 17 | /// Get the struct that has to be use to decode a particular item. 18 | static func getDecodingStruct(forType type: Self) -> (any YTSearchResult.Type) { 19 | switch type { 20 | case .video: 21 | return YTVideo.self 22 | case .channel: 23 | return YTChannel.self 24 | case .playlist: 25 | return YTPlaylist.self 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/ReplyCommentResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReplyCommentResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 03.07.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | /// Response to reply to a comment of a video. 10 | public struct ReplyCommentResponse: SimpleActionAuthenticatedResponse { 11 | public static let headersType: HeaderTypes = .replyCommentHeaders 12 | 13 | public static let parametersValidationList: ValidationList = [.text: .textSanitizerValidator, .params: .existenceValidator] 14 | 15 | public var isDisconnected: Bool = true 16 | 17 | public var success: Bool = false 18 | 19 | public var newComment: YTComment? 20 | 21 | public static func decodeJSON(json: JSON) throws -> Self { 22 | let normalCommentDecoding = try CreateCommentResponse.decodeJSON(json: json) 23 | return Self(isDisconnected: normalCommentDecoding.isDisconnected, success: normalCommentDecoding.success, newComment: normalCommentDecoding.newComment) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseProtocols/Continuation/AuthenticatedContinuableResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticatedContinuableResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 17.03.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public typealias AuthenticatedResponseContinuation = AuthenticatedResponse & ResponseContinuation 12 | 13 | /// A protocol describing an ``AuthenticatedResponse`` that can have a continuation. 14 | public protocol AuthenticatedContinuableResponse: AuthenticatedResponse, ContinuableResponse where Continuation: AuthenticatedResponseContinuation { 15 | /// Fetch the continuation of the ``AuthenticatedContinuableResponse``. 16 | func fetchContinuation( 17 | youtubeModel: YouTubeModel, 18 | result: @escaping @Sendable (Result) -> Void 19 | ) 20 | 21 | /// Fetch the continuation of the ``AuthenticatedContinuableResponse``. 22 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 23 | func fetchContinuation( 24 | youtubeModel: YouTubeModel 25 | ) async throws -> Continuation 26 | } 27 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "YouTubeKit", 8 | products: [ 9 | // Products define the executables and libraries a package produces, and make them visible to other packages. 10 | .library( 11 | name: "YouTubeKit", 12 | targets: ["YouTubeKit"]), 13 | ], 14 | dependencies: [ 15 | // Dependencies declare other packages that this package depends on. 16 | // .package(url: /* package url */, from: "1.0.0"), 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 20 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 21 | .target( 22 | name: "YouTubeKit", 23 | dependencies: []/*, 24 | swiftSettings: [.unsafeFlags(["-Xfrontend", "-strict-concurrency=complete"])]*/), 25 | .testTarget( 26 | name: "YouTubeKitTests", 27 | dependencies: ["YouTubeKit"]), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/RemoveDislikeCommentResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoveDislikeCommentResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 03.07.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | /// Response remove a dislike from a video. 10 | public struct RemoveDislikeCommentResponse: SimpleActionAuthenticatedResponse { 11 | public static let headersType: HeaderTypes = .removeDislikeCommentHeaders 12 | 13 | public static let parametersValidationList: ValidationList = [.params: .existenceValidator] 14 | 15 | public var isDisconnected: Bool = true 16 | 17 | public var success: Bool = false 18 | 19 | public static func decodeJSON(json: JSON) -> Self { 20 | var toReturn = Self() 21 | 22 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 23 | 24 | toReturn.isDisconnected = false 25 | 26 | toReturn.success = json["actionResults"].arrayValue.first(where: {$0["status"].string == "STATUS_SUCCEEDED"}) != nil 27 | 28 | return toReturn 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/LikeCommentResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LikeCommentResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 03.07.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | /// Response to like a comment of a video. 10 | public struct LikeCommentResponse: SimpleActionAuthenticatedResponse { 11 | public static let headersType: HeaderTypes = .likeCommentHeaders 12 | 13 | public static let parametersValidationList: ValidationList = [.params: .existenceValidator] 14 | 15 | public var isDisconnected: Bool = true 16 | 17 | public var success: Bool = false 18 | 19 | public static func decodeJSON(json: JSON) -> Self { 20 | var toReturn = Self() 21 | 22 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 23 | 24 | toReturn.isDisconnected = false 25 | 26 | toReturn.success = json["actionResults"].arrayValue.first(where: {$0["status"].string == "STATUS_SUCCEEDED" && $0["feedback"].string == "FEEDBACK_LIKE"}) != nil 27 | 28 | return toReturn 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/HistoryActions/RemoveVideoFromHistroryResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoveVideoFromHistroryResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 03.01.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct RemoveVideoFromHistroryResponse: SimpleActionAuthenticatedResponse { 12 | public static let headersType: HeaderTypes = .deleteVideoFromHistory 13 | 14 | public static let parametersValidationList: ValidationList = [.movingVideoId: .existenceValidator] 15 | 16 | public var isDisconnected: Bool = true 17 | 18 | /// Success of the deletion operation. 19 | public var success: Bool = false 20 | 21 | public static func decodeJSON(json: JSON) -> RemoveVideoFromHistroryResponse { 22 | var toReturn = RemoveVideoFromHistroryResponse() 23 | 24 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 25 | 26 | toReturn.isDisconnected = false 27 | toReturn.success = json["feedbackResponses", 0, "isProcessed"].bool == true 28 | 29 | return toReturn 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/DislikeCommentResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DislikeCommentResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 03.07.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | /// Response to dislike a comment on a video. 10 | public struct DislikeCommentResponse: SimpleActionAuthenticatedResponse { 11 | public static let headersType: HeaderTypes = .dislikeVideoHeaders 12 | 13 | public static let parametersValidationList: ValidationList = [.params: .existenceValidator] 14 | 15 | public var isDisconnected: Bool = true 16 | 17 | public var success: Bool = false 18 | 19 | public static func decodeJSON(json: JSON) -> Self { 20 | var toReturn = Self() 21 | 22 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 23 | 24 | toReturn.isDisconnected = false 25 | 26 | toReturn.success = json["actionResults"].arrayValue.first(where: {$0["status"].string == "STATUS_SUCCEEDED" && $0["feedback"].string == "STATUS_SUCCEEDED"}) != nil 27 | 28 | return toReturn 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/EditCommentResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditCommentResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 03.07.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | /// Response to edit a comment on a video. 10 | public struct EditCommentResponse: SimpleActionAuthenticatedResponse { 11 | public static let headersType: HeaderTypes = .editCommentHeaders 12 | 13 | public static let parametersValidationList: ValidationList = [.text: .textSanitizerValidator, .params: .existenceValidator] 14 | 15 | public var isDisconnected: Bool = true 16 | 17 | public var success: Bool = false 18 | 19 | public static func decodeJSON(json: JSON) -> Self { 20 | var toReturn = Self() 21 | 22 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 23 | 24 | toReturn.isDisconnected = false 25 | 26 | toReturn.success = json["actions"].arrayValue.first(where: {$0["updateCommentAction", "actionResult", "status"].string == "STATUS_SUCCEEDED"}) != nil 27 | 28 | return toReturn 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/RemoveLikeCommentResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoveLikeCommentResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 03.07.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | /// Response remove a like from a video. 10 | public struct RemoveLikeCommentResponse: SimpleActionAuthenticatedResponse { 11 | public static let headersType: HeaderTypes = .removeLikeCommentHeaders 12 | 13 | public static let parametersValidationList: ValidationList = [.params: .existenceValidator] 14 | 15 | public var isDisconnected: Bool = true 16 | 17 | public var success: Bool = false 18 | 19 | public static func decodeJSON(json: JSON) -> Self { 20 | var toReturn = Self() 21 | 22 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 23 | 24 | toReturn.isDisconnected = false 25 | 26 | toReturn.success = json["actionResults"].arrayValue.first(where: {$0["status"].string == "STATUS_SUCCEEDED" && $0["feedback"].string == "FEEDBACK_UNLIKE"}) != nil 27 | 28 | return toReturn 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/RemoveVideoByIdFromPlaylistResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoveVideoByIdFromPlaylistResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 16.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct RemoveVideoByIdFromPlaylistResponse: SimpleActionAuthenticatedResponse { 12 | public static let headersType: HeaderTypes = .removeVideoByIdFromPlaylistHeaders 13 | 14 | public static let parametersValidationList: ValidationList = [.movingVideoId: .videoIdValidator, .browseId: .playlistIdWithoutVLPrefixValidator] 15 | 16 | public var isDisconnected: Bool = true 17 | 18 | public var success: Bool = false 19 | 20 | public static func decodeJSON(json: JSON) -> RemoveVideoByIdFromPlaylistResponse { 21 | var toReturn = RemoveVideoByIdFromPlaylistResponse() 22 | 23 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true), json["status"].string == "STATUS_SUCCEEDED" else { return toReturn } 24 | 25 | toReturn.isDisconnected = false 26 | toReturn.success = true 27 | 28 | return toReturn 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/EditReplyCommandResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditReplyCommandResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 03.07.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | /// Response to edit a reply on a comment of a video. 10 | public struct EditReplyCommandResponse: SimpleActionAuthenticatedResponse { 11 | public static let headersType: HeaderTypes = .editReplyCommentHeaders 12 | 13 | public static let parametersValidationList: ValidationList = [.text: .textSanitizerValidator, .params: .existenceValidator] 14 | 15 | public var isDisconnected: Bool = true 16 | 17 | public var success: Bool = false 18 | 19 | public static func decodeJSON(json: JSON) -> Self { 20 | var toReturn = Self() 21 | 22 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 23 | 24 | toReturn.isDisconnected = false 25 | 26 | toReturn.success = json["actions"].arrayValue.first(where: {$0["updateCommentReplyAction", "actionResult", "status"].string == "STATUS_SUCCEEDED"}) != nil 27 | 28 | return toReturn 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseProtocols/Continuation/AuthenticatedContinuableResponse+fetchContinuation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticatedContinuableResponse+fetchContinuation.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 17.03.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension AuthenticatedContinuableResponse { 12 | func fetchContinuation(youtubeModel: YouTubeModel, result: @escaping @Sendable (Result) -> Void) { 13 | if let continuationToken = continuationToken { 14 | Continuation.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.continuation: continuationToken], result: result) 15 | } else { 16 | result(.failure("Continuation token is not defined.")) 17 | } 18 | } 19 | 20 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 21 | func fetchContinuation(youtubeModel: YouTubeModel) async throws -> Continuation { 22 | return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in 23 | fetchContinuation(youtubeModel: youtubeModel, result: { response in 24 | continuation.resume(with: response) 25 | }) 26 | }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/DeleteCommentResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeleteCommentResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 03.07.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | /// Response to delete a comment on from video. 10 | public struct DeleteCommentResponse: SimpleActionAuthenticatedResponse { 11 | public static let headersType: HeaderTypes = .removeCommentHeaders 12 | 13 | public static let parametersValidationList: ValidationList = [.params: .existenceValidator] 14 | 15 | public var isDisconnected: Bool = true 16 | 17 | public var success: Bool = false 18 | 19 | public static func decodeJSON(json: JSON) -> Self { 20 | var toReturn = Self() 21 | 22 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 23 | 24 | toReturn.isDisconnected = false 25 | 26 | toReturn.success = json["actions"].arrayValue.first(where: {$0["removeCommentAction", "actionResult", "status"].string == "STATUS_SUCCEEDED" && $0["removeCommentAction", "actionResult", "feedback"].string == "FEEDBACK_REMOVE"}) != nil 27 | 28 | return toReturn 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Examples/SearchView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchView.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 04.06.23. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SwiftUIView: View { 11 | private var YTM = YouTubeModel() 12 | @State private var text: String = "" 13 | var body: some View { 14 | TextField("Search", text: $text) 15 | Button { 16 | SearchResponse.sendNonThrowingRequest(youtubeModel: YTM, data: [.query : text], result: { result in 17 | switch result { 18 | case .success(let response): 19 | print("Got a response! \(String(describing: response))") 20 | case .failure(let error): 21 | print("Failed to get a response: \(error.localizedDescription)") 22 | } 23 | }) 24 | /// You can also use async await system or even using throws 25 | /* 26 | Task { 27 | let result = await SearchResponse.sendNonThrowingRequest(youtubeModel: YTM, data: [.query : text]) 28 | // or 29 | let result = try await SearchResponse.sendThrowingRequest(youtubeModel: YTM, data: [.query : text]) 30 | } 31 | */ 32 | } label: { 33 | Text("Search") 34 | } 35 | } 36 | } 37 | 38 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/VideoInfos/CommentTranslationResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommentTranslationResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 03.07.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | public struct CommentTranslationResponse: YouTubeResponse { 10 | public static let headersType: HeaderTypes = .translateCommentHeaders 11 | 12 | public static let parametersValidationList: ValidationList = [.params: .existenceValidator] 13 | 14 | public var translation: String 15 | 16 | public static func decodeJSON(json: JSON) throws -> Self { 17 | guard json["actionResults"].arrayValue.first(where: {$0["status"].string == "STATUS_SUCCEEDED"}) != nil else { 18 | throw ResponseExtractionError(reponseType: Self.self, stepDescription: "Request result is not successful.") 19 | } 20 | 21 | guard let translatedText = json["frameworkUpdates", "entityBatchUpdate", "mutations"].arrayValue.first(where: {$0["payload", "commentEntityPayload", "translatedContent", "content"].string != nil })?["payload", "commentEntityPayload", "translatedContent", "content"].string else { 22 | throw ResponseExtractionError(reponseType: Self.self, stepDescription: "Couldn't extract translted comment.") 23 | } 24 | 25 | return Self(translation: translatedText) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/RemoveVideoFromPlaylistResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoveVideoFromPlaylistResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 16.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// - Note: For the moment, no extraction of the `playlistEditToken` has been done and you need to pass `"CAFAAQ%3D%3D"` as an argument for it. 12 | public struct RemoveVideoFromPlaylistResponse: SimpleActionAuthenticatedResponse { 13 | public static let headersType: HeaderTypes = .removeVideoFromPlaylistHeaders 14 | 15 | public static let parametersValidationList: ValidationList = [.movingVideoId: .existenceValidator, .playlistEditToken: .existenceValidator, .browseId: .playlistIdWithoutVLPrefixValidator] 16 | 17 | public var isDisconnected: Bool = true 18 | 19 | /// Boolean indicating whether the remove action was successful. 20 | public var success: Bool = false 21 | 22 | public static func decodeJSON(json: JSON) -> RemoveVideoFromPlaylistResponse { 23 | var toReturn = RemoveVideoFromPlaylistResponse() 24 | 25 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true), json["status"].string == "STATUS_SUCCEEDED" else { return toReturn } 26 | 27 | toReturn.isDisconnected = false 28 | toReturn.success = true 29 | 30 | return toReturn 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/ChannelsActions/SubscribeChannelResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubsribeChannelResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 16.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct SubscribeChannelResponse: SimpleActionAuthenticatedResponse { 12 | public static let headersType: HeaderTypes = .subscribeToChannelHeaders 13 | 14 | public static let parametersValidationList: ValidationList = [.browseId: .channelIdValidator] 15 | 16 | public var isDisconnected: Bool = true 17 | 18 | /// Boolean indicating whether the append action was successful. 19 | public var success: Bool = false 20 | 21 | public var channelId: String? 22 | 23 | public static func decodeJSON(json: JSON) -> SubscribeChannelResponse { 24 | var toReturn = SubscribeChannelResponse() 25 | 26 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 27 | 28 | toReturn.isDisconnected = false 29 | 30 | for action in json["actions"].arrayValue { 31 | let subscribeButton = action["updateSubscribeButtonAction"] 32 | if subscribeButton.exists() { 33 | toReturn.channelId = subscribeButton["channelId"].string 34 | toReturn.success = subscribeButton["subscribed"].boolValue 35 | } 36 | } 37 | 38 | return toReturn 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/ChannelsActions/UnsubscribeChannelResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UnsubscribeChannelResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 16.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct UnsubscribeChannelResponse: SimpleActionAuthenticatedResponse { 12 | public static let headersType: HeaderTypes = .unsubscribeFromChannelHeaders 13 | 14 | public static let parametersValidationList: ValidationList = [.browseId: .channelIdValidator] 15 | 16 | public var isDisconnected: Bool = true 17 | 18 | /// Boolean indicating whether the append action was successful. 19 | public var success: Bool = false 20 | 21 | public var channelId: String? 22 | 23 | public static func decodeJSON(json: JSON) -> UnsubscribeChannelResponse { 24 | var toReturn = UnsubscribeChannelResponse() 25 | 26 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 27 | 28 | toReturn.isDisconnected = false 29 | 30 | for action in json["actions"].arrayValue { 31 | let subscribeButton = action["updateSubscribeButtonAction"] 32 | if subscribeButton.exists() { 33 | toReturn.channelId = subscribeButton["channelId"].string 34 | toReturn.success = !subscribeButton["subscribed"].boolValue 35 | } 36 | } 37 | 38 | return toReturn 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseProtocols/Continuation/ContinuableResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContinuableResponse.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 12.07.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A protocol describing a ``YouTubeResponse`` that can have a continuation. 11 | public protocol ContinuableResponse: ResponseContinuation { 12 | associatedtype Continuation: ResponseContinuation where Continuation.ResultsType == ResultsType 13 | 14 | /// String token that is necessary to give to the continuation request in order to make it to work (it sorts of authenticate the continuation). 15 | /// 16 | /// Only present in first ``ContinuableResponse``and not in the continuations. 17 | var visitorData: String? { get set } 18 | 19 | /// Merge a ``ContinuableResponse/Continuation`` to this instance of ``ContinuableResponse``. 20 | /// - Parameter continuation: the ``ContinuableResponse/Continuation`` that will be merged. 21 | mutating func mergeContinuation(_ continuation: Continuation) 22 | 23 | /// Fetch the continuation of the ``ContinuableResponse``. 24 | func fetchContinuation( 25 | youtubeModel: YouTubeModel, 26 | useCookies: Bool?, 27 | result: @escaping @Sendable (Result) -> Void 28 | ) 29 | 30 | /// Fetch the continuation of the ``ContinuableResponse``. 31 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 32 | func fetchContinuationThrowing( 33 | youtubeModel: YouTubeModel, 34 | useCookies: Bool? 35 | ) async throws -> Continuation 36 | } 37 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/AccountResponses/AccountPlaylistsResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountPlaylistsResponse.swift 3 | // YouTubeKit 4 | // 5 | // Created by Antoine Bollengier on 27.11.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Response containing all the playlists of a user. 12 | public struct AccountPlaylistsResponse: AuthenticatedResponse { 13 | public static let headersType: HeaderTypes = .usersPlaylistsHeaders 14 | 15 | public static let parametersValidationList: ValidationList = [:] 16 | 17 | public var isDisconnected: Bool = true 18 | 19 | public var results: [YTPlaylist] = [] 20 | 21 | public static func decodeJSON(json: JSON) -> AccountPlaylistsResponse { 22 | var toReturn = AccountPlaylistsResponse() 23 | 24 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 25 | 26 | toReturn.isDisconnected = false 27 | 28 | for tab in json["contents", "twoColumnBrowseResultsRenderer", "tabs"].arrayValue { 29 | guard tab["tabRenderer", "selected"].bool == true else { continue } 30 | 31 | for playlistJSON in tab["tabRenderer", "content", "richGridRenderer", "contents"].arrayValue { 32 | if let playlist = YTPlaylist.decodeLockupJSON(json: playlistJSON["richItemRenderer", "content", "lockupViewModel"]) { 33 | toReturn.results.append(playlist) 34 | } 35 | } 36 | } 37 | 38 | return toReturn 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/DeletePlaylistResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeletePlaylistResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 16.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct DeletePlaylistResponse: SimpleActionAuthenticatedResponse { 12 | public static let headersType: HeaderTypes = .deletePlaylistHeaders 13 | 14 | public static let parametersValidationList: ValidationList = [.browseId: .playlistIdWithoutVLPrefixValidator] 15 | 16 | public var isDisconnected: Bool = true 17 | 18 | /// Boolean indicating whether the delete action was successful. 19 | public var success: Bool = false 20 | 21 | /// String representing the playlist's id. 22 | public var playlistId: String? 23 | 24 | public static func decodeJSON(json: JSON) -> DeletePlaylistResponse { 25 | var toReturn = DeletePlaylistResponse() 26 | 27 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 28 | 29 | toReturn.isDisconnected = false 30 | 31 | for command in json["command", "commandExecutorCommand", "commands"].arrayValue { 32 | if command["removeFromGuideSectionAction", "handlerData"].string == "GUIDE_ACTION_REMOVE_FROM_PLAYLISTS" { 33 | toReturn.success = true 34 | } 35 | if let playlistId = command["removeFromGuideSectionAction", "guideEntryId"].string { 36 | toReturn.playlistId = playlistId 37 | } 38 | } 39 | return toReturn 40 | } 41 | } 42 | 43 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/VideoInfos/DownloadFormat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadFormat.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 20.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Protocol representing a particular video/audio format that can be downloaded. 11 | public protocol DownloadFormat: Sendable { 12 | /// Type of the format. 13 | static var type: MediaType { get } 14 | 15 | /// Average birate of the media. 16 | var averageBitrate: Int? { get } 17 | 18 | /// Content length of the media, in bytes. 19 | var contentLength: Int? { get } 20 | 21 | /// Duration of the media in milliseconds. 22 | var contentDuration: Int? { get } 23 | 24 | /// Boolean indicating if the media is protected by YouTube from downloading. 25 | /// 26 | /// **Warning**: 27 | /// This property doesn't tell you if the media is copyright-free! 28 | var isCopyrightedMedia: Bool? { get } 29 | 30 | /// Download URL of the format. 31 | var url: URL? { get set } 32 | 33 | /// The mimeType of the format. 34 | /// 35 | /// Is usually "video/mp4", "video/webm", "audio/mp4" or "audio/webm". 36 | /// - Note: The WebM (mimeType: "audio/webm" or "video/webm") format isn't supported natively by AVFoundation. 37 | var mimeType: String? { get set } 38 | 39 | /// The codec of the format. 40 | /// 41 | /// It can be "avc1", "mp4a" or "av01" for example. 42 | /// - Note: The AV1 codec (codec: "av01") isn't supported natively by AVFoundation (for the moment) if you use it with an `AVMutableComposition`. 43 | var codec: String? { get set } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/URL+AppendQueryItems.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+AppendQueryItems.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 19.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension URL { 11 | /// adapted from https://stackoverflow.com/questions/34060754/how-can-i-build-a-url-with-query-parameters-containing-multiple-values-for-the-s 12 | /// If `queryItems` contains mulitple times headers with the same name, only the first will be kept. 13 | mutating func append(queryItems queryItemsToAdd: [URLQueryItem]) { 14 | guard var urlComponents = URLComponents(string: self.absoluteString) else { return } 15 | 16 | /// Create array of existing query items. 17 | var queryItems: [URLQueryItem] = urlComponents.queryItems ?? [] 18 | 19 | /// Remove parameters that contain values that will be updated in `queryItemsToAdd`. 20 | for queryItemToAdd in queryItemsToAdd { 21 | queryItems.removeAll(where: {$0.name == queryItemToAdd.name}) 22 | } 23 | 24 | /// Append the new query item in the existing query items array. 25 | queryItems.append(contentsOf: queryItemsToAdd.makeUnique()) 26 | 27 | /// Append updated query items array in the url component object. 28 | urlComponents.queryItems = queryItems 29 | 30 | /// Returns the url from new url components. 31 | self = urlComponents.url! 32 | } 33 | 34 | func appending(queryItems queryItemsToAdd: [URLQueryItem]) -> URL { 35 | var secondSelf = self 36 | 37 | secondSelf.append(queryItems: queryItemsToAdd) 38 | 39 | return secondSelf 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/MoveVideoInPlaylistResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoveVideoInPlaylistResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 16.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct MoveVideoInPlaylistResponse: SimpleActionAuthenticatedResponse { 12 | public static let headersType: HeaderTypes = .moveVideoInPlaylistHeaders 13 | 14 | public static let parametersValidationList: ValidationList = [.movingVideoId: .existenceValidator, .browseId: .playlistIdWithoutVLPrefixValidator] 15 | 16 | public var isDisconnected: Bool = true 17 | 18 | /// Boolean indicating whether the append action was successful. 19 | public var success: Bool = false 20 | 21 | /// String representing the playlist's id. 22 | public var playlistId: String? 23 | 24 | public static func decodeJSON(json: JSON) -> MoveVideoInPlaylistResponse { 25 | var toReturn = MoveVideoInPlaylistResponse() 26 | 27 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true), json["status"].string == "STATUS_SUCCEEDED" else { return toReturn } 28 | 29 | toReturn.isDisconnected = false 30 | toReturn.success = true 31 | 32 | for action in json["actions"].arrayValue { 33 | let newPlaylistRenderer = action["updatePlaylistAction", "updatedRenderer", "playlistVideoListRenderer"] 34 | if newPlaylistRenderer.exists() { 35 | toReturn.playlistId = newPlaylistRenderer["playlistId"].string 36 | break 37 | } 38 | } 39 | 40 | return toReturn 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseStructs/YTPlaylist+decodeShowFromJSON.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTPlaylist+decodeShowFromJSON.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 25.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension YTPlaylist { 11 | /// Decode a certain type of playlist named "show", can be recognized because they are stored in dicitonnaries named "gridShowRenderer". 12 | /// - Parameter json: the JSON representing the **inside** of the "gridShowRenderer". 13 | /// - Returns: A playlist if the decoding was successful, or nil if it wasn't. You can already know if the decoding is going to be successful using the `canShowBeDecoded` method. 14 | static func decodeShowFromJSON(json: JSON) -> YTPlaylist? { 15 | /// Decode the playlist that is named a "show" by YouTube. 16 | guard let playlistId = json["navigationEndpoint", "browseEndpoint", "browseId"].string else { return nil } 17 | var playlist = YTPlaylist(playlistId: playlistId.hasPrefix("VL") ? playlistId : "VL" + playlistId) 18 | playlist.title = json["title", "simpleText"].string 19 | 20 | YTThumbnail.appendThumbnails(json: json["thumbnailRenderer", "showCustomThumbnailRenderer", "thumbnail"], thumbnailList: &playlist.thumbnails) 21 | 22 | guard let videoCountArray = json["thumbnailOverlays"].array else { return playlist } 23 | 24 | for videoCountPotential in videoCountArray { 25 | if let videoCount = videoCountPotential["thumbnailOverlayBottomPanelRenderer", "text", "simpleText"].string { 26 | playlist.videoCount = videoCount 27 | } else if let videoCountTextArray = videoCountPotential["thumbnailOverlayBottomPanelRenderer", "text", "runs"].array { 28 | playlist.videoCount = videoCountTextArray.map({$0["text"].stringValue}).joined() 29 | } 30 | } 31 | 32 | return playlist 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/AccountResponses/AccountInfosResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountInfosResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 15.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Response containing information about the account. 12 | public struct AccountInfosResponse: AuthenticatedResponse { 13 | public static let headersType: HeaderTypes = .userAccountHeaders 14 | 15 | public static let parametersValidationList: ValidationList = [:] 16 | 17 | public var isDisconnected: Bool = true 18 | 19 | /// The name of the account. 20 | public var name: String? 21 | 22 | /// An array of ``YTThumbnail`` representing the avatar of the user. 23 | public var avatar: [YTThumbnail] = [] 24 | 25 | /// The channelHandle of the user, can be nil if the user does not have a channel. 26 | public var channelHandle: String? 27 | 28 | public static func decodeJSON(json: JSON) -> AccountInfosResponse { 29 | var toReturn = AccountInfosResponse() 30 | 31 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 32 | 33 | toReturn.isDisconnected = false 34 | 35 | for action in json["actions"].arrayValue { 36 | let accountInfos = action["openPopupAction", "popup", "multiPageMenuRenderer", "header", "activeAccountHeaderRenderer"] 37 | if accountInfos.exists() { 38 | YTThumbnail.appendThumbnails(json: accountInfos["accountPhoto"], thumbnailList: &toReturn.avatar) 39 | 40 | toReturn.channelHandle = accountInfos["channelHandle", "simpleText"].string 41 | 42 | toReturn.name = accountInfos["accountName", "simpleText"].string 43 | 44 | break 45 | } 46 | } 47 | 48 | return toReturn 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/Utils/String+ytkRegexMatches.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+ytkRegexMatches.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 09.05.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension String { 12 | // adapted from https://stackoverflow.com/a/27880748/16456439 13 | /// A method to return all the matches for a certain regular expression. 14 | /// Every sub-array starts with the whole match and the capture groups follow. 15 | func ytkRegexMatches(for regex: NSRegularExpression) -> [[String]] { 16 | return regex.matches(in: self, range: NSRange(self.startIndex..., in: self)) 17 | .map { match in 18 | var returnArray: [String] = [] 19 | for rangeIndex in 0.. [[String]] { 31 | guard let regex = try? NSRegularExpression(pattern: stringRegex) else { return [] } 32 | return self.ytkRegexMatches(for: regex) 33 | } 34 | 35 | /// Gets the first capture group of the first match. 36 | func ytkFirstGroupMatch(for regex: NSRegularExpression) -> String? { 37 | return self.ytkRegexMatches(for: regex).first?.dropFirst().first 38 | } 39 | 40 | /// Gets the first capture group of the first match. 41 | func ytkFirstGroupMatch(for stringRegex: String) -> String? { 42 | guard let regex = try? NSRegularExpression(pattern: stringRegex) else { return nil } 43 | return self.ytkFirstGroupMatch(for: regex) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/YouTubeKitTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 16 | 18 | 24 | 25 | 26 | 27 | 28 | 38 | 39 | 45 | 46 | 48 | 49 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/Logging/RequestLogger+defaultImplementations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestsLogger+defaultImplementations.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 06.03.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension RequestsLogger { 12 | func startLogging() { 13 | self.isLogging = true 14 | } 15 | 16 | func stopLogging() { 17 | self.isLogging = false 18 | } 19 | 20 | 21 | func setCacheSize(_ size: Int?) { 22 | self.maximumCacheSize = size 23 | if let size = size { 24 | self.removeFirstLogsWith(limit: size) 25 | } 26 | } 27 | 28 | 29 | func addLog(_ log: any GenericRequestLog) { 30 | func compareTypes(log1: T, log2: U.Type) -> Bool { 31 | let newRequest: RequestLog 32 | return type(of: log1) == type(of: newRequest) 33 | } 34 | 35 | guard self.isLogging, (self.maximumCacheSize ?? 1) > 0 else { return } 36 | guard (self.loggedTypes?.contains(where: { compareTypes(log1: log, log2: $0) }) ?? true) else { return } 37 | 38 | if let maximumCacheSize = self.maximumCacheSize { 39 | self.removeFirstLogsWith(limit: max(maximumCacheSize - 1, 0)) 40 | } 41 | self.logs.append(log) 42 | } 43 | 44 | 45 | func clearLogs() { 46 | self.logs.removeAll() 47 | } 48 | 49 | func clearLogsWithIds(_ ids: [UUID]) { 50 | for idToRemove in ids { 51 | self.logs.removeAll(where: {$0.id == idToRemove}) 52 | } 53 | } 54 | 55 | func clearLogWithId(_ id: UUID) { 56 | self.clearLogsWithIds([id]) 57 | } 58 | 59 | private func removeFirstLogsWith(limit maxCacheSize: Int) { 60 | let logsCount = self.logs.count 61 | let maxCacheSize = max(0, maxCacheSize) 62 | if logsCount > maxCacheSize { 63 | self.logs.removeFirst(abs(maxCacheSize - logsCount)) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseProtocols/Video/YouTubeVideo+getCaptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YouTubeVideo+getCaptions.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 27.06.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | public extension YouTubeVideo { 10 | /// Get the captions for the current video. 11 | static func getCaptions(youtubeModel: YouTubeModel, captionType: YTCaption, result: @escaping @Sendable (Result) -> Void) { 12 | VideoCaptionsResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.customURL: captionType.url.absoluteString], result: { response in 13 | switch response { 14 | case .success(let data): 15 | result(.success(data)) 16 | case .failure(let error): 17 | result(.failure(error)) 18 | } 19 | }) 20 | } 21 | 22 | /// Get the captions for the current video. 23 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 24 | static func getCaptionsThrowing(youtubeModel: YouTubeModel, captionType: YTCaption) async throws -> VideoCaptionsResponse { 25 | return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in 26 | self.getCaptions(youtubeModel: youtubeModel, captionType: captionType, result: { result in 27 | continuation.resume(with: result) 28 | }) 29 | }) 30 | } 31 | 32 | /// Get the captions for the current video. 33 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 34 | static func getCaptions(youtubeModel: YouTubeModel, captionType: YTCaption) async -> Result { 35 | return await withCheckedContinuation({ (continuation: CheckedContinuation, Never>) in 36 | self.getCaptions(youtubeModel: youtubeModel, captionType: captionType, result: { result in 37 | continuation.resume(returning: result) 38 | }) 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseStructs/YTVideo+decodeVideoFromPlaylist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTVideo+decodeVideoFromPlaylist.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 28.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension YTVideo { 11 | /// Decode a certain type of JSON dictionnary called "playlistVideoRenderer" or "playlistVideoListRenderer" 12 | /// - Parameter json: the JSON to be decoded. 13 | /// - Returns: A YTVideo if the decoding was successful or nil if it wasn't. 14 | static func decodeVideoFromPlaylist(json: JSON) -> YTVideo? { 15 | /// Check if the JSON can be decoded as a Video. 16 | guard let videoId = json["videoId"].string else { return nil } 17 | 18 | /// Inititalize a new ``YTSearchResultType/Video-swift.struct`` instance to put the informations in it. 19 | var video = YTVideo(videoId: videoId) 20 | 21 | if json["title", "simpleText"].string != nil { 22 | video.title = json["title", "simpleText"].string 23 | } else if let titleArray = json["title", "runs"].array { 24 | video.title = titleArray.map({$0["text"].stringValue}).joined() 25 | } 26 | 27 | if let channelId = json["shortBylineText", "runs", 0, "navigationEndpoint", "browseEndpoint", "browseId"].string { 28 | 29 | video.channel = YTLittleChannelInfos(channelId: channelId, name: json["shortBylineText", "runs", 0, "text"].string) 30 | } 31 | 32 | video.viewCount = json["videoInfo", "runs", 0, "text"].string 33 | 34 | video.timePosted = json["videoInfo", "runs", 2, "text"].string 35 | 36 | if let timeLength = json["lengthText", "simpleText"].string { 37 | video.timeLength = timeLength 38 | } else { 39 | video.timeLength = "live" 40 | } 41 | 42 | YTThumbnail.appendThumbnails(json: json["thumbnail"], thumbnailList: &video.thumbnails) 43 | 44 | return video 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/VideoInfos/VideoDownloadFormat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VideoDownloadFormat.swift 3 | // YouTubeKit 4 | // 5 | // Created by Antoine Bollengier on 02.08.2025. 6 | // Copyright © 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Struct representing a download format that contains the video and audio. 12 | public struct VideoDownloadFormat: DownloadFormat { 13 | public init(averageBitrate: Int? = nil, contentDuration: Int? = nil, contentLength: Int? = nil, is360: Bool? = nil, isCopyrightedMedia: Bool? = nil, mimeType: String? = nil, codec: String? = nil, url: URL? = nil, width: Int? = nil, height: Int? = nil, quality: String? = nil, fps: Int? = nil) { 14 | self.averageBitrate = averageBitrate 15 | self.contentDuration = contentDuration 16 | self.contentLength = contentLength 17 | self.is360 = is360 18 | self.isCopyrightedMedia = isCopyrightedMedia 19 | self.mimeType = mimeType 20 | self.codec = codec 21 | self.url = url 22 | self.width = width 23 | self.height = height 24 | self.quality = quality 25 | self.fps = fps 26 | } 27 | 28 | /// Protocol properties 29 | public static let type: MediaType = .video 30 | 31 | public var averageBitrate: Int? 32 | 33 | public var contentDuration: Int? 34 | 35 | public var contentLength: Int? 36 | 37 | public var is360: Bool? 38 | 39 | public var isCopyrightedMedia: Bool? 40 | 41 | public var mimeType: String? 42 | 43 | public var codec: String? 44 | 45 | public var url: URL? 46 | 47 | /// Video-specific infos 48 | 49 | /// Width in pixels of the media. 50 | public var width: Int? 51 | 52 | /// Height in pixels of the media. 53 | public var height: Int? 54 | 55 | /// Quality label of the media 56 | /// 57 | /// For example: 58 | /// - **720p** 59 | /// - **480p** 60 | /// - **360p** 61 | public var quality: String? 62 | 63 | /// Frames per second of the media. 64 | public var fps: Int? 65 | } 66 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseStructs/YTComment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTComment.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 02.07.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | public struct YTComment: Sendable { 10 | public init(commentIdentifier: String, sender: YTChannel? = nil, text: String, timePosted: String? = nil, translateString: String? = nil, likesCount: String? = nil, likesCountWhenUserLiked: String? = nil, replyLevel: Int? = nil, replies: [YTComment], totalRepliesNumber: String? = nil, actionsParams: [CommentAction : String], likeState: YTLikeStatus? = nil, isLikedByVideoCreator: Bool? = nil) { 11 | self.commentIdentifier = commentIdentifier 12 | self.sender = sender 13 | self.text = text 14 | self.timePosted = timePosted 15 | self.translateString = translateString 16 | self.likesCount = likesCount 17 | self.likesCountWhenUserLiked = likesCountWhenUserLiked 18 | self.replyLevel = replyLevel 19 | self.replies = replies 20 | self.totalRepliesNumber = totalRepliesNumber 21 | self.actionsParams = actionsParams 22 | self.likeState = likeState 23 | self.isLikedByVideoCreator = isLikedByVideoCreator 24 | } 25 | 26 | public var commentIdentifier: String 27 | 28 | public var sender: YTChannel? 29 | 30 | public var text: String 31 | 32 | public var timePosted: String? 33 | 34 | public var translateString: String? 35 | 36 | public var likesCount: String? 37 | 38 | public var likesCountWhenUserLiked: String? 39 | 40 | public var replyLevel: Int? 41 | 42 | public var replies: [YTComment] = [] 43 | 44 | public var totalRepliesNumber: String? 45 | 46 | public var actionsParams: [CommentAction: String] = [:] 47 | 48 | public var likeState: YTLikeStatus? 49 | 50 | public var isLikedByVideoCreator: Bool? 51 | 52 | public enum CommentAction: String, Sendable { 53 | case like, dislike, removeLike, removeDislike 54 | case reply 55 | case edit, delete 56 | case repliesContinuation 57 | case translate 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/CreateCommentResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateCommentResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 03.07.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | /// Response to create a comment on a video. 10 | public struct CreateCommentResponse: SimpleActionAuthenticatedResponse { 11 | public static let headersType: HeaderTypes = .createCommentHeaders 12 | 13 | public static let parametersValidationList: ValidationList = [.params: .existenceValidator, .text: .textSanitizerValidator] 14 | 15 | public var isDisconnected: Bool = true 16 | 17 | public var success: Bool = false 18 | 19 | public var newComment: YTComment? 20 | 21 | public static func decodeJSON(json: JSON) throws -> Self { 22 | var toReturn = Self() 23 | 24 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 25 | 26 | toReturn.isDisconnected = false 27 | 28 | toReturn.success = json["actionResult", "status"].string == "STATUS_SUCCEEDED" 29 | 30 | var modifiedJSONForVideoCommentsResponse = JSON() 31 | 32 | modifiedJSONForVideoCommentsResponse["responseContext"] = json["responseContext"] 33 | 34 | modifiedJSONForVideoCommentsResponse["frameworkUpdates"] = json["frameworkUpdates"] 35 | 36 | guard let createCommentOrReplyActionJSON = json["actions"].arrayValue.first(where: {$0["createCommentAction"].exists()})?["createCommentAction", "contents"].rawString() ?? json["actions"].arrayValue.first(where: {$0["createCommentReplyAction"].exists()})?["createCommentReplyAction", "contents"].rawString() else { 37 | throw ResponseExtractionError(reponseType: Self.self, stepDescription: "Couldn't extract the creation tokens.") 38 | } 39 | 40 | modifiedJSONForVideoCommentsResponse["onResponseReceivedEndpoints"] = JSON(parseJSON: "[{\"reloadContinuationItemsCommand\": {\"continuationItems\": [\(createCommentOrReplyActionJSON)]}}]") 41 | 42 | toReturn.newComment = try VideoCommentsResponse.decodeJSON(json: modifiedJSONForVideoCommentsResponse).results.first 43 | 44 | return toReturn 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/CreatePlaylistResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreatePlaylistResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 16.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct CreatePlaylistResponse: AuthenticatedResponse { 12 | public static let headersType: HeaderTypes = .createPlaylistHeaders 13 | 14 | public static let parametersValidationList: ValidationList = [.query: .existenceValidator, .params: .privacyValidator, .movingVideoId: .optionalVideoIdValidator] 15 | 16 | public var isDisconnected: Bool = true 17 | 18 | /// String representing the new playlist's id. 19 | public var createdPlaylistId: String? 20 | 21 | /// String representing the account's id. 22 | public var playlistCreatorId: String? 23 | 24 | public static func decodeJSON(json: JSON) -> CreatePlaylistResponse { 25 | var toReturn = CreatePlaylistResponse() 26 | 27 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 28 | 29 | toReturn.isDisconnected = false 30 | 31 | (toReturn.createdPlaylistId, toReturn.playlistCreatorId) = CreatePlaylistResponse.extractPlaylistAndCreatorIdsFrom(json: json) 32 | 33 | return toReturn 34 | } 35 | 36 | /// Extracts from a JSON response (generally a playlist modification response) the playlistId and the playlist's creator's id. 37 | public static func extractPlaylistAndCreatorIdsFrom(json: JSON) -> (playlistId: String?, creatorId: String?) { 38 | var toReturn: (playlistId: String?, creatorId: String?) = (nil, nil) 39 | for action in json["actions"].arrayValue { 40 | if let results = action["runAttestationCommand", "ids"].array { 41 | for result in results { 42 | if let playlistId = result["playlistId"].string { 43 | toReturn.playlistId = "VL" + playlistId 44 | } else if let playlistCreatorId = result["externalChannelId"].string { 45 | toReturn.creatorId = playlistCreatorId 46 | } 47 | } 48 | } 49 | } 50 | return toReturn 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseStructs/YTVideo+decodeShortFromJSON.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTVideo+decodeShortFromJSON.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 24.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension YTVideo { 11 | /// Process the JSON and give a decoded version of it. 12 | /// - Parameter json: the JSON to be decoded, should have a "videoId" directly in it. 13 | /// - Returns: An instance of YTVideo, that is actually representing a short. 14 | /// 15 | /// The informations about shorts are very little compared to the informations you would get with a normal video. 16 | static func decodeShortFromJSON(json: JSON) -> YTVideo? { 17 | /// Check if the JSON can be decoded as a Video. 18 | guard let videoId = json["videoId"].string else { return nil } 19 | 20 | /// Inititalize a new ``YTSearchResultType/Video-swift.struct`` instance to put the informations in it. 21 | var video = YTVideo(videoId: videoId) 22 | 23 | video.title = json["headline", "simpleText"].string 24 | 25 | video.viewCount = json["viewCountText", "simpleText"].string 26 | 27 | YTThumbnail.appendThumbnails(json: json["thumbnail"], thumbnailList: &video.thumbnails) 28 | 29 | return video 30 | } 31 | 32 | // Process the JSON and give a decoded version of it. 33 | /// - Parameter json: the JSON to be decoded, should be a `shortsLockupViewModel`. 34 | /// - Returns: An instance of YTVideo, that is actually representing a short. 35 | /// 36 | /// The informations about shorts are very little compared to the informations you would get with a normal video. 37 | static func decodeShortFromLockupJSON(json: JSON) -> YTVideo? { 38 | guard let videoId = json["onTap", "innertubeCommand", "reelWatchEndpoint", "videoId"].string else { return nil } 39 | 40 | var video = YTVideo(videoId: videoId) 41 | 42 | video.title = json["overlayMetadata", "primaryText", "content"].string 43 | 44 | video.viewCount = json["overlayMetadata", "secondaryText", "content"].string 45 | 46 | YTThumbnail.appendThumbnails(json: json["thumbnail"], thumbnailList: &video.thumbnails) 47 | 48 | return video 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/AddVideoToPlaylistResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddVideoToPlaylistResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 16.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct AddVideoToPlaylistResponse: SimpleActionAuthenticatedResponse { 12 | public static let headersType: HeaderTypes = .addVideoToPlaylistHeaders 13 | 14 | public static let parametersValidationList: ValidationList = [.browseId: .playlistIdWithoutVLPrefixValidator, .movingVideoId: .videoIdValidator] 15 | 16 | public var isDisconnected: Bool = true 17 | 18 | /// Boolean indicating whether the append action was successful. 19 | public var success: Bool = false 20 | 21 | /// String representing the id of the video that has been added to the playlist. 22 | public var addedVideoId: String? 23 | 24 | /// String representing the id in the playlist of the video that has been added to the playlist. 25 | public var addedVideoIdInPlaylist: String? 26 | 27 | /// String representing the playlist's id. 28 | public var playlistId: String? 29 | 30 | /// String representing the account's id. 31 | public var playlistCreatorId: String? 32 | 33 | public static func decodeJSON(json: JSON) -> AddVideoToPlaylistResponse { 34 | var toReturn = AddVideoToPlaylistResponse() 35 | 36 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true), json["status"].string == "STATUS_SUCCEEDED", let playlistModificationResults = json["playlistEditResults"].array else { return toReturn } 37 | 38 | toReturn.isDisconnected = false 39 | toReturn.success = true 40 | 41 | for playlistModificationResult in playlistModificationResults { 42 | if playlistModificationResult["playlistEditVideoAddedResultData"].exists() { 43 | toReturn.addedVideoId = playlistModificationResult["playlistEditVideoAddedResultData", "videoId"].string 44 | toReturn.addedVideoIdInPlaylist = playlistModificationResult["playlistEditVideoAddedResultData", "setVideoId"].string 45 | break 46 | } 47 | } 48 | 49 | (toReturn.playlistId, toReturn.playlistCreatorId) = CreatePlaylistResponse.extractPlaylistAndCreatorIdsFrom(json: json) 50 | 51 | return toReturn 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/Logging/RequestLog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestLog.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 06.03.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A protocol whose sole purpose is to have the ability to store the logs into an array in ``RequestsLogger/logs``. 12 | public protocol GenericRequestLog { 13 | associatedtype LogType: YouTubeResponse 14 | typealias RequestParameters = [HeadersList.AddQueryInfo.ContentTypes : String] 15 | 16 | /// The id of the log, can be used to remove it from ``RequestsLogger/logs`` using ``RequestsLogger/clearLogWithId(_:)`` or ``RequestsLogger/clearLogsWithIds(_:)``. 17 | var id: UUID { get } 18 | 19 | /// The date when the request has been finished (data has been received and processed). 20 | var date: Date { get } 21 | 22 | /// The request parameters provided in ``YouTubeModel/sendRequest(responseType:data:useCookies:result:)``. 23 | var providedParameters: RequestParameters { get } 24 | 25 | /// The request that has been sent over the network, can be nil if the request couldn't even be made, for example if one of the given RequestParameter didn't pass the verification tests. 26 | var request: URLRequest? { get } 27 | 28 | /// The raw data from the response. 29 | var responseData: Data? { get } 30 | 31 | /// The type of the request 32 | var expectedResultType: LogType.Type { get } 33 | 34 | /// The processed result or an error if there was one during the process. 35 | var result: Result { get } 36 | } 37 | 38 | /// A structure representing a log. 39 | public struct RequestLog: Identifiable, GenericRequestLog { 40 | public typealias LogType = ResponseType 41 | 42 | public let id = UUID() 43 | 44 | public let date = Date() 45 | 46 | public let providedParameters: RequestParameters 47 | 48 | public let request: URLRequest? 49 | 50 | public let responseData: Data? 51 | 52 | public let expectedResultType = LogType.self 53 | 54 | public let result: Result 55 | 56 | public init(providedParameters: RequestParameters, request: URLRequest?, responseData: Data?, result: Result) { 57 | self.providedParameters = providedParameters 58 | self.request = request 59 | self.responseData = responseData 60 | self.result = result 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/AllPossibleHostPlaylistsResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AllPossibleHostPlaylistsResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 17.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct AllPossibleHostPlaylistsResponse: AuthenticatedResponse { 12 | public static let headersType: HeaderTypes = .usersAllPlaylistsHeaders 13 | 14 | public static let parametersValidationList: ValidationList = [.browseId: .videoIdValidator] 15 | 16 | public var isDisconnected: Bool = true 17 | 18 | public var playlistsAndStatus: [(playlist: YTPlaylist, isVideoPresentInside: Bool)] = [] 19 | 20 | public static func decodeJSON(json: JSON) -> AllPossibleHostPlaylistsResponse { 21 | var toReturn = AllPossibleHostPlaylistsResponse() 22 | 23 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 24 | 25 | toReturn.isDisconnected = false 26 | 27 | for content in json["contents"].arrayValue { 28 | if content["addToPlaylistRenderer"].exists() { 29 | for playlistJSON in content["addToPlaylistRenderer", "playlists"].arrayValue { 30 | if let playlistId = playlistJSON["playlistAddToOptionRenderer", "playlistId"].string, let containsVideo = playlistJSON["playlistAddToOptionRenderer", "containsSelectedVideos"].string { 31 | 32 | // 2 ways of creating a playlist, YouTube changed (temporarily?) the system by removing the VL prefix at the beginning of the playlist's id. 33 | //var playlist = YTPlaylist(playlistId: playlistId.hasPrefix("VL") ? playlistId : "VL" + playlistId) 34 | var playlist = YTPlaylist(playlistId: playlistId.hasPrefix("VL") ? String(playlistId.dropFirst(2)) : playlistId) 35 | 36 | 37 | playlist.title = playlistJSON["playlistAddToOptionRenderer", "title", "simpleText"].string 38 | playlist.privacy = YTPrivacy(rawValue: playlistJSON["playlistAddToOptionRenderer", "privacy"].stringValue) 39 | toReturn.playlistsAndStatus.append((playlist, containsVideo == "ALL")) 40 | } 41 | } 42 | } 43 | } 44 | 45 | return toReturn 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/YouTubeKit-Package.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 43 | 49 | 50 | 56 | 57 | 58 | 59 | 61 | 62 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/Search/YTSearchResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTSearchResult.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 19.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Protocol representing a search result. 11 | public protocol YTSearchResult: Codable, Equatable, Hashable, Sendable { 12 | /// Defines the item's type, for example a video or a channel. 13 | /// 14 | /// You can filter array of YTSearchResult conform items using 15 | /// 16 | /// var array: [any YTSearchResult] = ... 17 | /// array.filterTypes(acceptedTypes: [.video]) 18 | /// 19 | /// to get videos only for example. 20 | static var type: YTSearchResultType { get } 21 | 22 | /// Decode and process the JSON from Data, and give a decoded version of it.. 23 | /// - Parameter data: the JSON encoded in Data. 24 | /// - Returns: an instance of the decoded JSON object or nil if the item can't be decoded, can be checked before with ``YTSearchResult/canBeDecoded(data:)``. 25 | static func decodeJSON(data: Data) -> Self? 26 | 27 | /// Process the JSON and give a decoded version of it. 28 | /// - Parameter json: the JSON that has to be decoded. 29 | /// - Returns: an instance of the decoded JSON object or nil if the item can't be decoded, can be checked before with ``YTSearchResult/canBeDecoded(json:)``. 30 | static func decodeJSON(json: JSON) -> Self? 31 | 32 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use canBeDecoded(json: JSON) instead. You can convert your Data into JSON by calling the JSON(_ data: Data) initializer.") // deprecated as you can't really find some result JSON in raw data. 33 | /// Method indicating whether some Data can be converted to this type of ``YTSearchResult``. 34 | /// - Parameter data: the data to be checked. 35 | /// - Returns: a boolean indicating if the conversion is possible. 36 | static func canBeDecoded(data: Data) -> Bool 37 | 38 | 39 | /// Method indicating whether some JSON can be converted to this type of ``YTSearchResult``. 40 | /// - Parameter json: the json to be checked. 41 | /// - Returns: a boolean indicating if the conversion is possible. 42 | static func canBeDecoded(json: JSON) -> Bool 43 | 44 | /// Identifier of the item in the request result array, useful when you want to display all your results in the right order. 45 | /// Has to be defined during the array push operation. 46 | var id: Int? { get set } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseProtocols/Channel/YouTubeChannel+fetchInfos.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YouTubeChannel+fetchInfos.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 17.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension YouTubeChannel { 12 | func fetchInfos(youtubeModel: YouTubeModel, useCookies: Bool? = nil, result: @escaping @Sendable (Result) -> ()) { 13 | ChannelInfosResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.browseId: self.channelId], useCookies: useCookies, result: result) 14 | } 15 | 16 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 17 | func fetchInfosThrowing(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async throws -> ChannelInfosResponse { 18 | return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in 19 | fetchInfos(youtubeModel: youtubeModel, useCookies: useCookies, result: { result in 20 | continuation.resume(with: result) 21 | }) 22 | }) 23 | } 24 | 25 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use fetchInfos(youtubeModel: YouTubeModel, useCookies: Bool? = nil, result: @escaping (Result) -> ()) instead.") // safer and better to use the Result API instead of a tuple 26 | func fetchInfos(youtubeModel: YouTubeModel, useCookies: Bool? = nil, result: @escaping @Sendable (ChannelInfosResponse?, Error?) -> ()) { 27 | self.fetchInfos(youtubeModel: youtubeModel, result: { returning in 28 | switch returning { 29 | case .success(let response): 30 | result(response, nil) 31 | case .failure(let error): 32 | result(nil, error) 33 | } 34 | }) 35 | } 36 | 37 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use fetchInfos(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async throws -> ChannelInfosResponse instead.") // safer and better to use the throws API instead of a tuple 38 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 39 | func fetchInfos(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async -> (ChannelInfosResponse?, Error?) { 40 | do { 41 | return await (try self.fetchInfosThrowing(youtubeModel: youtubeModel, useCookies: useCookies), nil) 42 | } catch { 43 | return (nil, error) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseProtocols/Video/YouTubeVideo+fetchMoreInfos.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YouTubeVideo+fetchMoreInfos.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 17.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension YouTubeVideo { 12 | func fetchMoreInfos(youtubeModel: YouTubeModel, useCookies: Bool? = nil, result: @escaping @Sendable (Result) -> ()) { 13 | MoreVideoInfosResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.query: self.videoId], useCookies: useCookies, result: result) 14 | } 15 | 16 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 17 | func fetchMoreInfosThrowing(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async throws -> MoreVideoInfosResponse { 18 | return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in 19 | self.fetchMoreInfos(youtubeModel: youtubeModel, useCookies: useCookies, result: { result in 20 | continuation.resume(with: result) 21 | }) 22 | }) 23 | } 24 | 25 | 26 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use fetchMoreInfos(youtubeModel: YouTubeModel, useCookies: Bool? = nil, result: @escaping (MoreVideoInfosResponse?, Error?) -> ()) instead.") // safer and better to use the Result API instead of a tuple 27 | func fetchMoreInfos(youtubeModel: YouTubeModel, useCookies: Bool? = nil, result: @escaping @Sendable (MoreVideoInfosResponse?, Error?) -> ()) { 28 | self.fetchMoreInfos(youtubeModel: youtubeModel, result: { returning in 29 | switch returning { 30 | case .success(let response): 31 | result(response, nil) 32 | case .failure(let error): 33 | result(nil, error) 34 | } 35 | }) 36 | } 37 | 38 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use fetchMoreInfos(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async throws -> MoreVideoInfosResponse instead.") // safer and better to use the throws API instead of a tuple 39 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 40 | func fetchMoreInfos(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async -> (MoreVideoInfosResponse?, Error?) { 41 | do { 42 | return await (try self.fetchMoreInfosThrowing(youtubeModel: youtubeModel, useCookies: useCookies), nil) 43 | } catch { 44 | return (nil, error) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseStructs/YTThumbnail.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTThumbnail.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 20.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Struct representing a thumbnail. 11 | public struct YTThumbnail: Codable, Equatable, Hashable, Sendable { 12 | public init(width: Int? = nil, height: Int? = nil, url: URL) { 13 | self.width = width 14 | self.height = height 15 | self.url = url 16 | } 17 | 18 | /// Width of the image. 19 | public var width: Int? 20 | 21 | /// Height of the image. 22 | public var height: Int? 23 | 24 | /// URL of the image. 25 | public var url: URL 26 | 27 | /// Append to `[Thumbnail]` another `[Thumbnail]` from JSON. 28 | /// - Parameters: 29 | /// - json: the JSON of the thumbnails, of form 30 | /// { 31 | /// "thumbnails": [thumbnailsHere] 32 | /// } 33 | /// or 34 | /// { 35 | /// "image": { 36 | /// "sources": [thumbnailsHere] 37 | /// } 38 | /// } 39 | /// - thumbnailList: the array of `Thumbnail` where the ones in the given JSON have to be appended. 40 | public static func appendThumbnails(json: JSON, thumbnailList: inout [YTThumbnail]) { 41 | for thumbnail in json["thumbnails"].array ?? json["image", "sources"].array ?? json["sources"].array ?? [] { 42 | if var url = thumbnail["url"].url { 43 | /// URL is of form "//yt3.googleusercontent.com/ytc" 44 | if url.absoluteString.hasPrefix("//") { 45 | url = URL(string: "https:\(url.absoluteString)") ?? url 46 | thumbnailList.append( 47 | YTThumbnail( 48 | width: thumbnail["width"].int, 49 | height: thumbnail["height"].int, 50 | url: url 51 | ) 52 | ) 53 | } else { 54 | thumbnailList.append( 55 | YTThumbnail( 56 | width: thumbnail["width"].int, 57 | height: thumbnail["height"].int, 58 | url: url 59 | ) 60 | ) 61 | } 62 | } 63 | } 64 | } 65 | 66 | public static func getThumbnails(json: JSON) -> [YTThumbnail] { 67 | var toReturn: [YTThumbnail] = [] 68 | self.appendThumbnails(json: json, thumbnailList: &toReturn) 69 | return toReturn 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseProtocols/Channel/YouTubeChannel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YouTubeChannel.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 17.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol YouTubeChannel { 12 | /// Channel's identifier, can be used to get the informations about the channel. 13 | /// 14 | /// For example: 15 | /// ```swift 16 | /// let YTM = YouTubeModel() 17 | /// let channelId: String = ... 18 | /// ChannelInfosResponse.sendNonThrowingRequest(youtubeModel: YTM, data: [.browseId : channelId], result: { result in 19 | /// print(result) 20 | /// }) 21 | /// ``` 22 | var channelId: String { get set } 23 | 24 | /// Name of the owning channel. 25 | var name: String? { get set } 26 | 27 | /// Array of thumbnails representing the avatar of the channel. 28 | /// 29 | /// Usually sorted by resolution from low to high. 30 | /// Only found in ``YTVideo``items in ``SearchResponse`` and ``HomeScreenResponse`` and their continuation. 31 | var thumbnails: [YTThumbnail] { get set } 32 | 33 | /// Get more informations about a channel (homepage infos of the channel ``ChannelInfosResponse``) 34 | /// 35 | /// - Parameter youtubeModel: the model to use to execute the request. 36 | /// - Parameter useCookies: boolean that precises if the request should include the model's ``YouTubeModel/cookies``, if set to nil, the value will be taken from ``YouTubeModel/alwaysUseCookies``. The cookies will be added to the `Cookie` HTTP header if one is already present or a new one will be created if not. 37 | /// - Parameter result: the closure to execute when the request is finished. 38 | func fetchInfos( 39 | youtubeModel: YouTubeModel, 40 | useCookies: Bool?, 41 | result: @escaping @Sendable (Result) -> () 42 | ) 43 | 44 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 45 | /// Get more informations about a video (detailled description, chapters, recommended videos, etc...). 46 | /// 47 | /// - Parameter youtubeModel: the model to use to execute the request. 48 | /// - Parameter useCookies: boolean that precises if the request should include the model's ``YouTubeModel/cookies``, if set to nil, the value will be taken from ``YouTubeModel/alwaysUseCookies``. The cookies will be added to the `Cookie` HTTP header if one is already present or a new one will be created if not. 49 | /// - Returns: A ``MoreVideoInfosResponse`` or an error. 50 | func fetchInfosThrowing( 51 | youtubeModel: YouTubeModel, 52 | useCookies: Bool? 53 | ) async throws -> ChannelInfosResponse 54 | } 55 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/HistoryActions/HistoryReponse+removeVideo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HistoryReponse+removeVideo.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 03.01.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension HistoryResponse { 12 | /// Remove the video from the account's history. 13 | /// 14 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 15 | func removeVideo(withSuppressToken suppressToken: String, youtubeModel: YouTubeModel, result: @escaping @Sendable (Error?) -> Void) { 16 | RemoveVideoFromHistroryResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.movingVideoId: suppressToken], result: { response in 17 | switch response { 18 | case .success(let response): 19 | if response.isDisconnected { 20 | result("Failed to remove video from history because no account is connected.") 21 | } else if response.success { 22 | result(nil) 23 | } else { 24 | result("Removing video was not successful.") 25 | } 26 | case .failure(let error): 27 | result(error) 28 | } 29 | }) 30 | } 31 | 32 | /// Remove the video from the account's history. 33 | /// 34 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 35 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 36 | func removeVideoThrowing(withSuppressToken suppressToken: String, youtubeModel: YouTubeModel) async throws { 37 | return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in 38 | removeVideo(withSuppressToken: suppressToken, youtubeModel: youtubeModel, result: { error in 39 | if let error = error { 40 | continuation.resume(throwing: error) 41 | } else { 42 | continuation.resume() 43 | } 44 | }) 45 | }) 46 | } 47 | 48 | 49 | /// Remove the video from the account's history. 50 | /// 51 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 52 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 53 | func removeVideo(withSuppressToken suppressToken: String, youtubeModel: YouTubeModel) async -> Error? { 54 | return await withCheckedContinuation({ (continuation: CheckedContinuation) in 55 | removeVideo(withSuppressToken: suppressToken, youtubeModel: youtubeModel, result: { error in 56 | continuation.resume(returning: error) 57 | }) 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseProtocols/Video/YouTubeVideo+fetchStreamingInfos.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YouTubeVideo+fetchStreamingInfos.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 22.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension YouTubeVideo { 11 | func fetchStreamingInfos( 12 | youtubeModel: YouTubeModel, 13 | useCookies: Bool? = nil, 14 | infos: @escaping @Sendable (Result) -> () 15 | ) { 16 | VideoInfosResponse.sendNonThrowingRequest( 17 | youtubeModel: youtubeModel, 18 | data: [.query: videoId], 19 | useCookies: useCookies, 20 | result: infos 21 | ) 22 | } 23 | 24 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 25 | func fetchStreamingInfosThrowing( 26 | youtubeModel: YouTubeModel, 27 | useCookies: Bool? = nil 28 | ) async throws -> VideoInfosResponse { 29 | return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in 30 | self.fetchStreamingInfos(youtubeModel: youtubeModel, useCookies: useCookies, infos: { result in 31 | continuation.resume(with: result) 32 | }) 33 | }) 34 | } 35 | 36 | 37 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use fetchStreamingInfos(youtubeModel: YouTubeModel, useCookies: Bool? = nil, infos: @escaping (Result) -> ()) instead.") // safer and better to use the Result API instead of a tuple 38 | func fetchStreamingInfos( 39 | youtubeModel: YouTubeModel, 40 | useCookies: Bool? = nil, 41 | infos: @escaping @Sendable (VideoInfosResponse?, Error?) -> () 42 | ) { 43 | self.fetchStreamingInfos(youtubeModel: youtubeModel, useCookies: useCookies, infos: { returning in 44 | switch returning { 45 | case .success(let response): 46 | infos(response, nil) 47 | case .failure(let error): 48 | infos(nil, error) 49 | } 50 | }) 51 | } 52 | 53 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use fetchStreamingInfos(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async throws -> VideoInfosResponse instead.") // safer and better to use the throws API instead of a tuple 54 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 55 | func fetchStreamingInfos( 56 | youtubeModel: YouTubeModel, 57 | useCookies: Bool? = nil 58 | ) async -> (VideoInfosResponse?, Error?) { 59 | do { 60 | return await (try self.fetchStreamingInfosThrowing(youtubeModel: youtubeModel, useCookies: useCookies), nil) 61 | } catch { 62 | return (nil, error) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseProtocols/Video/YouTubeVideo+fetchAllPossibleHostPlaylists.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YouTubeVideo+fetchAllPossibleHostPlaylists.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 17.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension YouTubeVideo { 12 | /// Get all the user's playlists and if the video is already inside or not. 13 | func fetchAllPossibleHostPlaylists(youtubeModel: YouTubeModel, result: @escaping @Sendable (Result) -> Void) { 14 | AllPossibleHostPlaylistsResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.browseId: self.videoId], result: result) 15 | } 16 | 17 | /// Get all the user's playlists and if the video is already inside or not. 18 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 19 | func fetchAllPossibleHostPlaylistsThrowing(youtubeModel: YouTubeModel) async throws -> AllPossibleHostPlaylistsResponse { 20 | return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in 21 | self.fetchAllPossibleHostPlaylists(youtubeModel: youtubeModel, result: { result in 22 | continuation.resume(with: result) 23 | }) 24 | }) 25 | } 26 | 27 | 28 | /// Get all the user's playlists and if the video is already inside or not. 29 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use fetchAllPossibleHostPlaylists(youtubeModel: YouTubeModel, result: @escaping (Result) -> Void) instead.") // safer and better to use the Result API instead of a tuple 30 | func fetchAllPossibleHostPlaylists(youtubeModel: YouTubeModel, result: @escaping @Sendable (AllPossibleHostPlaylistsResponse?, Error?) -> Void) { 31 | self.fetchAllPossibleHostPlaylists(youtubeModel: youtubeModel, result: { returning in 32 | switch returning { 33 | case .success(let response): 34 | result(response, nil) 35 | case .failure(let error): 36 | result(nil, error) 37 | } 38 | }) 39 | } 40 | 41 | /// Get all the user's playlists and if the video is already inside or not. 42 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use fetchAllPossibleHostPlaylists(youtubeModel: YouTubeModel) async throws -> AllPossibleHostPlaylistsResponse instead.") // safer and better to use the throws API instead of a tuple 43 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 44 | func fetchAllPossibleHostPlaylists(youtubeModel: YouTubeModel) async -> (AllPossibleHostPlaylistsResponse?, Error?) { 45 | do { 46 | return await (try self.fetchAllPossibleHostPlaylistsThrowing(youtubeModel: youtubeModel), nil) 47 | } catch { 48 | return (nil, error) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseProtocols/Continuation/ContinuableResponse+fetchContinuation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContinuableResponse+fetchContinuation.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 17.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension ContinuableResponse { 12 | func fetchContinuation(youtubeModel: YouTubeModel, useCookies: Bool? = nil, result: @escaping @Sendable (Result) -> Void) { 13 | if let continuationToken = continuationToken { 14 | if let visitorData = visitorData { 15 | Continuation.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.continuation: continuationToken, .visitorData: visitorData], useCookies: useCookies, result: result) 16 | } else { 17 | Continuation.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.continuation: continuationToken], useCookies: useCookies, result: result) 18 | } 19 | } else { 20 | result(.failure("Continuation token is not defined.")) 21 | } 22 | } 23 | 24 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 25 | func fetchContinuationThrowing(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async throws -> Continuation { 26 | return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in 27 | self.fetchContinuation(youtubeModel: youtubeModel, useCookies: useCookies, result: { response in 28 | continuation.resume(with: response) 29 | }) 30 | }) 31 | } 32 | 33 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use fetchContinuation(youtubeModel: YouTubeModel, useCookies: Bool? = nil, result: @escaping (Result) -> Void) instead.") // safer and better to use the Result API instead of a tuple 34 | func fetchContinuation(youtubeModel: YouTubeModel, useCookies: Bool? = nil, result: @escaping @Sendable ((Continuation)?, Error?) -> Void) { 35 | self.fetchContinuation(youtubeModel: youtubeModel, useCookies: useCookies, result: { returning in 36 | switch returning { 37 | case .success(let response): 38 | result(response, nil) 39 | case .failure(let error): 40 | result(nil, error) 41 | } 42 | }) 43 | } 44 | 45 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use fetchContinuation(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async throws -> Continuation instead.") // safer and better to use the throws API instead of a tuple 46 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 47 | func fetchContinuation(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async -> ((Continuation)?, Error?) { 48 | do { 49 | return await (try self.fetchContinuationThrowing(youtubeModel: youtubeModel, useCookies: useCookies), nil) 50 | } catch { 51 | return (nil, error) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseProtocols/Video/YouTubeVideo+fetchStreamingInfosWithDownloadFormats.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YouTubeVideo+fetchStreamingInfosWithDownloadFormats.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 22.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension YouTubeVideo { 11 | func fetchStreamingInfosWithDownloadFormats( 12 | youtubeModel: YouTubeModel, 13 | useCookies: Bool? = nil, 14 | infos: @escaping @Sendable (Result) -> () 15 | ) { 16 | VideoInfosWithDownloadFormatsResponse.sendNonThrowingRequest( 17 | youtubeModel: youtubeModel, 18 | data: [.query: videoId], 19 | useCookies: useCookies, 20 | result: infos 21 | ) 22 | } 23 | 24 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 25 | func fetchStreamingInfosWithDownloadFormatsThrowing( 26 | youtubeModel: YouTubeModel, 27 | useCookies: Bool? = nil 28 | ) async throws -> VideoInfosWithDownloadFormatsResponse { 29 | return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in 30 | fetchStreamingInfosWithDownloadFormats(youtubeModel: youtubeModel, useCookies: useCookies, infos: { result in 31 | continuation.resume(with: result) 32 | }) 33 | }) 34 | } 35 | 36 | 37 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use fetchStreamingInfosWithDownloadFormats(youtubeModel: YouTubeModel, useCookies: Bool? = nil, infos: @escaping (Result) -> ()) instead.") // safer and better to use the Result API instead of a tuple 38 | func fetchStreamingInfosWithDownloadFormats( 39 | youtubeModel: YouTubeModel, 40 | useCookies: Bool? = nil, 41 | infos: @escaping @Sendable (VideoInfosWithDownloadFormatsResponse?, Error?) -> () 42 | ) { 43 | self.fetchStreamingInfosWithDownloadFormats(youtubeModel: youtubeModel, useCookies: useCookies, infos: { returning in 44 | switch returning { 45 | case .success(let response): 46 | infos(response, nil) 47 | case .failure(let error): 48 | infos(nil, error) 49 | } 50 | }) 51 | } 52 | 53 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use fetchStreamingInfosWithDownloadFormats(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async throws -> VideoInfosResponse instead.") // safer and better to use the throws API instead of a tuple 54 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 55 | func fetchStreamingInfosWithDownloadFormats( 56 | youtubeModel: YouTubeModel, 57 | useCookies: Bool? = nil 58 | ) async -> (VideoInfosWithDownloadFormatsResponse?, Error?) { 59 | do { 60 | return await (try self.fetchStreamingInfosWithDownloadFormatsThrowing(youtubeModel: youtubeModel, useCookies: useCookies), nil) 61 | } catch { 62 | return (nil, error) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/VideoInfos/AudioOnlyFormat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudioOnlyFormat.swift 3 | // YouTubeKit 4 | // 5 | // Created by Antoine Bollengier on 02.08.2025. 6 | // Copyright © 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct AudioOnlyFormat: DownloadFormat { 12 | public init(averageBitrate: Int? = nil, contentLength: Int? = nil, contentDuration: Int? = nil, isCopyrightedMedia: Bool? = nil, url: URL? = nil, mimeType: String? = nil, codec: String? = nil, audioSampleRate: Int? = nil, loudness: Double? = nil, formatLocaleInfos: FormatLocaleInfos? = nil) { 13 | self.averageBitrate = averageBitrate 14 | self.contentLength = contentLength 15 | self.contentDuration = contentDuration 16 | self.isCopyrightedMedia = isCopyrightedMedia 17 | self.url = url 18 | self.mimeType = mimeType 19 | self.codec = codec 20 | self.audioSampleRate = audioSampleRate 21 | self.loudness = loudness 22 | self.formatLocaleInfos = formatLocaleInfos 23 | } 24 | 25 | /// Protocol properties 26 | public static let type: MediaType = .audio 27 | 28 | public var averageBitrate: Int? 29 | 30 | public var contentLength: Int? 31 | 32 | public var contentDuration: Int? 33 | 34 | public var isCopyrightedMedia: Bool? 35 | 36 | public var url: URL? 37 | 38 | public var mimeType: String? 39 | 40 | public var codec: String? 41 | 42 | /// Audio only medias specific infos 43 | 44 | /// Sample rate of the audio in hertz. 45 | public var audioSampleRate: Int? 46 | 47 | /// Audio loudness in decibels. 48 | public var loudness: Double? 49 | 50 | /// Infos about the audio track language. 51 | /// 52 | /// - Note: it will be present only if the audio is not the original audio of the video. 53 | public var formatLocaleInfos: FormatLocaleInfos? 54 | 55 | /// Struct representing some informations about the audio track language. 56 | public struct FormatLocaleInfos: Sendable, Hashable { 57 | public init(displayName: String? = nil, localeId: String? = nil, isDefaultAudioFormat: Bool? = nil, isAutoDubbed: Bool? = nil) { 58 | self.displayName = displayName 59 | self.localeId = localeId 60 | self.isDefaultAudioFormat = isDefaultAudioFormat 61 | self.isAutoDubbed = isAutoDubbed 62 | } 63 | 64 | /// Name of the language, e.g. "French". 65 | /// 66 | /// - Note: the name of the language depends on the ``YouTubeModel``'s locale and the cookie's (if provided) account's default language. E.g. you would get "French" if your cookies point to an english account and "Français" if they pointed to a french one. 67 | public var displayName: String? 68 | 69 | /// Id of the language, generally is the language code that has ".n" has suffix. E.g. "fr.3" or "en.4". 70 | public var localeId: String? 71 | 72 | /// A boolean indicating whether the audio was auto-dubbed by YouTube. 73 | public var isAutoDubbed: Bool? 74 | 75 | /// A boolean indicating whether the format is considered as the default one by YouTube (depends on the ``YouTubeModel``'s locale and the cookie's (if provided) account's default language). 76 | public var isDefaultAudioFormat: Bool? 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseStructs/YTPlaylist+fetchVideos.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTPlaylist+fetchVideos.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 17.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension YTPlaylist { 12 | /// Fetch the ``PlaylistInfosResponse`` related to the playlist. 13 | func fetchVideos(youtubeModel: YouTubeModel, useCookies: Bool? = nil, result: @escaping @Sendable (Result) -> Void) { 14 | PlaylistInfosResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.browseId: self.playlistId], useCookies: useCookies, result: result) 15 | } 16 | 17 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 18 | /// Fetch the ``PlaylistInfosResponse`` related to the playlist. 19 | func fetchVideosThrowing(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async throws -> PlaylistInfosResponse { 20 | return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in 21 | self.fetchVideos(youtubeModel: youtubeModel, useCookies: useCookies, result: { response in 22 | continuation.resume(with: response) 23 | }) 24 | }) 25 | } 26 | 27 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 28 | /// Fetch the ``PlaylistInfosResponse`` related to the playlist. 29 | func fetchVideos(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async -> Result { 30 | do { 31 | return try await .success(self.fetchVideosThrowing(youtubeModel: youtubeModel, useCookies: useCookies)) 32 | } catch { 33 | return .failure(error) 34 | } 35 | } 36 | 37 | 38 | /// Fetch the ``PlaylistInfosResponse`` related to the playlist. 39 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use fetchInfos(youtubeModel: YouTubeModel, useCookies: Bool? = nil, result: @escaping (Result) -> ()) instead.") // safer and better to use the Result API instead of a tuple 40 | func fetchVideos(youtubeModel: YouTubeModel, useCookies: Bool? = nil, result: @escaping @Sendable (PlaylistInfosResponse?, Error?) -> Void) { 41 | self.fetchVideos(youtubeModel: youtubeModel, useCookies: useCookies, result: { returning in 42 | switch returning { 43 | case .success(let response): 44 | result(response, nil) 45 | case .failure(let error): 46 | result(nil, error) 47 | } 48 | }) 49 | } 50 | 51 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 52 | /// Fetch the ``PlaylistInfosResponse`` related to the playlist. 53 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use fetchInfos(youtubeModel: YouTubeModel, useCookies: Bool? = nil) -> Result instead.") // safer and better to use the Result API instead of a tuple 54 | func fetchVideos(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async -> (PlaylistInfosResponse?, Error?) { 55 | do { 56 | return await (try self.fetchVideosThrowing(youtubeModel: youtubeModel, useCookies: useCookies), nil) 57 | } catch { 58 | return (nil, error) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/Logging/RequestsLogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestsLogger.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 06.03.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A protocol describing a logger that is used by an instance of ``YouTubeModel``. 12 | /// 13 | /// You can create a simple logger like this: 14 | /// ```swift 15 | /// class MyLogger: RequestsLogger { 16 | /// var loggedTypes: [any YouTubeResponse.Type]? = nil 17 | /// 18 | /// var logs: [RequestLog] = [] 19 | /// 20 | /// var isLogging: Bool = false 21 | /// 22 | /// var maximumCacheSize: Int? = nil 23 | /// } 24 | /// ``` 25 | /// If you want to use it as a model for a SwiftUI view you can also make a logger that conforms to the ObservableObject protocol: 26 | /// ```swift 27 | /// class MyLogger: RequestsLogger, ObservableObject { 28 | /// var loggedTypes: [any YouTubeResponse.Type]? = nil 29 | /// 30 | /// @Published var logs: [RequestLog] = [] 31 | /// 32 | /// @Published var isLogging: Bool = false 33 | /// 34 | /// var maximumCacheSize: Int? = nil 35 | /// } 36 | /// ``` 37 | /// 38 | /// And then don't forget to add it to your ``YouTubeModel`` instance: 39 | /// ```swift 40 | /// YTM.logger = MyLogger() 41 | /// ``` 42 | /// 43 | /// - Note: Be aware that enabling logging can consume a lot of RAM as the logger stores a lot of raw informations. Therefore, make sure that you regularly clear the ``RequestsLogger/logs`` or disable logging when it's not needed. 44 | public protocol RequestsLogger: AnyObject, Sendable { 45 | /// An array of logs for requests/responses executed by the ``YouTubeModel``. A log is added once the request is fully finished and processed. 46 | var logs: [any GenericRequestLog] { get set } 47 | 48 | /// A boolean indicating whether the logging is active, if it's set to false, no additional logs will be added to the ``logs`` array. 49 | var isLogging: Bool { get set } 50 | 51 | /// The maximum amount of logs that the logger should retain, old logs should be deleted first. 52 | var maximumCacheSize: Int? { get set } 53 | 54 | /// A list, that, if it isn't empty, will block all response types that are not included inside from being added to ``logs`` by ``addLog(_:)``. 55 | /// 56 | /// You can fill it like that: 57 | /// ```swift 58 | /// logger.loggedTypes = [HomeScreenResponse.self] 59 | /// ``` 60 | var loggedTypes: [any YouTubeResponse.Type]? { get set } 61 | 62 | 63 | /// Start the logging, has to at least set ``isLogging`` to true. 64 | func startLogging() 65 | 66 | /// Start the logging, has to at least set ``isLogging`` to false. 67 | func stopLogging() 68 | 69 | 70 | /// Set the ``maximumCacheSize`` to the given size, if it's nil then no limit is applied. Be aware that if the current amount of logs exceeds the new size, the oldest logs will be deleted. 71 | func setCacheSize(_ size: Int?) 72 | 73 | /// Add a log to the logger, shouldn't add a log if ``isLogging`` is set to false. Will be called when the response of the request finished processing. 74 | func addLog(_ log: any GenericRequestLog) 75 | 76 | /// Clear all the logs from the ``logs`` array. 77 | func clearLogs() 78 | 79 | /// Clear all the logs whose id is in the `ids` array. 80 | func clearLogsWithIds(_ ids: [UUID]) 81 | 82 | /// Clear the log that has the specified `id`, won't do anything if such log does not exist. 83 | func clearLogWithId(_ id: UUID) 84 | } 85 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AutoCompletion/AutoCompletionResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AutoCompletionResponse.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 22.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Struct representing an search AutoCompletion response. 11 | /// 12 | /// Note: by using this request you consent to YouTube's cookie policy (even if no cookies are kept wiht YouTubeKit). 13 | public struct AutoCompletionResponse: YouTubeResponse { 14 | public static let headersType: HeaderTypes = .autoCompletion 15 | 16 | public static let parametersValidationList: ValidationList = [.query: .existenceValidator] 17 | 18 | /// Text query used to get the search suggestions. 19 | public var initialQuery: String = "" 20 | 21 | /// An array of string representing the search suggestion, usually sorted by relevance from most to least. 22 | public var autoCompletionEntries: [String] = [] 23 | 24 | public static func decodeData(data: Data) throws -> AutoCompletionResponse { 25 | guard var dataString = String(data: data, encoding: String.Encoding.windowsCP1254)? 26 | .replacingOccurrences(of: "window.google.ac.h(", with: "") else { throw ResponseExtractionError(reponseType: Self.self, stepDescription: "Couldn't convert the response data to a string.") } 27 | dataString = String(dataString.dropLast()) 28 | 29 | let json = JSON(parseJSON: dataString) 30 | 31 | try self.checkForErrors(json: json) 32 | 33 | return decodeJSON(json: json) 34 | } 35 | 36 | public static func decodeJSON(json: JSON) -> AutoCompletionResponse { 37 | var response = AutoCompletionResponse() 38 | 39 | 40 | /// Responses are like this 41 | /// 42 | /// [ 43 | /// "yourInitialQuery", 44 | /// [ 45 | /// [ 46 | /// "autoCompletionEntry", 47 | /// 0, 48 | /// [ 49 | /// 512, 50 | /// 433 51 | /// ] 52 | /// ] 53 | /// // and more entries like this 54 | /// ], 55 | /// { 56 | /// "a": "xxxxxxxxxx", // an unknown string 57 | /// "j": "x", // an unknown string (usually the string is actually an int) 58 | /// "k": x, // an integer 59 | /// "q": "xxxxxxx" // an unknown string 60 | /// } 61 | /// ] 62 | 63 | guard let jsonArray = json.array else { return response } 64 | 65 | for jsonElement in jsonArray { 66 | if let initialQuery = jsonElement.string { 67 | response.initialQuery = initialQuery 68 | } else if let autoCompletionEntriesArray = jsonElement.array { 69 | for autoCompletionEntry in autoCompletionEntriesArray { 70 | if let autoCompletionEntry = autoCompletionEntry.array { 71 | for entryPartsOfArray in autoCompletionEntry { 72 | guard let entryString = entryPartsOfArray.string else { continue } 73 | response.autoCompletionEntries.append(entryString) 74 | break 75 | } 76 | } 77 | } 78 | } 79 | /// We don't care of the dictionnary with unknown strings and integers 80 | } 81 | 82 | return response 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/ChannelInfos/ChannelContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChannelContent.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 23.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Protocol to make conform to a decodable channel content like channel's video, shorts, directs or even playlists. 11 | public protocol ChannelContent: Sendable { 12 | /// Type of the content, associated with a ``ChannelInfosResponse/RequestTypes`` type. 13 | static var type: ChannelInfosResponse.RequestTypes { get } 14 | 15 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use decodeJSONFromTab(_ json: JSON, channelInfos: YTLittleChannelInfos?) instead. You can convert your Data into JSON by calling the JSON(_ data: Data) initializer.") // deprecated as you can't really find some tab JSON in raw data. 16 | /// Decode from a "tab" YouTube JSON dictionnary a ChannelContent 17 | /// - Parameter data: JSON encoded in Data representing a "tabRenderer". 18 | /// - Parameter channelInfos: A piece of information about the channel that will be used to complete the informations in the results that are often missing. 19 | /// - Returns: a ChannelContent 20 | static func decodeJSONFromTab(_ data: Data, channelInfos: YTLittleChannelInfos?) -> Self? 21 | 22 | /// Decode from a "tab" YouTube JSON dictionnary a ChannelContent 23 | /// - Parameter data: some JSON representing a "tabRenderer". 24 | /// - Parameter channelInfos: A piece of information about the channel that will be used to complete the informations in the results that are often missing. 25 | /// - Returns: a ChannelContent 26 | static func decodeJSONFromTab(_ json: JSON, channelInfos: YTLittleChannelInfos?) -> Self? 27 | 28 | @available(*, deprecated, message: "This method will be removed in a future version of YouTubeKit, please use canDecode(json: JSON) instead. You can convert your Data into JSON by calling the JSON(_ data: Data) initializer.") // deprecated as you can't really find some tab JSON in raw data. 29 | /// Boolean indicating if some Data can be decoded to give an instance of this ``ChannelContent`` type. 30 | /// - Parameter data: the Data to be checked. 31 | /// - Returns: the boolean that indicate if a decoding would be possible. 32 | static func canDecode(data: Data) -> Bool 33 | 34 | /// Method that returns a boolean indicating if some JSON can be decoded to give an instance of this ``ChannelContent`` type. 35 | /// - Parameter json: the JSON to be checked, must have a direct dictionnary child named "tabRenderer". 36 | /// - Returns: the boolean that indicate if a decoding would be possible. 37 | static func canDecode(json: JSON) -> Bool 38 | 39 | /// Takes some JSON and decode it to give a continuation result. 40 | /// - Parameter json: the JSON to be decoded. 41 | /// - Returns: a continuation result. 42 | static func decodeContinuation(json: JSON) -> ChannelInfosResponse.ContentContinuation 43 | 44 | /// Method that extracts the continuation token in case there is one. 45 | /// - Parameter json: the JSON that will be used to extract the token. 46 | /// - Returns: an optional string (nil if there is no continuation token), representing the continuation token. 47 | static func getContinuationFromTab(json: JSON) -> String? 48 | 49 | /// To check whether a part of JSON is a tab of the concerned ``ChannelContent`` type. 50 | /// - Parameter json: the JSON to be checked. 51 | /// - Returns: a boolean indicating if the tab is of the concerned ``ChannelContent`` type. 52 | /// 53 | /// This method will decode check from a JSON dictionnary of name "tabRenderer". 54 | static func isTabOfSelfType(json: JSON) -> Bool 55 | } 56 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/AuthenticatedResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticatedResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 15.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol AuthenticatedResponse: YouTubeResponse { 12 | /// A function to call the request of the given YouTubeResponse. For more informations see ``YouTubeModel/sendRequest(responseType:data:useCookies:result:)``. 13 | static func sendNonThrowingRequest( 14 | youtubeModel: YouTubeModel, 15 | data: [HeadersList.AddQueryInfo.ContentTypes : String], 16 | result: @escaping @Sendable (Result) -> () 17 | ) 18 | 19 | /// A function to call the request of the given YouTubeResponse. For more informations see ``YouTubeModel/sendRequest(responseType:data:useCookies:result:)``. 20 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 21 | static func sendNonThrowingRequest( 22 | youtubeModel: YouTubeModel, 23 | data: [HeadersList.AddQueryInfo.ContentTypes : String] 24 | ) async -> Result 25 | 26 | /// A function to call the request of the given YouTubeResponse. For more informations see ``YouTubeModel/sendRequest(responseType:data:useCookies:result:)``. 27 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 28 | static func sendThrowingRequest( 29 | youtubeModel: YouTubeModel, 30 | data: [HeadersList.AddQueryInfo.ContentTypes : String] 31 | ) async throws -> Self 32 | 33 | /// Boolean indicating whether the response has a valid result or not (if it was disconnected then it couldn't end up with a valid response). 34 | var isDisconnected: Bool { get } 35 | } 36 | 37 | public extension AuthenticatedResponse { 38 | 39 | static func sendNonThrowingRequest( 40 | youtubeModel: YouTubeModel, 41 | data: [HeadersList.AddQueryInfo.ContentTypes : String], 42 | result: @escaping @Sendable (Result) -> () 43 | ) { 44 | if youtubeModel.cookies != "" && youtubeModel.cookies != "" { 45 | /// Call YouTubeModel's `sendRequest` function to have a more readable use. 46 | youtubeModel.sendRequest( 47 | responseType: Self.self, 48 | data: data, 49 | useCookies: true, 50 | result: result 51 | ) 52 | } else { 53 | result(.failure("Authentification cookies not provided: youtubeModel.cookies = \(String(describing: youtubeModel.cookies))")) 54 | } 55 | } 56 | 57 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 58 | static func sendNonThrowingRequest( 59 | youtubeModel: YouTubeModel, 60 | data: [HeadersList.AddQueryInfo.ContentTypes : String] 61 | ) async -> Result { 62 | do { 63 | return .success(try await self.sendThrowingRequest(youtubeModel: youtubeModel, data: data)) 64 | } catch { 65 | return .failure(error) 66 | } 67 | } 68 | 69 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 70 | static func sendThrowingRequest( 71 | youtubeModel: YouTubeModel, 72 | data: [HeadersList.AddQueryInfo.ContentTypes : String] 73 | ) async throws -> Self { 74 | guard youtubeModel.cookies != "" && youtubeModel.cookies != "" else { throw "Authentification cookies not provided: youtubeModel.cookies = \(String(describing: youtubeModel.cookies))" } 75 | return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in 76 | sendNonThrowingRequest(youtubeModel: youtubeModel, data: data, useCookies: true, result: { result in 77 | continuation.resume(with: result) 78 | }) 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/YouTubeKit.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 48 | 54 | 55 | 56 | 58 | 59 | 61 | 62 | 63 | 64 | 65 | 66 | 76 | 77 | 83 | 84 | 90 | 91 | 92 | 93 | 95 | 96 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseStructs/YTChannel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTChannel.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 22.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | /// Struct representing a channel. 12 | public struct YTChannel: YTSearchResult, YouTubeChannel { 13 | public init(id: Int? = nil, name: String? = nil, channelId: String, handle: String? = nil, thumbnails: [YTThumbnail] = [], subscriberCount: String? = nil, badges: [String] = [], videoCount: String? = nil) { 14 | self.id = id 15 | self.name = name 16 | self.handle = handle 17 | self.channelId = channelId 18 | self.thumbnails = thumbnails 19 | self.subscriberCount = subscriberCount 20 | self.badges = badges 21 | self.videoCount = videoCount 22 | } 23 | 24 | public static func canBeDecoded(json: JSON) -> Bool { 25 | return json["channelId"].string != nil 26 | } 27 | 28 | public static func decodeJSON(json: JSON) -> YTChannel? { 29 | /// Check if the JSON can be decoded as a Channel. 30 | guard let channelId = json["channelId"].string else { return nil } 31 | 32 | /// Inititalize a new ``YTSearchResultType/Channel-swift.struct`` instance to put the informations in it. 33 | var channel = YTChannel(channelId: channelId) 34 | channel.name = json["title", "simpleText"].string 35 | if json["navigationEndpoint", "browseEndpoint", "canonicalBaseUrl"].stringValue.contains("/c/") || json["subscriberCountText", "simpleText"].string?.hasPrefix("@") != true { // special channel json with no handle 36 | channel.subscriberCount = json["subscriberCountText", "simpleText"].string 37 | channel.videoCount = json["videoCountText", "runs"].array?.map {$0["text"].stringValue}.reduce("", +) ?? json["videoCountText", "simpleText"].string 38 | } else { 39 | channel.handle = json["subscriberCountText", "simpleText"].string 40 | channel.subscriberCount = json["videoCountText", "runs"].array?.map {$0["text"].stringValue}.reduce("", +) ?? json["videoCountText", "simpleText"].string 41 | } 42 | YTThumbnail.appendThumbnails(json: json["thumbnail"], thumbnailList: &channel.thumbnails) 43 | 44 | if let badgesList = json["ownerBadges"].array { 45 | for badge in badgesList { 46 | if let badgeName = badge["metadataBadgeRenderer", "style"].string { 47 | channel.badges.append(badgeName) 48 | } 49 | } 50 | } 51 | 52 | return channel 53 | } 54 | 55 | public static let type: YTSearchResultType = .channel 56 | 57 | public var id: Int? 58 | 59 | /// Channel's name. 60 | public var name: String? 61 | 62 | /// Channel's handle. 63 | public var handle: String? 64 | 65 | /// Channel's identifier, can be used to get the informations about the channel. 66 | /// 67 | /// For example: 68 | /// ```swift 69 | /// let YTM = YouTubeModel() 70 | /// let channelId: String = ... 71 | /// ChannelInfosResponse.sendNonThrowingRequest(youtubeModel: YTM, data: [.browseId : channelId], result: { result in 72 | /// print(result) 73 | /// }) 74 | /// ``` 75 | public var channelId: String 76 | 77 | /// Array of thumbnails representing the avatar of the channel. 78 | /// 79 | /// Usually sorted by resolution, from low to high. 80 | public var thumbnails: [YTThumbnail] = [] 81 | 82 | /// Channel's subscribers count. 83 | /// 84 | /// Usually like "123k subscribers". 85 | public var subscriberCount: String? 86 | 87 | /// Array of string identifiers of the badges that a channel has. 88 | /// 89 | /// Usually like "BADGE_STYLE_TYPE_VERIFIED" 90 | public var badges: [String] = [] 91 | 92 | /// String representing the video count of the channel. Might not be present if the channel handle should be displayed instead. 93 | public var videoCount: String? 94 | 95 | ///Not necessary here because of prepareJSON() method 96 | /* 97 | enum CodingKeys: String, CodingKey { 98 | case name 99 | case stringIdentifier 100 | case thumbnails 101 | case subscriberCount 102 | case badges 103 | } 104 | */ 105 | } 106 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseProtocols/Channel/YouTubeChannel+subscribeActions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YouTubeChannel+subscribeActions.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 17.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension YouTubeChannel { 12 | /// Subscribe to the channel. 13 | /// 14 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 15 | func subscribe(youtubeModel: YouTubeModel, result: @escaping @Sendable (Error?) -> Void) { 16 | SubscribeChannelResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.browseId: self.channelId], result: { response in 17 | switch response { 18 | case .success(let data): 19 | if data.success { 20 | result(nil) 21 | } else { 22 | result("Failed to subscribe to channel: with id \(String(describing: data.channelId)).") 23 | } 24 | case .failure(let error): 25 | result(error) 26 | } 27 | }) 28 | } 29 | 30 | /// Subscribe to the channel. 31 | /// 32 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 33 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 34 | func subscribeThrowing(youtubeModel: YouTubeModel) async throws { 35 | return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in 36 | subscribe(youtubeModel: youtubeModel, result: { error in 37 | if let error = error { 38 | continuation.resume(throwing: error) 39 | } else { 40 | continuation.resume() 41 | } 42 | }) 43 | }) 44 | } 45 | 46 | /// Subscribe to the channel. 47 | /// 48 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 49 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 50 | func subscribe(youtubeModel: YouTubeModel) async -> Error? { 51 | return await withCheckedContinuation({ (continuation: CheckedContinuation) in 52 | subscribe(youtubeModel: youtubeModel, result: { error in 53 | continuation.resume(returning: (error)) 54 | }) 55 | }) 56 | } 57 | 58 | 59 | /// Unsubscribe to the channel. 60 | /// 61 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 62 | func unsubscribe(youtubeModel: YouTubeModel, result: @escaping @Sendable (Error?) -> Void) { 63 | UnsubscribeChannelResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.browseId: self.channelId], result: { response in 64 | switch response { 65 | case .success(let data): 66 | if data.success { 67 | result(nil) 68 | } else { 69 | result("Failed to subscribe to channel with id: \(String(describing: data.channelId))") 70 | } 71 | case .failure(let error): 72 | result(error) 73 | } 74 | }) 75 | } 76 | 77 | /// Unsubscribe to the channel. 78 | /// 79 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 80 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 81 | func unsubscribeThrowing(youtubeModel: YouTubeModel) async throws { 82 | return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in 83 | unsubscribe(youtubeModel: youtubeModel, result: { error in 84 | if let error = error { 85 | continuation.resume(throwing: error) 86 | } else { 87 | continuation.resume() 88 | } 89 | }) 90 | }) 91 | } 92 | 93 | /// Unsubscribe to the channel. 94 | /// 95 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 96 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 97 | func unsubscribe(youtubeModel: YouTubeModel) async -> Error? { 98 | return await withCheckedContinuation({ (continuation: CheckedContinuation) in 99 | unsubscribe(youtubeModel: youtubeModel, result: { error in 100 | continuation.resume(returning: (error)) 101 | }) 102 | }) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/AccountResponses/AccountSubscriptionsResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountSubscriptionsResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 02.07.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A response to get all the channels the YouTubeModel's account is subscribed to. 12 | public struct AccountSubscriptionsResponse: AuthenticatedContinuableResponse { 13 | public static let headersType: HeaderTypes = .usersSubscriptionsHeaders 14 | 15 | public static let parametersValidationList: ValidationList = [:] 16 | 17 | public var isDisconnected: Bool = true 18 | 19 | public var results: [YTChannel] = [] 20 | 21 | public var continuationToken: String? = nil 22 | 23 | public var visitorData: String? = nil // will never be filled 24 | 25 | public static func decodeJSON(json: JSON) throws -> AccountSubscriptionsResponse { 26 | var toReturn = AccountSubscriptionsResponse() 27 | 28 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 29 | 30 | toReturn.isDisconnected = false 31 | 32 | guard let tab = json["contents", "twoColumnBrowseResultsRenderer", "tabs"].arrayValue.first(where: { 33 | return $0["tabRenderer", "selected"].boolValue 34 | }), tab["tabRenderer", "tabIdentifier"].string == "FEchannels" else { 35 | throw ResponseExtractionError(reponseType: Self.self, stepDescription: "Error while trying the get the tab of the channels.") 36 | } 37 | 38 | for section in tab["tabRenderer", "content", "sectionListRenderer", "contents"].arrayValue { 39 | if section["itemSectionRenderer"].exists() { 40 | toReturn.results.append(contentsOf: self.getChannelsFromItemSectionRenderer(section["itemSectionRenderer"])) 41 | } else if section["continuationItemRenderer"].exists() { 42 | toReturn.continuationToken = section["continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token"].string 43 | } 44 | } 45 | 46 | return toReturn 47 | } 48 | 49 | /// Struct representing the continuation ("load more videos" button) 50 | public struct Continuation: AuthenticatedResponse, ResponseContinuation { 51 | public static let headersType: HeaderTypes = .usersSubscriptionsContinuationHeaders 52 | 53 | public static let parametersValidationList: ValidationList = [.continuation: .existenceValidator] 54 | 55 | public var isDisconnected: Bool = true 56 | 57 | /// Continuation token used to fetch more channels, nil if there is no more channels to fetch. 58 | public var continuationToken: String? 59 | 60 | /// Array of history blocks. 61 | public var results: [YTChannel] = [] 62 | 63 | public static func decodeJSON(json: JSON) -> AccountSubscriptionsResponse.Continuation { 64 | var toReturn = Continuation() 65 | 66 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 67 | 68 | toReturn.isDisconnected = false 69 | 70 | for continuationAction in json["onResponseReceivedActions"].arrayValue where continuationAction["appendContinuationItemsAction"].exists() { 71 | for continuationItem in continuationAction["appendContinuationItemsAction", "continuationItems"].arrayValue { 72 | if continuationItem["itemSectionRenderer"].exists() { 73 | toReturn.results.append(contentsOf: getChannelsFromItemSectionRenderer(continuationItem["itemSectionRenderer"])) 74 | } else if continuationItem["continuationItemRenderer"].exists() { 75 | toReturn.continuationToken = continuationItem["continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token"].string 76 | } 77 | } 78 | } 79 | 80 | return toReturn 81 | } 82 | } 83 | 84 | private static func getChannelsFromItemSectionRenderer(_ json: JSON) -> [YTChannel] { 85 | var toReturn: [YTChannel] = [] 86 | for itemSectionContents in json["contents"].arrayValue { 87 | for channelJSON in itemSectionContents["shelfRenderer", "content", "expandedShelfContentsRenderer", "items"].arrayValue { 88 | guard let channel = YTChannel.decodeJSON(json: channelJSON["channelRenderer"]) else { continue } 89 | toReturn.append(channel) 90 | } 91 | } 92 | 93 | return toReturn 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/Home/HomeScreenResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeScreenResponse.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 28.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Struct representing the request/response of the main YouTube webpage. 11 | public struct HomeScreenResponse: ContinuableResponse { 12 | public static let headersType: HeaderTypes = .home 13 | 14 | public static let parametersValidationList: ValidationList = [:] 15 | 16 | /// Continuation token used to fetch more videos, nil if there is no more videos to fetch. 17 | /// 18 | /// It should normally never be nil because this is the main webpage with infinite results 19 | public var continuationToken: String? 20 | 21 | /// An array representing the videos from the home screen, can be empty if no cookies were provided. 22 | public var results: [YTVideo] = [] 23 | 24 | /// String token that is necessary to give to the continuation request in order to make it to work (it sorts of authenticate the continuation). 25 | public var visitorData: String? 26 | 27 | public static func decodeJSON(json: JSON) -> HomeScreenResponse { 28 | var toReturn = HomeScreenResponse() 29 | 30 | toReturn.visitorData = json["responseContext", "visitorData"].string 31 | 32 | guard let tabsArray = json["contents", "twoColumnBrowseResultsRenderer", "tabs"].array else { return toReturn } 33 | for tab in tabsArray { 34 | guard tab["tabRenderer", "selected"].bool ?? false else { continue } 35 | 36 | guard let videosArray = tab["tabRenderer", "content", "richGridRenderer", "contents"].array else { continue } 37 | for video in videosArray { 38 | if video["richItemRenderer", "content", "videoRenderer", "videoId"].string != nil, let decodedVideo = YTVideo.decodeJSON(json: video["richItemRenderer", "content", "videoRenderer"]) { 39 | toReturn.results.append(decodedVideo) 40 | } else if video["richItemRenderer", "content", "lockupViewModel"].exists(), let video = YTVideo.decodeLockupJSON(json: video["richItemRenderer", "content", "lockupViewModel"]) { 41 | toReturn.results.append(video) 42 | } else if let continuationToken = video["continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token"].string { 43 | toReturn.continuationToken = continuationToken 44 | } 45 | } 46 | } 47 | 48 | return toReturn 49 | } 50 | 51 | /// Struct representing the continuation ("load more videos" button) 52 | public struct Continuation: ResponseContinuation { 53 | public static let headersType: HeaderTypes = .homeVideosContinuationHeader 54 | 55 | public static let parametersValidationList: ValidationList = [.continuation: .existenceValidator, .visitorData: .existenceValidator] 56 | 57 | /// Continuation token used to fetch more videos, nil if there is no more videos to fetch. 58 | /// 59 | /// It should normally never be nil because this is the main webpage with infinite results 60 | public var continuationToken: String? 61 | 62 | /// Videos array representing the results of the request. 63 | public var results: [YTVideo] = [] 64 | 65 | public static func decodeJSON(json: JSON) -> HomeScreenResponse.Continuation { 66 | var toReturn = Continuation() 67 | 68 | guard let continuationActionsArray = json["onResponseReceivedActions"].array else { return toReturn } 69 | 70 | for continuationAction in continuationActionsArray { 71 | guard let continuationItemsArray = continuationAction["appendContinuationItemsAction", "continuationItems"].array else { return toReturn } 72 | 73 | for video in continuationItemsArray { 74 | if video["richItemRenderer", "content", "videoRenderer", "videoId"].string != nil, let decodedVideo = YTVideo.decodeJSON(json: video["richItemRenderer", "content", "videoRenderer"]) { 75 | toReturn.results.append(decodedVideo) 76 | } else if video["richItemRenderer", "content", "lockupViewModel"].exists(), let video = YTVideo.decodeLockupJSON(json: video["richItemRenderer", "content", "lockupViewModel"]) { 77 | toReturn.results.append(video) 78 | } else if let continuationToken = video["continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token"].string { 79 | toReturn.continuationToken = continuationToken 80 | } 81 | } 82 | } 83 | 84 | return toReturn 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/VideoInfos/VideoCaptionsResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VideoCaptionsResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 27.06.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Struct representing a response containing the captions of a video. 12 | public struct VideoCaptionsResponse: YouTubeResponse { 13 | public static let headersType: HeaderTypes = .videoCaptionsHeaders 14 | 15 | public static let parametersValidationList: ValidationList = [.customURL: .urlValidator] 16 | 17 | public var captionParts: [CaptionPart] 18 | 19 | public init(captionParts: [CaptionPart]) { 20 | self.captionParts = captionParts 21 | } 22 | 23 | public static func decodeData(data: Data) throws -> VideoCaptionsResponse { 24 | var toReturn = VideoCaptionsResponse(captionParts: []) 25 | 26 | #if os(macOS) 27 | let dataText = CFXMLCreateStringByUnescapingEntities(nil, CFXMLCreateStringByUnescapingEntities(nil, String(decoding: data, as: UTF8.self) as CFString, nil), nil) as String 28 | #else 29 | let dataText = String(decoding: data, as: UTF8.self) 30 | #endif 31 | 32 | let regexResults = dataText.ytkRegexMatches(for: #"(?:([\w\W]*?)<\/text>)"#) 33 | 34 | var currentEndTime: Double = Double.infinity 35 | 36 | for result in regexResults.reversed() { 37 | guard result.count == 4 else { continue } 38 | 39 | let startTime = Double(result[1]) ?? 0 40 | let duration = min(Double(result[2]) ?? 0, currentEndTime - startTime) 41 | 42 | let text = result[3] 43 | 44 | toReturn.captionParts.append( 45 | CaptionPart( 46 | text: text, 47 | startTime: startTime, 48 | duration: duration 49 | ) 50 | ) 51 | 52 | currentEndTime = startTime 53 | } 54 | 55 | toReturn.captionParts.reverse() 56 | 57 | return toReturn 58 | } 59 | 60 | /// Decode json to give an instance of ``VideoInfosResponse``. 61 | /// - Parameter json: the json to be decoded. 62 | /// - Returns: an instance of ``VideoInfosResponse``. 63 | public static func decodeJSON(json: JSON) throws -> VideoCaptionsResponse { 64 | throw ResponseExtractionError(reponseType: Self.self, stepDescription: "Can't decode a VideoCaptionsResponse from some raw JSON.") 65 | } 66 | 67 | public func getFormattedString(withFormat format: CaptionFormats) -> String { 68 | func getTimeString(_ time: Double) -> String { 69 | let hours: String = String(format: "%02d", Int(time / 3600)) 70 | let minutes: String = String(format: "%02d", Int(time - (time / 3600).rounded(.down) * 3600) / 60) 71 | let seconds: String = String(format: "%02d", Int(time.truncatingRemainder(dividingBy: 60))) 72 | let milliseconds: String = String(format: "%03d", Int(time.truncatingRemainder(dividingBy: 1) * 1000)) 73 | 74 | return "\(hours):\(minutes):\(seconds)\(format == .vtt ? "." : ",")\(milliseconds)" 75 | } 76 | 77 | return """ 78 | \(format == .vtt ? "WEBVTT\n\n" : "")\( 79 | self.captionParts.enumerated() 80 | .map { offset, captionPart in 81 | return """ 82 | \(offset + 1) 83 | \(getTimeString(captionPart.startTime)) --> \(getTimeString(captionPart.startTime + captionPart.duration)) 84 | \(captionPart.text) 85 | """ 86 | } 87 | .joined(separator: "\n\n") 88 | ) 89 | """ 90 | } 91 | 92 | public enum CaptionFormats { 93 | case vtt 94 | case srt 95 | } 96 | 97 | public struct CaptionPart: Sendable, Codable { 98 | /// Text of the caption. 99 | /// 100 | /// - Warning: The text might contain HTML entities (if `CFXMLCreateStringByUnescapingEntities` is not present), to remove them, call a function like `CFXMLCreateStringByUnescapingEntities()` two times on the text. 101 | public var text: String 102 | 103 | /// Start time of the caption, in seconds. 104 | public var startTime: Double 105 | 106 | /// Duration of the caption, in seconds. 107 | public var duration: Double 108 | 109 | public init(text: String, startTime: Double, duration: Double) { 110 | self.text = text 111 | self.startTime = startTime 112 | self.duration = duration 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/ErrorHandling/ParameterValidator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParameterValidator.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 01.03.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct ParameterValidator: Sendable { 12 | /// A boolean that indicated whether the existence of the parameter is needed in order for it to succeed. 13 | public let needExistence: Bool 14 | 15 | /// The handler that is executed to check if a parameter's value is valid and modify it if necessary. Returns a optional string if everything is fine (should override the request's actual parameter in case it's not nil) or a ``ValidationError``. 16 | public let handler: @Sendable (String?) -> Result 17 | 18 | /// - Parameter needExistence: A boolean that indicated whether the existence of the parameter is needed in order for it to succeed. 19 | /// - Parameter validator: The handler that is executed to check if a parameter's value is valid and modify it if necessary. Returns a optional string if everything is fine (should override the request's actual parameter) or a ``ValidationError``. 20 | public init(needExistence: Bool = true, validator: @escaping @Sendable (String?) -> Result) { 21 | self.needExistence = needExistence 22 | self.handler = validator 23 | } 24 | 25 | /// Combine this validator with another, if the two affect the final parameter, the validator from when this method is called will be called first. 26 | public func combine(with otherValidator: ParameterValidator) -> ParameterValidator { 27 | return ParameterValidator(needExistence: self.needExistence /* we don't do an OR operation with the other validator as the current one could introduce a default value for nil values */, validator: { parameter in 28 | switch self.handler(parameter) { 29 | case .success(let result): 30 | guard otherValidator.needExistence && result == nil else { return .failure(ValidationError(reason: "Nil value.", validatorFailedNameDescriptor: "Combination of \(String(describing: self)) and \(String(describing: otherValidator))")) } 31 | return otherValidator.handler(parameter) 32 | case .failure(let error): 33 | return .failure(error) 34 | } 35 | }) 36 | } 37 | 38 | public func validate(parameter: String?, contentType: HeadersList.AddQueryInfo.ContentTypes) throws -> String? { 39 | if parameter == nil { 40 | if self.needExistence { 41 | throw 42 | TypedValidationError(dataType: contentType, reason: "DataType \(contentType.rawValue) parameter was not provided but is required.", validatorFailedNameDescriptor: "NeedExistence default validator.") 43 | } 44 | return nil 45 | } 46 | 47 | switch self.handler(parameter) { 48 | case .success(let newParameter): 49 | return newParameter 50 | case .failure(let error): 51 | throw TypedValidationError(dataType: contentType, reason: error.reason, validatorFailedNameDescriptor: error.validatorFailedNameDescriptor) 52 | } 53 | } 54 | 55 | /// A struct representing an error returned by a validator's handler. 56 | public struct ValidationError: Error { 57 | 58 | /// A string describing the reason of the error. 59 | public let reason: String 60 | 61 | /// A string describing the validator's role. 62 | public let validatorFailedNameDescriptor: String 63 | 64 | /// - Parameter reason: A string describing the reason of the error. 65 | /// - Parameter validatorFailedNameDescriptor: A string describing the validator's role. 66 | public init(reason: String, validatorFailedNameDescriptor: String) { 67 | self.reason = reason 68 | self.validatorFailedNameDescriptor = validatorFailedNameDescriptor 69 | } 70 | } 71 | 72 | /// A struct representing an error returned by a validator's handler. 73 | public struct TypedValidationError: Error { 74 | /// The type of data concerned by the error. 75 | public let dataType: HeadersList.AddQueryInfo.ContentTypes 76 | 77 | /// A string describing the reason of the error. 78 | public let reason: String 79 | 80 | /// A string describing the validator's role. 81 | public let validatorFailedNameDescriptor: String 82 | 83 | /// - Parameter dataType: The type of data concerned by the error. 84 | /// - Parameter reason: A string describing the reason of the error. 85 | /// - Parameter validatorFailedNameDescriptor: A string describing the validator's role. 86 | public init(dataType: HeadersList.AddQueryInfo.ContentTypes, reason: String, validatorFailedNameDescriptor: String) { 87 | self.dataType = dataType 88 | self.reason = reason 89 | self.validatorFailedNameDescriptor = validatorFailedNameDescriptor 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/HeaderTypes+RawRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeadersList+RawRepresentable.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 19.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | extension HeaderTypes: RawRepresentable { 11 | 12 | public init?(rawValue: String) { 13 | return nil 14 | } 15 | 16 | public var rawValue: String { 17 | switch self { 18 | case .home: 19 | return "home" 20 | case .search: 21 | return "search" 22 | case .restrictedSearch: 23 | return "restrictedSearch" 24 | case .videoInfos: 25 | return "videoInfos" 26 | case .videoInfosWithDownloadFormats: 27 | return "videoInfosWithAdaptative" 28 | case .autoCompletion: 29 | return "autoCompletion" 30 | case .channelHeaders: 31 | return "channelHeaders" 32 | case .playlistHeaders: 33 | return "playlistHeaders" 34 | case .playlistContinuationHeaders: 35 | return "playlistContinuationHeaders" 36 | case .homeVideosContinuationHeader: 37 | return "homeVideosContinuationHeader" 38 | case .searchContinuationHeaders: 39 | return "searchContinuationHeaders" 40 | case .channelContinuationHeaders: 41 | return "channelContinuationHeaders" 42 | case .userAccountHeaders: 43 | return "userAccountHeaders" 44 | case .usersLibraryHeaders: 45 | return "usersLibraryHeaders" 46 | case .usersPlaylistsHeaders: 47 | return "usersPlaylistsHeaders" 48 | case .usersAllPlaylistsHeaders: 49 | return "usersAllPlaylistsHeaders" 50 | case .createPlaylistHeaders: 51 | return "createPlaylistHeaders" 52 | case .moveVideoInPlaylistHeaders: 53 | return "moveVideoInPlaylistHeaders" 54 | case .removeVideoFromPlaylistHeaders: 55 | return "removeVideoFromPlaylistHeaders" 56 | case .removeVideoByIdFromPlaylistHeaders: 57 | return "removeVideoByIdFromPlaylistHeaders" 58 | case .addVideoToPlaylistHeaders: 59 | return "addVideoToPlaylistHeaders" 60 | case .deletePlaylistHeaders: 61 | return "deletePlaylistHeaders" 62 | case .historyHeaders: 63 | return "historyHeaders" 64 | case .historyContinuationHeaders: 65 | return "historyContinuationHeaders" 66 | case .deleteVideoFromHistory: 67 | return "deleteVideoFromHistory" 68 | case .moreVideoInfosHeaders: 69 | return "moreVideoInfosHeaders" 70 | case .fetchMoreRecommendedVideosHeaders: 71 | return "fetchMoreRecommendedVideosHeaders" 72 | case .likeVideoHeaders: 73 | return "likeVideoHeaders" 74 | case .dislikeVideoHeaders: 75 | return "dislikeVideoHeaders" 76 | case .removeLikeStatusFromVideoHeaders: 77 | return "removeLikeStatusFromVideoHeaders" 78 | case .subscribeToChannelHeaders: 79 | return "subscribeToChannelHeaders" 80 | case .unsubscribeFromChannelHeaders: 81 | return "unsubscribeFromChannelHeaders" 82 | case .videoCaptionsHeaders: 83 | return "videoCaptionsHeaders" 84 | case .trendingVideosHeaders: 85 | return "trendingVideosHeaders" 86 | case .usersSubscriptionsHeaders: 87 | return "usersSubscriptionsHeaders" 88 | case .usersSubscriptionsContinuationHeaders: 89 | return "usersSubscriptionsContinuationHeaders" 90 | case .usersSubscriptionsFeedHeaders: 91 | return "usersSubscriptionsFeedHeaders" 92 | case .usersSubscriptionsFeedContinuationHeaders: 93 | return "usersSubscriptionsFeedContinuationHeaders" 94 | case .videoCommentsHeaders: 95 | return "videoCommentsHeaders" 96 | case .videoCommentsContinuationHeaders: 97 | return "videoCommentsContinuationHeaders" 98 | case .createCommentHeaders: 99 | return "createCommentHeaders" 100 | case .likeCommentHeaders: 101 | return "likeCommentHeaders" 102 | case .dislikeCommentHeaders: 103 | return "dislikeCommentHeaders" 104 | case .removeLikeCommentHeaders: 105 | return "removeLikeCommentHeaders" 106 | case .removeDislikeCommentHeaders: 107 | return "removeDislikeCommentHeaders" 108 | case .editCommentHeaders: 109 | return "editCommentHeaders" 110 | case .replyCommentHeaders: 111 | return "replyCommentHeaders" 112 | case .removeCommentHeaders: 113 | return "removeCommentHeaders" 114 | case .translateCommentHeaders: 115 | return "removeCommentHeaders" 116 | case .editReplyCommentHeaders: 117 | return "editReplyCommentHeaders" 118 | case .customHeaders(let stringIdentifier): 119 | return stringIdentifier 120 | } 121 | } 122 | 123 | public typealias RawValue = String 124 | 125 | } 126 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/ErrorHandling/ParameterValidator+commonValidators.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParameterValidator+commonValidators.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 01.03.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension ParameterValidator { 12 | static let videoIdValidator = ParameterValidator(validator: { videoId in 13 | let validatorName = "VideoId validator" 14 | guard let videoId = videoId else { return .failure(.init(reason: "Nil value.", validatorFailedNameDescriptor: validatorName))} 15 | 16 | // https://webapps.stackexchange.com/a/101153 17 | guard videoId.count == 11, let lastChar = videoId.last else { return .failure(.init(reason: "The videoId does not contain exactly 11 characters." /* this can change in the future */, validatorFailedNameDescriptor: validatorName))} 18 | 19 | guard "048AEIMQUYcgkosw".contains(lastChar) else { return .failure(.init(reason: "Last character of videoId is not valid, should be included in 048AEIMQUYcgkosw, current videoId: \(videoId)", validatorFailedNameDescriptor: validatorName))} 20 | 21 | return .success(videoId) 22 | }) 23 | 24 | static let optionalVideoIdValidator = ParameterValidator(needExistence: false, validator: Self.videoIdValidator.handler) 25 | 26 | static let existenceValidator = ParameterValidator(validator: { parameter in 27 | if parameter == nil { 28 | return .failure(.init(reason: "Parameter is nil.", validatorFailedNameDescriptor: "ExistenceValidator.")) 29 | } else { 30 | return .success(parameter) 31 | } 32 | }) 33 | 34 | static let textSanitizerValidator = ParameterValidator(validator: { parameter in 35 | if let parameter = parameter { 36 | return .success(parameter.replacingOccurrences(of: "\\", with: #"\\"#).replacingOccurrences(of: "\"", with: #"\""#)) 37 | } else { 38 | return .failure(.init(reason: "Parameter is nil.", validatorFailedNameDescriptor: "ExistenceValidator.")) 39 | } 40 | }) 41 | 42 | static let channelIdValidator = ParameterValidator(validator: { channelId in 43 | let validatorName = "ChannelId validator" 44 | guard let channelId = channelId else { return .failure(.init(reason: "Nil value.", validatorFailedNameDescriptor: validatorName))} 45 | 46 | // https://webapps.stackexchange.com/a/101153 47 | 48 | let idCount = channelId.count 49 | 50 | guard idCount == 22 || idCount == 24, let lastChar = channelId.last else { return .failure(.init(reason: "The channelId does not contain exactly 22 or 24 characters." /* this can change in the future */, validatorFailedNameDescriptor: validatorName))} 51 | 52 | guard "AQgw".contains(lastChar) else { return .failure(.init(reason: "Last character of channelId is not valid, should be included in AQgw, current channelId: \(channelId)", validatorFailedNameDescriptor: validatorName))} 53 | 54 | return .success(channelId) 55 | }) 56 | 57 | static let playlistIdWithVLPrefixValidator = ParameterValidator(validator: { playlistId in 58 | let validatorName = "PlaylistId with VL prefix validator" 59 | guard let playlistId = playlistId, !playlistId.isEmpty else { return .failure(.init(reason: "Nil or empty value.", validatorFailedNameDescriptor: validatorName))} 60 | 61 | if playlistId.hasPrefix("VL") { 62 | return .success(playlistId) 63 | } else { 64 | return .success("VL" + playlistId) 65 | } 66 | }) 67 | 68 | static let playlistIdWithoutVLPrefixValidator = ParameterValidator(validator: { playlistId in 69 | let validatorName = "PlaylistId without VL prefix validator" 70 | guard let playlistId = playlistId, !playlistId.isEmpty else { return .failure(.init(reason: "Nil or empty value.", validatorFailedNameDescriptor: validatorName))} 71 | 72 | if playlistId.hasPrefix("VL") { 73 | return .success(String(playlistId.dropFirst(2))) 74 | } else { 75 | return .success(playlistId) 76 | } 77 | }) 78 | 79 | static let privacyValidator = ParameterValidator(validator: { privacy in 80 | let validatorName = "Privacy validator" 81 | guard let privacy = privacy else { return .failure(.init(reason: "Nil value.", validatorFailedNameDescriptor: validatorName))} 82 | 83 | if YTPrivacy(rawValue: privacy) == nil { 84 | return .failure(.init(reason: "Given privacy is not valid, received: \(privacy) but expected one of those: \(YTPrivacy.allCases.map({$0.rawValue})). Make sure you pass the rawValue of one of the YTPrivacy.", validatorFailedNameDescriptor: validatorName)) 85 | } else { 86 | return .success(privacy) 87 | } 88 | }) 89 | 90 | static let urlValidator = ParameterValidator(validator: { url in 91 | let validatorName = "URL validator" 92 | 93 | guard let url = url else { return .failure(.init(reason: "Nil value.", validatorFailedNameDescriptor: validatorName)) } // should never be called because of the needExistence 94 | 95 | if URL(string: url) != nil { 96 | return .success(url) 97 | } else { 98 | return .failure(.init(reason: "Given url is not a valid URL.", validatorFailedNameDescriptor: validatorName)) 99 | } 100 | }) 101 | } 102 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/AccountResponses/AccountSubscriptionsFeedResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountSubscriptionsFeedResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 02.07.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A response to get the latest video from channels the YouTubeModel's account is subscribed to. 12 | public struct AccountSubscriptionsFeedResponse: AuthenticatedContinuableResponse { 13 | public static let headersType: HeaderTypes = .usersSubscriptionsFeedHeaders 14 | 15 | public static let parametersValidationList: ValidationList = [:] 16 | 17 | public var isDisconnected: Bool = true 18 | 19 | public var results: [YTVideo] = [] 20 | 21 | public var continuationToken: String? = nil 22 | 23 | public var visitorData: String? = nil // will never be filled 24 | 25 | public static func decodeJSON(json: JSON) throws -> AccountSubscriptionsFeedResponse { 26 | var toReturn = AccountSubscriptionsFeedResponse() 27 | 28 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 29 | 30 | toReturn.isDisconnected = false 31 | 32 | guard let tab = json["contents", "twoColumnBrowseResultsRenderer", "tabs"].arrayValue.first(where: { 33 | return $0["tabRenderer", "selected"].boolValue 34 | }), tab["tabRenderer", "tabIdentifier"].string == "FEsubscriptions" else { 35 | throw ResponseExtractionError(reponseType: Self.self, stepDescription: "Error while trying the get the tab of the subscriptions.") 36 | } 37 | 38 | for section in tab["tabRenderer", "content", "richGridRenderer", "contents"].arrayValue { 39 | if section["richItemRenderer"].exists() { 40 | guard let video = self.getVideoFromItemRenderer(section["richItemRenderer"]) else { continue } 41 | toReturn.results.append(video) 42 | } else if section["richSectionRenderer"].exists() { 43 | let videos = self.getShortsFromSectionRenderer(section["richSectionRenderer"]) 44 | toReturn.results.append(contentsOf: videos) 45 | } else if section["continuationItemRenderer"].exists() { 46 | toReturn.continuationToken = getContinuationToken(section["continuationItemRenderer"]) 47 | } 48 | } 49 | 50 | return toReturn 51 | } 52 | 53 | /// Struct representing the continuation ("load more videos" button) 54 | public struct Continuation: AuthenticatedResponse, ResponseContinuation { 55 | public static let headersType: HeaderTypes = .usersSubscriptionsContinuationHeaders 56 | 57 | public static let parametersValidationList: ValidationList = [.continuation: .existenceValidator] 58 | 59 | public var isDisconnected: Bool = true 60 | 61 | /// Continuation token used to fetch more videos, nil if there is no more videos to fetch. 62 | public var continuationToken: String? 63 | 64 | /// Array of videos. 65 | public var results: [YTVideo] = [] 66 | 67 | public static func decodeJSON(json: JSON) -> AccountSubscriptionsFeedResponse.Continuation { 68 | var toReturn = Continuation() 69 | 70 | guard !(json["responseContext", "mainAppWebResponseContext", "loggedOut"].bool ?? true) else { return toReturn } 71 | 72 | toReturn.isDisconnected = false 73 | 74 | for continuationAction in json["onResponseReceivedActions"].arrayValue where continuationAction["appendContinuationItemsAction"].exists() { 75 | for continuationItem in continuationAction["appendContinuationItemsAction", "continuationItems"].arrayValue { 76 | if continuationItem["richItemRenderer"].exists() { 77 | guard let video = getVideoFromItemRenderer(continuationItem["richItemRenderer"]) else { continue } 78 | toReturn.results.append(video) 79 | } else if continuationItem["richSectionRenderer"].exists() { 80 | let videos = getShortsFromSectionRenderer(continuationItem["richSectionRenderer"]) 81 | toReturn.results.append(contentsOf: videos) 82 | } else if continuationItem["continuationItemRenderer"].exists() { 83 | toReturn.continuationToken = getContinuationToken(continuationItem["continuationItemRenderer"]) 84 | } 85 | } 86 | } 87 | 88 | return toReturn 89 | } 90 | } 91 | 92 | private static func getShortsFromSectionRenderer(_ json: JSON) -> [YTVideo] { 93 | var toReturn: [YTVideo] = [] 94 | for itemSectionContents in json["content", "richShelfRenderer", "contents"].arrayValue { 95 | guard let video = getVideoFromItemRenderer(itemSectionContents["richItemRenderer"]) else { continue } 96 | toReturn.append(video) 97 | } 98 | 99 | return toReturn 100 | } 101 | 102 | private static func getVideoFromItemRenderer(_ json: JSON) -> YTVideo? { 103 | if json["content", "videoRenderer"].exists() { 104 | return YTVideo.decodeJSON(json: json["content", "videoRenderer"]) 105 | } else { 106 | return YTVideo.decodeShortFromJSON(json: json["content", "reelItemRenderer"]) ?? YTVideo.decodeShortFromLockupJSON(json: json["content", "shortsLockupViewModel"]) 107 | } 108 | } 109 | 110 | private static func getContinuationToken(_ json: JSON) -> String? { 111 | return json["continuationEndpoint", "continuationCommand", "token"].string 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseProtocols/Video/YouTubeVideo+likeActions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YouTubeVideo+likeActions.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 17.10.2023. 6 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension YouTubeVideo { 12 | /// Like the video. 13 | /// 14 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 15 | func likeVideo(youtubeModel: YouTubeModel, result: @escaping @Sendable (Error?) -> Void) { 16 | LikeVideoResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.query: self.videoId], result: { response in 17 | switch response { 18 | case .success(let data): 19 | if data.isDisconnected { 20 | result("Failed to like video because the account is disconnected.") 21 | } else { 22 | result(nil) 23 | } 24 | case .failure(let error): 25 | result(error) 26 | } 27 | }) 28 | } 29 | 30 | /// Like the video. 31 | /// 32 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 33 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 34 | func likeVideoThrowing(youtubeModel: YouTubeModel) async throws { 35 | return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in 36 | self.likeVideo(youtubeModel: youtubeModel, result: { error in 37 | if let error = error { 38 | continuation.resume(throwing: error) 39 | } else { 40 | continuation.resume() 41 | } 42 | }) 43 | }) 44 | } 45 | 46 | /// Like the video. 47 | /// 48 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 49 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 50 | func likeVideo(youtubeModel: YouTubeModel) async -> Error? { 51 | return await withCheckedContinuation({ (continuation: CheckedContinuation) in 52 | likeVideo(youtubeModel: youtubeModel, result: { error in 53 | continuation.resume(returning: error) 54 | }) 55 | }) 56 | } 57 | 58 | /// Dislike the video. 59 | /// 60 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 61 | func dislikeVideo(youtubeModel: YouTubeModel, result: @escaping @Sendable (Error?) -> Void) { 62 | DislikeVideoResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.query: self.videoId], result: { response in 63 | switch response { 64 | case .success(let data): 65 | if data.isDisconnected { 66 | result("Failed to dislike video because the account is disconnected.") 67 | } else { 68 | result(nil) 69 | } 70 | case .failure(let error): 71 | result(error) 72 | } 73 | }) 74 | } 75 | 76 | /// Dislike the video. 77 | /// 78 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 79 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 80 | func dislikeVideoThrowing(youtubeModel: YouTubeModel) async throws { 81 | return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in 82 | self.dislikeVideo(youtubeModel: youtubeModel, result: { error in 83 | if let error = error { 84 | continuation.resume(throwing: error) 85 | } else { 86 | continuation.resume() 87 | } 88 | }) 89 | }) 90 | } 91 | 92 | /// Dislike the video. 93 | /// 94 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 95 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 96 | func dislikeVideo(youtubeModel: YouTubeModel) async -> Error? { 97 | return await withCheckedContinuation({ (continuation: CheckedContinuation) in 98 | dislikeVideo(youtubeModel: youtubeModel, result: { error in 99 | continuation.resume(returning: error) 100 | }) 101 | }) 102 | } 103 | 104 | /// Remove the like/dislike from the video. 105 | /// 106 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 107 | func removeLikeFromVideo(youtubeModel: YouTubeModel, result: @escaping @Sendable (Error?) -> Void) { 108 | RemoveLikeFromVideoResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.query: self.videoId], result: { response in 109 | switch response { 110 | case .success(let data): 111 | if data.isDisconnected { 112 | result("Failed to remove like from video because the account is disconnected.") 113 | } else { 114 | result(nil) 115 | } 116 | case .failure(let error): 117 | result(error) 118 | } 119 | }) 120 | } 121 | 122 | /// Remove the like/dislike from the video. 123 | /// 124 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 125 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 126 | func removeLikeFromVideoThrowing(youtubeModel: YouTubeModel) async throws { 127 | return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in 128 | self.removeLikeFromVideo(youtubeModel: youtubeModel, result: { error in 129 | if let error = error { 130 | continuation.resume(throwing: error) 131 | } else { 132 | continuation.resume() 133 | } 134 | }) 135 | }) 136 | } 137 | 138 | /// Remove the like/dislike from the video. 139 | /// 140 | /// Requires a ``YouTubeModel`` where ``YouTubeModel/cookies`` is defined. 141 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 142 | func removeLikeFromVideo(youtubeModel: YouTubeModel) async -> Error? { 143 | return await withCheckedContinuation({ (continuation: CheckedContinuation) in 144 | self.removeLikeFromVideo(youtubeModel: youtubeModel, result: { error in 145 | continuation.resume(returning: error) 146 | }) 147 | }) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/YouTubeResponseTypes/Trending/TrendingVideosResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrendingVideosResponse.swift 3 | // 4 | // 5 | // Created by Antoine Bollengier on 02.07.2024. 6 | // Copyright © 2024 - 2025 Antoine Bollengier (github.com/b5i). All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | @available(*, deprecated, message: "YouTube removed the Trending tab and it is no longer accessible. This struct will be removed in a future version.") 12 | public struct TrendingVideosResponse: YouTubeResponse { 13 | public static let headersType: HeaderTypes = .trendingVideosHeaders 14 | 15 | public static let parametersValidationList: ValidationList = [:] 16 | 17 | /// Dictionnary associating a category identifier (usually like "Music" or "Film" contained in the ``TrendingVideosResponse/requestParams`` as the dictionnary keys) with an array of videos (representing the trendig videos of the tab identifier). It can be seen as some cache for every type of category. 18 | /// 19 | /// You can also overwrite the content that it contains to another response by using (only the video arrays that are non-empty will overwrite): 20 | /// ```swift 21 | /// let YTM = YouTubeModel() 22 | /// let myInstance: TrendingVideosResponse = ... 23 | /// 24 | /// // Supposing myInstance.requestParams["MyIdentifier"] != nil (but can be an empty string) 25 | /// myInstance.getCategoryContent(forIdentifier: "MyIdentifier", youtubeModel: YTM) { result in 26 | /// switch result { 27 | /// case .success(let newInstance): 28 | /// myInstance.mergeTrendingResponse(newInstance) 29 | /// case .failure(let error): 30 | /// // Deal with the error 31 | /// } 32 | /// } 33 | /// ``` 34 | public var categoriesContentsStore: [String : [YTVideo]] = [:] 35 | 36 | /// The category that is currently displayed (opened category on YouTube's side that contains the videos), by default, if no params (values of the ``TrendingVideosResponse/requestParams``) were given during the request (if you're using one of the `TrendingVideosResponse.sendRequest...` methods), the default "Trending" tab is returned. 37 | public var currentContentIdentifier: String? = nil 38 | 39 | /// Dictionnary of a string representing the params to send to get the RequestType from YouTube. 40 | public var requestParams: [String : String] = [:] 41 | 42 | public static func decodeJSON(json: JSON) -> TrendingVideosResponse { 43 | var toReturn = TrendingVideosResponse() 44 | 45 | /// Time to get the params to be able to make channel content requests. 46 | 47 | guard let tabsArray = json["contents", "twoColumnBrowseResultsRenderer", "tabs"].array else { return toReturn } 48 | 49 | for tab in tabsArray { 50 | guard let tabName = tab["tabRenderer", "title"].string else { continue } 51 | 52 | toReturn.requestParams[tabName] = self.getParams(json: tab) 53 | 54 | guard tab["tabRenderer", "selected"].bool == true else { continue } 55 | 56 | var currentVideosArray: [YTVideo] = [] 57 | 58 | toReturn.currentContentIdentifier = tabName 59 | 60 | for sectionContent in tab["tabRenderer", "content", "sectionListRenderer", "contents"].arrayValue { 61 | for itemSectionContents in sectionContent["itemSectionRenderer", "contents"].arrayValue { 62 | for videoJSON in itemSectionContents["shelfRenderer", "content", "expandedShelfContentsRenderer", "items"].arrayValue { 63 | guard let video = YTVideo.decodeJSON(json: videoJSON["videoRenderer"]) else { continue } 64 | 65 | currentVideosArray.append(video) 66 | } 67 | } 68 | } 69 | 70 | toReturn.categoriesContentsStore[tabName] = currentVideosArray 71 | } 72 | 73 | return toReturn 74 | } 75 | 76 | /// Get the trending videos for a certain category. 77 | /// 78 | /// To see an example usage, check ``TrendingVideosResponse/categoriesContentsStore``. 79 | public func getCategoryContent(forIdentifier identifier: String, youtubeModel: YouTubeModel, useCookies: Bool? = nil, result: @escaping @Sendable (Result) -> ()) { 80 | guard 81 | let params = requestParams[identifier] 82 | else { result(.failure("Something between returnType or params haven't been added where it should, returnType in TrendingVideosResponse.requestTypes and params in TrendingVideosResponse.requestParams")); return } 83 | 84 | TrendingVideosResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.params: params], useCookies: useCookies, result: { trendingVideoResponse in 85 | result(trendingVideoResponse) 86 | }) 87 | } 88 | 89 | /// Get the trending videos for a certain category. 90 | /// 91 | /// To see an example usage, check ``TrendingVideosResponse/categoriesContentsStore``. 92 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) 93 | public func getCategoryContentThrowing(forIdentifier identifier: String, youtubeModel: YouTubeModel, useCookies: Bool? = nil) async throws -> TrendingVideosResponse { 94 | return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in 95 | self.getCategoryContent(forIdentifier: identifier, youtubeModel: youtubeModel, useCookies: useCookies, result: { categoryContent in 96 | continuation.resume(with: categoryContent) 97 | }) 98 | }) 99 | } 100 | 101 | /// Merges another response to the current instance by overwriting the non-empty tabs and the currentContentIdentifier (if not nil) into it. 102 | public mutating func mergeTrendingResponse(_ otherResponse: TrendingVideosResponse) { 103 | for tab in otherResponse.categoriesContentsStore where !tab.value.isEmpty { 104 | self.categoriesContentsStore[tab.key] = otherResponse.categoriesContentsStore[tab.key] 105 | self.currentContentIdentifier = otherResponse.currentContentIdentifier ?? self.currentContentIdentifier 106 | } 107 | } 108 | 109 | /// Method that can be used to retrieve some request's params for a certain tab. 110 | /// - Parameter json: the JSON to be decoded. 111 | /// - Returns: The params that would be used to make the request for the category of the tab. 112 | private static func getParams(json: JSON) -> String { 113 | return json["tabRenderer", "endpoint", "browseEndpoint", "params"].stringValue 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Sources/YouTubeKit/BaseStructs/YTPlaylist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YTPlaylist.swift 3 | // 4 | // Created by Antoine Bollengier (github.com/b5i) on 24.06.2023. 5 | // Copyright © 2023 - 2025 Antoine Bollengier. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Struct representing a playlist. 11 | public struct YTPlaylist: YTSearchResult, Sendable { 12 | public init(id: Int? = nil, playlistId: String, title: String? = nil, thumbnails: [YTThumbnail] = [], videoCount: String? = nil, channel: YTLittleChannelInfos? = nil, timePosted: String? = nil, frontVideos: [YTVideo] = [], privacy: YTPrivacy? = nil) { 13 | self.id = id 14 | self.playlistId = playlistId 15 | self.title = title 16 | self.thumbnails = thumbnails 17 | self.videoCount = videoCount 18 | self.channel = channel 19 | self.timePosted = timePosted 20 | self.frontVideos = frontVideos 21 | self.privacy = privacy 22 | } 23 | 24 | public static func == (lhs: YTPlaylist, rhs: YTPlaylist) -> Bool { 25 | return lhs.channel?.channelId == rhs.channel?.channelId && lhs.channel?.name == rhs.channel?.name && lhs.playlistId == rhs.playlistId && lhs.timePosted == rhs.timePosted && lhs.videoCount == rhs.videoCount && lhs.title == rhs.title && lhs.frontVideos == rhs.frontVideos 26 | } 27 | 28 | public static func canBeDecoded(json: JSON) -> Bool { 29 | return json["playlistId"].string != nil 30 | } 31 | 32 | public static func decodeJSON(json: JSON) -> YTPlaylist? { 33 | /// Check if the JSON can be decoded as a Playlist. 34 | guard let playlistId = json["playlistId"].string else { return nil } 35 | /// Inititalize a new ``YTSearchResultType/Playlist-swift.struct`` instance to put the informations in it. 36 | var playlist = YTPlaylist(playlistId: playlistId.hasPrefix("VL") ? playlistId : "VL" + playlistId) 37 | 38 | if let playlistTitle = json["title", "simpleText"].string { 39 | playlist.title = playlistTitle 40 | } else { 41 | let playlistTitle = json["title", "runs"].arrayValue.map({$0["text"].stringValue}).joined() 42 | playlist.title = playlistTitle 43 | } 44 | 45 | YTThumbnail.appendThumbnails(json: json["thumbnailRenderer", "playlistVideoThumbnailRenderer", "thumbnail"], thumbnailList: &playlist.thumbnails) 46 | 47 | playlist.videoCount = json["videoCountText", "runs"].arrayValue.map({$0["text"].stringValue}).joined() 48 | 49 | if let channelId = json["longBylineText", "runs", 0, "navigationEndpoint", "browseEndpoint", "browseId"].string { 50 | playlist.channel = YTLittleChannelInfos(channelId: channelId, name: json["longBylineText", "runs"].arrayValue.map({$0["text"].stringValue}).joined()) 51 | } 52 | 53 | playlist.timePosted = json["publishedTimeText", "simpleText"].string 54 | 55 | for frontVideoIndex in 0..<(json["videos"].array?.count ?? 0) { 56 | let video = json["videos", frontVideoIndex, "childVideoRenderer"] 57 | guard YTVideo.canBeDecoded(json: video), let castedVideo = YTVideo.decodeJSON(json: video) else { continue } 58 | playlist.frontVideos.append(castedVideo) 59 | } 60 | 61 | return playlist 62 | } 63 | 64 | public static func decodeLockupJSON(json: JSON) -> YTPlaylist? { 65 | guard let playlistId = json["contentId"].string, json["contentType"] == "LOCKUP_CONTENT_TYPE_PLAYLIST" else { return nil } 66 | 67 | var playlist = YTPlaylist(playlistId: playlistId.hasPrefix("VL") ? playlistId : "VL" + playlistId) 68 | 69 | playlist.title = json["metadata", "lockupMetadataViewModel", "title", "content"].string 70 | 71 | let channelElement1 = json["metadata", "lockupMetadataViewModel", "metadata", "contentMetadataViewModel", "metadataRows"] 72 | .arrayValue.compactMap { metadataPart in 73 | metadataPart["metadataParts"].array 74 | } 75 | 76 | let channelElement2 = channelElement1 77 | .first(where: { metadataPart in 78 | metadataPart.first(where: { 79 | $0["text", "commandRuns"] 80 | .arrayValue 81 | .first?["onTap", "innertubeCommand", "commandMetadata", "webCommandMetadata", "webPageType"] 82 | .string == "WEB_PAGE_TYPE_CHANNEL" 83 | }) != nil 84 | }) 85 | 86 | if let channelElement = channelElement2?.first(where: { 87 | $0["text", "commandRuns"] 88 | .arrayValue 89 | .first?["onTap", "innertubeCommand", "commandMetadata", "webCommandMetadata", "webPageType"] 90 | .string == "WEB_PAGE_TYPE_CHANNEL" 91 | })?["text"], 92 | let channelId = channelElement["commandRuns"] 93 | .array? 94 | .first?["onTap", "innertubeCommand", "browseEndpoint", "browseId"].string 95 | { 96 | playlist.channel = YTLittleChannelInfos(channelId: channelId, name: channelElement["content"].string) 97 | } 98 | 99 | YTThumbnail.appendThumbnails(json: json["contentImage", "collectionThumbnailViewModel", "primaryThumbnail", "thumbnailViewModel"], thumbnailList: &playlist.thumbnails) 100 | 101 | mainLoop: for thumbnailOverlay in json["contentImage", "collectionThumbnailViewModel", "primaryThumbnail", "thumbnailViewModel", "overlays"].arrayValue { 102 | for thumbnailBadge in thumbnailOverlay["thumbnailOverlayBadgeViewModel", "thumbnailBadges"].arrayValue { 103 | if thumbnailBadge["thumbnailBadgeViewModel", "icon", "sources", 0, "clientResource", "imageName"].string == "PLAYLISTS", let text = thumbnailBadge["thumbnailBadgeViewModel", "text"].string { 104 | playlist.videoCount = text 105 | break mainLoop 106 | } 107 | } 108 | } 109 | 110 | return playlist 111 | } 112 | 113 | public static let type: YTSearchResultType = .playlist 114 | 115 | public var id: Int? 116 | 117 | /// Playlist's identifier, can be used to get the informations about the channel. 118 | /// 119 | /// For example: 120 | /// ```swift 121 | /// let YTM = YouTubeModel() 122 | /// let playlistId: String = ... 123 | /// PlaylistInfosResponse.sendNonThrowingRequest(youtubeModel: YTM, data: [.query : playlistId], result: { result in 124 | /// print(result) 125 | /// }) 126 | /// ``` 127 | public var playlistId: String 128 | 129 | /// Title of the playlist. 130 | public var title: String? 131 | 132 | /// Array of thumbnails. 133 | /// 134 | /// Usually sorted by resolution, from low to high. 135 | public var thumbnails: [YTThumbnail] = [] 136 | 137 | /// A string representing the count of video in the playlist. 138 | public var videoCount: String? 139 | 140 | /// Channel informations. 141 | public var channel: YTLittleChannelInfos? = nil 142 | 143 | /// String representing the moment when the video was posted. 144 | /// 145 | /// Usually like `posted 3 months ago`. 146 | public var timePosted: String? 147 | 148 | /// An array of videos that are contained in the playlist, usually the first ones. 149 | public var frontVideos: [YTVideo] = [] 150 | 151 | public var privacy: YTPrivacy? 152 | 153 | ///Not necessary here because of prepareJSON() method 154 | /* 155 | enum CodingKeys: String, CodingKey { 156 | case playlistId 157 | case title 158 | case thumbnails 159 | case thumbnails 160 | case videoCount 161 | case channel 162 | case timePosted 163 | case frontVideos 164 | } 165 | */ 166 | } 167 | --------------------------------------------------------------------------------