Skip to content

External Resource - #1523

Closed
Hirobreak wants to merge 29 commits into
TortugaPower:developfrom
Hirobreak:external-resource
Closed

Hirobreak wants to merge 29 commits into
TortugaPower:developfrom
Hirobreak:external-resource

Conversation

@Hirobreak

Copy link
Copy Markdown
Contributor

Purpose

  • Allow for a new subscription tier to stream files from external resources (Jellyfin, Audiobookshelf)

Related tasks

  • Based on the rework of uuids for library items

Approach

  • New table External Resource which allows for the id, provider name and more metadata related to the source to be stored in the database

Screenshots

IMG_8741

@GianniCarlo GianniCarlo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

AI feedback:

Comment thread Shared/Services/ConcurrentSync/AsyncOperation.swift
Comment thread Shared/Services/ConcurrentSync/ConcurrenceService.swift
Comment thread Shared/CoreData/BookPlayer.xcdatamodeld/Audiobook Player 12.xcdatamodel/contents Outdated
Comment thread BookPlayer/Import/ImportManager.swift
Comment thread BookPlayer/Profile/Login/ExternalSyncInfoView.swift
Comment thread BookPlayer/Profile/Profile/Views/SubscriptionPillView.swift Outdated
Comment thread Shared/CoreData/Lightweight-Models/SimpleExternalResource.swift Outdated
@Hirobreak
Hirobreak force-pushed the external-resource branch 2 times, most recently from 3c6c771 to 77e7c44 Compare May 13, 2026 21:27
@Hirobreak
Hirobreak force-pushed the external-resource branch from 03d0720 to a2001b7 Compare June 15, 2026 19:57
@Hirobreak
Hirobreak force-pushed the external-resource branch from 6121610 to 948be81 Compare July 7, 2026 19:20
@GianniCarlo

Copy link
Copy Markdown
Collaborator

Thanks @Hirobreak for this work! develop moved ~70 commits since this branch's base and the fork has maintainer edits disabled, so we've rebased it ourselves and continued in #1586 — your commits are preserved as the feature commit's author there. Closing this one in favor of #1586.

GianniCarlo added a commit that referenced this pull request Sep 6, 2026
The #1523 squash-rebase resurrected two files that develop had
deliberately deleted in the connection-flow and integrations reworks:
JellyfinConnectionFormViewModel and JellyfinLibraryGridView (with its
MockJellyfinLibraryViewModel). Both were git-tracked but never
pbxproj-registered, so they never compiled — dead on arrival, including
every conformance edit this branch faithfully made to the grid file's
mock along the way.

A three-signal sweep of all 33 PR-added Swift files (develop deletion
history x pbxproj registration x external type references) found exactly
these two.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNhXd8EkXcrjBVYLHm5ZSp
GianniCarlo pushed a commit that referenced this pull request Sep 9, 2026
… onto develop)

Squash of Hirobreak's PR #1523 (29 commits) replayed onto current develop —
the original branch is from a fork with maintainer edits disabled and had
diverged 70 commits from its July base, so per-commit rebase was not viable.

Conflict-resolution decisions:
- The integration services the PR moved into Shared/ (Jellyfin + ABS
  connection services, IntegrationError) were grafted onto develop's newer
  store-based rewrites at their new homes — develop's implementations win,
  with the PR's deltas re-applied: public framework surface, the
  manualConnection/useConnection pin for the sync engine, deviceIdentifier,
  and the new fetchItem/updateItemProgress/updateItemsFromJellyfin endpoints
  (routed through develop's send()/validateAuthenticatedResponse so the
  session-expiry re-auth mapping applies to them too).
- Server-GUID capture (the cross-device hostId identity) now happens on ALL
  sign-in paths: Jellyfin password AND Quick Connect (getPublicSystemInfo.id),
  ABS password login (server.id), always preserving a previously captured id.
- The browser-SSO stack develop added (WebAuthenticating, OIDC flow, PKCE) is
  guarded iOS-only — it cannot compile for watchOS and the watch never signs
  in by itself. Its window lookup goes through a dynamic sharedApplication
  read because BookPlayerKit compiles extension-safe.
- The app-layer helper closure the moved services depend on
  (IntegrationConnectionStore, IntegrationConnectionPayload,
  IntegrationHTTPClient, WebAuthenticating, PKCE) moved to
  Shared/Integrations with framework-target membership (both Kits).
