diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/appinitializers/PlaybackReporterInitializer.kt b/android/app/src/main/java/com/simplecityapps/shuttle/appinitializers/PlaybackReporterInitializer.kt new file mode 100644 index 000000000..43a206ba8 --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/appinitializers/PlaybackReporterInitializer.kt @@ -0,0 +1,25 @@ +package com.simplecityapps.shuttle.appinitializers + +import android.app.Application +import com.simplecityapps.playback.PlaybackWatcher +import com.simplecityapps.shuttle.playback.PlaybackReporter +import javax.inject.Inject +import timber.log.Timber + +/** + * Initializes the PlaybackReporter by registering it with the PlaybackWatcher. + * + * This allows the PlaybackReporter to listen for playback events and report them + * to remote media servers (Jellyfin, Emby, Plex). + */ +class PlaybackReporterInitializer +@Inject +constructor( + private val playbackWatcher: PlaybackWatcher, + private val playbackReporter: PlaybackReporter +) : AppInitializer { + override fun init(application: Application) { + Timber.v("PlaybackReporterInitializer.init()") + playbackWatcher.addCallback(playbackReporter) + } +} diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/di/AppModuleBinds.kt b/android/app/src/main/java/com/simplecityapps/shuttle/di/AppModuleBinds.kt index 19a5f1c53..e5cab76fb 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/di/AppModuleBinds.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/di/AppModuleBinds.kt @@ -4,6 +4,7 @@ import com.simplecityapps.shuttle.appinitializers.AppInitializer import com.simplecityapps.shuttle.appinitializers.CrashReportingInitializer import com.simplecityapps.shuttle.appinitializers.MediaProviderInitializer import com.simplecityapps.shuttle.appinitializers.PlaybackInitializer +import com.simplecityapps.shuttle.appinitializers.PlaybackReporterInitializer import com.simplecityapps.shuttle.appinitializers.RemoteConfigInitializer import com.simplecityapps.shuttle.appinitializers.ShortcutInitializer import com.simplecityapps.shuttle.appinitializers.TimberInitializer @@ -30,6 +31,10 @@ abstract class AppModuleBinds { @IntoSet abstract fun providePlaybackInitializer(bind: PlaybackInitializer): AppInitializer + @Binds + @IntoSet + abstract fun providePlaybackReporterInitializer(bind: PlaybackReporterInitializer): AppInitializer + @Binds @IntoSet abstract fun provideWidgetInitializer(bind: WidgetInitializer): AppInitializer diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/playback/PlaybackReporter.kt b/android/app/src/main/java/com/simplecityapps/shuttle/playback/PlaybackReporter.kt new file mode 100644 index 000000000..03fe497dc --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/playback/PlaybackReporter.kt @@ -0,0 +1,361 @@ +package com.simplecityapps.shuttle.playback + +import com.simplecityapps.playback.PlaybackState +import com.simplecityapps.playback.PlaybackWatcherCallback +import com.simplecityapps.provider.emby.EmbyAuthenticationManager +import com.simplecityapps.provider.emby.http.PlaybackReportingService as EmbyPlaybackReportingService +import com.simplecityapps.provider.emby.http.playbackProgress as embyPlaybackProgress +import com.simplecityapps.provider.emby.http.playbackStart as embyPlaybackStart +import com.simplecityapps.provider.emby.http.playbackStopped as embyPlaybackStopped +import com.simplecityapps.provider.jellyfin.JellyfinAuthenticationManager +import com.simplecityapps.provider.jellyfin.http.PlaybackReportingService as JellyfinPlaybackReportingService +import com.simplecityapps.provider.jellyfin.http.playbackProgress as jellyfinPlaybackProgress +import com.simplecityapps.provider.jellyfin.http.playbackStart as jellyfinPlaybackStart +import com.simplecityapps.provider.jellyfin.http.playbackStopped as jellyfinPlaybackStopped +import com.simplecityapps.provider.plex.PlexAuthenticationManager +import com.simplecityapps.provider.plex.http.PlaybackReportingService as PlexPlaybackReportingService +import com.simplecityapps.provider.plex.http.PlaybackState as PlexPlaybackState +import com.simplecityapps.provider.plex.http.markPlayed as plexMarkPlayed +import com.simplecityapps.provider.plex.http.timelineUpdate as plexTimelineUpdate +import com.simplecityapps.shuttle.di.AppCoroutineScope +import com.simplecityapps.shuttle.model.MediaProviderType +import com.simplecityapps.shuttle.model.Song +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * Coordinates playback reporting to remote media servers (Jellyfin, Emby, Plex). + * + * This class follows the Observer pattern by implementing PlaybackWatcherCallback + * to listen for playback events and report them to the appropriate media server. + * + * Architecture notes: + * - Uses dependency injection to get provider-specific services and auth managers + * - Progress updates are sent every 10 seconds (recommended interval for server APIs) + * - Session IDs are generated per playback session for server tracking + * - Only reports playback for remote media providers (not local files) + */ +@Singleton +class PlaybackReporter +@Inject +constructor( + private val jellyfinPlaybackReportingService: JellyfinPlaybackReportingService, + private val jellyfinAuthenticationManager: JellyfinAuthenticationManager, + private val embyPlaybackReportingService: EmbyPlaybackReportingService, + private val embyAuthenticationManager: EmbyAuthenticationManager, + private val plexPlaybackReportingService: PlexPlaybackReportingService, + private val plexAuthenticationManager: PlexAuthenticationManager, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope +) : PlaybackWatcherCallback { + private var currentSong: Song? = null + private var currentSessionId: String? = null + private var progressReportingJob: Job? = null + private var lastReportedPosition: Int = 0 + private var isPaused: Boolean = false + + override fun onPlaybackStateChanged(playbackState: PlaybackState) { + when (playbackState) { + is PlaybackState.Playing -> { + val song = playbackState.queueItem.song + if (currentSong?.id != song.id) { + // New song started + stopProgressReporting() + reportPlaybackStart(song) + currentSong = song + currentSessionId = UUID.randomUUID().toString() + lastReportedPosition = 0 + isPaused = false + startProgressReporting(song) + } else if (isPaused) { + // Resuming from pause + isPaused = false + startProgressReporting(song) + } + } + is PlaybackState.Paused -> { + isPaused = true + stopProgressReporting() + currentSong?.let { song -> + reportProgress(song, playbackState.progress, isPaused = true) + } + } + else -> { + // Stopped or other states + stopProgressReporting() + currentSong = null + currentSessionId = null + lastReportedPosition = 0 + isPaused = false + } + } + } + + override fun onTrackEnded(song: Song) { + stopProgressReporting() + reportPlaybackStopped(song, song.duration) + reportScrobble(song) + currentSong = null + currentSessionId = null + lastReportedPosition = 0 + isPaused = false + } + + private fun startProgressReporting(song: Song) { + progressReportingJob?.cancel() + progressReportingJob = + appCoroutineScope.launch { + while (isActive) { + delay(10_000) // Report every 10 seconds + if (!isPaused) { + // We don't have direct access to current position here, + // so we rely on onProgressChanged to update it + reportProgress(song, lastReportedPosition, isPaused = false) + } + } + } + } + + private fun stopProgressReporting() { + progressReportingJob?.cancel() + progressReportingJob = null + } + + override fun onProgressChanged( + position: Int, + duration: Int, + fromUser: Boolean + ) { + lastReportedPosition = position + } + + private fun reportPlaybackStart(song: Song) { + if (!song.mediaProvider.remote || song.externalId == null) { + return + } + + val sessionId = currentSessionId ?: return + + appCoroutineScope.launch { + try { + when (song.mediaProvider) { + MediaProviderType.Jellyfin -> { + val credentials = jellyfinAuthenticationManager.getAuthenticatedCredentials() + val serverUrl = jellyfinAuthenticationManager.getAddress() + if (credentials != null && serverUrl != null) { + jellyfinPlaybackReportingService.jellyfinPlaybackStart( + serverUrl = serverUrl, + token = credentials.accessToken, + itemId = song.externalId, + sessionId = sessionId, + userId = credentials.userId + ) + Timber.d("Jellyfin playback start reported for ${song.name}") + } + } + MediaProviderType.Emby -> { + val credentials = embyAuthenticationManager.getAuthenticatedCredentials() + val serverUrl = embyAuthenticationManager.getAddress() + if (credentials != null && serverUrl != null) { + embyPlaybackReportingService.embyPlaybackStart( + serverUrl = serverUrl, + token = credentials.accessToken, + itemId = song.externalId, + sessionId = sessionId, + userId = credentials.userId + ) + Timber.d("Emby playback start reported for ${song.name}") + } + } + MediaProviderType.Plex -> { + val credentials = plexAuthenticationManager.getAuthenticatedCredentials() + val serverUrl = plexAuthenticationManager.getAddress() + if (credentials != null && serverUrl != null) { + plexPlaybackReportingService.plexTimelineUpdate( + serverUrl = serverUrl, + token = credentials.accessToken, + ratingKey = song.externalId, + key = "/library/metadata/${song.externalId}", + state = PlexPlaybackState.PLAYING, + positionMs = 0, + durationMs = song.duration.toLong() + ) + Timber.d("Plex playback start reported for ${song.name}") + } + } + else -> { + // Local providers don't need reporting + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to report playback start for ${song.name}") + } + } + } + + private fun reportProgress( + song: Song, + position: Int, + isPaused: Boolean + ) { + if (!song.mediaProvider.remote || song.externalId == null) { + return + } + + val sessionId = currentSessionId ?: return + val positionTicks = (position * 10_000L) // Convert milliseconds to ticks (100-nanosecond units) + + appCoroutineScope.launch { + try { + when (song.mediaProvider) { + MediaProviderType.Jellyfin -> { + val credentials = jellyfinAuthenticationManager.getAuthenticatedCredentials() + val serverUrl = jellyfinAuthenticationManager.getAddress() + if (credentials != null && serverUrl != null) { + jellyfinPlaybackReportingService.jellyfinPlaybackProgress( + serverUrl = serverUrl, + token = credentials.accessToken, + itemId = song.externalId, + sessionId = sessionId, + positionTicks = positionTicks, + isPaused = isPaused + ) + } + } + MediaProviderType.Emby -> { + val credentials = embyAuthenticationManager.getAuthenticatedCredentials() + val serverUrl = embyAuthenticationManager.getAddress() + if (credentials != null && serverUrl != null) { + embyPlaybackReportingService.embyPlaybackProgress( + serverUrl = serverUrl, + token = credentials.accessToken, + itemId = song.externalId, + sessionId = sessionId, + positionTicks = positionTicks, + isPaused = isPaused + ) + } + } + MediaProviderType.Plex -> { + val credentials = plexAuthenticationManager.getAuthenticatedCredentials() + val serverUrl = plexAuthenticationManager.getAddress() + if (credentials != null && serverUrl != null) { + plexPlaybackReportingService.plexTimelineUpdate( + serverUrl = serverUrl, + token = credentials.accessToken, + ratingKey = song.externalId, + key = "/library/metadata/${song.externalId}", + state = if (isPaused) PlexPlaybackState.PAUSED else PlexPlaybackState.PLAYING, + positionMs = position.toLong(), + durationMs = song.duration.toLong() + ) + } + } + else -> { + // Local providers don't need reporting + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to report playback progress for ${song.name}") + } + } + } + + private fun reportPlaybackStopped( + song: Song, + position: Int + ) { + if (!song.mediaProvider.remote || song.externalId == null) { + return + } + + val sessionId = currentSessionId ?: return + val positionTicks = (position * 10_000L) + + appCoroutineScope.launch { + try { + when (song.mediaProvider) { + MediaProviderType.Jellyfin -> { + val credentials = jellyfinAuthenticationManager.getAuthenticatedCredentials() + val serverUrl = jellyfinAuthenticationManager.getAddress() + if (credentials != null && serverUrl != null) { + jellyfinPlaybackReportingService.jellyfinPlaybackStopped( + serverUrl = serverUrl, + token = credentials.accessToken, + itemId = song.externalId, + sessionId = sessionId, + positionTicks = positionTicks + ) + Timber.d("Jellyfin playback stopped reported for ${song.name}") + } + } + MediaProviderType.Emby -> { + val credentials = embyAuthenticationManager.getAuthenticatedCredentials() + val serverUrl = embyAuthenticationManager.getAddress() + if (credentials != null && serverUrl != null) { + embyPlaybackReportingService.embyPlaybackStopped( + serverUrl = serverUrl, + token = credentials.accessToken, + itemId = song.externalId, + sessionId = sessionId, + positionTicks = positionTicks + ) + Timber.d("Emby playback stopped reported for ${song.name}") + } + } + MediaProviderType.Plex -> { + val credentials = plexAuthenticationManager.getAuthenticatedCredentials() + val serverUrl = plexAuthenticationManager.getAddress() + if (credentials != null && serverUrl != null) { + plexPlaybackReportingService.plexTimelineUpdate( + serverUrl = serverUrl, + token = credentials.accessToken, + ratingKey = song.externalId, + key = "/library/metadata/${song.externalId}", + state = PlexPlaybackState.STOPPED, + positionMs = position.toLong(), + durationMs = song.duration.toLong() + ) + Timber.d("Plex playback stopped reported for ${song.name}") + } + } + else -> { + // Local providers don't need reporting + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to report playback stopped for ${song.name}") + } + } + } + + private fun reportScrobble(song: Song) { + if (song.mediaProvider != MediaProviderType.Plex || song.externalId == null) { + return + } + + appCoroutineScope.launch { + try { + val credentials = plexAuthenticationManager.getAuthenticatedCredentials() + val serverUrl = plexAuthenticationManager.getAddress() + if (credentials != null && serverUrl != null) { + plexPlaybackReportingService.plexMarkPlayed( + serverUrl = serverUrl, + token = credentials.accessToken, + key = song.externalId, + identifier = "com.plexapp.plugins.library" + ) + Timber.d("Plex scrobble reported for ${song.name}") + } + } catch (e: Exception) { + Timber.e(e, "Failed to report scrobble for ${song.name}") + } + } + } +} diff --git a/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/di/EmbyMediaProviderModule.kt b/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/di/EmbyMediaProviderModule.kt index 522745116..1bd38824b 100644 --- a/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/di/EmbyMediaProviderModule.kt +++ b/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/di/EmbyMediaProviderModule.kt @@ -11,6 +11,7 @@ import com.simplecityapps.provider.emby.EmbyMediaProvider import com.simplecityapps.provider.emby.http.EmbyTranscodeService import com.simplecityapps.provider.emby.http.ItemsService import com.simplecityapps.provider.emby.http.LoginCredentials +import com.simplecityapps.provider.emby.http.PlaybackReportingService import com.simplecityapps.provider.emby.http.UserService import com.simplecityapps.shuttle.persistence.SecurePreferenceManager import com.squareup.moshi.Moshi @@ -67,6 +68,12 @@ open class EmbyMediaProviderModule { @Named("EmbyRetrofit") retrofit: Retrofit ): EmbyTranscodeService = retrofit.create() + @Provides + @Singleton + fun providePlaybackReportingService( + @Named("EmbyRetrofit") retrofit: Retrofit + ): PlaybackReportingService = retrofit.create() + @Provides @Singleton fun provideCredentialStore(securePreferenceManager: SecurePreferenceManager): CredentialStore = CredentialStore(securePreferenceManager).apply { diff --git a/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/http/PlaybackInfo.kt b/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/http/PlaybackInfo.kt new file mode 100644 index 000000000..6dd4def08 --- /dev/null +++ b/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/http/PlaybackInfo.kt @@ -0,0 +1,54 @@ +package com.simplecityapps.provider.emby.http + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PlaybackStartInfo( + @Json(name = "ItemId") + val itemId: String, + @Json(name = "SessionId") + val sessionId: String, + @Json(name = "CanSeek") + val canSeek: Boolean, + @Json(name = "IsPaused") + val isPaused: Boolean, + @Json(name = "IsMuted") + val isMuted: Boolean, + @Json(name = "VolumeLevel") + val volumeLevel: Int, + @Json(name = "PlayMethod") + val playMethod: String, + @Json(name = "QueueableMediaTypes") + val queueableMediaTypes: String +) + +@JsonClass(generateAdapter = true) +data class PlaybackProgressInfo( + @Json(name = "ItemId") + val itemId: String, + @Json(name = "SessionId") + val sessionId: String, + @Json(name = "PositionTicks") + val positionTicks: Long, + @Json(name = "CanSeek") + val canSeek: Boolean, + @Json(name = "IsPaused") + val isPaused: Boolean, + @Json(name = "IsMuted") + val isMuted: Boolean, + @Json(name = "VolumeLevel") + val volumeLevel: Int, + @Json(name = "PlayMethod") + val playMethod: String +) + +@JsonClass(generateAdapter = true) +data class PlaybackStopInfo( + @Json(name = "ItemId") + val itemId: String, + @Json(name = "SessionId") + val sessionId: String, + @Json(name = "PositionTicks") + val positionTicks: Long +) diff --git a/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/http/PlaybackReportingService.kt b/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/http/PlaybackReportingService.kt new file mode 100644 index 000000000..5112a8b15 --- /dev/null +++ b/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/http/PlaybackReportingService.kt @@ -0,0 +1,102 @@ +package com.simplecityapps.provider.emby.http + +import com.simplecityapps.networking.retrofit.NetworkResult +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Url + +interface PlaybackReportingService { + @POST + @Headers( + "Accept: application/json", + "Content-Type: application/json" + ) + suspend fun reportPlaybackStart( + @Url url: String, + @Header("X-Emby-Token") token: String, + @Body body: PlaybackStartInfo + ): NetworkResult + + @POST + @Headers( + "Accept: application/json", + "Content-Type: application/json" + ) + suspend fun reportPlaybackProgress( + @Url url: String, + @Header("X-Emby-Token") token: String, + @Body body: PlaybackProgressInfo + ): NetworkResult + + @POST + @Headers( + "Accept: application/json", + "Content-Type: application/json" + ) + suspend fun reportPlaybackStopped( + @Url url: String, + @Header("X-Emby-Token") token: String, + @Body body: PlaybackStopInfo + ): NetworkResult +} + +suspend fun PlaybackReportingService.playbackStart( + serverUrl: String, + token: String, + itemId: String, + sessionId: String, + userId: String +): NetworkResult = reportPlaybackStart( + url = "$serverUrl/Sessions/Playing", + token = token, + body = PlaybackStartInfo( + itemId = itemId, + sessionId = sessionId, + canSeek = true, + isPaused = false, + isMuted = false, + volumeLevel = 100, + playMethod = "DirectPlay", + queueableMediaTypes = "Audio" + ) +) + +suspend fun PlaybackReportingService.playbackProgress( + serverUrl: String, + token: String, + itemId: String, + sessionId: String, + positionTicks: Long, + isPaused: Boolean +): NetworkResult = reportPlaybackProgress( + url = "$serverUrl/Sessions/Playing/Progress", + token = token, + body = PlaybackProgressInfo( + itemId = itemId, + sessionId = sessionId, + positionTicks = positionTicks, + canSeek = true, + isPaused = isPaused, + isMuted = false, + volumeLevel = 100, + playMethod = "DirectPlay" + ) +) + +suspend fun PlaybackReportingService.playbackStopped( + serverUrl: String, + token: String, + itemId: String, + sessionId: String, + positionTicks: Long +): NetworkResult = reportPlaybackStopped( + url = "$serverUrl/Sessions/Playing/Stopped", + token = token, + body = PlaybackStopInfo( + itemId = itemId, + sessionId = sessionId, + positionTicks = positionTicks + ) +) diff --git a/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/di/JellyfinMediaProviderModule.kt b/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/di/JellyfinMediaProviderModule.kt index 572722cf7..a11ac6bc9 100644 --- a/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/di/JellyfinMediaProviderModule.kt +++ b/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/di/JellyfinMediaProviderModule.kt @@ -11,6 +11,7 @@ import com.simplecityapps.provider.jellyfin.JellyfinMediaProvider import com.simplecityapps.provider.jellyfin.http.ItemsService import com.simplecityapps.provider.jellyfin.http.JellyfinTranscodeService import com.simplecityapps.provider.jellyfin.http.LoginCredentials +import com.simplecityapps.provider.jellyfin.http.PlaybackReportingService import com.simplecityapps.provider.jellyfin.http.UserService import com.simplecityapps.shuttle.persistence.SecurePreferenceManager import com.squareup.moshi.Moshi @@ -67,6 +68,12 @@ open class JellyfinMediaProviderModule { @Named("JellyfinRetrofit") retrofit: Retrofit ): JellyfinTranscodeService = retrofit.create() + @Provides + @Singleton + fun providePlaybackReportingService( + @Named("JellyfinRetrofit") retrofit: Retrofit + ): PlaybackReportingService = retrofit.create() + @Provides @Singleton fun provideCredentialStore(securePreferenceManager: SecurePreferenceManager): CredentialStore = CredentialStore(securePreferenceManager).apply { diff --git a/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/http/PlaybackInfo.kt b/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/http/PlaybackInfo.kt new file mode 100644 index 000000000..caf385433 --- /dev/null +++ b/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/http/PlaybackInfo.kt @@ -0,0 +1,54 @@ +package com.simplecityapps.provider.jellyfin.http + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PlaybackStartInfo( + @Json(name = "ItemId") + val itemId: String, + @Json(name = "SessionId") + val sessionId: String, + @Json(name = "CanSeek") + val canSeek: Boolean, + @Json(name = "IsPaused") + val isPaused: Boolean, + @Json(name = "IsMuted") + val isMuted: Boolean, + @Json(name = "VolumeLevel") + val volumeLevel: Int, + @Json(name = "PlayMethod") + val playMethod: String, + @Json(name = "QueueableMediaTypes") + val queueableMediaTypes: String +) + +@JsonClass(generateAdapter = true) +data class PlaybackProgressInfo( + @Json(name = "ItemId") + val itemId: String, + @Json(name = "SessionId") + val sessionId: String, + @Json(name = "PositionTicks") + val positionTicks: Long, + @Json(name = "CanSeek") + val canSeek: Boolean, + @Json(name = "IsPaused") + val isPaused: Boolean, + @Json(name = "IsMuted") + val isMuted: Boolean, + @Json(name = "VolumeLevel") + val volumeLevel: Int, + @Json(name = "PlayMethod") + val playMethod: String +) + +@JsonClass(generateAdapter = true) +data class PlaybackStopInfo( + @Json(name = "ItemId") + val itemId: String, + @Json(name = "SessionId") + val sessionId: String, + @Json(name = "PositionTicks") + val positionTicks: Long +) diff --git a/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/http/PlaybackReportingService.kt b/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/http/PlaybackReportingService.kt new file mode 100644 index 000000000..065cad7a5 --- /dev/null +++ b/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/http/PlaybackReportingService.kt @@ -0,0 +1,102 @@ +package com.simplecityapps.provider.jellyfin.http + +import com.simplecityapps.networking.retrofit.NetworkResult +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Url + +interface PlaybackReportingService { + @POST + @Headers( + "Accept: application/json", + "Content-Type: application/json" + ) + suspend fun reportPlaybackStart( + @Url url: String, + @Header("X-Emby-Token") token: String, + @Body body: PlaybackStartInfo + ): NetworkResult + + @POST + @Headers( + "Accept: application/json", + "Content-Type: application/json" + ) + suspend fun reportPlaybackProgress( + @Url url: String, + @Header("X-Emby-Token") token: String, + @Body body: PlaybackProgressInfo + ): NetworkResult + + @POST + @Headers( + "Accept: application/json", + "Content-Type: application/json" + ) + suspend fun reportPlaybackStopped( + @Url url: String, + @Header("X-Emby-Token") token: String, + @Body body: PlaybackStopInfo + ): NetworkResult +} + +suspend fun PlaybackReportingService.playbackStart( + serverUrl: String, + token: String, + itemId: String, + sessionId: String, + userId: String +): NetworkResult = reportPlaybackStart( + url = "$serverUrl/Sessions/Playing", + token = token, + body = PlaybackStartInfo( + itemId = itemId, + sessionId = sessionId, + canSeek = true, + isPaused = false, + isMuted = false, + volumeLevel = 100, + playMethod = "DirectPlay", + queueableMediaTypes = "Audio" + ) +) + +suspend fun PlaybackReportingService.playbackProgress( + serverUrl: String, + token: String, + itemId: String, + sessionId: String, + positionTicks: Long, + isPaused: Boolean +): NetworkResult = reportPlaybackProgress( + url = "$serverUrl/Sessions/Playing/Progress", + token = token, + body = PlaybackProgressInfo( + itemId = itemId, + sessionId = sessionId, + positionTicks = positionTicks, + canSeek = true, + isPaused = isPaused, + isMuted = false, + volumeLevel = 100, + playMethod = "DirectPlay" + ) +) + +suspend fun PlaybackReportingService.playbackStopped( + serverUrl: String, + token: String, + itemId: String, + sessionId: String, + positionTicks: Long +): NetworkResult = reportPlaybackStopped( + url = "$serverUrl/Sessions/Playing/Stopped", + token = token, + body = PlaybackStopInfo( + itemId = itemId, + sessionId = sessionId, + positionTicks = positionTicks + ) +) diff --git a/android/mediaprovider/plex/src/main/java/com/simplecityapps/provider/plex/di/PlexMediaProviderModule.kt b/android/mediaprovider/plex/src/main/java/com/simplecityapps/provider/plex/di/PlexMediaProviderModule.kt index 433bbda91..eee3c6cf3 100644 --- a/android/mediaprovider/plex/src/main/java/com/simplecityapps/provider/plex/di/PlexMediaProviderModule.kt +++ b/android/mediaprovider/plex/src/main/java/com/simplecityapps/provider/plex/di/PlexMediaProviderModule.kt @@ -8,6 +8,7 @@ import com.simplecityapps.provider.plex.PlexAuthenticationManager import com.simplecityapps.provider.plex.PlexMediaInfoProvider import com.simplecityapps.provider.plex.PlexMediaProvider import com.simplecityapps.provider.plex.http.ItemsService +import com.simplecityapps.provider.plex.http.PlaybackReportingService import com.simplecityapps.provider.plex.http.UserService import com.simplecityapps.shuttle.persistence.SecurePreferenceManager import com.squareup.moshi.Moshi @@ -58,6 +59,12 @@ open class PlexMediaProviderModule { @Named("PlexRetrofit") retrofit: Retrofit ): ItemsService = retrofit.create() + @Provides + @Singleton + fun providePlaybackReportingService( + @Named("PlexRetrofit") retrofit: Retrofit + ): PlaybackReportingService = retrofit.create() + @Provides @Singleton fun provideCredentialStore(securePreferenceManager: SecurePreferenceManager): CredentialStore = CredentialStore(securePreferenceManager) diff --git a/android/mediaprovider/plex/src/main/java/com/simplecityapps/provider/plex/http/PlaybackReportingService.kt b/android/mediaprovider/plex/src/main/java/com/simplecityapps/provider/plex/http/PlaybackReportingService.kt new file mode 100644 index 000000000..8b2a70a3c --- /dev/null +++ b/android/mediaprovider/plex/src/main/java/com/simplecityapps/provider/plex/http/PlaybackReportingService.kt @@ -0,0 +1,64 @@ +package com.simplecityapps.provider.plex.http + +import com.simplecityapps.networking.retrofit.NetworkResult +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Query +import retrofit2.http.Url + +interface PlaybackReportingService { + @GET + suspend fun reportTimeline( + @Url url: String, + @Header("X-Plex-Token") token: String, + @Query("ratingKey") ratingKey: String, + @Query("key") key: String, + @Query("state") state: String, + @Query("time") time: Long, + @Query("duration") duration: Long + ): NetworkResult + + @GET + suspend fun scrobble( + @Url url: String, + @Header("X-Plex-Token") token: String, + @Query("key") key: String, + @Query("identifier") identifier: String + ): NetworkResult +} + +suspend fun PlaybackReportingService.timelineUpdate( + serverUrl: String, + token: String, + ratingKey: String, + key: String, + state: PlaybackState, + positionMs: Long, + durationMs: Long +): NetworkResult = reportTimeline( + url = "$serverUrl/:/timeline", + token = token, + ratingKey = ratingKey, + key = key, + state = state.value, + time = positionMs, + duration = durationMs +) + +suspend fun PlaybackReportingService.markPlayed( + serverUrl: String, + token: String, + key: String, + identifier: String +): NetworkResult = scrobble( + url = "$serverUrl/:/scrobble", + token = token, + key = key, + identifier = identifier +) + +enum class PlaybackState(val value: String) { + PLAYING("playing"), + PAUSED("paused"), + STOPPED("stopped") +}