import Alamofire import SwiftUI @MainActor class BooruManager: ObservableObject { // swiftlint:disable:this type_body_length // MARK: - Published Properties @Published var posts: [BooruPost] = [] @Published var allTags: [BooruTag] = [] @Published var isLoading = false @Published var currentPage = 1 @Published var searchText = "" @Published var endOfData = false @Published var cacheSize: String? @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 tagsCacheFileURL: URL? = FileManager.default.urls( for: .cachesDirectory, in: .userDomainMask ).first? .appendingPathComponent("\(BooruProvider.safebooru.asFileNameComponent)_tags.json") private let pageCache = NSCache() // swiftlint:disable:this legacy_objc_type private let cacheDuration: TimeInterval = 300 private let credentials: BooruProviderCredentials? // 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) { self.provider = provider self.flavor = BooruProviderFlavor(provider: provider) self.domain = provider.domain self.credentials = credentials pageCache.countLimit = 50 pageCache.totalCostLimit = 50 * 1_024 * 1_024 let rootQuery = BooruSearchQuery( provider: provider, tags: [] ) searchHistory.append(rootQuery) historyIndex = 0 } // MARK: - Public Methods func initializeTags() { loadCachedTags() fetchAllTags() updateTagsCacheSize() } 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)_\(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 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.id > $1.id } continuation.resume(returning: parsedPosts) } } let cacheEntry = BooruPageCacheEntry(posts: newPosts, timestamp: Date()) pageCache.setObject(cacheEntry, forKey: cacheKey) withAnimation(nil) { updatePosts(newPosts, replace: replace) } } catch { self.error = error debugPrint("BooruManager.fetchPosts: \(error)") } isLoading = false } func clearCachedPages() { pageCache.removeAllObjects() } func performSearch(settings: SettingsManager? = nil) async { let inputTags = tags 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: " ") clearCachedPages() cancelCurrentTask() currentTask = Task { isNavigatingHistory = false } } func fetchAllTags(limit: Int = 10_000) { guard let url = urlForTags(limit: limit) else { return } Task { do { let data = try await requestURL(url) guard !Task.isCancelled else { return } allTags = BooruTagXMLParser(data: data).parse().sorted { $0.count > $1.count } saveTagsToCache() } catch { if (error as? URLError)?.code != .cancelled { debugPrint("BooruManager.fetchAllTags: \(error)") } } } } func clearCachedTags() { try? tagsCacheFileURL.map { url in try FileManager.default.removeItem(at: url) updateTagsCacheSize() } } func updateTagsCacheSize() { cacheSize = tagsCacheFileURL.flatMap { url in ByteCountFormatter.string( fromByteCount: Int64( (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int) ?? 0 ), countStyle: .file ) } } // MARK: - Private Methods private func urlForPosts(page: Int, limit: Int, tags: [String]) -> URL? { let tagString = tags.joined(separator: "+") switch flavor { case .danbooru: return URL(string: "https://\(domain)/posts.json?page=\(page)&tags=\(tagString)") case .moebooru: return URL(string: "https://\(domain)/post.xml?page=\(page)&limit=\(limit)&tags=\(tagString)") case .gelbooru: var urlString = "https://\(domain)/index.php?page=dapi&s=post&q=index&pid=\(page)&limit=\(limit)&tags=\(tagString)" if let validCredentials = credentials, !validCredentials.apiKey.isEmpty, validCredentials.userID != 0 { urlString += "&api_key=\(validCredentials.apiKey)&user_id=\(validCredentials.userID)" } return URL( string: urlString ) } } private func urlForTags(limit: Int, order: String = "count") -> URL? { switch flavor { case .moebooru: return URL(string: "https://\(domain)/tag.xml?limit=\(limit)&order=\(order)") case .gelbooru: return URL( string: "https://\(domain)/index.php?page=dapi&s=tag&q=index&limit=\(limit)&orderby=\(order)" ) case .danbooru: return nil } } nonisolated private func parsePosts( from data: Data, flavor: BooruProviderFlavor, provider: BooruProvider ) -> [BooruPost] { Array( Set( flavor == .danbooru ? DanbooruPostParser(data: data).parse() : BooruPostXMLParser(data: data, provider: provider).parse() ) ) } private func updatePosts(_ newPosts: [BooruPost], replace: Bool) { if replace { posts = [] currentPage = 1 } endOfData = newPosts.isEmpty guard !endOfData else { return } Task.detached { let batchSize = 10 for chunk in newPosts.chunked(into: batchSize) { try? await Task.sleep(nanoseconds: 30_000_000) // 30 ms await MainActor.run { withTransaction(Transaction(animation: nil)) { self.posts += chunk let startIndex = self.posts.count let indexMap = Dictionary( uniqueKeysWithValues: chunk.enumerated().map { offset, post in (post.id, startIndex + offset) } ) self.postIndexMap.merge(indexMap) { _, newIndex in newIndex } } } } } } private func saveTagsToCache() { if let url = tagsCacheFileURL { do { let data = try JSONEncoder().encode(allTags) try data.write(to: url) updateTagsCacheSize() } catch { debugPrint("BooruManager.saveTagsToCache:", error) } } } private func loadCachedTags() { guard let url = tagsCacheFileURL else { return } if let data = try? Data(contentsOf: url), let tags = try? JSONDecoder().decode([BooruTag].self, from: data) { allTags = tags updateTagsCacheSize() } } private func requestURL(_ url: URL) async throws -> Data { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" return try await AF.request( url, headers: ["User-Agent": "Sora/\(version) (Build \(buildNumber))"] ) .serializingData() .value } private func cancelCurrentTask() { currentTask?.cancel() currentTask = nil } // MARK: - Deinitialisation deinit { currentTask?.cancel() } }