- The PR's SPM version bumps (jellyfin-sdk 0.6, Kingfisher 8) were dropped in
  favor of develop's existing pins — nothing in the PR requires them.
- Localizations union-merged; develop's wording wins on duplicated keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNhXd8EkXcrjBVYLHm5ZSp
GianniCarlo added a commit that referenced this pull request Sep 9, 2026
…contract

Review findings from comparing the rebased #1523 against the Android
implementation (shipped in android 1.1.3):

- New IntegrationHostResolver in Shared/Integrations — the single resolution
  choke point, mirroring Android's ExternalServiceUtils.serverForResource:
  stable server GUID match (case-insensitive; Jellyfin reports lowercase hex,
  ABS uppercase UUIDs) → canonical URL key → nil. The previous per-call-site
  lookups compared serverId case-sensitively against raw url.absoluteString
  and then fell back to connections.first — the guess-a-server behavior the
  Android rework eliminated, which cross-device streams the wrong file or
  writes progress to the wrong server when provider ids collide.
- Applied at every consumer: PlaybackService stream-URL building,
  SyncService.downloadRemoteFiles, ItemDetailsView host display,
  ExternalUpdateProgressOperation (progress pushes to an unresolvable host
  are now DISCARDED, never sent to the active connection), and
  ItemListViewModel (batch metadata refresh now groups resources by their
  resolved server; the remote-resume check pins the resource's own server).
- hostId writes go through the new stableHostId helper
  (serverId ?? url.canonicalDedupKey) at all six import sites — raw
  absoluteString fallbacks break cross-device matching on trailing-slash/
  port/case URL variants.
- PlayableChapter's Codable now excludes externalUrl/externalHeaders: the
  headers carry the media server's live Authorization token, and encoded
  PlayableItems travel through the WatchConnectivity application context,
  which is persisted to disk on both devices. Both values are per-device and
  re-resolved locally at load time.
- The error alert's Media Servers shortcut now also appears when NO source
  resolves (no local file, no cloud copy) — previously it was gated on
  externalUrl != nil, hiding it in exactly the connect-your-server case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNhXd8EkXcrjBVYLHm5ZSp
GianniCarlo added a commit that referenced this pull request Sep 9, 2026
The #1523 squash-rebase resurrected two files that develop had
deliberately deleted in the connection-flow and integrations reworks:
JellyfinConnectionFormViewModel and JellyfinLibraryGridView (with its
MockJellyfinLibraryViewModel). Both were git-tracked but never
pbxproj-registered, so they never compiled — dead on arrival, including
every conformance edit this branch faithfully made to the grid file's
mock along the way.

A three-signal sweep of all 33 PR-added Swift files (develop deletion
history x pbxproj registration x external type references) found exactly
these two.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNhXd8EkXcrjBVYLHm5ZSp
GianniCarlo pushed a commit that referenced this pull request Oct 5, 2026
Library items can now carry links to the Jellyfin and AudiobookShelf items
they came from (Hardcover links move onto the same model), so a book imported
from the user's own server streams from it, its link syncs to other devices,
and listening progress goes back to the server. This is #1523 replayed onto
develop, with the connection services moved into Shared for both Kits.

Data model and tiers
- CoreData v12 adds ExternalResource (provider name and id, hostId, sync
  status), cascading from LibraryItem. The v11->v12 mapping runs with
  inference off; tests pin its checksums, as a stale mapping breaks launch.
- SwiftData V3 replaces SyncTasksStorage with one container keyed by queue;
  v2->v3 replays the old queue in FIFO order and backfills Hardcover links.
  An incompatible tasks store is moved aside (an older build over a newer
  schema must not lose queued deletes) instead of crash-looping launch;
  other failures, including a bug in the custom stage, still trap.
- A new lite tier syncs the library without S3. Streaming gates on pro OR
  lite, pro wins when both are held, and streaming tiers import server books
  as linked references while other tiers download them.

Concurrent sync engine
- ConcurrenceService runs one worker per queue over a capped OperationQueue:
  "sync" stays serial for BookPlayer-server jobs, S3 PUTs get an uploadFile
  lane, and each provider a lane for progress pushes, so a slow media server
  never stalls library sync.
- Tasks pop only on success and failures retry every 5s forever (Android
  parity), so permanent failures are consumed: a missing file, a 4xx on a
  presigned PUT, an unknown host, an expired session, a provider 4xx, and
  non-finite persisted progress that would crash-loop every pop.
- synced:true and the PRO external_set pipe's uploaded:true are confirmed
  only after a real 2xx PUT, streamed from disk; confirmations retry and are
  then consumed rather than re-uploading a multi-GB file.
- The tier policy is set before workers wake and rechecked per upload.
  Progress pushes run on every tier (they hit the user's own server); a lapse
  cancels only the server and upload lanes; logout wipes every queue and
  cancels in-flight work, down to the Task inside a sync operation, so
  nothing runs under the next account's token.
- Operation state is lock-guarded, and pushes coalesce per providerId.

Syncing links and resolving hosts
- hostId now survives sync-down; decoding is per element, so one bad element
  no longer nils the list and feeds deletions; reconciliation drops only
  links that already synced; lastSyncedAt travels as epoch seconds (a Date
  crashed JSONSerialization); lookups are provider-scoped, as ids are unique
  only per provider; linked books key relativePath on the provider item id.
- IntegrationHostResolver mirrors Android: server GUID (case-insensitive),
  then canonical URL key, then nil; never the first connection, which
  streamed the wrong file or pushed progress to the wrong server. An
  unresolvable host discards its pushes and fails downloads with a
  missing-connection error; old hostIds get no backfill by design.

Playback and progress
- Linked chapters always stream from the external URL with the server's
  headers, bound books included, never rerouted to S3 on refresh; every
  currentItem swap rebinds the chapter subscription.
- Pushes are debounced and dropped if cancelled; ABS takes a 0-1 fraction
  (0-100 marked books finished). A resume prompt offers the server's position
  when it is ahead, whether or not the player is open. The buffering overlay
  shows only while a chapter streams.

Security
- PlayableChapter never encodes the external URL or headers: they hold the
  live Authorization token, and encoded items cross the WatchConnectivity
  context, which is persisted on both devices.
- Custom proxy headers ride along, but Authorization is deduped
  case-insensitively and wins; presigned PUT URLs log only their path.

Tests cover the task engine, host resolution, the resource service, the
token never encoding, and the v11->v12 migration. Positional specifiers in
sync_invitation_description are fixed in 23 locales (Lokalise needs it too).
GianniCarlo pushed a commit that referenced this pull request Oct 6, 2026
Library items can now carry links to the Jellyfin and AudiobookShelf items
they came from (Hardcover links move onto the same model), so a book imported
from the user's own server streams from it, its link syncs to other devices,
and listening progress goes back to the server. This is #1523 replayed onto
develop, with the connection services moved into Shared for both Kits.

Data model and tiers
- CoreData v12 adds ExternalResource (provider name and id, hostId, sync
  status), cascading from LibraryItem. The v11->v12 mapping runs with
  inference off; tests pin its checksums, as a stale mapping breaks launch.
- SwiftData V3 replaces SyncTasksStorage with one container keyed by queue;
  v2->v3 replays the old queue in FIFO order and backfills Hardcover links.
  An incompatible tasks store is moved aside (an older build over a newer
  schema must not lose queued deletes) instead of crash-looping launch;
  other failures, including a bug in the custom stage, still trap.
- A new lite tier syncs the library without S3. Streaming gates on pro OR
  lite, pro wins when both are held, and streaming tiers import server books
  as linked references while other tiers download them.

Concurrent sync engine
- ConcurrenceService runs one worker per queue over a capped OperationQueue:
  "sync" stays serial for BookPlayer-server jobs, S3 PUTs get an uploadFile
  lane, and each provider a lane for progress pushes, so a slow media server
  never stalls library sync.
- Tasks pop only on success and failures retry every 5s forever (Android
  parity), so permanent failures are consumed: a missing file, a 4xx on a
  presigned PUT, an unknown host, an expired session, a provider 4xx, and
  non-finite persisted progress that would crash-loop every pop.
- synced:true and the PRO external_set pipe's uploaded:true are confirmed
  only after a real 2xx PUT, streamed from disk; confirmations retry and are
  then consumed rather than re-uploading a multi-GB file.
- The tier policy is set before workers wake and rechecked per upload.
  Progress pushes run on every tier (they hit the user's own server); a lapse
  cancels only the server and upload lanes; logout wipes every queue and
  cancels in-flight work, down to the Task inside a sync operation, so
  nothing runs under the next account's token.
- Operation state is lock-guarded, and pushes coalesce per providerId.

Syncing links and resolving hosts
- hostId now survives sync-down; decoding is per element, so one bad element
  no longer nils the list and feeds deletions; reconciliation drops only
  links that already synced; lastSyncedAt travels as epoch seconds (a Date
  crashed JSONSerialization); lookups are provider-scoped, as ids are unique
  only per provider; linked books key relativePath on the provider item id.
- IntegrationHostResolver mirrors Android: server GUID (case-insensitive),
  then canonical URL key, then nil; never the first connection, which
  streamed the wrong file or pushed progress to the wrong server. An
  unresolvable host discards its pushes and fails downloads with a
  missing-connection error; old hostIds get no backfill by design.

Playback and progress
- Linked chapters always stream from the external URL with the server's
  headers, bound books included, never rerouted to S3 on refresh; every
  currentItem swap rebinds the chapter subscription.
- Pushes are debounced and dropped if cancelled; ABS takes a 0-1 fraction
  (0-100 marked books finished). A resume prompt offers the server's position
  when it is ahead, whether or not the player is open. The buffering overlay
  shows only while a chapter streams.

Security
- PlayableChapter never encodes the external URL or headers: they hold the
  live Authorization token, and encoded items cross the WatchConnectivity
  context, which is persisted on both devices.
- Custom proxy headers ride along, but Authorization is deduped
  case-insensitively and wins; presigned PUT URLs log only their path.

Tests cover the task engine, host resolution, the resource service, the
token never encoding, and the v11->v12 migration. Positional specifiers in
sync_invitation_description are fixed in 23 locales (Lokalise needs it too).
GianniCarlo pushed a commit that referenced this pull request Oct 7, 2026
Library items can now carry links to the Jellyfin and AudiobookShelf items
they came from (Hardcover links move onto the same model), so a book imported
from the user's own server streams from it, its link syncs to other devices,
and listening progress goes back to the server. This is #1523 replayed onto
develop, with the connection services moved into Shared for both Kits.

Data model and tiers
- CoreData v12 adds ExternalResource (provider name and id, hostId, sync
  status), cascading from LibraryItem. The v11->v12 mapping runs with
  inference off; tests pin its checksums, as a stale mapping breaks launch.
- SwiftData V3 replaces SyncTasksStorage with one container keyed by queue;
  v2->v3 replays the old queue in FIFO order and backfills Hardcover links.
  An incompatible tasks store is moved aside (an older build over a newer
  schema must not lose queued deletes) instead of crash-looping launch;
  other failures, including a bug in the custom stage, still trap.
- A new lite tier syncs the library without S3. Streaming gates on pro OR
  lite, pro wins when both are held, and streaming tiers import server books
  as linked references while other tiers download them.

Concurrent sync engine
- ConcurrenceService runs one worker per queue over a capped OperationQueue:
  "sync" stays serial for BookPlayer-server jobs, S3 PUTs get an uploadFile
  lane, and each provider a lane for progress pushes, so a slow media server
  never stalls library sync.
- Tasks pop only on success and failures retry every 5s forever (Android
  parity), so permanent failures are consumed: a missing file, a 4xx on a
  presigned PUT, an unknown host, an expired session, a provider 4xx, and
  non-finite persisted progress that would crash-loop every pop.
- synced:true and the PRO external_set pipe's uploaded:true are confirmed
  only after a real 2xx PUT, streamed from disk; confirmations retry and are
  then consumed rather than re-uploading a multi-GB file.
- The tier policy is set before workers wake and rechecked per upload.
  Progress pushes run on every tier (they hit the user's own server); a lapse
  cancels only the server and upload lanes; logout wipes every queue and
  cancels in-flight work, down to the Task inside a sync operation, so
  nothing runs under the next account's token.
- Operation state is lock-guarded, and pushes coalesce per providerId.

Syncing links and resolving hosts
- hostId now survives sync-down; decoding is per element, so one bad element
  no longer nils the list and feeds deletions; reconciliation drops only
  links that already synced; lastSyncedAt travels as epoch seconds (a Date
  crashed JSONSerialization); lookups are provider-scoped, as ids are unique
  only per provider; linked books key relativePath on the provider item id.
- IntegrationHostResolver mirrors Android: server GUID (case-insensitive),
  then canonical URL key, then nil; never the first connection, which
  streamed the wrong file or pushed progress to the wrong server. An
  unresolvable host discards its pushes and fails downloads with a
  missing-connection error; old hostIds get no backfill by design.

Playback and progress
- Linked chapters always stream from the external URL with the server's
  headers, bound books included, never rerouted to S3 on refresh; every
  currentItem swap rebinds the chapter subscription.
- Pushes are debounced and dropped if cancelled; ABS takes a 0-1 fraction
  (0-100 marked books finished). A resume prompt offers the server's position
  when it is ahead, whether or not the player is open. The buffering overlay
  shows only while a chapter streams.

Security
- PlayableChapter never encodes the external URL or headers: they hold the
  live Authorization token, and encoded items cross the WatchConnectivity
  context, which is persisted on both devices.
- Custom proxy headers ride along, but Authorization is deduped
  case-insensitively and wins; presigned PUT URLs log only their path.

Tests cover the task engine, host resolution, the resource service, the
token never encoding, and the v11->v12 migration. Positional specifiers in
sync_invitation_description are fixed in 23 locales (Lokalise needs it too).
GianniCarlo pushed a commit that referenced this pull request Oct 10, 2026
Library items can now carry links to the Jellyfin and AudiobookShelf items
they came from (Hardcover links move onto the same model), so a book imported
from the user's own server streams from it, its link syncs to other devices,
and listening progress goes back to the server. This is #1523 replayed onto
develop, with the connection services moved into Shared for both Kits.

Data model and tiers
- CoreData v12 adds ExternalResource (provider name and id, hostId, sync
  status), cascading from LibraryItem. The v11->v12 mapping runs with
  inference off; tests pin its checksums, as a stale mapping breaks launch.
- SwiftData V3 replaces SyncTasksStorage with one container keyed by queue;
  v2->v3 replays the old queue in FIFO order and backfills Hardcover links.
  An incompatible tasks store is moved aside (an older build over a newer
  schema must not lose queued deletes) instead of crash-looping launch;
  other failures, including a bug in the custom stage, still trap.
- A new lite tier syncs the library without S3. Streaming gates on pro OR
  lite, pro wins when both are held, and streaming tiers import server books
  as linked references while other tiers download them.

Concurrent sync engine
- ConcurrenceService runs one worker per queue over a capped OperationQueue:
  "sync" stays serial for BookPlayer-server jobs, S3 PUTs get an uploadFile
  lane, and each provider a lane for progress pushes, so a slow media server
  never stalls library sync.
- Tasks pop only on success and failures retry every 5s forever (Android
  parity), so permanent failures are consumed: a missing file, a 4xx on a
  presigned PUT, an unknown host, an expired session, a provider 4xx, and
  non-finite persisted progress that would crash-loop every pop.
- synced:true and the PRO external_set pipe's uploaded:true are confirmed
  only after a real 2xx PUT, streamed from disk; confirmations retry and are
  then consumed rather than re-uploading a multi-GB file.
- The tier policy is set before workers wake and rechecked per upload.
  Progress pushes run on every tier (they hit the user's own server); a lapse
  cancels only the server and upload lanes; logout wipes every queue and
  cancels in-flight work, down to the Task inside a sync operation, so
  nothing runs under the next account's token.
- Operation state is lock-guarded, and pushes coalesce per providerId.

Syncing links and resolving hosts
- hostId now survives sync-down; decoding is per element, so one bad element
  no longer nils the list and feeds deletions; reconciliation drops only
  links that already synced; lastSyncedAt travels as epoch seconds (a Date
  crashed JSONSerialization); lookups are provider-scoped, as ids are unique
  only per provider; linked books key relativePath on the provider item id.
- IntegrationHostResolver mirrors Android: server GUID (case-insensitive),
  then canonical URL key, then nil; never the first connection, which
  streamed the wrong file or pushed progress to the wrong server. An
  unresolvable host discards its pushes and fails downloads with a
  missing-connection error; old hostIds get no backfill by design.

Playback and progress
- Linked chapters always stream from the external URL with the server's
  headers, bound books included, never rerouted to S3 on refresh; every
  currentItem swap rebinds the chapter subscription.
- Pushes are debounced and dropped if cancelled; ABS takes a 0-1 fraction
  (0-100 marked books finished). A resume prompt offers the server's position
  when it is ahead, whether or not the player is open. The buffering overlay
  shows only while a chapter streams.

Security
- PlayableChapter never encodes the external URL or headers: they hold the
  live Authorization token, and encoded items cross the WatchConnectivity
  context, which is persisted on both devices.
- Custom proxy headers ride along, but Authorization is deduped
  case-insensitively and wins; presigned PUT URLs log only their path.

Tests cover the task engine, host resolution, the resource service, the
token never encoding, and the v11->v12 migration. Positional specifiers in
sync_invitation_description are fixed in 23 locales (Lokalise needs it too).
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