Skip to content

Commit aab03bd

Browse files
committed
delete external resource, hardcover as an er
1 parent 7e7ecab commit aab03bd

15 files changed

Lines changed: 367 additions & 26 deletions

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

Lines changed: 2 additions & 2 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: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,46 @@ class LibraryServiceProtocolMock: LibraryServiceProtocol {
10311031
setHardcoverBookForReceivedInvocations.append((hardcoverBook: hardcoverBook, relativePath: relativePath))
10321032
await setHardcoverBookForClosure?(hardcoverBook, relativePath)
10331033
}
1034+
//MARK: - setExternalResource
1035+
1036+
var setExternalResourceProviderNameProviderIdForCallsCount = 0
1037+
var setExternalResourceProviderNameProviderIdForCalled: Bool {
1038+
return setExternalResourceProviderNameProviderIdForCallsCount > 0
1039+
}
1040+
var setExternalResourceProviderNameProviderIdForReceivedArguments: (providerName: String, providerId: String, uuid: String)?
1041+
var setExternalResourceProviderNameProviderIdForReceivedInvocations: [(providerName: String, providerId: String, uuid: String)] = []
1042+
var setExternalResourceProviderNameProviderIdForReturnValue: SyncableExternalResource?
1043+
var setExternalResourceProviderNameProviderIdForClosure: ((String, String, String) async -> SyncableExternalResource?)?
1044+
func setExternalResource(providerName: String, providerId: String, for uuid: String) async -> SyncableExternalResource? {
1045+
setExternalResourceProviderNameProviderIdForCallsCount += 1
1046+
setExternalResourceProviderNameProviderIdForReceivedArguments = (providerName: providerName, providerId: providerId, uuid: uuid)
1047+
setExternalResourceProviderNameProviderIdForReceivedInvocations.append((providerName: providerName, providerId: providerId, uuid: uuid))
1048+
if let setExternalResourceProviderNameProviderIdForClosure = setExternalResourceProviderNameProviderIdForClosure {
1049+
return await setExternalResourceProviderNameProviderIdForClosure(providerName, providerId, uuid)
1050+
} else {
1051+
return setExternalResourceProviderNameProviderIdForReturnValue
1052+
}
1053+
}
1054+
//MARK: - removeExternalResource
1055+
1056+
var removeExternalResourceProviderNameForCallsCount = 0
1057+
var removeExternalResourceProviderNameForCalled: Bool {
1058+
return removeExternalResourceProviderNameForCallsCount > 0
1059+
}
1060+
var removeExternalResourceProviderNameForReceivedArguments: (providerName: String, uuid: String)?
1061+
var removeExternalResourceProviderNameForReceivedInvocations: [(providerName: String, uuid: String)] = []
1062+
var removeExternalResourceProviderNameForReturnValue: String?
1063+
var removeExternalResourceProviderNameForClosure: ((String, String) async -> String?)?
1064+
func removeExternalResource(providerName: String, for uuid: String) async -> String? {
1065+
removeExternalResourceProviderNameForCallsCount += 1
1066+
removeExternalResourceProviderNameForReceivedArguments = (providerName: providerName, uuid: uuid)
1067+
removeExternalResourceProviderNameForReceivedInvocations.append((providerName: providerName, uuid: uuid))
1068+
if let removeExternalResourceProviderNameForClosure = removeExternalResourceProviderNameForClosure {
1069+
return await removeExternalResourceProviderNameForClosure(providerName, uuid)
1070+
} else {
1071+
return removeExternalResourceProviderNameForReturnValue
1072+
}
1073+
}
10341074
//MARK: - getHardcoverBook
10351075

10361076
var getHardcoverBookForCallsCount = 0
@@ -2145,6 +2185,36 @@ class SyncServiceProtocolMock: SyncServiceProtocol {
21452185
scheduleUploadArtworkRelativePathUuidReceivedInvocations.append((relativePath: relativePath, uuid: uuid))
21462186
scheduleUploadArtworkRelativePathUuidClosure?(relativePath, uuid)
21472187
}
2188+
//MARK: - scheduleExternalResourceUpload
2189+
2190+
var scheduleExternalResourceUploadRelativePathUuidCallsCount = 0
2191+
var scheduleExternalResourceUploadRelativePathUuidCalled: Bool {
2192+
return scheduleExternalResourceUploadRelativePathUuidCallsCount > 0
2193+
}
2194+
var scheduleExternalResourceUploadRelativePathUuidReceivedArguments: (resource: SyncableExternalResource, relativePath: String, uuid: String)?
2195+
var scheduleExternalResourceUploadRelativePathUuidReceivedInvocations: [(resource: SyncableExternalResource, relativePath: String, uuid: String)] = []
2196+
var scheduleExternalResourceUploadRelativePathUuidClosure: ((SyncableExternalResource, String, String) -> Void)?
2197+
func scheduleExternalResourceUpload(_ resource: SyncableExternalResource, relativePath: String, uuid: String) {
2198+
scheduleExternalResourceUploadRelativePathUuidCallsCount += 1
2199+
scheduleExternalResourceUploadRelativePathUuidReceivedArguments = (resource: resource, relativePath: relativePath, uuid: uuid)
2200+
scheduleExternalResourceUploadRelativePathUuidReceivedInvocations.append((resource: resource, relativePath: relativePath, uuid: uuid))
2201+
scheduleExternalResourceUploadRelativePathUuidClosure?(resource, relativePath, uuid)
2202+
}
2203+
//MARK: - scheduleExternalResourceDeletion
2204+
2205+
var scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidCallsCount = 0
2206+
var scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidCalled: Bool {
2207+
return scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidCallsCount > 0
2208+
}
2209+
var scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidReceivedArguments: (providerName: String, providerId: String, relativePath: String, uuid: String)?
2210+
var scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidReceivedInvocations: [(providerName: String, providerId: String, relativePath: String, uuid: String)] = []
2211+
var scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidClosure: ((String, String, String, String) -> Void)?
2212+
func scheduleExternalResourceDeletion(providerName: String, providerId: String, relativePath: String, uuid: String) {
2213+
scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidCallsCount += 1
2214+
scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidReceivedArguments = (providerName: providerName, providerId: providerId, relativePath: relativePath, uuid: uuid)
2215+
scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidReceivedInvocations.append((providerName: providerName, providerId: providerId, relativePath: relativePath, uuid: uuid))
2216+
scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidClosure?(providerName, providerId, relativePath, uuid)
2217+
}
21482218
//MARK: - getAllQueuedJobs
21492219

21502220
var getAllQueuedJobsCallsCount = 0

