// swiftlint:disable file_length import Alamofire import SwiftUI @MainActor class BooruManager: ObservableObject { // swiftlint:disable:this type_body_length // MARK: - Published Properties @Published var posts: [BooruPost] = [] @Published var isLoading = false @Published var currentPage = 1 @Published var searchText = "" @Published var endOfData = false @Published var selectedPost: BooruPost? @Published var flavor: BooruProviderFlavor @Published var domain: String @Published private(set) var postIndexMap: [String: Int] = [:] @Published var provider: BooruProvider @Published var historyIndex: Int = -1 @Published var searchHistory: [BooruSearchQuery] = [] @Published var isNavigatingHistory = false @Published var error: Error? // MARK: - Private Properties private var currentTask: Task? private let pageCache = NSCache() // swiftlint:disable:this legacy_objc_type private let cacheDuration: TimeInterval private let credentials: BooruProviderCredentials? 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] { searchText.isEmpty ? [] : searchText .components(separatedBy: .whitespaces) .filter { !$0.isEmpty } } var canGoBackInHistory: Bool { historyIndex > 0 } var canGoForwardInHistory: Bool { historyIndex < searchHistory.count - 1 } // MARK: - Initialisation init( _ provider: BooruProvider, credentials: BooruProviderCredentials? = nil, cacheDuration: TimeInterval = BooruPageCacheEntry.defaultExpiration ) { self.provider = provider self.flavor = BooruProviderFlavor(provider: provider) self.domain = provider.domain self.credentials = credentials self.cacheDuration = cacheDuration 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))" let rootQuery = BooruSearchQuery( provider: provider, tags: [] ) searchHistory.append(rootQuery) historyIndex = 0 } // MARK: - Public Methods func fetchPosts(page: Int = 1, limit: Int = 100, tags: [String] = [], replace: Bool = false) async { guard !isLoading else { return } let pageValue = flavor == .gelbooru ? page - 1 : page guard let url = urlForPosts(page: pageValue, limit: limit, tags: tags) else { return } let cacheKey = "\(url.absoluteString.hashValue)_\(replace)" as NSString // swiftlint:disable:this legacy_objc_type if let cachedEntry = pageCache.object(forKey: cacheKey), !cachedEntry.isExpired { isLoading = true defer { isLoading = false } updatePosts(cachedEntry.posts, replace: replace) return } isLoading = true defer { isLoading = false } var finalPosts: [BooruPost] = [] let maxAttempts = 4 for attempt in 1...maxAttempts { do { let data = try await requestURL(url) let flavor = self.flavor let provider = self.provider let newPosts = await withCheckedContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { let parsedPosts = Self.parsePosts( from: data, flavor: flavor, provider: provider ) .sorted { $0.createdAt > $1.createdAt } continuation.resume(returning: parsedPosts) } } if !newPosts.isEmpty { finalPosts = newPosts break } if attempt < maxAttempts { try await Task.sleep(for: .seconds(0.5 * Double(attempt))) } } catch { self.error = error debugPrint("BooruManager.fetchPosts(\(attempt)): \(error)") break } } let cacheEntry = BooruPageCacheEntry( posts: finalPosts, timestamp: Date(), expiration: cacheDuration ) pageCache.setObject(cacheEntry, forKey: cacheKey, cost: finalPosts.count) withAnimation(nil) { updatePosts(finalPosts, replace: replace) } } func clearCachedPages() { pageCache.removeAllObjects() urlCache.removeAll() } func performSearch(settings: SettingsManager? = nil) async { let inputTags = tags guard !inputTags.isEmpty else { return } if searchHistory.last?.tags == inputTags { return } if historyIndex < searchHistory.count - 1 { searchHistory = Array(searchHistory[0...historyIndex]) } let query = BooruSearchQuery( provider: settings?.preferredBooru ?? provider, tags: inputTags ) searchHistory.append(query) historyIndex = searchHistory.count - 1 settings?.appendToSearchHistory(query) searchText = inputTags.joined(separator: " ") await fetchPosts(page: 1, tags: inputTags, replace: true) } func loadNextPage() async { guard !isLoading else { return } currentPage += 1 await fetchPosts(page: currentPage, tags: tags) if historyIndex >= 0 && historyIndex < searchHistory.count { var currentQuery = searchHistory[historyIndex] currentQuery.page = currentPage searchHistory[historyIndex] = currentQuery } } func goBackInHistory() { guard canGoBackInHistory else { return } isNavigatingHistory = true historyIndex -= 1 let previousQuery = searchHistory[historyIndex] searchText = previousQuery.tags.joined(separator: " ") cancelCurrentTask() currentTask = Task { isNavigatingHistory = false } } func goForwardInHistory() { guard canGoForwardInHistory else { return } historyIndex += 1 isNavigatingHistory = true let nextQuery = searchHistory[historyIndex] searchText = nextQuery.tags.joined(separator: " ") cancelCurrentTask() currentTask = Task { isNavigatingHistory = false } } func searchTags(name: String) async -> [BooruTag] { guard let url = urlForTagsSearch(name: name) else { return [] } do { let data = try await requestURL(url) guard !Task.isCancelled else { return [] } return BooruTagXMLParser(data: data).parse().sorted { $0.count > $1.count } } catch { if (error as? URLError)?.code != .cancelled { debugPrint("BooruManager.searchTags: \(error)") } return [] } } // MARK: - Private Methods func urlForPosts(page: Int, limit: Int, tags: [String]) -> URL? { let tagString = tags.joined(separator: "+") let cacheKey = "posts_\(page)_\(limit)_\(tagString.hashValue)" if let cachedURL = urlCache[cacheKey] { return cachedURL } let url: URL? switch flavor { case .danbooru: 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: 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 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 { queryItems.append(URLQueryItem(name: "api_key", value: validCredentials.apiKey)) queryItems.append(URLQueryItem(name: "user_id", value: String(validCredentials.userID))) } components.queryItems = queryItems url = components.url } if let constructedURL = url { urlCache[cacheKey] = constructedURL } return url } private func urlForTags(limit: Int, order: String = "count") -> URL? { switch flavor { case .moebooru: 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: 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 } } private func urlForTagsSearch(name: String) -> URL? { switch flavor { case .moebooru: 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 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 { queryItems.append(URLQueryItem(name: "api_key", value: validCredentials.apiKey)) queryItems.append(URLQueryItem(name: "user_id", value: String(validCredentials.userID))) } components.queryItems = queryItems return components.url case .danbooru: return nil } } nonisolated static func parsePosts( from data: Data, flavor: BooruProviderFlavor, provider: BooruProvider ) -> [BooruPost] { let parsedPosts = flavor == .danbooru ? DanbooruPostParser(data: data).parse() : BooruPostXMLParser(data: data, provider: provider).parse() 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() defer { parserPoolLock.unlock() } if let parser = xmlParserPool.popLast() { return parser } 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) { if replace { posts = [] currentPage = 1 postIndexMap.removeAll() lastPostCount = 0 } endOfData = newPosts.isEmpty guard !endOfData else { return } withTransaction(Transaction(animation: nil)) { let oldCount = self.posts.count self.posts += newPosts if newPosts.count > 10 || self.posts.count - lastPostCount > 50 { for (offset, post) in newPosts.enumerated() { self.postIndexMap[post.id] = oldCount + offset } lastPostCount = self.posts.count } } } func requestURL(_ url: URL) async throws -> Data { try await AF.request(url, headers: ["User-Agent": userAgent]) .serializingData() .value } private func cancelCurrentTask() { currentTask?.cancel() currentTask = nil } // MARK: - Deinitialisation nonisolated deinit { currentTask?.cancel() urlCache.removeAll() } }