domain: player/reporting ticks -> milliseconds (phase 4c)

Playback position now crosses the IPC boundary in milliseconds. Ticks
survive only inside Rust (DB storage, Jellyfin API) and at the genuine
remote-session boundary (session seek / transfer / RemoteControls).

Rust command signatures (ms in, converted to ticks internally):
- storage_update_playback_progress / _context: position_ms
- repository_report_playback_start / _progress / _stopped: position_ms
- PlaybackProgress.position_ticks -> position_ms (converted in the query)

Frontend:
- playbackReporting, playerEvents, VideoPlayer, Queue, player/[id] resume:
  seconds*1000 / durationMs/1000 instead of tick math.
- repository-client + syncService param names -> positionMs.
- Tests updated to ms fixtures/assertions.

Out of scope (legitimately ticks): NowPlayingItem, PlayState.positionTicks,
sessionSeek, playbackModeTransferToLocal, RemoteControls, SessionCard — the
remote Jellyfin session API.

Rust 456, frontend 644, check clean.
This commit is contained in:
2026-07-23 22:02:29 +02:00
parent 93d198ce21
commit ec8a7610f5
14 changed files with 88 additions and 71 deletions
+17 -9
View File
@@ -583,7 +583,9 @@ pub async fn storage_delete_user(
#[serde(rename_all = "camelCase")]
pub struct PlaybackProgress {
pub item_id: String,
pub position_ticks: i64,
/// Resume position in milliseconds. Stored as Jellyfin ticks in the DB;
/// converted here so the frontend never sees ticks.
pub position_ms: i64,
pub is_played: bool,
pub is_favorite: bool,
pub play_count: i32,
@@ -597,8 +599,11 @@ pub async fn storage_update_playback_progress(
db: State<'_, DatabaseWrapper>,
user_id: String,
item_id: String,
position_ticks: i64,
position_ms: i64,
) -> Result<(), String> {
// The frontend speaks milliseconds; ticks are a Jellyfin storage detail that
// stays on this side of the boundary. 10_000 ticks = 1 ms.
let position_ticks = position_ms * 10_000;
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
@@ -649,12 +654,14 @@ pub async fn storage_update_playback_context(
db: State<'_, DatabaseWrapper>,
user_id: String,
item_id: String,
position_ticks: i64,
position_ms: i64,
context_type: Option<String>,
context_id: Option<String>,
) -> Result<(), String> {
use crate::storage::db_service::{Query, QueryParam};
// Milliseconds in, Jellyfin ticks stored. 10_000 ticks = 1 ms.
let position_ticks = position_ms * 10_000;
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
@@ -857,9 +864,10 @@ pub async fn storage_get_playback_progress(
db_service
.query_optional(query, |row| {
let position_ticks: i64 = row.get(1)?;
Ok(PlaybackProgress {
item_id: row.get(0)?,
position_ticks: row.get(1)?,
position_ms: position_ticks / 10_000,
is_played: row.get::<_, i32>(2)? != 0,
is_favorite: row.get::<_, i32>(3)? != 0,
play_count: row.get(4)?,
@@ -1495,7 +1503,7 @@ mod tests {
fn test_playback_progress_serialization() {
let progress = PlaybackProgress {
item_id: "item-123".to_string(),
position_ticks: 150_000_000,
position_ms: 150_000_000,
is_played: true,
is_favorite: false,
play_count: 3,
@@ -1512,7 +1520,7 @@ mod tests {
fn test_playback_progress_played_status() {
let progress = PlaybackProgress {
item_id: "item-456".to_string(),
position_ticks: 0,
position_ms: 0,
is_played: true,
is_favorite: true,
play_count: 1,
@@ -1530,7 +1538,7 @@ mod tests {
fn test_playback_progress_not_played() {
let progress = PlaybackProgress {
item_id: "item-789".to_string(),
position_ticks: 30_000_000,
position_ms: 30_000_000,
is_played: false,
is_favorite: false,
play_count: 0,
@@ -1600,7 +1608,7 @@ mod tests {
fn test_playback_progress_camel_case() {
let progress = PlaybackProgress {
item_id: "i1".to_string(),
position_ticks: 100,
position_ms: 100,
is_played: true,
is_favorite: false,
play_count: 1,
@@ -1609,7 +1617,7 @@ mod tests {
let json = serde_json::to_string(&progress).unwrap();
// Verify camelCase serialization
assert!(json.contains("itemId"));
assert!(json.contains("positionTicks"));
assert!(json.contains("positionMs"));
assert!(json.contains("isPlayed"));
assert!(json.contains("isFavorite"));
assert!(json.contains("playCount"));