diff options
Diffstat (limited to 'Sora/Data/Booru')
| -rw-r--r-- | Sora/Data/Booru/BooruManager.swift | 105 | ||||
| -rw-r--r-- | Sora/Data/Booru/BooruNetworkImageLoader.swift | 63 | ||||
| -rw-r--r-- | Sora/Data/Booru/BooruRequestConfiguration.swift | 66 | ||||
| -rw-r--r-- | Sora/Data/Booru/Post/BooruPost.swift | 2 | ||||
| -rw-r--r-- | Sora/Data/Booru/Tag/DanbooruTagParser.swift | 59 | ||||
| -rw-r--r-- | Sora/Data/Booru/Tag/GelbooruAutocompleteTagParser.swift | 84 |
6 files changed, 348 insertions, 31 deletions
diff --git a/Sora/Data/Booru/BooruManager.swift b/Sora/Data/Booru/BooruManager.swift index 3c4374e..b70ad5b 100644 --- a/Sora/Data/Booru/BooruManager.swift +++ b/Sora/Data/Booru/BooruManager.swift @@ -26,10 +26,12 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng private let pageCache = NSCache<NSString, BooruPageCacheEntry>() // swiftlint:disable:this legacy_objc_type private let cacheDuration: TimeInterval private let credentials: BooruProviderCredentials? - private let userAgent: String + private let referer: String + private let userAgent: String? private let showHeldMoebooruPosts: Bool private var urlCache: [String: URL] = [:] private var lastPostCount = 0 + private var cachedMinimumPostID: Int? // MARK: - Computed Properties var tags: [String] { @@ -47,6 +49,8 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng _ provider: BooruProvider, credentials: BooruProviderCredentials? = nil, cacheDuration: TimeInterval = BooruPageCacheEntry.defaultExpiration, + sendUserAgent: Bool = true, + customUserAgent: String = "", showHeldMoebooruPosts: Bool = false ) { self.provider = provider @@ -58,10 +62,11 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng pageCache.countLimit = 50 pageCache.totalCostLimit = 50 * 1_024 * 1_024 - let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" - let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" - - self.userAgent = "Sora/\(version) (Build \(buildNumber))" + self.referer = BooruRequestConfiguration.baseReferer(for: provider.domain) + self.userAgent = BooruRequestConfiguration.resolvedUserAgent( + sendUserAgent: sendUserAgent, + customUserAgent: customUserAgent + ) let rootQuery = BooruSearchQuery( provider: provider, @@ -80,7 +85,7 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng let pageValue = flavor == .gelbooru ? page - 1 : page guard let url = url(forPosts: pageValue, limit: limit, tags: tags) else { return } - let cacheKey = "\(url.absoluteString.hashValue)_\(replace)" as NSString // swiftlint:disable:this legacy_objc_type + let cacheKey = pageCacheKey(for: url, replace: replace) if let cachedEntry = pageCache.object(forKey: cacheKey), !cachedEntry.isExpired @@ -303,7 +308,16 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng guard !Task.isCancelled else { return [] } - return BooruTagXMLParser(data: data).parse().sorted { $0.count > $1.count } + let parsedTags = + if flavor == .danbooru { + DanbooruTagParser(data: data).parse() + } else if flavor == .gelbooru, provider != .safebooru { + GelbooruAutocompleteTagParser(data: data).parse() + } else { + BooruTagXMLParser(data: data).parse() + } + + return parsedTags.sorted { $0.count > $1.count } } catch { if (error as? URLError)?.code != .cancelled { debugPrint("BooruManager.searchTags: \(error)") @@ -340,9 +354,7 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng guard page > 1 else { return "1" } guard !hasExplicitSortTag(in: tags) else { return String(page) } - guard let minimumPostID = posts.lazy.compactMap({ Int($0.id) }).min() else { - return String(page) - } + guard let minimumPostID = cachedMinimumPostID else { return String(page) } return "b\(minimumPostID)" } @@ -351,7 +363,11 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng let tagString = tags.joined(separator: "+") let pageCacheValue = flavor == .danbooru ? danbooruPageToken(for: page, tags: tags) : String(page) - let cacheKey = "posts_\(pageCacheValue)_\(limit)_\(tagString.hashValue)" + let cacheKey = postsURLCacheKey( + page: pageCacheValue, + limit: limit, + tagString: tagString + ) if let cachedURL = urlCache[cacheKey] { return cachedURL @@ -434,6 +450,16 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng return url } + // swiftlint:disable:next legacy_objc_type + private func pageCacheKey(for url: URL, replace: Bool) -> NSString { + // swiftlint:disable:next legacy_objc_type + "posts|\(url.absoluteString)|replace:\(replace)" as NSString + } + + private func postsURLCacheKey(page: String, limit: Int, tagString: String) -> String { + "posts|\(page)|limit:\(limit)|tags:\(tagString)" + } + private func url(forTags limit: Int, order: String = "count") -> URL? { switch flavor { case .moebooru: @@ -479,7 +505,7 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng components.host = domain components.path = "/tag.xml" components.queryItems = [ - URLQueryItem(name: "name_pattern", value: name), + URLQueryItem(name: "name", value: name), URLQueryItem(name: "order", value: "count"), ] @@ -492,28 +518,36 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng components.host = domain components.path = "/index.php" - var queryItems = [ - URLQueryItem(name: "page", value: "dapi"), - URLQueryItem(name: "s", value: "tag"), - URLQueryItem(name: "q", value: "index"), - URLQueryItem(name: "name_pattern", value: "%\(name)%"), - URLQueryItem(name: "orderby", value: "count"), - ] - - if let validCredentials = credentials, - !validCredentials.apiKey.isEmpty, - validCredentials.userID != 0 - { - queryItems.append(URLQueryItem(name: "api_key", value: validCredentials.apiKey)) - queryItems.append(URLQueryItem(name: "user_id", value: String(validCredentials.userID))) + if provider == .safebooru { + components.queryItems = [ + URLQueryItem(name: "page", value: "dapi"), + URLQueryItem(name: "s", value: "tag"), + URLQueryItem(name: "q", value: "index"), + URLQueryItem(name: "name_pattern", value: "%\(name)%"), + URLQueryItem(name: "orderby", value: "count"), + ] + } else { + components.queryItems = [ + URLQueryItem(name: "page", value: "autocomplete2"), + URLQueryItem(name: "type", value: "tag_query"), + URLQueryItem(name: "term", value: name), + ] } - components.queryItems = queryItems - return components.url case .danbooru: - return nil + var components = URLComponents() + + components.scheme = "https" + components.host = domain + components.path = "/tags.json" + components.queryItems = [ + URLQueryItem(name: "search[name_matches]", value: "\(name)*"), + URLQueryItem(name: "limit", value: "50"), + ] + + return components.url } } @@ -545,12 +579,17 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng postIndexMap.removeAll() lastPostCount = 0 + cachedMinimumPostID = nil } endOfData = newPosts.isEmpty guard !endOfData else { return } + if let nextMinimumPostID = newPosts.lazy.compactMap({ Int($0.id) }).min() { + cachedMinimumPostID = min(cachedMinimumPostID ?? nextMinimumPostID, nextMinimumPostID) + } + withTransaction(Transaction(animation: nil)) { let oldCount = self.posts.count @@ -567,7 +606,13 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng } func requestURL(_ url: URL) async throws -> Data { - try await AF.request(url, headers: ["User-Agent": userAgent]) + let request = BooruRequestConfiguration.request( + url: url, + referer: referer, + userAgent: userAgent + ) + + return try await AF.request(request) .validate(statusCode: 200..<300) .serializingData() .value diff --git a/Sora/Data/Booru/BooruNetworkImageLoader.swift b/Sora/Data/Booru/BooruNetworkImageLoader.swift new file mode 100644 index 0000000..8005550 --- /dev/null +++ b/Sora/Data/Booru/BooruNetworkImageLoader.swift @@ -0,0 +1,63 @@ +import CoreGraphics +import Foundation +import ImageIO +import NetworkImage + +actor BooruNetworkImageLoader: NetworkImageLoader { + private let domain: String + private let sendUserAgent: Bool + private let customUserAgent: String + private var ongoingTasks: [URL: Task<CGImage, Error>] = [:] + + init( + domain: String, + sendUserAgent: Bool, + customUserAgent: String + ) { + self.domain = domain + self.sendUserAgent = sendUserAgent + self.customUserAgent = customUserAgent + } + + func image(from url: URL) async throws -> CGImage { + if let task = ongoingTasks[url] { + return try await task.value + } + + let domain = self.domain + let sendUserAgent = self.sendUserAgent + let customUserAgent = self.customUserAgent + + let task = Task<CGImage, Error> { + guard + let data = await ImageCacheManager.shared.loadImageData( + for: url, + domain: domain, + sendUserAgent: sendUserAgent, + customUserAgent: customUserAgent + ) + else { + throw URLError(.badServerResponse) + } + + guard + let source = CGImageSourceCreateWithData(data as CFData, nil), + let image = CGImageSourceCreateImageAtIndex( + source, + 0, + [kCGImageSourceShouldCache: true] as CFDictionary + ) + else { + throw URLError(.cannotDecodeContentData) + } + + return image + } + + ongoingTasks[url] = task + + defer { ongoingTasks.removeValue(forKey: url) } + + return try await task.value + } +} diff --git a/Sora/Data/Booru/BooruRequestConfiguration.swift b/Sora/Data/Booru/BooruRequestConfiguration.swift new file mode 100644 index 0000000..2b786cf --- /dev/null +++ b/Sora/Data/Booru/BooruRequestConfiguration.swift @@ -0,0 +1,66 @@ +import Foundation + +enum BooruRequestConfiguration { + static func resolvedUserAgent( + sendUserAgent: Bool, + customUserAgent: String + ) -> String? { + guard sendUserAgent else { return nil } + + let trimmedCustomUserAgent = customUserAgent.trimmingCharacters( + in: .whitespacesAndNewlines + ) + + guard trimmedCustomUserAgent.isEmpty else { + return trimmedCustomUserAgent + } + + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" + + return "Sora/\(version) (Build \(buildNumber))" + } + + static func baseReferer(for domain: String) -> String { + "https://\(domain)/" + } + + static func request( + url: URL, + referer: String, + userAgent: String?, + accept: String? = nil + ) -> URLRequest { + var request = URLRequest(url: url) + + request.setValue(referer, forHTTPHeaderField: "Referer") + + if let userAgent { + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + } + + if let accept { + request.setValue(accept, forHTTPHeaderField: "Accept") + } + + return request + } + + static func request( + url: URL, + domain: String, + sendUserAgent: Bool, + customUserAgent: String, + accept: String? = nil + ) -> URLRequest { + request( + url: url, + referer: baseReferer(for: domain), + userAgent: resolvedUserAgent( + sendUserAgent: sendUserAgent, + customUserAgent: customUserAgent + ), + accept: accept + ) + } +} diff --git a/Sora/Data/Booru/Post/BooruPost.swift b/Sora/Data/Booru/Post/BooruPost.swift index 458faa6..fb8ab08 100644 --- a/Sora/Data/Booru/Post/BooruPost.swift +++ b/Sora/Data/Booru/Post/BooruPost.swift @@ -1,6 +1,6 @@ import Foundation -struct BooruPost: Identifiable, Hashable { +struct BooruPost: Identifiable, Hashable, Sendable { let id: String let height: Int let score: String diff --git a/Sora/Data/Booru/Tag/DanbooruTagParser.swift b/Sora/Data/Booru/Tag/DanbooruTagParser.swift new file mode 100644 index 0000000..9c48e11 --- /dev/null +++ b/Sora/Data/Booru/Tag/DanbooruTagParser.swift @@ -0,0 +1,59 @@ +import Foundation + +nonisolated class DanbooruTagParser { + private let data: Data + + init(data: Data) { + self.data = data + } + + func parse() -> [BooruTag] { + do { + guard let decodedTags = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { + debugPrint("DanbooruTagParser.parse: failed to decode top-level tag array.") + + return [] + } + + var parsedTags: [BooruTag] = [] + var droppedRecordCount = 0 + + parsedTags.reserveCapacity(decodedTags.count) + + for tag in decodedTags { + guard let id = tag["id"] as? Int, + let name = tag["name"] as? String, + let postCount = tag["post_count"] as? Int, + let category = tag["category"] as? Int + else { + droppedRecordCount += 1 + + continue + } + + parsedTags.append( + BooruTag( + id: String(id), + name: name, + count: postCount, + type: category, + ambiguous: false + ) + ) + } + + if droppedRecordCount > 0 { + debugPrint( + "DanbooruTagParser.parse: dropped \(droppedRecordCount) malformed records while decoding tags." + ) + } + + return parsedTags + } catch { + debugPrint("DanbooruTagParser.parse: failed to decode response: \(error)") + + return [] + } + } +} diff --git a/Sora/Data/Booru/Tag/GelbooruAutocompleteTagParser.swift b/Sora/Data/Booru/Tag/GelbooruAutocompleteTagParser.swift new file mode 100644 index 0000000..fb57437 --- /dev/null +++ b/Sora/Data/Booru/Tag/GelbooruAutocompleteTagParser.swift @@ -0,0 +1,84 @@ +import Foundation + +nonisolated class GelbooruAutocompleteTagParser { + private let data: Data + + init(data: Data) { + self.data = data + } + + func parse() -> [BooruTag] { + do { + guard let decodedTags = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { + debugPrint("GelbooruAutocompleteTagParser.parse: failed to decode top-level tag array.") + + return [] + } + + var parsedTags: [BooruTag] = [] + var droppedRecordCount = 0 + + parsedTags.reserveCapacity(decodedTags.count) + + for tag in decodedTags { + guard let name = tag["value"] as? String, !name.isEmpty else { + droppedRecordCount += 1 + + continue + } + + let count: Int + + if let postCount = tag["post_count"] as? String { + count = Int(postCount) ?? 0 + } else if let postCount = tag["post_count"] as? Int { + count = postCount + } else { + count = 0 + } + + parsedTags.append( + BooruTag( + id: name, + name: name, + count: count, + type: tagType(for: tag["category"] as? String), + ambiguous: false + ) + ) + } + + if droppedRecordCount > 0 { + debugPrint( + "GelbooruAutocompleteTagParser.parse: dropped \(droppedRecordCount) malformed records while decoding tags." + ) + } + + return parsedTags + } catch { + debugPrint("GelbooruAutocompleteTagParser.parse: failed to decode response: \(error)") + + return [] + } + } + + private func tagType(for category: String?) -> Int { + switch category?.lowercased() { + case "artist": + 1 + + case "copyright": + 3 + + case "character": + 4 + + case "metadata", "meta": + 5 + + default: + 0 + } + } +} |