@@ -42,6 +42,11 @@ protocol HardcoverServiceProtocol {
4242 /// Remove a book from the user's Hardcover library
4343 /// - Parameter book: The Hardcover book to remove from library
4444 func removeFromLibrary( _ book: SimpleHardcoverBook ) async throws
45+
46+ /// Fetch a single book's info from Hardcover by its id
47+ /// - Parameter id: The Hardcover book id (the external resource's providerId)
48+ /// - Returns: The book info, or nil if not found
49+ func getBook( id: Int ) async throws -> SimpleHardcoverBook ?
4550}
4651
4752@Observable
@@ -135,6 +140,38 @@ extension HardcoverService {
135140 return result
136141 }
137142
143+ func getBook( id: Int ) async throws -> SimpleHardcoverBook ? {
144+ Self . logger. info ( " Fetching Hardcover book info for id \( id) " )
145+
146+ let queryString = """
147+ query GetBook($id: Int!) {
148+ books(where: {id: {_eq: $id}}, limit: 1) {
149+ id
150+ title
151+ image { url }
152+ contributions { author { name } }
153+ }
154+ }
155+ """
156+
157+ let result = try await graphQL. execute (
158+ query: queryString,
159+ variables: [ " id " : id] ,
160+ authorization: authorization,
161+ responseType: BookByIdData . self
162+ )
163+
164+ guard let book = result. books. first else { return nil }
165+
166+ return SimpleHardcoverBook (
167+ id: book. id,
168+ artworkURL: book. image? . url. flatMap ( URL . init ( string: ) ) ,
169+ title: book. title,
170+ author: book. contributions? . first? . author? . name ?? " " ,
171+ status: . local
172+ )
173+ }
174+
138175 private func insertUserBook( bookID: Int , status: HardcoverBook . Status ) async throws -> InsertUserBookData {
139176 let mutation = """
140177 mutation InsertUserBook($book_id: Int!, $status_id: Int!) {
@@ -234,47 +271,67 @@ extension HardcoverService {
234271 }
235272
236273 private func handleBookStarted( relativePath: String , percentCompleted: Double ) async {
237- guard
238- percentCompleted > readingThreshold,
239- var item = await libraryService. getHardcoverBook ( for: relativePath) ,
240- item. status < . reading
241- else { return }
274+ guard percentCompleted > readingThreshold else { return }
242275
243- do {
244- Self . logger. info ( " Updating Hardcover API: book \( item. id) to 'reading' status " )
245- _ = try await insertUserBook (
246- bookID: item. id,
247- status: . reading
248- )
249- Self . logger. info ( " Successfully updated Hardcover API for book \( item. id) " )
276+ await updateExternalResources ( for: relativePath, to: . reading)
277+ }
250278
251- item. status = . reading
252- await libraryService. setHardcoverBook ( item, for: relativePath)
253- Self . logger. info ( " Updated local status to 'reading' for \( relativePath) " )
254- } catch {
255- Self . logger. error ( " Failed to update Hardcover status for book \( item. id) : \( error) " )
279+ private func handleBookFinished( relativePath: String ) async {
280+ await updateExternalResources ( for: relativePath, to: . read)
281+ }
282+
283+ /// Iterate the item's external resources and run the matching provider action for the new status.
284+ private func updateExternalResources(
285+ for relativePath: String ,
286+ to status: HardcoverBook . Status
287+ ) async {
288+ let resources = await libraryService. getExternalResources ( for: relativePath)
289+
290+ for resource in resources {
291+ guard let provider = ExternalResource . ProviderName ( rawValue: resource. providerName) else { continue }
292+
293+ switch provider {
294+ case . hardcover:
295+ await updateHardcoverStatus ( status, providerId: resource. providerId, for: relativePath)
296+ case . jellyfin, . audiobookshelf:
297+ break
298+ }
256299 }
257300 }
258301
259- private func handleBookFinished( relativePath: String ) async {
260- guard
261- var item = await libraryService. getHardcoverBook ( for: relativePath) ,
262- item. status != . read
263- else { return }
302+ /// Push the given status to Hardcover for the book identified by `providerId`,
303+ /// keeping the local reference in sync. No-op if it already reached `status`.
304+ private func updateHardcoverStatus(
305+ _ status: HardcoverBook . Status ,
306+ providerId: String ,
307+ for relativePath: String
308+ ) async {
309+ guard let bookID = Int ( providerId) else { return }
310+
311+ let existing = await libraryService. getHardcoverBook ( for: relativePath)
312+ if let existing, existing. status >= status {
313+ return
314+ }
264315
265316 do {
266- Self . logger. info ( " Updating Hardcover API: book \( item. id) to 'read' status " )
267- _ = try await insertUserBook (
268- bookID: item. id,
269- status: . read
317+ Self . logger. info ( " Updating Hardcover API: book \( bookID) to ' \( String ( describing: status) ) ' status " )
318+ let response = try await insertUserBook ( bookID: bookID, status: status)
319+
320+ var updated = existing ?? SimpleHardcoverBook (
321+ id: bookID,
322+ artworkURL: nil ,
323+ title: " " ,
324+ author: " " ,
325+ status: status
270326 )
271- Self . logger. info ( " Successfully updated Hardcover API for book \( item. id) " )
272-
273- item. status = . read
274- await libraryService. setHardcoverBook ( item, for: relativePath)
275- Self . logger. info ( " Updated local status to 'read' for \( relativePath) " )
327+ updated. status = status
328+ if updated. userBookID == nil {
329+ updated. userBookID = response. insertUserBook. id
330+ }
331+ await libraryService. setHardcoverBook ( updated, for: relativePath)
332+ Self . logger. info ( " Updated local Hardcover status to ' \( String ( describing: status) ) ' for \( relativePath) " )
276333 } catch {
277- Self . logger. error ( " Failed to update Hardcover status for book \( item . id ) : \( error) " )
334+ Self . logger. error ( " Failed to update Hardcover status for book \( bookID ) : \( error) " )
278335 }
279336 }
280337
@@ -502,3 +559,27 @@ extension SimpleHardcoverBook {
502559 )
503560 }
504561}
562+
563+ /// Response model for fetching a single book by its Hardcover id.
564+ private struct BookByIdData : Codable {
565+ let books : [ Book ]
566+
567+ struct Book : Codable {
568+ let id : Int
569+ let title : String
570+ let image : Artwork ?
571+ let contributions : [ Contribution ] ?
572+
573+ struct Artwork : Codable {
574+ let url : String ?
575+ }
576+
577+ struct Contribution : Codable {
578+ let author : Author ?
579+
580+ struct Author : Codable {
581+ let name : String ?
582+ }
583+ }
584+ }
585+ }
0 commit comments