// 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 referer: String private let userAgent: String? private let showHeldMoebooruPosts: Bool private var urlCache: [String: URL] = [:] private var lastPostCount = 0 private var cachedMinimumPostID: Int? // 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, sendUserAgent: Bool = true, customUserAgent: String = "", showHeldMoebooruPosts: Bool = false ) { self.provider = provider self.flavor = BooruProviderFlavor(provider: provider) self.domain = provider.domain self.credentials = credentials self.cacheDuration = cacheDuration self.showHeldMoebooruPosts = showHeldMoebooruPosts pageCache.countLimit = 50 pageCache.totalCostLimit = 50 * 1_024 * 1_024 self.referer = BooruRequestConfiguration.baseReferer(for: provider.domain) self.userAgent = BooruRequestConfiguration.resolvedUserAgent( sendUserAgent: sendUserAgent, customUserAgent: customUserAgent ) 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 = url(forPosts: 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 } let finalPosts = await fetchPostsWithRetry(url: url) if Task.isCancelled { return } let cacheEntry = BooruPageCacheEntry( posts: finalPosts, timestamp: Date(), expiration: cacheDuration ) pageCache.setObject(cacheEntry, forKey: cacheKey, cost: finalPosts.count) withAnimation(nil) { updatePosts(finalPosts, replace: replace) } } 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 self.requestURL(requestURL) if Task.isCancelled { return [] } 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 ) continuation.resume(returning: parsedPosts) } } if Task.isCancelled { return [] } if !newPosts.isEmpty { return newPosts } if attempt < maxAttempts { try await Task.sleep(for: .seconds(0.5 * Double(attempt))) } } 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)") } break } } 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() } 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] if previousQuery.provider != provider { provider = previousQuery.provider flavor = BooruProviderFlavor(provider: provider) domain = provider.domain } searchText = previousQuery.tags.joined(separator: " ") cancelCurrentTask() currentTask = Task { await fetchPosts(page: 1, tags: previousQuery.tags, replace: true) isNavigatingHistory = false } } func goForwardInHistory() { guard canGoForwardInHistory else { return } historyIndex += 1 isNavigatingHistory = true let nextQuery = searchHistory[historyIndex] if nextQuery.provider != provider { provider = nextQuery.provider flavor = BooruProviderFlavor(provider: provider) domain = provider.domain } searchText = nextQuery.tags.joined(separator: " ") cancelCurrentTask() currentTask = Task { await fetchPosts(page: 1, tags: nextQuery.tags, replace: true) isNavigatingHistory = false } } func searchTags(name: String) async -> [BooruTag] { guard let url = url(forTagsSearch: name) else { return [] } do { let data = try await requestURL(url) guard !Task.isCancelled else { return [] } let parsedTags = if flavor == .danbooru { DanbooruTagParser(data: data).parse() } else if flavor == .gelbooru, provider != .safebooru { GelbooruAutocompleteTagParser(data: data).parse() } else { BooruTagXMLParser(data: data).parse() } return parsedTags.sorted { $0.count > $1.count } } catch { if (error as? URLError)?.code != .cancelled { debugPrint("BooruManager.searchTags: \(error)") } return [] } } // MARK: - Private Methods private func moebooruTagString(for tags: [String]) -> String { let hasExplicitHoldsFilter = tags.contains { tag in tag.lowercased().hasPrefix("holds:") } if hasExplicitHoldsFilter { return tags.joined(separator: "+") } guard !showHeldMoebooruPosts else { return tags.joined(separator: "+") } return (tags + ["holds:false"]).joined(separator: "+") } private func hasExplicitSortTag(in tags: [String]) -> Bool { tags.contains { tag in tag.lowercased().hasPrefix("order:") } } private func danbooruPageToken(for page: Int, tags: [String]) -> String { guard page > 1 else { return "1" } guard !hasExplicitSortTag(in: tags) else { return String(page) } guard let minimumPostID = cachedMinimumPostID else { return String(page) } return "b\(minimumPostID)" } func url(forPosts page: Int, limit: Int, tags: [String]) -> URL? { let tagString = tags.joined(separator: "+") let pageCacheValue = flavor == .danbooru ? danbooruPageToken(for: page, tags: tags) : String(page) let cacheKey = "posts_\(pageCacheValue)_\(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" var queryItems = [ URLQueryItem(name: "page", value: danbooruPageToken(for: page, tags: tags)), URLQueryItem(name: "limit", value: String(limit)), URLQueryItem(name: "tags", value: tagString), ] if let validCredentials = credentials { let login = validCredentials.login.trimmingCharacters(in: .whitespacesAndNewlines) if !validCredentials.apiKey.isEmpty, !login.isEmpty { queryItems.append(URLQueryItem(name: "login", value: login)) queryItems.append(URLQueryItem(name: "api_key", value: validCredentials.apiKey)) } } components.queryItems = queryItems url = components.url case .moebooru: var components = URLComponents() let moebooruTags = moebooruTagString(for: tags) 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: moebooruTags), ] 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 url(forTags 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 url(forTagsSearch 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", 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" if provider == .safebooru { components.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"), ] } else { components.queryItems = [ URLQueryItem(name: "page", value: "autocomplete2"), URLQueryItem(name: "type", value: "tag_query"), URLQueryItem(name: "term", value: name), ] } return components.url case .danbooru: var components = URLComponents() components.scheme = "https" components.host = domain components.path = "/tags.json" components.queryItems = [ URLQueryItem(name: "search[name_matches]", value: "\(name)*"), URLQueryItem(name: "limit", value: "50"), ] return components.url } } 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 seenPostIDs = Set() var orderedUniquePosts: [BooruPost] = [] for post in parsedPosts where seenPostIDs.insert(post.id).inserted { orderedUniquePosts.append(post) } return orderedUniquePosts } private func updatePosts(_ newPosts: [BooruPost], replace: Bool) { if replace { posts = [] currentPage = 1 postIndexMap.removeAll() lastPostCount = 0 cachedMinimumPostID = nil } endOfData = newPosts.isEmpty guard !endOfData else { return } if let nextMinimumPostID = newPosts.lazy.compactMap({ Int($0.id) }).min() { cachedMinimumPostID = min(cachedMinimumPostID ?? nextMinimumPostID, nextMinimumPostID) } 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 { let request = BooruRequestConfiguration.request( url: url, referer: referer, userAgent: userAgent ) return try await AF.request(request) .validate(statusCode: 200..<300) .serializingData() .value } private func cancelCurrentTask() { currentTask?.cancel() currentTask = nil } // MARK: - Deinitialisation nonisolated deinit { currentTask?.cancel() urlCache.removeAll() } }