From 5552d451ce430c57fe244d26acc90fd1bc985baf Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:09:26 +0530 Subject: [PATCH] feat(trakt): implement TraktWatchedSyncAdapter for syncing watched items --- .../app/features/watched/WatchedRepository.kt | 11 +- .../watching/sync/TraktWatchedSyncAdapter.kt | 449 ++++++++++++++++++ 2 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index e02769ce..8cc9056c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -3,7 +3,9 @@ package com.nuvio.app.features.watched import co.touchlab.kermit.Logger import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.watching.sync.SupabaseWatchedSyncAdapter +import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter import com.nuvio.app.features.watching.sync.WatchedSyncAdapter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -40,6 +42,9 @@ object WatchedRepository { private var itemsByKey: MutableMap = mutableMapOf() internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter + private fun activeSyncAdapter(): WatchedSyncAdapter = + if (TraktAuthRepository.isAuthenticated.value) TraktWatchedSyncAdapter else syncAdapter + fun ensureLoaded() { if (hasLoaded) return loadFromDisk(ProfileRepository.activeProfileId) @@ -76,7 +81,7 @@ object WatchedRepository { suspend fun pullFromServer(profileId: Int) { currentProfileId = profileId runCatching { - val serverItems = syncAdapter.pull( + val serverItems = activeSyncAdapter().pull( profileId = profileId, pageSize = watchedItemsPageSize, ) @@ -198,7 +203,7 @@ object WatchedRepository { runCatching { if (items.isEmpty()) return@runCatching val profileId = ProfileRepository.activeProfileId - syncAdapter.push(profileId = profileId, items = items) + activeSyncAdapter().push(profileId = profileId, items = items) }.onFailure { e -> log.e(e) { "Failed to push watched items" } } @@ -210,7 +215,7 @@ object WatchedRepository { runCatching { if (items.isEmpty()) return@runCatching val profileId = ProfileRepository.activeProfileId - syncAdapter.delete(profileId = profileId, items = items) + activeSyncAdapter().delete(profileId = profileId, items = items) }.onFailure { e -> log.e(e) { "Failed to push watched item delete" } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt new file mode 100644 index 00000000..4293f559 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt @@ -0,0 +1,449 @@ +package com.nuvio.app.features.watching.sync + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.httpGetTextWithHeaders +import com.nuvio.app.features.addons.httpPostJsonWithHeaders +import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.watched.WatchedItem +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private const val BASE_URL = "https://api.trakt.tv" + + +object TraktWatchedSyncAdapter : WatchedSyncAdapter { + private val log = Logger.withTag("TraktWatchedSync") + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = false + explicitNulls = false + } + + // ── pull ──────────────────────────────────────────────────────────── + override suspend fun pull( + profileId: Int, + pageSize: Int, + ): List { + val headers = TraktAuthRepository.authorizedHeaders() ?: return emptyList() + + val (moviesPayload, showsPayload) = coroutineScope { + val movies = async { + httpGetTextWithHeaders( + url = "$BASE_URL/sync/watched/movies", + headers = headers, + ) + } + val shows = async { + httpGetTextWithHeaders( + url = "$BASE_URL/sync/watched/shows", + headers = headers, + ) + } + movies.await() to shows.await() + } + + val movieItems = runCatching { + json.decodeFromString>(moviesPayload) + }.getOrDefault(emptyList()) + + val showItems = runCatching { + json.decodeFromString>(showsPayload) + }.getOrDefault(emptyList()) + + val result = mutableListOf() + + movieItems.forEach { item -> + val movie = item.movie ?: return@forEach + val id = normalizeId(movie.ids) ?: return@forEach + result += WatchedItem( + id = id, + type = "movie", + name = movie.title ?: id, + season = null, + episode = null, + markedAtEpochMs = rankedTimestamp(item.lastWatchedAt), + ) + } + + showItems.forEach { item -> + val show = item.show ?: return@forEach + val showId = normalizeId(show.ids) ?: return@forEach + val showName = show.title ?: showId + + // Add a series-level watched entry + result += WatchedItem( + id = showId, + type = "series", + name = showName, + season = null, + episode = null, + markedAtEpochMs = rankedTimestamp(item.lastWatchedAt), + ) + + // Add per-episode watched entries + item.seasons.orEmpty().forEach seasonLoop@{ season -> + val seasonNumber = season.number ?: return@seasonLoop + season.episodes.orEmpty().forEach episodeLoop@{ episode -> + val episodeNumber = episode.number ?: return@episodeLoop + result += WatchedItem( + id = showId, + type = "series", + name = showName, + season = seasonNumber, + episode = episodeNumber, + markedAtEpochMs = rankedTimestamp(episode.lastWatchedAt ?: item.lastWatchedAt), + ) + } + } + } + + return result + } + + // ── push (add to history) ─────────────────────────────────────────── + override suspend fun push( + profileId: Int, + items: Collection, + ) { + if (items.isEmpty()) return + val headers = TraktAuthRepository.authorizedHeaders() ?: return + + val movies = mutableListOf() + val shows = mutableListOf() + + items.forEach { item -> + val ids = parseIds(item.id) ?: return@forEach + val normalizedType = item.type.trim().lowercase() + + if (normalizedType == "movie" || normalizedType == "film") { + movies += TraktHistoryMovieRequestDto( + title = item.name.takeIf { it.isNotBlank() }, + year = parseYear(item.releaseInfo), + ids = ids, + watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null, + ) + } else if (item.season != null && item.episode != null) { + // Episode-level mark → attach to show with specific season/episode + val existing = shows.firstOrNull { it.ids == ids } + if (existing != null) { + // Append episode to existing show entry + val seasonDto = existing.seasons?.firstOrNull { it.number == item.season } + if (seasonDto != null) { + (seasonDto.episodes as? MutableList)?.add( + TraktHistoryEpisodeRequestDto( + number = item.episode, + watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null, + ), + ) + } else { + (existing.seasons as? MutableList)?.add( + TraktHistorySeasonRequestDto( + number = item.season, + episodes = mutableListOf( + TraktHistoryEpisodeRequestDto( + number = item.episode, + watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null, + ), + ), + ), + ) + } + } else { + shows += TraktHistoryShowRequestDto( + title = item.name.takeIf { it.isNotBlank() }, + year = parseYear(item.releaseInfo), + ids = ids, + seasons = mutableListOf( + TraktHistorySeasonRequestDto( + number = item.season, + episodes = mutableListOf( + TraktHistoryEpisodeRequestDto( + number = item.episode, + watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null, + ), + ), + ), + ), + ) + } + } else { + // Series-level mark (no season/episode) → mark entire show + shows += TraktHistoryShowRequestDto( + title = item.name.takeIf { it.isNotBlank() }, + year = parseYear(item.releaseInfo), + ids = ids, + ) + } + } + + val body = json.encodeToString( + TraktHistoryAddRequestDto( + movies = movies.takeIf { it.isNotEmpty() }, + shows = shows.takeIf { it.isNotEmpty() }, + ), + ) + + runCatching { + httpPostJsonWithHeaders( + url = "$BASE_URL/sync/history", + body = body, + headers = headers, + ) + }.onFailure { e -> + if (e is CancellationException) throw e + log.w { "Failed to push watched items to Trakt: ${e.message}" } + } + } + + // ── delete (remove from history) ──────────────────────────────────── + override suspend fun delete( + profileId: Int, + items: Collection, + ) { + if (items.isEmpty()) return + val headers = TraktAuthRepository.authorizedHeaders() ?: return + + val movies = mutableListOf() + val shows = mutableListOf() + + items.forEach { item -> + val ids = parseIds(item.id) ?: return@forEach + val normalizedType = item.type.trim().lowercase() + + if (normalizedType == "movie" || normalizedType == "film") { + movies += TraktHistoryMovieRequestDto( + title = item.name.takeIf { it.isNotBlank() }, + year = parseYear(item.releaseInfo), + ids = ids, + ) + } else if (item.season != null && item.episode != null) { + shows += TraktHistoryShowRequestDto( + title = item.name.takeIf { it.isNotBlank() }, + year = parseYear(item.releaseInfo), + ids = ids, + seasons = listOf( + TraktHistorySeasonRequestDto( + number = item.season, + episodes = listOf( + TraktHistoryEpisodeRequestDto(number = item.episode), + ), + ), + ), + ) + } else { + shows += TraktHistoryShowRequestDto( + title = item.name.takeIf { it.isNotBlank() }, + year = parseYear(item.releaseInfo), + ids = ids, + ) + } + } + + val body = json.encodeToString( + TraktHistoryRemoveRequestDto( + movies = movies.takeIf { it.isNotEmpty() }, + shows = shows.takeIf { it.isNotEmpty() }, + ), + ) + + runCatching { + httpPostJsonWithHeaders( + url = "$BASE_URL/sync/history/remove", + body = body, + headers = headers, + ) + }.onFailure { e -> + if (e is CancellationException) throw e + log.w { "Failed to remove watched items from Trakt: ${e.message}" } + } + } + + // ── helpers ───────────────────────────────────────────────────────── + + private fun normalizeId(ids: TraktSyncIdsDto?): String? { + if (ids == null) return null + ids.imdb?.takeIf { it.isNotBlank() }?.let { return it } + ids.tmdb?.let { return "tmdb:$it" } + ids.trakt?.let { return "trakt:$it" } + return null + } + + private fun parseIds(rawId: String): TraktSyncIdsDto? { + val trimmed = rawId.trim() + if (trimmed.isBlank()) return null + + if (trimmed.startsWith("tt")) { + return TraktSyncIdsDto(imdb = trimmed.substringBefore(':')) + } + if (trimmed.startsWith("tmdb:", ignoreCase = true)) { + val value = trimmed.substringAfter(':').toIntOrNull() ?: return null + return TraktSyncIdsDto(tmdb = value) + } + if (trimmed.startsWith("trakt:", ignoreCase = true)) { + val value = trimmed.substringAfter(':').toIntOrNull() ?: return null + return TraktSyncIdsDto(trakt = value) + } + + val numeric = trimmed.substringBefore(':').toIntOrNull() + if (numeric != null) { + return TraktSyncIdsDto(trakt = numeric) + } + + return null + } + + private val yearRegex = Regex("(19|20)\\d{2}") + private fun parseYear(value: String?): Int? { + if (value.isNullOrBlank()) return null + return yearRegex.find(value)?.value?.toIntOrNull() + } + + private fun rankedTimestamp(isoDate: String?): Long { + val digits = isoDate + ?.filter(Char::isDigit) + ?.take(14) + ?.takeIf { it.length >= 8 } + ?.padEnd(14, '0') + ?.toLongOrNull() + return digits ?: 0L + } + + private fun epochMsToIso(epochMs: Long): String { + // Convert to a compact ISO 8601 UTC string. + // Input is stored as a ranked-timestamp (YYYYMMDDHHmmss) in some places, + // or a real epoch-ms. We only send when it looks like real epoch-ms. + if (epochMs <= 0L) return "unknown" + if (epochMs < 10_000_000_000L) { + // Looks like seconds-based or ranked timestamp — send unknown + return "unknown" + } + // Real epoch ms → simple ISO via arithmetic + val totalSeconds = epochMs / 1000 + val s = (totalSeconds % 60).toInt() + val m = ((totalSeconds / 60) % 60).toInt() + val h = ((totalSeconds / 3600) % 24).toInt() + var days = (totalSeconds / 86400).toInt() + + // Simple Gregorian conversion + var year = 1970 + while (true) { + val daysInYear = if (isLeapYear(year)) 366 else 365 + if (days < daysInYear) break + days -= daysInYear + year++ + } + val monthDays = if (isLeapYear(year)) { + intArrayOf(31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + } else { + intArrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + } + var month = 0 + while (month < 12 && days >= monthDays[month]) { + days -= monthDays[month] + month++ + } + month += 1 + val day = days + 1 + + return "${year.pad4()}-${month.pad2()}-${day.pad2()}T${h.pad2()}:${m.pad2()}:${s.pad2()}.000Z" + } + + private fun isLeapYear(y: Int): Boolean = (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0) + private fun Int.pad2(): String = if (this < 10) "0$this" else "$this" + private fun Int.pad4(): String = "$this".padStart(4, '0') +} + +// ── DTOs for pull (GET /sync/watched) ─────────────────────────────────── + +@Serializable +private data class TraktWatchedMovieDto( + @SerialName("plays") val plays: Int? = null, + @SerialName("last_watched_at") val lastWatchedAt: String? = null, + @SerialName("movie") val movie: TraktSyncMediaDto? = null, +) + +@Serializable +private data class TraktWatchedShowDto( + @SerialName("plays") val plays: Int? = null, + @SerialName("last_watched_at") val lastWatchedAt: String? = null, + @SerialName("show") val show: TraktSyncMediaDto? = null, + @SerialName("seasons") val seasons: List? = null, +) + +@Serializable +private data class TraktWatchedSeasonDto( + @SerialName("number") val number: Int? = null, + @SerialName("episodes") val episodes: List? = null, +) + +@Serializable +private data class TraktWatchedEpisodeDto( + @SerialName("number") val number: Int? = null, + @SerialName("plays") val plays: Int? = null, + @SerialName("last_watched_at") val lastWatchedAt: String? = null, +) + +@Serializable +private data class TraktSyncMediaDto( + @SerialName("title") val title: String? = null, + @SerialName("year") val year: Int? = null, + @SerialName("ids") val ids: TraktSyncIdsDto? = null, +) + +@Serializable +private data class TraktSyncIdsDto( + @SerialName("trakt") val trakt: Int? = null, + @SerialName("slug") val slug: String? = null, + @SerialName("imdb") val imdb: String? = null, + @SerialName("tmdb") val tmdb: Int? = null, + @SerialName("tvdb") val tvdb: Int? = null, +) + +// ── DTOs for push (POST /sync/history) ────────────────────────────────── + +@Serializable +private data class TraktHistoryAddRequestDto( + @SerialName("movies") val movies: List? = null, + @SerialName("shows") val shows: List? = null, +) + +@Serializable +private data class TraktHistoryMovieRequestDto( + @SerialName("title") val title: String? = null, + @SerialName("year") val year: Int? = null, + @SerialName("ids") val ids: TraktSyncIdsDto, + @SerialName("watched_at") val watchedAt: String? = null, +) + +@Serializable +private data class TraktHistoryShowRequestDto( + @SerialName("title") val title: String? = null, + @SerialName("year") val year: Int? = null, + @SerialName("ids") val ids: TraktSyncIdsDto, + @SerialName("seasons") val seasons: List? = null, +) + +@Serializable +private data class TraktHistorySeasonRequestDto( + @SerialName("number") val number: Int, + @SerialName("episodes") val episodes: List? = null, +) + +@Serializable +private data class TraktHistoryEpisodeRequestDto( + @SerialName("number") val number: Int, + @SerialName("watched_at") val watchedAt: String? = null, +) + +// ── DTOs for delete (POST /sync/history/remove) ───────────────────────── + +@Serializable +private data class TraktHistoryRemoveRequestDto( + @SerialName("movies") val movies: List? = null, + @SerialName("shows") val shows: List? = null, +)