Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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<Boolean> 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<String, String?> = mutableMapOf()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,16 @@ class SyncRepo(override val api: SyncAPI) : AuthRepo(api) {
suspend fun library(): Result<SyncAPI.LibraryMetadata?> = runCatching {
api.library(freshAuth())
}

suspend fun onPlaybackStatus(
progress: SyncAPI.PlaybackProgress,
status: SyncAPI.PlaybackStatus
): Result<Boolean> =
runCatching {
api.onPlaybackStatus(
freshAuth(),
progress,
status
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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<ScrobbleEpisode>(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<ScrobbleRequest>(generatedSerializer())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -235,6 +236,57 @@ class GeneratorPlayer : FullScreenPlayer() {
if (player.getIsPlaying()) {
viewModel.forceClearCache = false
}
reportPlaybackStatusToSync()
}

private var lastSyncPlaybackStatus: CSPlayerLoading? = null
private var lastSyncEpisodeKey: Pair<Int?, Int?>? = 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 {
Expand Down Expand Up @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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) {
Expand All @@ -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()
}
}

Expand Down
Loading