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
521 lines
18 KiB
Swift
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
|
|
}()
|
|
}
|