import Foundation // MARK: - API Client // Communicates with the SvelteKit UI server (/api/* endpoints). // Auth is carried via the libnovel_auth cookie (HMAC-signed token). actor APIClient { static let shared = APIClient() var baseURL: URL private var authCookie: String? // raw "libnovel_auth=" header value private let session: URLSession = { let config = URLSessionConfiguration.default config.httpCookieAcceptPolicy = .always config.httpShouldSetCookies = true config.httpCookieStorage = HTTPCookieStorage.shared return URLSession(configuration: config) }() private init() { let urlString = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String ?? "https://v2.libnovel.kalekber.cc" baseURL = URL(string: urlString)! } // MARK: - Auth cookie management func setAuthCookie(_ value: String?) { authCookie = value if let value { let cookieProps: [HTTPCookiePropertyKey: Any] = [ .name: "libnovel_auth", .value: value, .domain: baseURL.host ?? "localhost", .path: "/" ] if let cookie = HTTPCookie(properties: cookieProps) { HTTPCookieStorage.shared.setCookie(cookie) } } else { let storage = HTTPCookieStorage.shared storage.cookies(for: baseURL)?.forEach { storage.deleteCookie($0) } } } // MARK: - Low-level request builder private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest { let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard let url = URL(string: urlString) else { throw APIError.invalidResponse } var req = URLRequest(url: url) req.httpMethod = method req.setValue("application/json", forHTTPHeaderField: "Accept") if let body { req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONEncoder().encode(body) } return req } // MARK: - Generic fetch func fetch(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> T { let req = try makeRequest(path, method: method, body: body) let (data, response) = try await session.data(for: req) guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } let rawBody = String(data: data, encoding: .utf8) ?? "" guard (200..<300).contains(http.statusCode) else { if http.statusCode == 401 { throw APIError.unauthorized } throw APIError.httpError(http.statusCode, rawBody) } do { return try JSONDecoder.apiDecoder.decode(T.self, from: data) } catch { throw APIError.decodingError(error) } } func fetchVoid(_ path: String, method: String = "GET", body: Encodable? = nil) async throws { let req = try makeRequest(path, method: method, body: body) let (data, response) = try await session.data(for: req) guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } guard (200..<300).contains(http.statusCode) else { let rawBody = String(data: data, encoding: .utf8) ?? "" throw APIError.httpError(http.statusCode, rawBody) } } // MARK: - Auth private struct LoginRequest: Encodable { let username: String let password: String } struct LoginResponse: Decodable { let token: String let user: AppUser } func login(username: String, password: String) async throws -> LoginResponse { try await fetch("/api/auth/login", method: "POST", body: LoginRequest(username: username, password: password)) } func register(username: String, password: String) async throws -> LoginResponse { try await fetch("/api/auth/register", method: "POST", body: LoginRequest(username: username, password: password)) } func logout() async throws { let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST") setAuthCookie(nil) } // MARK: - Home func homeData() async throws -> HomeDataResponse { try await fetch("/api/home") } // MARK: - Library func library() async throws -> [LibraryItem] { try await fetch("/api/library") } func saveBook(slug: String) async throws { let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "POST") } func unsaveBook(slug: String) async throws { let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "DELETE") } // MARK: - Book Detail func bookDetail(slug: String) async throws -> BookDetailResponse { try await fetch("/api/book/\(slug)") } // MARK: - Chapter func chapterContent(slug: String, chapter: Int) async throws -> ChapterResponse { try await fetch("/api/chapter/\(slug)/\(chapter)") } // MARK: - Browse func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse { let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)" return try await fetch("/api/browse-page\(query)") } func search(query: String) async throws -> SearchResponse { let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query return try await fetch("/api/search?q=\(encoded)") } func ranking() async throws -> [RankingItem] { try await fetch("/api/ranking") } // MARK: - Progress func progress() async throws -> [ProgressEntry] { try await fetch("/api/progress") } func setProgress(slug: String, chapter: Int) async throws { struct Body: Encodable { let chapter: Int } let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "POST", body: Body(chapter: chapter)) } func deleteProgress(slug: String) async throws { let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "DELETE") } func audioTime(slug: String, chapter: Int) async throws -> Double? { struct Response: Decodable { let audioTime: Double? enum CodingKeys: String, CodingKey { case audioTime = "audio_time" } } let r: Response = try await fetch("/api/progress/audio-time?slug=\(slug)&chapter=\(chapter)") return r.audioTime } func setAudioTime(slug: String, chapter: Int, time: Double) async throws { struct Body: Encodable { let slug: String; let chapter: Int; let audioTime: Double enum CodingKeys: String, CodingKey { case slug, chapter; case audioTime = "audio_time" } } let _: EmptyResponse = try await fetch("/api/progress/audio-time", method: "PATCH", body: Body(slug: slug, chapter: chapter, audioTime: time)) } // MARK: - Audio func triggerAudio(slug: String, chapter: Int, voice: String, speed: Double) async throws -> AudioTriggerResponse { struct Body: Encodable { let voice: String; let speed: Double } return try await fetch("/api/audio/\(slug)/\(chapter)", method: "POST", body: Body(voice: voice, speed: speed)) } /// Poll until the TTS job is done, failed, or the task is cancelled. /// Returns the playback URL on success. func pollAudioStatus(slug: String, chapter: Int, voice: String) async throws -> String { let path = "/api/audio/status/\(slug)/\(chapter)?voice=\(voice)" struct StatusResponse: Decodable { let status: String let url: String? let error: String? } while true { try Task.checkCancellation() let r: StatusResponse = try await fetch(path) switch r.status { case "done": guard let url = r.url, !url.isEmpty else { throw URLError(.badServerResponse) } return url case "failed": throw NSError(domain: "AudioGeneration", code: 0, userInfo: [NSLocalizedDescriptionKey: r.error ?? "Audio generation failed"]) default: try await Task.sleep(nanoseconds: 2_000_000_000) } } } func presignAudio(slug: String, chapter: Int, voice: String) async throws -> String { struct Response: Decodable { let url: String } let r: Response = try await fetch("/api/presign/audio?slug=\(slug)&chapter=\(chapter)&voice=\(voice)") return r.url } func presignVoiceSample(voice: String) async throws -> String { struct Response: Decodable { let url: String } let r: Response = try await fetch("/api/presign/voice-sample?voice=\(voice)") return r.url } func voices() async throws -> [String] { struct Response: Decodable { let voices: [String] } let r: Response = try await fetch("/api/voices") return r.voices } // MARK: - Settings func settings() async throws -> UserSettings { try await fetch("/api/settings") } func updateSettings(_ settings: UserSettings) async throws { let _: EmptyResponse = try await fetch("/api/settings", method: "PUT", body: settings) } // MARK: - Sessions func sessions() async throws -> [UserSession] { struct Response: Decodable { let sessions: [UserSession] } let r: Response = try await fetch("/api/sessions") return r.sessions } func revokeSession(id: String) async throws { let _: EmptyResponse = try await fetch("/api/sessions/\(id)", method: "DELETE") } // MARK: - Avatar struct AvatarPresignResponse: Decodable { let uploadURL: String let key: String enum CodingKeys: String, CodingKey { case uploadURL = "upload_url"; case key } } struct AvatarResponse: Decodable { let avatarURL: String? enum CodingKeys: String, CodingKey { case avatarURL = "avatar_url" } } func uploadAvatar(_ imageData: Data, mimeType: String = "image/jpeg") async throws -> String? { let presign: AvatarPresignResponse = try await fetch( "/api/profile/avatar", method: "POST", body: ["mime_type": mimeType]) guard let putURL = URL(string: presign.uploadURL) else { throw APIError.invalidResponse } var putReq = URLRequest(url: putURL) putReq.httpMethod = "PUT" putReq.setValue(mimeType, forHTTPHeaderField: "Content-Type") putReq.httpBody = imageData let (_, putResp) = try await session.data(for: putReq) guard let putHttp = putResp as? HTTPURLResponse, (200..<300).contains(putHttp.statusCode) else { throw APIError.httpError((putResp as? HTTPURLResponse)?.statusCode ?? 0, "MinIO PUT failed") } let result: AvatarResponse = try await fetch("/api/profile/avatar", method: "PATCH", body: ["key": presign.key]) return result.avatarURL } func fetchAvatarPresignedURL() async throws -> String? { let result: AvatarResponse = try await fetch("/api/profile/avatar") return result.avatarURL } // MARK: - User Profiles & Subscriptions func fetchUserProfile(username: String) async throws -> PublicUserProfile { try await fetch("/api/users/\(username)") } @discardableResult func subscribeUser(username: String) async throws -> Bool { struct Response: Decodable { let subscribed: Bool } let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "POST") return r.subscribed } @discardableResult func unsubscribeUser(username: String) async throws -> Bool { struct Response: Decodable { let subscribed: Bool } let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "DELETE") return r.subscribed } func fetchUserLibrary(username: String) async throws -> PublicUserLibraryResponse { try await fetch("/api/users/\(username)/library") } // MARK: - Comments func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse { try await fetch("/api/comments/\(slug)?sort=\(sort)") } private struct PostCommentBody: Encodable { let body: String let parent_id: String? } func postComment(slug: String, body: String, parentId: String? = nil) async throws -> BookComment { try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body, parent_id: parentId)) } func voteComment(commentId: String, vote: String) async throws -> BookComment { struct VoteBody: Encodable { let vote: String } return try await fetch("/api/comment/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote)) } func deleteComment(commentId: String) async throws { try await fetchVoid("/api/comment/\(commentId)", method: "DELETE") } } // MARK: - Response types struct HomeDataResponse: Decodable { struct ContinueItem: Decodable { let book: Book let chapter: Int } let continueReading: [ContinueItem] let recentlyUpdated: [Book] let stats: HomeStats let subscriptionFeed: [SubscriptionFeedItem] enum CodingKeys: String, CodingKey { case continueReading = "continue_reading" case recentlyUpdated = "recently_updated" case stats case subscriptionFeed = "subscription_feed" } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) continueReading = try c.decodeIfPresent([ContinueItem].self, forKey: .continueReading) ?? [] recentlyUpdated = try c.decodeIfPresent([Book].self, forKey: .recentlyUpdated) ?? [] stats = try c.decode(HomeStats.self, forKey: .stats) subscriptionFeed = try c.decodeIfPresent([SubscriptionFeedItem].self, forKey: .subscriptionFeed) ?? [] } } struct LibraryItem: Decodable, Identifiable { var id: String { book.id } let book: Book let savedAt: String let lastChapter: Int? enum CodingKeys: String, CodingKey { case book case savedAt = "saved_at" case lastChapter = "last_chapter" } } struct BookDetailResponse: Decodable { let book: Book let chapters: [ChapterIndex] let inLib: Bool let saved: Bool let lastChapter: Int? enum CodingKeys: String, CodingKey { case book, chapters case inLib = "in_lib" case saved case lastChapter = "last_chapter" } } struct BrowseResponse: Decodable { let novels: [BrowseNovel] let page: Int let hasNext: Bool } struct BrowseNovel: Decodable, Identifiable, Hashable { var id: String { slug.isEmpty ? url : slug } let slug: String let title: String let cover: String let rank: String let rating: String let chapters: String let url: String let author: String let status: String let genres: [String] enum CodingKeys: String, CodingKey { case slug, title, cover, rank, rating, chapters, url, author, status, genres } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? "" title = try c.decode(String.self, forKey: .title) cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? "" rank = try c.decodeIfPresent(String.self, forKey: .rank) ?? "" rating = try c.decodeIfPresent(String.self, forKey: .rating) ?? "" chapters = try c.decodeIfPresent(String.self, forKey: .chapters) ?? "" url = try c.decodeIfPresent(String.self, forKey: .url) ?? "" author = try c.decodeIfPresent(String.self, forKey: .author) ?? "" status = try c.decodeIfPresent(String.self, forKey: .status) ?? "" genres = try c.decodeIfPresent([String].self, forKey: .genres) ?? [] } } struct SearchResponse: Decodable { let results: [BrowseNovel] let localCount: Int let remoteCount: Int enum CodingKeys: String, CodingKey { case results case localCount = "local_count" case remoteCount = "remote_count" } } struct AudioTriggerResponse: Decodable { let jobId: String? let status: String? let url: String? let filename: String? enum CodingKeys: String, CodingKey { case jobId = "job_id" case status, url, filename } var isAsync: Bool { jobId != nil } } struct ProgressEntry: Decodable, Identifiable { var id: String { slug } let slug: String let chapter: Int let audioTime: Double? let updated: String enum CodingKeys: String, CodingKey { case slug, chapter, updated case audioTime = "audio_time" } } struct EmptyResponse: Decodable {} // MARK: - API Error enum APIError: LocalizedError { case invalidResponse case httpError(Int, String) case decodingError(Error) case unauthorized case networkError(Error) var errorDescription: String? { switch self { case .invalidResponse: return "Invalid server response" case .httpError(let code, let m): return "HTTP \(code): \(m)" case .decodingError(let e): return "Decode error: \(e.localizedDescription)" case .unauthorized: return "Not authenticated" case .networkError(let e): return e.localizedDescription } } } // MARK: - JSONDecoder helper extension JSONDecoder { static let apiDecoder: JSONDecoder = { let d = JSONDecoder() d.dateDecodingStrategy = .iso8601 return d }() }