Connected Devices: fixed rplaylist sync

This commit is contained in:
Ultradesu
2026-07-24 03:22:18 +03:00
parent e83f356005
commit be58933e8d
2 changed files with 151 additions and 26 deletions
+72 -3
View File
@@ -421,6 +421,7 @@ impl DeviceSync {
event_tx: Arc::new(std::sync::Mutex::new(None)),
});
sync.ensure_identity()?;
sync.repair_like_order_from_sync_state()?;
Ok(sync)
}
@@ -1334,17 +1335,32 @@ impl DeviceSync {
let effective_liked = if apply {
liked
} else {
current.map(|(liked, _, _)| liked).unwrap_or(false)
current
.as_ref()
.map(|(liked, _, _)| *liked)
.unwrap_or(false)
};
let effective_hlc_ms = if apply {
hlc_ms
} else {
current
.as_ref()
.map(|(_, current_hlc, _)| *current_hlc)
.unwrap_or(hlc_ms)
};
let mut changed = apply;
if let Some(track_id) = self.library.track_id_by_content_id(&content_id)? {
changed |= self.library.set_like(track_id, effective_liked)?;
changed |= self
.library
.set_synced_like(track_id, effective_liked, effective_hlc_ms)?;
if effective_liked {
changed |= self.library.remove_fed_like_by_content_id(&content_id)?;
}
} else if effective_liked {
if let Some(fed) = fed {
changed |= self.library.upsert_synced_fed_like(&fed.to_fed_track())?;
changed |= self
.library
.upsert_synced_fed_like(&fed.to_fed_track(), effective_hlc_ms)?;
}
} else if apply {
changed |= self.library.remove_fed_like_by_content_id(&content_id)?;
@@ -1352,6 +1368,29 @@ impl DeviceSync {
Ok(changed)
}
fn repair_like_order_from_sync_state(&self) -> Result<()> {
let rows = {
let conn = lock(&self.conn);
let mut stmt = conn.prepare(
"SELECT content_id, hlc_ms
FROM sync_state_likes
WHERE liked = 1",
)?;
stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
for (content_id, hlc_ms) in rows {
if let Some(track_id) = self.library.track_id_by_content_id(&content_id)? {
let _ = self.library.set_synced_like(track_id, true, hlc_ms)?;
} else if let Some(fed) = self.library.fed_like_by_content_id(&content_id)? {
let _ = self.library.upsert_synced_fed_like(&fed, hlc_ms)?;
}
}
Ok(())
}
fn apply_playlist_state(
&self,
playlist_id: &str,
@@ -3140,6 +3179,36 @@ mod tests {
assert!(sync.library.fed_like_ids().unwrap().is_empty());
}
#[test]
fn synced_fed_likes_are_ordered_by_hlc_not_receive_time() {
let sync = test_sync();
let old_content_id = format!("b3:{}", "c".repeat(64));
let new_content_id = format!("b3:{}", "d".repeat(64));
let mut old_fed = test_fed_track(&old_content_id);
old_fed.item_id = "fed_old".to_string();
old_fed.title = "Old Fed".to_string();
let mut new_fed = test_fed_track(&new_content_id);
new_fed.item_id = "fed_new".to_string();
new_fed.title = "New Fed".to_string();
let new_synced = SyncedFedTrack::from_fed(&new_fed).unwrap();
let old_synced = SyncedFedTrack::from_fed(&old_fed).unwrap();
sync.apply_like_state(&new_content_id, true, Some(&new_synced), 20, "dev_remote:2")
.unwrap();
sync.apply_like_state(&old_content_id, true, Some(&old_synced), 10, "dev_remote:1")
.unwrap();
let titles: Vec<String> = sync
.library
.playlist(crate::library::LIKES_PLAYLIST_ID)
.unwrap()
.tracks
.into_iter()
.map(|track| track.title)
.collect();
assert_eq!(titles, vec!["New Fed", "Old Fed"]);
}
#[test]
fn synced_playlist_item_metadata_creates_pending_fed_track() {
let sync = test_sync();
+79 -23
View File
@@ -82,7 +82,8 @@ CREATE TABLE IF NOT EXISTS playlist_tracks (
);
CREATE TABLE IF NOT EXISTS likes (
track_id INTEGER PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
liked_at TEXT NOT NULL DEFAULT (datetime('now'))
liked_at TEXT NOT NULL DEFAULT (datetime('now')),
liked_hlc_ms INTEGER
);
CREATE TABLE IF NOT EXISTS fed_likes (
item_id TEXT PRIMARY KEY,
@@ -96,7 +97,8 @@ CREATE TABLE IF NOT EXISTS fed_likes (
release_title TEXT,
track_number INTEGER,
disc_number INTEGER,
liked_at TEXT NOT NULL DEFAULT (datetime('now'))
liked_at TEXT NOT NULL DEFAULT (datetime('now')),
liked_hlc_ms INTEGER
);
CREATE TABLE IF NOT EXISTS fed_playlist_tracks (
playlist_sync_id TEXT NOT NULL,
@@ -657,34 +659,42 @@ impl Library {
params![],
)?;
let mut liked_at_by_track = HashMap::new();
let mut liked_stmt = conn.prepare("SELECT track_id, liked_at FROM likes")?;
let liked_rows = liked_stmt.query_map([], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
})?;
let mut liked_stmt = conn.prepare(
"SELECT track_id,
COALESCE(liked_hlc_ms,
CAST(strftime('%s', liked_at) AS INTEGER) * 1000,
0)
FROM likes",
)?;
let liked_rows = liked_stmt
.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))?;
for row in liked_rows {
let (track_id, liked_at) = row?;
liked_at_by_track.insert(track_id, liked_at);
}
drop(liked_stmt);
let mut entries: Vec<(String, String, TrackItem)> = local_tracks
let mut entries: Vec<(i64, String, TrackItem)> = local_tracks
.into_iter()
.map(|track| {
let liked_at = liked_at_by_track
.get(&track.id)
.cloned()
.copied()
.unwrap_or_default();
(liked_at, track.title.clone(), track)
})
.collect();
let mut fed_stmt = conn.prepare(
"SELECT liked_at, item_id, owner, title, artist_names, featured_artist_names,
"SELECT COALESCE(liked_hlc_ms,
CAST(strftime('%s', liked_at) AS INTEGER) * 1000,
0),
item_id, owner, title, artist_names, featured_artist_names,
year, duration_seconds, content_id, release_title, track_number, disc_number
FROM fed_likes",
)?;
let fed_rows = fed_stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, fed_track_from_offset_row(row, 1)?))
Ok((row.get::<_, i64>(0)?, fed_track_from_offset_row(row, 1)?))
})?;
for row in fed_rows {
let (liked_at, fed) = row?;
@@ -1017,12 +1027,16 @@ impl Library {
.optional()?)
}
pub fn set_like(&self, track_id: i64, liked: bool) -> Result<bool> {
pub fn set_synced_like(&self, track_id: i64, liked: bool, liked_hlc_ms: i64) -> Result<bool> {
let conn = self.lock();
let changed = if liked {
conn.execute(
"INSERT OR IGNORE INTO likes (track_id) VALUES (?1)",
[track_id],
"INSERT INTO likes (track_id, liked_at, liked_hlc_ms)
VALUES (?1, datetime(?2 / 1000, 'unixepoch'), ?2)
ON CONFLICT(track_id) DO UPDATE SET
liked_at = excluded.liked_at,
liked_hlc_ms = excluded.liked_hlc_ms",
params![track_id, liked_hlc_ms],
)?
} else {
conn.execute("DELETE FROM likes WHERE track_id = ?1", [track_id])?
@@ -1266,7 +1280,9 @@ impl Library {
year, duration_seconds, content_id, release_title, track_number, disc_number
FROM fed_likes
WHERE content_id = ?1
ORDER BY liked_at DESC
ORDER BY COALESCE(liked_hlc_ms,
CAST(strftime('%s', liked_at) AS INTEGER) * 1000,
0) DESC
LIMIT 1",
)?;
let track = statement
@@ -1300,7 +1316,11 @@ impl Library {
Ok(track)
}
pub fn upsert_synced_fed_like(&self, fed: &crate::federation::FedTrack) -> Result<bool> {
pub fn upsert_synced_fed_like(
&self,
fed: &crate::federation::FedTrack,
liked_hlc_ms: i64,
) -> Result<bool> {
let Some(content_id) = fed
.content_id
.as_deref()
@@ -1323,10 +1343,12 @@ impl Library {
Option<String>,
Option<i32>,
Option<i32>,
Option<i64>,
)> = conn
.query_row(
"SELECT owner, title, artist_names, featured_artist_names,
year, duration_seconds, release_title, track_number, disc_number
year, duration_seconds, release_title, track_number, disc_number,
liked_hlc_ms
FROM fed_likes
WHERE item_id = ?1",
[&fed.item_id],
@@ -1341,6 +1363,7 @@ impl Library {
row.get(6)?,
row.get(7)?,
row.get(8)?,
row.get(9)?,
))
},
)
@@ -1355,6 +1378,7 @@ impl Library {
fed.release_title.clone(),
fed.track_number,
fed.disc_number,
Some(liked_hlc_ms),
);
if duplicate_rows == 0 && existing.as_ref() == Some(&incoming) {
return Ok(false);
@@ -1362,8 +1386,9 @@ impl Library {
conn.execute(
"INSERT INTO fed_likes (item_id, owner, title, artist_names,
featured_artist_names, year, duration_seconds, content_id,
release_title, track_number, disc_number)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
release_title, track_number, disc_number, liked_at, liked_hlc_ms)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11,
datetime(?12 / 1000, 'unixepoch'), ?12)
ON CONFLICT(item_id) DO UPDATE SET
owner = excluded.owner,
title = excluded.title,
@@ -1374,7 +1399,9 @@ impl Library {
content_id = excluded.content_id,
release_title = excluded.release_title,
track_number = excluded.track_number,
disc_number = excluded.disc_number",
disc_number = excluded.disc_number,
liked_at = excluded.liked_at,
liked_hlc_ms = excluded.liked_hlc_ms",
params![
fed.item_id,
fed.owner,
@@ -1387,6 +1414,7 @@ impl Library {
fed.release_title,
fed.track_number,
fed.disc_number,
liked_hlc_ms,
],
)?;
Ok(true)
@@ -1404,14 +1432,32 @@ impl Library {
/// whether a transfer happened.
pub fn transfer_fed_like(&self, item_id: &str, track_id: i64) -> Result<bool> {
let conn = self.lock();
let liked: Option<(String, Option<i64>)> = conn
.query_row(
"SELECT liked_at, liked_hlc_ms FROM fed_likes WHERE item_id = ?1",
[item_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?;
let removed = conn.execute("DELETE FROM fed_likes WHERE item_id = ?1", [item_id])?;
if removed == 0 {
return Ok(false);
}
conn.execute(
"INSERT OR IGNORE INTO likes (track_id) VALUES (?1)",
[track_id],
)?;
if let Some((liked_at, liked_hlc_ms)) = liked {
conn.execute(
"INSERT INTO likes (track_id, liked_at, liked_hlc_ms)
VALUES (?1, ?2, ?3)
ON CONFLICT(track_id) DO UPDATE SET
liked_at = excluded.liked_at,
liked_hlc_ms = excluded.liked_hlc_ms",
params![track_id, liked_at, liked_hlc_ms],
)?;
} else {
conn.execute(
"INSERT OR IGNORE INTO likes (track_id) VALUES (?1)",
[track_id],
)?;
}
Ok(true)
}
@@ -1873,6 +1919,16 @@ fn ensure_schema_migrations(conn: &Connection) -> Result<()> {
if !fed_like_columns.iter().any(|column| column == "content_id") {
conn.execute("ALTER TABLE fed_likes ADD COLUMN content_id TEXT", [])?;
}
if !fed_like_columns
.iter()
.any(|column| column == "liked_hlc_ms")
{
conn.execute("ALTER TABLE fed_likes ADD COLUMN liked_hlc_ms INTEGER", [])?;
}
let like_columns = table_columns(conn, "likes")?;
if !like_columns.iter().any(|column| column == "liked_hlc_ms") {
conn.execute("ALTER TABLE likes ADD COLUMN liked_hlc_ms INTEGER", [])?;
}
let track_columns = table_columns(conn, "tracks")?;
if !track_columns.iter().any(|column| column == "content_id") {
conn.execute("ALTER TABLE tracks ADD COLUMN content_id TEXT", [])?;