Skip to content

Commit 1a30ee4

Browse files
committed
Creating sync tasks for ahrdcover ids
1 parent aab03bd commit 1a30ee4

11 files changed

Lines changed: 750 additions & 533 deletions

File tree

BookPlayer.xcodeproj/project.pbxproj

Lines changed: 14 additions & 76 deletions
Large diffs are not rendered by default.

BookPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 4 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

BookPlayer/Generated/AutoMockable.generated.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,6 +1071,26 @@ class LibraryServiceProtocolMock: LibraryServiceProtocol {
10711071
return removeExternalResourceProviderNameForReturnValue
10721072
}
10731073
}
1074+
//MARK: - getExternalResources
1075+
1076+
var getExternalResourcesForCallsCount = 0
1077+
var getExternalResourcesForCalled: Bool {
1078+
return getExternalResourcesForCallsCount > 0
1079+
}
1080+
var getExternalResourcesForReceivedRelativePath: String?
1081+
var getExternalResourcesForReceivedInvocations: [String] = []
1082+
var getExternalResourcesForReturnValue: [SimpleExternalResource]! = []
1083+
var getExternalResourcesForClosure: ((String) async -> [SimpleExternalResource])?
1084+
func getExternalResources(for relativePath: String) async -> [SimpleExternalResource] {
1085+
getExternalResourcesForCallsCount += 1
1086+
getExternalResourcesForReceivedRelativePath = relativePath
1087+
getExternalResourcesForReceivedInvocations.append(relativePath)
1088+
if let getExternalResourcesForClosure = getExternalResourcesForClosure {
1089+
return await getExternalResourcesForClosure(relativePath)
1090+
} else {
1091+
return getExternalResourcesForReturnValue
1092+
}
1093+
}
10741094
//MARK: - getHardcoverBook
10751095

10761096
var getHardcoverBookForCallsCount = 0

BookPlayer/Hardcover/ItemDetails Section/ItemDetailsHardcoverSectionView.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
//
88

99
import SwiftUI
10+
import BookPlayerKit
1011

1112
struct ItemDetailsHardcoverSectionView: View {
1213
@Environment(\.dismiss) var dismiss
@@ -16,8 +17,14 @@ struct ItemDetailsHardcoverSectionView: View {
1617

1718
var body: some View {
1819
Section(
19-
header: Text("section_item_hardcover".localized)
20-
.foregroundStyle(theme.secondaryColor)
20+
header: HStack(spacing: Spacing.S) {
21+
Text("section_item_hardcover".localized)
22+
.foregroundStyle(theme.secondaryColor)
23+
if viewModel.isFetchingBook {
24+
ProgressView()
25+
.controlSize(.small)
26+
}
27+
}
2128
) {
2229
NavigationLink(
2330
destination: {
@@ -40,6 +47,8 @@ struct ItemDetailsHardcoverSectionView: View {
4047
extension ItemDetailsHardcoverSectionView {
4148
class Model: ObservableObject {
4249
@Published var pickerViewModel: HardcoverBookPickerView.Model
50+
/// True while fetching the linked book's info from Hardcover via its external resource
51+
@Published var isFetchingBook: Bool = false
4352

4453
init(pickerViewModel: HardcoverBookPickerView.Model = .init()) {
4554
self.pickerViewModel = pickerViewModel

BookPlayer/Hardcover/Network/HardcoverService.swift

Lines changed: 113 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

BookPlayer/Library/ItemDetails/ItemDetailsViewModel.swift

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -100,16 +100,70 @@ final class ItemDetailsViewModel: ObservableObject {
100100
)
101101

102102
Task {
103-
if let item = await libraryService.getHardcoverBook(for: item.relativePath) {
104-
hardcoverBook = item
105-
hardcoverSectionViewModel?.pickerViewModel.selected = .init(
106-
id: item.id,
107-
artworkURL: item.artworkURL,
108-
title: item.title,
109-
author: item.author
110-
)
111-
}
103+
await resolveHardcoverSelection()
104+
}
105+
}
106+
107+
/// Populate the Hardcover picker selection. Prefers the full local reference; otherwise
108+
/// falls back to the hardcover external resource — showing the item's title right away,
109+
/// then fetching the real book info from Hardcover by its providerId.
110+
@MainActor
111+
private func resolveHardcoverSelection() async {
112+
if let book = await libraryService.getHardcoverBook(for: item.relativePath) {
113+
hardcoverBook = book
114+
hardcoverSectionViewModel?.pickerViewModel.selected = .init(
115+
id: book.id,
116+
artworkURL: book.artworkURL,
117+
title: book.title,
118+
author: book.author
119+
)
120+
return
112121
}
122+
123+
/// No full local reference — fall back to the hardcover external resource, if any
124+
guard hardcoverSectionViewModel != nil else { return }
125+
126+
let resources = await libraryService.getExternalResources(for: item.relativePath)
127+
guard
128+
let resource = resources.first(where: {
129+
$0.providerName == ExternalResource.ProviderName.hardcover.rawValue
130+
}),
131+
let bookID = Int(resource.providerId)
132+
else { return }
133+
134+
/// Show at least the item's title so the row reflects a selection
135+
hardcoverBook = SimpleHardcoverBook(
136+
id: bookID,
137+
artworkURL: nil,
138+
title: item.title,
139+
author: item.details,
140+
status: .local
141+
)
142+
hardcoverSectionViewModel?.pickerViewModel.selected = .init(
143+
id: bookID,
144+
artworkURL: nil,
145+
title: item.title,
146+
author: item.details
147+
)
148+
149+
/// Fetch the real book info from Hardcover and update the selection
150+
hardcoverSectionViewModel?.isFetchingBook = true
151+
let fetched = try? await hardcoverService.getBook(id: bookID)
152+
hardcoverSectionViewModel?.isFetchingBook = false
153+
154+
/// Discard the result if the selection was swapped or unlinked while fetching
155+
guard
156+
let fetched,
157+
hardcoverSectionViewModel?.pickerViewModel.selected?.id == bookID
158+
else { return }
159+
160+
hardcoverBook = fetched
161+
hardcoverSectionViewModel?.pickerViewModel.selected = .init(
162+
id: fetched.id,
163+
artworkURL: fetched.artworkURL,
164+
title: fetched.title,
165+
author: fetched.author
166+
)
113167
}
114168

115169
func handleSaveAction(_ loadingState: LoadingOverlayState, success: @escaping () -> Void) {

BookPlayer/Profile/Profile/QueuedSyncTasksView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ struct QueuedSyncTasksView: View {
141141
return "arrow.up.forward.square"
142142
case .externalResourceToDownload:
143143
return "link.badge.plus"
144+
case .deleteExternalResource:
145+
return "xmark.bin"
144146
}
145147
}
146148
}

0 commit comments

Comments
 (0)