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
- 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
469 lines
16 KiB
Swift
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
|
|
}()
|
|
}
|