Skip to content

Commit 06beed8

Browse files
authored
Merge pull request #6 from neckaros/t3code/cross-library-episode-history
2 parents 2b3d156 + fcc1b67 commit 06beed8

6 files changed

Lines changed: 177 additions & 119 deletions

File tree

docs/SSE.md

Lines changed: 12 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ interface MediasRatingMessage {
235235
// Watched events (user-specific). IDs include the media type and identity scheme.
236236
interface Watched {
237237
type: string; // MediaType: "movie", "episode", etc.
238-
id: string; // e.g. "movie:imdb/tt1234567" or "episode:redseat/seriesId/1/2"
238+
id: string; // e.g. "movie:imdb/tt1234567" or "episode:imdb/tt0108778/1/2"
239239
userRef?: string;
240240
date: number; // Timestamp when content was watched
241241
modified: number;
@@ -653,10 +653,11 @@ History IDs include the media type so IDs from different domains cannot collide.
653653
|---------|--------|---------|
654654
| Movie with IMDb ID | `movie:imdb/<imdbId>` | `movie:imdb/tt1234567` |
655655
| Movie without IMDb ID | `movie:redseat/<movieId>` | `movie:redseat/abc123` |
656-
| Series progress parent | `series:redseat/<seriesId>` | `series:redseat/series123` |
657-
| Episode | `episode:redseat/<seriesId>/<season>/<episode>` | `episode:redseat/series123/1/2` |
656+
| Series progress parent | `series:imdb/<seriesImdbId>` | `series:imdb/tt0108778` |
657+
| Episode | `episode:imdb/<seriesImdbId>/<season>/<episode>` | `episode:imdb/tt0108778/1/2` |
658+
| Episode fallback | `episode:redseat/<seriesId>/<season>/<episode>` | `episode:redseat/series123/1/2` |
658659

659-
Episode IDs deliberately use the immutable local series ID and numeric season/episode tuple. Plugin metadata refreshes therefore cannot change watched state or progress IDs.
660+
Episode IDs use the series IMDb ID and numeric season/episode tuple so watched state follows the same show across libraries. Series without an IMDb ID temporarily use the RedSeat fallback; if IMDb metadata is added later, the server migrates watched state and progress immediately.
660661

661662
### REST API Endpoints
662663

@@ -752,79 +753,24 @@ function isMatchingUnwatchedEvent(movie: LocalMovie, eventIds: string[]): boolea
752753

753754
## Offline Sync for Watch History
754755

755-
When clients are offline or disconnected from SSE, they can miss `unwatched` events. The REST API provides a mechanism to sync these missed deletions.
756-
757-
### How It Works
758-
759-
- **`date > 0`**: Item is actively watched (timestamp indicates when it was watched)
760-
- **`date = 0`**: Item was unwatched/deleted (soft-deleted, kept for sync purposes)
761-
762-
When content is marked as unwatched, instead of being deleted from the database, the `date` field is set to `0` and the `modified` timestamp is updated. This allows clients to fetch all changes (including deletions) via the history API.
756+
Unwatching content permanently deletes its history row. The server does not retain deletion tombstones.
763757

764758
### Client Sync Flow
765759

766760
```typescript
767-
// 1. Store last sync timestamp locally
768-
let lastSyncTimestamp = localStorage.getItem('lastHistorySync') || '0';
769-
770-
// 2. Fetch all history changes since last sync, including deleted items
761+
// Replace local watched state with a complete server snapshot.
771762
async function syncHistory() {
772-
const response = await fetch(
773-
`/users/me/history?after=${lastSyncTimestamp}&includeDeleted=true`
774-
);
763+
const response = await fetch('/users/me/history');
775764
const items: Watched[] = await response.json();
776-
777-
for (const item of items) {
778-
if (item.date > 0) {
779-
// Active watched item - add or update in local state
780-
addToLocalWatched(item);
781-
} else {
782-
// Deleted item (date = 0) - remove from local state
783-
removeFromLocalWatched(item.type, item.id);
784-
}
785-
786-
// Track highest modified timestamp for next sync
787-
if (item.modified > parseInt(lastSyncTimestamp)) {
788-
lastSyncTimestamp = item.modified.toString();
789-
}
790-
}
791-
792-
localStorage.setItem('lastHistorySync', lastSyncTimestamp);
765+
replaceLocalWatched(items);
793766
}
794-
795-
// 3. Call on app startup and periodically while online
796-
syncHistory();
797767
```
798768

769+
Connected clients can apply live `watched` and `unwatched` SSE events. After being offline, clients must perform this full reload because incremental history queries cannot report deletions.
770+
799771
### API Query Parameters
800772

801773
| Parameter | Type | Description |
802774
|-----------|------|-------------|
803-
| `after` | number | Only return items modified after this timestamp (milliseconds) |
804-
| `includeDeleted` | boolean | Include items with `date=0` (unwatched). Default: `false` |
775+
| `after` | number | Only return watched items modified after this timestamp (milliseconds). This does not report deletions. |
805776
| `types` | string[] | Filter by content types (e.g., `movie`, `episode`) |
806-
807-
### Example Response with Deleted Items
808-
809-
```json
810-
[
811-
{
812-
"type": "movie",
813-
"id": "movie:imdb/tt1234567",
814-
"userRef": "user123",
815-
"date": 1705766400000,
816-
"modified": 1705852800000
817-
},
818-
{
819-
"type": "movie",
820-
"id": "movie:imdb/tt9876543",
821-
"userRef": "user123",
822-
"date": 0,
823-
"modified": 1705939200000
824-
}
825-
]
826-
```
827-
828-
In this response:
829-
- First item: Movie was watched at timestamp `1705766400000`
830-
- Second item: Movie was unwatched (`date=0`), client should remove it from local state

scripts/remap_trakt_watched.py

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,11 @@ class EpisodeRow:
4242
trakt: int | None
4343
tvdb: int | None
4444

45-
@property
46-
def redseat_id(self) -> str:
47-
return f"episode:redseat/{self.serie_ref}/{self.season}/{self.number}"
45+
46+
def episode_history_id(series: SeriesRow, episode: EpisodeRow) -> str:
47+
imdb = series.imdb.strip() if series.imdb else ""
48+
key = f"imdb/{imdb}" if imdb else f"redseat/{series.id}"
49+
return f"episode:{key}/{episode.season}/{episode.number}"
4850

4951

5052
class TraktClient:
@@ -192,7 +194,7 @@ def build_episode_map(
192194
fieldnames=[
193195
"source",
194196
"trakt_episode_id",
195-
"redseat_episode_id",
197+
"history_episode_id",
196198
"serie_ref",
197199
"serie_name",
198200
"serie_imdb",
@@ -208,22 +210,27 @@ def build_episode_map(
208210
)
209211
writer.writeheader()
210212

213+
series_by_id = {series.id: series for series in series_rows}
211214
for episode in local_episodes.values():
212215
if not episode.trakt:
213216
continue
217+
series = series_by_id.get(episode.serie_ref)
218+
if not series:
219+
continue
214220
trakt_key = f"trakt:{episode.trakt}"
215-
mapping[trakt_key] = episode.redseat_id
221+
history_id = episode_history_id(series, episode)
222+
mapping[trakt_key] = history_id
216223
writer.writerow(
217224
{
218225
"source": "local_db",
219226
"trakt_episode_id": trakt_key,
220-
"redseat_episode_id": episode.redseat_id,
227+
"history_episode_id": history_id,
221228
"serie_ref": episode.serie_ref,
222-
"serie_name": "",
223-
"serie_imdb": "",
224-
"serie_tmdb": "",
225-
"serie_tvdb": "",
226-
"serie_trakt": "",
229+
"serie_name": series.name,
230+
"serie_imdb": series.imdb or "",
231+
"serie_tmdb": series.tmdb or "",
232+
"serie_tvdb": series.tvdb or "",
233+
"serie_trakt": series.trakt or "",
227234
"season": episode.season,
228235
"episode": episode.number,
229236
"episode_imdb": episode.imdb or "",
@@ -259,12 +266,13 @@ def build_episode_map(
259266
continue
260267

261268
trakt_key = f"trakt:{trakt_episode_id}"
262-
mapping[trakt_key] = local_episode.redseat_id
269+
history_id = episode_history_id(series, local_episode)
270+
mapping[trakt_key] = history_id
263271
writer.writerow(
264272
{
265273
"source": "trakt_api",
266274
"trakt_episode_id": trakt_key,
267-
"redseat_episode_id": local_episode.redseat_id,
275+
"history_episode_id": history_id,
268276
"serie_ref": series.id,
269277
"serie_name": series.name,
270278
"serie_imdb": series.imdb or "",
@@ -351,15 +359,19 @@ def rewrite_history(
351359
"progress_updated": 0,
352360
"progress_merged": 0,
353361
"progress_unresolved": 0,
362+
"tombstones_removed": 0,
354363
}
355364

356365
try:
366+
conn.execute("BEGIN IMMEDIATE")
367+
stats["tombstones_removed"] = conn.execute(
368+
"DELETE FROM Watched WHERE date <= 0"
369+
).rowcount
357370
rows = list(
358371
conn.execute(
359372
"SELECT type, id, user_ref, date, modified FROM Watched ORDER BY type, user_ref, id"
360373
)
361374
)
362-
conn.execute("BEGIN IMMEDIATE")
363375
for row in rows:
364376
kind = row["type"]
365377
old_id = row["id"]
@@ -415,7 +427,7 @@ def rewrite_history(
415427
stats[updated_key] += 1
416428
conn.execute(
417429
"""
418-
UPDATE Watched SET date = 0
430+
DELETE FROM Watched
419431
WHERE type = ? AND id = ? AND user_ref = ?
420432
""",
421433
(kind, old_id, row["user_ref"]),
@@ -447,7 +459,8 @@ def rewrite_history(
447459
).fetchone()
448460
parent = row["parent"]
449461
if kind == "episode":
450-
parent = f"series:redseat/{new_id.split('/')[1]}"
462+
prefix, series_id, _, _ = new_id.split("/")
463+
parent = f"{prefix.replace('episode:', 'series:')}/{series_id}"
451464
if destination:
452465
if destination["modified"] > row["modified"]:
453466
value = destination["progress"]

src/model/history.rs

Lines changed: 97 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,34 @@ use super::{
1313
ModelController,
1414
};
1515

16-
const HISTORY_MIGRATION: &str = "canonical_history_ids_v2";
16+
const HISTORY_MIGRATION: &str = "canonical_history_ids_v4";
17+
18+
fn non_empty_id(id: Option<&str>) -> Option<&str> {
19+
id.map(str::trim).filter(|id| !id.is_empty())
20+
}
21+
22+
fn series_history_key(serie: &Serie) -> String {
23+
non_empty_id(serie.imdb.as_deref())
24+
.map(|imdb| format!("imdb/{imdb}"))
25+
.unwrap_or_else(|| format!("redseat/{}", serie.id))
26+
}
1727

1828
pub fn series_history_id(serie: &Serie) -> String {
19-
format!("series:redseat/{}", serie.id)
29+
format!("series:{}", series_history_key(serie))
2030
}
2131

2232
pub fn movie_history_id(movie: &Movie) -> String {
23-
movie
24-
.imdb
25-
.as_ref()
33+
non_empty_id(movie.imdb.as_deref())
2634
.map(|imdb| format!("movie:imdb/{imdb}"))
2735
.unwrap_or_else(|| format!("movie:redseat/{}", movie.id))
2836
}
2937

3038
pub fn episode_history_id(serie: &Serie, episode: &Episode) -> String {
3139
format!(
32-
"episode:redseat/{}/{}/{}",
33-
serie.id, episode.season, episode.number
40+
"episode:{}/{}/{}",
41+
series_history_key(serie),
42+
episode.season,
43+
episode.number
3444
)
3545
}
3646

@@ -111,7 +121,7 @@ fn episode_legacy_ids(serie: &Serie, episode: &Episode) -> HashSet<String> {
111121
.collect();
112122

113123
for key in [
114-
serie.imdb.as_ref().map(|id| format!("imdb/{id}")),
124+
non_empty_id(serie.imdb.as_deref()).map(|id| format!("imdb/{id}")),
115125
serie.tmdb.map(|id| format!("tmdb/{id}")),
116126
serie.tvdb.map(|id| format!("tvdb/{id}")),
117127
Some(format!("redseat/{}", serie.id)),
@@ -150,8 +160,22 @@ fn unique_candidates<T>(candidates: HashMap<String, HashSet<T>>) -> HashMap<Stri
150160

151161
fn is_current_history_id(kind: &MediaType, id: &str) -> bool {
152162
match kind {
153-
MediaType::Movie => id.starts_with("movie:imdb/") || id.starts_with("movie:redseat/"),
154-
MediaType::Episode => id.starts_with("episode:redseat/"),
163+
MediaType::Movie => ["movie:imdb/", "movie:redseat/"]
164+
.into_iter()
165+
.any(|prefix| non_empty_id(id.strip_prefix(prefix)).is_some()),
166+
MediaType::Episode => {
167+
let Some(path) = id.strip_prefix("episode:imdb/") else {
168+
return false;
169+
};
170+
let mut parts = path.split('/');
171+
matches!(
172+
(parts.next(), parts.next(), parts.next(), parts.next()),
173+
(Some(imdb), Some(season), Some(number), None)
174+
if non_empty_id(Some(imdb)).is_some()
175+
&& season.parse::<u32>().is_ok()
176+
&& number.parse::<u32>().is_ok()
177+
)
178+
}
155179
_ => true,
156180
}
157181
}
@@ -166,20 +190,27 @@ impl ModelController {
166190
return Ok(());
167191
}
168192

169-
let watched = self.store.get_all_watched().await?;
170-
let progress = self.store.get_all_view_progress_rows().await?;
193+
let tombstones_removed = self.store.purge_watched_tombstones().await?;
194+
let mut watched = self.store.get_all_watched().await?;
195+
let mut progress = self.store.get_all_view_progress_rows().await?;
196+
watched.sort_by(|a, b| b.modified.cmp(&a.modified));
197+
progress.sort_by(|a, b| b.modified.cmp(&a.modified));
171198
let legacy_movie_ids: HashSet<String> = watched
172199
.iter()
173200
.map(|row| (&row.kind, &row.id))
174201
.chain(progress.iter().map(|row| (&row.kind, &row.id)))
175-
.filter(|(kind, id)| **kind == MediaType::Movie && !is_current_history_id(kind, id))
202+
.filter(|(kind, id)| {
203+
**kind == MediaType::Movie && !is_current_history_id(kind, id)
204+
})
176205
.map(|(_, id)| id.clone())
177206
.collect();
178207
let legacy_episode_ids: HashSet<String> = watched
179208
.iter()
180209
.map(|row| (&row.kind, &row.id))
181210
.chain(progress.iter().map(|row| (&row.kind, &row.id)))
182-
.filter(|(kind, id)| **kind == MediaType::Episode && !is_current_history_id(kind, id))
211+
.filter(|(kind, id)| {
212+
**kind == MediaType::Episode && !is_current_history_id(kind, id)
213+
})
183214
.map(|(_, id)| id.clone())
184215
.collect();
185216

@@ -285,8 +316,8 @@ impl ModelController {
285316
log_info(
286317
LogServiceType::Database,
287318
format!(
288-
"History migration complete: watched rewrites={}, progress rewrites={}",
289-
watched_count, progress_count
319+
"History migration complete: watched rewrites={}, progress rewrites={}, tombstones removed={}",
320+
watched_count, progress_count, tombstones_removed
290321
),
291322
);
292323
Ok(())
@@ -334,4 +365,54 @@ impl ModelController {
334365
.await?;
335366
Ok(())
336367
}
368+
369+
pub async fn migrate_series_history_ids(
370+
&self,
371+
old_serie: &Serie,
372+
new_serie: &Serie,
373+
) -> crate::Result<()> {
374+
let old_prefix = format!("episode:{}/", series_history_key(old_serie));
375+
let new_prefix = format!("episode:{}/", series_history_key(new_serie));
376+
if old_prefix == new_prefix {
377+
return Ok(());
378+
}
379+
380+
let watched_rewrites = self
381+
.store
382+
.get_all_watched()
383+
.await?
384+
.into_iter()
385+
.filter(|row| row.kind == MediaType::Episode)
386+
.filter_map(|row| {
387+
let suffix = row.id.strip_prefix(&old_prefix)?;
388+
Some(HistoryIdRewrite {
389+
new_id: format!("{new_prefix}{suffix}"),
390+
kind: row.kind,
391+
old_id: row.id,
392+
user_ref: row.user_ref?,
393+
})
394+
})
395+
.collect();
396+
let progress_rewrites = self
397+
.store
398+
.get_all_view_progress_rows()
399+
.await?
400+
.into_iter()
401+
.filter(|row| row.kind == MediaType::Episode)
402+
.filter_map(|row| {
403+
let suffix = row.id.strip_prefix(&old_prefix)?;
404+
Some(ProgressIdRewrite {
405+
new_id: format!("{new_prefix}{suffix}"),
406+
new_parent: Some(series_history_id(new_serie)),
407+
kind: row.kind,
408+
old_id: row.id,
409+
user_ref: row.user_ref,
410+
})
411+
})
412+
.collect();
413+
self.store
414+
.apply_history_rewrites(watched_rewrites, progress_rewrites)
415+
.await?;
416+
Ok(())
417+
}
337418
}

0 commit comments

Comments
 (0)