Skip to content

Commit

Permalink
Show notes: send to Trakt
Browse files Browse the repository at this point in the history
  • Loading branch information
UweTrottmann committed Nov 7, 2024
1 parent cc518e7 commit 3c65f49
Show file tree
Hide file tree
Showing 5 changed files with 184 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,15 @@ interface SgShow2Helper {
@Query("SELECT _id, series_tmdb_id, series_user_note FROM sg_show WHERE _id = :id")
fun getShowWithNote(id: Long): SgShow2WithNote?

@Query("UPDATE sg_show SET series_user_note = :note WHERE _id = :id")
fun updateUserNote(id: Long, note: String?)
@Query("UPDATE sg_show SET series_user_note = :note, series_user_note_trakt_id = :traktId WHERE _id = :id")
fun updateUserNote(id: Long, note: String?, traktId: Long?)

data class NoteUpdate(val text: String?, val traktId: Long?)

@Transaction
fun updateUserNotes(notesById: Map<Long, String?>) {
fun updateUserNotes(notesById: Map<Long, NoteUpdate>) {
for (entry in notesById) {
updateUserNote(entry.key, entry.value)
updateUserNote(entry.key, entry.value.text, entry.value.traktId)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.battlelancer.seriesguide.SgApp
import com.battlelancer.seriesguide.provider.SgRoomDatabase
import com.battlelancer.seriesguide.shows.database.SgShow2
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
Expand All @@ -24,6 +23,7 @@ class EditNoteDialogViewModel(application: Application, private val showId: Long

data class EditNoteDialogUiState(
val noteText: String? = null,
val noteTraktId: Long? = null,
val isEditingEnabled: Boolean = false,
val isNoteSaved: Boolean = false
)
Expand All @@ -39,6 +39,7 @@ class EditNoteDialogViewModel(application: Application, private val showId: Long
uiState.update {
it.copy(
noteText = show.userNote,
noteTraktId = show.userNoteTraktId,
isEditingEnabled = true
)
}
Expand All @@ -61,16 +62,25 @@ class EditNoteDialogViewModel(application: Application, private val showId: Long
uiState.update {
it.copy(isEditingEnabled = false)
}
val savedText = uiState.value.noteText?.take(SgShow2.MAX_USER_NOTE_LENGTH)
val noteDraft = uiState.value.noteText
val noteTraktId = uiState.value.noteTraktId
viewModelScope.launch {
val success = SgApp.getServicesComponent(getApplication()).showTools()
.storeUserNote(showId, savedText)
val result = SgApp.getServicesComponent(getApplication()).showTools()
.storeUserNote(showId, noteDraft, noteTraktId)
uiState.update {
it.copy(
noteText = savedText,
isEditingEnabled = true,
isNoteSaved = success
)
if (result != null) {
it.copy(
noteText = result.text,
noteTraktId = result.traktId,
isEditingEnabled = true,
isNoteSaved = true
)
} else {
// Failed, re-enable buttons
it.copy(
isEditingEnabled = true
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import com.battlelancer.seriesguide.notifications.NotificationService
import com.battlelancer.seriesguide.provider.SeriesGuideContract
import com.battlelancer.seriesguide.provider.SeriesGuideDatabase
import com.battlelancer.seriesguide.provider.SgRoomDatabase
import com.battlelancer.seriesguide.shows.database.SgShow2
import com.battlelancer.seriesguide.sync.HexagonShowSync
import com.battlelancer.seriesguide.traktapi.TraktCredentials
import com.battlelancer.seriesguide.traktapi.TraktTools2
import com.uwetrottmann.androidutils.AndroidUtils
import com.uwetrottmann.seriesguide.backend.shows.model.SgCloudShow
import dagger.Lazy
Expand Down Expand Up @@ -451,40 +454,108 @@ class ShowTools2 @Inject constructor(
notifyAboutSyncing()
}

data class StoreUserNoteResult(val text: String?, val traktId: Long?)

/**
* Uploads to Cloud and on success saves to local database.
* Does not sanitize the given values.
* Uploads to Hexagon and Trakt and on success saves to local database.
*/
suspend fun storeUserNote(showId: Long, userNote: String?): Boolean {
// Send to Cloud.
val isCloudFailed = withContext(Dispatchers.Default) {
if (!HexagonSettings.isEnabled(context)) {
return@withContext false
}
if (isNotConnected(context)) {
return@withContext true
}
val showTmdbId =
SgRoomDatabase.getInstance(context).sgShow2Helper().getShowTmdbId(showId)
if (showTmdbId == 0) {
return@withContext true
suspend fun storeUserNote(
showId: Long,
noteDraft: String?,
noteTraktId: Long?
): StoreUserNoteResult? {
val noteText = noteDraft
?.take(SgShow2.MAX_USER_NOTE_LENGTH)
?.ifEmpty { null } // Map empty string to null so note is removed at Trakt

// Send to Cloud first, Trakt may fail if user is not VIP
val isSendToCloudSuccess: Boolean = if (HexagonSettings.isEnabled(context)) {
withContext(Dispatchers.Default) {
if (isNotConnected(context)) {
return@withContext false
}

val showTmdbId = withContext(Dispatchers.IO) {
SgRoomDatabase.getInstance(context).sgShow2Helper().getShowTmdbId(showId)
}
if (showTmdbId == 0) return@withContext false

val show = SgCloudShow()
show.tmdbId = showTmdbId
show.note = noteText
return@withContext uploadShowToCloud(show)
}
} else {
true // Not sending to Cloud
}

val show = SgCloudShow()
show.tmdbId = showTmdbId
show.note = userNote
// If sending to Cloud failed, do not even try Trakt or save to database
if (!isSendToCloudSuccess) return null

val success = uploadShowToCloud(show)
return@withContext !success
var result: StoreUserNoteResult? = StoreUserNoteResult(text = noteDraft, traktId = null)

val sendToTrakt = TraktCredentials.get(context).hasCredentials()
if (sendToTrakt) {
result = withContext(Dispatchers.Default) {
if (isNotConnected(context)) {
return@withContext null
}

val showTmdbId = withContext(Dispatchers.IO) {
SgRoomDatabase.getInstance(context).sgShow2Helper().getShowTmdbId(showId)
}
if (showTmdbId == 0) return@withContext null

val trakt = SgApp.getServicesComponent(context).trakt()
if (noteText == null) {
// Delete note
if (noteTraktId == null) return@withContext null
val response = TraktTools2.deleteNote(trakt, noteTraktId)
return@withContext when (response) {
is TraktTools2.TraktResponse.Success -> {
StoreUserNoteResult(null, null) // Remove text and Trakt ID
}

is TraktTools2.TraktResponse.IsUnauthorized -> {
TraktCredentials.get(context).setCredentialsInvalid()
null // Abort
}

is TraktTools2.TraktResponse.IsNotVip -> result // Store as is
is TraktTools2.TraktResponse.Error -> null // Abort
}
} else {
// Add or update note
val response = TraktTools2.saveNoteForShow(trakt, showTmdbId, noteText)
return@withContext when (response) {
is TraktTools2.TraktResponse.Success -> {
// Store ID and note text from Trakt
// (which may shorten or otherwise modify it).
StoreUserNoteResult(response.data.notes, response.data.id)
}

is TraktTools2.TraktResponse.IsUnauthorized -> {
TraktCredentials.get(context).setCredentialsInvalid()
null
}

is TraktTools2.TraktResponse.IsNotVip -> result // Store as is
is TraktTools2.TraktResponse.Error -> null // Abort
}
}
}
}
// Do not save to local database if sending to cloud has failed.
if (isCloudFailed) return false

// Do not save to local database if sending to Trakt has failed,
// but not if user is just not VIP.
if (result == null) return null

// Save to local database
withContext(Dispatchers.IO) {
SgRoomDatabase.getInstance(context).sgShow2Helper().updateUserNote(showId, userNote)
SgRoomDatabase.getInstance(context).sgShow2Helper()
.updateUserNote(showId, result.text, result.traktId)
}
return true
return result
}

private suspend fun notifyAboutSyncing() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package com.battlelancer.seriesguide.sync

import com.battlelancer.seriesguide.provider.SgRoomDatabase
import com.battlelancer.seriesguide.shows.database.SgShow2Helper
import com.battlelancer.seriesguide.traktapi.SgTrakt
import com.battlelancer.seriesguide.traktapi.TraktSettings
import com.battlelancer.seriesguide.util.Errors
Expand Down Expand Up @@ -89,7 +90,8 @@ class TraktNotesSync(
uploadNotesForShows(showIdsWithNotesToUploadOrRemove)
} else {
// Remove notes from shows that are not on Trakt, meaning their note got removed
showHelper.updateUserNotes(showIdsWithNotesToUploadOrRemove.associateWith { null })
showHelper.updateUserNotes(showIdsWithNotesToUploadOrRemove
.associateWith { SgShow2Helper.NoteUpdate(null, null) })
}

if (isInitialSync) {
Expand All @@ -109,13 +111,15 @@ class TraktNotesSync(
return
}

val noteUpdates = mutableMapOf<Long, String>()
val noteUpdates = mutableMapOf<Long, SgShow2Helper.NoteUpdate>()

for (note in response) {
val showTmdbId = note.show?.ids?.tmdb
?: continue // Need a TMDB ID
val noteText = note.note?.notes
?: continue // Need a note
val noteTraktId = note.note?.id
?: continue // Need its Trakt ID

val localShowId = tmdbIdsToLocalShowIds[showTmdbId]
?: continue // Show not in database
Expand All @@ -124,7 +128,7 @@ class TraktNotesSync(
?: continue // Show was removed in the meantime

if (localShow.userNote != noteText) {
noteUpdates[localShowId] = noteText
noteUpdates[localShowId] = SgShow2Helper.NoteUpdate(noteText, noteTraktId)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Copyright 2021-2024 Uwe Trottmann
// SPDX-License-Identifier: Apache-2.0
// Copyright 2021-2024 Uwe Trottmann

package com.battlelancer.seriesguide.traktapi

Expand All @@ -13,19 +13,75 @@ import com.github.michaelbull.result.Result
import com.github.michaelbull.result.andThen
import com.github.michaelbull.result.mapError
import com.github.michaelbull.result.runCatching
import com.uwetrottmann.trakt5.TraktV2
import com.uwetrottmann.trakt5.entities.AddNoteRequest
import com.uwetrottmann.trakt5.entities.BaseShow
import com.uwetrottmann.trakt5.entities.LastActivity
import com.uwetrottmann.trakt5.entities.LastActivityMore
import com.uwetrottmann.trakt5.entities.LastActivityUpdated
import com.uwetrottmann.trakt5.entities.Note
import com.uwetrottmann.trakt5.entities.Ratings
import com.uwetrottmann.trakt5.entities.Show
import com.uwetrottmann.trakt5.entities.ShowIds
import com.uwetrottmann.trakt5.enums.Extended
import com.uwetrottmann.trakt5.enums.IdType
import com.uwetrottmann.trakt5.enums.Type
import retrofit2.Call
import retrofit2.Response
import retrofit2.awaitResponse

object TraktTools2 {

sealed class TraktResponse<T> {
data class Success<T>(val data: T) : TraktResponse<T>()
class IsNotVip<T> : TraktResponse<T>()
class IsUnauthorized<T> : TraktResponse<T>()
class Error<T> : TraktResponse<T>()
}

/**
* Adds or updates the note for the given show.
*/
suspend fun saveNoteForShow(
trakt: SgTrakt,
showTmdbId: Int,
noteText: String
): TraktResponse<Note> {
// Note: calling the add endpoint for an existing note will update it
return awaitTraktCall(trakt.notes().addNote(
AddNoteRequest(
Show().apply {
ids = ShowIds.tmdb(showTmdbId)
},
noteText
)
), "update note")
}

suspend fun deleteNote(
trakt: SgTrakt,
noteId: Long
): TraktResponse<Void> {
return awaitTraktCall(trakt.notes().deleteNote(noteId), "delete note")
}

private suspend fun <T> awaitTraktCall(call: Call<T>, action: String): TraktResponse<T> {
try {
val response = call.awaitResponse()
if (response.isSuccessful) {
response.body()
?.let { return TraktResponse.Success(it) }
} else {
if (TraktV2.isNotVip(response)) return TraktResponse.IsNotVip()
if (TraktV2.isUnauthorized(response)) return TraktResponse.IsUnauthorized()
Errors.logAndReport(action, response)
}
} catch (e: Exception) {
Errors.logAndReport(action, e)
}
return TraktResponse.Error()
}

/**
* Look up a show by its TMDB ID, may return `null` if not found.
*/
Expand Down

0 comments on commit 3c65f49

Please sign in to comment.