BookPlayer/Hardcover/Network/HardcoverService.swift

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ final class HardcoverService: BPLogger, HardcoverServiceProtocol {
5050
private let graphQL = GraphQLClient(baseURL: "https://api.hardcover.app/v1/graphql")
5151
private var audioMetadataService: AudioMetadataServiceProtocol!
5252
private var libraryService: LibraryServiceProtocol!
53+
private var syncService: SyncServiceProtocol?
5354

5455
private var metadataSubscription: AnyCancellable?
5556
private var progressSubscription: AnyCancellable?
@@ -71,10 +72,12 @@ final class HardcoverService: BPLogger, HardcoverServiceProtocol {
7172

7273
func setup(
7374
libraryService: LibraryServiceProtocol,
75+
syncService: SyncServiceProtocol? = nil,
7476
keychain: KeychainServiceProtocol = KeychainService(),
7577
audioMetadataService: AudioMetadataServiceProtocol = AudioMetadataService()
7678
) {
7779
self.libraryService = libraryService
80+
self.syncService = syncService
7881
self.keychain = keychain
7982
self.audioMetadataService = audioMetadataService
8083

@@ -325,6 +328,7 @@ extension HardcoverService {
325328
}
326329

327330
await libraryService.setHardcoverBook(book, for: match.item.relativePath)
331+
await uploadExternalResource(for: book, to: match.item)
328332
Self.logger.info("Auto-matched '\(match.item.title)' to Hardcover ID \(bookID)")
329333
processedCount += 1
330334
} catch {
@@ -343,6 +347,9 @@ extension HardcoverService {
343347
_ book: SimpleHardcoverBook?,
344348
to item: SimpleLibraryItem
345349
) async {
350+
/// Remove any existing hardcover resource first (handles both unlink and swap)
351+
await deleteExternalResource(for: item)
352+
346353
guard let book else {
347354
await libraryService.setHardcoverBook(nil, for: item.relativePath)
348355
Self.logger.info("Removed Hardcover assignment from '\(item.title)'")
@@ -351,26 +358,66 @@ extension HardcoverService {
351358

352359
Self.logger.info("Assigning Hardcover book \(book.id) to '\(item.title)'")
353360

354-
guard autoAddWantToReadEnabled, authorization != nil else {
361+
if autoAddWantToReadEnabled, authorization != nil {
362+
do {
363+
let response = try await insertUserBook(
364+
bookID: book.id,
365+
status: .library
366+
)
367+
Self.logger.info("Added '\(item.title)' to Hardcover Want to Read list")
368+
369+
var updated = book
370+
updated.status = .library
371+
updated.userBookID = response.insertUserBook.id
372+
await libraryService.setHardcoverBook(updated, for: item.relativePath)
373+
} catch {
374+
Self.logger.error("Failed to add '\(item.title)' to Hardcover Want to Read: \(error)")
375+
await libraryService.setHardcoverBook(book, for: item.relativePath)
376+
}
377+
} else {
355378
await libraryService.setHardcoverBook(book, for: item.relativePath)
356-
return
357379
}
358380

359-
do {
360-
let response = try await insertUserBook(
361-
bookID: book.id,
362-
status: .library
381+
await uploadExternalResource(for: book, to: item)
382+
}
383+
384+
/// Create the hardcover external resource for the item and sync-upload it if a sync service is available.
385+
private func uploadExternalResource(
386+
for book: SimpleHardcoverBook,
387+
to item: SimpleLibraryItem
388+
) async {
389+
guard
390+
let syncable = await libraryService.setExternalResource(
391+
providerName: ExternalResource.ProviderName.hardcover.rawValue,
392+
providerId: String(book.id),
393+
for: item.uuid
363394
)
364-
Self.logger.info("Added '\(item.title)' to Hardcover Want to Read list")
395+
else { return }
365396

366-
var updated = book
367-
updated.status = .library
368-
updated.userBookID = response.insertUserBook.id
369-
await libraryService.setHardcoverBook(updated, for: item.relativePath)
370-
} catch {
371-
Self.logger.error("Failed to add '\(item.title)' to Hardcover Want to Read: \(error)")
372-
await libraryService.setHardcoverBook(book, for: item.relativePath)
373-
}
397+
syncService?.scheduleExternalResourceUpload(
398+
syncable,
399+
relativePath: item.relativePath,
400+
uuid: item.uuid
401+
)
402+
}
403+
404+
/// Delete the hardcover external resource for the item and schedule a delete sync task if a sync service is available.
405+
private func deleteExternalResource(for item: SimpleLibraryItem) async {
406+
let providerName = ExternalResource.ProviderName.hardcover.rawValue
407+
408+
guard
409+
let providerId = await libraryService.removeExternalResource(
410+
providerName: providerName,
411+
for: item.uuid
412+
)
413+
else { return }
414+
415+
syncService?.scheduleExternalResourceDeletion(
416+
providerName: providerName,
417+
providerId: providerId,
418+
relativePath: item.relativePath,
419+
uuid: item.uuid
420+
)
374421
}
375422

376423
func removeFromLibrary(_ book: SimpleHardcoverBook) async throws {

BookPlayer/Utils/AppServices.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ final class AppServices: BPLogger {
9898
playbackService: playbackService,
9999
playerManager: playerManager
100100
)
101-
let hardcoverService = makeHardcoverService(libraryService: libraryService)
101+
let hardcoverService = makeHardcoverService(libraryService: libraryService, syncService: syncService)
102102

103103
let preferencesService = PreferencesSyncService()
104104
preferencesService.setup(
@@ -268,9 +268,9 @@ final class AppServices: BPLogger {
268268
return service
269269
}
270270

271-
private func makeHardcoverService(libraryService: LibraryService) -> HardcoverService {
271+
private func makeHardcoverService(libraryService: LibraryService, syncService: SyncService) -> HardcoverService {
272272
let service = HardcoverService()
273-
service.setup(libraryService: libraryService)
273+
service.setup(libraryService: libraryService, syncService: syncService)
274274
return service
275275
}
276276
}

Shared/Services/LibraryService.swift

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,13 @@ public protocol LibraryServiceProtocol: AnyObject {
173173
func setHardcoverBook(_ hardcoverBook: SimpleHardcoverBook?, for relativePath: String) async
174174
/// Get hardcover book for an item
175175
func getHardcoverBook(for relativePath: String) async -> SimpleHardcoverBook?
176-
176+
/// Create an external resource linking the item (by uuid) to a provider's resource.
177+
/// Returns the syncable representation to upload, or nil if it already exists or the item is missing.
178+
func setExternalResource(providerName: String, providerId: String, for uuid: String) async -> SyncableExternalResource?
179+
/// Remove the external resource of the given provider from the item (by uuid).
180+
/// Returns the deleted resource's providerId, or nil if there was nothing to delete.
181+
func removeExternalResource(providerName: String, for uuid: String) async -> String?
182+
177183
func getExternalResource(for providerId: String) async -> ExternalResource?
178184

179185
func findResource(for providerId: String, context: NSManagedObjectContext?) -> ExternalResource?
@@ -3000,6 +3006,79 @@ extension LibraryService {
30003006
}
30013007
}
30023008

3009+
public func setExternalResource(
3010+
providerName: String,
3011+
providerId: String,
3012+
for uuid: String
3013+
) async -> SyncableExternalResource? {
3014+
return await withCheckedContinuation { continuation in
3015+
let context = dataManager.getBackgroundContext()
3016+
3017+
context.perform { [unowned self, context] in
3018+
let fetchRequest: NSFetchRequest<LibraryItem> = LibraryItem.fetchRequest()
3019+
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LibraryItem.uuid), uuid)
3020+
fetchRequest.fetchLimit = 1
3021+
3022+
guard let item = try? context.fetch(fetchRequest).first else {
3023+
continuation.resume(returning: nil)
3024+
return
3025+
}
3026+
3027+
/// Skip if the same resource is already linked
3028+
if item.resourcesArray.contains(where: {
3029+
$0.providerName == providerName && $0.providerId == providerId
3030+
}) {
3031+
continuation.resume(returning: nil)
3032+
return
3033+
}
3034+
3035+
let syncable = SyncableExternalResource(
3036+
providerName: providerName,
3037+
providerId: providerId,
3038+
syncStatus: ExternalResource.SyncStatus.notSynced.rawValue,
3039+
lastSyncedAt: nil,
3040+
processedFile: true,
3041+
hostId: nil
3042+
)
3043+
3044+
_ = ExternalResource.create(syncable, libraryItem: item, in: context)
3045+
3046+
dataManager.saveSyncContext(context)
3047+
continuation.resume(returning: syncable)
3048+
}
3049+
}
3050+
}
3051+
3052+
public func removeExternalResource(
3053+
providerName: String,
3054+
for uuid: String
3055+
) async -> String? {
3056+
return await withCheckedContinuation { continuation in
3057+
let context = dataManager.getBackgroundContext()
3058+
3059+
context.perform { [unowned self, context] in
3060+
let fetchRequest: NSFetchRequest<LibraryItem> = LibraryItem.fetchRequest()
3061+
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LibraryItem.uuid), uuid)
3062+
fetchRequest.fetchLimit = 1
3063+
3064+
guard
3065+
let item = try? context.fetch(fetchRequest).first,
3066+
let resource = item.resourcesArray.first(where: { $0.providerName == providerName })
3067+
else {
3068+
continuation.resume(returning: nil)
3069+
return
3070+
}
3071+
3072+
let providerId = resource.providerId
3073+
item.removeFromExternalResources(resource)
3074+
context.delete(resource)
3075+
3076+
dataManager.saveSyncContext(context)
3077+
continuation.resume(returning: providerId)
3078+
}
3079+
}
3080+
}
3081+
30033082
public func getHardcoverBook(for relativePath: String) async -> SimpleHardcoverBook? {
30043083
return await withCheckedContinuation { continuation in
30053084
let context = dataManager.getBackgroundContext()

Shared/Services/Sync/LibraryAPI.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public enum LibraryAPI {
2525
case uploadArtwork(path: String, filename: String, uploaded: Bool?, uuid: String)
2626
case matchUuids(uuidsDictionary: [String: String])
2727
case externalResourceToDownload(uuid: String, uploaded: Bool)
28+
case deleteExternalResource(uuid: String, providerName: String, providerId: String)
2829
}
2930

3031
extension LibraryAPI: Endpoint {
@@ -62,6 +63,8 @@ extension LibraryAPI: Endpoint {
6263
return "/v1/library/external"
6364
case .externalResourceToDownload:
6465
return "/v1/library/external_set"
66+
case .deleteExternalResource:
67+
return "/v1/library/external"
6568
}
6669
}
6770

@@ -99,6 +102,8 @@ extension LibraryAPI: Endpoint {
99102
return .put
100103
case .externalResourceToDownload:
101104
return .post
105+
case .deleteExternalResource:
106+
return .delete
102107
}
103108
}
104109

@@ -181,6 +186,12 @@ extension LibraryAPI: Endpoint {
181186
"uuid": uuid,
182187
"uploaded": uploaded
183188
]
189+
case .deleteExternalResource(let uuid, let providerName, let providerId):
190+
return [
191+
"uuid": uuid,
192+
"providerName": providerName,
193+
"providerId": providerId
194+
]
184195
}
185196
}
186197
}

Shared/Services/Sync/LibraryItemSyncOperation.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,17 @@ class LibraryItemSyncOperation: Operation, BPLogger {
147147
case .externalResourceToDownload:
148148
try await handleExternalResourceToDownload()
149149
finish()
150+
case .deleteExternalResource:
151+
guard
152+
let providerName = parameters["providerName"] as? String,
153+
let providerId = parameters["providerId"] as? String
154+
else {
155+
throw BookPlayerError.runtimeError("Missing parameters for deleting an external resource")
156+
}
157+
let _: Empty = try await self.provider.request(
158+
.deleteExternalResource(uuid: uuid, providerName: providerName, providerId: providerId)
159+
)
160+
finish()
150161
}
151162
} catch {
152163
self.error = error

0 commit comments

Comments
 (0)