fixed mark as watched items not syncing

This commit is contained in:
tapframe 2026-03-14 08:50:11 +05:30
parent 4135b4725a
commit c18f1130fc
4 changed files with 137 additions and 66 deletions

View file

@ -193,38 +193,36 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
case 'watched': {
const targetWatched = !isWatched;
setIsWatched(targetWatched);
try {
await mmkvStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false');
} catch { }
showInfo(targetWatched ? t('library.marked_watched') : t('library.marked_unwatched'), targetWatched ? t('library.item_marked_watched') : t('library.item_marked_unwatched'));
setTimeout(() => {
DeviceEventEmitter.emit('watchedStatusChanged');
}, 100);
// Best-effort sync: record local progress and push to Trakt if available
if (targetWatched) {
try {
await storageService.setWatchProgress(
item.id,
item.type,
{ currentTime: 1, duration: 1, lastUpdated: Date.now() },
undefined,
{ forceNotify: true, forceWrite: true }
);
} catch { }
if (item.type === 'movie') {
try {
const trakt = TraktService.getInstance();
if (await trakt.isAuthenticated()) {
await trakt.addToWatchedMovies(item.id);
try {
await storageService.updateTraktSyncStatus(item.id, item.type, true, 100);
} catch { }
}
} catch { }
// Use the centralized watchedService to handle all the sync logic (Supabase, Trakt, Simkl, MAL)
import('../../services/watchedService').then(({ watchedService }) => {
if (targetWatched) {
if (item.type === 'movie') {
// Pass the title so it correctly populates the database instead of defaulting to IMDb ID
watchedService.markMovieAsWatched(item.id, new Date(), undefined, undefined, item.name);
} else {
// For series from the homescreen drop-down, we mark S1E1 as watched as a baseline
watchedService.markEpisodeAsWatched(item.id, item.id, 1, 1, new Date(), undefined, item.name);
}
} else {
if (item.type === 'movie') {
watchedService.unmarkMovieAsWatched(item.id);
} else {
// Unmarking a series from the top level is tricky as we don't know the exact episodes.
// For safety and consistency with old behavior, we just clear the legacy flag.
mmkvStorage.removeItem(`watched:${item.type}:${item.id}`);
}
}
}
showInfo(
targetWatched ? t('library.marked_watched') : t('library.marked_unwatched'),
targetWatched ? t('library.item_marked_watched') : t('library.item_marked_unwatched')
);
setTimeout(() => {
DeviceEventEmitter.emit('watchedStatusChanged');
}, 100);
});
setMenuVisible(false);
break;
}

View file

@ -335,7 +335,6 @@ class StremioService {
if (lowerPrefix.endsWith(':') || lowerPrefix.endsWith('_')) return true;
return lowerId.length > lowerPrefix.length;
});
if (__DEV__) console.log(`🔍 [isValidContentId] Prefix match result: ${result} for ID '${id}'`);
return result;
}

View file

