summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Sora/Data/Booru/BooruManager.swift152
-rw-r--r--Sora/Data/Booru/Post/BooruPostXMLParser.swift63
-rw-r--r--Sora/Data/Danbooru/DanbooruPostParser.swift26
-rw-r--r--Sora/Data/ImageCacheManager.swift28
-rw-r--r--Sora/Data/Settings/SettingsManager.swift20
-rw-r--r--Sora/Views/Post/Details/PostDetailsImageView.swift2
6 files changed, 228 insertions, 63 deletions
diff --git a/Sora/Data/Booru/BooruManager.swift b/Sora/Data/Booru/BooruManager.swift
index f8aa9d0..db4d8f9 100644
--- a/Sora/Data/Booru/BooruManager.swift
+++ b/Sora/Data/Booru/BooruManager.swift
@@ -1,3 +1,5 @@
+// swiftlint:disable file_length
+
import Alamofire
import SwiftUI
@@ -27,6 +29,8 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng
private let userAgent: String
private var urlCache: [String: URL] = [:]
private var lastPostCount = 0
+ private var xmlParserPool: [BooruPostXMLParser] = []
+ private let parserPoolLock = NSLock()
// MARK: - Computed Properties
var tags: [String] {
@@ -75,8 +79,7 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng
let pageValue = flavor == .gelbooru ? page - 1 : page
guard let url = urlForPosts(page: pageValue, limit: limit, tags: tags) else { return }
-
- let cacheKey = "\(url.absoluteString)_\(replace)" as NSString // swiftlint:disable:this legacy_objc_type
+ let cacheKey = "\(url.absoluteString.hashValue)_\(replace)" as NSString // swiftlint:disable:this legacy_objc_type
if let cachedEntry = pageCache.object(forKey: cacheKey),
!cachedEntry.isExpired
@@ -243,7 +246,7 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng
// MARK: - Private Methods
func urlForPosts(page: Int, limit: Int, tags: [String]) -> URL? {
let tagString = tags.joined(separator: "+")
- let cacheKey = "posts_\(page)_\(limit)_\(tagString)"
+ let cacheKey = "posts_\(page)_\(limit)_\(tagString.hashValue)"
if let cachedURL = urlCache[cacheKey] {
return cachedURL
@@ -253,23 +256,56 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng
switch flavor {
case .danbooru:
- url = URL(string: "https://\(domain)/posts.json?page=\(page)&tags=\(tagString)")
+ var components = URLComponents()
+
+ components.scheme = "https"
+ components.host = domain
+ components.path = "/posts.json"
+ components.queryItems = [
+ URLQueryItem(name: "page", value: String(page)),
+ URLQueryItem(name: "tags", value: tagString),
+ ]
+ url = components.url
case .moebooru:
- url = URL(string: "https://\(domain)/post.xml?page=\(page)&limit=\(limit)&tags=\(tagString)")
+ var components = URLComponents()
+
+ components.scheme = "https"
+ components.host = domain
+ components.path = "/post.xml"
+ components.queryItems = [
+ URLQueryItem(name: "page", value: String(page)),
+ URLQueryItem(name: "limit", value: String(limit)),
+ URLQueryItem(name: "tags", value: tagString),
+ ]
+ url = components.url
case .gelbooru:
- var urlString =
- "https://\(domain)/index.php?page=dapi&s=post&q=index&pid=\(page)&limit=\(limit)&tags=\(tagString)"
+ var components = URLComponents()
+
+ components.scheme = "https"
+ components.host = domain
+ components.path = "/index.php"
+
+ var queryItems = [
+ URLQueryItem(name: "page", value: "dapi"),
+ URLQueryItem(name: "s", value: "post"),
+ URLQueryItem(name: "q", value: "index"),
+ URLQueryItem(name: "pid", value: String(page)),
+ URLQueryItem(name: "limit", value: String(limit)),
+ URLQueryItem(name: "tags", value: tagString),
+ ]
if let validCredentials = credentials,
!validCredentials.apiKey.isEmpty,
validCredentials.userID != 0
{
- urlString += "&api_key=\(validCredentials.apiKey)&user_id=\(validCredentials.userID)"
+ queryItems.append(URLQueryItem(name: "api_key", value: validCredentials.apiKey))
+ queryItems.append(URLQueryItem(name: "user_id", value: String(validCredentials.userID)))
}
- url = URL(string: urlString)
+ components.queryItems = queryItems
+ url = components.url
}
if let constructedURL = url {
@@ -282,13 +318,33 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng
private func urlForTags(limit: Int, order: String = "count") -> URL? {
switch flavor {
case .moebooru:
- return URL(string: "https://\(domain)/tag.xml?limit=\(limit)&order=\(order)")
+ var components = URLComponents()
+
+ components.scheme = "https"
+ components.host = domain
+ components.path = "/tag.xml"
+ components.queryItems = [
+ URLQueryItem(name: "limit", value: String(limit)),
+ URLQueryItem(name: "order", value: order),
+ ]
+
+ return components.url
case .gelbooru:
- return URL(
- string:
- "https://\(domain)/index.php?page=dapi&s=tag&q=index&limit=\(limit)&orderby=\(order)"
- )
+ var components = URLComponents()
+
+ components.scheme = "https"
+ components.host = domain
+ components.path = "/index.php"
+ components.queryItems = [
+ URLQueryItem(name: "page", value: "dapi"),
+ URLQueryItem(name: "s", value: "tag"),
+ URLQueryItem(name: "q", value: "index"),
+ URLQueryItem(name: "limit", value: String(limit)),
+ URLQueryItem(name: "orderby", value: order),
+ ]
+
+ return components.url
case .danbooru:
return nil
@@ -298,20 +354,44 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng
private func urlForTagsSearch(name: String) -> URL? {
switch flavor {
case .moebooru:
- return URL(string: "https://\(domain)/tag.xml?name_pattern=\(name)&order=count")
+ var components = URLComponents()
+
+ components.scheme = "https"
+ components.host = domain
+ components.path = "/tag.xml"
+ components.queryItems = [
+ URLQueryItem(name: "name_pattern", value: name),
+ URLQueryItem(name: "order", value: "count"),
+ ]
+
+ return components.url
case .gelbooru:
- var urlString =
- "https://\(domain)/index.php?page=dapi&s=tag&q=index&name_pattern=%\(name)%&orderby=count"
+ var components = URLComponents()
+
+ components.scheme = "https"
+ 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
{
- urlString += "&api_key=\(validCredentials.apiKey)&user_id=\(validCredentials.userID)"
+ queryItems.append(URLQueryItem(name: "api_key", value: validCredentials.apiKey))
+ queryItems.append(URLQueryItem(name: "user_id", value: String(validCredentials.userID)))
}
- return URL(string: urlString)
+ components.queryItems = queryItems
+
+ return components.url
case .danbooru:
return nil
@@ -327,15 +407,35 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng
flavor == .danbooru
? DanbooruPostParser(data: data).parse()
: BooruPostXMLParser(data: data, provider: provider).parse()
- var uniquePosts: [BooruPost] = []
- var seenIDs: Set<String> = []
+ var uniquePosts: [String: BooruPost] = [:]
+
+ for post in parsedPosts {
+ uniquePosts[post.id] = post
+ }
+
+ return Array(uniquePosts.values)
+ }
+
+ private func getXMLParser(for provider: BooruProvider) -> BooruPostXMLParser {
+ parserPoolLock.lock()
- for post in parsedPosts where !seenIDs.contains(post.id) {
- uniquePosts.append(post)
- seenIDs.insert(post.id)
+ defer { parserPoolLock.unlock() }
+
+ if let parser = xmlParserPool.popLast() {
+ return parser
}
- return uniquePosts
+ return BooruPostXMLParser(data: Data(), provider: provider)
+ }
+
+ private func returnXMLParser(_ parser: BooruPostXMLParser) {
+ parserPoolLock.lock()
+
+ defer { parserPoolLock.unlock() }
+
+ if xmlParserPool.count < 3 {
+ xmlParserPool.append(parser)
+ }
}
private func updatePosts(_ newPosts: [BooruPost], replace: Bool) {
@@ -380,7 +480,7 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng
}
// MARK: - Deinitialisation
- deinit {
+ nonisolated deinit {
currentTask?.cancel()
urlCache.removeAll()
}
diff --git a/Sora/Data/Booru/Post/BooruPostXMLParser.swift b/Sora/Data/Booru/Post/BooruPostXMLParser.swift
index 89fceb7..8b6260e 100644
--- a/Sora/Data/Booru/Post/BooruPostXMLParser.swift
+++ b/Sora/Data/Booru/Post/BooruPostXMLParser.swift
@@ -18,6 +18,19 @@ class BooruPostXMLParser: NSObject, XMLParserDelegate {
parser.delegate = self
}
+ func reset(with data: Data) {
+ posts.removeAll()
+
+ currentPost = nil
+
+ currentPostData.removeAll()
+
+ currentElementName = nil
+ currentText = ""
+ parser = XMLParser(data: data)
+ parser.delegate = self
+ }
+
func parse() -> [BooruPost] {
parser.parse()
@@ -26,30 +39,41 @@ class BooruPostXMLParser: NSObject, XMLParserDelegate {
private func makePost(from dict: [String: String]) -> BooruPost? {
guard let id = dict["id"],
- let heightStr = dict["height"], let height = Int(heightStr),
let score = dict["score"],
- let fileUrlString = dict["file_url"], let fileUrl = URL(string: fileUrlString),
let parentId = dict["parent_id"],
- let sampleUrlString = dict["sample_url"], let sampleUrl = URL(string: sampleUrlString),
- let sampleWidthString = dict["sample_width"], let sampleWidth = Int(sampleWidthString),
- let sampleHeightString = dict["sample_height"], let sampleHeight = Int(sampleHeightString),
- let previewUrlString = dict["preview_url"], let previewUrl = URL(string: previewUrlString),
let rating = dict["rating"],
let tagsString = dict["tags"],
- let widthString = dict["width"], let width = Int(widthString),
let change = dict["change"],
let md5 = dict["md5"],
let creatorId = dict["creator_id"],
let createdAtString = dict["created_at"],
- let createdAt = parseCreatedAt(createdAtString),
let status = dict["status"],
- let source = dict["source"],
- let previewWidthString = dict["preview_width"], let previewWidth = Int(previewWidthString),
- let previewHeightString = dict["preview_height"], let previewHeight = Int(previewHeightString)
+ let source = dict["source"]
else {
return nil
}
+ guard let height = Int(dict["height"] ?? ""),
+ let sampleWidth = Int(dict["sample_width"] ?? ""),
+ let sampleHeight = Int(dict["sample_height"] ?? ""),
+ let width = Int(dict["width"] ?? ""),
+ let previewWidth = Int(dict["preview_width"] ?? ""),
+ let previewHeight = Int(dict["preview_height"] ?? "")
+ else {
+ return nil
+ }
+
+ guard let fileUrl = URL(string: dict["file_url"] ?? ""),
+ let sampleUrl = URL(string: dict["sample_url"] ?? ""),
+ let previewUrl = URL(string: dict["preview_url"] ?? ""),
+ let createdAt = parseCreatedAt(createdAtString)
+ else {
+ return nil
+ }
+
+ let tags = tagsString.components(separatedBy: .whitespacesAndNewlines)
+ .filter { !$0.isEmpty }
+
return BooruPost(
id: id,
height: height,
@@ -61,8 +85,7 @@ class BooruPostXMLParser: NSObject, XMLParserDelegate {
sampleHeight: sampleHeight,
previewURL: previewUrl,
rating: BooruRating(rating),
- tags: tagsString.components(separatedBy: CharacterSet.whitespacesAndNewlines)
- .filter { !$0.isEmpty },
+ tags: tags,
width: width,
change: change,
md5: md5,
@@ -141,13 +164,17 @@ class BooruPostXMLParser: NSObject, XMLParserDelegate {
}
#endif
- func parseCreatedAt(_ input: String) -> Date? {
- let dateFormatter = DateFormatter()
+ private static let dateFormatter: DateFormatter = {
+ let formatter = DateFormatter()
- dateFormatter.dateFormat = "EEE MMM dd HH:mm:ss Z yyyy"
- dateFormatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.dateFormat = "EEE MMM dd HH:mm:ss Z yyyy"
+ formatter.locale = Locale(identifier: "en_US_POSIX")
- if let date = dateFormatter.date(from: input) {
+ return formatter
+ }()
+
+ func parseCreatedAt(_ input: String) -> Date? {
+ if let date = Self.dateFormatter.date(from: input) {
return date
}
diff --git a/Sora/Data/Danbooru/DanbooruPostParser.swift b/Sora/Data/Danbooru/DanbooruPostParser.swift
index 73db0cc..f990ed5 100644
--- a/Sora/Data/Danbooru/DanbooruPostParser.swift
+++ b/Sora/Data/Danbooru/DanbooruPostParser.swift
@@ -1,6 +1,6 @@
import Foundation
-class DanbooruPostParser {
+nonisolated class DanbooruPostParser {
private let data: Data
init(data: Data) {
@@ -25,20 +25,28 @@ class DanbooruPostParser {
}
}
- private static func parseDate(_ input: String) -> Date? {
- let isoFormatter = ISO8601DateFormatter()
+ nonisolated(unsafe) private static let isoFormatter: ISO8601DateFormatter = {
+ let formatter = ISO8601DateFormatter()
+
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+
+ return formatter
+ }()
+
+ private static let alternativeFormatter: DateFormatter = {
+ let formatter = DateFormatter()
- isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ formatter.dateFormat = "EEE MMM dd HH:mm:ss Z yyyy"
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ return formatter
+ }()
+
+ private static func parseDate(_ input: String) -> Date? {
if let date = isoFormatter.date(from: input) {
return date
}
- let alternativeFormatter = DateFormatter()
-
- alternativeFormatter.dateFormat = "EEE MMM dd HH:mm:ss Z yyyy"
- alternativeFormatter.locale = Locale(identifier: "en_US_POSIX")
-
if let date = alternativeFormatter.date(from: input) {
return date
}
diff --git a/Sora/Data/ImageCacheManager.swift b/Sora/Data/ImageCacheManager.swift
index cd8b4e2..8a14198 100644
--- a/Sora/Data/ImageCacheManager.swift
+++ b/Sora/Data/ImageCacheManager.swift
@@ -1,5 +1,5 @@
import Combine
-import SwiftUI
+@preconcurrency import SwiftUI
@MainActor
final class ImageCacheManager {
@@ -16,11 +16,24 @@ final class ImageCacheManager {
private var cancellables = Set<AnyCancellable>()
private let downloadQueue = OperationQueue()
private var preloadingURLs = Set<URL>()
+ private var memoryWarningObserver: NSObjectProtocol?
// MARK: - Initialisation
private init() {
downloadQueue.maxConcurrentOperationCount = 5
downloadQueue.qualityOfService = .utility
+
+ #if os(iOS)
+ memoryWarningObserver = NotificationCenter.default.addObserver(
+ forName: UIApplication.didReceiveMemoryWarningNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor in
+ self?.handleMemoryPressure()
+ }
+ }
+ #endif
}
// MARK: - Public Methods
@@ -60,4 +73,17 @@ final class ImageCacheManager {
func getCachedResponse(for url: URL) -> CachedURLResponse? {
cache.cachedResponse(for: URLRequest(url: url))
}
+
+ private func handleMemoryPressure() {
+ cache.removeAllCachedResponses()
+ downloadQueue.cancelAllOperations()
+ cancellables.removeAll()
+ preloadingURLs.removeAll()
+ }
+
+ deinit {
+ if let observer = memoryWarningObserver {
+ NotificationCenter.default.removeObserver(observer)
+ }
+ }
}
diff --git a/Sora/Data/Settings/SettingsManager.swift b/Sora/Data/Settings/SettingsManager.swift
index 30fe587..3fba04e 100644
--- a/Sora/Data/Settings/SettingsManager.swift
+++ b/Sora/Data/Settings/SettingsManager.swift
@@ -1,6 +1,6 @@
// swiftlint:disable file_length
-import SwiftUI
+@preconcurrency import SwiftUI
@MainActor
class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_length
@@ -35,7 +35,7 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
@AppStorage("uniformThumbnailGrid")
private var _uniformThumbnailGrid: Bool = false
- @preconcurrency private var syncObservation: (any NSObjectProtocol & Sendable)?
+ private var syncObservation: NSObjectProtocol?
#if os(macOS)
@AppStorage("saveTagsToFile")
@@ -448,15 +448,16 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
guard let encoded = Self.encode(sortedValues) else {
localData.wrappedValue = Data()
-
return
}
- localData.wrappedValue = encoded
+ if localData.wrappedValue != encoded {
+ localData.wrappedValue = encoded
- if enableSync {
- NSUbiquitousKeyValueStore.default.set(encoded, forKey: key)
- NSUbiquitousKeyValueStore.default.synchronize()
+ if enableSync {
+ NSUbiquitousKeyValueStore.default.set(encoded, forKey: key)
+ NSUbiquitousKeyValueStore.default.synchronize()
+ }
}
}
@@ -472,9 +473,10 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
}
}
+ // swiftlint:disable:next async_without_await
private func performBatchedSync(for keys: Set<SettingsSyncKey>) async {
for key in keys {
- await triggerSyncIfNeeded(for: key)
+ triggerSyncIfNeeded(for: key)
}
}
@@ -918,7 +920,7 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
#endif
// MARK: - Deinitialisation
- deinit {
+ nonisolated deinit {
if let observation = syncObservation {
NotificationCenter.default.removeObserver(observation)
}
diff --git a/Sora/Views/Post/Details/PostDetailsImageView.swift b/Sora/Views/Post/Details/PostDetailsImageView.swift
index 9ec2079..f9e49db 100644
--- a/Sora/Views/Post/Details/PostDetailsImageView.swift
+++ b/Sora/Views/Post/Details/PostDetailsImageView.swift
@@ -183,12 +183,14 @@ struct PostDetailsImageView<Placeholder: View>: View {
}
#if os(macOS)
+ @preconcurrency
private func saveImageToPicturesFolder() {
guard let url = self.url else { return }
let provider = manager.provider
let detailViewQuality = settings.detailViewQuality
let saveTagsToFile = settings.saveTagsToFile
+ let post = self.post
URLSession.shared.dataTask(with: url) { data, _, _ in
guard let data, let post else { return }