Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fbc080733 | ||
|
|
ba5fd55204 | ||
|
|
fec4b7ae8c | ||
|
|
2ca2174cea | ||
|
|
0ca2857c3a | ||
|
|
1f32e4040b | ||
|
|
e4632bb2b2 | ||
|
|
2d50744320 | ||
|
|
9d7cb085e9 | ||
|
|
85bd227714 |
@@ -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-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-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-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-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-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 |
|
| 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-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-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-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-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-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 |
|
| 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 |
|
||||||
|
|||||||
@@ -175,8 +175,8 @@ describe("live requirements.md", () => {
|
|||||||
|
|
||||||
expect(defined.UR).toBe(71);
|
expect(defined.UR).toBe(71);
|
||||||
expect(defined.IR).toBe(32);
|
expect(defined.IR).toBe(32);
|
||||||
expect(defined.DR).toBe(148);
|
expect(defined.DR).toBe(150);
|
||||||
expect(defined.JA).toBe(35);
|
expect(defined.JA).toBe(35);
|
||||||
expect(defined.total).toBe(286);
|
expect(defined.total).toBe(288);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1003,6 +1003,16 @@ pub async fn player_stop(
|
|||||||
.clone()
|
.clone()
|
||||||
};
|
};
|
||||||
client.send_session_command(session_id, "Stop").await?;
|
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 {
|
} else {
|
||||||
// Local playback
|
// Local playback
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
|
|||||||
@@ -712,9 +712,19 @@ pub async fn repository_report_playback_progress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Report playback stopped
|
/// 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]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn repository_report_playback_stopped(
|
pub async fn repository_report_playback_stopped(
|
||||||
|
db: State<'_, crate::commands::storage::DatabaseWrapper>,
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
@@ -723,10 +733,39 @@ pub async fn repository_report_playback_stopped(
|
|||||||
// Milliseconds across the boundary; the Jellyfin API wants ticks.
|
// Milliseconds across the boundary; the Jellyfin API wants ticks.
|
||||||
let position_ticks = position_ms * 10_000;
|
let position_ticks = position_ms * 10_000;
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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)
|
.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
|
.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
|
/// Get image URL for an item
|
||||||
|
|||||||
@@ -355,6 +355,65 @@ async fn mark_failed(
|
|||||||
Ok(())
|
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.
|
/// Drain on every offline→online transition.
|
||||||
///
|
///
|
||||||
/// TRACES: UR-025 | DR-131
|
/// TRACES: UR-025 | DR-131
|
||||||
@@ -781,6 +840,143 @@ mod tests {
|
|||||||
assert_eq!(row_state(&db, "theirs").await.0, "pending");
|
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
|
/// Nothing queued means no server calls at all — a reconnect must not
|
||||||
/// generate traffic just because it happened.
|
/// generate traffic just because it happened.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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.
|
/// send a resume position, so a fresh track casts from 0 rather than ~0.
|
||||||
const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
|
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
|
/// Convert a live playback position (seconds) into the `StartPositionTicks` to
|
||||||
/// hand to a remote session, or `None` if we're effectively at the start.
|
/// 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
|
/// Manages playback mode transfers between local and remote sessions
|
||||||
pub struct PlaybackModeManager {
|
pub struct PlaybackModeManager {
|
||||||
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
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
|
/// 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.
|
/// mirror store stays in sync with this authoritative one. `None` in tests.
|
||||||
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
|
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 {
|
impl PlaybackModeManager {
|
||||||
@@ -65,6 +115,24 @@ impl PlaybackModeManager {
|
|||||||
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
|
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
|
||||||
is_transferring: Arc::new(AtomicBool::new(false)),
|
is_transferring: Arc::new(AtomicBool::new(false)),
|
||||||
event_emitter: Arc::new(Mutex::new(None)),
|
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
|
/// the frontend's mirror store reconciles to this authoritative value. The
|
||||||
/// write lock is released before emitting to avoid holding it across the
|
/// write lock is released before emitting to avoid holding it across the
|
||||||
/// emitter call.
|
/// 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) {
|
pub fn set_mode(&self, mode: PlaybackMode) {
|
||||||
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
|
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
|
||||||
let changed = {
|
let (changed, was_remote) = {
|
||||||
let mut current = self.current_mode.write_safe();
|
let mut current = self.current_mode.write_safe();
|
||||||
let changed = *current != mode;
|
let changed = *current != mode;
|
||||||
|
let was_remote = matches!(*current, PlaybackMode::Remote { .. });
|
||||||
*current = mode.clone();
|
*current = mode.clone();
|
||||||
changed
|
(changed, was_remote)
|
||||||
};
|
};
|
||||||
|
|
||||||
if !changed {
|
if !changed {
|
||||||
return;
|
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 {
|
let (mode_str, session_id) = match &mode {
|
||||||
PlaybackMode::Local => ("local".to_string(), None),
|
PlaybackMode::Local => ("local".to_string(), None),
|
||||||
PlaybackMode::Idle => ("idle".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
|
/// Both symptoms share this one cause, so this must not be skipped on any
|
||||||
/// remote-entry path (notably the empty-queue early return in
|
/// remote-entry path (notably the empty-queue early return in
|
||||||
/// `transfer_to_remote_inner`). No-op / non-Android builds do nothing.
|
/// `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) {
|
fn enable_remote_control(&self) {
|
||||||
#[cfg(target_os = "android")]
|
self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
|
||||||
{
|
|
||||||
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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if currently transferring
|
/// Check if currently transferring
|
||||||
@@ -766,18 +849,10 @@ impl PlaybackModeManager {
|
|||||||
// This will be improved in Phase 3 when repository is migrated to Rust.
|
// 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");
|
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);
|
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");
|
log::info!("[PlaybackMode] Successfully transferred to local");
|
||||||
Ok(())
|
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
|
/// Setting the same mode twice must not re-emit — the frontend reconciler
|
||||||
/// (and the event channel) shouldn't be spammed on no-op transitions.
|
/// (and the event channel) shouldn't be spammed on no-op transitions.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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)
|
/// Simple timeout wrapper for cache queries (100ms timeout)
|
||||||
///
|
///
|
||||||
/// @req: DR-013 - Repository pattern (cache-first with 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> {
|
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
||||||
let offline = Arc::clone(&self.offline);
|
let offline = Arc::clone(&self.offline);
|
||||||
let online = Arc::clone(&self.online);
|
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 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 };
|
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(
|
async fn get_latest_items(
|
||||||
|
|||||||
@@ -665,40 +665,63 @@ impl OfflineRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Mirror the server's per-user state for an item into the local
|
/// 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
|
/// `user_data` table, so favourites marked — and positions watched — on any
|
||||||
/// here — including offline, where the local table is the only source.
|
/// 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
|
/// 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
|
/// 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
|
/// server is still reporting. For a position that means it is never pulled
|
||||||
/// entirely rather than written as `0`, which would fabricate an
|
/// *backwards* by a server that has not yet heard where we got to.
|
||||||
/// "unfavourited" record from an endpoint that simply omits `UserData`.
|
|
||||||
///
|
///
|
||||||
/// 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> {
|
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(());
|
return Ok(());
|
||||||
};
|
}
|
||||||
|
|
||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
"INSERT INTO user_data (user_id, item_id, is_favorite, synced_at, pending_sync)
|
"INSERT INTO user_data
|
||||||
VALUES (?1, ?2, ?3, ?4, 0)
|
(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
|
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
|
synced_at = excluded.synced_at
|
||||||
WHERE user_data.pending_sync = 0",
|
WHERE user_data.pending_sync = 0",
|
||||||
vec![
|
vec![
|
||||||
QueryParam::String(self.user_id.clone()),
|
QueryParam::String(self.user_id.clone()),
|
||||||
QueryParam::String(item.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()),
|
QueryParam::String(now.to_string()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// A missing item row (FK) is not fatal here — the favourite mirror is
|
// A missing item row (FK) is not fatal here — the mirror is best-effort
|
||||||
// best-effort metadata, and failing the whole cache write over it would
|
// metadata, and failing the whole cache write over it would break
|
||||||
// break browsing.
|
// browsing.
|
||||||
if let Err(e) = self.db_service.execute(query).await {
|
if let Err(e) = self.db_service.execute(query).await {
|
||||||
debug!(
|
debug!(
|
||||||
"[OfflineRepo] user_data mirror skipped for {}: {}",
|
"[OfflineRepo] user_data mirror skipped for {}: {}",
|
||||||
@@ -1426,6 +1449,14 @@ impl MediaRepository for OfflineRepository {
|
|||||||
FROM items i
|
FROM items i
|
||||||
INNER JOIN downloaded_items di ON i.id = di.id
|
INNER JOIN downloaded_items di ON i.id = di.id
|
||||||
WHERE i.server_id = ? AND i.library_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
|
ORDER BY i.synced_at DESC
|
||||||
LIMIT {}", limit_val
|
LIMIT {}", limit_val
|
||||||
),
|
),
|
||||||
@@ -3544,6 +3575,32 @@ mod tests {
|
|||||||
.unwrap();
|
.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) {
|
async fn seed_completed_download(db: &Arc<RusqliteService>, item_id: &str, file_size: i64) {
|
||||||
db.execute(Query::with_params(
|
db.execute(Query::with_params(
|
||||||
"INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)",
|
"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,
|
/// UT: downloaded-only browse returns a downloaded leaf AND its container,
|
||||||
/// filtered to the requested album parent. A non-downloaded sibling is omitted.
|
/// 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"
|
"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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -670,6 +670,26 @@ fn build_get_items_endpoint(
|
|||||||
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.
|
/// Build the Jellyfin endpoint for a favourites listing.
|
||||||
///
|
///
|
||||||
/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
|
/// 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,
|
parent_id: &str,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_str = limit.unwrap_or(16);
|
let endpoint = build_latest_items_endpoint(&self.user_id, parent_id, limit);
|
||||||
let endpoint = format!(
|
|
||||||
"/Users/{}/Items/Latest?ParentId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
|
||||||
self.user_id, parent_id, limit_str
|
|
||||||
);
|
|
||||||
|
|
||||||
let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
|
let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
|
||||||
Ok(items
|
Ok(items
|
||||||
@@ -2783,6 +2799,25 @@ mod tests {
|
|||||||
assert!(!off.contains("Filters=IsFavorite"));
|
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`.
|
/// UT-099 — a Jellyfin item's `UserData` reaches `MediaItem.user_data`.
|
||||||
///
|
///
|
||||||
/// Before DR-113 this mapping was hardcoded to `None`, so nothing outside
|
/// Before DR-113 this mapping was hardcoded to `None`, so nothing outside
|
||||||
|
|||||||
@@ -97,9 +97,14 @@ fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
|
|||||||
/// working through.
|
/// working through.
|
||||||
/// 2. **The server's Next Up** for this series — it accounts for watch history
|
/// 2. **The server's Next Up** for this series — it accounts for watch history
|
||||||
/// we do not cache locally.
|
/// we do not cache locally.
|
||||||
/// 3. **The first unwatched episode** in series order. This is the offline path:
|
/// 3. **The episode after the furthest-watched one**, falling back to the first
|
||||||
/// `OfflineRepository::get_next_up_episodes` returns an empty vec, so without
|
/// unwatched episode when nothing has been watched or the series is finished.
|
||||||
/// this rung the whole feature would be online-only.
|
/// 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
|
/// 4. **The first episode**, so a never-watched series opens on its premiere
|
||||||
/// rather than on nothing.
|
/// rather than on nothing.
|
||||||
///
|
///
|
||||||
@@ -136,7 +141,18 @@ pub fn pick_current_episode(
|
|||||||
return Some(matched.unwrap_or(found).clone());
|
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)) {
|
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
|
||||||
return Some(found.clone());
|
return Some(found.clone());
|
||||||
}
|
}
|
||||||
@@ -352,6 +368,54 @@ mod tests {
|
|||||||
assert_eq!(current.id, "s2e2");
|
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]
|
#[test]
|
||||||
fn crosses_a_season_boundary_when_a_season_is_finished() {
|
fn crosses_a_season_boundary_when_a_season_is_finished() {
|
||||||
let mut eps = [season(1, 3), season(2, 3)].concat();
|
let mut eps = [season(1, 3), season(2, 3)].concat();
|
||||||
|
|||||||
@@ -1472,6 +1472,15 @@ async repositoryReportPlaybackProgress(handle: string, itemId: string, positionM
|
|||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Report playback stopped
|
* 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> {
|
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise<null> {
|
||||||
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs });
|
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs });
|
||||||
|
|||||||
@@ -45,9 +45,16 @@
|
|||||||
* TRACES: UR-068 | DR-119
|
* TRACES: UR-068 | DR-119
|
||||||
*/
|
*/
|
||||||
showFavorite?: boolean;
|
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
|
// 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
|
// 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")
|
"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(() => {
|
const aspectRatio = $derived(() => {
|
||||||
|
if (aspect) return FIXED_ASPECT[aspect];
|
||||||
if ("kind" in item) {
|
if ("kind" in item) {
|
||||||
return isMusicType ? "aspect-square" : "aspect-[2/3]";
|
return isMusicType ? "aspect-square" : "aspect-[2/3]";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -721,6 +721,41 @@
|
|||||||
// No-op for the native adapter, which owns no DOM element.
|
// No-op for the native adapter, which owns no DOM element.
|
||||||
playerAdapter.attach(videoElement);
|
playerAdapter.attach(videoElement);
|
||||||
playerController.setActiveAdapter(playerAdapter);
|
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) {
|
if (!useHtml5Element) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const playerToggle = vi.fn((..._a: any[]): any => ({ state: "playing" }));
|
|||||||
const playerSetVolume = vi.fn((..._a: any[]): any => ({}));
|
const playerSetVolume = vi.fn((..._a: any[]): any => ({}));
|
||||||
const playerToggleMute = vi.fn((..._a: any[]): any => ({}));
|
const playerToggleMute = vi.fn((..._a: any[]): any => ({}));
|
||||||
const playerSetSubtitleTrack = vi.fn((..._a: any[]): any => ({}));
|
const playerSetSubtitleTrack = vi.fn((..._a: any[]): any => ({}));
|
||||||
|
const playerSeek = vi.fn((..._a: any[]): any => ({}));
|
||||||
|
|
||||||
vi.mock("$lib/api/bindings", () => ({
|
vi.mock("$lib/api/bindings", () => ({
|
||||||
commands: {
|
commands: {
|
||||||
@@ -20,6 +21,7 @@ vi.mock("$lib/api/bindings", () => ({
|
|||||||
playerSetVolume: (...a: any[]) => playerSetVolume(...a),
|
playerSetVolume: (...a: any[]) => playerSetVolume(...a),
|
||||||
playerToggleMute: (...a: any[]) => playerToggleMute(...a),
|
playerToggleMute: (...a: any[]) => playerToggleMute(...a),
|
||||||
playerSetSubtitleTrack: (...a: any[]) => playerSetSubtitleTrack(...a),
|
playerSetSubtitleTrack: (...a: any[]) => playerSetSubtitleTrack(...a),
|
||||||
|
playerSeek: (...a: any[]) => playerSeek(...a),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -72,6 +74,39 @@ describe("NativePlayerAdapter", () => {
|
|||||||
expect(adapter.getPosition()).toBe(90);
|
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", () => {
|
it("setVolume clamps and delegates; setMuted toggles mute", () => {
|
||||||
adapter.setVolume(2);
|
adapter.setVolume(2);
|
||||||
expect(playerSetVolume).toHaveBeenCalledWith(1);
|
expect(playerSetVolume).toHaveBeenCalledWith(1);
|
||||||
|
|||||||
@@ -43,10 +43,21 @@ export class NativePlayerAdapter implements PlayerAdapter {
|
|||||||
|
|
||||||
async load(_streamUrl: string, options: PlayerLoadOptions): Promise<void> {
|
async load(_streamUrl: string, options: PlayerLoadOptions): Promise<void> {
|
||||||
// player_play_item already initiated native playback before this adapter is
|
// player_play_item already initiated native playback before this adapter is
|
||||||
// created; nothing further to do. Seed a resume position if requested (the
|
// created, so there is no stream to load here — but it carries no start
|
||||||
// native backend performs the actual seek internally).
|
// position, and ExoPlayer always begins at 0. The resume seek must be
|
||||||
if (options.initialPosition > 0) {
|
// 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;
|
this.position = options.initialPosition;
|
||||||
|
await commands.playerSeek(options.initialPosition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
if (userId && positionSeconds > 0) {
|
||||||
try {
|
try {
|
||||||
// Get the repository to check if we should queue
|
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
await repo.reportPlaybackStopped(itemId, positionMs);
|
await repo.reportPlaybackStopped(itemId, positionMs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaybackReporting] Failed to report to server:", e);
|
console.warn("[PlaybackReporting] Stop-report did not reach the server; queued for sync:", e);
|
||||||
// Server error - could queue, but for now just log
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,12 +159,15 @@
|
|||||||
{#if shortcutLibraries.length > 0}
|
{#if shortcutLibraries.length > 0}
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold text-white mb-4 px-4">Your Libraries</h2>
|
<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)}
|
{#each shortcutLibraries as lib (lib.id)}
|
||||||
<div class="flex-shrink-0">
|
<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
|
<MediaCard
|
||||||
item={lib}
|
item={lib}
|
||||||
size="medium"
|
size="medium"
|
||||||
|
aspect="video"
|
||||||
onclick={() => handleLibraryClick(lib)}
|
onclick={() => handleLibraryClick(lib)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user