diff --git a/winston/components/Links/PostLink/getPostContentWidth.swift b/winston/components/Links/PostLink/getPostContentWidth.swift index e89e014b..c27e1438 100644 --- a/winston/components/Links/PostLink/getPostContentWidth.swift +++ b/winston/components/Links/PostLink/getPostContentWidth.swift @@ -109,8 +109,11 @@ func getPostDimensions(post: Post, winstonData: PostWinstonData? = nil, columnWi } case .video(let video): ACC_mediaSize = defaultMediaSize(video.size) + // Streamable and redgifs won't be ever considered, might be better to use a default case case .streamable(_): ACC_mediaSize = CGSize(width: contentWidth, height: 100) + case .redgifs(_): + ACC_mediaSize = CGSize(width: contentWidth, height: 100) case .yt(let ytMediaExtracted): let size = ytMediaExtracted.size let actualHeight = (contentWidth * CGFloat(size.height)) / CGFloat(size.width) diff --git a/winston/components/Media/mediaExtractor.swift b/winston/components/Media/mediaExtractor.swift index 221a2d58..1db5d7bb 100644 --- a/winston/components/Media/mediaExtractor.swift +++ b/winston/components/Media/mediaExtractor.swift @@ -74,12 +74,111 @@ struct StreamableCached: Equatable { } } +struct RedgifsExtracted: Equatable { + static func == (lhs: RedgifsExtracted, rhs: RedgifsExtracted) -> Bool { + lhs.id == rhs.id + } + + let id: String + + init(id: String) { + self.id = id + } +} + +struct RedgifsCached: Equatable { + static func == (lhs: RedgifsCached, rhs: RedgifsCached) -> Bool { + lhs.url == rhs.url && lhs.size == rhs.size + } + + let url: URL + let size: CGSize + + init(url: URL, size: CGSize) { + self.url = url + self.size = size + } +} + +enum RedgifsError: Error { + case tokenNonExistent +} + +class RedgifsClient { + static let shared = RedgifsClient() + + private var token: String? + private var tokenExpiry: Date? + + private init() {} + + func refreshToken() async throws { + let token: String = try await RedgifsClient.getToken() + self.token = token + tokenExpiry = await RedgifsClient.getTokenExpiry(token: token) + } + + func getToken() async throws -> String { + if token == nil { + try await refreshToken() + } else if let tokenExpiry = tokenExpiry, Date.now > tokenExpiry { + try await refreshToken() + } + + guard let token else { throw RedgifsError.tokenNonExistent } + + return token + } + + private struct JWTData: Decodable { + let expiry: Date + + enum CodingKeys: String, CodingKey { + case expiry = "exp" + } + } + + private struct TokenResponse: Decodable { + let token: String + let expiry: Date? + } + + static func getTokenExpiry(token: String) async -> Date? { + // Get the data part of a JWT, Base64 Decode that into a JSON string, decode that JSON and extract the expiry + let splitToken = token.components(separatedBy: ".") + if splitToken.count == 3, let decodedData = Data(base64Encoded: splitToken[1]) { + let jsonDecoder = JSONDecoder() + jsonDecoder.dateDecodingStrategy = .secondsSince1970 + + guard let jwtData = try? jsonDecoder.decode(JWTData.self, from: decodedData) else { + return nil + } + + return jwtData.expiry + } else { + return nil + } + } + + static func getToken() async throws -> String { + let headers: HTTPHeaders = [.accept("application/json"),.defaultUserAgent,.defaultAcceptEncoding,.defaultAcceptLanguage] + + let data = try await AF.request("https://api.redgifs.com/v2/auth/temporary", headers: headers) + .validate() + .serializingDecodable(TokenResponse.self) + .value + + return data.token + } +} + enum MediaExtractedType: Equatable { case link(PreviewModel) case video(SharedVideo) case imgs([ImgExtracted]) case yt(YTMediaExtracted) case streamable(StreamableExtracted) + case redgifs(RedgifsExtracted) case repost(Post) case post(EntityExtracted?) case comment(EntityExtracted?) @@ -87,10 +186,23 @@ enum MediaExtractedType: Equatable { case user(EntityExtracted?) } +fileprivate func urlComponentsExtractor(data: PostData) -> (URLComponents?, [String]) { + let actualURL = data.url.hasPrefix("/r/") || data.url.hasPrefix("/u/") ? "https://reddit.com\(data.url)" : data.url + guard let urlComponents = URLComponents(string: actualURL) else { + return (nil, []) + } + + let pathComponents = urlComponents.path.components(separatedBy: "/").filter({ !$0.isEmpty }) + + return (urlComponents, pathComponents) +} // ORDER MATTERS! func mediaExtractor(compact: Bool, contentWidth: Double = .screenW, _ data: PostData, theme: WinstonTheme? = nil) -> MediaExtractedType? { guard !data.is_self else { return nil } + + var urlComponents: URLComponents? + var pathComponents: [String] = [] let contentWidth = contentWidth - ((theme?.postLinks.theme.innerPadding.horizontal ?? 0) * 2) - ((theme?.postLinks.theme.outerHPadding ?? 0) * 2) @@ -123,6 +235,20 @@ func mediaExtractor(compact: Bool, contentWidth: Double = .screenW, _ data: Post return .imgs(galleryArr) } + if data.domain.contains("streamable.com") { + return .streamable(StreamableExtracted(url: data.url)) + } + + if data.domain.contains("redgifs.com") { + (urlComponents, pathComponents) = urlComponentsExtractor(data: data) + + if let urlComponents = urlComponents { + if urlComponents.host == "www.redgifs.com" || urlComponents.host == "v3.redgifs.com", pathComponents.count >= 2, pathComponents[0] == "watch" || pathComponents[0] == "ifr" { + return .redgifs(RedgifsExtracted(id: pathComponents[1])) + } + } + } + if let videoPreview = data.preview?.reddit_video_preview, let url = videoPreview.hls_url, let videoURL = URL(string: url), let width = videoPreview.width, let height = videoPreview.height { return .video(SharedVideo.get(url: videoURL, size: CGSize(width: CGFloat(width), height: CGFloat(height)))) } @@ -175,17 +301,21 @@ func mediaExtractor(compact: Bool, contentWidth: Double = .screenW, _ data: Post if VIDEOS_FORMATS.contains(where: { data.url.hasSuffix($0) }), let url = URL(string: data.url) { return .video(SharedVideo.get(url: url, size: CGSize(width: 0, height: 0))) } - - if data.url.contains("streamable.com") { - return .streamable(StreamableExtracted(url: data.url)) + + if urlComponents == nil { + (urlComponents, pathComponents) = urlComponentsExtractor(data: data) } - let actualURL = data.url.hasPrefix("/r/") || data.url.hasPrefix("/u/") ? "https://reddit.com\(data.url)" : data.url - guard let urlComponents = URLComponents(string: actualURL) else { + guard let urlComponents else { return nil } - let pathComponents = urlComponents.path.components(separatedBy: "/").filter({ !$0.isEmpty }) +// let actualURL = data.url.hasPrefix("/r/") || data.url.hasPrefix("/u/") ? "https://reddit.com\(data.url)" : data.url +// guard let urlComponents = URLComponents(string: actualURL) else { +// return nil +// } +// +// let pathComponents = urlComponents.path.components(separatedBy: "/").filter({ !$0.isEmpty }) if urlComponents.host?.hasSuffix("reddit.com") == true || urlComponents.host?.hasSuffix("app.winston.cafe") == true, pathComponents.count > 1 { switch pathComponents[0] { @@ -264,3 +394,53 @@ struct StreamableAPIFile: Codable { let width: Int let height: Int } + +struct RedgifsResponse: Decodable { + let created: Date? + let width: Int? + let height: Int? + let username: String + let videoURL: String + + enum OuterKeys: String, CodingKey { + case gif + } + + enum CodingKeys: String, CodingKey { + case created = "createDate" + case width, height + case username = "userName" + case URLs = "urls" + } + + enum URLCodingKeys: String, CodingKey { + case thumbnail + case videoThumbnail = "vthumbnail" + case standardDefinition = "sd" + case highDefinition = "hd" + case poster + } + + init(from decoder: any Decoder) throws { + let outerContainer = try decoder.container(keyedBy: OuterKeys.self) + + let container = try outerContainer.nestedContainer(keyedBy: CodingKeys.self, forKey: .gif) + + if let decodedDate = try? container.decode(Int.self, forKey: .created) { + created = Date(timeIntervalSince1970: Double(decodedDate)) + } else { + created = nil + } + width = try? container.decode(Int.self, forKey: .width) + height = try? container.decode(Int.self, forKey: .height) + username = try container.decode(String.self, forKey: .username) + + let imageContainer = try container.nestedContainer( + keyedBy: URLCodingKeys.self, + forKey: .URLs + ) + + // Logic here can be alternated between SD and HD video based on metrics or user prefs + videoURL = try imageContainer.decode(String.self, forKey: .highDefinition) + } +} diff --git a/winston/components/RedditListingFeed/FeedItemsManager.swift b/winston/components/RedditListingFeed/FeedItemsManager.swift index b172cf23..7a5feae8 100644 --- a/winston/components/RedditListingFeed/FeedItemsManager.swift +++ b/winston/components/RedditListingFeed/FeedItemsManager.swift @@ -136,6 +136,8 @@ class FeedItemsManager { newPartial.append(video.thumbnailRequest) case .streamable(_): break + case .redgifs(_): + break case .repost(_): break case .post(_): diff --git a/winston/models/RedditEntities/Post/Post.swift b/winston/models/RedditEntities/Post/Post.swift index 5abe0472..8d34cc8b 100644 --- a/winston/models/RedditEntities/Post/Post.swift +++ b/winston/models/RedditEntities/Post/Post.swift @@ -62,6 +62,29 @@ extension Post { if let video = await self.loadStreamableMedia(streamable: streamable) { Caches.streamable.addKeyValue(key: streamable.shortCode, data: { StreamableCached(url: video.url, size: video.size) }, expires: Date().dateByAdding(1, .day).date) + DispatchQueue.main.async { + withAnimation { + self.winstonData?.extractedMedia = .video(video) + self.winstonData?.extractedMediaForcedNormal = .video(video) + + self.winstonData?.postDimensions = getPostDimensions(post: self, winstonData: self.winstonData, columnWidth: contentWidth, secondary: secondary, rawTheme: theme, subId: sub?.id) + self.winstonData?.postDimensionsForcedNormal = getPostDimensions(post: self, winstonData: self.winstonData, columnWidth: contentWidth, secondary: secondary, rawTheme: theme, compact: false) + } + } + } + } + } + case .redgifs(let redgif): + if let redgifCached = Caches.redgifs.get(key: redgif.id) { + let sharedVideo = SharedVideo.get(url: redgifCached.url, size: redgifCached.size) + + extractedMedia = .video(sharedVideo) + extractedMediaForcedNormal = .video(sharedVideo) + } else { + Task(priority: .background) { + if let video = await self.loadRedgifsMedia(redgif: redgif) { + Caches.redgifs.addKeyValue(key: redgif.id, data: { RedgifsCached(url: video.url, size: video.size) }, expires: Date().dateByAdding(1, .day).date) + DispatchQueue.main.async { withAnimation { self.winstonData?.extractedMedia = .video(video) @@ -242,6 +265,41 @@ extension Post { return nil } + func loadRedgifsMedia(redgif: RedgifsExtracted) async -> SharedVideo? { + var token: String + do { + token = try await RedgifsClient.shared.getToken() + } catch { + print("Error getting RedGifs token: \(error)") + return nil + } + + let headers: HTTPHeaders = [.authorization(bearerToken: token), .accept("application/json"),.defaultUserAgent,.defaultAcceptEncoding,.defaultAcceptLanguage] + + let response = await AF.request("https://api.redgifs.com/v2/gifs/\(redgif.id)", headers: headers) + .validate() + .serializingDecodable(RedgifsResponse.self).response + + switch response.result { + case .success(let data): + if let width = data.width, let height = data.height { + if let videoURL = URL(string: data.videoURL) { + let size = CGSize(width: width, height: height) + return SharedVideo.get(url: videoURL, size: size) + } + } else { + // Perhaps load the video and get the width and height? + return nil + } + // Doesn't account for case where Redgifs client failed to parse API token expiry, and as such token expires and req fails + case .failure(let failure): + print("Error loading RedGifs video with reason \(String(data: response.data ?? Data(), encoding: .utf8))") + return nil + } + + return nil + } + func toggleSeen(_ seen: Bool? = nil, optimistic: Bool = false) async -> Void { let context = PersistenceController.shared.primaryBGContext if (self.data?.winstonSeen ?? false) == seen { return } diff --git a/winston/models/caches/caches.swift b/winston/models/caches/caches.swift index 6e853940..e5011300 100644 --- a/winston/models/caches/caches.swift +++ b/winston/models/caches/caches.swift @@ -18,4 +18,5 @@ class Caches { static let postsAttrStr = BaseCache(cacheLimit: 100) static let videos = BaseCache(cacheLimit: 50) static let streamable = BaseCache(cacheLimit: 100) + static let redgifs = BaseCache(cacheLimit: 100) }