From 89c8c906695ba67f1cfd6cedd0d4e0b13b85e7b6 Mon Sep 17 00:00:00 2001 From: KhooLy <73142442+KhooLy@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:36:18 +0300 Subject: [PATCH] Add local mutation timestamps for watchlist and watched state Lays the groundwork for timestamp-based conflict resolution against Trakt/Simkl/Stremio (matching the pattern Nuvio already uses), by giving watchlist entries and watched episodes an updatedAt column and a soft-delete tombstone table so removals carry a comparable timestamp instead of vanishing on hard delete. Progress already had updatedAt; this brings watchlist/watched up to the same level. --- .../16.json | 715 ++++++++++++++++++ .../com/fluxa/app/data/local/WatchlistDao.kt | 24 + .../fluxa/app/data/local/WatchlistDatabase.kt | 6 +- .../fluxa/app/data/local/WatchlistEntities.kt | 27 +- .../fluxa/app/data/local/WatchlistManager.kt | 4 + 5 files changed, 772 insertions(+), 4 deletions(-) create mode 100644 data/schemas/com.fluxa.app.data.local.AppDatabase/16.json diff --git a/data/schemas/com.fluxa.app.data.local.AppDatabase/16.json b/data/schemas/com.fluxa.app.data.local.AppDatabase/16.json new file mode 100644 index 0000000..e7f1bc7 --- /dev/null +++ b/data/schemas/com.fluxa.app.data.local.AppDatabase/16.json @@ -0,0 +1,715 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "e7e0e9a89764aed8cd18a0fcbd66abaf", + "entities": [ + { + "tableName": "content_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `poster` TEXT, `background` TEXT, `logo` TEXT, `description` TEXT, `rating` TEXT, `releaseInfo` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`profileId`, `contentId`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poster", + "columnName": "poster", + "affinity": "TEXT" + }, + { + "fieldPath": "background", + "columnName": "background", + "affinity": "TEXT" + }, + { + "fieldPath": "logo", + "columnName": "logo", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "rating", + "columnName": "rating", + "affinity": "TEXT" + }, + { + "fieldPath": "releaseInfo", + "columnName": "releaseInfo", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profileId", + "contentId" + ] + } + }, + { + "tableName": "watchlist_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `addedAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`profileId`, `contentId`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profileId", + "contentId" + ] + }, + "indices": [ + { + "name": "index_watchlist_entries_profile_addedAt", + "unique": false, + "columnNames": [ + "profileId", + "addedAt" + ], + "orders": [ + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_watchlist_entries_profile_addedAt` ON `${TABLE_NAME}` (`profileId` ASC, `addedAt` DESC)" + } + ] + }, + { + "tableName": "playback_progress", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `videoId` TEXT, `timeOffset` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `lastStreamIndex` INTEGER, `lastEpisodeName` TEXT, `lastStreamUrl` TEXT, `lastStreamTitle` TEXT, `lastBingeGroup` TEXT, `continueWatchingPoster` TEXT, `continueWatchingBackground` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`profileId`, `contentId`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT" + }, + { + "fieldPath": "timeOffset", + "columnName": "timeOffset", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStreamIndex", + "columnName": "lastStreamIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastEpisodeName", + "columnName": "lastEpisodeName", + "affinity": "TEXT" + }, + { + "fieldPath": "lastStreamUrl", + "columnName": "lastStreamUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "lastStreamTitle", + "columnName": "lastStreamTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "lastBingeGroup", + "columnName": "lastBingeGroup", + "affinity": "TEXT" + }, + { + "fieldPath": "continueWatchingPoster", + "columnName": "continueWatchingPoster", + "affinity": "TEXT" + }, + { + "fieldPath": "continueWatchingBackground", + "columnName": "continueWatchingBackground", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profileId", + "contentId" + ] + }, + "indices": [ + { + "name": "index_playback_progress_profile_updatedAt", + "unique": false, + "columnNames": [ + "profileId", + "updatedAt" + ], + "orders": [ + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_progress_profile_updatedAt` ON `${TABLE_NAME}` (`profileId` ASC, `updatedAt` DESC)" + } + ] + }, + { + "tableName": "user_feedback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `isLiked` INTEGER, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`profileId`, `contentId`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isLiked", + "columnName": "isLiked", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profileId", + "contentId" + ] + }, + "indices": [ + { + "name": "index_user_feedback_profile_updatedAt", + "unique": false, + "columnNames": [ + "profileId", + "updatedAt" + ], + "orders": [ + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_feedback_profile_updatedAt` ON `${TABLE_NAME}` (`profileId` ASC, `updatedAt` DESC)" + } + ] + }, + { + "tableName": "track_preferences", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `lastAudioLanguage` TEXT, `lastSubtitleLanguage` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`profileId`, `contentId`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastAudioLanguage", + "columnName": "lastAudioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSubtitleLanguage", + "columnName": "lastSubtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profileId", + "contentId" + ] + } + }, + { + "tableName": "watched_episodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `seriesId` TEXT NOT NULL, `videoId` TEXT NOT NULL, `watchedAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`profileId`, `seriesId`, `videoId`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watchedAt", + "columnName": "watchedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profileId", + "seriesId", + "videoId" + ] + }, + "indices": [ + { + "name": "index_watched_episodes_profile_series", + "unique": false, + "columnNames": [ + "profileId", + "seriesId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_watched_episodes_profile_series` ON `${TABLE_NAME}` (`profileId`, `seriesId`)" + } + ] + }, + { + "tableName": "watched_content_durations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `source` TEXT NOT NULL, `contentId` TEXT NOT NULL, `videoId` TEXT NOT NULL, `duration` INTEGER NOT NULL, `watchedAt` INTEGER NOT NULL, PRIMARY KEY(`profileId`, `source`, `contentId`, `videoId`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "watchedAt", + "columnName": "watchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profileId", + "source", + "contentId", + "videoId" + ] + }, + "indices": [ + { + "name": "index_watched_content_durations_profile", + "unique": false, + "columnNames": [ + "profileId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_watched_content_durations_profile` ON `${TABLE_NAME}` (`profileId`)" + }, + { + "name": "index_watched_content_durations_profile_source", + "unique": false, + "columnNames": [ + "profileId", + "source" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_watched_content_durations_profile_source` ON `${TABLE_NAME}` (`profileId`, `source`)" + } + ] + }, + { + "tableName": "external_playback_progress", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `provider` TEXT NOT NULL, `contentId` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `poster` TEXT, `background` TEXT, `logo` TEXT, `description` TEXT, `rating` TEXT, `releaseInfo` TEXT, `videoId` TEXT, `timeOffset` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `lastEpisodeName` TEXT, `reason` TEXT, `continueWatchingPoster` TEXT, `continueWatchingBackground` TEXT, `syncedAt` INTEGER NOT NULL, PRIMARY KEY(`profileId`, `provider`, `contentId`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poster", + "columnName": "poster", + "affinity": "TEXT" + }, + { + "fieldPath": "background", + "columnName": "background", + "affinity": "TEXT" + }, + { + "fieldPath": "logo", + "columnName": "logo", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "rating", + "columnName": "rating", + "affinity": "TEXT" + }, + { + "fieldPath": "releaseInfo", + "columnName": "releaseInfo", + "affinity": "TEXT" + }, + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT" + }, + { + "fieldPath": "timeOffset", + "columnName": "timeOffset", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEpisodeName", + "columnName": "lastEpisodeName", + "affinity": "TEXT" + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT" + }, + { + "fieldPath": "continueWatchingPoster", + "columnName": "continueWatchingPoster", + "affinity": "TEXT" + }, + { + "fieldPath": "continueWatchingBackground", + "columnName": "continueWatchingBackground", + "affinity": "TEXT" + }, + { + "fieldPath": "syncedAt", + "columnName": "syncedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profileId", + "provider", + "contentId" + ] + }, + "indices": [ + { + "name": "index_external_playback_progress_profile_syncedAt", + "unique": false, + "columnNames": [ + "profileId", + "syncedAt" + ], + "orders": [ + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_external_playback_progress_profile_syncedAt` ON `${TABLE_NAME}` (`profileId` ASC, `syncedAt` DESC)" + } + ] + }, + { + "tableName": "external_watched_episodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `provider` TEXT NOT NULL, `seriesId` TEXT NOT NULL, `videoId` TEXT NOT NULL, `syncedAt` INTEGER NOT NULL, PRIMARY KEY(`profileId`, `provider`, `seriesId`, `videoId`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncedAt", + "columnName": "syncedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profileId", + "provider", + "seriesId", + "videoId" + ] + }, + "indices": [ + { + "name": "index_external_watched_episodes_profile_provider_series", + "unique": false, + "columnNames": [ + "profileId", + "provider", + "seriesId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_external_watched_episodes_profile_provider_series` ON `${TABLE_NAME}` (`profileId`, `provider`, `seriesId`)" + } + ] + }, + { + "tableName": "watchlist_removals", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `removedAt` INTEGER NOT NULL, PRIMARY KEY(`profileId`, `contentId`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "removedAt", + "columnName": "removedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profileId", + "contentId" + ] + } + }, + { + "tableName": "watched_episode_removals", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `seriesId` TEXT NOT NULL, `videoId` TEXT NOT NULL, `removedAt` INTEGER NOT NULL, PRIMARY KEY(`profileId`, `seriesId`, `videoId`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "removedAt", + "columnName": "removedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profileId", + "seriesId", + "videoId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e7e0e9a89764aed8cd18a0fcbd66abaf')" + ] + } +} \ No newline at end of file diff --git a/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistDao.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistDao.kt index 791bab4..adc23fa 100644 --- a/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistDao.kt +++ b/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistDao.kt @@ -242,6 +242,12 @@ interface WatchlistDao { @Query("SELECT contentId FROM watchlist_entries WHERE profileId = :profileId") fun observeWatchlistIds(profileId: String): Flow> + @Query("SELECT * FROM watchlist_entries WHERE profileId = :profileId") + suspend fun getWatchlistEntries(profileId: String): List + + @Query("SELECT * FROM watched_episodes WHERE profileId = :profileId AND seriesId = :seriesId") + suspend fun getWatchedEpisodeEntries(profileId: String, seriesId: String): List + @Query("SELECT EXISTS(SELECT 1 FROM watchlist_entries WHERE profileId = :profileId AND contentId = :contentId)") suspend fun isInWatchlist(profileId: String, contentId: String): Boolean @@ -320,6 +326,15 @@ interface WatchlistDao { @Query("DELETE FROM watchlist_entries WHERE profileId = :profileId AND contentId = :contentId") suspend fun deleteWatchlistEntry(profileId: String, contentId: String) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertWatchlistRemoval(removal: WatchlistRemovalEntity) + + @Query("DELETE FROM watchlist_removals WHERE profileId = :profileId AND contentId = :contentId") + suspend fun clearWatchlistRemoval(profileId: String, contentId: String) + + @Query("SELECT * FROM watchlist_removals WHERE profileId = :profileId") + suspend fun getWatchlistRemovals(profileId: String): List + @Query("DELETE FROM playback_progress WHERE profileId = :profileId AND contentId = :contentId") suspend fun deletePlaybackProgress(profileId: String, contentId: String) @@ -332,6 +347,15 @@ interface WatchlistDao { @Query("DELETE FROM watched_episodes WHERE profileId = :profileId AND seriesId = :seriesId AND videoId IN (:videoIds)") suspend fun deleteWatchedEpisodes(profileId: String, seriesId: String, videoIds: List) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertWatchedEpisodeRemovals(removals: List) + + @Query("DELETE FROM watched_episode_removals WHERE profileId = :profileId AND seriesId = :seriesId AND videoId IN (:videoIds)") + suspend fun clearWatchedEpisodeRemovals(profileId: String, seriesId: String, videoIds: List) + + @Query("SELECT * FROM watched_episode_removals WHERE profileId = :profileId AND seriesId = :seriesId") + suspend fun getWatchedEpisodeRemovals(profileId: String, seriesId: String): List + @Query("DELETE FROM watched_episodes WHERE profileId = :profileId") suspend fun deleteAllWatchedEpisodes(profileId: String) diff --git a/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistDatabase.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistDatabase.kt index 1e57bae..993e5b6 100644 --- a/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistDatabase.kt +++ b/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistDatabase.kt @@ -13,9 +13,11 @@ import androidx.room.RoomDatabase WatchedEpisodeEntity::class, WatchedContentDurationEntity::class, ExternalPlaybackProgressEntity::class, - ExternalWatchedEpisodeEntity::class + ExternalWatchedEpisodeEntity::class, + WatchlistRemovalEntity::class, + WatchedEpisodeRemovalEntity::class ], - version = 15, + version = 16, exportSchema = true ) abstract class AppDatabase : RoomDatabase() { diff --git a/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistEntities.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistEntities.kt index d629633..0d9e4b7 100644 --- a/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistEntities.kt +++ b/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistEntities.kt @@ -36,7 +36,18 @@ data class ContentItemEntity( data class WatchlistEntryEntity( val profileId: String, val contentId: String, - val addedAt: Long = System.currentTimeMillis() + val addedAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis() +) + +@Entity( + tableName = "watchlist_removals", + primaryKeys = ["profileId", "contentId"] +) +data class WatchlistRemovalEntity( + val profileId: String, + val contentId: String, + val removedAt: Long = System.currentTimeMillis() ) @Entity( @@ -90,7 +101,19 @@ data class WatchedEpisodeEntity( val profileId: String, val seriesId: String, val videoId: String, - val watchedAt: Long = System.currentTimeMillis() + val watchedAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis() +) + +@Entity( + tableName = "watched_episode_removals", + primaryKeys = ["profileId", "seriesId", "videoId"] +) +data class WatchedEpisodeRemovalEntity( + val profileId: String, + val seriesId: String, + val videoId: String, + val removedAt: Long = System.currentTimeMillis() ) @Entity( diff --git a/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistManager.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistManager.kt index a630aac..95f5bd4 100644 --- a/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistManager.kt +++ b/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistManager.kt @@ -94,7 +94,9 @@ class WatchlistManager @Inject constructor( dao.upsertContent(item.toContentItemEntity(profileId)) if (dao.isInWatchlist(profileId, item.id)) { dao.deleteWatchlistEntry(profileId, item.id) + dao.upsertWatchlistRemoval(WatchlistRemovalEntity(profileId, item.id)) } else { + dao.clearWatchlistRemoval(profileId, item.id) dao.upsertWatchlistEntry(WatchlistEntryEntity(profileId, item.id)) } } @@ -241,9 +243,11 @@ class WatchlistManager @Inject constructor( val cleanVideoIds = videoIds.filter { it.isNotBlank() }.distinct() if (cleanVideoIds.isEmpty()) return getLocalWatchedVideoIds(seriesId) if (watched) { + dao.clearWatchedEpisodeRemovals(profileId, seriesId, cleanVideoIds) dao.upsertWatchedEpisodes(cleanVideoIds.map { WatchedEpisodeEntity(profileId, seriesId, it) }) } else { dao.deleteWatchedEpisodes(profileId, seriesId, cleanVideoIds) + dao.upsertWatchedEpisodeRemovals(cleanVideoIds.map { WatchedEpisodeRemovalEntity(profileId, seriesId, it) }) } return getLocalWatchedVideoIds(seriesId) }