Repository navigation
External resource support for media-server items (Jellyfin / AudiobookShelf) - #1586
Merged
Merged
Conversation
Closed
GianniCarlo
force-pushed
the
external-resource-rebased
branch
from
September 9, 2026 19:10
10494d2 to
0781028
Compare
This was referenced Sep 23, 2026
This was referenced Sep 24, 2026
GianniCarlo
force-pushed
the
external-resource-rebased
branch
from
October 5, 2026 15:57
b52d79a to
de1d623
Compare
The Apple/Google login fell back to the Apple credential's id when the response had no `revenuecat_id`, for API versions that predate it. Every login answer has carried it since early 2025 (the account's `external_id`, which every account has: backfilled then, and set on every new one), and the passkey login already required it. A login answer without it now fails to decode, instead of signing in to a different RevenueCat customer than the one the server checks entitlements against. `login(with:userId:)` only took the id for that fallback, so it's `login(with:)` now.
…Profile Both built `observeQueueCounts()` in `body` for `.onReceive`, so each render made a new publisher, subscribed again and took the replayed snapshot. Neither looped, because the values they set were unchanged, but the library list re-renders often (playback progress). They now read the counts in `.task` over `.values`, as Queued Tasks does: once while the screen is up, again when it comes back. Profile no longer needs its compare-before-assign guards. Found by the PR reviewer (the library list); Profile's predates this branch.
`refreshAccessLevel()` isn't main-actor, so after its hop to update the account it went on off main. For an account with no paid entitlement, `hasPlusAccess()` then fell back to the stored account and fetched it on the view context from a background thread, on exactly the path a server rejection of the account takes. The level is now worked out in the same main-actor step that updates the account. Found by the PR reviewer; the read came in with 1a0cd40.
…language When the destination of a Move, or of the import's placement prompt, disappeared before the user confirmed (a sync pull deleting it, say), the error was an English-only "Can't find the folder at <path>". It's now the error iOS words and localizes itself, as `nameTakenError` already does: "The file “Shelf” doesn’t exist." Also brings the comment on the upload-progress notification up to date: its listeners are Profile's sync row, the Queued Tasks rows and the continued upload task; SyncJobScheduler stopped listening in this branch. Both found by the PR reviewer.
…ds uploads The "Wi-Fi required" banner showed whenever the Wi-Fi-only setting was on and the device wasn't on Wi-Fi. Uploads under that setting go over any network but cellular (the session disallows cellular, and the continued task checks the same), so on wired Ethernet they kept running under a banner saying they couldn't. It's now the exact inverse of that check: cellular or offline (where Wi-Fi is still the way out), never Wi-Fi or Ethernet. `NetworkMonitor.isConnectedViaWiFi` had no other reader and is gone. Found by the PR reviewer; the condition predates this branch.
Leftovers of the earlier Queued Tasks screens: `sync_tasks_queued_count_title`, `sync_tasks_concurrent_count_title`, `sync_tasks_running_empty_title`, and the `sync_task_update_progress_label` and `sync_task_upload_file_label` that `task_updating_progress_label` and `task_uploading_file_label` replaced. Removed from every locale.
The progress row of the Stream & Sync sign-up screen still read "Two-Way Progress Sync" and "Your position syncs both ways with Jellyfin or Audiobookshelf", but a media server's position is no longer read back: progress only goes to it. Now "Your Place, Everywhere" and "Your position syncs to Jellyfin or Audiobookshelf, and through your BookPlayer account so every device you use stays in step."
60 of the strings this branch added were only in Base and English: the media-server browser and its errors, the Stream & Sync sign-up screen, Queued Tasks with parked tasks, and the background uploads. Every other locale now has them too, so all 24 match Base again, as on develop. Each translation keeps the terms and the form of address its locale already uses, and the same format specifiers as the English.
…can't serve it A book or volume with a media-server link always downloaded from that server, including books whose file is already in S3. Without a saved connection to the server on the device, or with the server failing (unreachable, session expired, file gone, refused), the download failed outright. The watch never stores media-server connections, so it couldn't download any of them, while playback falls back to the cloud copy and Android does the same for downloads. The media server still comes first, which keeps the S3 egress down. When it can't serve the files and sync is on, the cloud copy is downloaded; without one, the server's own error stands, so the user still hears what to fix. Found by the PR reviewer.
Spells out what the pull gate's count of parked tasks means: a task the server keeps refusing, even a leaf one, holds every pull and a pending first sync until it's resolved, and with no Skip the way out is Report. Decided, and the same on Android; the PR reviewer asked for it to be written down.
… an upload A finished download of a book with its own media-server link always scheduled the follow-up that sends the file to S3. The watch keeps no media-server connections, so whatever it downloads came from the cloud, and there the follow-up could only end in `/upload/start` answering `exists`. It's now scheduled on the phone only; the link is still marked processed everywhere. Not gated on the in-memory "came from the media server" record instead: a download that finishes after a relaunch would lose it, and with the missing-items pass skipping media-server books, that file would never reach the cloud. Found by the PR reviewer.
Loading a streamed volume resolved the volume's link again for every book in it, and each resolve reads the keychain and decodes every saved connection. Audiobookshelf books often have dozens to hundreds of files: at about 0.23 ms a resolve on the simulator, a 100-file book spent ~23 ms of its load there. The volume's link is now resolved once when its `VolumeStream` is built and shared by its books; a book with a link of its own still resolves it. Nothing is kept across loads, so a connection change is picked up as before. Found by the PR reviewer.
…enuecat_id CLAUDE.md now says that JellyfinAPI and Get link into the app, both frameworks and the tests, and that Xcode therefore builds each as a single dynamic package framework the app embeds, so their types exist once. It also documents the login as `Purchases.logIn(revenuecatId)`, without the removed Apple-id fallback. Two JellyfinLibraryItem comments no longer say the test target doesn't link JellyfinAPI. Notes from the PR reviewer.
…es load, on purpose Records the decision the PR reviewer keeps raising: on a cold launch the handler is set only after the core services load. Per BGTask.h the system marks a task without one complete and unsuccessful when its time runs out, so a load that outlasts the window costs one skipped resubmit, and a provisional handler would need a once-flag against completing twice.
…ry item The list a parked task's Report attaches and the Settings › Debug export read every item's path and uuid by fetching each `LibraryItem` as a managed object on the main context: `propertiesToFetch` only narrows a fetch whose result type is dictionaries. On a library of thousands of items that paused the UI before the mail or share sheet. It now fetches just the two columns as dictionaries, like `getSimpleItem` and `fetchRelativePath`. Found by the PR reviewer.
…count requirement `LibraryItem.jellyfinResource` had no callers and picked Jellyfin by name, where every server-only path derives from `ProviderName.mediaServer`; a future caller would have missed Audiobookshelf. It's gone. `IntegrationLibraryViewModelProtocol` required a concrete, settable `AccountService` from every conformer, and the only reader was the generic Jellyfin entity tab, passing it on to its destinations. That tab now takes the account service in its initializer like its other services, and the protocol no longer asks for it. Notes from the PR reviewer.
The External Resources section becomes Integrations, named as in Settings: the Hardcover row and one "<integration> <host>" row per media server the item streams from. Tapping a server row opens the Media Servers list on top of the editor, never a server's library directly, so it can't look like picking another book for the item. The server browsers now end the flow through a closeMediaServers environment action that each presenter supplies, so their Close works from the Library tab and from item details alike. The four strings only the old section used are removed.
… reach useConnection(_:) pins a connection service to one server for the rest of its life, with no way to unpin. Every caller builds its own instance inside BookPlayerKit (progress pushes, chapter lookups), so it's now internal: the app's shared instances, which the UI reads, can't be redirected by a future call. The doc comments now say the pin lasts for the instance's life. Found by the PR reviewer.
A finished download deletes its media-server record before finalizing, so the check that the file came from the server always read false and a volume book's upload was never queued there. Rather than fix it, the branch goes: a volume book's file reaches the cloud through the weekly missing-items pass, which doesn't skip it, while queuing on download would repeat on every later download from the server, since the volume's link stays `stream`. CLAUDE.md now says so. Found by the PR reviewer.
…gration The backfill writes to the shared Core Data background context. A failed save left its rows pending there, so the context's next save would retry them and crash in saveContext. It now rolls back, and the comment says what is lost instead of calling it self-healing: the stage runs once, so those books keep no Hardcover resource.
| /// sessions only upload from files) | ||
| static func partsDirectory(for uuid: String) -> URL { | ||
| FileManager.default.temporaryDirectory | ||
| .appendingPathComponent("uploads", isDirectory: true) |
There was a problem hiding this comment.
🔵 INFO — Temp-directory name clash. Part files go in tmp/uploads/<uuid>/, but SyncJobScheduler.hardLinkURL(for:) puts each item's hard link at tmp/<relativePath>. So a user item named uploads at the library root maps onto the same path.
- A folder named
uploads: registering it (import, first sync, re-registration) runscreateHardLink, which first callsremoveItem(at: tmp/uploads). That deletes the in-flight part files of every book upload. The sessions then fail those parts and the engine slices them again: wasted bandwidth and retries. - A book file named
uploads(no extension): while its hard link exists,slice()can't createtmp/uploads/<uuid>because a file is in the way. Every upload then fails with an uncoded error and retries every 5 s until that link is removed.
It recovers on its own, but the fix is cheap. Put the parts in a directory the library can't name, for example FileManager.default.temporaryDirectory.appendingPathComponent(".bp-multipart-parts", isDirectory: true). Alternatively, move the hard links under their own subdirectory too.
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #1523 — @Hirobreak's External Resource work, squash-rebased onto current develop (the original fork branch has maintainer edits disabled and had diverged ~70 commits from its July base). Feature authorship is preserved on the squash commit.
What this adds
Media-server items (External Resource)
Cross-device sync of media-server books: an item imported from Jellyfin/AudiobookShelf carries an
ExternalResource(provider, provider item id,hostId) that round-trips through the BookPlayer API — a second device resolves the resource to its own saved connection and streams directly from the user's server. Progress pushes back to the media server run on the unifiedSyncQueueengine (SwiftData schema v3), and connections capture the server's stable GUID at sign-in (Jellyfin password, Quick Connect, ABS login). CoreData migrates v11→v12 with a mapping model. The LITE tier lands on iOS (AccessLevel.lite,hasSyncEnabled = pro || lite) with per-job access gating.Book uploads as S3 multipart uploads
uploadFilelane as multipart uploads (64 MiB parts, 8 in flight) on backgroundURLSessions, one task per part. S3 is the source of truth (GET /upload/parts), so a relaunch, force quit or lost event resumes without re-sending; expired part URLs (403) are refreshed; the server setssyncedat/upload/complete.BGContinuedProcessingTaskkeeps the whole queue at full speed in the background, with a Live Activity "(x / n) "; "Continue in background" buttons on Queued Tasks and Profile; background-session wakes are answered only after the work settles.item_not_foundparks it, and an explicit Retry registers it again.Parking tasks the server can never accept
A task parks only on a coded failure (the server's
errorcode, or the app's ownfile_too_large): leaf tasks park alone, structural sync tasks hold their lane, account rejections hold every server lane and re-check RevenueCat. Parked tasks show in Queued Tasks with Retry and Report (mail or share sheet, with the queue and the local library tree); a book over 10 GiB shows Dismiss only and isn't reported. Profile's "Queued sync tasks" title turns red while anything is parked; a blocked lane's header turns red and a blocked pull-to-refresh says sync is paused. One automatic retry at launch.The missing-items pass
One
POST /v1/library/statuswith every local uuid returns{ unknown, unsynced }. Unknown items are matched by path first (adopting the server's uuid) and then registered like an import; unsynced books (PRO) go to the upload lane by uuid. It is the first sync's registration step (also after a lapse, so books imported meanwhile survive the root listing), runs on LITE → PRO and weekly, skips media-server books, books without a local file and books over 10 GiB. LITE books are never confirmed as synced./keysis no longer called.Also
xcode-27runner (the upload code needs the iOS 27 SDK). Xcode Cloud must move to Xcode 27 before the next release.matchUuidbatches capped at 1,000.Requires bookplayer-api
#50 (multipart upload routes,
syncedset by the server), #51 (stable error codes on the legacy library routes), #53 (POST /v1/library/status,activeNOT NULL) and #54 (deletes without the support copy) — merged and deployed 2026-10-01.The cross-platform hostId contract (Android shipped this in 1.1.3)
hostId := <server-reported GUID> ?: canonicalDedupKey(url); resolution is provider-scoped: GUID match (case-insensitive) → canonical URL key → nil, never guess a server. Unresolvable playback surfaces the Media Servers shortcut; unresolvable progress pushes are consumed/discarded (an unknown host is permanent on-device, and the queue retries failures forever).On top of the rebase, fixes from comparing against the Android implementation
IntegrationHostResolverreplacing per-site lookups that compared GUIDs case-sensitively against rawabsoluteStringand then fell back toconnections.first— cross-device that streams the wrong file or writes progress to the wrong server.hostIdwrites usestableHostId(serverId ?? canonicalDedupKey) at all six import sites.PlayableChapterno longer encodesexternalUrl/externalHeaders: the headers carry the media server's liveAuthorizationtoken, and encodedPlayableItems reach the WatchConnectivity application context, which is persisted to disk on both devices.externalUpdateis available on every tier (Android parity — the push targets the user's own server, not a billed resource).Verification