Skip to content

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

Open
GianniCarlo wants to merge 151 commits into
developfrom
external-resource-rebased
Open

GianniCarlo wants to merge 151 commits into
developfrom
external-resource-rebased

Conversation

@GianniCarlo

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

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 new unified ConcurrenceService task queue (SwiftData schema v3), and connections now capture the server's stable GUID at sign-in (all three flows: 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.

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, this branch fixes review findings 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 now appears when no source resolves (previously gated on externalUrl != nil, hiding it in exactly the connect-your-server case).
  • externalUpdate is available on every tier (Android parity — the push targets the user's own server, not a billed resource).

Verification

Phone + watch schemes build; 530 unit tests, 0 failures. Browser-SSO stack (WebAuthenticating/OIDC/PKCE) is guarded iOS-only in the shared framework; its window lookup uses a dynamic sharedApplication read because BookPlayerKit compiles extension-safe.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YNhXd8EkXcrjBVYLHm5ZSp

@GianniCarlo GianniCarlo mentioned this pull request Aug 23, 2026
Comment thread BookPlayer/Player/PlayerLoaderService.swift Outdated
Comment thread BookPlayer/Player/PlayerManager.swift Outdated
Comment thread BookPlayer/Player/PlayerManager.swift Outdated
Comment thread Shared/CoreData/CoreDataStack.swift Outdated
Comment thread Shared/CoreData/CoreDataStack.swift Outdated
Comment thread Shared/Services/ConcurrentSync/FileUploadOperation.swift Outdated
Comment thread Shared/Services/PlaybackService.swift Outdated
Comment thread Shared/Services/Sync/SyncService.swift Outdated
Comment thread Shared/Services/ConcurrentSync/ConcurrenceService.swift Outdated
Comment thread Shared/Services/ConcurrentSync/AsyncOperation.swift Outdated
@github-actions

github-actions Bot commented Aug 23, 2026 •

Copy link
Copy Markdown

✅ Claude PR Review — PASS

Large feature PR: media-server cloud sync via a new CoreData ExternalResource entity (model v12), a full rewrite of the sync-task queue (SyncQueueService/SyncQueueRepository replacing SyncTasksStorage/SyncJobScheduler), external streaming in PlayerManager, and a new PromptSurfaceArbiter unifying resume-offer/failure surfaces across phone and CarPlay. Concurrency/threading: verified — ModelContext/@Model stay confined to the SyncQueueRepository actor, MigrationPlan.injectedCoreDataContext is set before the container is built, snapshots (Simple*/Syncable*) cross thread boundaries (never raw NSManagedObject), and off-main failure presentation is funneled through a @MainActor hop. Player/AVAudioSession: the external-stream branch and one-shot retry preserve the named-cancellable and currentItem→bindPlayableChapterSubscription invariants; player recreation/KVO balance untouched. CoreData: the manual 5-step v11→v12 migration ritual is complete and model/NSManagedObject-property consistent. Auth/entitlement: SyncService.isActive is mutated only via updateSyncEnabled/logout, the progress PULL is gated live on hasSyncEnabled() and cancels on entitlement drop, and the JWT never leaks to S3/media-server hosts. No blocking issues found; a couple of latent Swift-6-concurrency/UX notes below.

Findings: 2 info

Model claude-opus-4-8 · run log · 2 new · 0 carried over · 1 resolved · advisory (a human should still review). Duplicate findings are de-duplicated and stale ones auto-resolved across pushes.

Comment thread BookPlayer/Player/PlayerManager.swift Outdated
Comment thread Shared/Services/Sync/LibraryItemSyncOperation.swift Outdated
Comment thread Shared/Services/Sync/LibraryItemSyncOperation.swift Outdated
Comment thread Shared/Services/Sync/SyncService.swift Outdated
Comment thread Shared/Services/LibraryService.swift Outdated
Comment thread BookPlayer/Coordinators/MainCoordinator.swift Outdated
Comment thread Shared/Services/ConcurrentSync/FileUploadOperation.swift
Comment thread BookPlayer/Player/Views/PlayerView.swift Outdated
Comment thread BookPlayer/Import/ExternalSource/ExternalImportView.swift Outdated
Comment thread BookPlayer/Profile/Account/AccountPerksSectionView.swift Outdated
Comment thread BookPlayer/Base.lproj/Localizable.strings Outdated
Comment thread Shared/Services/ConcurrentSync/ExternalUpdateProgressOperation.swift Outdated
Comment thread Shared/Jellyfin/Network/JellyfinError.swift Outdated
Comment thread BookPlayer/Import/ImportManager.swift Outdated
Comment thread Shared/Services/ConcurrentSync/FileUploadOperation.swift Outdated
Comment thread Shared/Services/LibraryService.swift Outdated
Comment thread Shared/Services/ConcurrentSync/FileUploadOperation.swift
Comment thread BookPlayer/Library/ItemList/ItemListViewModel.swift Outdated
Comment thread Shared/Services/Account/AccountService.swift
Comment thread Shared/Services/SyncQueue/FileUploadOperation.swift
Comment thread Shared/Services/SyncQueue/SyncQueueService.swift
A string literal handed to a SwiftUI API that takes `LocalizedStringKey`
already localizes — the non-generic `LocalizedStringKey` overload wins over
the generic `S: StringProtocol` one, which is why develop ships bare literals
in `Text` and `accessibilityLabel` and they translate fine. The PR had added
`.localized` to 34 of them, which reads as though it were required and
suggests the bare ones nearby are bugs.

Only lines this PR introduced are touched. Develop has plenty of the same
redundancy and cleaning it up here would bury the branch's real changes under
churn in files it never meant to modify.

Everything that genuinely needs `.localized` keeps it: ternaries and `??`,
`String(format:)` and `localizedStringWithFormat`, and `String`-typed
parameters like `JellyfinLibraryViewModel.navigationTitle`. `files_title` is
one of these and is load-bearing — it is a stringsdict plural key in 25
locales, so it has to reach `localizedStringWithFormat` with its count.

"Apple Watch (Beta)" goes back to a plain literal. Every other place a tier or
product is named to the user is an untranslated brand literal — "BookPlayer
Pro" in the settings banner, the profile callout, this same perks list and
both login screens — so translating this one alone would have been the odd one
out. `apple_watch_beta_title` had no other caller and is removed from Base and
en, the only locales that had it.

One behaviour difference worth recording: `Text(LocalizedStringKey)` parses
the RESOLVED string as Markdown, which `Text(String)` does not. No translation
of any of the 33 unwrapped keys contains a Markdown-active character today, so
nothing changes now — but a future translation with a `*` or `_` would render
as emphasis rather than literally.
`SettingsCompleteAccountView` took a `subType` with a `.pro` default and
forwarded it, but no caller ever set it — `SettingsView`, `AccountView` and the
preview all construct it bare, so the value was always `.pro`. The parameter
read as a variation point that does not exist.

The one place that genuinely varies the tier, `ExternalSyncInfoView`, builds
`CompleteAccountView(subType: .lite)` directly rather than going through this
wrapper, so nothing is lost by fixing the tier here.

The `BookPlayerKit` import goes with it: it was there for the `AccessLevel`
annotation, and `.pro` infers from `CompleteAccountView`'s own parameter. The
file is now one line away from develop.
The file showed 80 changed lines and almost none of it meant anything: the
`.lpf` imported-type declaration moved from the top of the dict to the bottom,
every alternate app icon was re-sorted alphabetically under both
`CFBundleIcons` and `CFBundleIcons~ipad`, `UIPrerenderedIcon` moved below the
shortcut colour keys, and the mime-type and filename-extension arrays swapped
places. A plist dict is unordered, so the built app is byte-identical either
way — it just buried the file in a diff nobody can read.

Two real keys were hiding in that noise, and neither should stay.

`LSApplicationCategoryType` was added as an empty string, which says nothing.

`NSAllowsLocalNetworking` looks like it would help reach a media server on the
LAN, but it does the opposite here. develop already sets
`NSAllowsArbitraryLoads`, and when a narrower ATS key is present alongside it,
iOS 10 and later IGNORE `NSAllowsArbitraryLoads` and honour only the narrow
one — this target deploys to iOS 18. So the effect is to shrink plain-HTTP
access from "anywhere" down to "local network only", which would break a
server reached over a tunnel or a public http address while still working on
the LAN. It is also unnecessary for the local case: arbitrary loads already
cover it, and the iOS 14 local-network prompt is a different mechanism
(`NSLocalNetworkUsageDescription` plus Bonjour) that this key has no bearing on.

The file is identical to develop again.
`ExternalSyncIntroView` did not say what it was. It is the sibling of
`LoginView` — same benefit rows, same sign-in buttons — pitching the server
features and ending on `.lite` where `LoginView` pitches cloud sync and ends
on `.pro`. It is now `MediaServersLoginView`, which pairs with the screen it
mirrors and matches the user-facing wording of `MediaServersView`, and the
file name matches the type again (it was `ExternalSyncInfoView.swift` holding
an `ExternalSyncIntroView`, so both names were in circulation for one screen).
A doc comment states the relationship, since that is the part the old name hid.

The copy from `LoginView` lost three modifiers, and the omission is
user-visible. `AppleSignInLink` reads `\.loadingState` from the environment and
writes its progress and its errors into it. This screen declared a
`LoadingOverlayState` but never injected it and never attached `.errorAlert` or
`.loadingOverlay`, so sign-in wrote into the `@Entry` placeholder that nothing
observes: no spinner, and a failed Apple sign-in did nothing at all. The same
failure on `LoginView` raises an alert. All three are restored.

The `ZStack` went with them. `LoginView` needs one because it reserves space
with a hardcoded 88pt spacer and overlays the buttons as a sibling; this screen
puts the buttons inside `safeAreaInset` as real content, which reserves their
height on its own. Once that changed the `ZStack` wrapped a single child.

Also: the missing `#Preview`, and two comments that were addressed to whoever
was going to finish the screen — "State to trigger your subscription tiers
presentation" and "Or whatever your primary button color is".

Left alone: the Continue button still carries its own font, padding and
background rather than sharing the sign-in buttons' treatment. That is a visual
call, not a defect.
The first benefit row pitched "Bring Your Own Server — connect your existing
Jellyfin or Audiobookshelf". Nothing gates that: adding a connection and
downloading from it cost nothing, and only streaming and the progress pull sit
behind the entitlement. The screen is also reachable only from inside a
connected browser, so the row described something the reader had already done,
for free, on a screen asking them to pay. Its two strings go with it.

The sync row claimed progress "stays perfectly synced with your server and
across all your devices", which reads as though the devices talk to each other.
They do not — the cross-device half goes through BookPlayer's servers, and that
is the part the subscription pays for. The disclaimer below already says as
much ("ongoing server costs for cloud storage and progress syncing"), so the
benefit row was the only place not admitting it. It now names both hops.

"Two-way" stays in the title because it is the accurate word for the paid half:
pushing progress TO your own server is free on every tier, and it is the pull
back that needs lite or pro.

The icon follows the copy. `arrow.triangle.2.circlepath` said "sync" but not
"cloud"; `arrow.clockwise.icloud` keeps the sync motion and adds the cloud the
sentence now describes, while staying distinct from the `icloud.and.arrow.up.fill`
that LoginView's own sync row uses. Symbol verified against the system catalog
(available since 2019, against an iOS 18 target) rather than trusted from memory.
The shared disclaimer was written for the cloud funnel and both of its bullets
are wrong on this screen.

"Due to ongoing server costs for cloud storage and progress syncing" describes
storage we do not provide these users: lite sets `.uploadFile: false`, so their
audio never reaches our S3 and stays on their own server. Telling a self-hoster
they are paying for our storage is both false and the claim least likely to
land with that audience.

Worse, "you only need an account with us if you plan to listen across different
devices" sits directly above Sign in with Apple and Continue with Passkey, and
this screen cannot proceed without an account — streaming needs a purchase and
the purchase needs the account. The text talked the reader out of the thing the
screen was asking for.

Deleting the block outright was the other option and I did not take it: the
question a self-hoster asks is sharper here than on the cloud screen, not
softer — my server, my bandwidth, why am I paying you. Leaving that unanswered
on a paywall reads worse than answering it. So the slot stays and the words
change:

  Your audiobook files stay on your own server, never on ours. Your
  subscription unlocks streaming and covers the cost of syncing your progress
  between devices through our servers.

The two halves are deliberately different verbs. Streaming is UNLOCKED — it
costs us nothing, since it is their server and their bandwidth, and pretending
otherwise invites exactly the objection above. The syncing is what we COVER THE
COST of, because that genuinely runs on our machines. Naming our servers is the
point rather than an admission: "we never store your audiobooks" alone implies
we store nothing, which is untrue, and a reader who learns otherwise from a
network trace has been misled by omission.

It says "audiobook FILES" for the same reason. Two things about this branch
make the looser wording a claim the code will not back. A media-server book
that is DOWNLOADED is not a special object — `SingleFileDownloadService` moves
it into the documents folder and the ordinary import pipeline turns it into a
regular local `Book`, which `.pro` then uploads. And even on lite the library
rows travel: `handleItemsToUpload` schedules `scheduleLibraryItemUploadJob` and
`scheduleExternalResourceUpload` for every item, which is what DB-only sync
means. The audio is what never leaves their server, so the audio is what the
sentence promises.

`LoginDisclaimerSectionView` takes its bullets as a parameter now, defaulting to
the existing two keys, so `LoginView` is untouched. `ForEach` iterates
`.indices` because `LocalizedStringKey` is Equatable but not Hashable, so
`id: \.self` over the elements does not compile.
The Stream button wore `arrow.down.circle.dotted` — a downward arrow into a
dotted circle, which is iOS's pending-download idiom — while sitting beside the
actual download button wearing `square.and.arrow.down`. Two download arrows,
and the reader left to work out which one streams. It is `waveform` now, in all
three places it appeared: the details screen and both the Label and the Image
on the library screen, so the two screens no longer disagree about what
streaming looks like. That glyph is now absent from the app.

The library tab's "Import files" menu had the opposite problem: it used
`waveform`, which everywhere else in this codebase means "this is an
audiobook" — the library rows, the Jellyfin and AudiobookShelf item icons, the
share extension. Importing is not audio, and the clash got worse once
`waveform` also started meaning "stream", so it takes `square.and.arrow.down`.

Stream now leads and Download follows, and Stream is driven by the app's shared
`PrimaryButtonStyle` rather than a fourth hand-rolled treatment — the feature
already had three, differing in height, corner radius and font. The style is
passed `theme.linkColor` and white, so the button looks as it did while its
geometry, pressed and disabled states come from one place. Download is resized
to match the style's 48pt height with the same radius; at its old 68x56 it
would have stood 8pt taller than the button beside it.

Also folded in, per the request: the redundant `.localized` calls that the
earlier sweep missed. That sweep parsed `+++ b/<path>` from the diff, and git
appends a TAB to paths containing spaces, so `Path(f).exists()` was false and
every file under `Library Screen/`, `ItemDetails Section/` and their siblings
was skipped in silence — the media-server feature almost in its entirety. 20
lines across 5 files, all of them PR-added; develop's own redundancy is left
alone as before.
The app had four primary-button treatments and no two agreed: the shared
`PrimaryButtonStyle` at 48pt with a 24 radius, `IntegrationFlowPrimaryButton`
at 14pt vertical padding with a 12 radius, a hand-rolled Stream button at 56pt
with a 10 radius, and another hand-rolled one on the media-server paywall at a
12 radius. Three of them existed because the shared one was wearing the wrong
clothes.

`PrimaryButtonStyle` inverted black and white by theme, which is Sign in with
Apple's treatment — and imitating it was pointless, because that button is
Apple's own `SignInWithAppleButton` and takes its look from
`.signInWithAppleButtonStyle`, never from us. The lookalike mostly appeared on
sign-in screens standing next to the real thing. It is the accent treatment
now, matching the Pro callout's Learn More button, so the two hand-rolled
copies had no reason to exist: `IntegrationFlowPrimaryButton` is deleted and
its three call sites plus the paywall's Continue button use `PrimaryButton`.
`disabledWithOpacity` is not needed either — the style already dims through
`@Environment(\.isEnabled)`.

Shapes follow one rule. Everything is a `Capsule`, including Apple's button,
whose corners were never a house choice — we simply never styled them.
`Capsule` rather than a radius constant because it tracks the height instead of
going quietly wrong when someone changes it, and because `cornerRadius(_:)` is
deprecated and was only a clip mask anyway, so it was never setting Apple's
`cornerRadius` property regardless.

The label goes to `.headline` (17pt semibold). At `.title` (16pt callout) it
looked undersized in a 48pt button beside Sign in with Apple, whose text scales
with its height; `.titleLarge` (20pt) was tried and reads too loud for a
one-word label.

On the details screen both buttons now take that style — Stream in the accent,
Download in the secondary colours — so they cannot drift apart in height,
radius or font the way hand-matched geometry does, and they split the row
evenly. Download shows its label instead of a bare glyph, reusing the
`download_title` string it was already announcing to VoiceOver; the explicit
`accessibilityLabel` goes, since a visible `Label` supplies it. It is no longer
`SmallDownloadButton`, because it is not small. Download leads and Stream
follows, which is also the order develop had.
Comparing it against the `ImportViewController` it will eventually replace, the
differences came from three separate causes rather than one drift.

The structural one: it re-implemented a navigation bar. The UIKit screen uses a
real `UINavigationBar` with `prefersLargeTitles` and `systemItem="cancel"` /
`"done"`, which is why iOS 26 gives it a large title and circular glass buttons
for nothing. This built an HStack of two buttons with hand-drawn `Circle()`s
inside a `ZStack`, so it could inherit none of that. It is a `NavigationStack`
with `.toolbar` and `.navigationTitle` now, over a `List` with the house
`applyListStyle` — which also brings `toolbarColorScheme`, a modifier that only
does anything once a real navigation bar exists. The two fixes were one fix.

Two colours were drawn with the background token: the button circles at
`systemBackgroundColor.opacity(0.3)` and the row dividers at `0.2`. On a dark
theme that is near-black on near-black, which is why the circles were invisible.
Both elements are gone — `List` and the toolbar supply them — along with the
hardcoded 24pt horizontal padding, so the insets are the system's like every
other list in the app.

The row icon was a literal `.pink`, ignoring the theme; `ImportTableViewCell`
tints it `theme.linkColor`.

Rows are sorted with `localizedStandardCompare`, matching `ImportFileItem`'s
`Comparable` and the library's own sort — "002" before "010", which plain string
ordering gets wrong on the numbered filenames audiobooks nearly always have.
Sorted in the shell so every adopter inherits it. The header gains
`.localizedCapitalized`, as UIKit has.

`import_warning_description` is now a parameter rather than fixed copy, and the
external flow passes none: it warns that files are still transferring and the
count may change, and neither is true there — the batch is frozen when the sheet
opens. Not defaulted, because a default is how that copy reached this screen in
the first place. Where it IS supplied it is pinned with `safeAreaInset` in a new
`glassCard` helper: as a section footer, a long list scrolled the one caveat you
are meant to read before confirming clean off the screen. `glassCard` gives glass
an explicit rounded rect, because `liquidGlassBackground` is shaped for floating
pills — the mini player, the player's bubble buttons — and stretching it full
width produced a capsule with its ends clipped to the display.

Swipe-to-dismiss is disabled, deliberately stricter than UIKit, which allows it
and runs `try? discardImportOperation()` on the way out — cleanup whose failure
it then swallows.

The four things the file flow must bring before it can adopt this are documented
on the type: closures that can report failure, folder icons with sub-counts, the
directory watching that keeps the count live, and counting files inside folders
rather than rows. None is reachable from the external flow, whose batch is frozen
and whose removals cannot fail.
Comment thread Shared/Services/SyncQueue/SyncQueueService.swift
`LoginView` reserved a hardcoded 88pt of bottom safe area with a clear spacer
and then floated the button stack over it as a `ZStack` sibling. The stack is
nearer 100pt before Dynamic Type touches it, so the reservation was short from
the start: the last line of the disclaimer could not be scrolled clear of the
buttons on a short screen. The buttons ARE the `safeAreaInset` now, which is
what `MediaServersLoginView` already did, so the list reserves exactly their
height whatever that height turns out to be and the `ZStack` goes with it.

The opaque background matters as much as the geometry, and both screens needed
it. `AppleSignInLink` is an opaque pill and hides whatever passes behind it,
which is why this never looked broken in a screenshot — but `ContinueWithPasskeyButton`
is bare text over nothing, so the disclaimer scrolled straight through it.
`hasStreamingEnabled()` asked only whether the user had ever paid, and
`donationMade` deliberately outlives logout. A past tipper who signed out kept
media-server streaming with no account at all.

The gate takes `isSignedIn` now, and reads it from `getAccountId()` rather than
`hasAccount()`. Logout blanks the account's fields but leaves the row behind, so
`hasAccount()` stays true afterwards — `AccountServiceTests` asserts it is still
true even after `deleteAccount()`. Only the id going empty marks a signed-out
user.

The separate `donationMade` clause goes at the same time, because it was never
buying anything. It was read directly to dodge `hasPlusAccess()` returning early
on a refunded pro/lite, but RevenueCat attaches `plus` to non-subscription
purchases and that check short-circuits before the refund handling, so
`hasPlusAccess()` already answers for tippers. Two clauses, both load-bearing:
an active tier or a tip, and a subscription that expired without being refunded.

`AccountServiceMock.getAccountId()` mirrors the real one in mapping an empty id
to nil. Without that, a mock account in the logged-out state — blank id, row
still present — reports `Optional("")` and reads as signed IN, which is exactly
the state this gate exists to catch.
`jellyfin-icon` and `audiobookshelf-icon` were image sets, and an image set
interpolated into a `Text` draws at its intrinsic point size with its bottom
edge pinned to the baseline — font metrics apply to symbol images only.
`jellyfin.svg` declared 20x18.3 and `abs.svg` 16x16, against a `.caption` whose
cap height is about 8.5pt. So the marks towered over the line they sat in,
disagreed with each other, and ignored Dynamic Type entirely, which is the
opposite of what the comment above `subtitle` claimed they did.

Both are `.symbolset`s now, authored as v4.0 templates against the real guides:
each logo scaled so its ink spans exactly baseline to capline, 70.459 units,
which is SF Pro's cap height at the 100 points the template is typeset at. Equal
heights rather than equal boxes — Jellyfin's mark is square and
Audiobookshelf's is 0.84 as wide as it is tall, so matching width would have
made one of them look oversized. Measured against Apple's own symbols, flat
shapes land at about 1.0x cap height (square.fill 1.015, book.fill 1.022) while
round and pointed ones overshoot (triangle.fill 1.043, circle.fill 1.128).
These sit at a strict 1.000; the overshoot is a knob to turn if Jellyfin's apex
reads small on device. The same artwork fills all three weight slots on
purpose: a brand mark has no business getting bolder next to a bold font.

With the mark finally the size of the text, the name beside it was a second
copy of the same fact, and the expensive one — "Audiobookshelf • " crowded the
author off the single line this row gives it, worst at large Dynamic Type where
that line truncates first. Glyph and author now, no name and no separator.

That leaves the glyph as the only trace of where an item came from, so VoiceOver
needs it, and it never had it: `getAccessibilityLabel` builds from title,
details, percent and duration, and the row is `children: .ignore`, so the
visible name never reached the accessibility tree either. Only the sighted
reader ever knew. `includeSource` adds the provider names, leading the
announcement the way the glyphs lead the line, and defaults to false so the
Watch cell — which draws no glyph — says exactly what it said before.

The names are deliberately unlocalized. They are brands, and wrapping them in a
`"from %@"` key would be worse than bare: `localized` is a plain
`NSLocalizedString` with no fallback to Base, so the two dozen locales without
the key would announce the key itself and swallow the name — a regression aimed
squarely at the users the change is for.

Two things turned up on the way. `DynamicAccessibilityLabelModifier` rebuilds
`SimpleLibraryItem` on every progress tick and when the title-source toggle
flips, and neither rebuild passed `externalResources`, which the initialiser
defaults to nil; the source would have been announced once and then silently
dropped. And the order was undefined — `resourcesArray` is `allObjects` off an
`NSSet`, so an item linked to two servers could draw its glyphs, and now speak
their names, in a different order each launch. `streamingResource` already
documents and pins that hazard, so `displayOrderedMediaServerResources` reuses
its comparator for everything the user sees or hears. It mattered little while
the names were spelled out; it matters now that the silhouette is all there is.
Comment thread Shared/Services/SyncQueue/SyncQueueService.swift
`ProviderName` answers two questions at once: which row is this in Core Data,
and which server does it belong to. Hardcover is a provider but not a server —
no host, nothing streams from it — so every piece of code that only makes sense
for a server carried a case it had no answer for.

Three of them answered with `default:`. `ExternalStreamResolver`,
`IntegrationHostResolver` and `ExternalUpdateProgressOperation` each handled
`.jellyfin` and `.audiobookshelf` and swept the rest into a catch-all. That is
right for Hardcover and for a providerName this build doesn't know, and silently
wrong for the next media server anyone adds: it would not stream, would not
resolve its host, and would not push progress, with no compile error at any of
the three to say so. The fourth answered with a placeholder — the library-row
glyph returned `plusImageAppIcons`, a 2023 Plus promo PNG, for Hardcover. That
arm was already unreachable (the row filters to media servers), and a plain
image set interpolated into a `Text` draws at its intrinsic size anyway, which
is the bug the comment above `subtitle` warns about. The asset had no other
reference in the project and is deleted.

`MediaServerProvider` is that second question as a type. `ProviderName.mediaServer`
maps onto it in the one exhaustive switch, `isMediaServer` and
`SimpleExternalResource.mediaServer` derive from it, and the four sites above now
take a value with two cases and nothing to leave unhandled. Adding a media server
is a compile error in each of them.

It is deliberately not `RawRepresentable`. `ProviderName` owns the strings that
reach Core Data, so there is no second spelling to drift from them:
`mediaServerRawValues` still derives from `allCases` and the `IN` predicate in
`findMediaServerResources(at:)` is byte-identical. Nothing changes for the user —
the `case nil:` arms cover exactly what `default:` covered, and `BookView`'s
`?? .jellyfin` fallback was already dead behind the media-server filter.

`testProviderNameMapsOntoItsOwnMediaServer` pins the `.audiobookshelf` arm,
which nothing else did. The existing host-resolver test catches a wrong
`.jellyfin` mapping, but no test drives an AudiobookShelf resource through one,
so a transposition there compiles, leaves the SQL predicate intact — it filters
on nil-ness, not identity — and passes the whole suite while routing every
AudiobookShelf item at the Jellyfin connection. Verified by mutating the mapping:
it is the only test in the suite that fails.

`ExternalProgressService` keeps its `[ProviderName: ExternalProgressProviding]`
registry on purpose. It holds only the two servers today, but Hardcover is a
progress provider by nature, so that key stays the wider type.
Comment thread Shared/Jellyfin/JellyfinLibraryItem.swift
Comment thread Shared/AudiobookShelf/AudiobookShelfLibraryItem.swift
Comment thread Shared/Services/Account/AccountService.swift
Deleting the Hardcover glyph's placeholder left `plus/` holding four more
imagesets nothing asks for: `BookPlayerPlus`, `plusImageSupport`,
`plusImageThemes` and `support-no-padding`, all from the 2023 Plus screen that
no longer exists. They compiled into every build of the app, the watch app and
both frameworks — the catalog is a folder reference, so each is carried by all
four targets that list it.

Verified unreferenced rather than assumed, because this app force-unwraps
`UIImage(named:)` in places and a missed lookup is a crash, not a blank frame.
Checked every file type (Swift, storyboards, xibs, Info.plist's 32
`CFBundleIconFiles`, `Icons.json`'s 15 `imageName`s, strings, pbxproj, catalog
JSON), case-insensitively and in the generated camelCase symbol form
(`.bookPlayerPlus`, `.supportNoPadding`) that a literal grep for the directory
name would miss. Also traced every non-literal `UIImage(named:)` argument in the
repo — CarPlay's chevron ternaries, `AppIconView`'s `Icons.json` lookup, and
`addLayerMask`'s parameter — and none can construct these names. No string
interpolation reaches an image lookup anywhere.

`plus/confetti/` stays. Four of its five are live in `ConfettiView`, and
`confetti_heart` is unreferenced but belongs to that set rather than to the dead
Plus screen. Neither `plus/` nor `plus/confetti/` provides a namespace, so those
lookups are flat and independent of what sits beside them.
Comment thread Shared/Services/SyncQueue/SyncQueueService.swift
The artwork's corner is a three-state download indicator — a spinner while
downloading, nothing once downloaded, a `cloud` in a triangle while the file
isn't on the device. That last glyph is right for our own storage and wrong for
a media server: the file is sitting on the user's machine, not ours, which is
the distinction the media-server paywall copy goes out of its way to make. The
provider's mark says everything the cloud said and says whose as well.

No state is lost, because the mapping is one to one. `.notDownloaded` is exactly
the state in which the question "from where" has an answer worth showing, and
the other two arms are untouched: a downloaded item still draws nothing, a
downloading one still draws the spinner.

`streamingResource`, not the first media-server link. It is the same value
`downloadRemoteFiles` picks, so the badge can never name a server the tap won't
reach — and it is already Hardcover-filtered, which matters here: the API's
`markExternalSourceUploaded` marks EVERY provider row of an item 'downloaded',
so a dual-linked book's Hardcover row passes a bare syncStatus check. That once
aimed a download at a provider with no files at all.

With the corner carrying provenance, the subtitle goes back to what develop
has — glyph and separator deleted, `Text(verbatim: item.details)` inline again,
and the doc comment justifying the `Text` concatenation with it. The trade
accepted knowingly: provenance is now visible only while the file is remote.
Once it is downloaded the book plays the local file and where it came from stops
mattering to the reader. It does NOT stop mattering to the app — progress still
pushes to that server, keyed off the resource's providerName — which is why
`getAccessibilityLabel(includeSource:)` stays unconditional. VoiceOver never saw
the badge in any state, so the announcement is that user's only channel.

The geometry is measured against the cloud rather than eyeballed. All three
badges share the cloud's 14pt-wide box, which is what centres them: a height-only
frame lets each mark's own width decide where its centre falls, and these two
differ enough (Jellyfin square, Audiobookshelf taller than wide) to sit visibly
right of a cloud row. The 3pt below buys what the cloud gets for free — it is a
wide, low glyph, so fitting it into a 14x16 box letterboxes it and leaves ~4.6pt
of air under its ink, where a mark that fills its box lands flush on the corner's
edge and reads as sitting lower. Heights are per-provider on `badgeHeight`
because the symbols don't fill their boxes equally: at a shared frame height
Jellyfin's ink draws ~0.4pt shorter than Audiobookshelf's. The ceiling is not the
corner's diagonal, which nothing reaches until well past 13, but the ink's centre
climbing away from the cloud's.
public let jobType: SyncJobType
public let uuid: String
public let relativePath: String
public let parameters: [String: Any]

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 — QueuedSyncTask carries public let parameters: [String: Any] and is returned across the SyncQueueRepository actor boundary and captured into detached Tasks in SyncQueueService. It is runtime-safe today (only value-typed dict entries are read across the hop), but the [String: Any] payload defeats Sendable checking on a type that legitimately crosses actor/Task boundaries — a latent gap if the target moves to Swift 6 strict concurrency. Consider a typed payload or an explicit @unchecked Sendable with a rationale.

let message: String
let error: String?
}

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 — upload(fileURL:remoteURL:) streams a potentially multi-GB audiobook over URLSession.shared (a foreground session), so the S3 PUT won't survive app termination/suspension mid-transfer. The call site (LibraryItemSyncOperation) documents this as intentional with a background-transfer follow-up planned — noting it here so the BPURLSession background-session migration isn't lost.

This branch has not been deployed

No deployments
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