diff --git a/CHANGELOG.md b/CHANGELOG.md index 035ee70..7b622e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.2.40 — 2026-08-10 +- Improved: Video playback recovers more reliably from decoder failures and prolonged buffering. +- Improved: Memby's short pre-roll is prepared in advance for faster playback starts. +- Added: A libmpv compatibility fallback for videos Media3 cannot decode or read. +- Fixed: Shows opened from the upcoming schedule now resume from the viewer's latest episode progress. + ## 0.2.39 — 2026-08-09 - Improved: HEVC playback performance - Added: Cast panels now mark deceased performers and open biographies and filmographies. diff --git a/NOTICE b/NOTICE index 7571b78..2c9048f 100644 --- a/NOTICE +++ b/NOTICE @@ -38,6 +38,11 @@ source distributions and packaged dependency metadata. Those components remain governed by their own licences; GPLv2 applies to Memby's original and combined application code as required by the licence. +The compatibility playback path uses mpv-android-lib, an Android libmpv +wrapper distributed under the MIT License: + +https://github.com/abdallahmehiz/mpv-android + Memby is an independent project and is not affiliated with or endorsed by Emby LLC. Emby is a trademark of Emby LLC. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a1bc686..5a07e7e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,7 +42,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.39" +val defaultVersionName = "0.2.40" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() @@ -227,6 +227,11 @@ dependencies { // media3's own HttpURLConnection client — see ui/player/PlayerEngine.kt. implementation("androidx.media3:media3-datasource-okhttp:1.5.1") + // Software-decoding fallback after Media3 exhausts its codec/container recovery. + // Kept out of the normal path: Android TV still gets hardware decode, passthrough, + // the full Memby OSD and the pre-roll from Media3 whenever the device can play it. + implementation("io.github.abdallahmehiz:mpv-android-lib:0.1.12") + debugImplementation("androidx.compose.ui:ui-tooling") // Ahead-of-time compiles the startup + first-scroll path. Regenerate against a real diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 7542908..72c9d61 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -38,6 +38,11 @@ -dontwarn androidx.media3.** -dontwarn kotlinx.coroutines.** +# --- libmpv JNI ---------------------------------------------------------------------- +# The published wrapper has no consumer rules. Its native bridge looks these classes and +# callbacks up by their compiled names, so a release build must not rename either side. +-keep class is.xyz.mpv.** { *; } + # --- Crash readability --------------------------------------------------------------- # Releases are self-hosted with no crash reporter, so a stack trace read off a TV over # adb is the only diagnostic there is. Line numbers cost a little dex size and are worth diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 98f8653..4e42542 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -104,6 +104,16 @@ android:theme="@style/Theme.Memby.Fullscreen" tools:ignore="DiscouragedApi" /> + + + , season: Int?): List = else episodes.filter { it.parentIndexNumber == season }.sortedWith(seriesEpisodeComparator) /** - * What the viewer should watch next: whatever is part-watched, else the first unwatched - * episode. Specials are skipped while any numbered season exists — season 0 sorts first - * but is almost never where someone is up to. + * What the viewer should watch next, based on the furthest episode they have reached. + * An old resume marker behind later viewing must not pull them backwards — this matters + * when a schedule card opens a show whose early episode still has a stale position. + * Specials are skipped while any numbered season exists because season 0 sorts first but + * is almost never where someone is up to. */ fun nextEpisodeToWatch(episodes: List): BaseItem? { if (episodes.isEmpty()) return null @@ -301,8 +303,12 @@ fun nextEpisodeToWatch(episodes: List): BaseItem? { } else { ordered } - return candidates.firstOrNull { it.isResumable && !it.isPlayed } - ?: candidates.firstOrNull { !it.isPlayed } + val furthestProgress = candidates.indexOfLast { it.isPlayed || it.isResumable } + if (furthestProgress < 0) return candidates.firstOrNull { !it.isPlayed } + + val current = candidates[furthestProgress] + return current.takeIf { it.isResumable && !it.isPlayed } + ?: candidates.drop(furthestProgress + 1).firstOrNull { !it.isPlayed } } /** The season to open on: the one holding [nextEpisodeToWatch], else the earliest. */ diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/MpvFallbackActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/MpvFallbackActivity.kt new file mode 100644 index 0000000..4e2e546 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/MpvFallbackActivity.kt @@ -0,0 +1,327 @@ +package com.ponzischeme89.memby.ui.player + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import android.util.TypedValue +import android.view.Gravity +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.lifecycle.lifecycleScope +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.PlayableSubtitle +import com.ponzischeme89.memby.data.PlaybackSession +import `is`.xyz.mpv.BaseMPVView +import `is`.xyz.mpv.MPV +import `is`.xyz.mpv.MPVNode +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.math.roundToLong + +/** + * A deliberately small libmpv safety net for a stream Media3 could not decode or demux. + * Media3 remains Memby's normal Android TV player; this activity owns only the final + * compatibility hand-off and the Emby check-ins needed to keep resume state accurate. + */ +class MpvFallbackActivity : ComponentActivity(), MPV.EventObserver { + private lateinit var mpv: MPV + private lateinit var mpvView: BaseMPVView + private lateinit var controls: TextView + private var progressJob: Job? = null + private var positionMs = 0L + private var durationMs = 0L + private var loaded = false + private var stopped = false + private var controlsVisible = true + + private val itemId by lazy { intent.getStringExtra(EXTRA_ITEM_ID).orEmpty() } + private val session by lazy { + PlaybackSession( + itemId = itemId, + mediaSourceId = intent.getStringExtra(EXTRA_MEDIA_SOURCE_ID).orEmpty().ifBlank { itemId }, + playSessionId = intent.getStringExtra(EXTRA_PLAY_SESSION_ID).orEmpty(), + playMethod = intent.getStringExtra(EXTRA_PLAY_METHOD).orEmpty().ifBlank { "DirectPlay" }, + ) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val url = intent.getStringExtra(EXTRA_URL).orEmpty() + if (itemId.isBlank() || url.isBlank()) { + finish() + return + } + + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + + val root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) } + mpvView = object : BaseMPVView(this@MpvFallbackActivity, null) { + override fun initOptions() { + // auto-safe uses hardware where mpv trusts it and falls back to software + // when the same vendor decoder that failed Media3 is not viable. + mpv.setOptionString("hwdec", "auto-safe") + mpv.setOptionString("video-sync", "audio") + } + + override fun postInitOptions() { + mpv.setPropertyBoolean("pause", true) + } + + override fun observeProperties() { + mpv.observeProperty("time-pos", MPV.mpvFormat.MPV_FORMAT_DOUBLE) + mpv.observeProperty("duration", MPV.mpvFormat.MPV_FORMAT_DOUBLE) + mpv.observeProperty("pause", MPV.mpvFormat.MPV_FORMAT_FLAG) + } + } + root.addView( + mpvView, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ), + ) + controls = TextView(this).apply { + setTextColor(Color.WHITE) + setBackgroundColor(Color.argb(185, 8, 10, 12)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + gravity = Gravity.CENTER + setPadding(dp(24), dp(14), dp(24), dp(14)) + text = controlText(paused = true) + } + root.addView( + controls, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL, + ).apply { bottomMargin = dp(32) }, + ) + setContentView(root) + + runCatching { + mpvView.initialize( + filesDir.resolve("mpv").path, + cacheDir.resolve("mpv").path, + ) + mpv = mpvView.mpv + mpv.addObserver(this) + mpvView.setVo("gpu") + mpvView.playFile(url) + }.onFailure { error -> + Log.e(TAG, "event=libmpv_init_failed item=$itemId", error) + finish() + } + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean = when (keyCode) { + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + -> { + togglePause() + true + } + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_MEDIA_REWIND, + -> { + seekBy(-SEEK_SECONDS) + true + } + KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, + -> { + seekBy(SEEK_SECONDS) + true + } + KeyEvent.KEYCODE_MENU -> { + controlsVisible = !controlsVisible + controls.visibility = if (controlsVisible) View.VISIBLE else View.GONE + true + } + else -> super.onKeyDown(keyCode, event) + } + + private fun togglePause() { + val paused = mpv.getPropertyBoolean("pause") != false + mpv.setPropertyBoolean("pause", !paused) + controls.visibility = View.VISIBLE + controlsVisible = true + controls.text = controlText(paused = !paused) + reportProgress(if (!paused) "Pause" else "Unpause", isPaused = !paused) + } + + private fun seekBy(seconds: Int) { + mpv.command("seek", seconds.toString(), "relative+exact") + controls.visibility = View.VISIBLE + controlsVisible = true + controls.text = controlText(mpv.getPropertyBoolean("pause") != false) + reportProgress("TimeUpdate", mpv.getPropertyBoolean("pause") != false) + } + + private fun controlText(paused: Boolean): String { + val title = intent.getStringExtra(EXTRA_TITLE).orEmpty().ifBlank { "Compatibility playback" } + val state = if (paused) "Paused" else "Playing with libmpv" + return "$title\n$state · Left/Right seek · OK pause" + } + + private fun onFileLoaded() { + if (loaded) return + loaded = true + positionMs = intent.getLongExtra(EXTRA_POSITION_MS, 0L).coerceAtLeast(0L) + if (positionMs > 0L) mpv.setPropertyDouble("time-pos", positionMs / 1_000.0) + attachSelectedSubtitle() + mpv.setPropertyBoolean("pause", false) + controls.text = controlText(paused = false) + lifecycleScope.launch { + runCatching { ServiceLocator.repository.reportPlaybackStarted(session, positionMs) } + } + progressJob = lifecycleScope.launch { + while (isActive) { + delay(PROGRESS_INTERVAL_MS) + reportProgress("TimeUpdate", mpv.getPropertyBoolean("pause") != false) + } + } + } + + private fun attachSelectedSubtitle() { + val selectedId = intent.getStringExtra(EXTRA_SELECTED_SUBTITLE_ID).orEmpty() + if (selectedId.isBlank()) return + val subtitle = decodeSubtitles(intent.getStringExtra(EXTRA_SUBTITLES)) + .firstOrNull { it.id == selectedId && it.deliveryMethod.equals("External", true) } + ?: return + val title = subtitle.label?.takeIf(String::isNotBlank) ?: subtitle.language.orEmpty() + mpv.command("sub-add", subtitle.url, "select", title, subtitle.language.orEmpty()) + } + + private fun reportProgress(eventName: String, isPaused: Boolean) { + if (!loaded) return + lifecycleScope.launch { + runCatching { + ServiceLocator.repository.reportPlaybackProgress( + session = session, + positionMs = positionMs, + isPaused = isPaused, + eventName = eventName, + durationMs = durationMs, + ) + } + } + } + + override fun onStop() { + if (loaded && !stopped) { + stopped = true + reportProgress("Pause", isPaused = true) + PlaybackStopWorker.enqueue(this, session, positionMs) + } + if (::mpv.isInitialized) mpv.setPropertyBoolean("pause", true) + super.onStop() + } + + override fun onStart() { + super.onStart() + if (loaded && stopped) { + stopped = false + mpv.setPropertyBoolean("pause", false) + lifecycleScope.launch { + runCatching { ServiceLocator.repository.reportPlaybackStarted(session, positionMs) } + } + } + } + + override fun onDestroy() { + progressJob?.cancel() + if (::mpv.isInitialized) { + runCatching { mpv.removeObserver(this) } + runCatching { mpvView.destroy() } + } + super.onDestroy() + } + + override fun eventProperty(property: String) = Unit + override fun eventProperty(property: String, value: Long) = Unit + override fun eventProperty(property: String, value: Boolean) { + if (property == "pause") runOnUiThread { controls.text = controlText(value) } + } + override fun eventProperty(property: String, value: String) = Unit + override fun eventProperty(property: String, value: Double) { + when (property) { + "time-pos" -> positionMs = (value * 1_000.0).roundToLong().coerceAtLeast(0L) + "duration" -> durationMs = (value * 1_000.0).roundToLong().coerceAtLeast(0L) + } + } + override fun eventProperty(property: String, value: MPVNode) = Unit + override fun event(eventId: Int, data: MPVNode) { + when (eventId) { + MPV.mpvEvent.MPV_EVENT_FILE_LOADED -> runOnUiThread(::onFileLoaded) + MPV.mpvEvent.MPV_EVENT_END_FILE -> if (loaded) runOnUiThread(::finish) + } + } + + private fun dp(value: Int): Int = + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + value.toFloat(), + resources.displayMetrics, + ).roundToLong().toInt() + + companion object { + private const val TAG = "MembyPlayback" + private const val PROGRESS_INTERVAL_MS = 10_000L + private const val SEEK_SECONDS = 10 + private const val EXTRA_ITEM_ID = "mpv_item_id" + private const val EXTRA_TITLE = "mpv_title" + private const val EXTRA_URL = "mpv_url" + private const val EXTRA_POSITION_MS = "mpv_position_ms" + private const val EXTRA_SUBTITLES = "mpv_subtitles" + private const val EXTRA_SELECTED_SUBTITLE_ID = "mpv_selected_subtitle_id" + private const val EXTRA_MEDIA_SOURCE_ID = "mpv_media_source_id" + private const val EXTRA_PLAY_SESSION_ID = "mpv_play_session_id" + private const val EXTRA_PLAY_METHOD = "mpv_play_method" + private val json = Json { ignoreUnknownKeys = true } + + fun intent( + context: Context, + itemId: String, + title: String, + url: String, + positionMs: Long, + subtitles: List, + selectedSubtitleId: String, + mediaSourceId: String, + playSessionId: String, + playMethod: String, + ): Intent = Intent(context, MpvFallbackActivity::class.java).apply { + putExtra(EXTRA_ITEM_ID, itemId) + putExtra(EXTRA_TITLE, title) + putExtra(EXTRA_URL, url) + putExtra(EXTRA_POSITION_MS, positionMs.coerceAtLeast(0L)) + if (subtitles.isNotEmpty()) putExtra(EXTRA_SUBTITLES, json.encodeToString(subtitles)) + putExtra(EXTRA_SELECTED_SUBTITLE_ID, selectedSubtitleId) + putExtra(EXTRA_MEDIA_SOURCE_ID, mediaSourceId) + putExtra(EXTRA_PLAY_SESSION_ID, playSessionId) + putExtra(EXTRA_PLAY_METHOD, playMethod) + } + + private fun decodeSubtitles(encoded: String?): List = + encoded?.let { + runCatching { json.decodeFromString>(it) }.getOrNull() + }.orEmpty() + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt index 76034ff..8047d4b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt @@ -88,6 +88,14 @@ internal fun automaticRetryDelayMs(attempt: Int): Long? = else -> null } +/** + * libmpv is the codec/container safety net, not a second network retry. Media3 first gets + * both automatic recovery attempts, including its lower-risk H.264 stream; only a local + * format failure that survives those attempts is handed over. + */ +internal fun shouldUseLibmpvFallback(failure: PlaybackFailure, completedAttempts: Int): Boolean = + failure.requiresTranscode && automaticRetryDelayMs(completedAttempts + 1) == null + /** One prolonged mid-programme rebuffer gets a lower-risk H.264 stream. */ internal fun shouldRecoverProlongedRebuffer( renderedFirstFrame: Boolean, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index ce146a0..7082b53 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -142,6 +142,7 @@ class PlayerActivity : ComponentActivity() { private var prolongedRebufferRecoveryAttempted = false private var automaticRetryAttempt = 0 private var renderedFirstFrame = false + private var handingOffToLibmpv = false private var requestStartedAtMs = 0L private var trace = PlaybackTrace(SystemClock.elapsedRealtime(), SystemClock::elapsedRealtime) @@ -1234,9 +1235,41 @@ class PlayerActivity : ComponentActivity() { return } + if (shouldUseLibmpvFallback(failure, automaticRetryAttempt) && handOffToLibmpv()) return showPlaybackError(failure) } + /** Continue the same Emby session in libmpv without a second pre-roll or stop report. */ + private fun handOffToLibmpv(): Boolean { + val playback = player ?: return false + val id = itemId?.takeIf(String::isNotBlank) ?: return false + val url = playback.currentMediaItem?.localConfiguration?.uri?.toString() + ?.takeIf(String::isNotBlank) ?: return false + val positionMs = playback.currentPosition.coerceAtLeast(0L) + handingOffToLibmpv = true + Log.w( + PLAYBACK_LOG_TAG, + "event=libmpv_fallback item=$id positionMs=$positionMs playMethod=$playMethod", + ) + startActivity( + MpvFallbackActivity.intent( + context = this, + itemId = id, + title = playbackTitle, + url = url, + positionMs = positionMs, + subtitles = availableSubtitles, + selectedSubtitleId = serverSubtitleId, + mediaSourceId = mediaSourceId, + playSessionId = playSessionId, + playMethod = playMethod, + ), + ) + playback.pause() + finish() + return true + } + private fun retryPlayback(refreshSource: Boolean, forceTranscode: Boolean = false) { retryJob?.cancel() prolongedRebufferJob?.cancel() @@ -3738,7 +3771,7 @@ class PlayerActivity : ComponentActivity() { // this title resumes from — is the one the viewer had already skipped past. commitSeek() player?.let { - if (playbackStarted && !stopReported) { + if (!handingOffToLibmpv && playbackStarted && !stopReported) { reportProgress(it.currentPosition, isPaused = true, eventName = "Pause") stopReported = true stoppedInBackground = true @@ -3788,7 +3821,7 @@ class PlayerActivity : ComponentActivity() { "bufferingMs=$totalBufferingMs positionMs=${player?.currentPosition ?: 0L}", ) val playback = player - if (!stopReported && playbackStarted && !itemId.isNullOrBlank()) { + if (!handingOffToLibmpv && !stopReported && playbackStarted && !itemId.isNullOrBlank()) { stopReported = true PlaybackStopWorker.enqueue( this, diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/SeriesDetailsTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/SeriesDetailsTest.kt index bb2800a..b7c98d7 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/SeriesDetailsTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/SeriesDetailsTest.kt @@ -5,6 +5,8 @@ import com.ponzischeme89.memby.data.model.UserItemData import com.ponzischeme89.memby.ui.detail.availableSeasons import com.ponzischeme89.memby.ui.detail.defaultSeason import com.ponzischeme89.memby.ui.detail.episodesForSeason +import com.ponzischeme89.memby.ui.detail.nextEpisodeToWatch +import com.ponzischeme89.memby.ui.detail.primaryActionLabel import com.ponzischeme89.memby.ui.detail.seasonLabel import org.junit.Assert.assertEquals import org.junit.Test @@ -15,13 +17,14 @@ class SeriesDetailsTest { season: Int, number: Int, played: Boolean = false, + resumeTicks: Long = 0, ) = BaseItem( id = id, name = "Episode $number", type = "Episode", parentIndexNumber = season, indexNumber = number, - userData = UserItemData(played = played), + userData = UserItemData(played = played, playbackPositionTicks = resumeTicks), ) @Test @@ -58,6 +61,31 @@ class SeriesDetailsTest { assertEquals(2, defaultSeason(episodes)) } + @Test + fun `an old resume marker does not pull schedule playback behind later progress`() { + val episodes = (1..7).map { number -> + episode( + id = "s3e$number", + season = 3, + number = number, + played = number in 2..6, + resumeTicks = when (number) { + 1 -> 60_000_000L + 7 -> 120_000_000L + else -> 0L + }, + ) + } + + val next = requireNotNull(nextEpisodeToWatch(episodes)) + + assertEquals("s3e7", next.id) + assertEquals("Resume S03E07", primaryActionLabel( + BaseItem(id = "series", name = "Series", type = "Series"), + next, + )) + } + @Test fun `season zero is labelled specials`() { assertEquals("Specials", seasonLabel(0)) diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt index 82cee08..e6a54cd 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt @@ -8,6 +8,27 @@ import org.junit.Assert.assertTrue import org.junit.Test class PlaybackRecoveryTest { + + @Test + fun `libmpv takes codec failures only after Media3 recovery is exhausted`() { + val codecFailure = describePlaybackFailure( + PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, + ) + + assertFalse(shouldUseLibmpvFallback(codecFailure, completedAttempts = 0)) + assertFalse(shouldUseLibmpvFallback(codecFailure, completedAttempts = 1)) + assertTrue(shouldUseLibmpvFallback(codecFailure, completedAttempts = 2)) + } + + @Test + fun `libmpv does not replace Media3 network recovery`() { + val networkFailure = describePlaybackFailure( + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + ) + + assertFalse(shouldUseLibmpvFallback(networkFailure, completedAttempts = 2)) + } + @Test fun networkFailuresAreSafeToRetry() { val failure = describePlaybackFailure( diff --git a/server/README.md b/server/README.md index e69b032..b2c1141 100644 --- a/server/README.md +++ b/server/README.md @@ -324,13 +324,13 @@ maintenance switch and row engagement. Set `MEMBY_ADMIN_TOKEN` to enable it; uns `/admin` route 404s so it cannot be left exposed by accident. The page first uses the same discreet Emby login gate as the private installer. After successful verification it establishes the HttpOnly admin cookie, but browser API requests require both that cookie -and the current 30-minute Emby-verified session. The old admin cookie therefore cannot +and the current 12-hour Emby-verified session. The old admin cookie therefore cannot bypass the gate after the browser session expires. Scripts may continue to use `Authorization: Bearer ` without a browser session. -That 30 minutes is idle time, not a hard limit: opening an admin page, making any change, +Those 12 hours are idle time, not a hard limit: opening an admin page, making any change, or reading one while interacting with it slides the expiry forward once it is inside the -last fifteen minutes. What deliberately does **not** extend it is the page's own status +last six hours. What deliberately does **not** extend it is the page's own status poll — a console left open on a second monitor still times out, which is the whole point of the TTL. The page marks its own requests with `X-Memby-Admin-Active` when there has been interaction in the last five minutes, and on a 401 it reloads, so an expiry lands as @@ -339,6 +339,7 @@ the sign-in form with `next` pointing back at the page rather than as an error b | Method | Path | Purpose | | --- | --- | --- | | GET | `/admin/` | The page | +| POST | `/admin/logout` | End the browser admin session | | GET | `/admin/api/status` | Library counts, sync history, maintenance state | | GET | `/admin/api/runtime` | Protected Go heap, memory-limit and goroutine metrics | | POST | `/admin/api/sync` | `{"kind":"full"}` or `{"kind":"incremental"}` | diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index 5adab99..8622acd 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -33,6 +33,7 @@ func (s *Server) adminRoutes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /admin/{$}", s.handleAdminRoot) + mux.HandleFunc("POST /admin/logout", s.handleAdminLogout) mux.HandleFunc("GET /admin/{page}", s.handleAdminPage) // One person's own page. It is a path rather than a query string so it can be linked, // bookmarked and returned to after a sign-in, like every other page here. @@ -148,7 +149,7 @@ func (s *Server) adminAuth(h http.HandlerFunc) http.Handler { return } if browser && operatorPresent(r) { - s.renewInstallerSession(w, r) + s.renewAdminSession(w, r) } h(w, r) }) @@ -213,7 +214,7 @@ func (s *Server) serveAdminPage(w http.ResponseWriter, r *http.Request, page, ne return } // Opening a page is somebody at the keyboard, so it starts the clock again. - s.renewInstallerSession(w, r) + s.renewAdminSession(w, r) secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") http.SetCookie(w, &http.Cookie{ Name: adminCookieName, @@ -230,6 +231,24 @@ func (s *Server) serveAdminPage(w http.ResponseWriter, r *http.Request, page, ne _, _ = w.Write(body) } +func (s *Server) handleAdminLogout(w http.ResponseWriter, r *http.Request) { + if s.cfg.AdminToken == "" { + http.NotFound(w, r) + return + } + s.clearInstallerCookie(w) + secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") + http.SetCookie(w, &http.Cookie{ + Name: adminCookieName, + Path: "/admin", + MaxAge: -1, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteStrictMode, + }) + http.Redirect(w, r, "/admin/", http.StatusSeeOther) +} + type adminStatus struct { // ServerVersion is what the page's footer reports. An operator reading the live log // needs to know which build wrote it, and the page is the one place that is asked. diff --git a/server/internal/api/admin/admin.css b/server/internal/api/admin/admin.css index 438d8a6..ebc4625 100644 --- a/server/internal/api/admin/admin.css +++ b/server/internal/api/admin/admin.css @@ -122,12 +122,16 @@ a { color: var(--accent-ink); } width: 3px; height: 18px; border-radius: 0 3px 3px 0; background: var(--accent); } .rail-foot { - display: flex; align-items: center; gap: 9px; + display: grid; gap: 8px; padding: 12px 12px 0; border-top: 1px solid var(--line); } +.rail-status { display: flex; align-items: center; gap: 9px; min-width: 0; } .rail-foot-copy { min-width: 0; } .rail-foot-copy b { display: block; font-size: 12px; font-weight: 600; } .rail-foot-copy span { display: block; color: var(--quiet); font-size: 11px; } +.rail-foot form { margin: 0; } +.rail-logout { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; } +.rail-logout .glyph { width: 22px; height: 22px; background: none; } /* ---------- page ---------- */ @@ -485,9 +489,10 @@ details[open] summary { margin-bottom: 8px; } :root { --rail: 62px; } .rail { padding: 16px 7px 10px; } .rail-brand { justify-content: center; padding: 0 0 14px; } - .rail-brand-copy, .rail-group, .rail-link span, .rail-foot-copy { display: none; } + .rail-brand-copy, .rail-group, .rail-link span, .rail-foot-copy, .rail-logout span { display: none; } .rail-link { justify-content: center; padding: 0; min-height: 38px; } - .rail-foot { justify-content: center; padding: 12px 0 0; } + .rail-foot { justify-items: center; padding: 12px 0 0; } + .rail-logout { width: auto; padding: 5px; } .grid.wide { grid-template-columns: 1fr; } .page { padding: 20px 14px 44px; } .table-wrap { margin: 0 -20px -18px; } diff --git a/server/internal/api/admin/core.js b/server/internal/api/admin/core.js index e70426c..0ed6622 100644 --- a/server/internal/api/admin/core.js +++ b/server/internal/api/admin/core.js @@ -16,7 +16,7 @@ const Admin = (() => { /* ---- transport ------------------------------------------------------- */ - // The sign-in behind this page lasts thirty minutes and slides forward only for requests + // The sign-in behind this page lasts twelve hours and slides forward only for requests // an operator actually caused, so the poll of a tab nobody is reading cannot keep it // alive. Anything the console does while somebody is working it says so with this // header; see operatorPresent on the server. diff --git a/server/internal/api/admin/shell.html b/server/internal/api/admin/shell.html index df99fb0..ff31e84 100644 --- a/server/internal/api/admin/shell.html +++ b/server/internal/api/admin/shell.html @@ -27,8 +27,15 @@ {{end}}
- - connectinggateway … +
+ + connectinggateway … +
+
+ +
diff --git a/server/internal/api/admin_test.go b/server/internal/api/admin_test.go index 0f1db9d..b09e1ee 100644 --- a/server/internal/api/admin_test.go +++ b/server/internal/api/admin_test.go @@ -106,7 +106,7 @@ func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) { } // installerSessionExpiring mints a session with a chosen life left, which is the only way -// to reach the renewal window without waiting a quarter of an hour in a test. +// to reach the renewal window without waiting six hours in a test. func installerSessionExpiring(t *testing.T, s *Server, remaining time.Duration) *http.Cookie { t.Helper() payload := make([]byte, 8+16) @@ -137,7 +137,7 @@ func renewedCookie(rec *httptest.ResponseRecorder) *http.Cookie { // The admin sign-in used to be an absolute half hour: an operator was signed out from // under themselves mid-edit, and the console's poll then reported "invalid admin token" -// with no way back to a login. +// with no way back to a login. A renewed admin session now lasts a full working day. func TestAdminSessionIsExtendedWhileTheOperatorIsWorking(t *testing.T) { server := testServer(config.Config{ AdminToken: "secret", ReleasePublishToken: "release-secret", @@ -158,10 +158,14 @@ func TestAdminSessionIsExtendedWhileTheOperatorIsWorking(t *testing.T) { if cookie == nil { t.Fatal("expected a refreshed installer cookie") } + if cookie.MaxAge != int(adminSessionTTL/time.Second) { + t.Fatalf("renewed admin cookie MaxAge = %d, want %d", + cookie.MaxAge, int(adminSessionTTL/time.Second)) + } follow := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil) follow.AddCookie(cookie) expires, ok := server.installerSessionExpiry(follow) - if !ok || time.Until(expires) < installerSessionTTL-time.Minute { + if !ok || time.Until(expires) < adminSessionTTL-time.Minute { t.Fatalf("renewed session should carry a full TTL, has %v (ok=%v)", time.Until(expires), ok) } @@ -198,7 +202,7 @@ func TestAdminSessionIsNotRewrittenWhileItIsStillFresh(t *testing.T) { w.WriteHeader(http.StatusOK) }) - req := adminRequest(server, installerSessionTTL-time.Minute, t) + req := adminRequest(server, adminSessionTTL-time.Minute, t) req.Header.Set(adminActivityHeader, "1") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -456,12 +460,16 @@ func TestAdminPageEstablishesPersistentCookie(t *testing.T) { server.adminRoutes().ServeHTTP(rec, req) - result := rec.Result() - cookies := result.Cookies() - if len(cookies) != 1 { - t.Fatalf("expected one admin cookie, got %d", len(cookies)) + var cookie *http.Cookie + for _, candidate := range rec.Result().Cookies() { + if candidate.Name == adminCookieName { + cookie = candidate + break + } + } + if cookie == nil { + t.Fatal("admin page did not establish its persistent cookie") } - cookie := cookies[0] if cookie.Name != adminCookieName || cookie.Value != "secret" { t.Fatalf("unexpected admin cookie: %#v", cookie) } @@ -473,6 +481,48 @@ func TestAdminPageEstablishesPersistentCookie(t *testing.T) { } } +func TestAdminPageOffersLogout(t *testing.T) { + server := testServer(config.Config{ + AdminToken: "secret", ReleasePublishToken: "release-secret", + }) + req := httptest.NewRequest(http.MethodGet, "/admin/overview", nil) + addInstallerSession(t, server, req) + rec := httptest.NewRecorder() + + server.adminRoutes().ServeHTTP(rec, req) + + if !strings.Contains(rec.Body.String(), `method="post" action="/admin/logout"`) || + !strings.Contains(rec.Body.String(), ">Log out") { + t.Fatal("admin shell does not offer logout") + } +} + +func TestAdminLogoutClearsBothBrowserCookies(t *testing.T) { + server := testServer(config.Config{ + AdminToken: "secret", ReleasePublishToken: "release-secret", + }) + req := httptest.NewRequest(http.MethodPost, "https://memby.local/admin/logout", nil) + rec := httptest.NewRecorder() + + server.adminRoutes().ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" { + t.Fatalf("logout = %d %q, want 303 to admin gate", rec.Code, rec.Header().Get("Location")) + } + cleared := map[string]*http.Cookie{} + for _, cookie := range rec.Result().Cookies() { + cleared[cookie.Name] = cookie + } + for name, path := range map[string]string{ + installerCookieName: "/", adminCookieName: "/admin", + } { + cookie := cleared[name] + if cookie == nil || cookie.MaxAge >= 0 || cookie.Path != path || !cookie.HttpOnly || !cookie.Secure { + t.Fatalf("logout did not clear %s safely: %#v", name, cookie) + } + } +} + func TestAdminAuthAcceptsPersistentCookie(t *testing.T) { server := testServer(config.Config{ AdminToken: "secret", ReleasePublishToken: "release-secret", diff --git a/server/internal/api/installer_auth.go b/server/internal/api/installer_auth.go index 69517ec..10c418c 100644 --- a/server/internal/api/installer_auth.go +++ b/server/internal/api/installer_auth.go @@ -17,13 +17,13 @@ import ( const ( installerCookieName = "memby_installer" installerSessionTTL = 30 * time.Minute + adminSessionTTL = 12 * time.Hour installerDeviceID = "memby-web-installer" installerDeviceName = "Memby Web Installer" - // installerRenewWithin is how close to expiry a session must be before an operator's - // own request re-issues it. Half the TTL, so a cookie is rewritten at most once every - // fifteen minutes rather than on every request of a working session. - installerRenewWithin = installerSessionTTL / 2 + // adminRenewWithin is how close to expiry a session must be before an operator's own + // request re-issues it. Half the TTL avoids rewriting the cookie on every request. + adminRenewWithin = adminSessionTTL / 2 ) func (s *Server) installerSecret() []byte { @@ -45,8 +45,12 @@ func (s *Server) signInstallerValue(purpose string, payload []byte) []byte { } func (s *Server) newInstallerSession() (string, error) { + return s.newBrowserSession(installerSessionTTL) +} + +func (s *Server) newBrowserSession(ttl time.Duration) (string, error) { payload := make([]byte, 8+16) - binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(installerSessionTTL).Unix())) + binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(ttl).Unix())) if _, err := rand.Read(payload[8:]); err != nil { return "", err } @@ -79,7 +83,7 @@ func (s *Server) installerSessionExpiry(r *http.Request) (time.Time, bool) { } expires := int64(binary.BigEndian.Uint64(payload[:8])) now := time.Now().Unix() - if expires <= now || expires > now+int64(installerSessionTTL/time.Second)+60 { + if expires <= now || expires > now+int64(adminSessionTTL/time.Second)+60 { return time.Time{}, false } return time.Unix(expires, 0), true @@ -90,31 +94,35 @@ func (s *Server) validInstallerSession(r *http.Request) bool { return ok } -// renewInstallerSession slides a valid session's expiry forward. The TTL was absolute and +// renewAdminSession slides a valid session's expiry forward. The TTL was absolute and // nothing extended it, so an operator working the admin console was signed out from under -// themselves after thirty minutes and the page's poll became a permanent "invalid admin +// themselves and the page's poll became a permanent "invalid admin // token" banner with no sign-in to return to. Callers must only reach here for a request // an operator actually made — see operatorPresent — or an abandoned tab's own polling // would keep the session alive indefinitely, which is what the TTL exists to stop. -func (s *Server) renewInstallerSession(w http.ResponseWriter, r *http.Request) { +func (s *Server) renewAdminSession(w http.ResponseWriter, r *http.Request) { expires, ok := s.installerSessionExpiry(r) - if !ok || time.Until(expires) > installerRenewWithin { + if !ok || time.Until(expires) > adminRenewWithin { return } - session, err := s.newInstallerSession() + session, err := s.newBrowserSession(adminSessionTTL) if err != nil { s.loggerFor(r.Context()).Error("installer session renewal failed", "error", err) return } - s.setInstallerCookie(w, session) + s.setBrowserSessionCookie(w, session, adminSessionTTL) } func (s *Server) setInstallerCookie(w http.ResponseWriter, value string) { + s.setBrowserSessionCookie(w, value, installerSessionTTL) +} + +func (s *Server) setBrowserSessionCookie(w http.ResponseWriter, value string, ttl time.Duration) { http.SetCookie(w, &http.Cookie{ Name: installerCookieName, Value: value, Path: "/", - MaxAge: int(installerSessionTTL / time.Second), + MaxAge: int(ttl / time.Second), HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, @@ -210,13 +218,17 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) { return } - session, err := s.newInstallerSession() + ttl := installerSessionTTL + if strings.HasPrefix(next, "/admin/") { + ttl = adminSessionTTL + } + session, err := s.newBrowserSession(ttl) if err != nil { s.loggerFor(r.Context()).Error("installer session generation failed", "error", err) writeError(w, http.StatusInternalServerError, "could not start installer session") return } - s.setInstallerCookie(w, session) + s.setBrowserSessionCookie(w, session, ttl) http.Redirect(w, r, next, http.StatusSeeOther) }