Compare commits

..
Author SHA1 Message Date
dtourolleandClaude Opus 5 50934e2ac6 ci(android): ship Gradle in the builder image instead of downloading it
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 10m7s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m28s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
Build & Release / Run Tests (push) Successful in 7m35s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 3m2s
Build & Release / Build Linux (push) Successful in 20m0s
Build & Release / Build Windows (push) Successful in 8m36s
Build & Release / Build Android (push) Successful in 30m30s
Build & Release / Create Release (push) Successful in 21s
The release APK job died at the Gradle wrapper step, after the 11-minute
Rust compile had already succeeded:

    Downloading https://services.gradle.org/distributions/gradle-8.14.3-bin.zip
    java.net.SocketException: Unexpected end of file from server

`tauri android init` regenerates gen/android with a wrapper pointing at
services.gradle.org, so every Android job re-downloaded ~130MB of Gradle at
build time. That is slow on a good day and a hard build failure when the CDN
drops the connection mid-transfer. It was also a standing violation of the
rule that every build tool must already live in the builder image.

Dockerfile.builder installs Gradle 8.14.3, keeping both the unpacked
distribution (on PATH) and the original zip under /opt/gradle/dist. A
`gradle --version` smoke-test fails the image build on a bad version rather
than letting CI discover it.

sync-android-sources.sh then repoints the regenerated wrapper at that local
zip, which is the established place for fixing up the generated project.
It parses the version the wrapper actually requests, so a future Tauri Gradle
bump logs "not in image, will download" instead of pointing at a missing
file. On dev machines /opt/gradle/dist does not exist and the properties file
is left untouched.

Verified by running the project's own wrapper jar inside a network namespace
with no connectivity: it resolved and unpacked the local zip to 100% and
proceeded into build-script evaluation.

Note: this is inert until the builder image is rebuilt and pushed
(scripts/build-builder-image.sh).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 07:47:43 +02:00
dtourolleandClaude Opus 5 8fbc080733 Merge branch 'fix/android-resume-position'
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 8m2s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m34s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 2m58s
Build & Release / Run Tests (push) Successful in 8m22s
Build & Release / Build Linux (push) Successful in 19m40s
Build & Release / Build Windows (push) Successful in 8m6s
Build & Release / Build Android (push) Failing after 13m33s
Build & Release / Create Release (push) Skipped
Resume-playback fixes across the three layers where the position was lost:

- DR-150 path: the Android native (ExoPlayer) surface never applied the
  resume seek, so resume always played from the start on device.
- DR-154: a stop-report the server could not be told about was logged and
  dropped, even though sync_queue and its drain were built and running.
- DR-155: the server's watch position was never mirrored into the local
  user_data row the resume check reads, so resume never crossed devices.

Also carries concurrent fixes merged in from parallel work: download
bitrate, series resume ordering, Recently Added grouping, remote-session
volume handoff, and home library card heights.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:14:44 +02:00
dtourolleandClaude Opus 5 ba5fd55204 fix(sync): mirror the server's watch position so resume crosses devices (DR-155)
The resume check reads the local user_data row and nothing else, but
mirror_user_data -- the only path by which server UserData lands in that
table -- mirrored is_favorite alone, and returned early whenever that
field was absent, which is exactly the shape of an ordinary watched
episode. playback_position_ticks was therefore write-only from this
device's perspective: watch 40 minutes in a browser, open JellyTau, and
it resumed from whatever this device last saw, or offered no resume at
all. Same user-visible symptom as the Android bug fixed earlier on this
branch, from an unrelated cause -- which is why resume read as broadly
flaky rather than as one defect.

The mirror now carries the position alongside the favourite flag under
the same pending_sync = 0 conflict rule, so a local position still
waiting to be pushed is never pulled backwards by a server that has not
yet heard where we got to. COALESCE(excluded.x, user_data.x) keeps the
stored value for a field the server omitted rather than nulling it, and
a row with neither field is still skipped rather than fabricated as
zeroes.

Mirroring alone was not sufficient. get_item -- the call the player route
makes -- returned the cached copy on a hit and never consulted the
server, so for an already-cached item the mirror never ran. It now
refreshes in the background on a cache hit via race_with_refresh, the
reusable form of what get_items already did inline. That asymmetry is
why browsing a season picked up other devices' state while opening the
episode directly did not. The refreshed value lands for the next read;
the cache-first race still answers immediately.

The DR/total counts in extract-traces.test.ts are updated for DR-154 and
DR-155 -- that edit is the test's intended signal that the CI gate's
denominator is live rather than frozen.

Verified red->green in the jellytau-builder image: both new tests failed
before the fix. Full Rust suite passes (634), cargo fmt clean, clippy
adds no new warnings; frontend suite (933) and svelte-check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:14:20 +02:00
dtourolle fec4b7ae8c Merge branch 'fix/download-bitrate-param' into HEAD 2026-08-12 20:11:12 +02:00
dtourolleandClaude Opus 5 2ca2174cea fix(player): return volume control to the local speaker when a remote session stops
Stopping a remote session left Android stuck on the remote volume slider
with no way back to the device speaker.

Two causes:

1. `player_stop`'s remote branch sent "Stop" to the session and returned
   without touching the playback mode, so the manager stayed in Remote.
   It now drops to Idle, mirroring what the local branch already does.

2. Volume routing was torn down at a single call site
   (`transfer_to_local_inner`), so every *other* exit from remote mode
   leaked the Android VolumeProviderCompat. Routing is now derived from
   the transition inside `set_mode`: entering remote attaches control,
   any exit from remote hands it back to the local media stream. This
   also covers the frontend `disconnect()` path (Remote -> Idle) and the
   local-playback-start paths (Remote -> Local).

Adds a `RemoteVolumeControl` trait so the routing rule is unit-testable
off-device — the real implementation is Android JNI. Tests cover
remote->idle, remote->local, remote->remote (re-arms, never releases),
and that local/idle transitions leave routing untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:09:17 +02:00
dtourolleandClaude Opus 5 0ca2857c3a fix(catalog): show a new album once in Recently Added, not once per track
Recently Added listed every newly-added track individually, so importing a
14-track album filled the whole row with that one album and buried everything
else. Both code paths that build the row had the same symptom from separate
causes:

- Online: Jellyfin's /Items/Latest defaults to GroupItems=false, returning each
  new leaf on its own. Send GroupItems=true so the server collapses children
  into the container that was added.
- Offline: the downloaded-items CTE deliberately matches leaves *and* their
  container (right for browsing, wrong here), so a downloaded album returned the
  album plus each of its tracks. Drop a leaf only when its own container is in
  the same result.

Items with no container (movies, standalone tracks) are unaffected in both
paths. The online URL is extracted into build_latest_items_endpoint so it can be
asserted without an HTTP server, matching build_favorites_endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:07:57 +02:00
dtourolleandClaude Opus 5 1f32e4040b Merge branch 'fix/home-library-card-heights' from origin
Local and remote had both advanced two commits from 3619f71 with no
overlapping files:

  remote: uniform card heights; resume after furthest-watched episode
  local:  Android native-path resume; queued watch-position sync (DR-154)

Merged cleanly with no conflicts. The series_progress policy tests pass
against the merged file (19/19).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:21:18 +02:00
dtourolleandClaude Opus 5 e4632bb2b2 fix(sync): queue a watch position the server could not be told about (DR-154)
sync_queue and its drain (DR-131) were built, tested and running, but the
stop-report path never fed them, so closing a video while the server was
unreachable lost the resume point outright.

HybridRepository::report_playback_stopped is a bare pass-through to the
online repository ("Playback reporting goes directly to server"), and on
failure the error surfaced to a frontend catch whose own comment read
"Server error - could queue, but for now just log". Both producers that
would have queued it -- PlaybackReporter::queue_for_sync in Rust and
syncService.queuePlaybackProgress on the frontend -- have no callers on
the playback path. user_data.pending_sync was dutifully set to 1, but
nothing drains that flag for positions the way favourites do (DR-120).

The command layer now enqueues a report_playback_stopped row whenever the
push fails; the existing drain already parses and replays that operation.

The pending row for an item is superseded in place rather than appended
to: progress is reported every 10s, so a server that stays down would
otherwise add a row per tick, all obsoleted by the newest -- the
unbounded queue DR-131 exists to prevent. Only pending/failed rows are
superseded, since reviving an abandoned row restores that same growing
counter. Queueing is best-effort and never fails the command: the local
position is already saved, so a failed queue write must not be reported
as a lost position.

