Skip to content

External resource support for media-server items (Jellyfin / AudiobookShelf) - #1586

Merged
GianniCarlo merged 58 commits into
developfrom
external-resource-rebased
Oct 11, 2026
Merged

GianniCarlo merged 58 commits into
developfrom
external-resource-rebased

Conversation

@GianniCarlo

@GianniCarlo GianniCarlo commented Aug 23, 2026 •

Copy link
Copy Markdown
Collaborator

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 unified SyncQueue engine (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

  • Book files go up through the new uploadFile lane as multipart uploads (64 MiB parts, 8 in flight) on background URLSessions, 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 sets synced at /upload/complete.
  • One BGContinuedProcessingTask keeps 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.
  • Wi-Fi-only setting respected, with a banner; a cellular-setting change resends the parts in flight through the right session.
  • A book the server lost mid-upload (deleted on another device) is registered again once per session; a second item_not_found parks 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 error code, or the app's own file_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/status with 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. /keys is no longer called.

Also

  • iOS minimum raised to 26 (watch stays on watchOS 10); CI builds with Xcode 27 on the xcode-27 runner (the upload code needs the iOS 27 SDK). Xcode Cloud must move to Xcode 27 before the next release.
  • Settings › Debug export of the local library tree with uuids (local only).
  • Temp hard links cleaned up; matchUuid batches capped at 1,000.

Requires bookplayer-api

#50 (multipart upload routes, synced set by the server), #51 (stable error codes on the legacy library routes), #53 (POST /v1/library/status, active NOT 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

  • Centralized IntegrationHostResolver replacing per-site lookups that compared GUIDs case-sensitively against raw absoluteString and then fell back to connections.first — cross-device that streams the wrong file or writes progress to the wrong server.
  • Progress pushes to an unresolvable host are discarded-by-consuming rather than sent to the active connection (original) or retried forever (naive discard).
  • hostId writes use stableHostId (serverId ?? canonicalDedupKey) at all six import sites.
  • PlayableChapter no longer encodes externalUrl/externalHeaders: the headers carry the media server's live Authorization token, and encoded PlayableItems reach the WatchConnectivity application context, which is persisted to disk on both devices.
  • The error alert's Media Servers shortcut appears when no source resolves.
  • externalUpdate is available on every tier (Android parity — the push targets the user's own server, not a billed resource).

Verification

  • 838 unit tests, 0 failures; phone and watch schemes build.
  • Device pass (24 cases) on an iPhone, two simulators and a paired watch simulator, against the API above: small, 3 GiB and multi-book uploads; background, Live Activity cancel, force quit and terminated-app resume; Wi-Fi-only and cellular; expired part URLs; the buttons; parking from a delete on another device mid-upload (twice), Retry, Report, a lane-level park, a book over 10 GiB; the missing-items pass on sign-in, uuid adoption, weekly, LITE → PRO, lapse and return, a LITE import; media-server download → upload; move, rename, delete, bookmarks, streaming and download; the watch lists and downloads and never uploads.

@GianniCarlo GianniCarlo mentioned this pull request Aug 23, 2026
@GianniCarlo
GianniCarlo force-pushed the external-resource-rebased branch from 10494d2 to 0781028 Compare September 9, 2026 19:10
@GianniCarlo
GianniCarlo force-pushed the external-resource-rebased branch from b52d79a to de1d623 Compare October 5, 2026 15:57
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
@TortugaPower TortugaPower deleted a comment from github-actions Bot Oct 6, 2026
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.
Comment thread Shared/CoreData/Backed-Models/LibraryItem+CoreDataProperties.swift Outdated
Comment thread BookPlayer/MediaServerIntegration/IntegrationLibraryViewModelProtocol.swift Outdated
…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.
Comment thread Shared/Services/SyncQueue/FileUploadOperation.swift
Comment thread Shared/Services/Sync/SyncService.swift
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.
Comment thread Shared/AudiobookShelf/Network/AudiobookShelfConnectionService.swift Outdated
Comment thread Shared/Services/Sync/SyncService.swift
… 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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) runs createHardLink, which first calls removeItem(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 create tmp/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

1 active deployment
reviewer — 9ae84db0 Deployed Oct 10, 2026 by GianniCarlo via review #245
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants