Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions winston/components/Links/PostLink/getPostContentWidth.swift
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be worth evaluating before merging

Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
192 changes: 186 additions & 6 deletions winston/components/Media/mediaExtractor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,23 +74,135 @@ 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<PostData, PostWinstonData>?)
case comment(EntityExtracted<CommentData, CommentWinstonData>?)
case subreddit(EntityExtracted<SubredditData, AnyHashable>?)
case user(EntityExtracted<UserData, AnyHashable>?)
}

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)

Expand Down Expand Up @@ -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))))
}
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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)
}
}
2 changes: 2 additions & 0 deletions winston/components/RedditListingFeed/FeedItemsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ class FeedItemsManager<S> {
newPartial.append(video.thumbnailRequest)
case .streamable(_):
break
case .redgifs(_):
break
case .repost(_):
break
case .post(_):
Expand Down
58 changes: 58 additions & 0 deletions winston/models/RedditEntities/Post/Post.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions winston/models/caches/caches.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ class Caches {
static let postsAttrStr = BaseCache<AttributedString>(cacheLimit: 100)
static let videos = BaseCache<SharedVideo>(cacheLimit: 50)
static let streamable = BaseCache<StreamableCached>(cacheLimit: 100)
static let redgifs = BaseCache<RedgifsCached>(cacheLimit: 100)
}