Verified red->green in the jellytau-builder image: the four new tests
failed to compile (enqueue_playback_stopped not found) before the fix.
Full Rust suite passes (627 tests), cargo fmt clean, clippy adds no new
warnings; frontend suite (933) and svelte-check also clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:18:11 +02:00
dtourolleandClaude Opus 5 2d50744320 fix(player): resume at saved position on the Android native path
The native (ExoPlayer) video path never applied the resume position, so
"resume from where you left off" always played from the start on Android.

Two layers each assumed the other did the seek:

- The only code acting on `initialPosition` was handleCanPlay, an HTML5
  <video> event handler. The native path has no <video> element, so
  `canplay` never fires and that seek never ran.
- NativePlayerAdapter.load() had an initialPosition branch, but it only
  recorded the number, claiming "the native backend performs the actual
  seek internally". It does not: PlayItemRequest carries no start
  position, and loadWithMetadata -> prepare() always starts ExoPlayer at 0.
- VideoPlayer never called adapter.load() at all, so even that branch was
  unreachable.

The frontend therefore believed it had resumed (the seek bar showed the
resume point) while ExoPlayer played from the beginning.

NativePlayerAdapter.load() now issues the backend seek, excluding live
streams (no resume point; seeking knocks the HLS window off its live
edge). VideoPlayer calls it on the native branch and marks the initial
seek as performed so the existing $effect does not fire a duplicate.
The HTML5 path is untouched: seeking before metadata is clamped to 0,
which is exactly what handleCanPlay waits for.

Verified red->green: the new test failed with "Number of calls: 0"
before the fix. Full frontend suite passes (933 tests); svelte-check
and check:boundary are clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:51:37 +02:00
dtourolleandClaude Opus 5 9d7cb085e9 fix(series): resume after the furthest-watched episode, not the first gap
`pick_current_episode` rung 3 returned the first unwatched episode in
series order. A viewer who skipped the pilot but is three seasons deep
was sent back to S1E1: the gap was a deliberate skip, not the place they
stopped.

This read as flaky rather than consistently wrong because rung 3 only
fires when the server's Next Up (rung 2) yields nothing, and
`resolve_current_episode` swallows that call's errors with
`.unwrap_or_default()`. `HybridRepository::get_next_up_episodes`
delegates unconditionally to the online repo, so any unreachable-server
moment silently degraded to the empty vec — same series, same watch
state, different answer depending on one request's outcome.

Rung 3 now scans the ordered list from the end with `rposition(is_played)`
and returns the episode after the furthest-watched one, falling back to
the previous first-unwatched behaviour when nothing is watched or the
series is finished. Season crossing comes free from the already-flat
series ordering, and `season_rank` keeps specials last so a watched
special cannot mark a show finished.

Tests written first and confirmed failing (S1E1 where S3E4 was
expected), covering the skipped-pilot case, rolling into the next season
past a skipped episode, and the watched-special case. All 17 existing
tests still pass.

Note: cargo test could not run locally (javascriptcoregtk-4.1 /
webkit2gtk-4.1 absent on this host). The pure policy half plus its
verbatim test module were extracted into a standalone crate to get real
red/green; the full crate suite still needs a run on a complete
toolchain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:23:26 +02:00
dtourolleandClaude Opus 5 85bd227714 fix(home): uniform card heights in the Your Libraries row
MediaCard derives its artwork aspect ratio from the item, so a music
library rendered aspect-square (144px tall at w-36) next to video
libraries at aspect-video (81px), leaving the home row ragged.

