summaryrefslogtreecommitdiff
path: root/Sora/Data/Booru
diff options
context:
space:
mode:
Diffstat (limited to 'Sora/Data/Booru')
-rw-r--r--Sora/Data/Booru/BooruManager.swift105
-rw-r--r--Sora/Data/Booru/BooruNetworkImageLoader.swift63
-rw-r--r--Sora/Data/Booru/BooruRequestConfiguration.swift66
-rw-r--r--Sora/Data/Booru/Post/BooruPost.swift2
-rw-r--r--Sora/Data/Booru/Tag/DanbooruTagParser.swift59
-rw-r--r--Sora/Data/Booru/Tag/GelbooruAutocompleteTagParser.swift84
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
+ }
+ }
+}