diff options
| -rw-r--r-- | Sora/Data/Booru/BooruManager.swift | 152 | ||||
| -rw-r--r-- | Sora/Data/Booru/Post/BooruPostXMLParser.swift | 63 | ||||
| -rw-r--r-- | Sora/Data/Danbooru/DanbooruPostParser.swift | 26 | ||||
| -rw-r--r-- | Sora/Data/ImageCacheManager.swift | 28 | ||||
| -rw-r--r-- | Sora/Data/Settings/SettingsManager.swift | 20 | ||||
| -rw-r--r-- | Sora/Views/Post/Details/PostDetailsImageView.swift | 2 |
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 } |