0.2.40
This commit is contained in:
@@ -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
|
## 0.2.39 — 2026-08-09
|
||||||
- Improved: HEVC playback performance
|
- Improved: HEVC playback performance
|
||||||
- Added: Cast panels now mark deceased performers and open biographies and filmographies.
|
- Added: Cast panels now mark deceased performers and open biographies and filmographies.
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ source distributions and packaged dependency metadata. Those components
|
|||||||
remain governed by their own licences; GPLv2 applies to Memby's original
|
remain governed by their own licences; GPLv2 applies to Memby's original
|
||||||
and combined application code as required by the licence.
|
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
|
Memby is an independent project and is not affiliated with or endorsed by
|
||||||
Emby LLC. Emby is a trademark of Emby LLC.
|
Emby LLC. Emby is a trademark of Emby LLC.
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ val projectNoticeText =
|
|||||||
|
|
||||||
// A release workflow can derive the app version from its Git tag without editing the
|
// 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.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.2.39"
|
val defaultVersionName = "0.2.40"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
@@ -227,6 +227,11 @@ dependencies {
|
|||||||
// media3's own HttpURLConnection client — see ui/player/PlayerEngine.kt.
|
// media3's own HttpURLConnection client — see ui/player/PlayerEngine.kt.
|
||||||
implementation("androidx.media3:media3-datasource-okhttp:1.5.1")
|
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")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
|
||||||
// Ahead-of-time compiles the startup + first-scroll path. Regenerate against a real
|
// Ahead-of-time compiles the startup + first-scroll path. Regenerate against a real
|
||||||
|
|||||||
Vendored
+5
@@ -38,6 +38,11 @@
|
|||||||
-dontwarn androidx.media3.**
|
-dontwarn androidx.media3.**
|
||||||
-dontwarn kotlinx.coroutines.**
|
-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 ---------------------------------------------------------------
|
# --- Crash readability ---------------------------------------------------------------
|
||||||
# Releases are self-hosted with no crash reporter, so a stack trace read off a TV over
|
# 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
|
# adb is the only diagnostic there is. Line numbers cost a little dex size and are worth
|
||||||
|
|||||||
@@ -104,6 +104,16 @@
|
|||||||
android:theme="@style/Theme.Memby.Fullscreen"
|
android:theme="@style/Theme.Memby.Fullscreen"
|
||||||
tools:ignore="DiscouragedApi" />
|
tools:ignore="DiscouragedApi" />
|
||||||
|
|
||||||
|
<!-- libmpv is a last-resort codec/container fallback. It receives an already
|
||||||
|
resolved stream only after Media3 recovery has been exhausted. -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.player.MpvFallbackActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="landscape"
|
||||||
|
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|orientation"
|
||||||
|
android:theme="@style/Theme.Memby.Fullscreen"
|
||||||
|
tools:ignore="DiscouragedApi" />
|
||||||
|
|
||||||
<!-- The system screensaver (Daydream / Ambient mode source).
|
<!-- The system screensaver (Daydream / Ambient mode source).
|
||||||
Interactive: select to open the panel, play, or favourite. -->
|
Interactive: select to open the panel, play, or favourite. -->
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -288,9 +288,11 @@ fun episodesForSeason(episodes: List<BaseItem>, season: Int?): List<BaseItem> =
|
|||||||
else episodes.filter { it.parentIndexNumber == season }.sortedWith(seriesEpisodeComparator)
|
else episodes.filter { it.parentIndexNumber == season }.sortedWith(seriesEpisodeComparator)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the viewer should watch next: whatever is part-watched, else the first unwatched
|
* What the viewer should watch next, based on the furthest episode they have reached.
|
||||||
* episode. Specials are skipped while any numbered season exists — season 0 sorts first
|
* An old resume marker behind later viewing must not pull them backwards — this matters
|
||||||
* but is almost never where someone is up to.
|
* 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>): BaseItem? {
|
fun nextEpisodeToWatch(episodes: List<BaseItem>): BaseItem? {
|
||||||
if (episodes.isEmpty()) return null
|
if (episodes.isEmpty()) return null
|
||||||
@@ -301,8 +303,12 @@ fun nextEpisodeToWatch(episodes: List<BaseItem>): BaseItem? {
|
|||||||
} else {
|
} else {
|
||||||
ordered
|
ordered
|
||||||
}
|
}
|
||||||
return candidates.firstOrNull { it.isResumable && !it.isPlayed }
|
val furthestProgress = candidates.indexOfLast { it.isPlayed || it.isResumable }
|
||||||
?: candidates.firstOrNull { !it.isPlayed }
|
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. */
|
/** The season to open on: the one holding [nextEpisodeToWatch], else the earliest. */
|
||||||
|
|||||||
@@ -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<PlayableSubtitle>,
|
||||||
|
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<PlayableSubtitle> =
|
||||||
|
encoded?.let {
|
||||||
|
runCatching { json.decodeFromString<List<PlayableSubtitle>>(it) }.getOrNull()
|
||||||
|
}.orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,14 @@ internal fun automaticRetryDelayMs(attempt: Int): Long? =
|
|||||||
else -> null
|
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. */
|
/** One prolonged mid-programme rebuffer gets a lower-risk H.264 stream. */
|
||||||
internal fun shouldRecoverProlongedRebuffer(
|
internal fun shouldRecoverProlongedRebuffer(
|
||||||
renderedFirstFrame: Boolean,
|
renderedFirstFrame: Boolean,
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private var prolongedRebufferRecoveryAttempted = false
|
private var prolongedRebufferRecoveryAttempted = false
|
||||||
private var automaticRetryAttempt = 0
|
private var automaticRetryAttempt = 0
|
||||||
private var renderedFirstFrame = false
|
private var renderedFirstFrame = false
|
||||||
|
private var handingOffToLibmpv = false
|
||||||
private var requestStartedAtMs = 0L
|
private var requestStartedAtMs = 0L
|
||||||
private var trace = PlaybackTrace(SystemClock.elapsedRealtime(), SystemClock::elapsedRealtime)
|
private var trace = PlaybackTrace(SystemClock.elapsedRealtime(), SystemClock::elapsedRealtime)
|
||||||
|
|
||||||
@@ -1234,9 +1235,41 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldUseLibmpvFallback(failure, automaticRetryAttempt) && handOffToLibmpv()) return
|
||||||
showPlaybackError(failure)
|
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) {
|
private fun retryPlayback(refreshSource: Boolean, forceTranscode: Boolean = false) {
|
||||||
retryJob?.cancel()
|
retryJob?.cancel()
|
||||||
prolongedRebufferJob?.cancel()
|
prolongedRebufferJob?.cancel()
|
||||||
@@ -3738,7 +3771,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
// this title resumes from — is the one the viewer had already skipped past.
|
// this title resumes from — is the one the viewer had already skipped past.
|
||||||
commitSeek()
|
commitSeek()
|
||||||
player?.let {
|
player?.let {
|
||||||
if (playbackStarted && !stopReported) {
|
if (!handingOffToLibmpv && playbackStarted && !stopReported) {
|
||||||
reportProgress(it.currentPosition, isPaused = true, eventName = "Pause")
|
reportProgress(it.currentPosition, isPaused = true, eventName = "Pause")
|
||||||
stopReported = true
|
stopReported = true
|
||||||
stoppedInBackground = true
|
stoppedInBackground = true
|
||||||
@@ -3788,7 +3821,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
"bufferingMs=$totalBufferingMs positionMs=${player?.currentPosition ?: 0L}",
|
"bufferingMs=$totalBufferingMs positionMs=${player?.currentPosition ?: 0L}",
|
||||||
)
|
)
|
||||||
val playback = player
|
val playback = player
|
||||||
if (!stopReported && playbackStarted && !itemId.isNullOrBlank()) {
|
if (!handingOffToLibmpv && !stopReported && playbackStarted && !itemId.isNullOrBlank()) {
|
||||||
stopReported = true
|
stopReported = true
|
||||||
PlaybackStopWorker.enqueue(
|
PlaybackStopWorker.enqueue(
|
||||||
this,
|
this,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import com.ponzischeme89.memby.data.model.UserItemData
|
|||||||
import com.ponzischeme89.memby.ui.detail.availableSeasons
|
import com.ponzischeme89.memby.ui.detail.availableSeasons
|
||||||
import com.ponzischeme89.memby.ui.detail.defaultSeason
|
import com.ponzischeme89.memby.ui.detail.defaultSeason
|
||||||
import com.ponzischeme89.memby.ui.detail.episodesForSeason
|
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 com.ponzischeme89.memby.ui.detail.seasonLabel
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -15,13 +17,14 @@ class SeriesDetailsTest {
|
|||||||
season: Int,
|
season: Int,
|
||||||
number: Int,
|
number: Int,
|
||||||
played: Boolean = false,
|
played: Boolean = false,
|
||||||
|
resumeTicks: Long = 0,
|
||||||
) = BaseItem(
|
) = BaseItem(
|
||||||
id = id,
|
id = id,
|
||||||
name = "Episode $number",
|
name = "Episode $number",
|
||||||
type = "Episode",
|
type = "Episode",
|
||||||
parentIndexNumber = season,
|
parentIndexNumber = season,
|
||||||
indexNumber = number,
|
indexNumber = number,
|
||||||
userData = UserItemData(played = played),
|
userData = UserItemData(played = played, playbackPositionTicks = resumeTicks),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -58,6 +61,31 @@ class SeriesDetailsTest {
|
|||||||
assertEquals(2, defaultSeason(episodes))
|
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
|
@Test
|
||||||
fun `season zero is labelled specials`() {
|
fun `season zero is labelled specials`() {
|
||||||
assertEquals("Specials", seasonLabel(0))
|
assertEquals("Specials", seasonLabel(0))
|
||||||
|
|||||||
@@ -8,6 +8,27 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class PlaybackRecoveryTest {
|
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
|
@Test
|
||||||
fun networkFailuresAreSafeToRetry() {
|
fun networkFailuresAreSafeToRetry() {
|
||||||
val failure = describePlaybackFailure(
|
val failure = describePlaybackFailure(
|
||||||
|
|||||||
+4
-3
@@ -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
|
`/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
|
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
|
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
|
bypass the gate after the browser session expires. Scripts may continue to use
|
||||||
`Authorization: Bearer <MEMBY_ADMIN_TOKEN>` without a browser session.
|
`Authorization: Bearer <MEMBY_ADMIN_TOKEN>` 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
|
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
|
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
|
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
|
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 |
|
| Method | Path | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| GET | `/admin/` | The page |
|
| 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/status` | Library counts, sync history, maintenance state |
|
||||||
| GET | `/admin/api/runtime` | Protected Go heap, memory-limit and goroutine metrics |
|
| GET | `/admin/api/runtime` | Protected Go heap, memory-limit and goroutine metrics |
|
||||||
| POST | `/admin/api/sync` | `{"kind":"full"}` or `{"kind":"incremental"}` |
|
| POST | `/admin/api/sync` | `{"kind":"full"}` or `{"kind":"incremental"}` |
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ func (s *Server) adminRoutes() http.Handler {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.HandleFunc("GET /admin/{$}", s.handleAdminRoot)
|
mux.HandleFunc("GET /admin/{$}", s.handleAdminRoot)
|
||||||
|
mux.HandleFunc("POST /admin/logout", s.handleAdminLogout)
|
||||||
mux.HandleFunc("GET /admin/{page}", s.handleAdminPage)
|
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,
|
// 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.
|
// 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
|
return
|
||||||
}
|
}
|
||||||
if browser && operatorPresent(r) {
|
if browser && operatorPresent(r) {
|
||||||
s.renewInstallerSession(w, r)
|
s.renewAdminSession(w, r)
|
||||||
}
|
}
|
||||||
h(w, r)
|
h(w, r)
|
||||||
})
|
})
|
||||||
@@ -213,7 +214,7 @@ func (s *Server) serveAdminPage(w http.ResponseWriter, r *http.Request, page, ne
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Opening a page is somebody at the keyboard, so it starts the clock again.
|
// 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")
|
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: adminCookieName,
|
Name: adminCookieName,
|
||||||
@@ -230,6 +231,24 @@ func (s *Server) serveAdminPage(w http.ResponseWriter, r *http.Request, page, ne
|
|||||||
_, _ = w.Write(body)
|
_, _ = 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 {
|
type adminStatus struct {
|
||||||
// ServerVersion is what the page's footer reports. An operator reading the live log
|
// 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.
|
// needs to know which build wrote it, and the page is the one place that is asked.
|
||||||
|
|||||||
@@ -122,12 +122,16 @@ a { color: var(--accent-ink); }
|
|||||||
width: 3px; height: 18px; border-radius: 0 3px 3px 0; background: var(--accent);
|
width: 3px; height: 18px; border-radius: 0 3px 3px 0; background: var(--accent);
|
||||||
}
|
}
|
||||||
.rail-foot {
|
.rail-foot {
|
||||||
display: flex; align-items: center; gap: 9px;
|
display: grid; gap: 8px;
|
||||||
padding: 12px 12px 0; border-top: 1px solid var(--line);
|
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 { min-width: 0; }
|
||||||
.rail-foot-copy b { display: block; font-size: 12px; font-weight: 600; }
|
.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-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 ---------- */
|
/* ---------- page ---------- */
|
||||||
|
|
||||||
@@ -485,9 +489,10 @@ details[open] summary { margin-bottom: 8px; }
|
|||||||
:root { --rail: 62px; }
|
:root { --rail: 62px; }
|
||||||
.rail { padding: 16px 7px 10px; }
|
.rail { padding: 16px 7px 10px; }
|
||||||
.rail-brand { justify-content: center; padding: 0 0 14px; }
|
.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-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; }
|
.grid.wide { grid-template-columns: 1fr; }
|
||||||
.page { padding: 20px 14px 44px; }
|
.page { padding: 20px 14px 44px; }
|
||||||
.table-wrap { margin: 0 -20px -18px; }
|
.table-wrap { margin: 0 -20px -18px; }
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const Admin = (() => {
|
|||||||
|
|
||||||
/* ---- transport ------------------------------------------------------- */
|
/* ---- 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
|
// 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
|
// alive. Anything the console does while somebody is working it says so with this
|
||||||
// header; see operatorPresent on the server.
|
// header; see operatorPresent on the server.
|
||||||
|
|||||||
@@ -27,9 +27,16 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="rail-foot">
|
<div class="rail-foot">
|
||||||
|
<div class="rail-status">
|
||||||
<span class="dot" id="rail-live" data-tone="idle"></span>
|
<span class="dot" id="rail-live" data-tone="idle"></span>
|
||||||
<span class="rail-foot-copy"><b id="rail-live-label">connecting</b><span id="rail-version">gateway …</span></span>
|
<span class="rail-foot-copy"><b id="rail-live-label">connecting</b><span id="rail-version">gateway …</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
<form method="post" action="/admin/logout">
|
||||||
|
<button class="quiet small rail-logout" type="submit" data-icon="power" aria-label="Log out">
|
||||||
|
<span>Log out</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main id="page" class="page" data-admin-page="{{.Page.ID}}">
|
<main id="page" class="page" data-admin-page="{{.Page.ID}}">
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// installerSessionExpiring mints a session with a chosen life left, which is the only way
|
// 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 {
|
func installerSessionExpiring(t *testing.T, s *Server, remaining time.Duration) *http.Cookie {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
payload := make([]byte, 8+16)
|
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
|
// 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"
|
// 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) {
|
func TestAdminSessionIsExtendedWhileTheOperatorIsWorking(t *testing.T) {
|
||||||
server := testServer(config.Config{
|
server := testServer(config.Config{
|
||||||
AdminToken: "secret", ReleasePublishToken: "release-secret",
|
AdminToken: "secret", ReleasePublishToken: "release-secret",
|
||||||
@@ -158,10 +158,14 @@ func TestAdminSessionIsExtendedWhileTheOperatorIsWorking(t *testing.T) {
|
|||||||
if cookie == nil {
|
if cookie == nil {
|
||||||
t.Fatal("expected a refreshed installer cookie")
|
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 := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
|
||||||
follow.AddCookie(cookie)
|
follow.AddCookie(cookie)
|
||||||
expires, ok := server.installerSessionExpiry(follow)
|
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)",
|
t.Fatalf("renewed session should carry a full TTL, has %v (ok=%v)",
|
||||||
time.Until(expires), ok)
|
time.Until(expires), ok)
|
||||||
}
|
}
|
||||||
@@ -198,7 +202,7 @@ func TestAdminSessionIsNotRewrittenWhileItIsStillFresh(t *testing.T) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
|
|
||||||
req := adminRequest(server, installerSessionTTL-time.Minute, t)
|
req := adminRequest(server, adminSessionTTL-time.Minute, t)
|
||||||
req.Header.Set(adminActivityHeader, "1")
|
req.Header.Set(adminActivityHeader, "1")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
@@ -456,12 +460,16 @@ func TestAdminPageEstablishesPersistentCookie(t *testing.T) {
|
|||||||
|
|
||||||
server.adminRoutes().ServeHTTP(rec, req)
|
server.adminRoutes().ServeHTTP(rec, req)
|
||||||
|
|
||||||
result := rec.Result()
|
var cookie *http.Cookie
|
||||||
cookies := result.Cookies()
|
for _, candidate := range rec.Result().Cookies() {
|
||||||
if len(cookies) != 1 {
|
if candidate.Name == adminCookieName {
|
||||||
t.Fatalf("expected one admin cookie, got %d", len(cookies))
|
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" {
|
if cookie.Name != adminCookieName || cookie.Value != "secret" {
|
||||||
t.Fatalf("unexpected admin cookie: %#v", cookie)
|
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</span>") {
|
||||||
|
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) {
|
func TestAdminAuthAcceptsPersistentCookie(t *testing.T) {
|
||||||
server := testServer(config.Config{
|
server := testServer(config.Config{
|
||||||
AdminToken: "secret", ReleasePublishToken: "release-secret",
|
AdminToken: "secret", ReleasePublishToken: "release-secret",
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ import (
|
|||||||
const (
|
const (
|
||||||
installerCookieName = "memby_installer"
|
installerCookieName = "memby_installer"
|
||||||
installerSessionTTL = 30 * time.Minute
|
installerSessionTTL = 30 * time.Minute
|
||||||
|
adminSessionTTL = 12 * time.Hour
|
||||||
installerDeviceID = "memby-web-installer"
|
installerDeviceID = "memby-web-installer"
|
||||||
installerDeviceName = "Memby Web Installer"
|
installerDeviceName = "Memby Web Installer"
|
||||||
|
|
||||||
// installerRenewWithin is how close to expiry a session must be before an operator's
|
// adminRenewWithin is how close to expiry a session must be before an operator's own
|
||||||
// own request re-issues it. Half the TTL, so a cookie is rewritten at most once every
|
// request re-issues it. Half the TTL avoids rewriting the cookie on every request.
|
||||||
// fifteen minutes rather than on every request of a working session.
|
adminRenewWithin = adminSessionTTL / 2
|
||||||
installerRenewWithin = installerSessionTTL / 2
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) installerSecret() []byte {
|
func (s *Server) installerSecret() []byte {
|
||||||
@@ -45,8 +45,12 @@ func (s *Server) signInstallerValue(purpose string, payload []byte) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) newInstallerSession() (string, error) {
|
func (s *Server) newInstallerSession() (string, error) {
|
||||||
|
return s.newBrowserSession(installerSessionTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) newBrowserSession(ttl time.Duration) (string, error) {
|
||||||
payload := make([]byte, 8+16)
|
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 {
|
if _, err := rand.Read(payload[8:]); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -79,7 +83,7 @@ func (s *Server) installerSessionExpiry(r *http.Request) (time.Time, bool) {
|
|||||||
}
|
}
|
||||||
expires := int64(binary.BigEndian.Uint64(payload[:8]))
|
expires := int64(binary.BigEndian.Uint64(payload[:8]))
|
||||||
now := time.Now().Unix()
|
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.Time{}, false
|
||||||
}
|
}
|
||||||
return time.Unix(expires, 0), true
|
return time.Unix(expires, 0), true
|
||||||
@@ -90,31 +94,35 @@ func (s *Server) validInstallerSession(r *http.Request) bool {
|
|||||||
return ok
|
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
|
// 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
|
// 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
|
// 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.
|
// 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)
|
expires, ok := s.installerSessionExpiry(r)
|
||||||
if !ok || time.Until(expires) > installerRenewWithin {
|
if !ok || time.Until(expires) > adminRenewWithin {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
session, err := s.newInstallerSession()
|
session, err := s.newBrowserSession(adminSessionTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.loggerFor(r.Context()).Error("installer session renewal failed", "error", err)
|
s.loggerFor(r.Context()).Error("installer session renewal failed", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setInstallerCookie(w, session)
|
s.setBrowserSessionCookie(w, session, adminSessionTTL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) setInstallerCookie(w http.ResponseWriter, value string) {
|
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{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: installerCookieName,
|
Name: installerCookieName,
|
||||||
Value: value,
|
Value: value,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: int(installerSessionTTL / time.Second),
|
MaxAge: int(ttl / time.Second),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: true,
|
Secure: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
@@ -210,13 +218,17 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := s.newInstallerSession()
|
ttl := installerSessionTTL
|
||||||
|
if strings.HasPrefix(next, "/admin/") {
|
||||||
|
ttl = adminSessionTTL
|
||||||
|
}
|
||||||
|
session, err := s.newBrowserSession(ttl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.loggerFor(r.Context()).Error("installer session generation failed", "error", err)
|
s.loggerFor(r.Context()).Error("installer session generation failed", "error", err)
|
||||||
writeError(w, http.StatusInternalServerError, "could not start installer session")
|
writeError(w, http.StatusInternalServerError, "could not start installer session")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setInstallerCookie(w, session)
|
s.setBrowserSessionCookie(w, session, ttl)
|
||||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user