Add an optional `aspect` prop that overrides the derived ratio, and pass
aspect="video" from the home Libraries strip. Unset, behaviour is
unchanged, so the /library overview grid and the media carousels keep
their per-type ratios. Artwork already uses object-cover, so square music
art crops rather than distorts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:13:27 +02:00
19 changed files with 1048 additions and 62 deletions
+16
View File
@@ -87,6 +87,22 @@ RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
# Set NDK environment variable
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
# Gradle distribution. `tauri android init` regenerates gen/android with a
# wrapper pointing at services.gradle.org, so every Android job would otherwise
# download ~130MB of Gradle at build time — slow, and a hard failure when the
# CDN hiccups ("Unexpected end of file from server"). Ship the distribution in
# the image instead; scripts/sync-android-sources.sh repoints the regenerated
# wrapper at this local copy. Keep GRADLE_VERSION in sync with the version
# Tauri's generated wrapper requests.
ENV GRADLE_VERSION=8.14.3 \
GRADLE_HOME=/opt/gradle/gradle-8.14.3
RUN mkdir -p /opt/gradle/dist && \
wget -q "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \
-O "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" && \
unzip -q "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" -d /opt/gradle && \
"$GRADLE_HOME/bin/gradle" --version
ENV PATH="$GRADLE_HOME/bin:$PATH"
# ---------------------------------------------------------------------------
# Desktop packaging tools — kept in a trailing layer ON PURPOSE so that adding
# or changing a packaging tool doesn't invalidate the expensive apt/rust/Android
+4
View File
@@ -316,6 +316,8 @@ Internal architecture, components, and application logic.
| DR-151 | The player's video SurfaceView actually reaches the view hierarchy. `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was always null and `autoAttachSurface()` returned at "Cannot attach surface - no Activity reference". The surface was created and handed to ExoPlayer but never added to the content view, so native video decoded to a surface that was never on screen — independent of any webview transparency. `MainActivity.onCreate` now supplies the reference, which also revives PiP on the video path: `canEnterPip()` gates on `isVideoSurfaceAttached()`, which had been permanently false | Playback | UR-003, UR-041 | Done |
| DR-152 | Platform playback facilities are reported by Rust, not sniffed from the user agent. `webviewAudio.ts` re-derived "does this platform have a native audio backend" by matching `navigator.userAgent` against `android`/`linux` — a second copy of the `cfg!` gate the backends are compiled under, free to drift from it. `player_get_capabilities` now returns `usesWebviewAudio` and `supportsNativeVideo` from the same cfg gates, and the frontend consumes them; the settings toggle for native video is hidden entirely where the platform cannot support it | Player | UR-003, UR-005 | Done |
| DR-153 | The git tag is the single source of truth for a release version. The version lived in four files (`package.json`, `tauri.conf.json`, `Cargo.toml`, `Cargo.lock`) that had to be hand-edited in lockstep, and CI's release job rewrote exactly one of them — so a tagged build produced an installer named for the tag wrapped around package metadata naming the previous release, while the Linux job had no version step at all and shipped whatever was committed. `scripts/set-version.sh` writes all four from one argument and is the only thing that does; every release job calls it with the tag. The Android `versionCode` is derived in the same place as `1000 + major*10000 + minor*100 + patch`, which is monotonic in semver order and clears the 1000 floor already installed in the field — a lower code than the installed one makes Android refuse the update. A prerelease suffix is stripped before that arithmetic, which would otherwise abort the script, and a non-tag ref (CI passes `${GITHUB_REF#refs/tags/}` unconditionally) falls back to `git describe` rather than failing a branch build | Build | - | Done |
| DR-154 | A watch position that cannot reach the server is queued, not dropped. `sync_queue` and its drain (DR-131) were built, tested and running, but the stop-report path never fed them: `HybridRepository::report_playback_stopped` is a bare pass-through to the online repository ("Playback reporting goes directly to server"), and on failure the error surfaced to a frontend `catch` whose own comment read "Server error - could queue, but for now just log". Both producers that *would* have queued it — `PlaybackReporter::queue_for_sync` in Rust and `syncService.queuePlaybackProgress` on the frontend — have no callers on the playback path, so closing a video while the server was unreachable lost the resume point outright even though `user_data.pending_sync` was dutifully set to 1 and nothing ever drains that flag for positions (unlike favourites, DR-120). The command layer now enqueues a `report_playback_stopped` row whenever the push fails, which the existing drain already knows how to parse and replay. The pending row for an item is **superseded in place** rather than appended to: progress is reported every 10s, so a server that stays down would otherwise add a row per tick, all of them obsoleted by the newest — the unbounded queue DR-131 exists to prevent. Only `pending`/`failed` rows are superseded, because an `abandoned` row has been given up on and reviving it would restore that same growing counter. Queueing is best-effort and never fails the command: the local position is already saved, so a failed *queue* write must not be reported as a lost position | Backend | UR-025, UR-002 | Done |
| DR-155 | A watch position set on another device reaches this one. The resume check reads the local `user_data` row and nothing else, but `mirror_user_data` — the only path by which server `UserData` lands in that table — mirrored `is_favorite` alone, and returned early whenever that field was absent, which is exactly the shape of an ordinary watched episode. So `playback_position_ticks` was write-only from this device's perspective: watch 40 minutes in a browser, open JellyTau, and it resumed from whatever *this* device last saw or offered no resume at all — the same user-visible symptom as DR-150's Android bug, from an unrelated cause, which is why resume read as broadly flaky. The mirror now carries the position alongside the favourite flag under the same `pending_sync = 0` conflict rule, so a local position still waiting to be pushed is never pulled *backwards* by a server that has not yet heard where we got to; `COALESCE(excluded.x, user_data.x)` means a field the server omitted keeps its stored value rather than being nulled, and a row with neither field is still skipped rather than fabricated as zeroes. Mirroring alone was not sufficient: `get_item` — the call the player route makes — returned the cached copy on a hit and never consulted the server, so for an already-cached item the mirror never ran. It now refreshes in the background on a cache hit (`race_with_refresh`, the reusable form of what `get_items` already did inline), which is why browsing a season picked up other devices' state while opening the episode directly did not. The refreshed value lands for the next read, the cache-first race still answering immediately | Backend | UR-025, UR-002 | Done |
| DR-143 | Flipping the offline downloaded-only gate actually re-queries the listing. The gate (DR-078) is a process-wide flag in Rust consulted only *while a query runs*, but no library surface re-queried when its inputs changed: `useServerReachabilityReload` fires only on the offline → **online** transition, and `GenericMediaListPage`, `GenericGenreBrowser` and the favourites page never even called its `checkServerReachability`. So going offline left the full server catalog on screen under a now-closed gate, and toggling "Show all server media" only greyed cards — `MediaCard.isServerOnly` is a pure frontend derivation that updates instantly — without adding or removing a single row. The filter therefore read as "shows everything until I filter, then greys some of it" while the backend gate was correct and simply never exercised. `catalogFilterVersion` is the refetch signal: `pushCatalogVisibility` now awaits `set_show_server_catalog` and bumps the version only **after** the backend accepts the new flag, since a reload racing the push would re-query under the old gate and undo itself. A failed push clears `lastIncludeCatalog` instead of latching it, so the next identical transition is retried rather than skipped as a no-op and left permanently disagreeing with the backend. `useOfflineFilterReload` subscribes pages to that signal, skipping the value they already loaded under; it is wired into both generic list components and the movies/music/tv/favourites landing pages and the `/library/[id]` detail page | UI | UR-052 | Done |
| DR-135 | A download's media type comes from the item, not a default. `download_item` — the path a media card uses to queue an item while offline — never records `media_type`, and the reconnect resolver read that NULL as `'audio'`, so a **movie** queued from a card had its URL resolved by `get_audio_stream_url`. The file that landed on disk was an audio-only transcode, which is why a "downloaded" film could never play offline no matter how the path or protocol was fixed. The resolver now falls back to the item's own `item_type` (`VIDEO_ITEM_TYPES` in Rust, so the frontend never learns which types are video) and only defaults to audio when the item is not cached locally. An explicit `media_type` on the row still wins | Downloads | UR-071, UR-052 | Done |
| DR-136 | Rows already downloaded under the audio default are repaired, not just prevented. They are identifiable after the fact — no `media_type`, but a video item — so on reconnect they are reset to `pending` with their audio URL cleared and re-resolved by DR-135's corrected logic, overwriting the audio file in place. Without this the fix is invisible to anyone who had already queued a film: the row still reads "downloaded" and still fails to play. Rows carrying an explicit `media_type` and genuine audio downloads are left untouched | Downloads | UR-071 | Done |
@@ -549,6 +551,8 @@ Internal architecture, components, and application logic.
| UT-148 | Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessed | DR-149 | Done |
| UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Done |
| UT-150 | `set-version.sh` stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix | DR-153 | Done |
| UT-151 | An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report | DR-154 | Done |
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
+2 -2
View File
@@ -175,8 +175,8 @@ describe("live requirements.md", () => {
expect(defined.UR).toBe(71);
expect(defined.IR).toBe(32);
expect(defined.DR).toBe(148);
expect(defined.DR).toBe(150);
expect(defined.JA).toBe(35);
expect(defined.total).toBe(286);
expect(defined.total).toBe(288);
});
});
+22
View File
@@ -118,4 +118,26 @@ if [ -d "$RES_SRC" ]; then
"$RES_DST"/drawable*/ic_launcher_background.xml
fi
# Gradle wrapper distribution. `tauri android init` regenerates the wrapper
# pointing at services.gradle.org, so each build downloads ~130MB of Gradle —
# slow, and a hard failure when the CDN drops the connection mid-transfer
# ("Unexpected end of file from server"), which is what broke the release APK
# job. The builder image ships the matching distribution under /opt/gradle/dist,
# so when it's present repoint the wrapper at that local zip and build offline.
# Outside the image (dev machines) the properties file is left untouched and the
# wrapper downloads as usual.
WRAPPER_PROPS="$PROJECT_ROOT/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties"
if [ -f "$WRAPPER_PROPS" ]; then
WANTED_VERSION="$(sed -n 's#.*/gradle-\([0-9.]*\)-\(bin\|all\)\.zip.*#\1#p' "$WRAPPER_PROPS")"
LOCAL_DIST="/opt/gradle/dist/gradle-${WANTED_VERSION}-bin.zip"
if [ -n "$WANTED_VERSION" ] && [ -f "$LOCAL_DIST" ]; then
# distributionUrl is a java.util.Properties value: ':' must stay escaped.
sed -i "s#^distributionUrl=.*#distributionUrl=file\\\\:///opt/gradle/dist/gradle-${WANTED_VERSION}-bin.zip#" \
"$WRAPPER_PROPS"
echo " Gradle wrapper -> local distribution ($WANTED_VERSION, offline)"
elif [ -n "$WANTED_VERSION" ]; then
echo " Gradle wrapper: $WANTED_VERSION not in image, will download"
fi
fi
echo "✓ Android sources synced successfully"
+10
View File
@@ -1003,6 +1003,16 @@ pub async fn player_stop(
.clone()
};
client.send_session_command(session_id, "Stop").await?;
// Stopping the remote session ends the cast, so the manager returns to
// Idle — same as a local stop. This is also what hands OS volume control
// back to this device: set_mode releases the Android remote volume
// provider on any exit from remote mode. Without it the mode stayed
// Remote and the system volume slider remained stuck on the remote
// session with no way back to the local speaker.
playback_mode
.0
.set_mode(crate::playback_mode::PlaybackMode::Idle);
} else {
// Local playback
let controller = player.0.lock().await;
+41 -2
View File
@@ -712,9 +712,19 @@ pub async fn repository_report_playback_progress(
}
/// Report playback stopped
///
/// A stop-report that cannot reach the server is queued rather than dropped:
/// this is the position the resume point is built from, and losing it is
/// exactly the "it forgot where I was" the sync queue exists to prevent. The
/// drain (DR-131) pushes it on the next reconnect. Queueing is best-effort —
/// failing the command because the *queue* write failed would tell the caller
/// the report was lost when the local position was already saved.
///
/// TRACES: UR-025 | DR-154 | UT-151
#[tauri::command]
#[specta::specta]
pub async fn repository_report_playback_stopped(
db: State<'_, crate::commands::storage::DatabaseWrapper>,
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
@@ -723,10 +733,39 @@ pub async fn repository_report_playback_stopped(
// Milliseconds across the boundary; the Jellyfin API wants ticks.
let position_ticks = position_ms * 10_000;
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
let result = repo
.as_ref()
.report_playback_stopped(&item_id, position_ticks)
.await;
if let Err(e) = &result {
let db_service = {
let database = db.0.lock().map_err(|err| err.to_string())?;
Arc::new(database.service())
};
let user_id = repo.user_id().to_string();
if let Err(queue_err) = crate::commands::sync_drain::enqueue_playback_stopped(
&db_service,
&user_id,
&item_id,
position_ticks,
)
.await
.map_err(|e| format!("{:?}", e))
{
warn!(
"[Repository] Stop-report for {} failed ({:?}) and could not be queued: {}",
item_id, e, queue_err
);
} else {
debug!(
"[Repository] Stop-report for {} failed ({:?}); queued for the next reconnect",
item_id, e
);
}
}
result.map_err(|e| format!("{:?}", e))
}
/// Get image URL for an item
+196
View File
@@ -355,6 +355,65 @@ async fn mark_failed(
Ok(())
}
/// Queue a watch position that could not be reported to the server.
///
/// The stop-report path pushed straight to the server and, on failure, logged
/// and dropped the position — so closing a video while the server was
/// unreachable lost the resume point outright, even though the queue and its
/// drain (DR-131) were built and running. This is the missing producer.
///
/// The pending row for an item is *replaced* rather than appended to. Progress
/// is reported every 10s, so a server that stays down would otherwise grow one
/// row per tick, all of them superseded by the newest — the unbounded queue
/// DR-131 exists to prevent. Only `pending`/`failed` rows are superseded:
/// an `abandoned` row has been given up on and must not be revived, and a
/// `completed` one is history.
///
/// TRACES: UR-025 | DR-154 | UT-151
pub async fn enqueue_playback_stopped(
db: &Arc<RusqliteService>,
user_id: &str,
item_id: &str,
position_ticks: i64,
) -> Result<(), String> {
let payload = format!(r#"{{"position_ticks": {}}}"#, position_ticks);
// Supersede an already-queued position for this item, keeping its place in
// the queue order (created_at) so a later item cannot overtake it.
let updated = db
.execute(Query::with_params(
"UPDATE sync_queue \
SET payload = ?, status = 'pending', error_message = NULL \
WHERE user_id = ? AND item_id = ? AND operation = 'report_playback_stopped' \
AND status IN ('pending', 'failed')",
vec![
QueryParam::String(payload.clone()),
QueryParam::String(user_id.to_string()),
QueryParam::String(item_id.to_string()),
],
))
.await?;
if updated == 0 {
db.execute(Query::with_params(
"INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at) \
VALUES (?, 'report_playback_stopped', ?, ?, 'pending', CURRENT_TIMESTAMP)",
vec![
QueryParam::String(user_id.to_string()),
QueryParam::String(item_id.to_string()),
QueryParam::String(payload),
],
))
.await?;
}
debug!(
"[SyncQueue] Queued unreported stop for {} at {} ticks",
item_id, position_ticks
);
Ok(())
}
/// Drain on every offline→online transition.
///
/// TRACES: UR-025 | DR-131
@@ -781,6 +840,143 @@ mod tests {
assert_eq!(row_state(&db, "theirs").await.0, "pending");
}
/// The bug DR-154 fixes: a stop-report that could not reach the server was
/// logged and dropped, so the watch position was lost outright. It must
/// land in the queue the drain already knows how to push.
///
/// TRACES: UR-025 | DR-154 | UT-151
#[tokio::test]
async fn test_failed_stop_report_is_queued_rather_than_dropped() {
let db = test_db();
enqueue_playback_stopped(&db, "u1", "ep1", 5_000_000_000)
.await
.unwrap();
// The very drain that already exists must be able to push it.
let sink = RecordingSink::new();
let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
assert_eq!(
sink.calls(),
vec![QueuedOp::PlaybackStopped {
item_id: "ep1".to_string(),
position_ticks: 5_000_000_000,
}]
);
assert_eq!(report.pushed, 1);
assert_eq!(report.remaining, 0);
}
/// Progress is reported every 10s, and a server that stays unreachable
/// would otherwise add a row per tick — an unbounded queue of positions
/// that are all superseded by the newest one. The pending row for an item
/// is replaced in place, so the queue holds the latest position only.
///
/// TRACES: UR-025 | DR-154 | UT-151
#[tokio::test]
async fn test_requeueing_the_same_item_supersedes_the_earlier_position() {
let db = test_db();
enqueue_playback_stopped(&db, "u1", "ep1", 1_000)
.await
.unwrap();
enqueue_playback_stopped(&db, "u1", "ep1", 2_000)
.await
.unwrap();
enqueue_playback_stopped(&db, "u1", "ep1", 3_000)
.await
.unwrap();
let sink = RecordingSink::new();
drain_sync_queue(&db, &sink, "u1").await.unwrap();
// One row, carrying the newest position — not three.
assert_eq!(
sink.calls(),
vec![QueuedOp::PlaybackStopped {
item_id: "ep1".to_string(),
position_ticks: 3_000,
}]
);
}
/// Distinct items must not collide — superseding is per item, not global.
///
/// TRACES: UR-025 | DR-154 | UT-151
#[tokio::test]
async fn test_requeueing_keeps_positions_for_different_items_apart() {
let db = test_db();
enqueue_playback_stopped(&db, "u1", "ep1", 1_000)
.await
.unwrap();
enqueue_playback_stopped(&db, "u1", "ep2", 2_000)
.await
.unwrap();
let sink = RecordingSink::new();
drain_sync_queue(&db, &sink, "u1").await.unwrap();
let mut calls = sink.calls();
calls.sort_by_key(|op| match op {
QueuedOp::PlaybackStopped { item_id, .. } => item_id.clone(),
_ => String::new(),
});
assert_eq!(
calls,
vec![
QueuedOp::PlaybackStopped {
item_id: "ep1".to_string(),
position_ticks: 1_000,
},
QueuedOp::PlaybackStopped {
item_id: "ep2".to_string(),
position_ticks: 2_000,
},
]
);
}
/// A row already abandoned (DR-131 gave up on it) must not be resurrected
/// by a later report — that would restore the queue-that-only-grows this
/// whole area exists to prevent. The new report is queued as its own row.
///
/// TRACES: UR-025 | DR-154 | UT-151
#[tokio::test]
async fn test_requeueing_does_not_revive_an_abandoned_row() {
let db = test_db();
seed(
&db,
&[(
"u1",
"report_playback_stopped",
"ep1",
Some(r#"{"position_ticks": 111}"#),
"abandoned",
MAX_SYNC_ATTEMPTS,
"2026-08-01T10:00:00Z",
)],
)
.await;
enqueue_playback_stopped(&db, "u1", "ep1", 999)
.await
.unwrap();
let sink = RecordingSink::new();
drain_sync_queue(&db, &sink, "u1").await.unwrap();
// Only the fresh row is pushed; the abandoned one stays abandoned.
assert_eq!(
sink.calls(),
vec![QueuedOp::PlaybackStopped {
item_id: "ep1".to_string(),
position_ticks: 999,
}]
);
}
/// Nothing queued means no server calls at all — a reconnect must not
/// generate traffic just because it happened.
///
+210 -23
View File
@@ -27,6 +27,10 @@ const TICKS_PER_SECOND: f64 = 10_000_000.0;
/// send a resume position, so a fresh track casts from 0 rather than ~0.
const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
/// Volume level (0-100) the remote volume slider starts at. The real level is
/// corrected by the session poller once the remote session reports its volume.
const DEFAULT_REMOTE_VOLUME: i32 = 50;
/// Convert a live playback position (seconds) into the `StartPositionTicks` to
/// hand to a remote session, or `None` if we're effectively at the start.
///
@@ -42,6 +46,50 @@ fn start_position_ticks_from_seconds(position_seconds: f64) -> Option<i64> {
}
}
/// Platform hook for attaching/detaching the OS remote-volume control.
///
/// On Android, entering remote mode hands the `MediaSession` a
/// `VolumeProviderCompat` so hardware volume buttons and the system slider drive
/// the *remote* session; leaving remote mode must hand it back to the local
/// media stream. Behind a trait so the routing rule (see
/// [`PlaybackModeManager::set_mode`]) is unit-testable off-device — the real
/// implementation is JNI and only exists on Android.
pub trait RemoteVolumeControl: Send + Sync {
/// Attach remote-volume control (and, on Android, start the playback service).
fn enable(&self, initial_volume: i32);
/// Return volume control to the local device speaker.
fn disable(&self);
}
/// Production hook: forwards to the Android JNI bridge; no-op elsewhere.
struct PlatformRemoteVolumeControl;
impl RemoteVolumeControl for PlatformRemoteVolumeControl {
#[allow(unused_variables)]
fn enable(&self, initial_volume: i32) {
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::enable_remote_volume(initial_volume) {
log::warn!(
"[PlaybackMode] Failed to enable remote volume/service: {}",
e
);
// Non-fatal - continue; the next poll tick will retry metadata.
}
}
}
fn disable(&self) {
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::disable_remote_volume() {
log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e);
// Non-fatal - the mode change itself has already happened.
}
}
}
}
/// Manages playback mode transfers between local and remote sessions
pub struct PlaybackModeManager {
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
@@ -51,6 +99,8 @@ pub struct PlaybackModeManager {
/// Optional emitter used to notify the frontend when the mode changes, so its
/// mirror store stays in sync with this authoritative one. `None` in tests.
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
/// Platform hook for OS-level remote volume routing (swapped in tests).
remote_volume: Arc<dyn RemoteVolumeControl>,
}
impl PlaybackModeManager {
@@ -65,6 +115,24 @@ impl PlaybackModeManager {
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
is_transferring: Arc::new(AtomicBool::new(false)),
event_emitter: Arc::new(Mutex::new(None)),
remote_volume: Arc::new(PlatformRemoteVolumeControl),
}
}
/// Construct with a custom remote-volume hook (tests).
#[cfg(test)]
fn with_remote_volume(
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
player_controller: Arc<TokioMutex<PlayerController>>,
remote_volume: Arc<dyn RemoteVolumeControl>,
) -> Self {
Self {
jellyfin_client,
player_controller,
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
is_transferring: Arc::new(AtomicBool::new(false)),
event_emitter: Arc::new(Mutex::new(None)),
remote_volume,
}
}
@@ -86,19 +154,39 @@ impl PlaybackModeManager {
/// the frontend's mirror store reconciles to this authoritative value. The
/// write lock is released before emitting to avoid holding it across the
/// emitter call.
///
/// Also owns **OS volume routing**, which is derived from the transition
/// rather than from each call site: entering remote mode attaches the remote
/// volume control, and *any* exit from remote mode hands it back to the local
/// speaker. Doing this per-call-site is what caused the bug where stopping a
/// remote session (`player_stop` → Idle) left Android stuck on the remote
/// volume slider — only the transfer-to-local path tore it down.
///
/// TRACES: UR-010 | DR-059, IR-021
pub fn set_mode(&self, mode: PlaybackMode) {
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
let changed = {
let (changed, was_remote) = {
let mut current = self.current_mode.write_safe();
let changed = *current != mode;
let was_remote = matches!(*current, PlaybackMode::Remote { .. });
*current = mode.clone();
changed
(changed, was_remote)
};
if !changed {
return;
}
// Volume routing follows the transition. Note remote->remote (switching
// target session) re-arms rather than releasing control.
let is_remote = matches!(mode, PlaybackMode::Remote { .. });
if is_remote {
self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
} else if was_remote {
log::info!("[PlaybackMode] Leaving remote mode - restoring local volume control");
self.remote_volume.disable();
}
let (mode_str, session_id) = match &mode {
PlaybackMode::Local => ("local".to_string(), None),
PlaybackMode::Idle => ("idle".to_string(), None),
@@ -122,18 +210,13 @@ impl PlaybackModeManager {
/// Both symptoms share this one cause, so this must not be skipped on any
/// remote-entry path (notably the empty-queue early return in
/// `transfer_to_remote_inner`). No-op / non-Android builds do nothing.
#[allow(unused_variables)]
///
/// [`set_mode`](Self::set_mode) already arms this on entry into remote mode;
/// calling it again is harmless (the service start is idempotent) and keeps
/// the guarantee when the mode was already remote, which `set_mode` skips as
/// a no-op transition.
fn enable_remote_control(&self) {
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::enable_remote_volume(50) {
log::warn!(
"[PlaybackMode] Failed to enable remote volume/service: {}",
e
);
// Non-fatal - continue; the next poll tick will retry metadata.
}
}
self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
}
/// Check if currently transferring
@@ -766,18 +849,10 @@ impl PlaybackModeManager {
// This will be improved in Phase 3 when repository is migrated to Rust.
log::debug!("[PlaybackMode] Cannot load media item in Rust yet - frontend handled it");
// Update mode to local
// Update mode to local. This also returns volume control to the local
// device speaker — set_mode owns that for every exit from remote mode.
self.set_mode(PlaybackMode::Local);
// Disable remote volume control on Android (return to system volume)
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::disable_remote_volume() {
log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e);
// Non-fatal - continue with transfer
}
}
log::info!("[PlaybackMode] Successfully transferred to local");
Ok(())
}
@@ -893,6 +968,118 @@ mod tests {
}
}
/// Records enable/disable calls so tests can assert volume routing.
struct RecordingVolumeControl {
calls: Mutex<Vec<&'static str>>,
}
impl RemoteVolumeControl for RecordingVolumeControl {
fn enable(&self, _initial_volume: i32) {
self.calls.lock().unwrap().push("enable");
}
fn disable(&self) {
self.calls.lock().unwrap().push("disable");
}
}
fn manager_with_volume_control() -> (PlaybackModeManager, Arc<RecordingVolumeControl>) {
let volume = Arc::new(RecordingVolumeControl {
calls: Mutex::new(Vec::new()),
});
let manager = PlaybackModeManager::with_remote_volume(
Arc::new(Mutex::new(None)),
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
volume.clone(),
);
(manager, volume)
}
/// Leaving remote mode must hand volume control back to the local device.
///
/// Stopping a remote session (`player_stop`) drives the manager
/// Remote -> Idle without going through `transfer_to_local`. Before this was
/// centralised in `set_mode`, only the transfer path tore the Android
/// `VolumeProviderCompat` down, so a plain stop left the system stuck on the
/// remote volume slider with no way back to the phone speaker.
///
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
#[test]
fn test_leaving_remote_mode_restores_local_volume() {
let (manager, volume) = manager_with_volume_control();
manager.set_mode(PlaybackMode::Remote {
session_id: "sess-1".to_string(),
});
// The stop path: remote -> idle, no transfer involved.
manager.set_mode(PlaybackMode::Idle);
assert_eq!(
*volume.calls.lock().unwrap(),
vec!["enable", "disable"],
"remote->idle must return volume control to the local speaker"
);
}
/// The same must hold for remote -> local (transfer back to this device).
///
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
#[test]
fn test_remote_to_local_restores_local_volume() {
let (manager, volume) = manager_with_volume_control();
manager.set_mode(PlaybackMode::Remote {
session_id: "sess-1".to_string(),
});
manager.set_mode(PlaybackMode::Local);
assert_eq!(
*volume.calls.lock().unwrap(),
vec!["enable", "disable"],
"remote->local must return volume control to the local speaker"
);
}
/// Volume routing must not be touched by transitions that never involve
/// remote mode — an idle->local start would otherwise issue a pointless
/// `setPlaybackToLocal` on every playback start.
///
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
#[test]
fn test_non_remote_transitions_leave_volume_routing_alone() {
let (manager, volume) = manager_with_volume_control();
manager.set_mode(PlaybackMode::Local);
manager.set_mode(PlaybackMode::Idle);
manager.set_mode(PlaybackMode::Local);
assert!(
volume.calls.lock().unwrap().is_empty(),
"local/idle transitions must not touch remote volume routing"
);
}
/// Switching directly between two remote sessions stays remote: control must
/// remain attached (re-armed for the new session), never handed back local.
///
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
#[test]
fn test_remote_to_remote_keeps_remote_volume() {
let (manager, volume) = manager_with_volume_control();
manager.set_mode(PlaybackMode::Remote {
session_id: "sess-1".to_string(),
});
manager.set_mode(PlaybackMode::Remote {
session_id: "sess-2".to_string(),
});
assert_eq!(
*volume.calls.lock().unwrap(),
vec!["enable", "enable"],
"remote->remote re-arms control without releasing it to local"
);
}
/// Setting the same mode twice must not re-emit — the frontend reconciler
/// (and the event channel) shouldn't be spammed on no-op transitions.
#[test]
+82 -1
View File
@@ -319,6 +319,49 @@ impl HybridRepository {
}
}
/// [`Self::parallel_race`], plus a callback fired on the fast path so the
/// caller can refresh the cache in the background.
///
/// A plain cache hit answers from data that may be arbitrarily old, which
/// is right for the *response* and wrong for what it leaves behind: per-user
/// state (watch positions, favourites) only reaches the local tables when a
/// server result is cached, so a surface that always hits cache never learns
/// what another device did. `get_items` had a bespoke version of this; this
/// is the same idea, reusable.
///
/// The callback runs only on a cache hit — on a miss the server result is
/// already being fetched and cached by the normal path.
///
/// TRACES: UR-002, UR-025 | DR-155
async fn race_with_refresh<T, F1, F2, R>(
&self,
cache_future: F1,
server_future: F2,
on_cache_hit: R,
) -> Result<T, RepoError>
where
T: MeaningfulContent + Clone + Send + 'static,
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
R: FnOnce(),
{
let cache_result = cache_future.await;
if let Ok(data) = &cache_result {
if data.has_content() {
debug!("[HybridRepo] Cache hit, returning immediately (refreshing in background)");
on_cache_hit();
return Ok(data.clone());
}
}
debug!("[HybridRepo] Cache miss, querying server");
match server_future.await {
Ok(data) => Ok(data),
Err(e) => cache_result.or(Err(e)),
}
}
/// Simple timeout wrapper for cache queries (100ms timeout)
///
/// @req: DR-013 - Repository pattern (cache-first with timeout)
@@ -489,6 +532,21 @@ impl MediaRepository for HybridRepository {
}
}
/// A single item, cache-first — and, on a cache hit, refreshed in the
/// background so the stored copy keeps up with the server.
///
/// The background refresh is what carries per-user state home: caching an
/// item runs `mirror_user_data`, which is the only path by which a watch
/// position set on another device reaches the local `user_data` row the
/// resume check reads. Without it a cache hit returned this device's own
/// stale position forever and cross-device resume silently did nothing —
/// `get_items` already refreshes this way, so browsing a season worked
/// while opening the episode directly did not.
///
/// The refreshed value lands for the *next* read rather than this one: the
/// point of the cache-first race is to answer immediately.
///
/// TRACES: UR-025, UR-002 | DR-155 | UT-152
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online);
@@ -497,9 +555,32 @@ impl MediaRepository for HybridRepository {
let cache_future = self.cache_with_timeout(async move { offline.get_item(&item_id).await });
let online_for_refresh = Arc::clone(&self.online);
let offline_for_save = Arc::clone(&self.offline);
let refresh_id = item_id_clone.clone();
let on_cache_hit = move || {
tokio::spawn(async move {
match online_for_refresh.get_item(&refresh_id).await {
Ok(fresh) => {
// `save_to_cache` files the row under a parent; the item's
// own parent keeps it where a later listing expects it.
let parent = fresh
.parent_id
.clone()
.unwrap_or_else(|| "item".to_string());
if let Err(e) = offline_for_save.save_to_cache(&parent, &[fresh]).await {
debug!("[HybridRepo] Background item refresh failed: {:?}", e);
}
}
Err(e) => debug!("[HybridRepo] Background item refresh unavailable: {:?}", e),
}
});
};
let server_future = async move { online.get_item(&item_id_clone).await };
self.parallel_race(cache_future, server_future).await
self.race_with_refresh(cache_future, server_future, on_cache_hit)
.await
}
async fn get_latest_items(
+241 -16
View File
@@ -665,40 +665,63 @@ impl OfflineRepository {
}
/// Mirror the server's per-user state for an item into the local
/// `user_data` table, so favourites marked on any other client are visible
/// here — including offline, where the local table is the only source.
/// `user_data` table, so favourites marked and positions watched — on any
/// other client are visible here, including offline, where the local table
/// is the only source.
///
/// The `WHERE user_data.pending_sync = 0` on the conflict clause is the
/// conflict rule: a toggle made while the server was unreachable is still
/// conflict rule: a change made while the server was unreachable is still
/// waiting to be pushed, and must not be clobbered by the stale value the
/// server is still reporting. Rows carrying no favourite state are skipped
/// entirely rather than written as `0`, which would fabricate an
/// "unfavourited" record from an endpoint that simply omits `UserData`.
/// server is still reporting. For a position that means it is never pulled
/// *backwards* by a server that has not yet heard where we got to.
///
/// TRACES: UR-069 | DR-114 | UT-102
/// Each field is mirrored only when the server actually reported it —
/// `COALESCE(excluded.x, user_data.x)` keeps the stored value for anything
/// absent, and a row with neither field is skipped outright rather than
/// written as zeroes, which would fabricate an "unfavourited, unwatched"
/// record from an endpoint that simply omits `UserData`.
///
/// The position half is what makes cross-device resume work: the resume
/// check reads this table alone, so before it was mirrored an item watched
/// elsewhere resumed from whatever *this* device last saw, or not at all.
///
/// TRACES: UR-025, UR-069 | DR-114, DR-155 | UT-102, UT-152
async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> {
let Some(is_favorite) = item.user_data.as_ref().and_then(|ud| ud.is_favorite) else {
let user_data = item.user_data.as_ref();
let is_favorite = user_data.and_then(|ud| ud.is_favorite);
let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
// Nothing the server actually told us about — do not invent a row.
if is_favorite.is_none() && position_ticks.is_none() {
return Ok(());
};
}
let query = Query::with_params(
"INSERT INTO user_data (user_id, item_id, is_favorite, synced_at, pending_sync)
VALUES (?1, ?2, ?3, ?4, 0)
"INSERT INTO user_data
(user_id, item_id, is_favorite, playback_position_ticks, synced_at, pending_sync)
VALUES (?1, ?2, ?3, ?4, ?5, 0)
ON CONFLICT(user_id, item_id) DO UPDATE SET
is_favorite = excluded.is_favorite,
is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
playback_position_ticks = COALESCE(
excluded.playback_position_ticks, user_data.playback_position_ticks),
synced_at = excluded.synced_at
WHERE user_data.pending_sync = 0",
vec![
QueryParam::String(self.user_id.clone()),
QueryParam::String(item.id.clone()),
QueryParam::Int(if is_favorite { 1 } else { 0 }),
is_favorite
.map(|f| QueryParam::Int(if f { 1 } else { 0 }))
.unwrap_or(QueryParam::Null),
position_ticks
.map(QueryParam::Int64)
.unwrap_or(QueryParam::Null),
QueryParam::String(now.to_string()),
],
);
// A missing item row (FK) is not fatal here — the favourite mirror is
// best-effort metadata, and failing the whole cache write over it would
// break browsing.
// A missing item row (FK) is not fatal here — the mirror is best-effort
// metadata, and failing the whole cache write over it would break
// browsing.
if let Err(e) = self.db_service.execute(query).await {
debug!(
"[OfflineRepo] user_data mirror skipped for {}: {}",
@@ -1426,6 +1449,14 @@ impl MediaRepository for OfflineRepository {
FROM items i
INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.server_id = ? AND i.library_id = ?
-- Collapse leaves into the container that was added: a new
-- 14-track album should read as one album, not 14 songs. Only
-- drops a leaf when its own container is present in the same
-- result, so a standalone track or movie still appears.
AND NOT EXISTS (
SELECT 1 FROM downloaded_items parent
WHERE parent.id IN (i.album_id, i.season_id, i.series_id, i.parent_id)
)
ORDER BY i.synced_at DESC
LIMIT {}", limit_val
),
@@ -3544,6 +3575,32 @@ mod tests {
.unwrap();
}
/// Like `insert_item`, but sets `library_id` — which `get_latest_items`
/// filters on, so rows without it are invisible to that query.
async fn insert_library_item(
db: &Arc<RusqliteService>,
id: &str,
item_type: &str,
library_id: &str,
album_id: Option<&str>,
) {
db.execute(Query::with_params(
"INSERT INTO items (id, server_id, library_id, name, item_type, album_id, synced_at)
VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, '2024-01-01')",
vec![
QueryParam::String(id.to_string()),
QueryParam::String(library_id.to_string()),
QueryParam::String(format!("Name {id}")),
QueryParam::String(item_type.to_string()),
album_id
.map(|s| QueryParam::String(s.to_string()))
.unwrap_or(QueryParam::Null),
],
))
.await
.unwrap();
}
async fn seed_completed_download(db: &Arc<RusqliteService>, item_id: &str, file_size: i64) {
db.execute(Query::with_params(
"INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)",
@@ -3578,6 +3635,36 @@ mod tests {
)
}
/// A newly-synced album appears once in "recently added", not once per track.
///
/// The downloaded-items CTE deliberately matches both the leaves and their
/// container, which is right for browsing but wrong here: it made a 3-track
/// album occupy 4 slots in the row. Tracks whose album is itself in the
/// result are now collapsed into it.
#[tokio::test]
async fn test_get_latest_items_collapses_tracks_into_their_album() {
let db = create_test_db();
insert_library_item(&db, "album-1", "MusicAlbum", "lib-1", None).await;
for track in ["track-1", "track-2", "track-3"] {
insert_library_item(&db, track, "Audio", "lib-1", Some("album-1")).await;
seed_completed_download(&db, track, 1000).await;
}
// A movie has no container, so it must still show up on its own.
insert_library_item(&db, "movie-1", "Movie", "lib-1", None).await;
seed_completed_download(&db, "movie-1", 2000).await;
let repo = make_repo(&db);
let latest = repo.get_latest_items("lib-1", Some(16)).await.unwrap();
let ids: Vec<&str> = latest.iter().map(|i| i.id.as_str()).collect();
assert!(
!ids.iter().any(|id| id.starts_with("track-")),
"individual tracks must collapse into their album, got: {ids:?}"
);
assert!(ids.contains(&"album-1"), "the album itself is listed");
assert!(ids.contains(&"movie-1"), "containerless items still listed");
}
/// UT: downloaded-only browse returns a downloaded leaf AND its container,
/// filtered to the requested album parent. A non-downloaded sibling is omitted.
///
@@ -4374,4 +4461,142 @@ mod tests {
"an unsynced local toggle must survive a cache write"
);
}
/// UT-152 — the server's watch position is mirrored locally, so an item
/// watched on another device resumes here.
///
/// The resume check reads only the local `user_data` row, and the mirror
/// previously carried `is_favorite` alone — so a position set on any other
/// client never reached this device and cross-device resume silently did
/// nothing. The `pending_sync` guard is the same conflict rule favourites
/// use: a local position still waiting to be pushed must not be pulled
/// backwards by the stale value the server is still reporting.
///
/// TRACES: UR-025, UR-069 | DR-155 | UT-152
#[tokio::test]
async fn test_save_to_cache_mirrors_playback_position_without_clobbering_pending() {
use crate::storage::db_service::DatabaseService;
let db_service = create_test_db();
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
let position = |id: &'static str| {
let db = db_service.clone();
async move {
db.query_optional(
Query::with_params(
"SELECT playback_position_ticks, pending_sync FROM user_data \
WHERE user_id = ? AND item_id = ?",
vec![
QueryParam::String("test-user".to_string()),
QueryParam::String(id.to_string()),
],
),
|row| Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, Option<i32>>(1)?)),
)
.await
.unwrap()
}
};
// Watched 20 minutes into this episode on another device.
let mut watched = create_test_item("ep-1", "Watched Elsewhere", None);
watched.user_data = Some(UserData {
playback_position_ticks: Some(12_000_000_000),
..Default::default()
});
// No user data at all — must not fabricate a position of 0.
let untouched = create_test_item("ep-2", "No User Data", None);
repo.save_to_cache("parent-1", &[watched.clone(), untouched])
.await
.unwrap();
assert_eq!(
position("ep-1").await,
Some((Some(12_000_000_000), Some(0))),
"the server's position should be mirrored as synced"
);
assert_eq!(
position("ep-2").await,
None,
"an item without UserData should not get an invented position"
);
// Watched further here while the server was unreachable: pending_sync = 1.
db_service
.execute(Query::with_params(
"UPDATE user_data SET playback_position_ticks = ?, pending_sync = 1 \
WHERE user_id = ? AND item_id = ?",
vec![
QueryParam::Int64(30_000_000_000),
QueryParam::String("test-user".to_string()),
QueryParam::String("ep-1".to_string()),
],
))
.await
.unwrap();
// The server still reports the older position; caching must not win.
repo.save_to_cache("parent-1", &[watched]).await.unwrap();
assert_eq!(
position("ep-1").await,
Some((Some(30_000_000_000), Some(1))),
"an unsynced local position must not be pulled backwards"
);
}
/// UT-152 — a server item carrying *only* a position (no favourite flag)
/// still gets mirrored.
///
/// The mirror used to return early whenever `is_favorite` was absent, which
/// is exactly the shape of an ordinary watched episode: Jellyfin reports
/// `PlaybackPositionTicks` with no favourite state. That early return is why
/// the position never landed.
///
/// TRACES: UR-025 | DR-155 | UT-152
#[tokio::test]
async fn test_position_is_mirrored_even_when_no_favourite_flag_is_present() {
use crate::storage::db_service::DatabaseService;
let db_service = create_test_db();
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
let mut watched = create_test_item("ep-3", "Position Only", None);
watched.user_data = Some(UserData {
is_favorite: None,
playback_position_ticks: Some(9_000_000_000),
..Default::default()
});
repo.save_to_cache("parent-1", &[watched]).await.unwrap();
let stored = db_service
.query_optional(
Query::with_params(
"SELECT playback_position_ticks FROM user_data \
WHERE user_id = ? AND item_id = ?",
vec![
QueryParam::String("test-user".to_string()),
QueryParam::String("ep-3".to_string()),
],
),
|row| row.get::<_, Option<i64>>(0),
)
.await
.unwrap();
assert_eq!(
stored,
Some(Some(9_000_000_000)),
"a position with no favourite flag must still be mirrored"
);
}
}
+40 -5
View File
@@ -670,6 +670,26 @@ fn build_get_items_endpoint(
endpoint
}
/// Build the Jellyfin endpoint for a "recently added" listing.
///
/// `GroupItems=true` is the load-bearing parameter: Jellyfin defaults it to
/// `false`, which returns each newly-added *leaf* separately, so importing one
/// 14-track album pushed 14 rows into "recently added" and buried everything
/// else. With grouping on, the server collapses children into the container
/// that was added — an album appears once, while movies (which have no such
/// container) are unaffected.
///
/// Pulled out of `get_latest_items` so the query can be asserted without an
/// HTTP server, matching `build_favorites_endpoint`.
fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
format!(
"/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
user_id,
parent_id,
limit.unwrap_or(16)
)
}
/// Build the Jellyfin endpoint for a favourites listing.
///
/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
@@ -908,11 +928,7 @@ impl MediaRepository for OnlineRepository {
parent_id: &str,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let endpoint = format!(
"/Users/{}/Items/Latest?ParentId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
self.user_id, parent_id, limit_str
);
let endpoint = build_latest_items_endpoint(&self.user_id, parent_id, limit);
let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
Ok(items
@@ -2783,6 +2799,25 @@ mod tests {
assert!(!off.contains("Filters=IsFavorite"));
}
/// A newly-added album must arrive as one entry, not one per track.
///
/// Jellyfin's `/Items/Latest` defaults to `GroupItems=false`, which returns
/// every new Audio track individually — so ripping a 14-track album filled
/// the whole "recently added" row with that one album. `GroupItems=true`
/// makes the server collapse children into their parent container.
#[test]
fn test_latest_items_endpoint_groups_children_into_containers() {
let endpoint = build_latest_items_endpoint("u1", "lib-1", Some(16));
assert!(
endpoint.contains("GroupItems=true"),
"latest items must be grouped so an album counts once, got: {}",
endpoint
);
assert!(endpoint.contains("ParentId=lib-1"));
assert!(endpoint.contains("Limit=16"));
}
/// UT-099 — a Jellyfin item's `UserData` reaches `MediaItem.user_data`.
///
/// Before DR-113 this mapping was hardcoded to `None`, so nothing outside
+68 -4
View File
@@ -97,9 +97,14 @@ fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
/// working through.
/// 2. **The server's Next Up** for this series — it accounts for watch history
/// we do not cache locally.
/// 3. **The first unwatched episode** in series order. This is the offline path:
/// `OfflineRepository::get_next_up_episodes` returns an empty vec, so without
/// this rung the whole feature would be online-only.
/// 3. **The episode after the furthest-watched one**, falling back to the first
/// unwatched episode when nothing has been watched or the series is finished.
/// This is the offline path: `OfflineRepository::get_next_up_episodes`
/// returns an empty vec, so without this rung the whole feature would be
/// online-only. It deliberately does *not* return the first unwatched
/// episode outright — an unwatched episode behind the viewer's furthest
/// point was skipped on purpose, and sending them back to it is the bug
/// DR-101 was reopened for.
/// 4. **The first episode**, so a never-watched series opens on its premiere
/// rather than on nothing.
///
@@ -136,7 +141,18 @@ pub fn pick_current_episode(
return Some(matched.unwrap_or(found).clone());
}
// 3. First unwatched in series order.
// 3. The episode after the furthest-watched one. Not simply the first
// unwatched: a viewer who skipped the pilot but is deep into season 3
// must not be dragged back to S1E1. An earlier gap is a deliberate skip;
// where they stopped is the *last* thing they watched.
if let Some(furthest) = episodes.iter().rposition(is_played) {
if let Some(found) = episodes.get(furthest + 1) {
return Some(found.clone());
}
}
// Nothing watched yet (or the furthest-watched episode is the finale):
// the first unwatched episode in series order.
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
return Some(found.clone());
}
@@ -352,6 +368,54 @@ mod tests {
assert_eq!(current.id, "s2e2");
}
/// A viewer deep in season 3 who never watched the pilot must not be sent
/// back to it: the gap was a skip, not the place they stopped.
#[test]
fn resumes_after_the_furthest_watched_episode_not_the_first_gap() {
let mut eps = [season(1, 4), season(2, 4), season(3, 4)].concat();
for ep in eps.iter_mut() {
// Everything through S3E3 watched, except the never-watched pilot.
let watched_through = ep.parent_index_number < Some(3) || ep.index_number <= Some(3);
if watched_through && ep.id != "s1e1" {
*ep = watched(ep.clone());
}
}
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
assert_eq!(current.id, "s3e4");
}
/// The furthest-watched episode being a finale must still roll into the
/// next season rather than stopping the series.
#[test]
fn resumes_into_the_next_season_after_a_skipped_earlier_episode() {
let mut eps = [season(1, 3), season(2, 3)].concat();
for ep in eps.iter_mut() {
if ep.parent_index_number == Some(1) && ep.id != "s1e1" {
*ep = watched(ep.clone());
}
}
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
assert_eq!(current.id, "s2e1");
}
/// Specials sort last, so watching one must not mark the series finished
/// while numbered episodes remain.
#[test]
fn a_watched_special_does_not_end_the_series() {
let mut eps = [season(1, 3), vec![episode("s0e1", 0, 1)]].concat();
sort_series_order(&mut eps);
for ep in eps.iter_mut() {
if ep.id == "s1e1" || ep.id == "s0e1" {
*ep = watched(ep.clone());
}
}
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
assert_eq!(current.id, "s1e2");
}
#[test]
fn crosses_a_season_boundary_when_a_season_is_finished() {
let mut eps = [season(1, 3), season(2, 3)].concat();
+9
View File
@@ -1472,6 +1472,15 @@ async repositoryReportPlaybackProgress(handle: string, itemId: string, positionM
},
/**
* Report playback stopped
*
* A stop-report that cannot reach the server is queued rather than dropped:
* this is the position the resume point is built from, and losing it is
* exactly the "it forgot where I was" the sync queue exists to prevent. The
* drain (DR-131) pushes it on the next reconnect. Queueing is best-effort
* failing the command because the *queue* write failed would tell the caller
* the report was lost when the local position was already saved.
*
* TRACES: UR-025 | DR-154 | UT-151
*/
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs });
+15 -1
View File
@@ -45,9 +45,16 @@
* TRACES: UR-068 | DR-119
*/
showFavorite?: boolean;
/**
* Force the artwork box to a fixed aspect ratio instead of deriving one from
* the item. Use on rows that mix item kinds (e.g. the home "Your Libraries"
* strip, where square music art next to 16:9 video art would otherwise give
* the cards different heights). Artwork still fills the box via object-cover.
*/
aspect?: "square" | "video" | "poster";
}
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true }: Props = $props();
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true, aspect }: Props = $props();
// Long-press detection. We arm a timer on pointerdown; if it fires before the
// pointer is released (or moves too far), we treat it as a long press and set a
@@ -179,7 +186,14 @@
"kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist")
);
const FIXED_ASPECT = {
square: "aspect-square",
video: "aspect-video",
poster: "aspect-[2/3]",
} as const;
const aspectRatio = $derived(() => {
if (aspect) return FIXED_ASPECT[aspect];
if ("kind" in item) {
return isMusicType ? "aspect-square" : "aspect-[2/3]";
}
@@ -721,6 +721,41 @@
// No-op for the native adapter, which owns no DOM element.
playerAdapter.attach(videoElement);
playerController.setActiveAdapter(playerAdapter);
// The native (ExoPlayer) path has no <video> element, so `canplay`
// never fires and the handleCanPlay initial-seek below never runs —
// resume-at-position played from the beginning on Android. Hand the
// resume point to the adapter, which issues the backend seek.
//
// HTML5 keeps its existing element-driven seek: seeking before the
// element has metadata is clamped back to 0, which is precisely what
// handleCanPlay waits for.
// TRACES: UR-005 | DR-004, DR-028
if (!useHtml5Element) {
hasPerformedInitialSeek = true; // native path owns the initial seek
lastAppliedInitialPosition = initialPosition;
await playerAdapter.load(currentStreamUrl, {
mediaId: media.id,
mediaSourceId: mediaSourceId ?? null,
needsTranscoding,
initialPosition: initialPosition ?? 0,
isLive,
audioTrackIndex: null,
knownDuration: media.durationMs ? media.durationMs / 1000 : 0,
// ExoPlayer already received these as SubtitleConfigurations via
// player_play_item; mapped to the adapter shape for the contract.
subtitleTracks: sentSubtitleTracks.map((t) => ({
index: t.streamIndex,
url: t.url,
language: t.srclang,
label: t.label,
mimeType: "text/vtt",
})),
});
if (initialPosition && initialPosition > 0 && !isLive) {
currentTime = initialPosition;
}
}
}
if (!useHtml5Element) {
@@ -11,6 +11,7 @@ const playerToggle = vi.fn((..._a: any[]): any => ({ state: "playing" }));
const playerSetVolume = vi.fn((..._a: any[]): any => ({}));
const playerToggleMute = vi.fn((..._a: any[]): any => ({}));
const playerSetSubtitleTrack = vi.fn((..._a: any[]): any => ({}));
const playerSeek = vi.fn((..._a: any[]): any => ({}));
vi.mock("$lib/api/bindings", () => ({
commands: {
@@ -20,6 +21,7 @@ vi.mock("$lib/api/bindings", () => ({
playerSetVolume: (...a: any[]) => playerSetVolume(...a),
playerToggleMute: (...a: any[]) => playerToggleMute(...a),
playerSetSubtitleTrack: (...a: any[]) => playerSetSubtitleTrack(...a),
playerSeek: (...a: any[]) => playerSeek(...a),
},
}));
@@ -72,6 +74,39 @@ describe("NativePlayerAdapter", () => {
expect(adapter.getPosition()).toBe(90);
});
// Regression: resume-at-position was broken on Android. player_play_item
// carries no start position, and ExoPlayer always begins at 0, so recording
// the number frontend-side left the backend playing from the beginning. The
// adapter must actually *issue* the seek.
it("load() issues the resume seek to the backend, not just records it", async () => {
await adapter.load("url", {
mediaId: "m", mediaSourceId: null, needsTranscoding: false,
initialPosition: 90, isLive: false, audioTrackIndex: null,
knownDuration: 0, subtitleTracks: [],
});
expect(playerSeek).toHaveBeenCalledWith(90);
});
it("load() does not seek when starting from the beginning", async () => {
await adapter.load("url", {
mediaId: "m", mediaSourceId: null, needsTranscoding: false,
initialPosition: 0, isLive: false, audioTrackIndex: null,
knownDuration: 0, subtitleTracks: [],
});
expect(playerSeek).not.toHaveBeenCalled();
});
// A live stream has no meaningful resume point; seeking one is at best a
// no-op and at worst knocks the HLS window off its live edge.
it("load() never seeks a live stream", async () => {
await adapter.load("url", {
mediaId: "m", mediaSourceId: null, needsTranscoding: false,
initialPosition: 90, isLive: true, audioTrackIndex: null,
knownDuration: 0, subtitleTracks: [],
});
expect(playerSeek).not.toHaveBeenCalled();
});
it("setVolume clamps and delegates; setMuted toggles mute", () => {
adapter.setVolume(2);
expect(playerSetVolume).toHaveBeenCalledWith(1);
+14 -3
View File
@@ -43,10 +43,21 @@ export class NativePlayerAdapter implements PlayerAdapter {
async load(_streamUrl: string, options: PlayerLoadOptions): Promise<void> {
// player_play_item already initiated native playback before this adapter is
// created; nothing further to do. Seed a resume position if requested (the
// native backend performs the actual seek internally).
if (options.initialPosition > 0) {
// created, so there is no stream to load here — but it carries no start
// position, and ExoPlayer always begins at 0. The resume seek must be
// issued explicitly or "resume at position" silently plays from the top.
//
// Recording the position without seeking (what this used to do) is what
// broke Android resume: the frontend believed it had resumed while
// ExoPlayer played from the beginning.
//
// Live streams have no resume point — seeking one knocks the HLS window off
// its live edge, so they are excluded.
//
// TRACES: UR-005 | DR-004, DR-028
if (options.initialPosition > 0 && !options.isLive) {
this.position = options.initialPosition;
await commands.playerSeek(options.initialPosition);
}
}
+4 -4
View File
@@ -103,15 +103,15 @@ export async function reportPlaybackStopped(itemId: string, positionSeconds: num
}
}
// Queue for sync to server (the sync service will handle retry logic)
// Report to the server. Rust queues the position for the next reconnect if
// the server cannot be reached (DR-154), so a throw here means the report
// did not land *this time* — not that the position was lost.
if (userId && positionSeconds > 0) {
try {
// Get the repository to check if we should queue
const repo = auth.getRepository();
await repo.reportPlaybackStopped(itemId, positionMs);
} catch (e) {
console.error("[PlaybackReporting] Failed to report to server:", e);
// Server error - could queue, but for now just log
console.warn("[PlaybackReporting] Stop-report did not reach the server; queued for sync:", e);
}
}
}
+4 -1
View File
@@ -159,12 +159,15 @@
{#if shortcutLibraries.length > 0}
<div>
<h2 class="text-xl font-bold text-white mb-4 px-4">Your Libraries</h2>
<div class="flex gap-4 overflow-x-auto px-4 pb-2">
<div class="flex gap-4 overflow-x-auto px-4 pb-2 items-start">
{#each shortcutLibraries as lib (lib.id)}
<div class="flex-shrink-0">
<!-- Uniform 16:9 artwork so music (square) and video libraries
line up at the same height in this mixed row. -->
<MediaCard
item={lib}
size="medium"
aspect="video"
onclick={() => handleLibraryClick(lib)}
/>
</div>