diff --git a/Sources/YouTubeKit/BaseProtocols/Continuation/ContinuableResponse+fetchContinuation.swift b/Sources/YouTubeKit/BaseProtocols/Continuation/ContinuableResponse+fetchContinuation.swift index fd03c8e..eca4c83 100644 --- a/Sources/YouTubeKit/BaseProtocols/Continuation/ContinuableResponse+fetchContinuation.swift +++ b/Sources/YouTubeKit/BaseProtocols/Continuation/ContinuableResponse+fetchContinuation.swift @@ -24,7 +24,7 @@ public extension ContinuableResponse { @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) func fetchContinuationThrowing(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async throws -> Continuation { return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in - fetchContinuation(youtubeModel: youtubeModel, useCookies: useCookies, result: { response in + self.fetchContinuation(youtubeModel: youtubeModel, useCookies: useCookies, result: { response in continuation.resume(with: response) }) }) diff --git a/Sources/YouTubeKit/BaseStructs/YTComment+actions.swift b/Sources/YouTubeKit/BaseStructs/YTComment+actions.swift new file mode 100644 index 0000000..043a754 --- /dev/null +++ b/Sources/YouTubeKit/BaseStructs/YTComment+actions.swift @@ -0,0 +1,193 @@ +// +// YTComment+actions.swift +// +// +// Created by Antoine Bollengier on 03.07.2024. +// Copyright © 2024 Antoine Bollengier (github.com/b5i). All rights reserved. +// + +public extension YTComment { + /// Do one of the ``YTComment/CommentAction`` (like, dislike, removeLike, removeDislike, delete). + func commentAction(youtubeModel: YouTubeModel, action: YTComment.CommentAction, result: @escaping @Sendable (Error?) -> Void) { + switch action { + case .like: + guard let param = self.actionsParams[.like] else { result("self.actionsParams[.like] is not present."); return } + LikeCommentResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.params: param], result: { res in + switch res { + case .success(_): + result(nil) + case .failure(let failure): + result(failure) + } + }) + case .dislike: + guard let param = self.actionsParams[.dislike] else { result("self.actionsParams[.dislike] is not present."); return } + DislikeCommentResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.params: param], result: { res in + switch res { + case .success(_): + result(nil) + case .failure(let failure): + result(failure) + } + }) + case .removeLike: + guard let param = self.actionsParams[.removeLike] else { result("self.actionsParams[.removeLike] is not present."); return } + RemoveLikeCommentResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.params: param], result: { res in + switch res { + case .success(_): + result(nil) + case .failure(let failure): + result(failure) + } + }) + case .removeDislike: + guard let param = self.actionsParams[.removeDislike] else { result("self.actionsParams[.removeDislike] is not present."); return } + RemoveDislikeCommentResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.params: param], result: { res in + switch res { + case .success(_): + result(nil) + case .failure(let failure): + result(failure) + } + }) + case .delete: + guard let param = self.actionsParams[.delete] else { result("self.actionsParams[.delete] is not present."); return } + DeleteCommentResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.params: param], result: { res in + switch res { + case .success(_): + result(nil) + case .failure(let failure): + result(failure) + } + }) + case .edit: + result("edit is not supported via commentAction(_:_:_:), please use editComment(_:_:).") + case .reply: + result("reply is not supported via commentAction(_:_:_:), please use replyToComment(_:_:).") + case .repliesContinuation: + result("repliesContinuation is not supported via commentAction(_:_:_:), please use fetchRepliesContinuation(_:_:).") + case .translate: + result("translate is not supported via commentAction(_:_:_:), please use translateText(_:_:).") + } + } + + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + func commentAction(youtubeModel: YouTubeModel, action: YTComment.CommentAction) async throws { + return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in + self.commentAction(youtubeModel: youtubeModel, action: action, result: { error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + }) + }) + } + + /// Edit the text of a comment (the cookies from the used ``YouTubeModel`` must be the owner of the comment). + func editComment(withNewText text: String, youtubeModel: YouTubeModel, result: @escaping @Sendable (Error?) -> Void) { + guard let param = self.actionsParams[.edit] else { result("self.actionsParams[.edit] is not present."); return } + if (self.replyLevel ?? 0) == 0 { + EditCommentResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.params: param, .text: text], result: { res in + switch res { + case .success(_): + result(nil) + case .failure(let failure): + result(failure) + } + }) + } else { + EditReplyCommandResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.params: param, .text: text], result: { res in + switch res { + case .success(_): + result(nil) + case .failure(let failure): + result(failure) + } + }) + } + } + + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + /// Edit the text of a comment (the cookies from the used ``YouTubeModel`` must be the owner of the comment). + func editComment(withNewText text: String, youtubeModel: YouTubeModel) async throws { + return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in + self.editComment(withNewText: text, youtubeModel: youtubeModel, result: { error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + }) + }) + } + + /// Reply to a comment. + func replyToComment(youtubeModel: YouTubeModel, text: String, result: @escaping @Sendable (Result) -> Void) { + guard let replyToken = self.actionsParams[.reply] else { result(.failure("self.actionsParams[.reply] is not present.")); return } + ReplyCommentResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.params: replyToken, .text: text], result: { res in + switch res { + case .success(let success): + result(.success(success)) + case .failure(let failure): + result(.failure(failure)) + } + }) + } + + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + /// Reply to a comment. + func replyToComment(youtubeModel: YouTubeModel, text: String) async throws -> ReplyCommentResponse { + return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in + self.replyToComment(youtubeModel: youtubeModel, text: text, result: { response in + continuation.resume(with: response) + }) + }) + } + + /// Get the replies of a comment, can also be used to get the continuation of the replies. + func fetchRepliesContinuation(youtubeModel: YouTubeModel, useCookies: Bool? = nil, result: @escaping @Sendable (Result) -> Void) { + guard let repliesContinuationToken = self.actionsParams[.repliesContinuation] else { result(.failure("self.actionsParams[.repliesContinuation] is not present.")); return } + VideoCommentsResponse.Continuation.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.continuation: repliesContinuationToken], useCookies: useCookies, result: { res in + switch res { + case .success(let success): + result(.success(success)) + case .failure(let failure): + result(.failure(failure)) + } + }) + } + + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + /// Get the replies of a comment, can also be used to get the continuation of the replies. + func fetchRepliesContinuation(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async throws -> VideoCommentsResponse.Continuation { + return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in + self.fetchRepliesContinuation(youtubeModel: youtubeModel, useCookies: useCookies, result: { response in + continuation.resume(with: response) + }) + }) + } + + /// Translate the text of a comment, is available if YouTube thinks that the cookies' user or the language of the user agent (?) might need a translation. + func translateText(youtubeModel: YouTubeModel, useCookies: Bool? = nil, result: @escaping @Sendable (Result) -> Void) { + guard let translationToken = self.actionsParams[.translate] else { result(.failure("self.actionsParams[.translate] is not present.")); return } + CommentTranslationResponse.sendNonThrowingRequest(youtubeModel: youtubeModel, data: [.params: translationToken], useCookies: useCookies, result: { res in + switch res { + case .success(let success): + result(.success(success)) + case .failure(let failure): + result(.failure(failure)) + } + }) + } + + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + /// Translate the text of a comment, is available if YouTube thinks that the cookies' user or the language of the user agent (?) might need a translation. + func translateText(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async throws -> CommentTranslationResponse { + return try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in + self.translateText(youtubeModel: youtubeModel, useCookies: useCookies, result: { response in + continuation.resume(with: response) + }) + }) + } +} diff --git a/Sources/YouTubeKit/BaseStructs/YTPlaylist+fetchVideos.swift b/Sources/YouTubeKit/BaseStructs/YTPlaylist+fetchVideos.swift index b0affba..bb7366b 100644 --- a/Sources/YouTubeKit/BaseStructs/YTPlaylist+fetchVideos.swift +++ b/Sources/YouTubeKit/BaseStructs/YTPlaylist+fetchVideos.swift @@ -24,8 +24,19 @@ public extension YTPlaylist { }) } + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + /// Fetch the ``PlaylistInfosResponse`` related to the playlist. + func fetchVideos(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async -> Result { + do { + return try await .success(self.fetchVideosThrowing(youtubeModel: youtubeModel, useCookies: useCookies)) + } catch { + return .failure(error) + } + } + /// Fetch the ``PlaylistInfosResponse`` related to the playlist. + @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 func fetchVideos(youtubeModel: YouTubeModel, useCookies: Bool? = nil, result: @escaping @Sendable (PlaylistInfosResponse?, Error?) -> Void) { self.fetchVideos(youtubeModel: youtubeModel, useCookies: useCookies, result: { returning in switch returning { @@ -39,6 +50,7 @@ public extension YTPlaylist { @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) /// Fetch the ``PlaylistInfosResponse`` related to the playlist. + @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 func fetchVideos(youtubeModel: YouTubeModel, useCookies: Bool? = nil) async -> (PlaylistInfosResponse?, Error?) { do { return await (try self.fetchVideosThrowing(youtubeModel: youtubeModel, useCookies: useCookies), nil) diff --git a/Sources/YouTubeKit/ErrorHandling/ParameterValidator+commonValidators.swift b/Sources/YouTubeKit/ErrorHandling/ParameterValidator+commonValidators.swift index afcfcc9..ad104ad 100644 --- a/Sources/YouTubeKit/ErrorHandling/ParameterValidator+commonValidators.swift +++ b/Sources/YouTubeKit/ErrorHandling/ParameterValidator+commonValidators.swift @@ -29,6 +29,14 @@ public extension ParameterValidator { } }) + static let textSanitizerValidator = ParameterValidator(validator: { parameter in + if let parameter = parameter { + return .success(parameter.replacingOccurrences(of: "\\", with: #"\\"#).replacingOccurrences(of: "\"", with: #"\""#)) + } else { + return .failure(.init(reason: "Parameter is nil.", validatorFailedNameDescriptor: "ExistenceValidator.")) + } + }) + static let channelIdValidator = ParameterValidator(validator: { channelId in let validatorName = "ChannelId validator" guard let channelId = channelId else { return .failure(.init(reason: "Nil value.", validatorFailedNameDescriptor: validatorName))} @@ -77,7 +85,7 @@ public extension ParameterValidator { } }) - static let urlValidator = ParameterValidator(needExistence: true, validator: { url in + static let urlValidator = ParameterValidator(validator: { url in let validatorName = "URL validator" guard let url = url else { return .failure(.init(reason: "Nil value.", validatorFailedNameDescriptor: validatorName)) } // should never be called because of the needExistence diff --git a/Sources/YouTubeKit/HeaderTypes+RawRepresentable.swift b/Sources/YouTubeKit/HeaderTypes+RawRepresentable.swift index 5cddf07..35c8e44 100644 --- a/Sources/YouTubeKit/HeaderTypes+RawRepresentable.swift +++ b/Sources/YouTubeKit/HeaderTypes+RawRepresentable.swift @@ -103,10 +103,16 @@ extension HeaderTypes: RawRepresentable { return "removeLikeCommentHeaders" case .removeDislikeCommentHeaders: return "removeDislikeCommentHeaders" - case .updateCommentHeaders: - return "updateCommentHeaders" + case .editCommentHeaders: + return "editCommentHeaders" case .replyCommentHeaders: return "replyCommentHeaders" + case .removeCommentHeaders: + return "removeCommentHeaders" + case .translateCommentHeaders: + return "removeCommentHeaders" + case .editReplyCommentHeaders: + return "editReplyCommentHeaders" case .customHeaders(let stringIdentifier): return stringIdentifier } diff --git a/Sources/YouTubeKit/HeaderTypes.swift b/Sources/YouTubeKit/HeaderTypes.swift index e494333..af9cd0f 100644 --- a/Sources/YouTubeKit/HeaderTypes.swift +++ b/Sources/YouTubeKit/HeaderTypes.swift @@ -167,24 +167,58 @@ public enum HeaderTypes: Codable, Sendable { /// - Parameter params: The operation param from ``TrendingVideosResponse/requestParams`` (optional). case trendingVideosHeaders + /// Get a video's comments. + /// - Parameter continuation: The continuation token from ``MoreVideoInfosResponse/commentsContinuationToken``. case videoCommentsHeaders + /// Get a video's comments' continuation. + /// - Parameter continuation: The continuation token from ``VideoCommentsResponse/continuationToken``. case videoCommentsContinuationHeaders + /// Create a comment. + /// - Parameter params: the params from ``VideoCommentsResponse/commentCreationToken`` + /// - Parameter text: the text of the new comment (no need to escape it). case createCommentHeaders + /// Like a comment. + /// - Parameter params: the params from ``YTComment/actionsParams``[.like], only present if ``YouTubeModel/cookies`` contains valid cookies. case likeCommentHeaders + /// Dislike a comment. + /// - Parameter params: the params from ``YTComment/actionsParams``[.dislike], only present if ``YouTubeModel/cookies`` contains valid cookies. case dislikeCommentHeaders + /// Remove the like from a comment. + /// - Parameter params: the params from ``YTComment/actionsParams``[.removeLike], only present if ``YouTubeModel/cookies`` contains valid cookies. case removeLikeCommentHeaders + /// Remove the dislike from a comment. + /// - Parameter params: the params from ``YTComment/actionsParams``[.removeDislike], only present if ``YouTubeModel/cookies`` contains valid cookies. case removeDislikeCommentHeaders - case updateCommentHeaders + /// Edit the content of a comment. + /// - Parameter params: the params from ``YTComment/actionsParams``[.edit], only present if ``YouTubeModel/cookies`` contains valid cookies. + /// - Parameter text: the new text of the comment (no need to escape it). + case editCommentHeaders + /// Edit the content of a comment. + /// - Parameter params: the params from ``YTComment/actionsParams``[.edit], only present if ``YouTubeModel/cookies`` contains valid cookies. + /// - Parameter text: the new text of the comment (no need to escape it). case replyCommentHeaders + /// Edit the text of a reply to a comment. + /// - Parameter params: the params from ``YTComment/actionsParams``[.edit] from the reply, only present if ``YouTubeModel/cookies`` contains valid cookies. + /// - Parameter text: the new text of the comment (no need to escape it). + case editReplyCommentHeaders + + /// Delete a comment. + /// - Parameter params: the params from ``YTComment/actionsParams``[.delete], only present if ``YouTubeModel/cookies`` contains valid cookies. + case removeCommentHeaders + + /// Translate the text of a comment. + /// - Parameter params: the params from ``YTComment/actionsParams``[.translate], only present if ``YouTubeModel/cookies`` contains valid cookies. + case translateCommentHeaders + /// For custom headers case customHeaders(String) } diff --git a/Sources/YouTubeKit/HeadersList.swift b/Sources/YouTubeKit/HeadersList.swift index d83569b..3ca22f8 100644 --- a/Sources/YouTubeKit/HeadersList.swift +++ b/Sources/YouTubeKit/HeadersList.swift @@ -155,12 +155,13 @@ public struct HeadersList: Codable { case continuation case params case visitorData + case text /// Those are used during the modification of a playlist case movingVideoId case videoBeforeId case playlistEditToken - + /// Used to completly replace the URL of the request, including the parameters that could potentially case customURL } diff --git a/Sources/YouTubeKit/YouTubeModel.swift b/Sources/YouTubeKit/YouTubeModel.swift index 53f44b7..18ed68a 100644 --- a/Sources/YouTubeKit/YouTubeModel.swift +++ b/Sources/YouTubeKit/YouTubeModel.swift @@ -269,16 +269,24 @@ public class YouTubeModel { return getUsersSubscriptionsFeedHeaders() case .usersSubscriptionsFeedContinuationHeaders: return getUsersSubscriptionsFeedContinuationHeaders() - case .videoCommentsHeaders: + case .videoCommentsHeaders, .videoCommentsContinuationHeaders: return getVideoCommentsHeaders() + case .removeLikeCommentHeaders, .removeDislikeCommentHeaders, .dislikeCommentHeaders, .likeCommentHeaders, .removeCommentHeaders, .translateCommentHeaders: + return likeActionsCommentHeaders(actionType: type) + case .replyCommentHeaders: + return replyCommentHeaders() + case .editCommentHeaders: + return editCommentHeaders() + case .editReplyCommentHeaders: + return editReplyCommentHeaders() + case .createCommentHeaders: + return createCommentHeaders() case .customHeaders(let stringIdentifier): if let headersGenerator = customHeadersFunctions[stringIdentifier] { return headersGenerator() } else { return HeadersList.getEmtpy() } - default: - return getVideoCommentsHeaders() } } @@ -1502,13 +1510,12 @@ public class YouTubeModel { } } - - func commentActionHeaders() -> HeadersList { - if let headers = self.customHeaders[.likeVideoHeaders] { + func getVideoCommentsHeaders() -> HeadersList { + if let headers = self.customHeaders[.videoCommentsHeaders] { return headers } else { return HeadersList( - url: URL(string: "https://www.youtube.com/youtubei/v1/browse/edit_playlist")!, + url: URL(string: "https://www.youtube.com/youtubei/v1/next")!, method: .POST, headers: [ .init(name: "Accept", content: "*/*"), @@ -1522,14 +1529,10 @@ public class YouTubeModel { .init(name: "X-Origin", content: "https://www.youtube.com") ], addQueryAfterParts: [ - .init(index: 0, encode: false, content: .movingVideoId), - .init(index: 1, encode: false, content: .playlistEditToken), - .init(index: 2, encode: false, content: .browseId) + .init(index: 0, encode: false, content: .continuation) ], httpBody: [ - "{\"context\":{\"client\":{\"hl\":\"\(self.selectedLocaleLanguageCode)\",\"gl\":\"\(self.selectedLocaleCountryCode.uppercased())\",\"deviceMake\":\"Apple\",\"userAgent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15,gzip(gfe)\",\"clientName\":\"WEB\",\"clientVersion\":\"2.20221220.09.00\",\"osName\":\"Macintosh\",\"osVersion\":\"10_15_7\",\"platform\":\"DESKTOP\",\"clientFormFactor\":\"UNKNOWN_FORM_FACTOR\",\"userInterfaceTheme\":\"USER_INTERFACE_THEME_DARK\",\"timeZone\":\"Europe/Zurich\",\"browserName\":\"Safari\",\"browserVersion\":\"16.2\",\"acceptHeader\":\"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\"utcOffsetMinutes\":60,\"mainAppWebInfo\":{\"webDisplayMode\":\"WEB_DISPLAY_MODE_BROWSER\",\"isWebNativeShareAvailable\":true}},\"user\":{\"lockedSafetyMode\":false},\"request\":{\"useSsl\":true,\"internalExperimentFlags\":[],\"consistencyTokenJars\":[]}},\"actions\":[{\"setVideoId\":\"", - "\",\"action\":\"ACTION_REMOVE_VIDEO\"}],\"params\":\"", - "\",\"playlistId\":\"", + "{\"context\":{\"client\":{\"hl\":\"\(self.selectedLocaleLanguageCode)\",\"gl\":\"\(self.selectedLocaleCountryCode.uppercased())\",\"deviceMake\":\"Apple\",\"userAgent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15,gzip(gfe)\",\"clientName\":\"WEB\",\"clientVersion\":\"2.20240702.01.00\",\"osName\":\"Macintosh\",\"osVersion\":\"10_15_7\",\"platform\":\"DESKTOP\",\"clientFormFactor\":\"UNKNOWN_FORM_FACTOR\",\"userInterfaceTheme\":\"USER_INTERFACE_THEME_DARK\",\"timeZone\":\"Europe/Zurich\",\"browserName\":\"Safari\",\"browserVersion\":\"16.2\",\"acceptHeader\":\"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\"utcOffsetMinutes\":60,\"mainAppWebInfo\":{\"webDisplayMode\":\"WEB_DISPLAY_MODE_BROWSER\",\"isWebNativeShareAvailable\":true}},\"user\":{\"lockedSafetyMode\":false},\"request\":{\"useSsl\":true,\"internalExperimentFlags\":[],\"consistencyTokenJars\":[]}},\"continuation\":\"", "\"}" ], parameters: [ @@ -1539,12 +1542,12 @@ public class YouTubeModel { } } - func getVideoCommentsHeaders() -> HeadersList { - if let headers = self.customHeaders[.videoCommentsHeaders] { + func likeActionsCommentHeaders(actionType: HeaderTypes) -> HeadersList { + if let headers = self.customHeaders[actionType] { return headers } else { return HeadersList( - url: URL(string: "https://www.youtube.com/youtubei/v1/next")!, + url: URL(string: "https://www.youtube.com/youtubei/v1/comment/perform_comment_action")!, method: .POST, headers: [ .init(name: "Accept", content: "*/*"), @@ -1561,7 +1564,143 @@ public class YouTubeModel { .init(index: 0, encode: false, content: .params) ], httpBody: [ - "{\"context\":{\"client\":{\"hl\":\"\(self.selectedLocaleLanguageCode)\",\"gl\":\"\(self.selectedLocaleCountryCode.uppercased())\",\"deviceMake\":\"Apple\",\"userAgent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15,gzip(gfe)\",\"clientName\":\"WEB\",\"clientVersion\":\"2.20240702.01.00\",\"osName\":\"Macintosh\",\"osVersion\":\"10_15_7\",\"platform\":\"DESKTOP\",\"clientFormFactor\":\"UNKNOWN_FORM_FACTOR\",\"userInterfaceTheme\":\"USER_INTERFACE_THEME_DARK\",\"timeZone\":\"Europe/Zurich\",\"browserName\":\"Safari\",\"browserVersion\":\"16.2\",\"acceptHeader\":\"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\"utcOffsetMinutes\":60,\"mainAppWebInfo\":{\"webDisplayMode\":\"WEB_DISPLAY_MODE_BROWSER\",\"isWebNativeShareAvailable\":true}},\"user\":{\"lockedSafetyMode\":false},\"request\":{\"useSsl\":true,\"internalExperimentFlags\":[],\"consistencyTokenJars\":[]}},\"continuation\":\"", + "{\"context\":{\"client\":{\"hl\":\"\(self.selectedLocaleLanguageCode)\",\"gl\":\"\(self.selectedLocaleCountryCode.uppercased())\",\"deviceMake\":\"Apple\",\"userAgent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15,gzip(gfe)\",\"clientName\":\"WEB\",\"clientVersion\":\"2.20240702.01.00\",\"osName\":\"Macintosh\",\"osVersion\":\"10_15_7\",\"platform\":\"DESKTOP\",\"clientFormFactor\":\"UNKNOWN_FORM_FACTOR\",\"userInterfaceTheme\":\"USER_INTERFACE_THEME_DARK\",\"timeZone\":\"Europe/Zurich\",\"browserName\":\"Safari\",\"browserVersion\":\"16.2\",\"acceptHeader\":\"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\"utcOffsetMinutes\":60,\"mainAppWebInfo\":{\"webDisplayMode\":\"WEB_DISPLAY_MODE_BROWSER\",\"isWebNativeShareAvailable\":true}},\"user\":{\"lockedSafetyMode\":false},\"request\":{\"useSsl\":true,\"internalExperimentFlags\":[],\"consistencyTokenJars\":[]}},\"actions\":[\"", + "\"]}" + ], + parameters: [ + .init(name: "prettyPrint", content: "false") + ] + ) + } + } + + func replyCommentHeaders() -> HeadersList { + if let headers = self.customHeaders[.replyCommentHeaders] { + return headers + } else { + return HeadersList( + url: URL(string: "https://www.youtube.com/youtubei/v1/comment/create_comment_reply")!, + method: .POST, + headers: [ + .init(name: "Accept", content: "*/*"), + .init(name: "Accept-Encoding", content: "gzip, deflate, br"), + .init(name: "Host", content: "www.youtube.com"), + .init(name: "User-Agent", content: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15"), + .init(name: "Accept-Language", content: "\(self.selectedLocale);q=0.9"), + .init(name: "Origin", content: "https://www.youtube.com/"), + .init(name: "Referer", content: "https://www.youtube.com/"), + .init(name: "Content-Type", content: "application/json"), + .init(name: "X-Origin", content: "https://www.youtube.com") + ], + addQueryAfterParts: [ + .init(index: 0, encode: false, content: .params), + .init(index: 1, encode: false, content: .text) + ], + httpBody: [ + "{\"context\":{\"client\":{\"hl\":\"\(self.selectedLocaleLanguageCode)\",\"gl\":\"\(self.selectedLocaleCountryCode.uppercased())\",\"deviceMake\":\"Apple\",\"userAgent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15,gzip(gfe)\",\"clientName\":\"WEB\",\"clientVersion\":\"2.20240702.01.00\",\"osName\":\"Macintosh\",\"osVersion\":\"10_15_7\",\"platform\":\"DESKTOP\",\"clientFormFactor\":\"UNKNOWN_FORM_FACTOR\",\"userInterfaceTheme\":\"USER_INTERFACE_THEME_DARK\",\"timeZone\":\"Europe/Zurich\",\"browserName\":\"Safari\",\"browserVersion\":\"16.2\",\"acceptHeader\":\"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\"utcOffsetMinutes\":60,\"mainAppWebInfo\":{\"webDisplayMode\":\"WEB_DISPLAY_MODE_BROWSER\",\"isWebNativeShareAvailable\":true}},\"user\":{\"lockedSafetyMode\":false},\"request\":{\"useSsl\":true,\"internalExperimentFlags\":[],\"consistencyTokenJars\":[]}},\"createReplyParams\":\"", + "\", \"commentText\":\"", + "\"}" + ], + parameters: [ + .init(name: "prettyPrint", content: "false") + ] + ) + } + } + + func editCommentHeaders() -> HeadersList { + if let headers = self.customHeaders[.editCommentHeaders] { + return headers + } else { + return HeadersList( + url: URL(string: "https://www.youtube.com/youtubei/v1/comment/update_comment")!, + method: .POST, + headers: [ + .init(name: "Accept", content: "*/*"), + .init(name: "Accept-Encoding", content: "gzip, deflate, br"), + .init(name: "Host", content: "www.youtube.com"), + .init(name: "User-Agent", content: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15"), + .init(name: "Accept-Language", content: "\(self.selectedLocale);q=0.9"), + .init(name: "Origin", content: "https://www.youtube.com/"), + .init(name: "Referer", content: "https://www.youtube.com/"), + .init(name: "Content-Type", content: "application/json"), + .init(name: "X-Origin", content: "https://www.youtube.com") + ], + addQueryAfterParts: [ + .init(index: 0, encode: false, content: .text), + .init(index: 1, encode: false, content: .params) + ], + httpBody: [ + "{\"context\":{\"client\":{\"hl\":\"\(self.selectedLocaleLanguageCode)\",\"gl\":\"\(self.selectedLocaleCountryCode.uppercased())\",\"deviceMake\":\"Apple\",\"userAgent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15,gzip(gfe)\",\"clientName\":\"WEB\",\"clientVersion\":\"2.20240702.01.00\",\"osName\":\"Macintosh\",\"osVersion\":\"10_15_7\",\"platform\":\"DESKTOP\",\"clientFormFactor\":\"UNKNOWN_FORM_FACTOR\",\"userInterfaceTheme\":\"USER_INTERFACE_THEME_DARK\",\"timeZone\":\"Europe/Zurich\",\"browserName\":\"Safari\",\"browserVersion\":\"16.2\",\"acceptHeader\":\"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\"utcOffsetMinutes\":60,\"mainAppWebInfo\":{\"webDisplayMode\":\"WEB_DISPLAY_MODE_BROWSER\",\"isWebNativeShareAvailable\":true}},\"user\":{\"lockedSafetyMode\":false},\"request\":{\"useSsl\":true,\"internalExperimentFlags\":[],\"consistencyTokenJars\":[]}},\"commentText\":\"", + "\", \"updateCommentParams\":\"", + "\"}" + ], + parameters: [ + .init(name: "prettyPrint", content: "false") + ] + ) + } + } + + func editReplyCommentHeaders() -> HeadersList { + if let headers = self.customHeaders[.editCommentHeaders] { + return headers + } else { + return HeadersList( + url: URL(string: "https://www.youtube.com/youtubei/v1/comment/update_comment_reply")!, + method: .POST, + headers: [ + .init(name: "Accept", content: "*/*"), + .init(name: "Accept-Encoding", content: "gzip, deflate, br"), + .init(name: "Host", content: "www.youtube.com"), + .init(name: "User-Agent", content: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15"), + .init(name: "Accept-Language", content: "\(self.selectedLocale);q=0.9"), + .init(name: "Origin", content: "https://www.youtube.com/"), + .init(name: "Referer", content: "https://www.youtube.com/"), + .init(name: "Content-Type", content: "application/json"), + .init(name: "X-Origin", content: "https://www.youtube.com") + ], + addQueryAfterParts: [ + .init(index: 0, encode: false, content: .text), + .init(index: 1, encode: false, content: .params) + ], + httpBody: [ + "{\"context\":{\"client\":{\"hl\":\"\(self.selectedLocaleLanguageCode)\",\"gl\":\"\(self.selectedLocaleCountryCode.uppercased())\",\"deviceMake\":\"Apple\",\"userAgent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15,gzip(gfe)\",\"clientName\":\"WEB\",\"clientVersion\":\"2.20240702.01.00\",\"osName\":\"Macintosh\",\"osVersion\":\"10_15_7\",\"platform\":\"DESKTOP\",\"clientFormFactor\":\"UNKNOWN_FORM_FACTOR\",\"userInterfaceTheme\":\"USER_INTERFACE_THEME_DARK\",\"timeZone\":\"Europe/Zurich\",\"browserName\":\"Safari\",\"browserVersion\":\"16.2\",\"acceptHeader\":\"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\"utcOffsetMinutes\":60,\"mainAppWebInfo\":{\"webDisplayMode\":\"WEB_DISPLAY_MODE_BROWSER\",\"isWebNativeShareAvailable\":true}},\"user\":{\"lockedSafetyMode\":false},\"request\":{\"useSsl\":true,\"internalExperimentFlags\":[],\"consistencyTokenJars\":[]}},\"replyText\":\"", + "\", \"updateReplyParams\":\"", + "\"}" + ], + parameters: [ + .init(name: "prettyPrint", content: "false") + ] + ) + } + } + + func createCommentHeaders() -> HeadersList { + if let headers = self.customHeaders[.createCommentHeaders] { + return headers + } else { + return HeadersList( + url: URL(string: "https://www.youtube.com/youtubei/v1/comment/create_comment")!, + method: .POST, + headers: [ + .init(name: "Accept", content: "*/*"), + .init(name: "Accept-Encoding", content: "gzip, deflate, br"), + .init(name: "Host", content: "www.youtube.com"), + .init(name: "User-Agent", content: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15"), + .init(name: "Accept-Language", content: "\(self.selectedLocale);q=0.9"), + .init(name: "Origin", content: "https://www.youtube.com/"), + .init(name: "Referer", content: "https://www.youtube.com/"), + .init(name: "Content-Type", content: "application/json"), + .init(name: "X-Origin", content: "https://www.youtube.com") + ], + addQueryAfterParts: [ + .init(index: 0, encode: false, content: .params), + .init(index: 1, encode: false, content: .text) + ], + httpBody: [ + "{\"context\":{\"client\":{\"hl\":\"\(self.selectedLocaleLanguageCode)\",\"gl\":\"\(self.selectedLocaleCountryCode.uppercased())\",\"deviceMake\":\"Apple\",\"userAgent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15,gzip(gfe)\",\"clientName\":\"WEB\",\"clientVersion\":\"2.20240702.01.00\",\"osName\":\"Macintosh\",\"osVersion\":\"10_15_7\",\"platform\":\"DESKTOP\",\"clientFormFactor\":\"UNKNOWN_FORM_FACTOR\",\"userInterfaceTheme\":\"USER_INTERFACE_THEME_DARK\",\"timeZone\":\"Europe/Zurich\",\"browserName\":\"Safari\",\"browserVersion\":\"16.2\",\"acceptHeader\":\"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\"utcOffsetMinutes\":60,\"mainAppWebInfo\":{\"webDisplayMode\":\"WEB_DISPLAY_MODE_BROWSER\",\"isWebNativeShareAvailable\":true}},\"user\":{\"lockedSafetyMode\":false},\"request\":{\"useSsl\":true,\"internalExperimentFlags\":[],\"consistencyTokenJars\":[]}},\"createCommentParams\":\"", + "\", \"commentText\":\"", "\"}" ], parameters: [ diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/ChannelsActions/SubscribeChannelResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/ChannelsActions/SubscribeChannelResponse.swift index daeb953..6bf2e23 100644 --- a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/ChannelsActions/SubscribeChannelResponse.swift +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/ChannelsActions/SubscribeChannelResponse.swift @@ -8,7 +8,7 @@ import Foundation -public struct SubscribeChannelResponse: AuthenticatedResponse { +public struct SubscribeChannelResponse: SimpleActionAuthenticatedResponse { public static let headersType: HeaderTypes = .subscribeToChannelHeaders public static let parametersValidationList: ValidationList = [.browseId: .channelIdValidator] diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/ChannelsActions/UnsubscribeChannelResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/ChannelsActions/UnsubscribeChannelResponse.swift index 21585ad..ed5750b 100644 --- a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/ChannelsActions/UnsubscribeChannelResponse.swift +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/ChannelsActions/UnsubscribeChannelResponse.swift @@ -8,7 +8,7 @@ import Foundation -public struct UnsubscribeChannelResponse: AuthenticatedResponse { +public struct UnsubscribeChannelResponse: SimpleActionAuthenticatedResponse { public static let headersType: HeaderTypes = .unsubscribeFromChannelHeaders public static let parametersValidationList: ValidationList = [.browseId: .channelIdValidator] diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/CreateCommentResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/CreateCommentResponse.swift new file mode 100644 index 0000000..388a132 --- /dev/null +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/CreateCommentResponse.swift @@ -0,0 +1,46 @@ +// +// CreateCommentResponse.swift +// +// +// Created by Antoine Bollengier on 03.07.2024. +// Copyright © 2024 Antoine Bollengier (github.com/b5i). All rights reserved. +// + +/// Response to create a comment on a video. +public struct CreateCommentResponse: SimpleActionAuthenticatedResponse { + public static let headersType: HeaderTypes = .createCommentHeaders + + public static let parametersValidationList: ValidationList = [.params: .existenceValidator, .text: .textSanitizerValidator] + + public var isDisconnected: Bool = true + + public var success: Bool = false + + public var newComment: YTComment? + + public static func decodeJSON(json: JSON) throws -> Self { + var toReturn = Self() + + guard !(json["responseContext"]["mainAppWebResponseContext"]["loggedOut"].bool ?? true) else { return toReturn } + + toReturn.isDisconnected = false + + toReturn.success = json["actionResult"]["status"].string == "STATUS_SUCCEEDED" + + var modifiedJSONForVideoCommentsResponse = JSON() + + modifiedJSONForVideoCommentsResponse["responseContext"] = json["responseContext"] + + modifiedJSONForVideoCommentsResponse["frameworkUpdates"] = json["frameworkUpdates"] + + 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 { + throw ResponseExtractionError(reponseType: Self.self, stepDescription: "Couldn't extract the creation tokens.") + } + + modifiedJSONForVideoCommentsResponse["onResponseReceivedEndpoints"] = JSON(parseJSON: "[{\"reloadContinuationItemsCommand\": {\"continuationItems\": [\(createCommentOrReplyActionJSON)]}}]") + + toReturn.newComment = try VideoCommentsResponse.decodeJSON(json: modifiedJSONForVideoCommentsResponse).results.first + + return toReturn + } +} diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/DeleteCommentResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/DeleteCommentResponse.swift new file mode 100644 index 0000000..2a3771e --- /dev/null +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/DeleteCommentResponse.swift @@ -0,0 +1,30 @@ +// +// DeleteCommentResponse.swift +// +// +// Created by Antoine Bollengier on 03.07.2024. +// Copyright © 2024 Antoine Bollengier (github.com/b5i). All rights reserved. +// + +/// Response to delete a comment on from video. +public struct DeleteCommentResponse: SimpleActionAuthenticatedResponse { + public static let headersType: HeaderTypes = .removeCommentHeaders + + public static let parametersValidationList: ValidationList = [.params: .existenceValidator] + + public var isDisconnected: Bool = true + + public var success: Bool = false + + public static func decodeJSON(json: JSON) -> Self { + var toReturn = Self() + + guard !(json["responseContext"]["mainAppWebResponseContext"]["loggedOut"].bool ?? true) else { return toReturn } + + toReturn.isDisconnected = false + + toReturn.success = json["actions"].arrayValue.first(where: {$0["removeCommentAction"]["actionResult"]["status"].string == "STATUS_SUCCEEDED" && $0["removeCommentAction"]["actionResult"]["feedback"].string == "FEEDBACK_REMOVE"}) != nil + + return toReturn + } +} diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/DislikeCommentResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/DislikeCommentResponse.swift new file mode 100644 index 0000000..53d52a5 --- /dev/null +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/DislikeCommentResponse.swift @@ -0,0 +1,30 @@ +// +// DislikeCommentResponse.swift +// +// +// Created by Antoine Bollengier on 03.07.2024. +// Copyright © 2024 Antoine Bollengier (github.com/b5i). All rights reserved. +// + +/// Response to dislike a comment on a video. +public struct DislikeCommentResponse: SimpleActionAuthenticatedResponse { + public static let headersType: HeaderTypes = .dislikeVideoHeaders + + public static let parametersValidationList: ValidationList = [.params: .existenceValidator] + + public var isDisconnected: Bool = true + + public var success: Bool = false + + public static func decodeJSON(json: JSON) -> Self { + var toReturn = Self() + + guard !(json["responseContext"]["mainAppWebResponseContext"]["loggedOut"].bool ?? true) else { return toReturn } + + toReturn.isDisconnected = false + + toReturn.success = json["actionResults"].arrayValue.first(where: {$0["status"].string == "STATUS_SUCCEEDED" && $0["feedback"].string == "STATUS_SUCCEEDED"}) != nil + + return toReturn + } +} diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/EditCommentResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/EditCommentResponse.swift new file mode 100644 index 0000000..1e50333 --- /dev/null +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/EditCommentResponse.swift @@ -0,0 +1,30 @@ +// +// EditCommentResponse.swift +// +// +// Created by Antoine Bollengier on 03.07.2024. +// Copyright © 2024 Antoine Bollengier (github.com/b5i). All rights reserved. +// + +/// Response to edit a comment on a video. +public struct EditCommentResponse: SimpleActionAuthenticatedResponse { + public static let headersType: HeaderTypes = .editCommentHeaders + + public static let parametersValidationList: ValidationList = [.text: .textSanitizerValidator, .params: .existenceValidator] + + public var isDisconnected: Bool = true + + public var success: Bool = false + + public static func decodeJSON(json: JSON) -> Self { + var toReturn = Self() + + guard !(json["responseContext"]["mainAppWebResponseContext"]["loggedOut"].bool ?? true) else { return toReturn } + + toReturn.isDisconnected = false + + toReturn.success = json["actions"].arrayValue.first(where: {$0["updateCommentAction"]["actionResult"]["status"].string == "STATUS_SUCCEEDED"}) != nil + + return toReturn + } +} diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/EditReplyCommandResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/EditReplyCommandResponse.swift new file mode 100644 index 0000000..31c53f8 --- /dev/null +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/EditReplyCommandResponse.swift @@ -0,0 +1,30 @@ +// +// EditReplyCommandResponse.swift +// +// +// Created by Antoine Bollengier on 03.07.2024. +// Copyright © 2024 Antoine Bollengier (github.com/b5i). All rights reserved. +// + +/// Response to edit a reply on a comment of a video. +public struct EditReplyCommandResponse: SimpleActionAuthenticatedResponse { + public static let headersType: HeaderTypes = .editReplyCommentHeaders + + public static let parametersValidationList: ValidationList = [.text: .textSanitizerValidator, .params: .existenceValidator] + + public var isDisconnected: Bool = true + + public var success: Bool = false + + public static func decodeJSON(json: JSON) -> Self { + var toReturn = Self() + + guard !(json["responseContext"]["mainAppWebResponseContext"]["loggedOut"].bool ?? true) else { return toReturn } + + toReturn.isDisconnected = false + + toReturn.success = json["actions"].arrayValue.first(where: {$0["updateCommentReplyAction"]["actionResult"]["status"].string == "STATUS_SUCCEEDED"}) != nil + + return toReturn + } +} diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/LikeCommentResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/LikeCommentResponse.swift new file mode 100644 index 0000000..3e56826 --- /dev/null +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/LikeCommentResponse.swift @@ -0,0 +1,30 @@ +// +// LikeCommentResponse.swift +// +// +// Created by Antoine Bollengier on 03.07.2024. +// Copyright © 2024 Antoine Bollengier (github.com/b5i). All rights reserved. +// + +/// Response to like a comment of a video. +public struct LikeCommentResponse: SimpleActionAuthenticatedResponse { + public static let headersType: HeaderTypes = .likeCommentHeaders + + public static let parametersValidationList: ValidationList = [.params: .existenceValidator] + + public var isDisconnected: Bool = true + + public var success: Bool = false + + public static func decodeJSON(json: JSON) -> Self { + var toReturn = Self() + + guard !(json["responseContext"]["mainAppWebResponseContext"]["loggedOut"].bool ?? true) else { return toReturn } + + toReturn.isDisconnected = false + + toReturn.success = json["actionResults"].arrayValue.first(where: {$0["status"].string == "STATUS_SUCCEEDED" && $0["feedback"].string == "FEEDBACK_LIKE"}) != nil + + return toReturn + } +} diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/RemoveDislikeCommentResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/RemoveDislikeCommentResponse.swift new file mode 100644 index 0000000..4690610 --- /dev/null +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/RemoveDislikeCommentResponse.swift @@ -0,0 +1,30 @@ +// +// RemoveDislikeCommentResponse.swift +// +// +// Created by Antoine Bollengier on 03.07.2024. +// Copyright © 2024 Antoine Bollengier (github.com/b5i). All rights reserved. +// + +/// Response remove a dislike from a video. +public struct RemoveDislikeCommentResponse: SimpleActionAuthenticatedResponse { + public static let headersType: HeaderTypes = .removeDislikeCommentHeaders + + public static let parametersValidationList: ValidationList = [.params: .existenceValidator] + + public var isDisconnected: Bool = true + + public var success: Bool = false + + public static func decodeJSON(json: JSON) -> Self { + var toReturn = Self() + + guard !(json["responseContext"]["mainAppWebResponseContext"]["loggedOut"].bool ?? true) else { return toReturn } + + toReturn.isDisconnected = false + + toReturn.success = json["actionResults"].arrayValue.first(where: {$0["status"].string == "STATUS_SUCCEEDED"}) != nil + + return toReturn + } +} diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/RemoveLikeCommentResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/RemoveLikeCommentResponse.swift new file mode 100644 index 0000000..62301da --- /dev/null +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/RemoveLikeCommentResponse.swift @@ -0,0 +1,30 @@ +// +// RemoveLikeCommentResponse.swift +// +// +// Created by Antoine Bollengier on 03.07.2024. +// Copyright © 2024 Antoine Bollengier (github.com/b5i). All rights reserved. +// + +/// Response remove a like from a video. +public struct RemoveLikeCommentResponse: SimpleActionAuthenticatedResponse { + public static let headersType: HeaderTypes = .removeLikeCommentHeaders + + public static let parametersValidationList: ValidationList = [.params: .existenceValidator] + + public var isDisconnected: Bool = true + + public var success: Bool = false + + public static func decodeJSON(json: JSON) -> Self { + var toReturn = Self() + + guard !(json["responseContext"]["mainAppWebResponseContext"]["loggedOut"].bool ?? true) else { return toReturn } + + toReturn.isDisconnected = false + + toReturn.success = json["actionResults"].arrayValue.first(where: {$0["status"].string == "STATUS_SUCCEEDED" && $0["feedback"].string == "FEEDBACK_UNLIKE"}) != nil + + return toReturn + } +} diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/ReplyCommentResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/ReplyCommentResponse.swift new file mode 100644 index 0000000..2d59898 --- /dev/null +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/CommentsActions/ReplyCommentResponse.swift @@ -0,0 +1,25 @@ +// +// ReplyCommentResponse.swift +// +// +// Created by Antoine Bollengier on 03.07.2024. +// Copyright © 2024 Antoine Bollengier (github.com/b5i). All rights reserved. +// + +/// Response to reply to a comment of a video. +public struct ReplyCommentResponse: SimpleActionAuthenticatedResponse { + public static let headersType: HeaderTypes = .replyCommentHeaders + + public static let parametersValidationList: ValidationList = [.text: .textSanitizerValidator, .params: .existenceValidator] + + public var isDisconnected: Bool = true + + public var success: Bool = false + + public var newComment: YTComment? + + public static func decodeJSON(json: JSON) throws -> Self { + let normalCommentDecoding = try CreateCommentResponse.decodeJSON(json: json) + return Self(isDisconnected: normalCommentDecoding.isDisconnected, success: normalCommentDecoding.success, newComment: normalCommentDecoding.newComment) + } +} diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/HistoryActions/RemoveVideoFromHistroryResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/HistoryActions/RemoveVideoFromHistroryResponse.swift index ddab307..9691d28 100644 --- a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/HistoryActions/RemoveVideoFromHistroryResponse.swift +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/HistoryActions/RemoveVideoFromHistroryResponse.swift @@ -8,7 +8,7 @@ import Foundation -public struct RemoveVideoFromHistroryResponse: AuthenticatedResponse { +public struct RemoveVideoFromHistroryResponse: SimpleActionAuthenticatedResponse { public static let headersType: HeaderTypes = .deleteVideoFromHistory public static let parametersValidationList: ValidationList = [.movingVideoId: .existenceValidator] diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/AddVideoToPlaylistResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/AddVideoToPlaylistResponse.swift index bac7b06..020a48c 100644 --- a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/AddVideoToPlaylistResponse.swift +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/AddVideoToPlaylistResponse.swift @@ -8,7 +8,7 @@ import Foundation -public struct AddVideoToPlaylistResponse: AuthenticatedResponse { +public struct AddVideoToPlaylistResponse: SimpleActionAuthenticatedResponse { public static let headersType: HeaderTypes = .addVideoToPlaylistHeaders public static let parametersValidationList: ValidationList = [.browseId: .playlistIdWithoutVLPrefixValidator, .movingVideoId: .videoIdValidator] diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/DeletePlaylistResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/DeletePlaylistResponse.swift index cb2e201..d68ce86 100644 --- a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/DeletePlaylistResponse.swift +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/DeletePlaylistResponse.swift @@ -8,7 +8,7 @@ import Foundation -public struct DeletePlaylistResponse: AuthenticatedResponse { +public struct DeletePlaylistResponse: SimpleActionAuthenticatedResponse { public static let headersType: HeaderTypes = .deletePlaylistHeaders public static let parametersValidationList: ValidationList = [.browseId: .playlistIdWithoutVLPrefixValidator] diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/MoveVideoInPlaylistResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/MoveVideoInPlaylistResponse.swift index 74febb3..40e9f97 100644 --- a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/MoveVideoInPlaylistResponse.swift +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/MoveVideoInPlaylistResponse.swift @@ -8,7 +8,7 @@ import Foundation -public struct MoveVideoInPlaylistResponse: AuthenticatedResponse { +public struct MoveVideoInPlaylistResponse: SimpleActionAuthenticatedResponse { public static let headersType: HeaderTypes = .moveVideoInPlaylistHeaders public static let parametersValidationList: ValidationList = [.movingVideoId: .existenceValidator, .browseId: .playlistIdWithoutVLPrefixValidator] diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/RemoveVideoByIdFromPlaylistResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/RemoveVideoByIdFromPlaylistResponse.swift index d841d82..87b8ded 100644 --- a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/RemoveVideoByIdFromPlaylistResponse.swift +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/RemoveVideoByIdFromPlaylistResponse.swift @@ -8,14 +8,13 @@ import Foundation -public struct RemoveVideoByIdFromPlaylistResponse: AuthenticatedResponse { +public struct RemoveVideoByIdFromPlaylistResponse: SimpleActionAuthenticatedResponse { public static let headersType: HeaderTypes = .removeVideoByIdFromPlaylistHeaders public static let parametersValidationList: ValidationList = [.movingVideoId: .videoIdValidator, .browseId: .playlistIdWithoutVLPrefixValidator] public var isDisconnected: Bool = true - /// Boolean indicating whether the remove action was successful. public var success: Bool = false public static func decodeJSON(json: JSON) -> RemoveVideoByIdFromPlaylistResponse { diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/RemoveVideoFromPlaylistResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/RemoveVideoFromPlaylistResponse.swift index 5aa49c7..2ec2069 100644 --- a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/RemoveVideoFromPlaylistResponse.swift +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/PlaylistsActions/RemoveVideoFromPlaylistResponse.swift @@ -9,7 +9,7 @@ import Foundation /// - 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. -public struct RemoveVideoFromPlaylistResponse: AuthenticatedResponse { +public struct RemoveVideoFromPlaylistResponse: SimpleActionAuthenticatedResponse { public static let headersType: HeaderTypes = .removeVideoFromPlaylistHeaders public static let parametersValidationList: ValidationList = [.movingVideoId: .existenceValidator, .playlistEditToken: .existenceValidator, .browseId: .playlistIdWithoutVLPrefixValidator] diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/SimpleOperationAuthenticatedResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/SimpleOperationAuthenticatedResponse.swift new file mode 100644 index 0000000..961a2b6 --- /dev/null +++ b/Sources/YouTubeKit/YouTubeResponseTypes/AuthenticatedResponses/SimpleOperationAuthenticatedResponse.swift @@ -0,0 +1,12 @@ +// +// SimpleOperationAuthenticatedResponse.swift +// +// +// Created by Antoine Bollengier on 03.07.2024. +// Copyright © 2024 Antoine Bollengier (github.com/b5i). All rights reserved. +// + +public protocol SimpleActionAuthenticatedResponse: AuthenticatedResponse { + /// Boolean indicating whether the action was successful. + var success: Bool { get } +} diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/VideoInfos/CommentTranslationResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/VideoInfos/CommentTranslationResponse.swift new file mode 100644 index 0000000..4ea510f --- /dev/null +++ b/Sources/YouTubeKit/YouTubeResponseTypes/VideoInfos/CommentTranslationResponse.swift @@ -0,0 +1,27 @@ +// +// CommentTranslationResponse.swift +// +// +// Created by Antoine Bollengier on 03.07.2024. +// Copyright © 2024 Antoine Bollengier (github.com/b5i). All rights reserved. +// + +public struct CommentTranslationResponse: YouTubeResponse { + public static let headersType: HeaderTypes = .translateCommentHeaders + + public static let parametersValidationList: ValidationList = [.params: .existenceValidator] + + public var translation: String + + public static func decodeJSON(json: JSON) throws -> Self { + guard json["actionResults"].arrayValue.first(where: {$0["status"].string == "STATUS_SUCCEEDED"}) != nil else { + throw ResponseExtractionError(reponseType: Self.self, stepDescription: "Request result is not successful.") + } + + guard let translatedText = json["frameworkUpdates"]["entityBatchUpdate"]["mutations"].arrayValue.first(where: {$0["payload"]["commentEntityPayload"]["translatedContent"]["content"].string != nil })?["payload"]["commentEntityPayload"]["translatedContent"]["content"].string else { + throw ResponseExtractionError(reponseType: Self.self, stepDescription: "Couldn't extract translted comment.") + } + + return Self(translation: translatedText) + } +} diff --git a/Sources/YouTubeKit/YouTubeResponseTypes/VideoInfos/VideoCommentsResponse.swift b/Sources/YouTubeKit/YouTubeResponseTypes/VideoInfos/VideoCommentsResponse.swift index 1904238..af797cd 100644 --- a/Sources/YouTubeKit/YouTubeResponseTypes/VideoInfos/VideoCommentsResponse.swift +++ b/Sources/YouTubeKit/YouTubeResponseTypes/VideoInfos/VideoCommentsResponse.swift @@ -12,7 +12,7 @@ import Foundation public struct VideoCommentsResponse: ContinuableResponse { public static let headersType: HeaderTypes = .videoCommentsHeaders - public static let parametersValidationList: ValidationList = [:] + public static let parametersValidationList: ValidationList = [.continuation: .existenceValidator] public var results: [YTComment] = [] @@ -20,16 +20,12 @@ public struct VideoCommentsResponse: ContinuableResponse { public var visitorData: String? = nil // will never be filled + /// Every sorting mode contains a ``VideoCommentsResponse/SortingMode/token`` that can be used as the continuation of a new ``VideoCommentsResponse``. + public var sortingModes: [SortingMode] = [] + + public var commentCreationToken: String? = nil + public static func decodeJSON(json: JSON) throws -> VideoCommentsResponse { - struct CommentRendererTokens { - let commentId: String - - let commentInfo: String - let commentAuthData: String? - let commentCommands: String? - let loadRepliesContinuationToken: String? - } - var toReturn = VideoCommentsResponse() let isConnected: Bool = !(json["responseContext"]["mainAppWebResponseContext"]["loggedOut"].bool ?? true) @@ -37,22 +33,35 @@ public struct VideoCommentsResponse: ContinuableResponse { var commentRenderers: [CommentRendererTokens] = [] for continuationActions in json["onResponseReceivedEndpoints"].arrayValue { - for commentRenderer in continuationActions["reloadContinuationItemsCommand"]["continuationItems"].array ?? continuationActions["appendContinuationItemsAction"]["continuationItems"].arrayValue where commentRenderer["commentThreadRenderer"].exists() { - let loadRepliesContinuationToken: String? = commentRenderer["commentThreadRenderer"]["replies"]["commentRepliesRenderer"]["contents"].arrayValue.first(where: {$0["continuationItemRenderer"].exists()})?["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]["token"].string - - let commentId: String? = commentRenderer["commentThreadRenderer"]["commentViewModel"]["commentViewModel"]["commentId"].string - let commentInfo: String? = commentRenderer["commentThreadRenderer"]["commentViewModel"]["commentViewModel"]["commentKey"].string - - guard let commentId = commentId, let commentInfo = commentInfo else { continue } - - let commentAuthData: String? = commentRenderer["commentThreadRenderer"]["commentViewModel"]["commentViewModel"]["toolbarStateKey"].string + for commentRenderer in continuationActions["reloadContinuationItemsCommand"]["continuationItems"].array ?? continuationActions["appendContinuationItemsAction"]["continuationItems"].arrayValue { + + let commentViewModel: JSON + let loadRepliesContinuationToken: String? - let commentCommands: String? = commentRenderer["commentThreadRenderer"]["commentViewModel"]["commentViewModel"]["toolbarSurfaceKey"].string + if commentRenderer["commentThreadRenderer"].exists() { + commentViewModel = commentRenderer["commentThreadRenderer"]["commentViewModel"]["commentViewModel"] + loadRepliesContinuationToken = commentRenderer["commentThreadRenderer"]["replies"]["commentRepliesRenderer"]["contents"].arrayValue.first(where: {$0["continuationItemRenderer"].exists()})?["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]["token"].string + } else if commentRenderer["commentViewModel"].exists() { + commentViewModel = commentRenderer["commentViewModel"] + loadRepliesContinuationToken = nil + } else if commentRenderer["continuationItemRenderer"].exists() { + toReturn.continuationToken = commentRenderer["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]["token"].string ?? commentRenderer["continuationItemRenderer"]["button"]["buttonRenderer"]["command"]["continuationCommand"]["token"].string + + continue + } else if commentRenderer["commentsHeaderRenderer"].exists() { + toReturn.commentCreationToken = commentRenderer["commentsHeaderRenderer"]["createRenderer"]["commentSimpleboxRenderer"]["submitButton"]["buttonRenderer"]["serviceEndpoint"]["createCommentEndpoint"]["createCommentParams"].string + for sortingModes in commentRenderer["commentsHeaderRenderer"]["sortMenu"]["sortFilterSubMenuRenderer"]["subMenuItems"].arrayValue { + guard let label = sortingModes["title"].string, let isSelected = sortingModes["selected"].bool, let token = sortingModes["serviceEndpoint"]["continuationCommand"]["token"].string else { continue } + toReturn.sortingModes.append(.init(label: label, isSelected: isSelected, token: token)) + } + continue + } else { + continue + } - commentRenderers.append(.init(commentId: commentId, commentInfo: commentInfo, commentAuthData: commentAuthData, commentCommands: commentCommands, loadRepliesContinuationToken: loadRepliesContinuationToken)) - } - if let continuationItem = (continuationActions["reloadContinuationItemsCommand"]["continuationItems"].array ?? continuationActions["appendContinuationItemsAction"]["continuationItems"].arrayValue).reversed().first(where: {$0["continuationItemRenderer"].exists()}) { - toReturn.continuationToken = continuationItem["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]["token"].string + guard let commentRendererTokens = self.extractCommentRendererTokensFromCommentViewModel(commentViewModel, loadRepliesContinuationToken: loadRepliesContinuationToken) else { continue } + + commentRenderers.append(commentRendererTokens) } } @@ -108,6 +117,8 @@ public struct VideoCommentsResponse: ContinuableResponse { } if let editButtonToken = commandsCluster["menuCommand"]["innertubeCommand"]["menuEndpoint"]["menu"]["menuRenderer"]["items"].arrayValue.first(where: {$0["menuNavigationItemRenderer"]["navigationEndpoint"]["updateCommentDialogEndpoint"]["dialog"]["commentDialogRenderer"]["submitButton"]["buttonRenderer"]["serviceEndpoint"]["commandMetadata"]["webCommandMetadata"]["apiUrl"].string == "/youtubei/v1/comment/update_comment"})?["menuNavigationItemRenderer"]["navigationEndpoint"]["updateCommentDialogEndpoint"]["dialog"]["commentDialogRenderer"]["submitButton"]["buttonRenderer"]["serviceEndpoint"]["updateCommentEndpoint"]["updateCommentParams"].string { commentToReturn.actionsParams[.edit] = editButtonToken + } else if let editButtonToken = commandsCluster["menuCommand"]["innertubeCommand"]["menuEndpoint"]["menu"]["menuRenderer"]["items"].arrayValue.first(where: {$0["menuNavigationItemRenderer"]["navigationEndpoint"]["updateCommentReplyDialogEndpoint"]["dialog"]["commentReplyDialogRenderer"]["replyButton"]["buttonRenderer"]["serviceEndpoint"]["commandMetadata"]["webCommandMetadata"]["apiUrl"].string == "/youtubei/v1/comment/update_comment_reply"})?["menuNavigationItemRenderer"]["navigationEndpoint"]["updateCommentReplyDialogEndpoint"]["dialog"]["commentReplyDialogRenderer"]["replyButton"]["buttonRenderer"]["serviceEndpoint"]["updateCommentReplyEndpoint"]["updateReplyParams"].string { + commentToReturn.actionsParams[.edit] = editButtonToken } if let deleteButtonToken = commandsCluster["menuCommand"]["innertubeCommand"]["menuEndpoint"]["menu"]["menuRenderer"]["items"].arrayValue.first(where: {$0["menuNavigationItemRenderer"]["navigationEndpoint"]["confirmDialogEndpoint"]["content"]["confirmDialogRenderer"]["confirmButton"]["buttonRenderer"]["serviceEndpoint"]["commandMetadata"]["webCommandMetadata"]["apiUrl"].string == "/youtubei/v1/comment/perform_comment_action"})?["menuNavigationItemRenderer"]["navigationEndpoint"]["confirmDialogEndpoint"]["content"]["confirmDialogRenderer"]["confirmButton"]["buttonRenderer"]["serviceEndpoint"]["performCommentActionEndpoint"]["action"].string { commentToReturn.actionsParams[.delete] = deleteButtonToken @@ -119,6 +130,10 @@ public struct VideoCommentsResponse: ContinuableResponse { commentToReturn.isLikedByVideoCreator = self.getHeartedByCreatorState(forKey: commentAuthDataJSON["payload"]["engagementToolbarStateEntityPayload"]["heartState"].stringValue) } + if let loadRepliesContinuationToken = commentRenderer.loadRepliesContinuationToken { + commentToReturn.actionsParams[.repliesContinuation] = loadRepliesContinuationToken + } + toReturn.results.append(commentToReturn) } @@ -129,7 +144,7 @@ public struct VideoCommentsResponse: ContinuableResponse { public struct Continuation: ResponseContinuation { public static let headersType: HeaderTypes = .videoCommentsContinuationHeaders - public static let parametersValidationList: ValidationList = [.params: .existenceValidator] + public static let parametersValidationList: ValidationList = [.continuation: .existenceValidator] /// Continuation token used to fetch more videos, nil if there is no more videos to fetch. public var continuationToken: String? @@ -147,6 +162,20 @@ public struct VideoCommentsResponse: ContinuableResponse { } } + public struct SortingMode: Sendable { + public init(label: String, isSelected: Bool, token: String) { + self.label = label + self.isSelected = isSelected + self.token = token + } + + public var label: String + + public var isSelected: Bool + + public var token: String + } + private static func getShortsFromSectionRenderer(_ json: JSON) -> [YTVideo] { var toReturn: [YTVideo] = [] for itemSectionContents in json["content"]["richShelfRenderer"]["contents"].arrayValue { @@ -192,4 +221,26 @@ public struct VideoCommentsResponse: ContinuableResponse { return nil } } + + private static func extractCommentRendererTokensFromCommentViewModel(_ json: JSON, loadRepliesContinuationToken: String? = nil) -> CommentRendererTokens? { + let commentId: String? = json["commentId"].string + let commentInfo: String? = json["commentKey"].string + + guard let commentId = commentId, let commentInfo = commentInfo else { return nil } + + let commentAuthData: String? = json["toolbarStateKey"].string + + let commentCommands: String? = json["toolbarSurfaceKey"].string + + return CommentRendererTokens(commentId: commentId, commentInfo: commentInfo, commentAuthData: commentAuthData, commentCommands: commentCommands, loadRepliesContinuationToken: loadRepliesContinuationToken) + } + + private struct CommentRendererTokens: Sendable { + let commentId: String + + let commentInfo: String + let commentAuthData: String? + let commentCommands: String? + let loadRepliesContinuationToken: String? + } } diff --git a/Tests/YouTubeKitTests/YouTubeKitTests.swift b/Tests/YouTubeKitTests/YouTubeKitTests.swift index 9f78f81..decd2ba 100644 --- a/Tests/YouTubeKitTests/YouTubeKitTests.swift +++ b/Tests/YouTubeKitTests/YouTubeKitTests.swift @@ -1181,11 +1181,11 @@ final class YouTubeKitTests: XCTestCase { } func testVideoComments() async throws { - let TEST_NAME = "Test: testAccountSubscriptionsFeedResponse() -> " + let TEST_NAME = "Test: testVideoComments() -> " YTM.cookies = self.cookies - let video = YTVideo(videoId: "3ryID_SwU5E") + let video = YTVideo(videoId: "KkCXLABwHP0") let videoResponse = try await video.fetchMoreInfosThrowing(youtubeModel: YTM, useCookies: true) @@ -1193,8 +1193,8 @@ final class YouTubeKitTests: XCTestCase { XCTFail(TEST_NAME + "videoResponse.commentsContinuationToken is nil.") return } - - var videoCommentsResponse = try await VideoCommentsResponse.sendThrowingRequest(youtubeModel: YTM, data: [.params: commentsToken], useCookies: true) + + var videoCommentsResponse = try await VideoCommentsResponse.sendThrowingRequest(youtubeModel: YTM, data: [.continuation: commentsToken], useCookies: true) XCTAssertNil(videoCommentsResponse.visitorData, TEST_NAME + "visitorData is not nil but it should.") @@ -1212,16 +1212,15 @@ final class YouTubeKitTests: XCTestCase { XCTAssertNotNil(firstComment.likeState) XCTAssertNotNil(firstComment.isLikedByVideoCreator) XCTAssertNotEqual(firstComment.commentIdentifier.count, 0) - - guard let continuationToken = videoCommentsResponse.continuationToken else { XCTFail(TEST_NAME + "videoCommentsResponse.continuationToken is nil."); return } - let continuation = try await VideoCommentsResponse.Continuation.sendThrowingRequest(youtubeModel: YTM, data: [.params: continuationToken], useCookies: true) + XCTAssertNotNil(firstComment.actionsParams[.repliesContinuation]) - XCTAssertNotNil(continuation.continuationToken, TEST_NAME + "continuationToken of continuation is nil.") + let moreRepliesResponse = try await firstComment.fetchRepliesContinuation(youtubeModel: YTM, useCookies: true) + XCTAssertNotNil(moreRepliesResponse.continuationToken, TEST_NAME + "continuationToken of moreRepliesResponse is nil.") - XCTAssert(!continuation.results.isEmpty, TEST_NAME + "continuation.results is empty") + XCTAssert(!moreRepliesResponse.results.isEmpty, TEST_NAME + "moreRepliesResponse.results is empty") - let firstCommentFromContinuation = continuation.results.first! + let firstCommentFromContinuation = moreRepliesResponse.results.first! XCTAssertNotNil(firstCommentFromContinuation.totalRepliesNumber) XCTAssertNotNil(firstCommentFromContinuation.timePosted) @@ -1234,11 +1233,86 @@ final class YouTubeKitTests: XCTestCase { XCTAssertNotNil(firstCommentFromContinuation.isLikedByVideoCreator) XCTAssertNotEqual(firstCommentFromContinuation.commentIdentifier.count, 0) + + XCTAssertNotNil(videoCommentsResponse.continuationToken) + + let continuation = try await videoCommentsResponse.fetchContinuationThrowing(youtubeModel: YTM, useCookies: true) + + XCTAssertNotNil(continuation.continuationToken, TEST_NAME + "continuationToken of continuation is nil.") + + XCTAssert(!continuation.results.isEmpty, TEST_NAME + "continuation.results is empty") + + let firstCommentFromNormalContinuation = continuation.results.first! + + XCTAssertNotNil(firstCommentFromNormalContinuation.totalRepliesNumber) + XCTAssertNotNil(firstCommentFromNormalContinuation.timePosted) + XCTAssertNotEqual(firstCommentFromNormalContinuation.text.count, 0) + XCTAssertNotNil(firstCommentFromNormalContinuation.sender) + XCTAssertNotNil(firstCommentFromNormalContinuation.replyLevel) + XCTAssertNotNil(firstCommentFromNormalContinuation.likesCountWhenUserLiked) + XCTAssertNotNil(firstCommentFromNormalContinuation.likesCount) + XCTAssertNotNil(firstCommentFromNormalContinuation.likeState) + XCTAssertNotNil(firstCommentFromNormalContinuation.isLikedByVideoCreator) + XCTAssertNotEqual(firstCommentFromNormalContinuation.commentIdentifier.count, 0) + let oldCount = videoCommentsResponse.results.count videoCommentsResponse.mergeContinuation(continuation) XCTAssertEqual(videoCommentsResponse.results.count, oldCount + continuation.results.count) XCTAssertEqual(videoCommentsResponse.continuationToken, continuation.continuationToken) + + if let commentToTranslate = videoCommentsResponse.results.first(where: {$0.actionsParams[.translate] != nil}) { + let translationResponse = try await commentToTranslate.translateText(youtubeModel: YTM) + XCTAssert(!translationResponse.translation.isEmpty) + } + } + + func testAuthActionsVideoComments() async throws { + let TEST_NAME = "Test: testVideoComments() -> " + + YTM.cookies = self.cookies + + guard self.cookies != "" else { return } // start of the tests that require a google account + + let video = YTVideo(videoId: "KkCXLABwHP0") + + let videoResponse = try await video.fetchMoreInfosThrowing(youtubeModel: YTM, useCookies: true) + + guard let commentsToken = videoResponse.commentsContinuationToken else { + XCTFail(TEST_NAME + "videoResponse.commentsContinuationToken is nil.") + return + } + + let videoCommentsResponse = try await VideoCommentsResponse.sendThrowingRequest(youtubeModel: YTM, data: [.continuation: commentsToken], useCookies: true) + + guard let commentCreationToken = videoCommentsResponse.commentCreationToken else { XCTFail(TEST_NAME + "commentCreationToken is nil."); return } + + let createCommentText = "YouTubeKit test ?=/\\\"" + let createCommentResponse = try await CreateCommentResponse.sendThrowingRequest(youtubeModel: YTM, data: [.params: commentCreationToken, .text: createCommentText]) + + XCTAssert(!createCommentResponse.isDisconnected) + XCTAssert(createCommentResponse.success) + guard let createdComment = createCommentResponse.newComment else { XCTFail(TEST_NAME + "Couldn't retrieve newly created comment."); return } + + XCTAssertEqual(createdComment.text, createCommentText) + try await Task.sleep(nanoseconds: 60_000_000_000) // time for the comment to be indexed by youtube + + try await createdComment.commentAction(youtubeModel: YTM, action: .like) + try await Task.sleep(nanoseconds: 5_000_000_000) + try await createdComment.commentAction(youtubeModel: YTM, action: .removeLike) + try await Task.sleep(nanoseconds: 5_000_000_000) + try await createdComment.commentAction(youtubeModel: YTM, action: .dislike) + try await Task.sleep(nanoseconds: 5_000_000_000) + try await createdComment.commentAction(youtubeModel: YTM, action: .removeDislike) + try await Task.sleep(nanoseconds: 5_000_000_000) + try await createdComment.editComment(withNewText: "YouTubeKit", youtubeModel: YTM) + try await Task.sleep(nanoseconds: 5_000_000_000) + let reply = try await createdComment.replyToComment(youtubeModel: YTM, text: "Yes!") + guard let replyComment = reply.newComment else { XCTFail(TEST_NAME + "Couldn't retrieve newly created reply."); return} + try await Task.sleep(nanoseconds: 60_000_000_000) + try await replyComment.editComment(withNewText: "No!", youtubeModel: YTM) + try await Task.sleep(nanoseconds: 5_000_000_000) + try await createdComment.commentAction(youtubeModel: YTM, action: .delete) } }