diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt index 60e41e8b3d5..4fea633307a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -148,6 +148,11 @@ abstract class AuthAPI { /** The requirements to login in app */ open val inAppLoginRequirement: AuthLoginRequirement? = null + /** Determine if SyncApi supports Playback Scrobbling. + * @see SyncAPI.onPlaybackStatus + * */ + open val supportScrobble: Boolean = false + companion object { @Deprecated( message = "Use APIHolder.unixTime instead", diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt index 645a19e3a60..93031a8aeea 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt @@ -7,6 +7,7 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID +import com.lagradost.cloudstream3.utils.PreferenceDelegate import com.lagradost.cloudstream3.utils.txt /** General-purpose repo */ @@ -25,6 +26,15 @@ abstract class AuthRepo(open val api: AuthAPI) { val hasInApp get() = api.hasInApp val inAppLoginRequirement get() = api.inAppLoginRequirement val isAvailable get() = !api.requiresLogin || authUser() != null + val supportScrobble get() = api.supportScrobble + + private val supportScrobbleDelegate: PreferenceDelegate by lazy { + PreferenceDelegate("$idPrefix/supportScrobble", api.supportScrobble) + } + + var scrobbleEnabled: Boolean + get() = supportScrobbleDelegate.getValue(this, ::scrobbleEnabled) + set(value) = supportScrobbleDelegate.setValue(this, ::scrobbleEnabled, value) companion object { private val oauthPayload: MutableMap = mutableMapOf() diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt index f30a647483a..38fde81e2ff 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt @@ -70,6 +70,37 @@ abstract class SyncAPI : AuthAPI() { @Throws open fun urlToId(url: String): String? = null + @Throws + @WorkerThread + open suspend fun onPlaybackStatus( + auth: AuthData?, + progress: PlaybackProgress, + status: PlaybackStatus + ) : Boolean = false + + enum class PlaybackStatus { + Started, + Paused, + Stopped + } + + /** Triggers on every player event (Start/Pause/Stop/Ended) */ + data class PlaybackProgress( + /** Show/Movie Sync level ID */ + val id: String, + /** Season Number, Should be null for Movies */ + val season: Int?, + /** Episode Number, Should be null for Movies */ + val episode: Int?, + val type: TvType?, + val positionMs: Long, + val durationMs: Long, + ) { + val progressPercentage: Double + get() = if (durationMs<= 0L) 0.0 else + (positionMs.toDouble() / durationMs.toDouble() * 100.0).coerceIn(0.0, 100.0) + } + data class SyncSearchResult( override val name: String, override val apiName: String, diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt index de82624fc7e..f847a1e6685 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt @@ -27,4 +27,16 @@ class SyncRepo(override val api: SyncAPI) : AuthRepo(api) { suspend fun library(): Result = runCatching { api.library(freshAuth()) } + + suspend fun onPlaybackStatus( + progress: SyncAPI.PlaybackProgress, + status: SyncAPI.PlaybackStatus + ): Result = + runCatching { + api.onPlaybackStatus( + freshAuth(), + progress, + status + ) + } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt index a57ec67164e..47f03415974 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt @@ -17,6 +17,7 @@ import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SimklSyncServices import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.isAnimeOp import com.lagradost.cloudstream3.mvvm.debugPrint import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING @@ -27,6 +28,9 @@ import com.lagradost.cloudstream3.syncproviders.AuthToken import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.syncproviders.providers.SimklApi.ScrobbleEpisode +import com.lagradost.cloudstream3.syncproviders.providers.SimklApi.ScrobbleEpisode.Companion.generatedSerializer +import com.lagradost.cloudstream3.syncproviders.providers.SimklApi.ScrobbleRequest import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppUtils.toJson @@ -61,6 +65,7 @@ class SimklApi : SyncAPI() { override val icon = R.drawable.simkl_logo override val createAccountUrl = "$mainUrl/signup" override val syncIdName = SyncIdName.Simkl + override val supportScrobble = true /** * This is required to override the reported last activity as simkl activites @@ -176,6 +181,8 @@ class SimklApi : SyncAPI() { companion object { private const val CLIENT_ID: String = BuildConfig.SIMKL_CLIENT_ID private const val CLIENT_SECRET: String = BuildConfig.SIMKL_CLIENT_SECRET + private const val APP_NAME: String = "CloudStream" + private const val APP_VERSION: String = BuildConfig.VERSION_NAME const val SIMKL_CACHED_LIST: String = "simkl_cached_list" const val SIMKL_CACHED_LIST_TIME: String = "simkl_cached_time" @@ -1138,4 +1145,89 @@ class SimklApi : SyncAPI() { profilePicture = user.user.avatar, ) } -} + + override suspend fun onPlaybackStatus( + auth: AuthData?, + progress: PlaybackProgress, + status: PlaybackStatus + ): Boolean { + + val token = auth?.token ?: return false + val ids = MediaObject.Ids.fromMap(readIdFromString(progress.id)) + val isAnime = progress.type?.isAnimeOp() == true + + val body = if (progress.season != null && progress.episode != null) { + val media = MediaObject( + title = null, + year = null, + ids = ids + ) + ScrobbleRequest( + show = if (!isAnime) media else null, + anime = if (isAnime) media else null, + episode = ScrobbleEpisode( + season = progress.season, + episode = progress.episode + ), + progress = progress.progressPercentage + ) + } else { + ScrobbleRequest( + movie = MediaObject( + title = null, + year = null, + ids = ids + ), + progress = progress.progressPercentage + ) + } + + val endPoint = when (status) { + PlaybackStatus.Started -> "start" + PlaybackStatus.Paused -> "pause" + PlaybackStatus.Stopped -> "stop" + } + + return try { + app.post( + url = "$mainUrl/scrobble/$endPoint", + params = mapOf( + "app-name" to APP_NAME, + "app-version" to APP_VERSION + ), + headers = getHeaders(token) + mapOf("User-Agent" to "$APP_NAME/$APP_VERSION"), + json = body + ).isSuccessful + } catch (e: Exception) { + logError(e) + false + } + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now + @Serializable(with = ScrobbleEpisode.Serializer::class) + @KeepGeneratedSerializer + data class ScrobbleEpisode( + @JsonProperty("season") @SerialName("season") val season: Int, + @JsonProperty("episode") @SerialName("episode") val episode: Int, + ) { + object Serializer : + NonEmptySerializer(generatedSerializer()) + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now + @Serializable(with = ScrobbleRequest.Serializer::class) + @KeepGeneratedSerializer + data class ScrobbleRequest( + @JsonProperty("show") @SerialName("show") val show: MediaObject? = null, + @JsonProperty("movie") @SerialName("movie") val movie: MediaObject? = null, + @JsonProperty("anime") @SerialName("anime") val anime: MediaObject? = null, + @JsonProperty("episode") @SerialName("episode") val episode: ScrobbleEpisode? = null, + @JsonProperty("progress") @SerialName("progress") val progress: Double, + ) { + object Serializer : + NonEmptySerializer(generatedSerializer()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 91e3ff9708d..bc729f47f11 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -74,6 +74,7 @@ import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders +import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForcedEncoding @@ -235,6 +236,57 @@ class GeneratorPlayer : FullScreenPlayer() { if (player.getIsPlaying()) { viewModel.forceClearCache = false } + reportPlaybackStatusToSync() + } + + private var lastSyncPlaybackStatus: CSPlayerLoading? = null + private var lastSyncEpisodeKey: Pair? = null + private var lastSyncPosition: Long = 0L + private var lastSyncDuration: Long = 0L + + private fun reportPlaybackStatusToSync() { + val status = currentPlayerStatus + val episode = currentMeta as? ResultEpisode + val episodeKey = episode?.season to episode?.episode + val type = episode?.tvType + + val episodeChanged = lastSyncEpisodeKey != null && episodeKey != lastSyncEpisodeKey + if (episodeChanged && lastSyncPlaybackStatus != CSPlayerLoading.IsEnded) { + val (oldSeason, oldEpisode) = lastSyncEpisodeKey!! + + sync.updatePlaybackStatus( + status = SyncAPI.PlaybackStatus.Stopped, + season = oldSeason, + episode = oldEpisode, + type = type, + positionMs = lastSyncPosition, + durationMs = lastSyncDuration, + ) + } + + if (status == lastSyncPlaybackStatus && !episodeChanged) return + + lastSyncPlaybackStatus = status + lastSyncEpisodeKey = episodeKey + + val playbackStatus = when (status) { + CSPlayerLoading.IsPlaying -> SyncAPI.PlaybackStatus.Started + CSPlayerLoading.IsPaused -> SyncAPI.PlaybackStatus.Paused + CSPlayerLoading.IsEnded -> SyncAPI.PlaybackStatus.Stopped + else -> return //Ignore IsBuffering + } + + lastSyncPosition = player.getPosition() ?: return + lastSyncDuration = player.getDuration() ?: return + + sync.updatePlaybackStatus( + status = playbackStatus, + season = episode?.season, + episode = episode?.episode, + type = type, + positionMs = lastSyncPosition, + durationMs = lastSyncDuration, + ) } private fun noSubtitles(): Boolean { @@ -1665,6 +1717,24 @@ class GeneratorPlayer : FullScreenPlayer() { override fun onDestroy() { ResultFragment.updateUI() currentVerifyLink?.cancel() + + if (lastSyncPlaybackStatus != CSPlayerLoading.IsEnded) { + val position = player.getPosition() + val duration = player.getDuration() + if (position != null && duration != null) { + val episode = currentMeta as? ResultEpisode + + sync.updatePlaybackStatus( + status = SyncAPI.PlaybackStatus.Stopped, + season = episode?.season, + episode = episode?.episode, + type = episode?.tvType, + positionMs = position, + durationMs = duration + ) + } + } + super.onDestroy() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt index 3161a12044e..ea6dd25aff7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError @@ -306,4 +307,30 @@ class SyncViewModel : ViewModel() { updateMetadata() updateUserData() } + + /** Triggers on every player event (Start/Pause/Stop/Ended) */ + fun updatePlaybackStatus( + status: SyncAPI.PlaybackStatus, + season: Int?, + episode: Int?, + type: TvType?, + positionMs: Long, + durationMs: Long, + ) = ioSafe { + syncs.amap { (prefix, id) -> + repos.firstOrNull { + it.idPrefix == prefix && it.supportScrobble && it.scrobbleEnabled + }?.onPlaybackStatus( + progress = SyncAPI.PlaybackProgress( + id = id, + season = season, + episode = episode, + type = type, + positionMs = positionMs, + durationMs = durationMs + ), + status = status + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 75ba55674a6..c707f59b629 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -61,6 +61,7 @@ import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.PreferenceDelegate import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogText import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe @@ -72,6 +73,7 @@ import com.lagradost.cloudstream3.utils.txt import qrcode.QRCode class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { + companion object { /** Used by nginx plugin too */ @SuppressLint("StringFormatInvalid") @@ -93,6 +95,12 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { !info?.profilePicture.isNullOrEmpty() binding.accountMainProfilePicture.loadImage(info?.profilePicture) + binding.accountSupportScrobbleSwitch.isChecked = api.scrobbleEnabled + binding.accountSupportScrobbleSwitch.isVisible = api.supportScrobble + binding.accountSupportScrobbleSwitch.setOnCheckedChangeListener { _, isChecked -> + api.scrobbleEnabled = isChecked + } + binding.accountLogout.isVisible = info != null binding.accountLogout.setOnClickListener { if (info != null) { @@ -117,7 +125,10 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { } if (isLayout(TV or EMULATOR)) { - binding.accountSwitchAccount.requestFocus() + if (binding.accountSupportScrobbleSwitch.isVisible) + binding.accountSupportScrobbleSwitch.requestFocus() + else + binding.accountSwitchAccount.requestFocus() } } diff --git a/app/src/main/res/layout/account_managment.xml b/app/src/main/res/layout/account_managment.xml index e7afb382c87..e9112a19a75 100644 --- a/app/src/main/res/layout/account_managment.xml +++ b/app/src/main/res/layout/account_managment.xml @@ -61,6 +61,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 31cf951cf5f..6863888f002 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -783,5 +783,6 @@ %d downloads queued Live + Enable Scrobbling