feat(trakt): implement TraktWatchedSyncAdapter for syncing watched items

This commit is contained in:
tapframe 2026-04-03 13:09:26 +05:30
parent 184460d47d
commit 5552d451ce
2 changed files with 457 additions and 3 deletions

View file

@ -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<String, WatchedItem> = 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" }
}

View file

@ -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<WatchedItem> {
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<List<TraktWatchedMovieDto>>(moviesPayload)
}.getOrDefault(emptyList())
val showItems = runCatching {
json.decodeFromString<List<TraktWatchedShowDto>>(showsPayload)
}.getOrDefault(emptyList())
val result = mutableListOf<WatchedItem>()
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<WatchedItem>,
) {
if (items.isEmpty()) return
val headers = TraktAuthRepository.authorizedHeaders() ?: return
val movies = mutableListOf<TraktHistoryMovieRequestDto>()
val shows = mutableListOf<TraktHistoryShowRequestDto>()
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<WatchedItem>,
) {
if (items.isEmpty()) return
val headers = TraktAuthRepository.authorizedHeaders() ?: return
val movies = mutableListOf<TraktHistoryMovieRequestDto>()
val shows = mutableListOf<TraktHistoryShowRequestDto>()
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<TraktWatchedSeasonDto>? = null,
)
@Serializable
private data class TraktWatchedSeasonDto(
@SerialName("number") val number: Int? = null,
@SerialName("episodes") val episodes: List<TraktWatchedEpisodeDto>? = 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<TraktHistoryMovieRequestDto>? = null,
@SerialName("shows") val shows: List<TraktHistoryShowRequestDto>? = 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<TraktHistorySeasonRequestDto>? = null,
)
@Serializable
private data class TraktHistorySeasonRequestDto(
@SerialName("number") val number: Int,
@SerialName("episodes") val episodes: List<TraktHistoryEpisodeRequestDto>? = 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<TraktHistoryMovieRequestDto>? = null,
@SerialName("shows") val shows: List<TraktHistoryShowRequestDto>? = null,
)