@ -1135,8 +1135,16 @@ class SupabaseSyncService {
}
private async isExternalProgressSyncConnected(): Promise<boolean> {
if (await this.isTraktConnected()) return true;
return await this.isSimklConnected();
const trakt = await this.isTraktConnected();
if (trakt) {
logger.log('[SupabaseSyncService] isExternalProgressSyncConnected: Trakt is connected, returning true');
return true;
}
const simkl = await this.isSimklConnected();
if (simkl) {
logger.log('[SupabaseSyncService] isExternalProgressSyncConnected: Simkl is connected, returning true');
}
return simkl;
}
private async pullPluginsToLocal(): Promise<void> {
@ -1409,62 +1417,90 @@ class SupabaseSyncService {
private async pushWatchProgressFromLocal(): Promise<void> {
const all = await storageService.getAllWatchProgress();
const allKeys = Object.keys(all);
const nextSeenKeys = new Set<string>();
const changedEntries: Array<{ key: string; row: WatchProgressRow; signature: string }> = [];
let skippedSameSignature = 0;
let skippedParseFailure = 0;
for (const [key, value] of Object.entries(all)) {
nextSeenKeys.add(key);
const signature = this.getWatchProgressEntrySignature(value);
if (this.watchProgressPushedSignatures.get(key) === signature) {
skippedSameSignature++;
continue;
}
const parsed = this.parseWatchProgressKey(key);
if (!parsed) {
skippedParseFailure++;
continue;
}
changedEntries.push({
key,
signature,
row: {
content_id: parsed.contentId,
content_type: parsed.contentType,
video_id: parsed.videoId,
season: parsed.season,
episode: parsed.episode,
position: this.secondsToMsLong(value.currentTime),
duration: this.secondsToMsLong(value.duration),
last_watched: this.normalizeEpochMs(value.lastUpdated || Date.now()),
progress_key: parsed.progressKey,
},
});
const row: WatchProgressRow = {
content_id: parsed.contentId,
content_type: parsed.contentType,
video_id: parsed.videoId,
season: parsed.season,
episode: parsed.episode,
position: this.secondsToMsLong(value.currentTime),
duration: this.secondsToMsLong(value.duration),
last_watched: this.normalizeEpochMs(value.lastUpdated || Date.now()),
progress_key: parsed.progressKey,
};
changedEntries.push({ key, signature, row });
}
// Prune signatures for entries no longer present locally (deletes are handled separately).
let prunedSignatures = 0;
for (const existingKey of Array.from(this.watchProgressPushedSignatures.keys())) {
if (!nextSeenKeys.has(existingKey)) {
this.watchProgressPushedSignatures.delete(existingKey);
prunedSignatures++;
}
}
logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: skippedSameSignature=${skippedSameSignature} skippedParseFailure=${skippedParseFailure} prunedStaleSignatures=${prunedSignatures}`);
if (changedEntries.length === 0) {
logger.log('[SupabaseSyncService] pushWatchProgressFromLocal: no changed entries; skipping push');
return;
}
await this.callRpc<void>('sync_push_watch_progress', {
p_entries: changedEntries.map((entry) => entry.row),
});
const rpcPayload = changedEntries.map((entry) => entry.row);
logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: calling sync_push_watch_progress with ${rpcPayload.length} entries`);
try {
await this.callRpc<void>('sync_push_watch_progress', {
p_entries: rpcPayload,
});
logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: RPC success`);
} catch (rpcError: any) {
logger.error(`[SupabaseSyncService] pushWatchProgressFromLocal: RPC FAILED`, rpcError?.message || rpcError);
throw rpcError;
}
for (const entry of changedEntries) {
this.watchProgressPushedSignatures.set(entry.key, entry.signature);
}
logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: pushedChanged=${changedEntries.length} totalLocal=${Object.keys(all).length}`);
logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: pushedChanged=${changedEntries.length} totalLocal=${allKeys.length}`);
}
private async pullLibraryToLocal(): Promise<void> {
const rows = await this.callRpc<LibraryRow[]>('sync_pull_library', {});
const PAGE_SIZE = 500;
const rows: LibraryRow[] = [];
let offset = 0;
while (true) {
const page = await this.callRpc<LibraryRow[]>('sync_pull_library', {
p_limit: PAGE_SIZE,
p_offset: offset,
});
if (!page || page.length === 0) break;
rows.push(...page);
if (page.length < PAGE_SIZE) break;
offset += PAGE_SIZE;
}
const localItems = await catalogService.getLibraryItems();
const existing = new Set(localItems.map((item) => `${item.type}:${item.id}`));
const remoteSet = new Set<string>();
@ -1532,6 +1568,8 @@ class SupabaseSyncService {
private async pushWatchedItemsFromLocal(): Promise<void> {
const items = await watchedService.getAllWatchedItems();
if (items.length === 0) return;
const payload: WatchedRow[] = items.map((item) => ({
content_id: item.content_id,
content_type: item.content_type,
@ -1540,7 +1578,13 @@ class SupabaseSyncService {
episode: item.episode,
watched_at: item.watched_at,
}));
await this.callRpc<void>('sync_push_watched_items', { p_items: payload });
try {
await this.callRpc<void>('sync_push_watched_items', { p_items: payload });
} catch (rpcError: any) {
logger.error(`[SupabaseSyncService] pushWatchedItemsFromLocal: RPC FAILED`, rpcError?.message || rpcError);
throw rpcError;
}
}
}

View file

@ -175,13 +175,22 @@ class WatchedService {
.filter((item) => Boolean(item.content_id));
// Guard: do not wipe local watched data if backend temporarily returns empty.
if (normalizedRemote.length === 0) {
logger.log('[WatchedService] reconcileRemoteWatchedItems: remote is empty, doing nothing');
return;
}
const currentLocal = await this.loadWatchedItems();
const remoteKeys = new Set(normalizedRemote.map(r => this.watchedKey(r)));
// Find local items that need to be removed because they don't exist remotely
const toRemove = currentLocal.filter(l => !remoteKeys.has(this.watchedKey(l)));
await this.saveWatchedItems(normalizedRemote);
this.notifyWatchedSubscribers();
// 1. Set watched status for all remote items
for (const item of normalizedRemote) {
if (item.content_type === 'movie') {
await this.setLocalWatchedStatus(item.content_id, 'movie', true, undefined, new Date(item.watched_at));
@ -192,8 +201,27 @@ class WatchedService {
const episodeId = `${item.content_id}:${item.season}:${item.episode}`;
await this.setLocalWatchedStatus(item.content_id, 'series', true, episodeId, new Date(item.watched_at));
}
// 2. Unset watched status for local items that were deleted remotely
for (const item of toRemove) {
if (item.content_type === 'movie') {
await this.setLocalWatchedStatus(item.content_id, 'movie', false);
} else if (item.season != null && item.episode != null) {
const episodeId = `${item.content_id}:${item.season}:${item.episode}`;
await this.setLocalWatchedStatus(item.content_id, 'series', false, episodeId);
}
}
if (toRemove.length > 0) {
logger.log(`[WatchedService] reconcileRemoteWatchedItems: Removed ${toRemove.length} local items that were deleted remotely`);
}
}
/**
* Mark a movie as watched
* @param imdbId - The IMDb ID of the movie
* @param watchedAt - Optional date when watched
*/
/**
* Mark a movie as watched
* @param imdbId - The IMDb ID of the movie
@ -207,7 +235,7 @@ class WatchedService {
title?: string
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
try {
logger.log(`[WatchedService] Marking movie as watched: ${imdbId}`);
logger.log(`[WatchedService] Marking movie as watched: ${imdbId} (${title || 'No title'})`);
const isTraktAuth = await this.traktService.isAuthenticated();
let syncedToTrakt = false;
@ -222,10 +250,10 @@ class WatchedService {
if (MalAuth.isAuthenticated()) {
MalSync.scrobbleEpisode(
title || 'Movie', // Use real title or generic fallback
1,
1,
'movie',
undefined,
1,
1,
'movie',
undefined,
imdbId,
undefined,
malId,
@ -247,7 +275,7 @@ class WatchedService {
{
content_id: imdbId,
content_type: 'movie',
title: imdbId,
title: title || imdbId,
season: null,
episode: null,
watched_at: watchedAt.getTime(),
@ -342,7 +370,7 @@ class WatchedService {
'series',
season,
showImdbId,
releaseDate,
releaseDate,
malId,
dayIndex,
tmdbId
@ -373,7 +401,7 @@ class WatchedService {
{
content_id: showImdbId,
content_type: 'series',
title: showImdbId,
title: showTitle || showImdbId,
season,
episode,
watched_at: watchedAt.getTime(),
@ -398,7 +426,8 @@ class WatchedService {
showImdbId: string,
showId: string,
episodes: Array<{ season: number; episode: number }>,
watchedAt: Date = new Date()
watchedAt: Date = new Date(),
showTitle?: string
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
try {
if (episodes.length === 0) {
@ -479,7 +508,8 @@ class WatchedService {
showId: string,
season: number,
episodeNumbers: number[],
watchedAt: Date = new Date()
watchedAt: Date = new Date(),
showTitle?: string
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
try {
logger.log(`[WatchedService] Marking season ${season} as watched for ${showImdbId}`);
@ -525,7 +555,7 @@ class WatchedService {
episodeNumbers.map((episode) => ({
content_id: showImdbId,
content_type: 'series' as const,
title: showImdbId,
title: showTitle || showImdbId,
season,
episode,
watched_at: watchedAt.getTime(),