summaryrefslogtreecommitdiff
path: root/Sora/Data
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-23 09:14:04 -0800
committerFuwn <[email protected]>2026-02-23 13:33:42 -0800
commit0f53ed0fc04952fb2fb43518be11c545da535f5b (patch)
tree8b16594291ff7a5e7dd289193eaa1998030edefe /Sora/Data
parent(no commit message) (diff)
downloadsora-testing-0f53ed0fc04952fb2fb43518be11c545da535f5b.tar.xz
sora-testing-0f53ed0fc04952fb2fb43518be11c545da535f5b.zip
fix: harden danbooru decoding and pagination flow
Diffstat (limited to 'Sora/Data')
-rw-r--r--Sora/Data/Booru/BooruManager.swift53
-rw-r--r--Sora/Data/Danbooru/DanbooruPost.swift34
-rw-r--r--Sora/Data/Danbooru/DanbooruPostParser.swift24
3 files changed, 93 insertions, 18 deletions
diff --git a/Sora/Data/Booru/BooruManager.swift b/Sora/Data/Booru/BooruManager.swift
index 2459f2d..5753089 100644
--- a/Sora/Data/Booru/BooruManager.swift
+++ b/Sora/Data/Booru/BooruManager.swift
@@ -114,12 +114,14 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng
private func fetchPostsWithRetry(url: URL) async -> [BooruPost] {
let maxAttempts = 4
+ var requestURL = url
+ var retriedDanbooruWithoutCredentials = false
for attempt in 1...maxAttempts {
if Task.isCancelled { return [] }
do {
- let data = try await requestURL(url)
+ let data = try await self.requestURL(requestURL)
if Task.isCancelled { return [] }
@@ -149,6 +151,23 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng
}
} catch {
if !Task.isCancelled {
+ if flavor == .danbooru,
+ !retriedDanbooruWithoutCredentials,
+ (error as? AFError)?.responseCode == 401,
+ let unauthenticatedURL = Self.removingDanbooruCredentials(from: requestURL),
+ unauthenticatedURL != requestURL
+ {
+ retriedDanbooruWithoutCredentials = true
+ requestURL = unauthenticatedURL
+
+ debugPrint(
+ "BooruManager.fetchPosts(\(attempt)): unauthorized credentials for \(domain),"
+ + " retrying without API credentials."
+ )
+
+ continue
+ }
+
self.error = error
debugPrint("BooruManager.fetchPosts(\(attempt)): \(error)")
@@ -161,6 +180,21 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng
return []
}
+ private static func removingDanbooruCredentials(from url: URL) -> URL? {
+ guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
+ return nil
+ }
+ guard let existingQueryItems = components.queryItems else {
+ return components.url
+ }
+
+ components.queryItems = existingQueryItems.filter { queryItem in
+ queryItem.name != "login" && queryItem.name != "api_key"
+ }
+
+ return components.url
+ }
+
func clearCachedPages() {
pageCache.removeAllObjects()
urlCache.removeAll()
@@ -278,9 +312,20 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng
}
// MARK: - Private Methods
+ private func danbooruPageToken(for page: Int) -> String {
+ guard page > 1 else { return "1" }
+
+ guard let minimumPostID = posts.lazy.compactMap({ Int($0.id) }).min() else {
+ return String(page)
+ }
+
+ return "b\(minimumPostID)"
+ }
+
func url(forPosts page: Int, limit: Int, tags: [String]) -> URL? {
let tagString = tags.joined(separator: "+")
- let cacheKey = "posts_\(page)_\(limit)_\(tagString.hashValue)"
+ let pageCacheValue = flavor == .danbooru ? danbooruPageToken(for: page) : String(page)
+ let cacheKey = "posts_\(pageCacheValue)_\(limit)_\(tagString.hashValue)"
if let cachedURL = urlCache[cacheKey] {
return cachedURL
@@ -297,7 +342,8 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng
components.path = "/posts.json"
var queryItems = [
- URLQueryItem(name: "page", value: String(page)),
+ URLQueryItem(name: "page", value: danbooruPageToken(for: page)),
+ URLQueryItem(name: "limit", value: String(limit)),
URLQueryItem(name: "tags", value: tagString),
]
@@ -493,6 +539,7 @@ 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])
+ .validate(statusCode: 200..<300)
.serializingData()
.value
}
diff --git a/Sora/Data/Danbooru/DanbooruPost.swift b/Sora/Data/Danbooru/DanbooruPost.swift
index 216816e..c619fce 100644
--- a/Sora/Data/Danbooru/DanbooruPost.swift
+++ b/Sora/Data/Danbooru/DanbooruPost.swift
@@ -6,16 +6,16 @@ struct DanbooruPost: Decodable {
let uploaderId: Int
let score: Int
let source: String
- let md5: String
+ let md5: String?
let rating: String
let imageWidth: Int
let imageHeight: Int
let tagString: String
let parentId: Int?
- let mediaAsset: DanbooruMediaAsset
- let fileURL: String
- let largeFileURL: String
- let previewFileURL: String
+ let mediaAsset: DanbooruMediaAsset?
+ let fileURL: String?
+ let largeFileURL: String?
+ let previewFileURL: String?
let isDeleted: Bool
enum CodingKeys: String, CodingKey {
@@ -38,24 +38,32 @@ struct DanbooruPost: Decodable {
}
func toBooruPost() -> BooruPost? {
- guard let fileURL = URL(string: fileURL),
- let sampleURL = URL(string: largeFileURL),
- let previewURL = URL(string: previewFileURL)
+ guard let fileURLString = fileURL,
+ let fileURL = URL(string: fileURLString)
else {
return nil
}
- let previewVariant = mediaAsset.variants.first { $0.type == "180x180" }
+ let sampleURLString = largeFileURL ?? fileURLString
+ let previewURLString = previewFileURL ?? largeFileURL ?? fileURLString
+
+ guard let sampleURL = URL(string: sampleURLString),
+ let previewURL = URL(string: previewURLString)
+ else {
+ return nil
+ }
+
+ let previewVariant = mediaAsset?.variants.first { $0.type == "180x180" }
let sampleVariant =
- mediaAsset.variants.first { $0.type == "sample" }
- ?? mediaAsset.variants.first { $0.type == "original" }
+ mediaAsset?.variants.first { $0.type == "sample" }
+ ?? mediaAsset?.variants.first { $0.type == "original" }
return BooruPost(
id: String(id),
height: imageHeight,
score: String(score),
fileURL: fileURL,
- parentID: parentId != nil ? String(parentId!) : "",
+ parentID: parentId.map(String.init) ?? "",
sampleURL: sampleURL,
sampleWidth: sampleVariant?.width ?? imageWidth,
sampleHeight: sampleVariant?.height ?? imageHeight,
@@ -64,7 +72,7 @@ struct DanbooruPost: Decodable {
tags: tagString.components(separatedBy: " ").filter { !$0.isEmpty },
width: imageWidth,
change: nil,
- md5: md5,
+ md5: md5 ?? "",
creatorID: String(uploaderId),
authorID: nil,
createdAt: createdAt,
diff --git a/Sora/Data/Danbooru/DanbooruPostParser.swift b/Sora/Data/Danbooru/DanbooruPostParser.swift
index f990ed5..1e5cdc4 100644
--- a/Sora/Data/Danbooru/DanbooruPostParser.swift
+++ b/Sora/Data/Danbooru/DanbooruPostParser.swift
@@ -1,6 +1,16 @@
import Foundation
nonisolated class DanbooruPostParser {
+ private struct FailableDecodable<Value: Decodable>: Decodable {
+ let value: Value?
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+
+ value = try? container.decode(Value.self)
+ }
+ }
+
private let data: Data
init(data: Data) {
@@ -17,10 +27,20 @@ nonisolated class DanbooruPostParser {
}
do {
- return try decoder.decode([DanbooruPost].self, from: data).compactMap { post in
- post.toBooruPost()
+ let decodedPosts = try decoder.decode([FailableDecodable<DanbooruPost>].self, from: data)
+ let validPosts = decodedPosts.compactMap(\.value)
+ let droppedRecordCount = decodedPosts.count - validPosts.count
+
+ if droppedRecordCount > 0 {
+ debugPrint(
+ "DanbooruPostParser.parse: dropped \(droppedRecordCount) malformed records while decoding page."
+ )
}
+
+ return validPosts.compactMap { post in post.toBooruPost() }
} catch {
+ debugPrint("DanbooruPostParser.parse: failed to decode response: \(error)")
+
return []
}
}