mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
feat(library):pagination and trakt mapping improvements
This commit is contained in:
parent
3a9eeee917
commit
b3640d5280
7 changed files with 466 additions and 117 deletions
|
|
@ -18,6 +18,12 @@ data class LibraryItem(
|
|||
val imdbRating: String? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
val posterShape: PosterShape = PosterShape.Poster,
|
||||
val addonBaseUrl: String? = null,
|
||||
val listKeys: Set<String> = emptySet(),
|
||||
val traktRank: Int? = null,
|
||||
val imdbId: String? = null,
|
||||
val tmdbId: Int? = null,
|
||||
val traktId: Int? = null,
|
||||
val savedAtEpochMs: Long,
|
||||
)
|
||||
|
||||
|
|
@ -54,6 +60,7 @@ fun MetaDetails.toLibraryItem(savedAtEpochMs: Long): LibraryItem =
|
|||
imdbRating = imdbRating,
|
||||
genres = genres,
|
||||
posterShape = PosterShape.Poster,
|
||||
imdbId = id.takeIf { it.startsWith("tt") },
|
||||
savedAtEpochMs = savedAtEpochMs,
|
||||
)
|
||||
|
||||
|
|
@ -70,6 +77,7 @@ fun MetaPreview.toLibraryItem(savedAtEpochMs: Long): LibraryItem =
|
|||
imdbRating = imdbRating,
|
||||
genres = genres,
|
||||
posterShape = posterShape,
|
||||
imdbId = id.takeIf { it.startsWith("tt") },
|
||||
savedAtEpochMs = savedAtEpochMs,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
package com.nuvio.app.features.library
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.core.auth.AuthRepository
|
||||
import com.nuvio.app.core.auth.AuthState
|
||||
import com.nuvio.app.core.network.SupabaseProvider
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktLibraryRepository
|
||||
|
|
@ -15,7 +18,9 @@ import io.github.jan.supabase.postgrest.postgrest
|
|||
import io.github.jan.supabase.postgrest.rpc
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
|
@ -49,10 +54,13 @@ private data class LibrarySyncItem(
|
|||
@SerialName("release_info") val releaseInfo: String? = null,
|
||||
@SerialName("imdb_rating") val imdbRating: Float? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
@SerialName("addon_base_url") val addonBaseUrl: String? = null,
|
||||
@SerialName("added_at") val addedAt: Long = 0,
|
||||
)
|
||||
|
||||
object LibraryRepository {
|
||||
private const val PULL_PAGE_SIZE = 500
|
||||
|
||||
private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val log = Logger.withTag("LibraryRepository")
|
||||
private val json = Json {
|
||||
|
|
@ -66,6 +74,9 @@ object LibraryRepository {
|
|||
private var hasLoaded = false
|
||||
private var currentProfileId: Int = 1
|
||||
private var itemsById: MutableMap<String, LibraryItem> = mutableMapOf()
|
||||
private var isPullingNuvioSyncFromServer = false
|
||||
private var hasCompletedInitialNuvioSyncPull = false
|
||||
private var pushJob: Job? = null
|
||||
|
||||
init {
|
||||
syncScope.launch {
|
||||
|
|
@ -119,6 +130,9 @@ object LibraryRepository {
|
|||
|
||||
fun onProfileChanged(profileId: Int) {
|
||||
if (profileId == currentProfileId && hasLoaded) return
|
||||
pushJob?.cancel()
|
||||
isPullingNuvioSyncFromServer = false
|
||||
hasCompletedInitialNuvioSyncPull = false
|
||||
TraktSettingsRepository.onProfileChanged()
|
||||
loadFromDisk(profileId)
|
||||
TraktAuthRepository.onProfileChanged()
|
||||
|
|
@ -135,6 +149,9 @@ object LibraryRepository {
|
|||
hasLoaded = false
|
||||
currentProfileId = 1
|
||||
itemsById.clear()
|
||||
pushJob?.cancel()
|
||||
isPullingNuvioSyncFromServer = false
|
||||
hasCompletedInitialNuvioSyncPull = false
|
||||
TraktAuthRepository.clearLocalState()
|
||||
TraktLibraryRepository.clearLocalState()
|
||||
_uiState.value = LibraryUiState()
|
||||
|
|
@ -150,7 +167,7 @@ object LibraryRepository {
|
|||
val items = runCatching {
|
||||
json.decodeFromString<StoredLibraryPayload>(payload).items
|
||||
}.getOrDefault(emptyList())
|
||||
itemsById = items.associateBy { it.id }.toMutableMap()
|
||||
itemsById = items.associateBy { libraryItemKey(it.id, it.type) }.toMutableMap()
|
||||
}
|
||||
|
||||
publish()
|
||||
|
|
@ -162,24 +179,30 @@ object LibraryRepository {
|
|||
if (isTraktLibrarySourceActive()) {
|
||||
runCatching { TraktLibraryRepository.refreshNow() }
|
||||
.onFailure { e -> log.e(e) { "Failed to pull Trakt library" } }
|
||||
hasCompletedInitialNuvioSyncPull = true
|
||||
publish()
|
||||
return
|
||||
}
|
||||
|
||||
isPullingNuvioSyncFromServer = true
|
||||
runCatching {
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_limit", 500)
|
||||
put("p_offset", 0)
|
||||
val serverItems = pullAllLibrarySyncItems(profileId)
|
||||
if (serverItems.isEmpty() && itemsById.isNotEmpty()) {
|
||||
log.w { "Remote library is empty while local has ${itemsById.size} entries; preserving local library" }
|
||||
} else {
|
||||
itemsById = serverItems
|
||||
.map { it.toLibraryItem() }
|
||||
.associateBy { libraryItemKey(it.id, it.type) }
|
||||
.toMutableMap()
|
||||
persist()
|
||||
}
|
||||
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_library", params)
|
||||
val serverItems = result.decodeList<LibrarySyncItem>()
|
||||
itemsById = serverItems.map { it.toLibraryItem() }.associateBy { it.id }.toMutableMap()
|
||||
hasLoaded = true
|
||||
publish()
|
||||
persist()
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Failed to pull library from server" }
|
||||
}.also {
|
||||
hasCompletedInitialNuvioSyncPull = true
|
||||
isPullingNuvioSyncFromServer = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -195,8 +218,8 @@ object LibraryRepository {
|
|||
return
|
||||
}
|
||||
|
||||
if (itemsById.containsKey(item.id)) {
|
||||
remove(item.id)
|
||||
if (itemsById.containsKey(libraryItemKey(item.id, item.type))) {
|
||||
remove(item.id, item.type)
|
||||
} else {
|
||||
save(item)
|
||||
}
|
||||
|
|
@ -204,7 +227,7 @@ object LibraryRepository {
|
|||
|
||||
fun save(item: LibraryItem) {
|
||||
ensureLoaded()
|
||||
itemsById[item.id] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs())
|
||||
itemsById[libraryItemKey(item.id, item.type)] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs())
|
||||
publish()
|
||||
persist()
|
||||
pushToServer()
|
||||
|
|
@ -212,7 +235,18 @@ object LibraryRepository {
|
|||
|
||||
fun remove(id: String) {
|
||||
ensureLoaded()
|
||||
if (itemsById.remove(id) != null) {
|
||||
val before = itemsById.size
|
||||
itemsById.entries.removeAll { (_, item) -> item.id == id }
|
||||
if (itemsById.size != before) {
|
||||
publish()
|
||||
persist()
|
||||
pushToServer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun remove(id: String, type: String) {
|
||||
ensureLoaded()
|
||||
if (itemsById.remove(libraryItemKey(id, type)) != null) {
|
||||
publish()
|
||||
persist()
|
||||
pushToServer()
|
||||
|
|
@ -233,7 +267,11 @@ object LibraryRepository {
|
|||
return false
|
||||
}
|
||||
|
||||
return itemsById.containsKey(id)
|
||||
return if (type != null) {
|
||||
itemsById.containsKey(libraryItemKey(id, type))
|
||||
} else {
|
||||
itemsById.values.any { it.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
fun savedItem(id: String): LibraryItem? {
|
||||
|
|
@ -243,7 +281,7 @@ object LibraryRepository {
|
|||
return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
return itemsById[id]
|
||||
return itemsById.values.firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
fun libraryListTabs(): List<TraktListTab> {
|
||||
|
|
@ -259,7 +297,7 @@ object LibraryRepository {
|
|||
|
||||
suspend fun getMembershipSnapshot(item: LibraryItem): Map<String, Boolean> {
|
||||
ensureLoaded()
|
||||
val inLocal = itemsById.containsKey(item.id)
|
||||
val inLocal = itemsById.containsKey(libraryItemKey(item.id, item.type))
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
val traktMembership = TraktLibraryRepository.getMembershipSnapshot(item).listMembership
|
||||
return libraryMembershipWithLocal(
|
||||
|
|
@ -273,12 +311,12 @@ object LibraryRepository {
|
|||
suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map<String, Boolean>) {
|
||||
ensureLoaded()
|
||||
val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true
|
||||
val currentlyInLocal = itemsById.containsKey(item.id)
|
||||
val currentlyInLocal = itemsById.containsKey(libraryItemKey(item.id, item.type))
|
||||
if (localDesired != currentlyInLocal) {
|
||||
if (localDesired) {
|
||||
save(item)
|
||||
} else {
|
||||
remove(item.id)
|
||||
remove(item.id, item.type)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -305,10 +343,17 @@ object LibraryRepository {
|
|||
}
|
||||
|
||||
private fun pushToServer() {
|
||||
syncScope.launch {
|
||||
val authState = AuthRepository.state.value
|
||||
if (authState !is AuthState.Authenticated || authState.isAnonymous) return
|
||||
if (isPullingNuvioSyncFromServer || !hasCompletedInitialNuvioSyncPull) return
|
||||
|
||||
pushJob?.cancel()
|
||||
pushJob = syncScope.launch {
|
||||
delay(500)
|
||||
runCatching {
|
||||
val profileId = ProfileRepository.activeProfileId
|
||||
val syncItems = itemsById.values.map { it.toSyncItem() }
|
||||
if (syncItems.isEmpty()) return@runCatching
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_items", json.encodeToJsonElement(syncItems))
|
||||
|
|
@ -320,6 +365,27 @@ object LibraryRepository {
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun pullAllLibrarySyncItems(profileId: Int): List<LibrarySyncItem> {
|
||||
val allItems = mutableListOf<LibrarySyncItem>()
|
||||
var offset = 0
|
||||
|
||||
while (true) {
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_limit", PULL_PAGE_SIZE)
|
||||
put("p_offset", offset)
|
||||
}
|
||||
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_library", params)
|
||||
val page = result.decodeList<LibrarySyncItem>()
|
||||
allItems.addAll(page)
|
||||
|
||||
if (page.size < PULL_PAGE_SIZE) break
|
||||
offset += PULL_PAGE_SIZE
|
||||
}
|
||||
|
||||
return allItems
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
if (isTraktLibrarySourceActive()) {
|
||||
val traktState = TraktLibraryRepository.uiState.value
|
||||
|
|
@ -443,6 +509,8 @@ private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem(
|
|||
releaseInfo = releaseInfo,
|
||||
imdbRating = imdbRating?.toString(),
|
||||
genres = genres,
|
||||
posterShape = posterShape.toPosterShape(),
|
||||
addonBaseUrl = addonBaseUrl,
|
||||
savedAtEpochMs = addedAt,
|
||||
)
|
||||
|
||||
|
|
@ -451,14 +519,33 @@ private fun LibraryItem.toSyncItem(): LibrarySyncItem = LibrarySyncItem(
|
|||
contentType = type,
|
||||
name = name,
|
||||
poster = poster,
|
||||
posterShape = posterShape.toSyncName(),
|
||||
background = banner,
|
||||
description = description,
|
||||
releaseInfo = releaseInfo,
|
||||
imdbRating = imdbRating?.toFloatOrNull(),
|
||||
genres = genres,
|
||||
addonBaseUrl = addonBaseUrl,
|
||||
addedAt = savedAtEpochMs,
|
||||
)
|
||||
|
||||
private fun libraryItemKey(id: String, type: String): String =
|
||||
"${type.trim().lowercase()}:${id.trim()}"
|
||||
|
||||
private fun String.toPosterShape(): PosterShape =
|
||||
when (trim().uppercase()) {
|
||||
"LANDSCAPE" -> PosterShape.Landscape
|
||||
"SQUARE" -> PosterShape.Square
|
||||
else -> PosterShape.Poster
|
||||
}
|
||||
|
||||
private fun PosterShape.toSyncName(): String =
|
||||
when (this) {
|
||||
PosterShape.Poster -> "POSTER"
|
||||
PosterShape.Square -> "SQUARE"
|
||||
PosterShape.Landscape -> "LANDSCAPE"
|
||||
}
|
||||
|
||||
internal fun String.toLibraryDisplayTitle(): String {
|
||||
val normalized = trim()
|
||||
if (normalized.isBlank()) return "Other"
|
||||
|
|
|
|||
|
|
@ -51,3 +51,6 @@ internal fun extractTraktYear(value: String?): Int? {
|
|||
if (value.isNullOrBlank()) return null
|
||||
return Regex("(\\d{4})").find(value)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
}
|
||||
|
||||
internal fun TraktExternalIds.hasAnyId(): Boolean =
|
||||
trakt != null || !imdb.isNullOrBlank() || tmdb != null || !slug.isNullOrBlank()
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.features.addons.httpGetTextWithHeaders
|
||||
import com.nuvio.app.features.addons.httpPostJsonWithHeaders
|
||||
import com.nuvio.app.features.addons.RawHttpResponse
|
||||
import com.nuvio.app.features.addons.httpRequestRaw
|
||||
import com.nuvio.app.features.library.LibraryItem
|
||||
import com.nuvio.app.features.tmdb.TmdbService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -32,7 +31,8 @@ import kotlinx.serialization.json.Json
|
|||
private const val BASE_URL = "https://api.trakt.tv"
|
||||
private const val WATCHLIST_KEY = "trakt:watchlist"
|
||||
private const val PERSONAL_LIST_PREFIX = "trakt:list:"
|
||||
private const val LIST_FETCH_CONCURRENCY = 4
|
||||
private const val LIST_FETCH_CONCURRENCY = 3
|
||||
private const val TRAKT_PAGE_LIMIT = 1_000
|
||||
private const val SNAPSHOT_CACHE_TTL_MS = 60_000L
|
||||
private const val LIST_TABS_CACHE_TTL_MS = 60_000L
|
||||
private const val FORCE_REFRESH_DEDUP_MS = 10_000L
|
||||
|
|
@ -270,11 +270,11 @@ object TraktLibraryRepository {
|
|||
val resolvedItem = resolveOptimisticItem(state, item)
|
||||
updatedEntriesByList[listKey] = listOf(resolvedItem) +
|
||||
updatedEntriesByList[listKey].orEmpty().filterNot {
|
||||
contentKey(it.id, it.type) == contentKey
|
||||
allContentKeys(it).contains(contentKey)
|
||||
}
|
||||
} else {
|
||||
updatedEntriesByList[listKey] = updatedEntriesByList[listKey].orEmpty().filterNot {
|
||||
contentKey(it.id, it.type) == contentKey
|
||||
allContentKeys(it).contains(contentKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -289,8 +289,9 @@ object TraktLibraryRepository {
|
|||
state: TraktLibraryUiState,
|
||||
item: LibraryItem,
|
||||
): LibraryItem {
|
||||
val itemKey = contentKey(item.id, item.type)
|
||||
val existing = state.allItems.firstOrNull {
|
||||
contentKey(it.id, it.type) == contentKey(item.id, item.type)
|
||||
allContentKeys(it).contains(itemKey)
|
||||
}
|
||||
val base = existing ?: item
|
||||
val savedAt = base.savedAtEpochMs.takeIf { it > 0L }
|
||||
|
|
@ -312,21 +313,33 @@ object TraktLibraryRepository {
|
|||
val membershipByContent = mutableMapOf<String, MutableSet<String>>()
|
||||
normalizedEntriesByList.forEach { (listKey, entries) ->
|
||||
entries.forEach { entry ->
|
||||
membershipByContent
|
||||
.getOrPut(contentKey(entry.id, entry.type)) { mutableSetOf() }
|
||||
.add(listKey)
|
||||
allContentKeys(entry).forEach { key ->
|
||||
membershipByContent
|
||||
.getOrPut(key) { mutableSetOf() }
|
||||
.add(listKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val allItems = normalizedEntriesByList.values
|
||||
val entriesWithMembership = normalizedEntriesByList.mapValues { (_, entries) ->
|
||||
entries.map { entry ->
|
||||
entry.copy(listKeys = membershipByContent[contentKey(entry.id, entry.type)].orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
val allItemsByContent = linkedMapOf<String, LibraryItem>()
|
||||
entriesWithMembership.values
|
||||
.flatten()
|
||||
.distinctBy { contentKey(it.id, it.type) }
|
||||
.sortedByDescending { it.savedAtEpochMs }
|
||||
.forEach { entry ->
|
||||
val key = contentKey(entry.id, entry.type)
|
||||
allItemsByContent[key] = entry.copy(listKeys = membershipByContent[key].orEmpty())
|
||||
}
|
||||
|
||||
return TraktLibraryUiState(
|
||||
listTabs = listTabs,
|
||||
entriesByList = normalizedEntriesByList,
|
||||
allItems = allItems,
|
||||
entriesByList = entriesWithMembership,
|
||||
allItems = allItemsByContent.values.toList().sortedByDescending { it.savedAtEpochMs },
|
||||
membershipByContent = membershipByContent.mapValues { it.value.toSet() },
|
||||
isLoading = false,
|
||||
hasLoaded = true,
|
||||
|
|
@ -366,26 +379,9 @@ object TraktLibraryRepository {
|
|||
},
|
||||
)
|
||||
|
||||
val membershipByContent = mutableMapOf<String, MutableSet<String>>()
|
||||
entriesByList.forEach { (listKey, entries) ->
|
||||
entries.forEach { entry ->
|
||||
membershipByContent
|
||||
.getOrPut(contentKey(entry.id, entry.type)) { mutableSetOf() }
|
||||
.add(listKey)
|
||||
}
|
||||
}
|
||||
|
||||
val allItems = entriesByList.values
|
||||
.flatten()
|
||||
.distinctBy { contentKey(it.id, it.type) }
|
||||
.sortedByDescending { it.savedAtEpochMs }
|
||||
|
||||
TraktLibraryUiState(
|
||||
rebuildUiState(
|
||||
listTabs = allTabs,
|
||||
entriesByList = entriesByList,
|
||||
allItems = allItems,
|
||||
membershipByContent = membershipByContent.mapValues { it.value.toSet() },
|
||||
hasLoaded = true,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -441,6 +437,8 @@ object TraktLibraryRepository {
|
|||
key = WATCHLIST_KEY,
|
||||
title = getString(Res.string.trakt_watchlist),
|
||||
type = TraktListType.WATCHLIST,
|
||||
sortBy = "rank",
|
||||
sortHow = "asc",
|
||||
),
|
||||
)
|
||||
return watchlistTabs + fetchPersonalLists(headers)
|
||||
|
|
@ -465,12 +463,12 @@ object TraktLibraryRepository {
|
|||
}
|
||||
val personalEntries = personalTabs.associate { tab ->
|
||||
tab.key to async {
|
||||
val listId = tab.traktListId?.toString().orEmpty()
|
||||
val listId = tab.traktListId?.toString() ?: tab.slug.orEmpty()
|
||||
if (listId.isBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
listSemaphore.withPermit {
|
||||
fetchPersonalListItems(headers, listId)
|
||||
fetchPersonalListItems(headers, tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -495,86 +493,184 @@ object TraktLibraryRepository {
|
|||
}
|
||||
|
||||
private suspend fun fetchPersonalLists(headers: Map<String, String>): List<TraktListTab> {
|
||||
val payload = httpGetTextWithHeaders(
|
||||
url = "$BASE_URL/users/me/lists",
|
||||
headers = headers,
|
||||
)
|
||||
val payload = getJson(headers = headers, url = "$BASE_URL/users/me/lists")
|
||||
val lists = json.decodeFromString<List<TraktListSummaryDto>>(payload)
|
||||
return lists.mapNotNull { list ->
|
||||
val traktId = list.ids?.trakt ?: return@mapNotNull null
|
||||
TraktListTab(
|
||||
key = "$PERSONAL_LIST_PREFIX$traktId",
|
||||
title = list.name?.ifBlank { null } ?: getString(Res.string.trakt_list_fallback_title, traktId),
|
||||
type = TraktListType.PERSONAL,
|
||||
traktListId = traktId,
|
||||
slug = list.ids.slug,
|
||||
description = list.description,
|
||||
)
|
||||
}
|
||||
return lists
|
||||
.filter { it.type.equals("personal", ignoreCase = true) }
|
||||
.mapNotNull { list ->
|
||||
val traktId = list.ids?.trakt
|
||||
val listIdPath = traktId?.toString() ?: list.ids?.slug ?: return@mapNotNull null
|
||||
val fallbackTitle = traktId?.let { getString(Res.string.trakt_list_fallback_title, it) }
|
||||
?: "List $listIdPath"
|
||||
TraktListTab(
|
||||
key = "$PERSONAL_LIST_PREFIX$listIdPath",
|
||||
title = list.name?.ifBlank { null } ?: fallbackTitle,
|
||||
type = TraktListType.PERSONAL,
|
||||
traktListId = traktId,
|
||||
slug = list.ids?.slug,
|
||||
description = list.description,
|
||||
privacy = TraktListPrivacy.fromApi(list.privacy),
|
||||
sortBy = list.sortBy,
|
||||
sortHow = list.sortHow,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchWatchlistItems(headers: Map<String, String>): List<LibraryItem> {
|
||||
val (moviesPayload, showsPayload) = coroutineScope {
|
||||
val (movieItems, showItems) = coroutineScope {
|
||||
val moviesDeferred = async {
|
||||
httpGetTextWithHeaders(
|
||||
url = "$BASE_URL/sync/watchlist/movies?extended=full,images",
|
||||
fetchPagedListItems(
|
||||
headers = headers,
|
||||
urlForPage = { page ->
|
||||
"$BASE_URL/users/me/watchlist/movies/rank?extended=full,images&page=$page&limit=$TRAKT_PAGE_LIMIT"
|
||||
},
|
||||
)
|
||||
}
|
||||
val showsDeferred = async {
|
||||
httpGetTextWithHeaders(
|
||||
url = "$BASE_URL/sync/watchlist/shows?extended=full,images",
|
||||
fetchPagedListItems(
|
||||
headers = headers,
|
||||
urlForPage = { page ->
|
||||
"$BASE_URL/users/me/watchlist/shows/rank?extended=full,images&page=$page&limit=$TRAKT_PAGE_LIMIT"
|
||||
},
|
||||
)
|
||||
}
|
||||
moviesDeferred.await() to showsDeferred.await()
|
||||
}
|
||||
val movieItems = json.decodeFromString<List<TraktListItemDto>>(moviesPayload)
|
||||
val showItems = json.decodeFromString<List<TraktListItemDto>>(showsPayload)
|
||||
return (movieItems + showItems)
|
||||
.mapNotNull(::mapToLibraryItem)
|
||||
.sortedByDescending { it.savedAtEpochMs }
|
||||
.sortedWith(
|
||||
compareBy<LibraryItem> { it.traktRank ?: Int.MAX_VALUE }
|
||||
.thenByDescending { it.savedAtEpochMs },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun fetchPersonalListItems(
|
||||
headers: Map<String, String>,
|
||||
listId: String,
|
||||
tab: TraktListTab,
|
||||
): List<LibraryItem> {
|
||||
val (moviesPayload, showsPayload) = coroutineScope {
|
||||
val listId = tab.traktListId?.toString() ?: tab.slug.orEmpty()
|
||||
if (listId.isBlank()) return emptyList()
|
||||
|
||||
val sortQuery = buildSortQuery(tab.sortBy, tab.sortHow)
|
||||
val (movieItems, showItems) = coroutineScope {
|
||||
val moviesDeferred = async {
|
||||
httpGetTextWithHeaders(
|
||||
url = "$BASE_URL/users/me/lists/$listId/items/movies?extended=full,images",
|
||||
fetchPagedListItems(
|
||||
headers = headers,
|
||||
urlForPage = { page ->
|
||||
"$BASE_URL/users/me/lists/$listId/items/movie?extended=full,images&page=$page&limit=$TRAKT_PAGE_LIMIT$sortQuery"
|
||||
},
|
||||
)
|
||||
}
|
||||
val showsDeferred = async {
|
||||
httpGetTextWithHeaders(
|
||||
url = "$BASE_URL/users/me/lists/$listId/items/shows?extended=full,images",
|
||||
fetchPagedListItems(
|
||||
headers = headers,
|
||||
urlForPage = { page ->
|
||||
"$BASE_URL/users/me/lists/$listId/items/show?extended=full,images&page=$page&limit=$TRAKT_PAGE_LIMIT$sortQuery"
|
||||
},
|
||||
)
|
||||
}
|
||||
moviesDeferred.await() to showsDeferred.await()
|
||||
}
|
||||
|
||||
val movieItems = json.decodeFromString<List<TraktListItemDto>>(moviesPayload)
|
||||
val showItems = json.decodeFromString<List<TraktListItemDto>>(showsPayload)
|
||||
return (movieItems + showItems)
|
||||
.mapNotNull(::mapToLibraryItem)
|
||||
.sortedByDescending { it.savedAtEpochMs }
|
||||
.sortedWith(
|
||||
compareBy<LibraryItem> { it.traktRank ?: Int.MAX_VALUE }
|
||||
.thenByDescending { it.savedAtEpochMs },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun fetchPagedListItems(
|
||||
headers: Map<String, String>,
|
||||
urlForPage: (Int) -> String,
|
||||
): List<TraktListItemDto> {
|
||||
val items = mutableListOf<TraktListItemDto>()
|
||||
var page = 1
|
||||
|
||||
while (true) {
|
||||
val response = getJsonResponse(headers = headers, url = urlForPage(page))
|
||||
val pageItems = json.decodeFromString<List<TraktListItemDto>>(response.body)
|
||||
items.addAll(pageItems)
|
||||
|
||||
val pageCount = response.headerInt("x-pagination-page-count") ?: page
|
||||
if (page >= pageCount || pageItems.size < TRAKT_PAGE_LIMIT) break
|
||||
page += 1
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private suspend fun getJson(headers: Map<String, String>, url: String): String =
|
||||
getJsonResponse(headers, url).body
|
||||
|
||||
private suspend fun getJsonResponse(
|
||||
headers: Map<String, String>,
|
||||
url: String,
|
||||
): RawHttpResponse {
|
||||
val response = httpRequestRaw(
|
||||
method = "GET",
|
||||
url = url,
|
||||
headers = mapOf("Accept" to "application/json") + headers,
|
||||
body = "",
|
||||
followRedirects = true,
|
||||
)
|
||||
if (response.status !in 200..299) {
|
||||
throw IllegalStateException(errorMessageForStatus(response.status, "Trakt request failed"))
|
||||
}
|
||||
if (response.body.isBlank()) {
|
||||
throw IllegalStateException("Empty response body")
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private suspend fun postJson(
|
||||
url: String,
|
||||
body: String,
|
||||
headers: Map<String, String>,
|
||||
): RawHttpResponse {
|
||||
val response = httpRequestRaw(
|
||||
method = "POST",
|
||||
url = url,
|
||||
headers = mapOf(
|
||||
"Accept" to "application/json",
|
||||
"Content-Type" to "application/json",
|
||||
) + headers,
|
||||
body = body,
|
||||
followRedirects = true,
|
||||
)
|
||||
if (response.status !in 200..299) {
|
||||
throw IllegalStateException(errorMessageForStatus(response.status, "Trakt request failed"))
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private fun RawHttpResponse.headerInt(name: String): Int? =
|
||||
headers.entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }
|
||||
?.value
|
||||
?.substringBefore(',')
|
||||
?.trim()
|
||||
?.toIntOrNull()
|
||||
|
||||
private fun buildSortQuery(sortBy: String?, sortHow: String?): String = buildString {
|
||||
sortBy?.takeIf { it.isNotBlank() }?.let { append("&sort_by=").append(it) }
|
||||
sortHow?.takeIf { it.isNotBlank() }?.let { append("&sort_how=").append(it) }
|
||||
}
|
||||
|
||||
private suspend fun addToWatchlist(headers: Map<String, String>, item: LibraryItem) {
|
||||
val body = buildMutationBody(item) ?: return
|
||||
httpPostJsonWithHeaders(
|
||||
val body = buildMutationBody(item)
|
||||
val response = postJson(
|
||||
url = "$BASE_URL/sync/watchlist",
|
||||
body = body,
|
||||
headers = headers,
|
||||
)
|
||||
if (!isSuccessfulAddResponse(response.body)) {
|
||||
throw IllegalStateException(errorMessageForStatus(response.status, "Failed to add to Trakt watchlist"))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun removeFromWatchlist(headers: Map<String, String>, item: LibraryItem) {
|
||||
val body = buildMutationBody(item) ?: return
|
||||
httpPostJsonWithHeaders(
|
||||
val body = buildMutationBody(item)
|
||||
postJson(
|
||||
url = "$BASE_URL/sync/watchlist/remove",
|
||||
body = body,
|
||||
headers = headers,
|
||||
|
|
@ -582,26 +678,32 @@ object TraktLibraryRepository {
|
|||
}
|
||||
|
||||
private suspend fun addToPersonalList(headers: Map<String, String>, listId: String, item: LibraryItem) {
|
||||
val body = buildMutationBody(item) ?: return
|
||||
httpPostJsonWithHeaders(
|
||||
val body = buildMutationBody(item)
|
||||
val response = postJson(
|
||||
url = "$BASE_URL/users/me/lists/$listId/items",
|
||||
body = body,
|
||||
headers = headers,
|
||||
)
|
||||
if (!isSuccessfulAddResponse(response.body)) {
|
||||
throw IllegalStateException(errorMessageForStatus(response.status, "Failed to add to Trakt list"))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun removeFromPersonalList(headers: Map<String, String>, listId: String, item: LibraryItem) {
|
||||
val body = buildMutationBody(item) ?: return
|
||||
httpPostJsonWithHeaders(
|
||||
val body = buildMutationBody(item)
|
||||
postJson(
|
||||
url = "$BASE_URL/users/me/lists/$listId/items/remove",
|
||||
body = body,
|
||||
headers = headers,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun buildMutationBody(item: LibraryItem): String? {
|
||||
private suspend fun buildMutationBody(item: LibraryItem): String {
|
||||
val type = normalizeType(item.type)
|
||||
val ids = resolveIds(item)
|
||||
if (!ids.hasAnyId()) {
|
||||
throw IllegalStateException("Missing compatible Trakt IDs")
|
||||
}
|
||||
|
||||
val request = if (type == "movie") {
|
||||
TraktListItemsMutationRequestDto(
|
||||
|
|
@ -627,36 +729,41 @@ object TraktLibraryRepository {
|
|||
return json.encodeToString(request)
|
||||
}
|
||||
|
||||
private suspend fun resolveIds(item: LibraryItem): TraktIdsDto? {
|
||||
val rawId = item.id.trim()
|
||||
val imdb = imdbRegex.find(rawId)?.value
|
||||
val tmdbFromId = rawId.removePrefix("tmdb:").toIntOrNull()
|
||||
val traktFromId = rawId.removePrefix("trakt:").toIntOrNull()
|
||||
|
||||
val normalizedType = if (normalizeType(item.type) == "movie") "movie" else "tv"
|
||||
val resolvedImdb = imdb ?: tmdbFromId?.let { TmdbService.tmdbToImdb(it, normalizedType) }
|
||||
|
||||
if (resolvedImdb.isNullOrBlank() && tmdbFromId == null && traktFromId == null) {
|
||||
return null
|
||||
}
|
||||
private fun resolveIds(item: LibraryItem): TraktIdsDto {
|
||||
val parsed = parseTraktContentIds(item.id)
|
||||
|
||||
return TraktIdsDto(
|
||||
imdb = resolvedImdb,
|
||||
tmdb = tmdbFromId,
|
||||
trakt = traktFromId,
|
||||
imdb = item.imdbId ?: parsed.imdb,
|
||||
tmdb = item.tmdbId ?: parsed.tmdb,
|
||||
trakt = item.traktId ?: parsed.trakt,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapToLibraryItem(item: TraktListItemDto): LibraryItem? {
|
||||
val movie = item.movie
|
||||
val show = item.show
|
||||
val media = movie ?: show ?: return null
|
||||
val type = if (movie != null) "movie" else "series"
|
||||
val type = when (item.type?.lowercase()) {
|
||||
"movie" -> "movie"
|
||||
"show" -> "series"
|
||||
else -> return null
|
||||
}
|
||||
val media = when (type) {
|
||||
"movie" -> movie
|
||||
else -> show
|
||||
} ?: return null
|
||||
val ids = media.ids
|
||||
|
||||
val id = ids?.imdb
|
||||
?: ids?.tmdb?.let { "tmdb:$it" }
|
||||
?: ids?.trakt?.let { "trakt:$it" }
|
||||
val fallbackId = when {
|
||||
ids?.trakt != null -> "trakt:${ids.trakt}"
|
||||
item.id != null -> "trakt-item:${item.id}"
|
||||
!media.title.isNullOrBlank() -> "${type}:${media.title.lowercase()}:${media.year ?: 0}"
|
||||
else -> null
|
||||
} ?: return null
|
||||
|
||||
val id = normalizeTraktContentId(
|
||||
ids?.toExternalIds(),
|
||||
fallback = fallbackId,
|
||||
).takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
|
||||
val poster = media.images.traktBestPosterUrl()
|
||||
|
|
@ -679,12 +786,50 @@ object TraktLibraryRepository {
|
|||
releaseInfo = media.year?.toString(),
|
||||
imdbRating = media.rating?.toString(),
|
||||
genres = media.genres.orEmpty(),
|
||||
traktRank = item.rank,
|
||||
imdbId = ids?.imdb?.takeIf { it.isNotBlank() },
|
||||
tmdbId = ids?.tmdb,
|
||||
traktId = ids?.trakt,
|
||||
savedAtEpochMs = savedAt,
|
||||
)
|
||||
}
|
||||
|
||||
private fun contentKey(itemId: String, itemType: String): String =
|
||||
"${normalizeType(itemType)}:${itemId.trim()}"
|
||||
private fun contentKey(itemId: String, itemType: String): String {
|
||||
val parsed = parseTraktContentIds(itemId)
|
||||
val normalizedId = normalizeTraktContentId(parsed, fallback = itemId.trim())
|
||||
val stableId = normalizedId.ifBlank { itemId.trim() }
|
||||
return "${normalizeType(itemType)}:$stableId"
|
||||
}
|
||||
|
||||
private fun allContentKeys(entry: LibraryItem): Set<String> {
|
||||
val type = normalizeType(entry.type)
|
||||
val keys = mutableSetOf(contentKey(entry.id, entry.type))
|
||||
entry.imdbId?.takeIf { it.isNotBlank() }?.let { keys.add("$type:$it") }
|
||||
entry.tmdbId?.let { keys.add("$type:tmdb:$it") }
|
||||
entry.traktId?.let { keys.add("$type:trakt:$it") }
|
||||
return keys
|
||||
}
|
||||
|
||||
private fun isSuccessfulAddResponse(body: String): Boolean {
|
||||
if (body.isBlank()) return false
|
||||
val parsed = runCatching {
|
||||
json.decodeFromString<TraktListItemsMutationResponseDto>(body)
|
||||
}.getOrNull() ?: return false
|
||||
val added = parsed.added
|
||||
val existing = parsed.existing
|
||||
val addCount = (added?.movies ?: 0) + (added?.shows ?: 0) + (added?.seasons ?: 0) + (added?.episodes ?: 0)
|
||||
val existingCount = (existing?.movies ?: 0) + (existing?.shows ?: 0) + (existing?.seasons ?: 0) + (existing?.episodes ?: 0)
|
||||
return addCount > 0 || existingCount > 0
|
||||
}
|
||||
|
||||
private fun errorMessageForStatus(status: Int, defaultMessage: String): String =
|
||||
when (status) {
|
||||
401, 403 -> "Trakt authorization expired"
|
||||
404 -> "Trakt list not found"
|
||||
420 -> "Trakt list limit reached"
|
||||
429 -> "Trakt rate limit reached"
|
||||
else -> "$defaultMessage ($status)"
|
||||
}
|
||||
|
||||
private fun normalizeType(type: String): String {
|
||||
val normalized = type.trim().lowercase()
|
||||
|
|
@ -701,7 +846,15 @@ object TraktLibraryRepository {
|
|||
return yearText.toIntOrNull()
|
||||
}
|
||||
|
||||
private val imdbRegex = Regex("tt\\d+")
|
||||
private fun TraktIdsDto.hasAnyId(): Boolean =
|
||||
trakt != null || !imdb.isNullOrBlank() || tmdb != null
|
||||
|
||||
private fun TraktIdsDto.toExternalIds(): TraktExternalIds =
|
||||
TraktExternalIds(
|
||||
trakt = trakt,
|
||||
imdb = imdb,
|
||||
tmdb = tmdb,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
|
@ -714,6 +867,10 @@ private data class StoredTraktLibraryPayload(
|
|||
private data class TraktListSummaryDto(
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
val privacy: String? = null,
|
||||
val type: String? = null,
|
||||
@SerialName("sort_by") val sortBy: String? = null,
|
||||
@SerialName("sort_how") val sortHow: String? = null,
|
||||
val ids: TraktListIdsDto? = null,
|
||||
)
|
||||
|
||||
|
|
@ -725,7 +882,10 @@ private data class TraktListIdsDto(
|
|||
|
||||
@Serializable
|
||||
private data class TraktListItemDto(
|
||||
val rank: Int? = null,
|
||||
val id: Long? = null,
|
||||
@SerialName("listed_at") val listedAt: String? = null,
|
||||
val type: String? = null,
|
||||
val movie: TraktMediaDto? = null,
|
||||
val show: TraktMediaDto? = null,
|
||||
)
|
||||
|
|
@ -754,6 +914,21 @@ private data class TraktListItemsMutationRequestDto(
|
|||
val shows: List<TraktListShowRequestItemDto>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TraktListItemsMutationResponseDto(
|
||||
val added: TraktListMutationCountDto? = null,
|
||||
val existing: TraktListMutationCountDto? = null,
|
||||
val deleted: TraktListMutationCountDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TraktListMutationCountDto(
|
||||
val movies: Int? = null,
|
||||
val shows: Int? = null,
|
||||
val seasons: Int? = null,
|
||||
val episodes: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TraktListMovieRequestItemDto(
|
||||
val title: String? = null,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,19 @@ enum class TraktListType {
|
|||
PERSONAL,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class TraktListPrivacy(val apiValue: String) {
|
||||
PRIVATE("private"),
|
||||
LINK("link"),
|
||||
FRIENDS("friends"),
|
||||
PUBLIC("public");
|
||||
|
||||
companion object {
|
||||
fun fromApi(value: String?): TraktListPrivacy =
|
||||
entries.firstOrNull { it.apiValue.equals(value, ignoreCase = true) } ?: PRIVATE
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TraktListTab(
|
||||
val key: String,
|
||||
|
|
@ -54,6 +67,9 @@ data class TraktListTab(
|
|||
val traktListId: Long? = null,
|
||||
val slug: String? = null,
|
||||
val description: String? = null,
|
||||
val privacy: TraktListPrivacy? = null,
|
||||
val sortBy: String? = null,
|
||||
val sortHow: String? = null,
|
||||
)
|
||||
|
||||
data class TraktMembershipSnapshot(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.nuvio.app.features.library
|
||||
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.trakt.TraktListTab
|
||||
import com.nuvio.app.features.trakt.TraktListType
|
||||
import kotlin.test.Test
|
||||
|
|
@ -40,6 +42,29 @@ class LibraryRepositoryTest {
|
|||
assertEquals("banner", preview.banner)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata mappings keep imdb ids for Trakt-compatible sync`() {
|
||||
val previewItem = MetaPreview(
|
||||
id = "tt1234567",
|
||||
type = "movie",
|
||||
name = "Movie",
|
||||
).toLibraryItem(savedAtEpochMs = 1L)
|
||||
val detailsItem = MetaDetails(
|
||||
id = "tt7654321",
|
||||
type = "series",
|
||||
name = "Show",
|
||||
).toLibraryItem(savedAtEpochMs = 2L)
|
||||
val tmdbOnlyItem = MetaPreview(
|
||||
id = "tmdb:42",
|
||||
type = "movie",
|
||||
name = "TMDB",
|
||||
).toLibraryItem(savedAtEpochMs = 3L)
|
||||
|
||||
assertEquals("tt1234567", previewItem.imdbId)
|
||||
assertEquals("tt7654321", detailsItem.imdbId)
|
||||
assertEquals(null, tmdbOnlyItem.imdbId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library tabs include local Nuvio library before Trakt tabs`() {
|
||||
val traktTab = TraktListTab(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class TraktIdUtilsTest {
|
||||
|
||||
@Test
|
||||
fun `parse content ids supports imdb tmdb trakt and numeric aliases`() {
|
||||
assertEquals(TraktExternalIds(imdb = "tt1234567"), parseTraktContentIds("tt1234567:movie"))
|
||||
assertEquals(TraktExternalIds(tmdb = 42), parseTraktContentIds("tmdb:42"))
|
||||
assertEquals(TraktExternalIds(trakt = 99), parseTraktContentIds("trakt:99"))
|
||||
assertEquals(TraktExternalIds(trakt = 77), parseTraktContentIds("77:show"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalize content id prefers imdb then tmdb then trakt then fallback`() {
|
||||
assertEquals(
|
||||
"tt1234567",
|
||||
normalizeTraktContentId(TraktExternalIds(trakt = 99, imdb = "tt1234567", tmdb = 42), fallback = "fallback"),
|
||||
)
|
||||
assertEquals("tmdb:42", normalizeTraktContentId(TraktExternalIds(trakt = 99, tmdb = 42)))
|
||||
assertEquals("trakt:99", normalizeTraktContentId(TraktExternalIds(trakt = 99)))
|
||||
assertEquals("fallback", normalizeTraktContentId(TraktExternalIds(), fallback = "fallback"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `external ids report slug-only identifiers as present`() {
|
||||
assertTrue(TraktExternalIds(slug = "favorites").hasAnyId())
|
||||
assertTrue(TraktExternalIds(tmdb = 42).hasAnyId())
|
||||
assertFalse(TraktExternalIds().hasAnyId())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue