Files
libnovel/ios/LibNovel/LibNovel/Networking/APIClient.swift
Admin 2793ad8cfa
Some checks failed
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Test (pull_request) Failing after 10s
CI / Scraper / Lint (pull_request) Failing after 10s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
iOS: codebase cleanup — remove dead code, unify design, deduplicate patterns
- Remove swift-markdown-ui dependency (project.yml, pbxproj, Package.resolved)
- Remove all debug print() statements (APIClient, BrowseViewModel, ChapterReaderViewModel, ChapterReaderView)
- Remove dead UI stubs: isFavorited/isLiked state, heart/star buttons, empty Share/Add to Library menu items in FullPlayerView
- Remove unused audioToolbarButton toolbar builder in ChapterReaderView
- Remove unused model types: NovelListing, BookDetailData, ChapterContent
- Remove amberLight color (never referenced)
- Remove mini player interactive seek (horizontal drag/tap to seek) — progress bar is now display-only
- Fix AccentColor asset to amber #f59e0b (was orange-pink mismatch)
- Fix Discover tab icon: globe → globe.americas.fill
- Fix speed slider max: 3.0 → 2.0 in ProfileView
- Fix yearText: use static DateFormatter instead of allocating on every access
- Fix ChangePasswordView.save(): DispatchQueue.main.asyncAfter → Task.sleep
- Deduplicate navigationDestination blocks via .appNavigationDestination() View extension
- Deduplicate .alert(Error) pattern via .errorAlert() View extension
2026-03-08 15:30:43 +05:00

469 lines
16 KiB
Swift

import Foundation
// MARK: - API Client
// Communicates with the SvelteKit UI server (not directly with the Go scraper).
// The SvelteKit layer handles auth, PocketBase queries, and MinIO presigning.
// For the iOS app we talk to the same /api/* endpoints the web UI uses,
// so we reuse the exact same HMAC-cookie auth flow.
actor APIClient {
static let shared = APIClient()
var baseURL: URL
private var authCookie: String? // raw "libnovel_auth=<token>" header value
private var sessionId: String? // anon session id (UUID)
// URLSession with persistent cookie storage
private let session: URLSession = {
let config = URLSessionConfiguration.default
config.httpCookieAcceptPolicy = .always
config.httpShouldSetCookies = true
config.httpCookieStorage = HTTPCookieStorage.shared
return URLSession(configuration: config)
}()
private init() {
// Default: point at the UI server. Override via Settings bundle or compile flag.
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 {
// Also inject into shared cookie storage so redirects carry the cookie
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 {
// Clear
let cookieStorage = HTTPCookieStorage.shared
cookieStorage.cookies(for: baseURL)?.forEach { cookieStorage.deleteCookie($0) }
}
}
func setSessionId(_ id: String) {
sessionId = id
}
// MARK: - Low-level request builder
private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest {
// Build URL by appending the path string directly to the base URL string.
// appendingPathComponent() percent-encodes slashes, which breaks multi-segment
// paths like /api/chapter/slug/1. URL(string:) preserves slashes correctly.
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, \(data.count) bytes>"
guard (200..<300).contains(http.statusCode) else {
throw APIError.httpError(http.statusCode, rawBody)
}
do {
return try JSONDecoder.iso8601.decode(T.self, from: data)
} catch {
throw APIError.decodingError(error)
}
}
func fetchRaw(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> (Data, HTTPURLResponse) {
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 }
return (data, http)
}
// MARK: - Auth
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 (_, _) = try await fetchRaw("/api/auth/logout", method: "POST")
await 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
struct BrowseParams: Encodable {
let page: Int
let genre: String
let sort: String
let status: String
}
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 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 GET /api/audio/status/{slug}/{n}?voice=... until the job is done or failed.
/// Returns the presigned/proxy URL on success, throws on failure or cancellation.
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:
// pending / generating / idle keep polling
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 s
}
}
}
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: - Response types
struct HomeDataResponse: Decodable {
struct ContinueItem: Decodable {
let book: Book
let chapter: Int
}
let continueReading: [ContinueItem]
let recentlyUpdated: [Book]
let stats: HomeStats
enum CodingKeys: String, CodingKey {
case continueReading = "continue_reading"
case recentlyUpdated = "recently_updated"
case stats
}
}
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 previewChapters: [PreviewChapter]?
let inLib: Bool
let saved: Bool
let lastChapter: Int?
enum CodingKeys: String, CodingKey {
case book, chapters
case previewChapters = "preview_chapters"
case inLib = "in_lib"
case saved
case lastChapter = "last_chapter"
}
}
struct ChapterResponse: Decodable {
let book: BookBrief
let chapter: ChapterIndex
let html: String
let voices: [String]
let prev: Int?
let next: Int?
let chapters: [ChapterIndexBrief]
let isPreview: Bool
enum CodingKeys: String, CodingKey {
case book, chapter, html, voices, prev, next, chapters
case isPreview = "is_preview"
}
}
struct BrowseResponse: Decodable {
let novels: [BrowseNovel]
let page: Int
let hasNext: Bool
enum CodingKeys: String, CodingKey {
case novels, page
case hasNext = "hasNext"
}
}
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"
}
}
/// Returned by POST /api/audio/{slug}/{n}.
/// - 202 Accepted: job enqueued poll via pollAudioStatus()
/// - 200 OK: audio already cached url is ready to play
struct AudioTriggerResponse: Decodable {
let jobId: String? // present on 202
let status: String? // present on 202: "pending" | "generating"
let url: String? // present on 200: proxy URL ready to play
let filename: String? // present on 200
enum CodingKeys: String, CodingKey {
case jobId = "job_id"
case status, url, filename
}
/// True when the server accepted the request and created an async job.
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 msg): return "HTTP \(code): \(msg)"
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 iso8601: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()
}