Open-source audiobook player for iOS and watchOS. Swift, hybrid UIKit-Coordinators + SwiftUI (MVVM).
Companion to the Android app; both share the BookPlayer backend (auth, per-user cloud sync, subscriptions).
Handles offline audio playback, background/lock-screen playback, cloud sync, auth, and paid entitlements —
so the highest-severity defects are in memory/concurrency, player & AVAudioSession lifecycle, thread-correct
persistence, and auth/entitlement handling. Default branch: develop.
This file is the architecture & conventions ground truth. It is read by human reviewers and by the automated PR reviewer (
.github/workflows/claude-review.yml, which loads.github/claude/review-guide.mdas its rubric and this file as the codebase reference). Keep it accurate; a stale claim here becomes a bad review. When you change an invariant described here, update this file in the same PR.
The on-disk tree is deeply misleading. Before judging where code should live, know this:
- Main app source lives under the nested
BookPlayer/BookPlayer/directory:Player/,Settings/,Profile/,Library/,Search/,Import/,Loading/,SecondOnboarding/,Coordinators/,Services/,Utils/,AppIntents/, and the media-server integrationsJellyfin/,AudiobookShelf/,Hardcover/. - The
BookPlayerKitframework compiles the top-levelShared/folder. TheBookPlayerKit/directory itself only holdsBookPlayerKit.h+Info.plist. Everything cross-target lives inShared/and ispublic. Shared/is compiled into BOTHBookPlayerKit(iOS) andBookPlayerWatchKit(watchOS). Every file inShared/therefore has twoPBXBuildFileentries — one per framework. Adding aShared/file to only one target breaks the watch build.- The top-level
Player/,Services/,Coordinators/,Library/folders are EMPTY STUBS — ignore them. Never add files there. Real code is underBookPlayer/BookPlayer/…orShared/. - Dead code to ignore:
BookPlayer/RootViewController.swiftreferencesBaseViewController/BaseViewModel, which do not exist in the repo — it is orphaned, not part of the live SwiftUI flow.
Nine native targets in BookPlayer.xcodeproj (no Package.swift, no app .xcworkspace):
| Target | Product / type | Role | Source |
|---|---|---|---|
BookPlayer |
"Audiobook Player" · app | iOS app | BookPlayer/BookPlayer/… |
BookPlayerKit |
framework (iOS) | Shared framework | top-level Shared/ |
BookPlayerWatchKit |
framework (watchOS) | Shared framework | top-level Shared/ (same files) |
BookPlayerWatch |
app (watchOS) | Watch app | BookPlayerWatch/ |
BookPlayerWidgetsPhone |
app-extension | iOS widgets | BookPlayerWidgets/ (Phone/ + Shared/) |
BookPlayerWidgetsWatch |
app-extension | watchOS widgets/complications | BookPlayerWidgets/Shared/ |
BookPlayerShareExtension |
app-extension | Share-sheet import | BookPlayerShareExtension/ |
BookPlayerTests |
"Audiobook PlayerTests" · unit-test | Unit + perf tests | BookPlayerTests/ |
BookPlayerUITests |
UI-test bundle | Release check only (never CI), see below | BookPlayerUITests/ |
Deployment targets: iOS 26.0 for every iOS target, watchOS 10.0 for the watch app, BookPlayerWatchKit
and the watch widgets. An #available(iOS 18…26) check is dead code on iOS, but Shared/ and the shared widget
files also compile for watchOS 10, so an iOS-only API there still needs its #if os(iOS) / availability guard.
Dependency rule: app → BookPlayerKit (import BookPlayerKit). Shared/ must not import app-layer
types — that breaks the framework boundary and is a 🔴 finding. Watch/shared code selects the framework with:
#if os(watchOS)
import BookPlayerWatchKit
#else
import BookPlayerKit
#endifDo not "fix" or collapse these conditional imports — they are the mechanism that lets one shared codebase
compile for both platforms. There is no SiriKit Intents extension: its custom intents were replaced by App Intents
(decided). Shortcuts built with the old SiriKit "Sleep Timer" action don't migrate and stop working; the
CustomSleepTimerIntent, EndChapterSleepTimerIntent and CancelSleepTimerIntent App Intents cover it. Time Listened
widgets do migrate: TimeListenedConfigurationIntent adopts CustomIntentMigratedAppIntent (intentClassName
"PlayAndSleepActionIntent"), and the system carries settings over by parameter name and type (sleepTimer, autoplay)
and SleepTimerOption case name. Renaming any of them silently drops what people chose. Without that conformance, a
widget set up with SiriKit freezes and can't be edited until it's re-added.
SiriKit media (INPlayMediaIntent) is still SiriKit, handled in the app (see "Playback suggestions").
RevenueCat (purchases-ios, ~5.78), Sentry (sentry-cocoa, exact 8.36.0), Kingfisher (~8.10),
JellyfinAPI (jellyfin-sdk-swift, ~1.0), Get (~2.2), MarqueeLabel (~4.0.5), DeviceKit (~5.1),
IDZSwiftCommonCrypto (~0.13.1), Themeable (~3.0), ZipArchive (~2.3), DirectoryWatcher (~2.8.6),
BrowserDrop (TortugaPower/SwiftBrowserDrop, ~1.0; the Transfer from Computer server).
BlurHashDecode.swift is vendored (SwiftLint-excluded). RevenueCat + Kingfisher link into the frameworks;
most others link into the app. JellyfinAPI and Get link into the app, both frameworks and BookPlayerTests, so
Xcode builds each as one dynamic …_PackageProduct.framework that the app embeds: one copy of their types (an
APIError thrown inside BookPlayerKit matches the app's catch), not a static copy per binary. SwiftLint runs as a build-phase run-script; Sourcery is run manually (not
a build phase) and its output is committed.
- CI (
.github/workflows/ci.yml): runnerxcode-27(GitHub's preview image), Xcode 27.1 (/Applications/Xcode_27.1.app, set withxcode-select). Xcode 27.1 is required: the upload code calls iOS 27 SDK APIs behind#available(BGTaskScheduler.submitTaskRequest), which Xcode 26 can't compile, and the player uses the iOS 27.1 SDK'sArrangementView(PlayerListPanel), which Xcode 27.0 can't. It copiesDebug.template.xcconfig→Debug.xcconfig, resolves SPM, resets the simulators, thenbuild-for-testingandtest-without-buildingwith theUnit Teststest plan,-only-testing:BookPlayerTests, simulatoriPhone 17. Triggers on push tomain/developand PRs todevelop. Release builds come from Xcode Cloud (ci_scripts/), whose Xcode version is set in App Store Connect and must be 27.1 too. - Release check (
scripts/release-check/release_check.py): run locally by the iOS release skill before a release is tagged, never by CI. It builds the Release configuration for the simulator through the sharedReleaseCheckscheme (CI'sBookPlayerscheme andUnit Testsplan don't includeBookPlayerUITests) and runs four scenarios per supported iOS major (deployment target → SDK, minus the script'sSKIPPED_MAJORS): fresh launch, import, playback, and upgrade from the previous release (cached in~/Library/Caches/BookPlayerReleaseCheck/). A failure with a crash report fails at once; one without gets a single retry on a reset simulator and is reported as "passed on retry" if it then passes (simulators flake; a crash never gets a second chance). If the previous release crashes on an iOS version, the upgrade there starts from the release before it (its users never had data in the crashing one), and is reported as not tested if that one crashes too. It exists because 5.22.1 crashed at launch on iOS < 27 while every check ran on iOS 27. The tests find the UI by fouraccessibilityIdentifiers (library.row.<relativePath>,miniPlayer.info,player.playPause,player.currentTime) and by the English labels "Done" (Import sheet) and "Library" (placement prompt); keepBookPlayerUITests/ReleaseCheckUITests.swiftin step when changing those. The upgrade scenario drives the previous release with labels only, since older builds lack the identifiers, and runs it on the frameworks it embeds: its copy of the.xctestrundrops the candidate'sDYLD_*paths, or a symbol the candidate removed fromBookPlayerKitwould stop the old app at launch. - Lint/format is enforced by tools — do NOT flag style they own.
.swiftlint.ymldisables:line_length,identifier_name,type_name,type_body_length,file_length,nesting,force_try,trailing_comma,trailing_newline,trailing_whitespace,switch_case_alignment,private_over_fileprivate,opening_brace,large_tuple,orphaned_doc_comment,todo. ExcludesBookPlayer/Generated/AutoMockable.generated.swift,BookPlayerTests,BlurHashDecode.swift. - SwiftFormat (
.swiftformat): 4-space indent, explicitself(--self insert),--wraparguments afterfirst,--ifdef noindent. A second Appleswift-formatconfig (.swift-format) says 2-space / 120-col. The two formatters disagree on indentation — do not flag indentation width. force_try/try!is allowed by lint in general. Only flag it on untrusted/remote/decoded data (network, JSON, S3, server payloads), where a bad payload crashes the app.
BookPlayer/AppDelegate.swift(@UIApplicationMain): registers defaults, notification observers, background-refresh tasks,MPRemoteCommandCentertargets, RevenueCat, Sentry, and callsAppServices.shared.setupCoreServices()(async DI). It does not create the window (scene-based).BookPlayer/SceneDelegate.swift: strongly ownsstartingNavigationControllerand aLoadingCoordinator; builds theUIWindow, callscoordinator.start(), sets the nav controller as root.LoadingViewController→LoadingViewModel.initializeDataIfNeeded()→DataInitializerCoordinator(@MainActor) awaitsAppServices.shared.setupCoreServicesTask, handles CoreData errors / backup restore, runs one-time first-launch defaults, then firesonFinish.LoadingCoordinator.didFinishLoadingSequence()force-unwrapsAppServices.shared.coreServices!, buildsMainCoordinator, retains it, and callsstart().MainCoordinator.start()hosts SwiftUIMainViewinsideAppHostingViewController(aUIHostingControllersubclass that only overrides orientation) and modally presents it full-screen over the loading nav controller.MainViewis aTabView(Library / Profile / Settings / Search, on every device) with the mini player as the native tab bottom accessory and a.fullScreenCoverfor the player. From iOS 26.1 an accessory with empty content still shows as an empty bar, so its visibility goes throughtabViewBottomAccessory(isEnabled:)(MiniPlayerModifier), never through emptying the content.
Invariant: the strong chain SceneDelegate → LoadingCoordinator.mainCoordinator → MainCoordinator is the
only thing keeping MainCoordinator alive — don't sever it. Boot ordering matters: several call sites
force-unwrap AppServices.shared.coreServices!, relying on DataInitializerCoordinator having awaited setup
first. Reordering boot risks a launch crash.
@MainActor final class AppServiceswithstatic let shared+private init(). Owns the asyncsetupCoreServicesTask, theDatabaseInitializer, and a sharedPlayerState.CoreServices(BookPlayer/Utils/CoreServices.swift) is a struct of exactly 12 services:accountService,syncQueueService,mediaServerChapterService(MediaServerChapterRefreshService: fills in the chapters of streamed books that arrived through sync with none, for lite/pro accounts;ListSyncRefreshService.syncListdrives it per level through the one-methodMediaServerChapterRefreshingseam),dataManager,hardcoverService,libraryService,playbackService,playerLoaderService,playerManager,preferencesService(PreferencesSyncService),syncService,watchService(PhoneWatchConnectivityService).- Two-step
init()+setup(...)DI pattern: services are created empty then configured, e.g.A new service should follow this pattern and be wired throughlet service = LibraryService() service.setup(dataManager: dataManager, audioMetadataService: audioMetadataService)
AppServices/CoreServices— not instantiated ad hoc in a view. (PlayerManagerandPhoneWatchConnectivityServicetake everything viainit.) - Services → coordinators:
CoreServicesis passed whole intoMainCoordinator.init(...), which also builds coordinator-scoped services (ImportManager,ListSyncRefreshService,SingleFileDownloadService,JellyfinConnectionService,AudiobookShelfConnectionService).ListSyncRefreshService.syncList(at:)is the ONE list-refresh entry point (list appear, pull-to-refresh, sync activation, CarPlay): cloud contents for the level → the level's media-server chapter refresh → preferences pull. Media-server positions are never pulled (decided; Android never had it): progress only goes out, and a streamed book starts at zero. A folder's cloud step is skipped until the first sync has run (SyncService.hasRunFirstSync, backed byhasScheduledLibraryContents): its listing would delete local items the server hasn't seen yet. The root refresh then asks, without waiting, forSyncService.scheduleMissingItemsIfNeeded()(the weekly / became-PRO missing-items pass). The chapter refresh runs strictly AFTER the cloud step and never alongside it (cloud writes on the background context, the chapters ingest on the view context; no merge policy), whatever the cloud outcome. It is resource-first:LibraryService.findChapterlessMediaServerResources(at:)fetches only the media-serverExternalResourcerows of the level's books that still have no chapters (direct children, providers filtered in SQL fromProviderName.mediaServerRawValues) on the background context, so in the steady state nothing is asked and the main thread does nothing but the ingest. A book streamed on this device gets its chapters at import.ExternalResource.ProviderName.mediaServer— which maps a provider onto theExternalResource.MediaServerProvidersub-enum (jellyfin,audiobookshelf) — is the single exhaustive "is this a media server" switch;isMediaServer,SimpleExternalResource.mediaServer, the SQL filter, and every server-only switch (stream source, host display, progress push, the library-row glyph) derive from it. - Services → SwiftUI:
ObservableObjects (playerManager,importManager,singleFileDownloadService,listSyncRefreshService) via.environmentObject, plusexternalImportEvents, whichMainViewowns as a@StateObjectand injects itself — the coordinator builds services, not SwiftUI-internal wires; the rest via.environment(\.key, …).ExternalImportEventsis a stateless wire (integrations send confirmed virtual-import batches;LibraryRootViewconsumes and inserts) that conforms toObservableObjectsolely for the loud-injection contract —ImportManageritself carries no external-import state or publishers. The environment keys live inBookPlayer/Utils/Extensions/Environment+BookPlayer.swift(@Entry). Each@Entrydefault is a throwaway placeholder (an un-setup()service). A view that reads the environment default instead of the injected instance gets a non-functional service: stored-property reads return inert defaults, but METHODS that touch un-setup()dependencies trap on their implicitly-unwrapped optionals (SyncQueueService.observeQueueCountsand siblings all behave this way — it is the pattern, not a defect). Verify real injection byMainCoordinator; previews that exercise such views must construct and inject set-up services (seeProfileSyncTasksSectionView's preview), never rely on the defaults. - App Intents DI: only
playerLoaderServiceandlibraryServiceare registered viaAppDependencyManager.shared.add(...)insetupCoreServices(). The extension targets consume them with@Dependency(under#if !MAIN_APP); the main app instead resolves viaawait AppServices.shared.awaitCoreServices(). A new@Dependencytype must beadd()-ed or intents trap.
@MainActor protocol Coordinator: AnyObject { var flow: BPCoordinatorPresentationFlow; func start() }.BPCoordinatorPresentationFlowhas three concrete variants (factory sugar in parens):BPPushPresentationFlow(.pushFlow),BPModalPresentationFlow(.modalFlow),BPModalOnlyPresentationFlow(.modalOnlyFlow, whosenavigationControllergetter is afatalErrortrap). All back-references (navigationController,presentingController) areunowned— presenting a flow whose presenter was dismissed crashes.- Live coordinators:
MainCoordinator(the SwiftUI bridge — aNSObject, notCoordinator-conforming, alsoPurchasesDelegate/Themeable/AlertPresenter),LoadingCoordinator,DataInitializerCoordinator,ImportCoordinator,SecondOnboardingCoordinator,SupportFlowCoordinator.LibraryListCoordinator/PlayerCoordinator/ItemListCoordinatorno longer exist — those flows are SwiftUI now. MainCoordinator.importCoordinatorisweak— the presented VC/flow must retain the import coordinator.- Keep new coordinators and view models
@MainActor.
- SwiftUI-era (dominant):
@MainActor class XViewModel: XViewModelProtocolwhere the protocol is@MainActor …: ObservableObject. VMs live next to their view in the feature folder. Services are constructor-injected, sourced from the view's@Environment. Navigation usesvar onTransition: BPTransition<Routes>?with a nestedenum Routes(BPTransition<T> = (T) -> Void). - UIKit-era (legacy, mostly
Loading*):MVVMControllerProtocol+@MainActor ViewModelProtocolwith aweak var coordinator. The genericBaseViewController/BaseViewModelbase classes are not in the repo.
Custom Notification.Names (namespaced with the bundle id at runtime) are the app ⇄ BookPlayerKit ⇄ Watch ⇄
CarPlay event bus. Declared in Shared/Extensions/Notification+BookPlayerKit.swift (framework-wide):
.chapterChange, .bookReady, .bookPlayed, .bookPaused, .bookEnd, .bookPlaying, .accountUpdate,
.logout, .messageReceived, .folderProgressUpdated, .uploadProgressUpdated, .uploadCompleted,
.listeningProgressChanged, .syncTaskPaused, .backgroundSessionFinishedEvents, .bookUploadsQueued; and app-internal ones in BookPlayer/Utils/Extensions/Notification+BookPlayer.swift.
PlayerManageris the dominant publisher of playback events;PhoneWatchConnectivityServiceandCarPlayManagerare the dominant cross-target subscribers;AccountServiceis the auth/account hub..logout(posted byAccountService.logout()) fans out teardown toSyncService,PreferencesSyncService, and Watch/Profile views..accountUpdatepropagates subscription state.- Because these names cross target/process boundaries, renaming a name or its raw string silently breaks cross-process delivery — treat renames as behavior changes.
- Store lives in the App Group container:
containerURL(forSecurityApplicationGroupIdentifier: Constants.ApplicationGroupIdentifier)! + "BookPlayer.sqlite"(shared with widgets/watch/extension). The force-unwrap crashes if the App Group entitlement is misconfigured. CoreDataStack.swift:shouldInferMappingModelAutomatically = false(migrations are manual),shouldMigrateStoreAutomatically = true.viewContextfor UI reads; a single cached lazybackgroundContextfor background work — bothautomaticallyMergesChangesFromParent = true.DataManageris the facade (getContext(),getBackgroundContext(),saveSyncContext, debouncedscheduleSaveContext).saveContextfatalErrors on any save failure — feeding it inconsistent state (e.g. a unique-constraint conflict) is a hard crash. No merge policy is set anywhere → the defaultNSErrorMergePolicyturns write conflicts into crashes rather than reconciling them.- Never pass an
NSManagedObjectacross threads/contexts or out of a service. Convert to a thread-safe value snapshot first (Shared/CoreData/Lightweight-Models/):SimpleLibraryItem,SimpleChapter,SimpleBookmark,SimpleTheme,SimpleAccount,SimplePlaybackRecord,SimpleHardcoverBook,SimpleItemType,LibraryItemRef,PlayableChapter.PlayableItemis the one exception — afinal class: NSObject(mutable,Codable), not an immutable struct — scrutinize its mutation/threading. - Entities (
Shared/CoreData/Backed-Models/): abstractLibraryItem(itsencode/init(from:)fatalError— concrete subclassesBook/Foldermust be used), plusLibrary,Bookmark,Chapter,Account,Theme,PlaybackRecord,HardcoverBook.ItemType: Int16 { folder, bound, book }. - Migration is manual and staged (
Shared/CoreData/Migrations/DataMigrationManager.swift+DBVersion.swift, currentlyv1…v12, current modelAudiobook Player 12— v12 adds theExternalResourceentity + theLibraryItem.externalResourcesrelationship for media-server items). It migrates one version at a time using explicit.xcmappingmodels where present (v1→v2 … v3→v4,v7→v8 … v11→v12; the v4–v7 hops rely on inference). A model change requires: (1) new.xcdatamodelversion + bump.xccurrentversion; (2) newDBVersioncase +model(); (3) a mapping model registered inmappingModelName()(inference is OFF, so anything non-trivial fails without one); (4) any custom data population added to the post-migration step; (5) bundled resources.DatabaseInitializer+DatabaseBackupServiceare the only safety net for a failed migration (the migrator deletes the old store before moving the new one into place — interruption = data loss).
TasksDataManager.swiftowns theModelContainer. Store isapplicationSupportDirectory/bp-synctasks.sqlite— the app-support dir, NOT the App Group, separate from the CoreData store. CloudKit disabled. Before the container is built,storeIsUnknownToMigrationPlan(at:)checks the store's own metadata against everyMigrationPlan.schemasmodel; a store none of them can open (dev builds between schema edits, an older build over a newer store) is MOVED aside (.incompatiblesuffix, never deleted) and a fresh one is created. Don't match Cocoa codes (134504/134100) on the thrown error instead — SwiftData wraps them opaquely, so that guard never fired. Any load failure that remains — a custom-migration-stage bug, a corrupt file, a fresh store that won't open — still crashes (fatalError).- Versioned schema:
SchemaV1(10 models) →SchemaV2(11 models, addsMatchUuidsTaskModel+ auuidfield) →SchemaV3(unified concurrent-task container:QueuedTaskReferenceModelwith per-queue keys, plusExternalUpdateTaskModel/UploadFileTaskModelpayloads). App code always uses the V3 typealiases. MigrationPlan.swift(SchemaMigrationPlan) has a customv1ToV2stage that reads UUIDs out of the CoreDataLibraryItemtable — it requiresMigrationPlan.injectedCoreDataContextto be set first, elsefatalError(thev2ToV3stage also uses the injected context, but degrades gracefully when absent). This is the coupling between the two stores; set it before theModelContaineris built.ModelContextis per-actor and notSendable. All task-queue reads/writes go throughpublic actor SyncQueueRepository: ModelActor(Shared/Services/SyncQueue/) with a single confinedModelContext(it replaced the oldSyncTasksStorageactor). Do not share/pass aModelContextacross actors or threads. Execution lives inSyncQueueService(OperationQueue): thesyncqueue key runs BookPlayer-server jobs serially; provider-named keys (externalUpdate pushes) anduploadFilerun concurrently. The engine is the single owner of queue counts:observeQueueCounts()publishes one per-laneQueueCountssnapshot (the Profile row shows itstotal; the single Queued Tasks screen — oneDisclosureGroupper lane, sync first — readscount(in:)for its headers and lists every lane throughgetOrderedQueuedJobs).SyncService.canSyncListContentsgates list refresh on thesynclane only (repositorygetTasksCount(in:)), andAppDelegate.handleAppRefreshwaits onlaneDrained(TaskQueueKey.sync)— S3 uploads and provider pushes never block a refresh or hold a background window open (pushes retry forever against an unreachable server). Parked tasks count toward that pull gate on purpose, as on Android: a task the server keeps refusing, a leafupdate/uploadArtworkincluded, holds every pull (and a pending first sync) until it's resolved. There is no Skip (decided), so the way out is the parked row's Report, which reaches support. Server-lane gate: the BookPlayer-server lanes (sync,uploadFile) run only whileSyncQueueService.serverLanesEnabledis on. It starts off (workers wake insetup, beforeSyncServiceis set up) and mirrorsSyncService.isActive—SyncServiceis the ONLY writer:setupin the same synchronous call asisActive,updateSyncEnabledandlogoutinside the main-actor block that writesisActive(so a fast re-login can't be overwritten by a late logout write). Workers check it before every pop, so a lapse stops a running lane after its current task. Gated tasks are held, never cleared: RevenueCat's cached info is nil before the first fetch, so a paying subscriber can read inactive at launch. Provider lanes ignore the gate, andhandleAppRefreshcompletes immediately while it's off (a gated lane never drains). On a cold launch into the refresh task,handleAppRefreshfirst awaits the core services and only then sets its expiration handler. That's deliberate:BGTask.hsays a task without an expiration handler is marked complete and unsuccessful by the system when its time runs out (the app isn't killed for it), so the only cost of a load that outlasts the window (a big migration, once per model version) is one skipped resubmit. A provisional handler would need a cross-thread once-flag so the refresh can't complete the task twice: decided not worth it. Parking: a failed task parks ONLY on a coded failure —SyncFailurePolicy.codedFailurereturns aCodedFailurefor the server's coded 4xx (BookPlayerError.networkErrorWithCode, itserrorkey) and for the app's ownUploadFileError.fileTooLarge(file_too_large, no HTTP status); anything uncoded (network, 5xx, the engine's retry-later) keeps the 5 s retry. Errors from bothLibraryItemSyncOperationandFileUploadOperationreach it.SyncFailurePolicyowns the rules: leaf tasks (update,uploadArtwork,uploadFile) park alone (TaskPauseScope.task, the lane keeps running); every other sync-lane task is structural and stops its lane (.lane);not_subscribed/tier_requiredpark.account(holds every server lane) and trigger a fresh RevenueCat read of the access level, judged by the task's route (SyncFailurePolicy.accountVerdict): no sync tier left runs the normal lapse path; LITE at a PRO-only S3 route (book file, artwork) drops the task, since the server was right; PRO, LITE at any other route, or a failed read keeps the tasks held and reports;externalUpdatekeeps its own handling. The watch setsparkingEnabled = false(no UI there), so task-level coded failures drop. The pause lives onQueuedTaskReferenceModel(pauseScope, code, message, status,pausedAt,sentryEventId);SyncQueueRepository.getNextTaskskips.taskrows and stops at a.lane/.accounthead, andTasksDataManager.queueCountsmirrors those rules (paused counts, blocked lanes;laneDrainedcounts a lane with nothing runnable as drained). Parked tasks and the running task (tracked by id in the repository — a Retry can put a resumed task ahead of it) are never coalescing targets. Retry = one automatic resume at launch (setup) plusretryPausedTask(id:); there is deliberately no Skip. A first park posts.syncTaskPaused, skipped when the row already carries asentryEventId(recorded viarecordPauseReport; it survives resumes) and for a too-large book (decided: an expected limit, nothing to act on). The app-targetSyncPauseReporter(owned byAppServices) turns it into ONE Sentry warning per task, fingerprint["sync-paused", jobType, errorCode], with a minimal payload (job type, code, status, lane, scope, item uuid) — never the server message or a path: both embed file names. The Queued Tasks row of a parked task shows the server's message with Retry and Report; Report mails (or, without Mail, shares)SyncPauseReport: the paused task, every queued task with its state, and the local library tree with uuids. A too-large book (file_too_large) instead shows the app's own message and a Dismiss action — the only dismissible pause (dismissPausedTask(id:)refuses anything else; server-refused tasks never get a Skip). A blocked lane's header turns red, Profile's "Queued sync tasks" title turns red with a warning icon while anything is parked (its caption keeps showing last sync / progress), and a blocked pull-to-refresh says sync is paused.- Realm is gone (Realm → SwiftData migration is complete). Only inert remnants remain
(
DataManager.getSyncTasksRealmURL()is dead; a stale comment inLibraryService). Don't reintroduce it.
PlayerManager.swift is final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject (~1500
lines). It is the highest-risk file in the app.
- Exactly one
AVPlayerat a time (var audioPlayer). It is recreated viasetupPlayerInstance()onmediaServicesWereResetand on.failedstatus. Recreation must re-add the 1-second periodic time observer (removed first, or it leaks onto an orphaned player) and re-bind the time-control passthrough. - Named single-purpose cancellables, each
.cancel()'d before rebind (distinct from the multi-sinkdisposeBag):timeControlSubscription(bridges the recreatable player'stimeControlStatusinto a stableCurrentValueSubjectsoisPlayingobservers survive recreation),playableChapterSubscription,isPlayingSubscription,nowPlayingClaimSubscription. Match this pattern; don't convert a named rebind-able subscription into adisposeBagentry. - Invariant — every
currentItemreassignment must be followed bybindPlayableChapterSubscription(3 call sites:load,loadRemoteURLAsset,reloadCurrentItem). Missing it silently breaks the end-of-chapter sleep timer and the Now Playing chapter title (the.chapterChangenotification is posted from that sink). - KVO on
AVPlayerItem.statusis balanced viaobserveStatusdidSet + ahasObserverRegisteredguard; nulling the player item resets the guard. An imbalance crashes onremoveObserver. - AVAudioSession: activated in
play()(.playback/.spokenAudio); deactivated ~0.1s after.paused(delay avoids clipping). Interruption observer is kept on.began(so.ended+.shouldResumecan resume) and removed on user pause/stop.mediaServicesWereResetre-applies the category and recreates the player. Session-activation failurefatalErrors in production (only downgraded to a Sentry capture on TestFlight) — a real crash surface. - Background task pairing lives in
AppServices.loadAndKeepAlive(...):beginBackgroundTask(withName: "streaming-playback")must be matched byendBackgroundTaskon all three paths — error, success, and the expiration handler — guarded by the.invalidsentinel to avoid a double-end. Any new begin without a matching end on every path (incl. errors) is a 🔴 leak/expiration crash. MPRemoteCommandCentertargets are wired once inAppDelegate.setupMPRemoteCommands()(play/pause/toggle, skip fwd/back, change-position); re-adding targets duplicates actions.MPNowPlayingInfoCenteris pushed after every relevant mutation.SleepTimer.swiftis a singleton (.shared) with@Published state(.off / .countdown / .endOfChapter). Countdown uses aTimer.publishsubscription;.endOfChapterobserves.chapterChange/.bookEnd.reset()cancels the subscription and removes observers, andsetTimeralways resets first. It emits three publishers consumed byPlayerManager(threshold volume-fade, end→pause, turned-on→.sleepbookmark; shake-to-resume viaShakeMotionService).- Playback suggestions (what the Lock Screen, Control Center and Home app offer while nothing plays) come only
from SiriKit media; App Intents, iOS 27's
.audioschemas included, has no equivalent.MediaSuggestionsController(app target, owned byAppServices) donates anINPlayMediaIntentwhen a book starts (.bookPlayed, once per book until another plays or it's finished), sets the 5 most recent unfinished books asINUpcomingMediaManagersuggestions, and makes anINMediaUserContextcurrent once per launch: book count, and.subscribedfor PRO/LITE only, anyone else left.unknown, never.notSubscribed(decided: free users play their whole library). Every intent names the book asmediaContaineralone, andIntents.intentdefinition's PlayMedia must keep itsmediaContainer-only supported combination: that combination is the opt-in. PlayMedia is the definition's only entry, compiled into the app target alone (no generated classes). A book is named by its uuid, or its path without a real one (PlayableItem.id,SimpleLibraryItem.stableIdentifier). Its donations are deleted when it's finished (metadataUpdatePublisher) or deleted (LibraryService.delete), and when an id played from one is gone, no longer a book (a bound book made a plain folder), or now plays as another item (a part donated before its folder was bound plays the bound book; a path id, the book's uuid). The upcoming list is rebuilt only at launch, when a different book starts and when one is finished or marked unfinished, so a deleted book (or one finished through sync) stays in it until then (decided: no deletion signal).PlayMediaIntentHandlerplays them: the app declares the intent (INIntentsSupported, Audiobooks media category) and returns the handler fromapplication(_:handlerFor:), so a suggestion, Siri or a Shortcut starts playback throughawaitCoreServices+loadAndKeepAlive, cold launch included. A media intent the system opens the app with instead (a user activity, at launch or later) goes through the same handler (ActionParserService), neverCommandParser's play-the-last-book. A spoken title or author plays the most recently played of up to 50 matches, a part of a bound book counting as the bound book (as in the Search tab), even over an exact title match (decided: "Play Dune" while in "Dune Messiah" plays Messiah). - Related services (protocols marked
/// sourcery: AutoMockable):SpeedService(per-book vs global speed),ShakeMotionService,PlayerLoaderService,WidgetReloadService.PlayerManagerProtocolis AutoMockable but its mock does not reproduceObservableObject/@Published— Combine-dependent tests need the concrete class.
NetworkClient.swift(URLSession): base URL from Info.plist config (scheme/domain/port). Bearer token pulled from Keychain (.token) per request — but only whenuseKeychain == true(the default). Errors map toBookPlayerError(4xxdecodeErrorResponse→.networkError/.networkErrorWithCode,5xx→.networkError). Decoder is.iso8601.- The bearer token must never be attached to S3/presigned or third-party (Jellyfin/ABS/Hardcover) URLs.
S3 presigned PUTs through
NetworkClient(folders and bound books inhandleUploadJob, artwork) useuseKeychain: false(the URL carries its own auth); book files never go throughNetworkClient— their parts are header-less background upload tasks (below). Media-server calls use their own connection tokens. Any newrequest(url:...)must setuseKeychaindeliberately — the defaulttrueattaches the JWT to whatever host is passed. - Background
URLSessions (Shared/Network/BPURLSession.swift): two sessions (.backgroundand.background.cellular) chosen by theallowCellularDatadefault; downloads viaBPDownloadURLSession. They carry multipart upload PARTS, one upload task each, described<uuid>#<partNumber>@<uploadId>(BackgroundPartUploadTransport);progressPublisheremits(task, bytesSent). Background wakes:AppDelegate.application(_:handleEventsForBackgroundURLSession:)hands iOS's completion handler toBackgroundSessionWakeCoordinator(names inBackgroundTransferSessions). For the upload sessions it recreates them and answers only afterSyncQueueService.settleUploads()— the running upload has handled the delivered parts and queued the next ones (FileUploadOperation.settle()) — underbeginBackgroundTask, with a 25 s cap; calling the handler earlier suspends the app mid-request. The downloads session waits the same way forSyncService.settleDownloads()(each finished download's follow-up: chapters, verification, scheduling — Core Data in the App Group container); any other session (single-file media-server downloads) is answered right away. Both delegates post.backgroundSessionFinishedEventsfrom SERIAL delegate queues (so it follows the last completion); either order of handler and events works. The coordinator is created indidFinishLaunching, before anything can wake a session. These session names are NOTBGTaskSchedulerPermittedIdentifiers. Continued task:UploadContinuationController(app target, owned byAppServices, registered at launch as<bundle>.uploads.continued) runs ONEBGContinuedProcessingTaskfor the whole upload queue, so uploads keep full speed in the background (Live Activity "Uploading files", "(x / n) ", progress in bytes). It is submitted whenever books are queued for upload —.bookUploadsQueued(posted bySyncService.handleItemsToUploadfor an import, and by the missing-items pass for the first sync and on becoming PRO, never its weekly run; decided: always, no size threshold), on a parked task's Retry, and from the "Continue in background" buttons (the Queued Tasks File Uploads header, a borderless button under Profile's Queued Tasks link; shown only whenisOfferAvailable) — only from the foreground, with S3 access, when the Wi-Fi-only setting allows the current network, and when some waiting book sits in a lane that can run (not all behind a blocked lane). Its books come fromSyncQueueService.pendingBookUploads()(one repository read of both lanes: the sync lane's book.uploads — not media-server ones, which carry no file — and pipe jobs, then the upload lane; parked ones counted apart); refreshes are coalesced (one in flight). It ends when the queue stays empty for a moment (a book between lanes can look absent; the upload task is stored BEFORE the sync.uploadis popped), unsuccessfully if only parked uploads remain or nothing can move (no S3 access, every waiting book behind a blocked lane). On expiry or a Live Activity cancel it answerssetTaskCompleted(false)in the handler and the parts carry on in the background sessions. A task iOS launches before the services exist is held, not failed; a request iOS dropped (pendingTaskRequests) no longer blocks the next submit. While a task runs, Profile's caption reads "Uploading in background · N%" (runningPercent); Profile stays two lines (no Wi-Fi caption there — the banner is Queued Tasks only). - Book uploads are S3 multipart (
FileUploadOperation, theuploadFilelane; contract in bookplayer-apidocs/multipart-uploads.md). A non-nilurlfromPUT /v1/libraryonly means "the server needs the bytes" — the client never PUTs to it. The server setssyncedat/upload/complete; the client never confirms a book upload it made (folders and bound books are confirmed after their presigned PUT). A nilurlis confirmed withsynced:truefor folders and bound books on every tier, but for a book only when the tier has S3 access (LibraryItemSyncOperation.canUploadFiles, fromaccessPolicy[.uploadFile]whencreateOperationbuilds it): there it means the file is already in S3, while on LITE it only means the tier stores no file, so LITE books staysynced=falseand the missing-items pass (below) queues their files on becoming PRO. S3 is the source of truth: every run rebuilds fromGET /upload/partsplus the session's tasks for the currentuploadId, so a relaunch or a lost event resumes without re-sending. The resumable state (uploadId,partSize,fileSize,restartCount) lives onUploadFileTaskModel; parts are sliced totmp/uploads/<uuid>/; 64 MiB parts, 8 in flight (fewer on low disk; with room for none it waits, checking again on the 30 s reconcile), fresh part URLs every top-up (403 = expired), up to 3 restarts (upload_not_found/invalid_parts/NoSuchUpload) before the task parks with the server's code — the dead upload is forgotten and the budget reset first, so a Retry starts fresh.completeansweringparts_missinggets one pass that re-reads S3's list and re-sends the gaps; if that list shows every part (completereads the same list), orcompletestill answersparts_missing, the task parks at once the same way, never restarting to re-send the whole file; books over 10 GiB park asfile_too_large. The source is the temp hard link, else the book's current Processed file found by uuid (LibrarySyncProtocol.fetchRelativePath(forUuid:)) — never deleted. A cellular-setting change cancels the parts in flight so they resend through the session that now applies.item_not_foundfrom the upload routes is not parked the first time: the book is re-registered through the sync lane from its CURRENT state (LibrarySyncProtocol.fetchSyncableItem(forUuid:)) — item, external resources, bookmarks, likeSyncService.handleItemsToUpload— before the task is popped, or dropped when it's gone locally too. Always re-register (decided): a book deleted on another device mid-upload can come back. A SECONDitem_not_foundfor the same uuid in a session parks it (re-registering didn't help: e.g. another uuid holds its key on the server). A Retry of that parked upload clears the mark (decided: the user asked for the upload), so the book is registered once more instead of parking again. Only a MEDIA-SERVER link (SyncableItem.mediaServerProviderName, never Hardcover) marks an.uploadas provider-backed (its answer then schedules no file); such a book's file goes up once downloaded through the sync-laneexternalResourceToDownloadjob, which just schedules the upload, on the phone only (the watch keeps no media-server connections, so what it downloads came from the cloud). The oldexternal_setroute is gone;completemarks the resources downloaded. - Missing-items pass (
SyncService.runMissingItemsPass; contract in bookplayer-apidocs/multipart-uploads.md): sends every local uuid in ONEPOST /v1/library/statusand gets{ unknown, unsynced }back. Unknown items (no server row, active or deleted) are first matched by path INLINE (POST /uuids, conflicts adopted in the library and the queue viaapplyUuidConflicts, items re-read) —PUT /at a path the server holds under no uuid or another one would never store ours, and a queued match job could rewrite uuids while the registrations were still being stored — then registered like an import (handleItemsToUpload, parents first). Unsynced books (PRO only:accessPolicy[.uploadFile]) go straight to the upload lane BY UUID (scheduleMissingFileUploads, the Processed file as source), never re-PUT: a stale path would move them back. It skips media-server books (no backfill), books without a local file or over 10 GiB, and any book with an upload already queued, parked included (storeFileUploadsIfAbsent: checked and stored in one actor turn, one save). It IS the first sync's registration step (syncLibraryContents: the pass, then a root pull that never deletes);/keysis deprecated and no longer called. The first sync never clears queued work: it waits until the sync lane is empty (the flag stays off, the next root refresh retries), and every run of the pass is single-flight. A run remembers the sync session it began in: sync going off (logout, a lapse) bumps it and clears the flag in one locked step, so a logout+sign-in during a pass can't let the old run mark the first sync done after its teardown wiped what it queued. Registration reads bookmarks in batches on a background context (getUserBookmarks). A lapse (updateSyncEnabled(false), orsetupfinding a signed-in account —getAccountId() != nil— that isn't syncing: the entitlement expired while the app was closed, or a stale cached read, which is harmless) clearshasScheduledLibraryContents, so coming back runs a first sync: books imported meanwhile aren't on the server, and a plain listing would delete them.scheduleMissingItemsIfNeeded()runs it again on LITE → PRO (the.accountUpdatesink'snoteProAccesssetsmissingItemsPassPendingon a change oflastKnownProAccess— its first reading, e.g. after an app update, only seeds it — cleared only by a run that could queue files, i.e. once the queue had its PRO policy) and weekly (missingItemsPassLastRun), only once the first sync ran, while sync is on and with an empty sync lane; a tier change arriving during a weekly run runs right after it; only a pending (tier-change) run posts.bookUploadsQueued. Its state lives in theUserDefaultsgiven tosetup(.standard; tests inject a suite). Phone only:setup(runsMissingItemsPass:)is true inAppServicesalone (the watch has downloaded files of its own); without it the first sync throws and the other entry points return. Accepted: a pre-March-2026 item that another device deleted under its own uuid, or none, reads as unknown and comes back. The Settings › Debug export is local only (tree with uuids, no server call). SyncService.swiftis@Observable.isActiveispublic private(set)and must be mutated only viaupdateSyncEnabled(_:)/logout()(both hop to@MainActor). It is driven by.logout(→ teardown, clears scheduled-contents flag, resets jobs) and.accountUpdate(→updateSyncEnabled(hasSyncEnabled())) notifications. AteardownTaskis awaited at the top of the sync-contents entry points so a fast logout→login can't let a lateresetAllJobs()wipe freshly-scheduled jobs — preserve this ordering. Everyschedule*method short-circuits onguard isActive.- Sync = the
proORliteentitlement (hasSyncEnabled());litegets DB-backed sync only — S3 file uploads are gated per-job viaSyncQueueService.accessPolicy(.uploadFileis pro-only, and it also gatesSyncService.scheduleUploadArtwork, since artwork goes to S3 too and its route refuses LITE;.externalUpdate— progress pushes to the USER'S OWN media server — is available on every tier, matching the Android app). The policy is set in the queue'ssetup, then refreshed only bySyncService's.accountUpdatelistener (refreshAccessPolicy()), first thing as the update arrives, before the main-actor step that turns the lanes on: the queue has no listener of its own, so a worker can't take a held upload under the previous tier's policy and drop it. Nothing pulls a media server's position back. The media-server chapter refresh is gated onaccountService.hasSyncEnabled()(lite/pro), read live on every refresh: only a synced library brings streamed books without chapters. Job types (SyncJobType):upload, update, move, renameFolder, delete, shallowDelete, setBookmark, deleteBookmark, uploadArtwork, matchUuid, externalResource, externalResourceToDownload, deleteExternalResource(sync lane),uploadFile(upload lane),externalUpdate(provider lanes). - Download verification:
verifyDownloadedFilerejects truncated files by comparingAVURLAssetduration to the stored duration (tolerancemax(2, expected*0.02)); completion is broadcast only after verification. Don't skip it — it prevents promoting a truncated book.
BuildConfiguration/*.xcconfigdefineBP_*keys;Info.plistsubstitutes them ($(BP_…));Shared/Configuration.swift(ConfigurationKeys) reads them.Bundle.configurationValue(for:)usestry!— a missing key crashes at launch. Keys: API scheme/domain/port, bundle id, RevenueCat key, Sentry DSN.Debug.xcconfigandRelease.xcconfigare gitignored and hold the working-tree real values — never commit or overwrite them. Nuance a reviewer should know:Debug.xcconfigis untracked and holds real prod secrets;Release.xcconfigis also gitignored but is already tracked with placeholder values (replace.me) — CI (ci_scripts/ci_post_clone.sh) rewrites it from Xcode Cloud env vars at build time. A new secret must be added to the template (Debug.template.xcconfig) + the CI script +Info.plist+ConfigurationKeysin lockstep, never inlined — or thetry!crashes Release builds. Hardcoded API keys / tokens / Sentry DSN / RevenueCat key are a 🔴 finding.
kSecClassGenericPassword, service = the bundle identifier, accessibilitykSecAttrAccessibleAfterFirstUnlock(so background sync/downloads work while locked). Stores JWT.token,.jellyfinConnection,.audiobookshelfConnection,.hardcoverToken.- Correction to older docs: the Keychain is NOT scoped via an App Group /
kSecAttrAccessGroup— there is no access group set; sharing relies on the default app access group. (The App Groupgroup.$(BP_BUNDLE_IDENTIFIER).filesis used forUserDefaults.sharedDefaultsand file storage, not the Keychain.) - Every mutation emits
valueUpdatedPublisher.send((key, deleted:));HardcoverServiceobserves it to start/stop tracking on token changes.
- RevenueCat entitlements
AccessLevel { free, plus, lite, pro }.hasSyncEnabled()=proORliteactive (pro wins when both are held);hasPlusAccess()=plusORproORlite(all paid tiers unlock plus perks; localdonationMadefallback when cached info is nil). - These are client-side cached RevenueCat reads — UX gating only. Never let
hasSyncEnabled()/isActivebecome the sole gate for a server-billed resource; the server validates the entitlement. Purchases are guarded byAppEnvironment.isPurchaseEnabled(disabled on TestFlight). login(with:)stores the JWT thenPurchases.logIn(revenuecatId): the account'sexternal_id, which every login answer carries (one without it fails to decode rather than signing in to another RevenueCat customer).logout()removes the token, resets the account,Purchases.logOut, and posts.logout.- Auth entry points: Apple/Google sign-in (
Profile/Login/), Passkeys/WebAuthn (Profile/Passkey/, relying partybookplayer.app, endpoints/v1/passkey/*), Watch credential transfer.
All three store secrets in the Keychain (never UserDefaults), persist custom headers alongside connection
data, and share the error type MediaServerIntegration/IntegrationError.swift (note sessionExpired(serverName:)
isSessionExpired). Jellyfin & AudiobookShelf share theMediaServerIntegration/protocol + UI layer.
-
Media Servers opens from two places: the Library tab (
MainView's sheet) and an item's details, stacked over the editor so unsaved edits survive; both use the.libraryEntrystyle. The details' Integrations section (ItemDetailsIntegrationsSectionView, titled as in Settings) holds the Hardcover row and one " " row per media server the item streams from. Tapping that row opens the Media Servers list only, never a server's library directly (decided: landing in a library there looks like picking another book for the item). The server browsers end the flow (their Close, and the close after a confirmed import) through thecloseMediaServersenvironment action, which each presenter supplies; never throughlistStatedirectly. A new presenter must set it and, as a cover over the library, register.mediaServersincoversOnScreen(see the import placement prompt). -
Session-expiry contract: a 401/403 from an authenticated call maps to
sessionExpired(serverName:)(a recoverable "sign in again" path that preserves the connection). Pre-sign-in probes (findServer/ping) must bypass this mapping — otherwise an unrelated saved server gets mis-thrown into re-auth, and users land in the duplicate-connection trap. -
Jellyfin (
Jellyfin/,@MainActor @Observable JellyfinConnectionService, backed byjellyfin-sdk-swift): add-server validates via a transientPendingServerwithout mutating the liveclient;rebuildClientis the single client-construction choke point and must forwardcustomHeaders; theJellyfinHeaderInjectormust never overwriteAuthorization(so Cloudflare-Access headers can't clobber the token). Downloads are delegated toSingleFileDownloadService. -
AudiobookShelf (
AudiobookShelf/,@MainActor @Observable, hand-rolled URLSession):Authorization: Bearerapplied after custom headers; re-auth/delete fire a fire-and-forget/logoutto avoid orphan tokens; image URLs keep the token in the header (KingfisherrequestModifier), not the URL, so a rotated token can't poison the disk cache. URL-encoding footgun: thefilterparam is manually percent-encoded (+→%2B,/→%2F) because ABS/Express corrupts+in a query value — don't route it throughURLQueryItem.- Has no instance id: sign-in stores
serverId: nil(its login'sserverSettings.idis the constant"server-settings"), so an ABS book'shostIdis the server's canonical address (URL.canonicalDedupKey, which must equal what Android phones store: AOSP'sjava.net.URIallows_in hosts). - Streams per file (a contract shared with the Android app): the item
download is a zip for any book in a folder, and a file's id is its inode, which changes when the file is
replaced. So a chapter carries
PlayableChapter.StreamLookupandPlayerManager.streamURLasksGET api/items/{id}?expanded=1(AudiobookShelfStreamLookup) when the chapter loads, once per item (a volume's books share it), never ahead of time. It playsapi/items/{id}/file/{ino}relative to the saved URL, token in the header. Time limits: 5 s on playback (an unreachable server falls through to the cloud copy), 30 s for downloads. Only a 401 is an expired session (its ownPlaybackFailure.Reason, said only for a play the user started and when no cloud copy plays the book); a 403 means this user can't open that item. - Multi-file items import as volumes (
LibraryService.createExternalVolume): a bound folder named after the title holding the link, one book per file named by its flattened path (MediaServerFileNames.volumeChildFileNames). The books have no link of their own: playback (PlaybackService.VolumeStream), downloads (MediaServerDownloadPlanner, one more lookup after a 404), sync registration (SyncableItem.volumeMediaServerProviderName, so no file is asked for) all go through the volume's. A downloaded volume book's file is NOT queued for upload on download: it goes up through the weekly missing-items pass, which doesn't skip it (it has no link of its own). Decided: the volume's link staysstream, so queuing on download would repeat on every later download from the server. Single books are named<title>.<ext>(no provider prefix), with-<uuid prefix>when a book anywhere already has that name. - Downloads go to the media server first (it keeps the S3 egress down), as playback does, and to the cloud
copy when the server can't serve them on this device: no saved connection to it (always the case on the
watch), or a failure (unreachable, session expired, file gone, refused). As on Android. Without a cloud copy
the server's error stands (
SyncService.mediaServerOrCloudFileURLs). - A stream import is created in the folder being browsed (Android's
basePath), and streaming a book already in the library makes another copy (decided; Android to follow), as a file imported again does. So a server's book can have several rows: find a link through its item (findResources(for:)), never by(providerName, providerId)alone. Each copy pushes its progress to the same server item, and the chapter refresh fills every copy linked to it.
- Has no instance id: sign-in stores
-
Hardcover (
Hardcover/,@Observable HardcoverService, GraphQL): two-way reading-progress sync, not a media server. StatusHardcoverBook.Status { local=0, library=1, reading=2, read=3 }; only 1/2/3 are ever POSTed (.localis a local-only marker). Monotonic guards prevent backwards writes; auto-match on import has explicit duplicate detection; API failures are log-and-swallowed so a Hardcover outage never blocks local playback. Token is Keychain.hardcoverToken; no token → subscriptions torn down. It holds the bare API key (hc_pat_…):HardcoverService.apiKey(from:)drops a pastedBearer(older tokens were copied with it, and ones saved that way read without it), and requests sendAuthorization: Bearer <key>, which the API requires.
- Import (
Import/): files enter from the document picker / drag-drop, the share extension (writes into the App Group folder, picked up by aDirectoryWatcher), URL-open/intents, and remote downloads — all converging onImportManager(ObservableObject).ImportOperation(asyncOperation, thread-safe via a barrierlockQueue) detects folder organization, handles zip/lpf (SSZipArchive), collision-safe-copies into the Processed folder, and uses balancedstart/stopAccessingSecurityScopedResource. A copy failureSentrySDK.captures thenfatalErrors — an intentional but real crash surface for bad imports. Book/folder records are created viaLibraryService.createBook/createFolder; artwork is extracted lazily byArtworkService, not inline. App-managed source files are removed after copy. The import's "where should these go?" prompt isImportPlacementPrompt, owned byLibraryRootView, not one of the list's alerts. SwiftUI drops a presentation started while something covers the library or is still leaving it, and a dropped value in the list's singleactiveAlertslot used to block every later list alert until relaunch. So: the import screen starts the import only once it has closed (finishPresentation(animated:completion:)); the prompt waits untilListStateManager.coversOnScreenis empty (covers register in their content'sonAppearand leave in the presentation'sonDismiss: a new cover over the library must do the same), the import screen is gone (ImportManager.isImportScreenShown) and the Library tab is on screen; and it carries the imported items' uuids, reading their current paths when an option is picked (LibraryService.getItemRefs(forUuids:)). The list's own sheets and alerts aren't waited for: the prompt closes one (seen with a sheet), or is dropped under it, lost, and cleared when the next import screen or cover closes (streams are confirmed in the media-server browser) so it can't block later prompts. Its actions and the list's multi-select shareLibraryOrganizer. An alert that leads to another (Move → the folder name) uses a separate alert, set from the first one's button: switching one alert's contents (nil, then the next) can be dropped mid-animation. Transfer from Computer (library add menu →Import/TransferView,MainView's library sheet): a BrowserDrop session (an upload-only HTTP server on the local network, port 80, then 8080, then any free port, no password by design) lives exactly as long as the sheet, which ownsTransferViewModel. The sheet is theImportConfirmationViewshell: itsemptyStateshows the address until the first file arrives, then the files are listed like an import, removable (BrowserDropServer.removeReceivedItem(at:)). Uploads wait in BrowserDrop'stmpstaging and only complete files count; progress shows on the web page only. Done never goes through Documents or the import screen: the files move into an owned holding folder (tmp/TransferImport/<uuid>) and anImportOperation(files:libraryService:ownedSourceRoot:)goes onImportManager.operationPublisher, as the import screen's own Done does, so the library runs it and shows the placement prompt once the sheet's cover is gone. The operation removes that folder when it finishes and suggests no folder named after it. Cancel, closing the sheet any other way, or the app being killed, wipes staging (TransferViewModel.removeLeftovers()at launch clears staging and holding); going to the background suspends the server and coming back resumes it on the same port, keeping what arrived. The sheet keeps the screen awake, restoring the previousisIdleTimerDisabled(the player sets it too). - Library (
Library/ItemList/…, backed byShared/Services/LibraryService.swift): the main list, folders, and drag-drop reordering. Ordering model (query-time sort):orderRankmeans ONLY the user's custom arrangement (written by drag/reverse/Custom-freeze/one-shot sorts and by sync; never by an automatic sort). An automatic sticky sort is applied at fetch time —resolveSortDescriptorsinLibraryServicemaps the location's effective sort toSortType.sortDescriptors(localizedStandardCompare:+orderRanktie-break); sync may overwrite ranks freely — the rendered order only follows ranks where the effective sort resolves to rank order: Custom,.unresolved/bound locations, and any target with nopreferencesServicewired. watchOS wires a pull-onlyPreferencesSyncService(constructed inExtensionDelegate, bootstrapped at launch, refreshed before each list sync inRemoteItemListViewModel), so the watch renders the same sticky sort as the phone; nothing on the watch writes sort prefs, and the pull is gated on the sync entitlement so free accounts never make the request. Rank updates always sync (no auto-sort suppression exists anymore). Both mutation invariants live infreezeVisibleOrder(at:transform:)— the single core ofreorderItems/reverseContents/adoptCurrentOrderAsCustom: (1) capture-before-flip — the effective (visible) descriptors are resolved before the pref flips to.custom, else the mutation acts on rank order instead of what the user sees; (2) the.custompref write precedes the rank rebuild so the next fetch doesn't re-sort the user's arrangement away. Route any new user-arrangement rank mutation through that helper (the one-shot materialization for.unresolvedlocations insortContentsis the deliberate exception — it has no pref key to flip). Playback prev/next walksgetOrderedSiblings(visible order, lightweight entries), not rank cursors. A correct fetch order doesn't update a list already on screen:ItemListViewholds fetched rows, so under "Most recent" it re-fetches when the loaded book changes, andsetLibraryLastBookstamps the book's parent folders too, so parent lists move the folder on that re-fetch instead of after the progress tick's delayed save. On logout,PreferencesSyncServicefreezes automatically-sorted locations into ranks before wipinglibrary_sort:*, so sign-out doesn't visibly re-scramble the library. UI reads onviewContext, background onbackgroundContext, onlySimple*snapshots cross back to UI. A move never lands on a taken name (as on Android):LibraryService.moveItemsmoves each item under its current name (its path's last component, at the root too) and leaves one whose name another item or a file already has at the destination where it is, returning it (a streamed item has no file, so the library is asked). Callers sync only what moved and report the clash withLibraryService.nameTakenError, the errorFileManageritself throws, worded and localized by iOS. "Delete folder only" is refused when a child's name is taken in the folder's parent: nothing moves and the folder stays. Layouts: List, Grid (cards) or Covers (LibraryLayout, synced aslibrary_layout:style); the grid isItemListView+Grid.swift+LibraryTileView. A tile dragged on from a long press (its menu open, or while selecting) reorders, through iOS 27'sreorderable()/reorderContainer; on iOS 26 only the List reorders. AdropConfigurationmakes other apps' files a copy (the "+" badge) and tile drags a move. Two iOS 27.0 SwiftUI bugs shape it: a reorderable item whose view comes down to anif/switchwith only modifiers above it crashes on lift (the tile wrapper is aZStack), and a drop destination on any ancestor of the reorder container (the old tab-wideonDrop, or even the grid's scroll view) misplaces where a tile lands. So files dropped from other apps are accepted per screen (acceptsDroppedFiles()on the grid itself, right afterreorderContainer; on the List; on the empty state), never on an ancestor of the grid.LazyVGridsizes each row to its tallest tile, and a card fills its row (frame(maxHeight: .infinity)), so a row lines up while each card fits its own text (title up to two lines, three at the accessibility sizes; author up to two). A folder is a stack inside a book's square: two dimmed leaves of the default artwork above a shorter front cover, its artwork fitted on a blurred copy of itself (ItemArtworkView'saspectRatioandcontentMode). The tile's content shapes go around the stack, so a folder lifts into its menu or a drag with its leaves. The progress mark is the list's own (CircularProgressView/PercentageProgressView): a card scales the wheel (15% of its width), never the percentage label, which grows with Dynamic Type only. Covers use 100 pt tiles, 140 pt in a regular-width window (iPad, iPhone Duo open). On a Mac ("Designed for iPad", drawn at 77%) every tile minimum is 1.3× and follows the Text Size setting, the Mac's stand-in for Dynamic Type (BPFontModifier.macOSScaleFactor). Across a fold running top to bottom (iPhone Duo half open in landscape, read fromreservedRegions(kind: .division), iOS 27.1) the grid switches to fixed columns that leave the fold in a gap (FoldedTileColumns): one tile width on both sides, so rows still line up, with the leftover space at the outer edges. - Search (
Search/): local-only CoreData search (LibraryService.searchAllBooks), 0.3s debounce, results grouped by parent folder. Remote search lives in the integration view models, not here — don't expect network calls inSearch/. The Search tab finds books only, across the library; selecting it focuses its field (tabViewSearchActivation(.searchTabSelection)). Each library level also has its own search with All / Books / Folders scopes (bpSearchable,LibraryService.filterContents, recursive over the folder's subtree). Closing that search resets the scope to All: the scope bar hides with search, so a leftover scope would keep the list filtered with nothing on screen saying so. - Settings (
Settings/): SwiftUIForm+SettingsScreenroute enum; gating readsaccountService.accessLevel. Integrations entry isSettingsIntegrationsSectionView→MediaServersView/ Hardcover.
- Prefer native Apple / SwiftUI APIs over custom implementations.
- Localization: every user-facing string via
"key".localized(Shared/Extensions/String+BookPlayer.swift→NSLocalizedString; no SwiftGen/L10n). New keys go inBookPlayer/Base.lproj/Localizable.strings; ~27 locales are community-translated — flag a missing Base key or a hardcoded literal, but do not nitpick the wording of existing translations. - Accessibility is first-class (audiobook app, many low-vision users). New interactive SwiftUI controls need
accessibilityLabel(andaccessibilityValuewhere stateful) and must respect Dynamic Type — use thebpFont(_:)modifier, not fixed.font(.system(size:)).Services/VoiceOverService.swiftbuilds VoiceOver strings; live content uses theDynamicAccessibilityLabelmechanism, not a static snapshot. - Mocks are Sourcery
AutoMockable: mark a protocol/// sourcery: AutoMockable; output isBookPlayer/Generated/AutoMockable.generated.swift(DO NOT EDIT, SwiftLint-excluded, regenerate via Sourcery overTemplates/AutoMockable.stencil). A protocol change needs regeneration or the test target won't build. New service logic should come with a test inBookPlayerTests/(XCTest only — no Swift Testing). - App Group correctness: data/defaults/files shared with widgets/watch/extension must use the App Group
container /
UserDefaults.sharedDefaults, not.standard. The App Group idgroup.$(BP_BUNDLE_IDENTIFIER).filesmust stay consistent across the app, watch, widgets, and share-extension entitlements — it is the sole data channel for widgets and the share extension. - Combine: long-lived subscriptions →
private var disposeBag = Set<AnyCancellable>()+.store(in:); single-purpose → a namedAnyCancellable?that is.cancel()'d before rebind.[weak self]in sinks/closures is the norm — match it. A sink that touches UI needs.receive(on: DispatchQueue.main). @MainActoris the UI/service isolation convention. Off-main → main hops are explicit (Task { @MainActor in … }/.receive(on:)).
The crash surfaces and invariants most likely to be broken by a change. (The full severity rubric lives in
.github/claude/review-guide.md; this is the architecture-backed "why".)
- CoreData threading: never pass an
NSManagedObjectacross threads — useSimple*/Playable*snapshots; UI onviewContext, background onbackgroundContext.saveContextfatalErrors; there is no merge policy, so conflicts crash. - CoreData model change without the full 5-step manual-migration ritual (auto-inference is OFF) → crashes existing installs.
- SwiftData: don't share a
ModelContextacross actors; the sync-queue lives behind theSyncQueueRepositoryactor;MigrationPlan.injectedCoreDataContextmust be set before the container is built. - Retain cycles / Combine leaks: missing
[weak self]in a sink; anAnyCancellablenot stored; a named subscription not.cancel()'d before rebind (PlayerManagerdepends on this). - UI/state mutated off the main actor without a
@MainActorhop /.receive(on: .main). - Player / AVAudioSession lifecycle:
currentItemswap without re-binding the chapter subscription; unbalanced KVO on player-item status; unhandled interruption /mediaServicesWereReset; abeginBackgroundTaskwithout a matchingendBackgroundTaskon every path including errors. SyncService.isActiveassigned directly instead of viaupdateSyncEnabled(_:)/logout(), or the.logout/.accountUpdate/teardownTask-await contract broken.- Entitlement gating that trusts client-only RevenueCat state for a server-billed resource, or gates sync
without going through
AccountService. - Secrets: committing/overwriting real
Debug.xcconfig/Release.xcconfig, or hardcoding a key instead of the xcconfig →Configurationpath. - Force-unwrap /
try!on remote or decoded data (network / JSON / S3) — a bad payload crashes. - App Group correctness for anything consumed by widgets/watch/extension.
BookPlayerKitboundary:Shared/importing app-layer types.- Integration session-expiry / token contracts (see the integrations section).
- Hand-editing
Generated/AutoMockable.generated.swift; adding code to a top-level empty stub folder. - SDK-only result-builder content: an
ifor several statements inside aForEachthat builds rotor or toolbar content usesOptional/TupleContentconformances that, with the iOS 27 SDK, exist only on iOS 27 → launch crash on older iOS (5.22.1). Filter the data before theForEachinstead.