Files
libnovel/ios/LibNovelV2/Networking/APIClient.swift
Admin 7413313100
All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
fix: update integration_test.go to match server.New signature (version, commit args)
2026-03-14 14:25:46 +05:00

521 lines
18 KiB
Swift

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=<token>" 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<T: Decodable>(_ 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) ?? "<non-utf8, \(data.count) bytes>"
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) ?? "<non-utf8, \(data.count) bytes>"
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
}()
}