├── .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 |
--------------------------------------------------------------------------------