0.2.57 - Traliers bug fixes

This commit is contained in:
ponzischeme89
2026-08-12 13:08:53 +12:00
parent f2d052dbf6
commit 64f19aeef2
45 changed files with 1215 additions and 681 deletions
+9
View File
@@ -1,3 +1,12 @@
## 0.2.57 — 2026-08-12
- Fixed: Televisions now identify themselves to Emby as MbyATV everywhere, including in the playback devices list, instead of reporting the app's own name.
- Fixed: Signing in from the admin console or the web installer is now recorded by Emby as MbyGateway, so the server is no longer listed as though it were a television.
- Fixed: Traliers show the logo from the show in the on screen player vs text.
- Added: Smart search for next episode recap/preview episode from YouTube that plays 2 mins before show end
- Added: Optional setting for next episode recap/preview.
- Fixed: Server logs for playing traliers.
- Fixed: The source IP address for each tralier play should come from the Client's real IP address, not from the server.
## 0.2.56 — 2026-08-12
- Added: Films, series and supported seasons can now play trailers with one press.
- Improved: Memby prefers official Apple and YouTube trailers, plays them natively and automatically tries another source when one is unavailable, blocked or fails during playback.
+47 -10
View File
@@ -25,6 +25,24 @@ in Emby's dashboard read as the same build and there was no way to tell which se
behind. A request the gateway makes for itself (library sync, health probe, device cleanup)
carries no session, and reports the gateway's own `buildinfo.Version()` instead.
**The header is not the only thing Emby writes down.** The **device profile** sent with
`PlaybackInfo` is named in Emby's playback device list too, and it read `Memby Android TV`
on both paths — the product name, in the one place it must never be. It is now `MbyATV`
(`deviceProfileName` in `internal/emby/device_profile.go`, the literal in
`DeviceProfile.embyAndroidTv`), and like the header the two must agree or one television
playing both ways appears as two clients.
**And the gateway is not a television.** `Credentials.Gateway` marks a request the server
makes on its own behalf, and Emby records those under `MEMBY_GATEWAY_CLIENT_NAME`
(**`MbyGateway`**, `emby.DefaultGatewayClientName`) with a device name to match, so the
sync, the health probe, device cleanup and an operator signing into the admin console or
the web installer are separable from the sets in the house. Two things to preserve: the
flag is **stated, never inferred** from a missing token or version — an old APK reports
neither, and reading one as the server would file a television under the wrong name — and
a gateway request with no device name falls back to the gateway's name rather than to
`store.DefaultDeviceName`, the placeholder that made the server read as somebody's unnamed
TV.
`Emby*` class names (`EmbyRepository`, `EmbyApi`, `EmbyServiceFactory`, `EmbyModels`) are
kept on purpose: those types model *Emby's* API, and renaming them would make the code
lie about what it talks to. App-identity types are `Memby*`.
@@ -747,24 +765,31 @@ network answered. Things to preserve:
**Trailers are a provider chain, not ordinary title playback.** `GET /v1/items/{id}/trailers`
is the cheap availability answer used while a detail page is warmed; the Play press opens
`PlayerActivity` immediately and `POST /v1/items/{id}/trailers/resolve` selects one native
stream from `internal/trailer`. Official Apple and labelled official YouTube sources come
first, followed by local Emby media and then the remaining recognised remote trailers. The server validates direct
media before returning it and caches only successful mappings. The player keeps the subject
and rejected candidate ids: an error or an eight-second stall before the first frame asks for
the next provider behind the same loading surface. Exhausting the chain closes the player and
returns to the still-composed detail page; it never shows the ordinary playback error pane.
`PlayerActivity` immediately and `POST /v1/items/{id}/trailers/resolve` selects a candidate.
Official Apple and labelled official YouTube sources come first, followed by local Emby media
and then the remaining recognised remote trailers. Local media is returned directly; Apple
and YouTube pages are resolved and validated on the television, then played natively. That
boundary is deliberate: provider requests and any IP-bound media URL must originate from the
viewer's real client address, never the gateway. The player keeps the subject and rejected
candidate ids: an error or an eight-second stall before the first frame asks for the next
provider behind the same loading surface. Exhausting the chain closes the player and returns
to the still-composed detail page; it never shows the ordinary playback error pane.
Keep these boundaries:
- Metadata discovery may be warmed and cached, but local `PlaybackInfo` and remote stream
resolution begin only after the viewer presses Trailer.
- Provider details stay behind the resolver interface. Compose knows only whether a trailer
exists, and the player knows only how to ask for the next candidate.
exists, and the player knows only how to ask for the next candidate. The client resolver
caches only mappings that passed a media probe; a failed player source is evicted before
fallback.
- A trailer never uses the Memby pre-roll. Back and natural completion return to the screen
that launched it, while the normal native controls and aspect-ratio handling remain intact.
The player's title treatment receives the subject's logo URL, with text only as its fallback.
- Direct-to-Emby mode retains local trailers. Remote Apple and YouTube resolution belongs to
the gateway, which is the only surface that can validate and cache those mappings without
putting provider scraping or expiring URLs on every television.
the gateway-assisted path because it owns metadata discovery and candidate ordering, but
provider resolution itself stays on the television. `POST /v1/items/{id}/trailers/report`
records started, failed and completed attempts with the forwarded client address; successful
candidates become the gateway's next first choice only after a real first frame.
**A detail overlay seeds its settings from `repository.currentSettings`**, never
`Settings.EMPTY`. Collecting a flow with an empty initial value draws the first frame under
@@ -1317,6 +1342,18 @@ inside the running player instead of relaunching the activity, so `itemId`/`play
/`stopReported` must all be reset together or the outgoing episode is never reported
stopped; and a movie simply resolves to null, which is why nothing special-cases item type.
The optional **next-episode recap or preview** extends that same auto-advance path. Once the
next episode is known, the television searches YouTube in the background using series name,
episode code and title, ranks official previews and recaps above reactions, reviews and
breakdowns, resolves the best playable native stream, and caches the result. Playback never
waits for this work. If the setting is on and the playhead naturally crosses two minutes
remaining, the preview replaces the closing credits and then advances to the episode. Seeking
straight into that window does not trigger it. A failure before the first preview frame
silently restores the current episode at its saved position; a failure after that frame moves
on to the next episode, because the outgoing episode has already been closed. Preview time is
never reported to Emby as episode progress, and no YouTube account or embedded web player is
involved.
**Skipping is Left and Right, and it does not open anything.** `ui/player/SeekControls.kt`
holds the arithmetic and the wording; `PlayerActivity.dispatchKeyEvent` owns the keys and
`player_seek_indicator.xml` is the centred chip that says what just happened. How far one
+1 -1
View File
@@ -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.56"
val defaultVersionName = "0.2.57"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -0,0 +1,359 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.remote.HttpStack
import java.net.URI
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
data class ResolvedRemoteTrailer(
val candidateId: String,
val provider: String,
val sourceUrl: String,
val url: String,
val title: String = "",
)
private data class CachedRemoteTrailer(val value: ResolvedRemoteTrailer, val expiresAtMs: Long)
/**
* Resolves remote trailer pages on the television rather than on the gateway.
*
* YouTube's signed media URLs may be tied to the address that requested them. Keeping both
* resolution and playback here means the provider sees the television's real public address,
* and the URL handed to Media3 is valid from the network that will actually fetch it.
*/
class ClientTrailerResolver(
private val http: OkHttpClient = HttpStack.base.newBuilder()
.connectTimeout(6, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.callTimeout(10, TimeUnit.SECONDS)
.build(),
private val nowMs: () -> Long = System::currentTimeMillis,
) {
private val cache = ConcurrentHashMap<String, CachedRemoteTrailer>()
suspend fun resolve(provider: String, sourceUrl: String): ResolvedRemoteTrailer =
withContext(Dispatchers.IO) {
val normalProvider = provider.trim().lowercase()
val source = sourceUrl.trim()
val key = "$normalProvider\u0000$source"
cache[key]?.takeIf { it.expiresAtMs > nowMs() }?.value?.let { return@withContext it }
cache.remove(key)
val resolved = when (normalProvider) {
"apple" -> resolveApple(source)
"youtube" -> resolveYouTube(source)
else -> error("Unsupported trailer provider")
}
cache[key] = CachedRemoteTrailer(resolved, nowMs() + CACHE_TTL_MS)
resolved
}
suspend fun searchNextEpisodePreview(next: NextEpisode): ResolvedRemoteTrailer? =
withContext(Dispatchers.IO) {
val key = "preview\u0000${next.seriesName}\u0000${next.episodeCode}\u0000${next.title}"
cache[key]?.takeIf { it.expiresAtMs > nowMs() }?.value?.let { return@withContext it }
cache.remove(key)
val query = previewSearchQuery(next)
val candidates = searchYouTube(query)
.sortedByDescending { previewScore(it, next) }
.filter { previewScore(it, next) >= MIN_PREVIEW_SCORE }
.take(MAX_PREVIEW_CANDIDATES)
for (candidate in candidates) {
val result = runCatching {
resolveYouTube("https://www.youtube.com/watch?v=${candidate.videoId}")
.copy(title = candidate.title)
}.getOrNull() ?: continue
cache[key] = CachedRemoteTrailer(result, nowMs() + CACHE_TTL_MS)
return@withContext result
}
null
}
fun invalidate(provider: String, sourceUrl: String) {
val normalProvider = provider.trim().lowercase()
val source = sourceUrl.trim()
cache.remove("$normalProvider\u0000$source")
cache.entries.forEach { (key, entry) ->
if (entry.value.provider == normalProvider && entry.value.sourceUrl == source) {
cache.remove(key, entry)
}
}
}
private fun resolveApple(source: String): ResolvedRemoteTrailer {
require(isAppleHost(source)) { "Unrecognised Apple trailer address" }
val direct = if (looksLikeMedia(source)) {
source
} else {
val page = getText(source, "text/html")
APPLE_MEDIA.findAll(page)
.map { it.value.replace("\\/", "/").replace("&amp;", "&") }
.distinct()
.sortedByDescending(::mediaQuality)
.firstOrNull { validateMedia(it) }
?: error("No playable Apple trailer")
}
require(validateMedia(direct)) { "Apple trailer is unavailable" }
return ResolvedRemoteTrailer(
candidateId = candidateId("apple", source),
provider = "apple",
sourceUrl = source,
url = direct,
)
}
private fun resolveYouTube(source: String): ResolvedRemoteTrailer {
val videoId = youtubeVideoId(source) ?: error("Unrecognised YouTube trailer address")
val playerBodies = sequenceOf(
innerTubePlayer(videoId),
runCatching { watchPagePlayer(videoId) }.getOrNull(),
).filterNotNull()
for (body in playerBodies) {
val root = runCatching { JSON.parseToJsonElement(body).jsonObject }.getOrNull() ?: continue
if (root.objectAt("playabilityStatus")?.stringAt("status") != "OK") continue
val formats = root.objectAt("streamingData")?.arrayAt("formats").orEmpty()
.mapNotNull { it as? JsonObject }
.sortedWith(compareByDescending<JsonObject> { it.intAt("height") }
.thenByDescending { it.intAt("bitrate") })
for (format in formats) {
val direct = format.stringAt("url")
val mime = format.stringAt("mimeType")
if (direct.isBlank() || !mime.startsWith("video/")) continue
if (validateMedia(direct)) {
return ResolvedRemoteTrailer(
candidateId = candidateId("youtube", source),
provider = "youtube",
sourceUrl = source,
url = direct,
)
}
}
}
error("YouTube trailer is unavailable")
}
private fun innerTubePlayer(videoId: String): String? {
val payload = """{"videoId":"$videoId","contentCheckOk":true,"racyCheckOk":true,"context":{"client":{"clientName":"ANDROID","clientVersion":"20.10.38","hl":"en","gl":"NZ"}}}"""
val request = Request.Builder()
.url("https://www.youtube.com/youtubei/v1/player")
.header("User-Agent", YOUTUBE_ANDROID_AGENT)
.post(payload.toRequestBody(JSON_MEDIA))
.build()
return executeText(request)
}
private fun watchPagePlayer(videoId: String): String? {
val page = getText(
"https://www.youtube.com/watch?v=$videoId&bpctr=9999999999&has_verified=1",
"text/html",
)
return listOf("ytInitialPlayerResponse = ", "\"playerResponse\":")
.firstNotNullOfOrNull { marker -> balancedObjectAfter(page, marker) }
}
private fun searchYouTube(query: String): List<YouTubeSearchCandidate> {
val encoded = URLEncoder.encode(query, StandardCharsets.UTF_8.name())
val page = getText("https://www.youtube.com/results?search_query=$encoded&hl=en&gl=NZ", "text/html")
return balancedObjectsAfter(page, "\"videoRenderer\":").mapNotNull { raw ->
val item = runCatching { JSON.parseToJsonElement(raw).jsonObject }.getOrNull() ?: return@mapNotNull null
val videoId = item.stringAt("videoId").takeIf { it.length == 11 } ?: return@mapNotNull null
val title = item.rendererText("title").takeIf(String::isNotBlank) ?: return@mapNotNull null
val channel = item.rendererText("ownerText")
val seconds = durationSeconds(item.objectAt("lengthText")?.stringAt("simpleText").orEmpty())
YouTubeSearchCandidate(videoId, title, channel, seconds)
}.distinctBy(YouTubeSearchCandidate::videoId)
}
private fun getText(url: String, accept: String): String {
val request = Request.Builder().url(url)
.header("Accept", accept)
.header("User-Agent", WEB_AGENT)
.get().build()
return executeText(request) ?: error("Provider did not return a usable response")
}
private fun executeText(request: Request): String? = http.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) return@use null
response.peekBody(MAX_PAGE_BYTES).string()
}
private fun validateMedia(url: String): Boolean {
val request = runCatching {
Request.Builder().url(url)
.header("Range", "bytes=0-0")
.header("User-Agent", WEB_AGENT)
.get().build()
}.getOrNull() ?: return false
return runCatching {
http.newCall(request).execute().use { response ->
if (response.code != 200 && response.code != 206) return@use false
val type = response.header("Content-Type").orEmpty().substringBefore(';').lowercase()
type.startsWith("video/") || type in PLAYABLE_MANIFEST_TYPES || type == "application/octet-stream"
}
}.getOrDefault(false)
}
companion object {
private val JSON = Json { ignoreUnknownKeys = true }
private val JSON_MEDIA = "application/json".toMediaType()
private const val CACHE_TTL_MS = 30 * 60 * 1_000L
private const val MAX_PAGE_BYTES = 2L * 1024L * 1024L
private const val MAX_PREVIEW_CANDIDATES = 4
private const val MIN_PREVIEW_SCORE = 45
private const val YOUTUBE_ANDROID_AGENT =
"com.google.android.youtube/20.10.38 (Linux; U; Android 12) gzip"
private const val WEB_AGENT =
"Mozilla/5.0 (Linux; Android 12; Android TV) AppleWebKit/537.36 Chrome/122 Safari/537.36"
private val PLAYABLE_MANIFEST_TYPES = setOf(
"application/vnd.apple.mpegurl", "application/x-mpegurl",
)
private val APPLE_MEDIA = Regex("""https?:\\?/\\?/[^\"'<> ]+\.(?:mov|mp4|m3u8)(?:\?[^\"'<> ]*)?""", RegexOption.IGNORE_CASE)
}
}
internal data class YouTubeSearchCandidate(
val videoId: String,
val title: String,
val channel: String,
val durationSeconds: Int,
)
internal fun previewSearchQuery(next: NextEpisode): String = buildString {
append(next.seriesName.trim())
next.episodeCode?.takeIf(String::isNotBlank)?.let { append(' ').append(it.replace(" ", "")) }
next.title.takeIf(String::isNotBlank)?.let { append(" \"").append(it.trim()).append('"') }
append(" official preview recap")
}
internal fun previewScore(candidate: YouTubeSearchCandidate, next: NextEpisode): Int {
val haystack = "${candidate.title} ${candidate.channel}".lowercase()
var score = 0
val seriesTokens = next.seriesName.lowercase().split(Regex("[^a-z0-9]+"))
.filter { it.length >= 3 }
score += seriesTokens.count(haystack::contains) * 7
val code = next.episodeCode.orEmpty().lowercase().replace(" ", "")
if (code.isNotBlank() && haystack.replace(" ", "").contains(code)) score += 30
val titleTokens = next.title.lowercase().split(Regex("[^a-z0-9]+"))
.filter { it.length >= 4 }
score += titleTokens.count(haystack::contains) * 4
if ("preview" in haystack || "promo" in haystack || "next on" in haystack) score += 28
if ("recap" in haystack || "previously on" in haystack) score += 22
if ("official" in haystack) score += 12
if (listOf("reaction", "review", "breakdown", "fan made", "explained").any(haystack::contains)) score -= 45
if (candidate.durationSeconds in 20..600) score += 8
if (candidate.durationSeconds > 1_200) score -= 25
return score
}
internal fun youtubeVideoId(raw: String): String? {
val uri = runCatching { URI(raw.trim()) }.getOrNull() ?: return null
val host = uri.host?.lowercase()?.removePrefix("www.") ?: return null
val id = when {
host == "youtu.be" -> uri.path.trim('/')
host == "youtube.com" || host.endsWith(".youtube.com") ||
host == "youtube-nocookie.com" || host.endsWith(".youtube-nocookie.com") -> {
val queryId = uri.rawQuery.orEmpty().split('&').firstNotNullOfOrNull { pair ->
val parts = pair.split('=', limit = 2)
parts.getOrNull(1)?.takeIf { parts.firstOrNull() == "v" }
}
queryId ?: uri.path.trim('/').split('/').takeIf {
it.size == 2 && it.first() in setOf("embed", "shorts")
}?.last()
}
else -> null
}
return id?.takeIf { it.length == 11 && it.all { char -> char.isLetterOrDigit() || char == '-' || char == '_' } }
}
internal fun balancedObjectAfter(body: String, marker: String, fromIndex: Int = 0): String? {
val markerAt = body.indexOf(marker, fromIndex).takeIf { it >= 0 } ?: return null
val start = body.indexOf('{', markerAt + marker.length).takeIf { it >= 0 } ?: return null
var depth = 0
var quoted = false
var escaped = false
for (index in start until body.length) {
val char = body[index]
if (quoted) {
when {
escaped -> escaped = false
char == '\\' -> escaped = true
char == '"' -> quoted = false
}
continue
}
when (char) {
'"' -> quoted = true
'{' -> depth += 1
'}' -> if (--depth == 0) return body.substring(start, index + 1)
}
}
return null
}
internal fun balancedObjectsAfter(body: String, marker: String): List<String> {
val results = mutableListOf<String>()
var offset = 0
while (true) {
val markerAt = body.indexOf(marker, offset)
if (markerAt < 0) break
val objectAt = body.indexOf('{', markerAt + marker.length)
if (objectAt < 0) break
val value = balancedObjectAfter(body, marker, markerAt) ?: break
results += value
offset = objectAt + value.length
}
return results
}
private fun isAppleHost(raw: String): Boolean {
val host = runCatching { URI(raw).host?.lowercase()?.trimEnd('.') }.getOrNull() ?: return false
return host == "apple.com" || host.endsWith(".apple.com") || host == "apple.co" || host.endsWith(".apple.co")
}
private fun looksLikeMedia(raw: String): Boolean =
raw.substringBefore('?').lowercase().let { it.endsWith(".mov") || it.endsWith(".mp4") || it.endsWith(".m3u8") }
private fun mediaQuality(raw: String): Int =
listOf(2160, 1440, 1080, 720, 480, 360).firstOrNull { raw.contains(it.toString()) } ?: 0
private fun candidateId(provider: String, source: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest("$provider\u0000${source.trim()}".toByteArray())
return "$provider-" + digest.take(8).joinToString("") { "%02x".format(it) }
}
private fun durationSeconds(raw: String): Int {
val parts = raw.split(':').mapNotNull(String::toIntOrNull)
if (parts.isEmpty()) return 0
return parts.fold(0) { total, value -> total * 60 + value }
}
private fun JsonObject.stringAt(key: String): String =
this[key]?.jsonPrimitive?.contentOrNull.orEmpty()
private fun JsonObject.intAt(key: String): Int = this[key]?.jsonPrimitive?.intOrNull ?: 0
private fun JsonObject.objectAt(key: String): JsonObject? = this[key] as? JsonObject
private fun JsonObject.arrayAt(key: String): JsonArray? = this[key] as? JsonArray
private fun JsonObject.rendererText(key: String): String {
val renderer = objectAt(key) ?: return ""
renderer.stringAt("simpleText").takeIf(String::isNotBlank)?.let { return it }
return renderer.arrayAt("runs")?.firstOrNull()?.jsonObject?.stringAt("text").orEmpty()
}
@@ -211,6 +211,7 @@ data class PlaybackRequest(
)
class EmbyRepository(private val settings: SettingsStore) {
private val clientTrailerResolver = ClientTrailerResolver()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -1458,24 +1459,68 @@ class EmbyRepository(private val settings: SettingsStore) {
request: com.ponzischeme89.memby.data.model.TrailerPlaybackRequest,
): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback {
if (ServerConfig.isGateway) {
return runCatching {
var excluded = request.excludedCandidateIds.distinct()
repeat(MAX_TRAILER_CANDIDATES) {
val selected = runCatching {
requireGateway().resolveTrailer(
request.subjectId,
com.ponzischeme89.memby.data.model.GatewayTrailerResolveRequest(
request.excludedCandidateIds,
),
com.ponzischeme89.memby.data.model.GatewayTrailerResolveRequest(excluded),
)
}.recoverCatching { error ->
if (error is HttpException && error.code() == 404 && request.excludedCandidateIds.isEmpty()) {
if (error is HttpException && error.code() == 404 && excluded.isEmpty()) {
resolveLegacyLocalTrailer(request)
} else {
throw error
}
}.getOrThrow()
if (selected.sourceUrl.isBlank()) return selected
val resolved = runCatching {
clientTrailerResolver.resolve(selected.provider, selected.sourceUrl)
}.getOrElse { error ->
reportTrailer(
request.subjectId, selected.candidateId, selected.provider,
phase = "failed", reason = "resolve_${error.javaClass.simpleName}",
)
excluded = (excluded + selected.candidateId).distinct()
return@repeat
}
return selected.copy(url = resolved.url)
}
throw NoSuchElementException("No playable trailer is available")
}
return resolveLegacyLocalTrailer(request)
}
suspend fun nextEpisodePreview(next: NextEpisode): ResolvedRemoteTrailer? =
runCatching { clientTrailerResolver.searchNextEpisodePreview(next) }.getOrNull()
fun reportTrailer(
itemId: String,
candidateId: String,
provider: String,
phase: String,
reason: String = "",
) {
if (!ServerConfig.isGateway || itemId.isBlank() || candidateId.isBlank()) return
scope.launch {
runCatching {
requireGateway().reportTrailer(
itemId,
com.ponzischeme89.memby.data.model.GatewayTrailerReport(
candidateId = candidateId,
provider = provider,
phase = phase,
reason = reason,
),
)
}
}
}
fun rejectTrailerSource(provider: String, sourceUrl: String) {
if (sourceUrl.isNotBlank()) clientTrailerResolver.invalidate(provider, sourceUrl)
}
private suspend fun resolveLegacyLocalTrailer(
request: com.ponzischeme89.memby.data.model.TrailerPlaybackRequest,
): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback {
@@ -2848,6 +2893,7 @@ private const val RELATED_LIMIT = 12
* point of the cache is that walking back into a page never asks again.
*/
private const val TRAILER_CACHE_SIZE = 64
private const val MAX_TRAILER_CANDIDATES = 12
// Long enough that walking back and forth between a row and a detail page never re-asks,
// short enough that a newly watched title drops out of "more like this" the same evening.
@@ -317,6 +317,8 @@ data class Settings(
val showTitleLogo: Boolean = true,
// Slide up a "next up" banner near the end of an episode and roll into the next one.
val autoPlayNextEpisode: Boolean = true,
// Play a short, matched YouTube recap or preview before rolling into the next episode.
val playNextEpisodePreview: Boolean = false,
// Show the compact lower-third when playback crosses ten minutes remaining.
val showTenMinuteReminder: Boolean = true,
// Turn a subtitle track on automatically, and which language wins when one is chosen.
@@ -490,6 +492,7 @@ data class EmbyProfile(
/** Playback and presentation choices, which sync alongside the home ones. */
val showTitleLogo: Boolean = true,
val autoPlayNextEpisode: Boolean = true,
val playNextEpisodePreview: Boolean = false,
val showTenMinuteReminder: Boolean = true,
val subtitlesEnabled: Boolean = true,
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
@@ -539,6 +542,7 @@ class SettingsStore(private val context: Context) {
val CONFIRM_EXIT_MEMBY = booleanPreferencesKey("confirm_exit_memby")
val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo")
val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next_episode")
val PLAY_NEXT_EPISODE_PREVIEW = booleanPreferencesKey("play_next_episode_preview")
val SHOW_TEN_MINUTE_REMINDER = booleanPreferencesKey("show_ten_minute_reminder")
val SUBTITLES_ENABLED = booleanPreferencesKey("subtitles_enabled")
val SUBTITLE_LANGUAGE = stringPreferencesKey("subtitle_language")
@@ -671,6 +675,13 @@ class SettingsStore(private val context: Context) {
}
}
suspend fun setPlayNextEpisodePreview(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[Keys.PLAY_NEXT_EPISODE_PREVIEW] = enabled
updateActiveProfile(preferences) { it.copy(playNextEpisodePreview = enabled) }
}
}
suspend fun setShowTenMinuteReminder(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[Keys.SHOW_TEN_MINUTE_REMINDER] = enabled
@@ -799,6 +810,7 @@ class SettingsStore(private val context: Context) {
store[Keys.WELCOME_QUOTE_STYLE] = preferences.welcomeQuoteStyle
store[Keys.THEME_ID] = preferences.themeId
store[Keys.AUTO_PLAY_NEXT] = preferences.autoPlayNextEpisode
store[Keys.PLAY_NEXT_EPISODE_PREVIEW] = preferences.playNextEpisodePreview
store[Keys.SHOW_TEN_MINUTE_REMINDER] = preferences.showTenMinuteReminder
store[Keys.SUBTITLES_ENABLED] = preferences.subtitlesEnabled
store[Keys.SUBTITLE_LANGUAGE] = preferences.subtitleLanguage
@@ -823,6 +835,7 @@ class SettingsStore(private val context: Context) {
welcomeQuoteStyle = preferences.welcomeQuoteStyle,
themeId = preferences.themeId,
autoPlayNextEpisode = preferences.autoPlayNextEpisode,
playNextEpisodePreview = preferences.playNextEpisodePreview,
showTenMinuteReminder = preferences.showTenMinuteReminder,
subtitlesEnabled = preferences.subtitlesEnabled,
subtitleLanguage = preferences.subtitleLanguage,
@@ -1249,6 +1262,7 @@ class SettingsStore(private val context: Context) {
preferencesRevision = previous?.preferencesRevision ?: 0,
showTitleLogo = previous?.showTitleLogo ?: true,
autoPlayNextEpisode = previous?.autoPlayNextEpisode ?: true,
playNextEpisodePreview = previous?.playNextEpisodePreview ?: false,
showTenMinuteReminder = previous?.showTenMinuteReminder ?: true,
subtitlesEnabled = previous?.subtitlesEnabled ?: true,
subtitleLanguage = previous?.subtitleLanguage ?: SUBTITLE_LANGUAGE_AUTO,
@@ -1340,6 +1354,7 @@ class SettingsStore(private val context: Context) {
preferences.remove(Keys.HOME_HIDDEN_ROWS)
preferences.remove(Keys.SHOW_TITLE_LOGO)
preferences.remove(Keys.AUTO_PLAY_NEXT)
preferences.remove(Keys.PLAY_NEXT_EPISODE_PREVIEW)
preferences.remove(Keys.SHOW_TEN_MINUTE_REMINDER)
preferences.remove(Keys.SUBTITLES_ENABLED)
preferences.remove(Keys.SUBTITLE_LANGUAGE)
@@ -1397,6 +1412,7 @@ class SettingsStore(private val context: Context) {
preferences[Keys.HOME_HIDDEN_ROWS] = profile.homeHiddenRows
preferences[Keys.SHOW_TITLE_LOGO] = profile.showTitleLogo
preferences[Keys.AUTO_PLAY_NEXT] = profile.autoPlayNextEpisode
preferences[Keys.PLAY_NEXT_EPISODE_PREVIEW] = profile.playNextEpisodePreview
preferences[Keys.SHOW_TEN_MINUTE_REMINDER] = profile.showTenMinuteReminder
preferences[Keys.SUBTITLES_ENABLED] = profile.subtitlesEnabled
preferences[Keys.SUBTITLE_LANGUAGE] = profile.subtitleLanguage
@@ -1439,6 +1455,7 @@ class SettingsStore(private val context: Context) {
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
playNextEpisodePreview = preferences[Keys.PLAY_NEXT_EPISODE_PREVIEW] ?: false,
showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true,
subtitlesEnabled = preferences[Keys.SUBTITLES_ENABLED] ?: true,
subtitleLanguage = preferences[Keys.SUBTITLE_LANGUAGE] ?: SUBTITLE_LANGUAGE_AUTO,
@@ -1476,6 +1493,7 @@ class SettingsStore(private val context: Context) {
confirmExitMemby = preferences[Keys.CONFIRM_EXIT_MEMBY] ?: false,
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
playNextEpisodePreview = preferences[Keys.PLAY_NEXT_EPISODE_PREVIEW] ?: false,
showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true,
subtitlesEnabled = preferences[Keys.SUBTITLES_ENABLED] ?: true,
subtitleLanguage = preferences[Keys.SUBTITLE_LANGUAGE] ?: SUBTITLE_LANGUAGE_AUTO,
@@ -14,3 +14,11 @@ internal fun afterTrailerCandidate(
/** Trailer failures always advance the provider chain instead of opening the generic error pane. */
internal fun shouldFallbackTrailer(isTrailer: Boolean): Boolean = isTrailer
internal fun shouldStartNextEpisodePreview(
armed: Boolean,
dismissed: Boolean,
remainingMs: Long,
leadMs: Long,
hasPreview: Boolean,
): Boolean = armed && !dismissed && hasPreview && remainingMs in 1L..leadMs
@@ -44,6 +44,7 @@ data class UserPreferences(
*/
val themeId: String = Settings.DEFAULT_THEME_ID,
val autoPlayNextEpisode: Boolean = true,
val playNextEpisodePreview: Boolean = false,
val showTenMinuteReminder: Boolean = true,
/**
* Whether a subtitle track is turned on automatically, and which language wins when it
@@ -86,6 +87,7 @@ fun Settings.toUserPreferences(): UserPreferences = UserPreferences(
welcomeQuoteStyle = welcomeQuoteStyle,
themeId = themeId,
autoPlayNextEpisode = autoPlayNextEpisode,
playNextEpisodePreview = playNextEpisodePreview,
showTenMinuteReminder = showTenMinuteReminder,
subtitlesEnabled = subtitlesEnabled,
subtitleLanguage = subtitleLanguage,
@@ -128,6 +130,7 @@ fun decodeUserPreferences(
welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle),
themeId = json.string("themeId", fallback.themeId),
autoPlayNextEpisode = json.boolean("autoPlayNextEpisode", fallback.autoPlayNextEpisode),
playNextEpisodePreview = json.boolean("playNextEpisodePreview", fallback.playNextEpisodePreview),
showTenMinuteReminder = json.boolean("showTenMinuteReminder", fallback.showTenMinuteReminder),
subtitlesEnabled = json.boolean("subtitlesEnabled", fallback.subtitlesEnabled),
subtitleLanguage = json.string("subtitleLanguage", fallback.subtitleLanguage),
@@ -161,6 +164,7 @@ fun UserPreferences.encode(): JsonObject = buildJsonObject {
put("welcomeQuoteStyle", welcomeQuoteStyle)
put("themeId", themeId)
put("autoPlayNextEpisode", autoPlayNextEpisode)
put("playNextEpisodePreview", playNextEpisodePreview)
put("showTenMinuteReminder", showTenMinuteReminder)
put("subtitlesEnabled", subtitlesEnabled)
put("subtitleLanguage", subtitleLanguage)
@@ -190,4 +194,3 @@ private fun JsonObject.stringList(key: String, fallback: List<String>): List<Str
(element as? JsonPrimitive)?.takeIf { it.isString }?.content?.trim()?.takeIf(String::isNotEmpty)
}.distinct()
}
@@ -98,7 +98,11 @@ data class DeviceProfile(
audio: DeviceAudioCapabilities = deviceAudioCapabilities,
passthrough: AudioPassthroughPreference = AudioPassthroughPreference.AUTOMATIC,
) = DeviceProfile(
name = "Memby Android TV",
// Emby records this against the playback session and shows it in its device
// list, so it is the client identity on the wire rather than the product
// name, and it must match what the gateway sends (deviceProfileName) or one
// television playing both ways appears as two.
name = "MbyATV",
subtitleProfiles = listOf(
"srt", "subrip", "ass", "ssa", "vtt", "webvtt", "mov_text", "tx3g",
).map { SubtitleProfile(it, "External") } + listOf(
@@ -58,6 +58,7 @@ data class GatewayTrailerPlayback(
val candidateId: String = "",
val provider: String = "",
val url: String = "",
val sourceUrl: String = "",
val title: String = "",
val itemId: String = "",
val mediaSourceId: String = "",
@@ -71,9 +72,18 @@ data class TrailerPlaybackRequest(
val subjectId: String,
val title: String,
val posterUrl: String? = null,
val logoUrl: String? = null,
val excludedCandidateIds: List<String> = emptyList(),
)
@Serializable
data class GatewayTrailerReport(
val candidateId: String,
val provider: String,
val phase: String,
val reason: String = "",
)
@Serializable
data class GatewayDeviceNameRequest(val deviceName: String)
@@ -302,6 +302,12 @@ interface GatewayApi {
@Body body: com.ponzischeme89.memby.data.model.GatewayTrailerResolveRequest,
): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback
@POST("v1/items/{id}/trailers/report")
suspend fun reportTrailer(
@Path("id") itemId: String,
@Body body: com.ponzischeme89.memby.data.model.GatewayTrailerReport,
)
@POST("v1/items/{id}/favorite")
suspend fun setFavorite(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData
@@ -118,7 +118,11 @@ class MembyDreamService : DreamService() {
private fun launchTrailer(item: BaseItem) {
val intent = PlayerActivity.trailerIntent(
applicationContext,
TrailerPlaybackRequest(subjectId = item.id, title = item.name),
TrailerPlaybackRequest(
subjectId = item.id,
title = item.name,
logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item, 720),
),
)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
finish()
@@ -2264,6 +2264,7 @@ private fun HomeScreen(
subjectId = item.id,
title = item.name,
posterUrl = repo.primaryUrl(item, maxWidth = 500),
logoUrl = repo.logoUrl(item, maxWidth = 720),
),
),
)
@@ -65,6 +65,7 @@ import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.IntroSegment
import com.ponzischeme89.memby.data.creditsWorthShowing
import com.ponzischeme89.memby.data.NextEpisode
import com.ponzischeme89.memby.data.ResolvedRemoteTrailer
import com.ponzischeme89.memby.data.Playable
import com.ponzischeme89.memby.data.PlayableSubtitle
import com.ponzischeme89.memby.data.PlaybackRequest
@@ -80,6 +81,7 @@ import com.ponzischeme89.memby.data.selectSubtitleId
import com.ponzischeme89.memby.data.subtitleLabelWithFlag
import com.ponzischeme89.memby.data.afterTrailerCandidate
import com.ponzischeme89.memby.data.shouldFallbackTrailer
import com.ponzischeme89.memby.data.shouldStartNextEpisodePreview
import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayPrerollEntry
@@ -175,6 +177,10 @@ class PlayerActivity : ComponentActivity() {
private var pendingRequest: PlaybackRequest? = null
private var pendingTrailerRequest: TrailerPlaybackRequest? = null
private var trailerStartupTimeoutJob: Job? = null
private var currentTrailerCandidateId = ""
private var currentTrailerProvider = ""
private var currentTrailerSourceUrl = ""
private var currentTrailerStartedReported = false
private var pendingResolveJob: Job? = null
private var pendingResolveGeneration = 0L
private var serviceAlertsMounted = false
@@ -281,6 +287,25 @@ class PlayerActivity : ComponentActivity() {
private var returningHomeAfterCompletion = false
private var nextUpJob: Job? = null
private var nextEpisodeLookupJob: Job? = null
private var nextEpisodePreviewLookupJob: Job? = null
private var nextEpisodePreviewTimeoutJob: Job? = null
private var nextEpisodePreview: ResolvedRemoteTrailer? = null
private var previewNextEpisode: NextEpisode? = null
private var previewWindowArmed = false
private var playingNextEpisodePreview = false
private var previewOutgoingReported = false
private var previewResumeUrl = ""
private var previewResumeSubtitles: List<PlayableSubtitle> = emptyList()
private var previewResumePositionMs = 0L
private var previewResumeDurationMs = 0L
private var previewResumeTitle = ""
private var previewResumeLogoUrl: String? = null
private var previewResumePosterUrl: String? = null
private var previewResumeOverview = ""
private var previewResumePlaybackStarted = false
private var previewResumeStopReported = false
private var currentMediaUrl = ""
private var currentMediaSubtitles: List<PlayableSubtitle> = emptyList()
private var nextUpBanner: View? = null
private var nextUpCountdown: TextView? = null
private var nextUpDismissed = false
@@ -658,7 +683,34 @@ class PlayerActivity : ComponentActivity() {
trailerStartupTimeoutJob = null
endSeekBuffering()
hidePlaybackLoading()
if (!playbackStarted && !prerollActive) {
pendingTrailerRequest?.let { request ->
if (!currentTrailerStartedReported && currentTrailerCandidateId.isNotBlank()) {
currentTrailerStartedReported = true
ServiceLocator.repository.reportTrailer(
request.subjectId,
currentTrailerCandidateId,
currentTrailerProvider,
phase = "started",
)
}
}
if (playingNextEpisodePreview) {
nextEpisodePreviewTimeoutJob?.cancel()
nextEpisodePreviewTimeoutJob = null
reportNextEpisodePreview("started")
if (!previewOutgoingReported) {
previewOutgoingReported = true
val completedId = itemId
if (!completedId.isNullOrBlank() && previewResumePlaybackStarted) {
PlaybackStopWorker.enqueue(
this@PlayerActivity,
playbackSession(completedId),
previewResumeDurationMs,
)
}
}
}
if (!playbackStarted && !prerollActive && !playingNextEpisodePreview) {
startPlaybackSession(playback)
}
if (prerollActive && localPrerollPlayer == null) {
@@ -737,6 +789,8 @@ class PlayerActivity : ComponentActivity() {
playWhenReady: Boolean,
) {
val playback = player ?: return
currentMediaUrl = url
currentMediaSubtitles = subtitles
val prepared = runCatching {
require(url.isNotBlank()) { "Playback URL is blank" }
playback.setMediaItem(mediaItem(url, subtitles), positionMs.coerceAtLeast(0L))
@@ -745,6 +799,10 @@ class PlayerActivity : ComponentActivity() {
}
if (prepared.isFailure) {
Log.e(PLAYBACK_LOG_TAG, "event=media_prepare_failed item=${itemId.orEmpty()}", prepared.exceptionOrNull())
if (playingNextEpisodePreview) {
resumeEpisodeAfterPreviewFailure("prepare")
return
}
if (shouldFallbackTrailer(pendingTrailerRequest != null)) {
fallbackToNextTrailer("prepare")
return
@@ -783,6 +841,10 @@ class PlayerActivity : ComponentActivity() {
return@onSuccess
}
pendingTrailerRequest = afterTrailerCandidate(request, playable.candidateId)
currentTrailerCandidateId = playable.candidateId
currentTrailerProvider = playable.provider
currentTrailerSourceUrl = playable.sourceUrl
currentTrailerStartedReported = false
itemId = playable.itemId.takeIf(String::isNotBlank)
mediaSourceId = playable.mediaSourceId
playSessionId = playable.playSessionId
@@ -806,6 +868,18 @@ class PlayerActivity : ComponentActivity() {
trailerStartupTimeoutJob?.cancel()
trailerStartupTimeoutJob = null
Log.w(PLAYBACK_LOG_TAG, "event=trailer_fallback reason=$reason")
pendingTrailerRequest?.let { request ->
if (currentTrailerCandidateId.isNotBlank()) {
ServiceLocator.repository.reportTrailer(
request.subjectId,
currentTrailerCandidateId,
currentTrailerProvider,
phase = "failed",
reason = reason,
)
ServiceLocator.repository.rejectTrailerSource(currentTrailerProvider, currentTrailerSourceUrl)
}
}
player?.apply {
stop()
clearMediaItems()
@@ -1412,6 +1486,10 @@ class PlayerActivity : ComponentActivity() {
}
private fun handlePlaybackError(error: PlaybackException) {
if (playingNextEpisodePreview) {
resumeEpisodeAfterPreviewFailure("player_${error.errorCodeName}")
return
}
if (shouldFallbackTrailer(pendingTrailerRequest != null)) {
fallbackToNextTrailer("player_${error.errorCodeName}")
return
@@ -2586,11 +2664,14 @@ class PlayerActivity : ComponentActivity() {
*/
private fun prefetchNextEpisode() {
nextEpisodeLookupJob?.cancel()
nextEpisodePreviewLookupJob?.cancel()
nextEpisode = null
nextEpisodePreview = null
previewWindowArmed = false
val id = itemId?.takeIf { it.isNotBlank() } ?: return
nextEpisodeLookupJob = lifecycleScope.launch {
val enabled = ServiceLocator.repository.settingsFlow.first().autoPlayNextEpisode
val resolved = if (enabled) {
val playbackSettings = ServiceLocator.repository.settingsFlow.first()
val resolved = if (playbackSettings.autoPlayNextEpisode) {
// A next episode with no stream behind it is not a next episode. Kept as one
// it would put up a banner and a countdown promising something that cannot be
// played, and then — because the countdown runs itself out — swap it in
@@ -2612,6 +2693,9 @@ class PlayerActivity : ComponentActivity() {
// Playback may have moved on to another episode while this was in flight.
if (itemId == id) {
nextEpisode = resolved
if (resolved != null && playbackSettings.playNextEpisodePreview) {
prefetchNextEpisodePreview(id, resolved)
}
// Extremely short episodes and hostile latency can reach Ended before the
// lookup. The ended frame waits for this answer rather than leaving Home.
if (player?.playbackState == Player.STATE_ENDED) {
@@ -2625,6 +2709,16 @@ class PlayerActivity : ComponentActivity() {
}
}
private fun prefetchNextEpisodePreview(currentItemId: String, next: NextEpisode) {
nextEpisodePreviewLookupJob?.cancel()
nextEpisodePreviewLookupJob = lifecycleScope.launch {
val preview = ServiceLocator.repository.nextEpisodePreview(next)
if (itemId == currentItemId && nextEpisode?.itemId == next.itemId) {
nextEpisodePreview = preview
}
}
}
/**
* Watches the media playhead rather than wall-clock time. This makes the transition
* deterministic: pause freezes the countdown and seeking out of the final minute
@@ -2641,16 +2735,26 @@ class PlayerActivity : ComponentActivity() {
}
private fun updateNextUpFromPlayhead() {
if (advancing) return
if (advancing || playingNextEpisodePreview) return
val playback = player ?: return
val next = nextEpisode ?: return
val duration = playback.duration
if (duration == C.TIME_UNSET || duration <= 0L) return
// Evaluated first, because whether the pane is up decides what the banner may do.
updateEndCreditsFromPlayhead(playback, next)
val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L)
if (remainingMs > NEXT_EPISODE_PREVIEW_LEAD_MS) previewWindowArmed = true
if (shouldStartNextEpisodePreview(
armed = previewWindowArmed,
dismissed = nextUpDismissed,
remainingMs = remainingMs,
leadMs = NEXT_EPISODE_PREVIEW_LEAD_MS,
hasPreview = nextEpisodePreview != null,
) && startNextEpisodePreview(playback, next)
) return
// Evaluated after the preview: both take over the closing-credits presentation, and
// a resolved preview is the more specific next-episode experience.
updateEndCreditsFromPlayhead(playback, next)
// The pane says what is on next already, and shrinks the same picture by a different
// amount. Raising the banner over it would fight for the transform and print the
// episode twice, so the countdown moves into the pane instead — and it is only worth
@@ -2683,6 +2787,112 @@ class PlayerActivity : ComponentActivity() {
}
}
private fun startNextEpisodePreview(playback: Player, next: NextEpisode): Boolean {
val preview = nextEpisodePreview ?: return false
if (playingNextEpisodePreview || preview.url.isBlank()) return false
previewResumeUrl = currentMediaUrl
previewResumeSubtitles = currentMediaSubtitles
previewResumePositionMs = playback.currentPosition.coerceAtLeast(0L)
previewResumeDurationMs = playback.duration.coerceAtLeast(previewResumePositionMs)
previewResumeTitle = playbackTitle
previewResumeLogoUrl = logoUrl
previewResumePosterUrl = pausePosterUrl
previewResumeOverview = pauseOverview
previewResumePlaybackStarted = playbackStarted
previewResumeStopReported = stopReported
previewOutgoingReported = false
previewNextEpisode = next
playingNextEpisodePreview = true
stopReported = true // Prevent preview positions being reported against the episode.
stopProgressUploading()
hideNextUp()
if (creditsActive) leaveEndCredits(restoreSpeed = true)
playbackTitle = "Next: ${nextTitle(next)}"
logoUrl = next.logoUrl
pausePosterUrl = next.imageUrl
pauseOverview = next.overview
bindTitleArtwork(playbackTitle, logoUrl)
setUpPlaybackIdentity(playbackTitle)
renderedFirstFrame = false
showPlaybackLoading(title = "Finding the next episode…", hint = "Starting recap or preview")
startMedia(preview.url, emptyList(), 0L, playWhenReady = true)
nextEpisodePreviewTimeoutJob?.cancel()
nextEpisodePreviewTimeoutJob = lifecycleScope.launch {
delay(NEXT_EPISODE_PREVIEW_STARTUP_TIMEOUT_MS)
if (playingNextEpisodePreview && !renderedFirstFrame) {
resumeEpisodeAfterPreviewFailure("startup_timeout")
}
}
return true
}
private fun resumeEpisodeAfterPreviewFailure(reason: String) {
if (!playingNextEpisodePreview) return
val next = previewNextEpisode
nextEpisodePreview?.let {
ServiceLocator.repository.rejectTrailerSource(it.provider, it.sourceUrl)
}
reportNextEpisodePreview("failed", reason)
nextEpisodePreviewTimeoutJob?.cancel()
nextEpisodePreviewTimeoutJob = null
nextEpisodePreview = null
playingNextEpisodePreview = false
previewNextEpisode = null
if (previewOutgoingReported) {
next?.let(::playNext) ?: returnHomeAfterCompletion()
return
}
playbackTitle = previewResumeTitle
logoUrl = previewResumeLogoUrl
pausePosterUrl = previewResumePosterUrl
pauseOverview = previewResumeOverview
playbackStarted = previewResumePlaybackStarted
stopReported = previewResumeStopReported
bindTitleArtwork(playbackTitle, logoUrl)
setUpPlaybackIdentity(playbackTitle)
renderedFirstFrame = false
if (previewResumeUrl.isBlank()) {
// The original stream should always be known, but losing the optional preview
// must never strand the viewer on a generic error pane.
next?.let(::playNext) ?: finish()
return
}
showPlaybackLoading(title = playbackTitle, hint = "Returning to the episode")
startMedia(
previewResumeUrl,
previewResumeSubtitles,
previewResumePositionMs,
playWhenReady = true,
)
if (playbackStarted && !stopReported) startProgressReporting()
}
private fun reportNextEpisodePreview(phase: String, reason: String = "") {
val preview = nextEpisodePreview ?: return
val subject = previewNextEpisode?.itemId ?: return
ServiceLocator.repository.reportTrailer(
subject,
preview.candidateId,
preview.provider,
phase,
reason,
)
}
private fun completeNextEpisodePreview() {
if (!playingNextEpisodePreview) return
reportNextEpisodePreview("completed")
nextEpisodePreviewTimeoutJob?.cancel()
nextEpisodePreviewTimeoutJob = null
val next = previewNextEpisode
playingNextEpisodePreview = false
previewNextEpisode = null
nextEpisodePreview = null
next?.let(::playNext) ?: returnHomeAfterCompletion()
}
@OptIn(UnstableApi::class)
private fun showNextUp(next: NextEpisode) {
val banner = nextUpBanner ?: return
@@ -3012,6 +3222,20 @@ class PlayerActivity : ComponentActivity() {
}
private fun handlePlaybackEnded() {
if (playingNextEpisodePreview) {
completeNextEpisodePreview()
return
}
pendingTrailerRequest?.let { request ->
if (currentTrailerCandidateId.isNotBlank()) {
ServiceLocator.repository.reportTrailer(
request.subjectId,
currentTrailerCandidateId,
currentTrailerProvider,
phase = "completed",
)
}
}
when (playbackCompletionAction(
hasNextEpisode = nextEpisode != null,
nextUpDismissed = nextUpDismissed,
@@ -3057,7 +3281,17 @@ class PlayerActivity : ComponentActivity() {
advancing = true
nextUpJob?.cancel()
nextEpisodeLookupJob?.cancel()
nextEpisodePreviewLookupJob?.cancel()
nextEpisodePreviewTimeoutJob?.cancel()
nextEpisodeLookupJob = null
nextEpisodePreviewLookupJob?.cancel()
nextEpisodePreviewLookupJob = null
nextEpisodePreviewTimeoutJob?.cancel()
nextEpisodePreviewTimeoutJob = null
playingNextEpisodePreview = false
previewNextEpisode = null
nextEpisodePreview = null
previewWindowArmed = false
retryGeneration += 1
retryJob?.cancel()
subtitleStreamGeneration += 1
@@ -4131,16 +4365,22 @@ class PlayerActivity : ComponentActivity() {
override fun onSaveInstanceState(outState: Bundle) {
player?.takeUnless { relaunchingForNewIntent }?.let { playback ->
outState.putLong(STATE_POSITION_MS, playback.currentPosition.coerceAtLeast(0L))
val savingPreview = playingNextEpisodePreview && previewResumeUrl.isNotBlank()
outState.putLong(
STATE_POSITION_MS,
if (savingPreview) previewResumePositionMs else playback.currentPosition.coerceAtLeast(0L),
)
outState.putBoolean(STATE_PLAY_WHEN_READY, playback.playWhenReady)
playback.currentMediaItem?.localConfiguration?.uri?.toString()?.takeIf(String::isNotBlank)
(if (savingPreview) previewResumeUrl else playback.currentMediaItem?.localConfiguration?.uri?.toString())
?.takeIf(String::isNotBlank)
?.let { outState.putString(STATE_URL, it) }
itemId?.let { outState.putString(STATE_ITEM_ID, it) }
outState.putString(STATE_MEDIA_SOURCE_ID, mediaSourceId)
outState.putString(STATE_PLAY_SESSION_ID, playSessionId)
outState.putString(STATE_PLAY_METHOD, playMethod)
if (availableSubtitles.isNotEmpty()) {
outState.putString(STATE_SUBTITLES, playerJson.encodeToString(availableSubtitles))
val savedSubtitles = if (savingPreview) previewResumeSubtitles else availableSubtitles
if (savedSubtitles.isNotEmpty()) {
outState.putString(STATE_SUBTITLES, playerJson.encodeToString(savedSubtitles))
}
outState.putBoolean(STATE_SUBTITLES_ENABLED, subtitlePreference == true)
outState.putString(STATE_SELECTED_SUBTITLE_ID, serverSubtitleId)
@@ -4148,10 +4388,10 @@ class PlayerActivity : ComponentActivity() {
outState.putBoolean(STATE_TRICKPLAY, trickplayAvailable)
outState.putBoolean(STATE_SKIP_INTRO, skipIntroAvailable)
outState.putBoolean(STATE_END_CREDITS, endCreditsAvailable)
outState.putString(STATE_TITLE, playbackTitle)
outState.putString(STATE_LOGO_URL, logoUrl)
outState.putString(STATE_OVERVIEW, pauseOverview)
outState.putString(STATE_POSTER_URL, pausePosterUrl)
outState.putString(STATE_TITLE, if (savingPreview) previewResumeTitle else playbackTitle)
outState.putString(STATE_LOGO_URL, if (savingPreview) previewResumeLogoUrl else logoUrl)
outState.putString(STATE_OVERVIEW, if (savingPreview) previewResumeOverview else pauseOverview)
outState.putString(STATE_POSTER_URL, if (savingPreview) previewResumePosterUrl else pausePosterUrl)
outState.putString(STATE_EPISODE_CODE, prerollEpisodeCode)
outState.putLong(STATE_RUNTIME_MS, prerollRuntimeMs)
pendingTrailerRequest?.let {
@@ -4250,6 +4490,8 @@ class PlayerActivity : ComponentActivity() {
encodedSubtitleJob?.cancel()
nextUpJob?.cancel()
nextEpisodeLookupJob?.cancel()
nextEpisodePreviewLookupJob?.cancel()
nextEpisodePreviewTimeoutJob?.cancel()
creditsSpeedJob?.cancel()
creditsView?.animate()?.cancel()
retryJob?.cancel()
@@ -4522,6 +4764,7 @@ class PlayerActivity : ComponentActivity() {
putExtra(EXTRA_ITEM_ID, request.subjectId)
putExtra(EXTRA_TITLE, request.title + " trailer")
request.posterUrl?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_POSTER_URL, it) }
request.logoUrl?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_LOGO_URL, it) }
putExtra(EXTRA_PREROLL_ENABLED, false)
putExtra(EXTRA_REQUEST_STARTED_AT_MS, SystemClock.elapsedRealtime())
}
@@ -4683,6 +4926,8 @@ class PlayerActivity : ComponentActivity() {
/** Show against the still-playing final minute, using media time for determinism. */
private const val NEXT_UP_LEAD_MS = 60_000L
private const val NEXT_UP_TICK_MS = 250L
private const val NEXT_EPISODE_PREVIEW_LEAD_MS = 120_000L
private const val NEXT_EPISODE_PREVIEW_STARTUP_TIMEOUT_MS = 8_000L
private const val NEXT_UP_ANIMATION_MS = 260L
private const val NEXT_UP_VIDEO_SCALE = 0.58f
private const val NEXT_UP_VIDEO_SHIFT_X = 0.18f
@@ -30,7 +30,11 @@ class ScreensaverActivity : ComponentActivity() {
startActivity(
PlayerActivity.trailerIntent(
this,
TrailerPlaybackRequest(subjectId = item.id, title = item.name),
TrailerPlaybackRequest(
subjectId = item.id,
title = item.name,
logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item, 720),
),
),
)
},
@@ -233,6 +233,7 @@ private val RailEdge = Color.White.copy(alpha = 0.10f)
internal data class SettingsPanelState(
val showLogo: Boolean = true,
val autoPlayNext: Boolean = true,
val playNextEpisodePreview: Boolean = false,
val showTenMinuteReminder: Boolean = true,
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
@@ -293,6 +294,7 @@ internal data class SettingsPanelActions(
val onClose: () -> Unit = {},
val onShowLogoChanged: (Boolean) -> Unit = {},
val onAutoPlayNextChanged: (Boolean) -> Unit = {},
val onPlayNextEpisodePreviewChanged: (Boolean) -> Unit = {},
val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {},
val onSeekIntervalChanged: (Int) -> Unit = {},
val onSkipIntroModeChanged: (String) -> Unit = {},
@@ -354,6 +356,7 @@ fun SettingsSheet(
var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) }
var autoPlayNext by rememberSaveable { mutableStateOf(settings.autoPlayNextEpisode) }
var playNextEpisodePreview by rememberSaveable { mutableStateOf(settings.playNextEpisodePreview) }
var showTenMinuteReminder by rememberSaveable { mutableStateOf(settings.showTenMinuteReminder) }
var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) }
var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) }
@@ -448,6 +451,7 @@ fun SettingsSheet(
settings.hideWatchedMovies,
settings.confirmExitMemby,
settings.autoPlayNextEpisode,
settings.playNextEpisodePreview,
settings.showTenMinuteReminder,
settings.seekIntervalSeconds,
settings.skipIntroMode,
@@ -458,6 +462,7 @@ fun SettingsSheet(
) {
showLogo = settings.showTitleLogo
autoPlayNext = settings.autoPlayNextEpisode
playNextEpisodePreview = settings.playNextEpisodePreview
showTenMinuteReminder = settings.showTenMinuteReminder
seekInterval = settings.seekIntervalSeconds
skipIntroMode = settings.skipIntroMode
@@ -486,6 +491,7 @@ fun SettingsSheet(
val state = SettingsPanelState(
showLogo = showLogo,
autoPlayNext = autoPlayNext,
playNextEpisodePreview = playNextEpisodePreview,
showTenMinuteReminder = showTenMinuteReminder,
seekIntervalSeconds = seekInterval,
skipIntroMode = skipIntroMode,
@@ -535,6 +541,11 @@ fun SettingsSheet(
autoPlayNext = it
persistSetting { store.setAutoPlayNextEpisode(it) }
},
onPlayNextEpisodePreviewChanged = {
onAnalyticsEvent("next_episode_preview", "toggle")
playNextEpisodePreview = it
persistSetting { store.setPlayNextEpisodePreview(it) }
},
onShowTenMinuteReminderChanged = {
onAnalyticsEvent("playback_reminder", "toggle")
showTenMinuteReminder = it
@@ -955,6 +966,14 @@ internal fun SettingsPanelContent(
onCheckedChange = actions.onAutoPlayNextChanged,
)
SettingDivider()
SettingsToggleRow(
title = "Next-episode recap or preview",
description = "With auto-play on, find a matching YouTube recap or preview and play it " +
"two minutes before the episode ends.",
checked = state.playNextEpisodePreview,
onCheckedChange = actions.onPlayNextEpisodePreviewChanged,
)
SettingDivider()
SettingsChoiceRow(
title = "Opening titles",
description = "What to do when an episode reaches its intro.",
@@ -0,0 +1,52 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ClientTrailerResolverTest {
@Test
fun youtubeIdsAcceptRealHostsAndRejectLookalikes() {
val id = "dQw4w9WgXcQ"
assertEquals(id, youtubeVideoId("https://www.youtube.com/watch?v=$id"))
assertEquals(id, youtubeVideoId("https://youtu.be/$id"))
assertEquals(id, youtubeVideoId("https://www.youtube-nocookie.com/embed/$id"))
assertNull(youtubeVideoId("https://notyoutube.com/watch?v=$id"))
}
@Test
fun balancedJsonIgnoresBracesInsideStrings() {
val body = "prefix \"videoRenderer\":{\"title\":\"a } brace\",\"nested\":{\"ok\":true}} suffix"
assertEquals(
"{\"title\":\"a } brace\",\"nested\":{\"ok\":true}}",
balancedObjectAfter(body, "\"videoRenderer\":"),
)
}
@Test
fun smartPreviewRankingFavoursTheExactOfficialEpisode() {
val next = NextEpisode(
itemId = "episode-2",
title = "The Crossing",
seriesName = "Southern Skies",
episodeCode = "S02 E04",
imageUrl = null,
url = "https://emby/episode-2",
)
val official = YouTubeSearchCandidate(
videoId = "abcdefghijk",
title = "Southern Skies S02E04 The Crossing Official Preview",
channel = "Southern Skies",
durationSeconds = 95,
)
val reaction = YouTubeSearchCandidate(
videoId = "zyxwvutsrqp",
title = "Southern Skies S02E04 preview reaction and breakdown",
channel = "Fan Reviews",
durationSeconds = 900,
)
assertTrue(previewScore(official, next) > previewScore(reaction, next))
assertTrue(previewSearchQuery(next).contains("S02E04"))
}
}
@@ -36,12 +36,13 @@ class GatewayPayloadTest {
"""{"available":true,"providers":["local","apple","youtube"]}""",
)
val playback = json.decodeFromString<GatewayTrailerPlayback>(
"""{"candidateId":"youtube-a1","provider":"youtube","url":"https://media.example/trailer.mp4","title":"Arrival trailer","playMethod":"DirectPlay"}""",
"""{"candidateId":"youtube-a1","provider":"youtube","sourceUrl":"https://youtu.be/dQw4w9WgXcQ","title":"Arrival trailer","playMethod":"DirectPlay"}""",
)
assertTrue(availability.available)
assertEquals(listOf("local", "apple", "youtube"), availability.providers)
assertEquals("youtube-a1", playback.candidateId)
assertEquals("https://youtu.be/dQw4w9WgXcQ", playback.sourceUrl)
assertEquals("Arrival trailer", playback.title)
}
@@ -20,6 +20,16 @@ class PlaybackReportMathTest {
assertEquals(0L, millisecondsToTicks(-1L))
}
/**
* Emby lists the device profile's name against a playback session, so it must carry
* the client identity rather than the product name and it must be the same literal
* the gateway sends, or one television playing both ways appears as two clients.
*/
@Test
fun deviceProfileReportsTheClientIdentityNotTheProductName() {
assertEquals("MbyATV", DeviceProfile.embyAndroidTv().name)
}
@Test
fun deviceProfileExternalizesTextAndEncodesBitmapSubtitles() {
val profiles = DeviceProfile.embyAndroidTv().subtitleProfiles.associate {
@@ -22,4 +22,12 @@ class TrailerSupportTest {
assertTrue(shouldFallbackTrailer(isTrailer = true))
assertFalse(shouldFallbackTrailer(isTrailer = false))
}
@Test
fun nextEpisodePreviewNeedsANaturalTwoMinuteWindow() {
assertTrue(shouldStartNextEpisodePreview(true, false, 120_000, 120_000, true))
assertFalse(shouldStartNextEpisodePreview(false, false, 60_000, 120_000, true))
assertFalse(shouldStartNextEpisodePreview(true, true, 60_000, 120_000, true))
assertFalse(shouldStartNextEpisodePreview(true, false, 120_001, 120_000, true))
}
}
@@ -27,6 +27,7 @@ class UserPreferencesTest {
showTitleLogo = false,
welcomeQuoteStyle = "homicidal",
autoPlayNextEpisode = false,
playNextEpisodePreview = true,
showTenMinuteReminder = false,
seekIntervalSeconds = 30,
skipIntroMode = SKIP_INTRO_AUTO,
@@ -142,6 +143,7 @@ class UserPreferencesTest {
showTitleLogo = false,
welcomeQuoteStyle = "positive",
autoPlayNextEpisode = false,
playNextEpisodePreview = true,
showTenMinuteReminder = false,
forYouMinutes = 30,
homeRowOrder = "recommended\nlatest",
@@ -160,6 +162,7 @@ class UserPreferencesTest {
showTitleLogo = false,
welcomeQuoteStyle = "positive",
autoPlayNextEpisode = false,
playNextEpisodePreview = true,
showTenMinuteReminder = false,
forYouMinutes = 30,
homeRowOrder = listOf("recommended", "latest"),
+4 -1
View File
@@ -449,7 +449,9 @@ shown, it asks for an Emby household username and password and verifies them dir
against Emby. Credentials are never stored. The temporary Emby access token is logged
out immediately, and the browser receives a secure, HTTP-only, same-site installer cookie
valid for 30 minutes. This browser login does not create a Memby TV session and therefore
does not appear in the user's signed-in device list.
does not appear in the user's signed-in device list. Emby records the password check
under the gateway's own client name (`MEMBY_GATEWAY_CLIENT_NAME`), not a television's, so
its temporary device record is never mistaken for a set in the house.
After authentication, `/updates/latest.apk` redirects to the current immutable, versioned
APK in the persistent `memby-releases` volume. Direct APK requests require either that
@@ -619,6 +621,7 @@ can review and revoke signed-in TVs from the app's Settings screen.
| `MEMBY_SERVER_MEMORY_LIMIT` | `512m` | Compose hard memory ceiling for the server container |
| `MEMBY_TIMEZONE` | `Pacific/Auckland` | Local day and time labels for schedule rows |
| `MEMBY_CLIENT_NAME` | `MbyATV` | Client name sent to Emby; must match the app's direct path |
| `MEMBY_GATEWAY_CLIENT_NAME` | `MbyGateway` | Client name Emby records for the gateway's own requests, including an admin or installer sign-in |
| `MEMBY_HOME_TTL` | `60s` | Also `MEMBY_ITEM_TTL`, `MEMBY_SEARCH_TTL`, `MEMBY_SCREENSAVER_TTL` |
| `MEMBY_RECOMMEND_TTL` | `24h` | How long computed recommendation rows stay warm |
| `MEMBY_RECOMMEND_TIMEOUT` | `60s` | Bounds a background rebuild |
+7 -2
View File
@@ -108,7 +108,10 @@ func run(log *slog.Logger, events *logging.Buffer) error {
log.Warn("could not clear interrupted sync runs", "error", err)
}
embyClient := emby.New(cfg.EmbyURL, cfg.EmbyPublicURL, cfg.ClientName, cfg.UpstreamTimeout)
embyClient := emby.New(
cfg.EmbyURL, cfg.EmbyPublicURL, cfg.ClientName, cfg.GatewayClientName,
cfg.UpstreamTimeout,
)
mdblistClient := mdblist.New(mdblist.DefaultBaseURL, cfg.UpstreamTimeout)
var sonarrClient *sonarr.Client
if cfg.SonarrURL != "" {
@@ -153,6 +156,7 @@ func run(log *slog.Logger, events *logging.Buffer) error {
UserID: cfg.SyncUserID,
Token: cfg.SyncAPIKey,
DeviceID: "memby-gateway-sync",
Gateway: true,
}, log.With("component", "library"))
var forYouService *foryou.Service
if tracearrClient != nil {
@@ -163,7 +167,8 @@ func run(log *slog.Logger, events *logging.Buffer) error {
forYouService.ConfigureTimeContext(cfg.SonarrLocation)
forYouService.ConfigureHouseholdUsers(embyClient, emby.Credentials{
UserID: cfg.SyncUserID, Token: cfg.SyncAPIKey,
DeviceID: "memby-for-you-builder", DeviceName: "Memby For You builder",
DeviceID: "memby-for-you-builder", DeviceName: "MbyGateway For You",
Gateway: true,
})
}
+1 -7
View File
@@ -35,7 +35,6 @@ import (
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/trailer"
)
type Server struct {
@@ -86,7 +85,6 @@ type Server struct {
// embyHealth is the reachability probe's live finding, which /v1/status publishes so
// a TV can show why playback stopped even if it missed the announcement.
embyHealth embyHealth
trailers *trailer.Resolver
}
// Deps are the collaborators the API needs. A struct rather than positional arguments:
@@ -107,10 +105,6 @@ type Deps struct {
}
func New(cfg config.Config, deps Deps) *Server {
trailerTimeout := cfg.UpstreamTimeout
if trailerTimeout <= 0 || trailerTimeout > 8*time.Second {
trailerTimeout = 8 * time.Second
}
return &Server{
cfg: cfg,
emby: deps.Emby,
@@ -125,7 +119,6 @@ func New(cfg config.Config, deps Deps) *Server {
syncer: deps.Syncer,
log: deps.Log,
events: deps.Events,
trailers: trailer.New(&http.Client{Timeout: trailerTimeout}),
}
}
@@ -201,6 +194,7 @@ func (s *Server) Routes() http.Handler {
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
v1.Handle("GET /v1/items/{id}/trailers", s.authed(s.handleTrailers))
v1.Handle("POST /v1/items/{id}/trailers/resolve", s.authed(s.handleResolveTrailer))
v1.Handle("POST /v1/items/{id}/trailers/report", s.authed(s.handleTrailerReport))
v1.Handle("GET /v1/items/{id}/intro", s.authed(s.handleIntro))
v1.Handle("GET /v1/items/{id}/trickplay", s.authed(s.handleTrickplay))
v1.Handle("GET /v1/items/{id}/trickplay/{frame}", s.authed(s.handleTrickplayFrame))
+7 -2
View File
@@ -69,7 +69,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
auth, err := s.emby.Authenticate(
r.Context(), req.Username, req.Password, req.DeviceID, req.DeviceName, clientVersion(r),
r.Context(), req.Username, req.Password,
emby.Credentials{
DeviceID: req.DeviceID, DeviceName: req.DeviceName,
ClientVersion: clientVersion(r),
},
)
if err != nil {
// Never echo Emby's body here: a failed sign-in is the one place a wrong
@@ -250,7 +254,8 @@ func (s *Server) retireEmbyDevice(ctx context.Context, deviceID string) {
}
if err := s.emby.DeleteDevice(ctx, emby.Credentials{
UserID: s.cfg.SyncUserID, Token: s.cfg.SyncAPIKey,
DeviceID: "memby-gateway", DeviceName: "Memby Gateway",
DeviceID: "memby-gateway", DeviceName: s.gatewayDeviceName(),
Gateway: true,
}, deviceID); err != nil {
s.loggerFor(ctx).Warn("emby device cleanup failed",
"removed_device_id", deviceID, "error", err)
+28 -4
View File
@@ -19,13 +19,30 @@ const (
installerSessionTTL = 30 * time.Minute
adminSessionTTL = 12 * time.Hour
installerDeviceID = "memby-web-installer"
installerDeviceName = "Memby Web Installer"
// 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
)
// gatewayDeviceName is what Emby records for a device row the gateway creates for itself.
// It follows the gateway's client name so one operator-set word covers both halves of how
// the server identifies itself, and it is deliberately never the product name — Emby's
// device list is read by whoever runs the server, and an entry called "Memby …" there
// reads as one of the household's televisions.
func (s *Server) gatewayDeviceName() string {
if name := strings.TrimSpace(s.cfg.GatewayClientName); name != "" {
return name
}
return emby.DefaultGatewayClientName
}
// installerDeviceName separates the temporary record an admin or installer sign-in
// creates from the gateway's own, so a password check is recognisable while it exists.
func (s *Server) installerDeviceName() string {
return s.gatewayDeviceName() + " Installer"
}
func (s *Server) installerSecret() []byte {
if s.cfg.ReleasePublishToken == "" {
return nil
@@ -192,8 +209,13 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
return
}
// Gateway, not a television: this sign-in is the admin console or the web installer
// checking a password, so Emby records it under the gateway's own client name.
auth, err := s.emby.Authenticate(
r.Context(), username, password, installerDeviceID, installerDeviceName, "",
r.Context(), username, password,
emby.Credentials{
DeviceID: installerDeviceID, DeviceName: s.installerDeviceName(), Gateway: true,
},
)
if err != nil {
s.loggerFor(r.Context()).Warn("installer Emby authentication failed", "username", username)
@@ -204,13 +226,15 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
// it succeeded, so retire the upstream session immediately and never persist it.
if err := s.emby.Logout(r.Context(), emby.Credentials{
UserID: auth.User.ID, Token: auth.AccessToken,
DeviceID: installerDeviceID, DeviceName: installerDeviceName,
DeviceID: installerDeviceID, DeviceName: s.installerDeviceName(),
Gateway: true,
}); err != nil {
s.loggerFor(r.Context()).Warn("installer Emby session cleanup failed", "error", err)
}
if err := s.emby.DeleteDevice(r.Context(), emby.Credentials{
UserID: s.cfg.SyncUserID, Token: s.cfg.SyncAPIKey,
DeviceID: "memby-gateway", DeviceName: "Memby Gateway",
DeviceID: "memby-gateway", DeviceName: s.gatewayDeviceName(),
Gateway: true,
}, installerDeviceID); err != nil {
s.loggerFor(r.Context()).Error("installer Emby device cleanup failed", "error", err)
s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.",
+28 -1
View File
@@ -3,6 +3,7 @@ package api
import (
"context"
"log/slog"
"net"
"net/http"
"strings"
@@ -173,7 +174,8 @@ func isPlaybackItemPath(path string) bool {
// Fetching a subtitle is two segments deep rather than one, and matching its trailing
// "search" on its own would claim any future per-item search as playback. A seek
// preview is the same shape: the frame number is the last segment, not the word.
if strings.Contains(path, "/subtitles/") || strings.Contains(path, "/trickplay") {
if strings.Contains(path, "/subtitles/") || strings.Contains(path, "/trickplay") ||
strings.Contains(path, "/trailers") {
return true
}
switch path[strings.LastIndex(path, "/")+1:] {
@@ -182,3 +184,28 @@ func isPlaybackItemPath(path string) bool {
}
return false
}
// requestClientIP is the viewer-facing address recorded for trailer playback. The first
// Forwarded address is the original client when the gateway is behind its normal reverse
// proxy; direct deployments fall back to RemoteAddr. This value is for operational logs,
// never authentication or access control.
func requestClientIP(r *http.Request) string {
for _, value := range strings.Split(r.Header.Get("X-Forwarded-For"), ",") {
if ip := net.ParseIP(strings.TrimSpace(value)); ip != nil {
return ip.String()
}
}
if ip := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); ip != nil {
return ip.String()
}
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err == nil {
if ip := net.ParseIP(host); ip != nil {
return ip.String()
}
}
if ip := net.ParseIP(strings.TrimSpace(r.RemoteAddr)); ip != nil {
return ip.String()
}
return "unknown"
}
+11
View File
@@ -48,6 +48,8 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) {
"/v1/items/42/related": "details",
"/v1/items/42/playback": "playback",
"/v1/items/42/next": "playback",
"/v1/items/42/trailers/resolve": "playback",
"/v1/items/42/trailers/report": "playback",
"/v1/items/42/subtitles/search": "playback",
"/v1/items/42/trickplay": "playback",
"/v1/items/42/trickplay/12.jpg": "playback",
@@ -69,6 +71,15 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) {
}
}
func TestRequestClientIPPrefersOriginalForwardedAddress(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/v1/items/42/trailers/report", nil)
request.RemoteAddr = "10.0.0.2:41234"
request.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.2")
if got := requestClientIP(request); got != "203.0.113.9" {
t.Fatalf("client ip = %q", got)
}
}
func TestIdentifyNamesTheViewerAndTelevision(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/v1/home", nil)
request.Header.Set("X-Memby-Version", "0.1.60")
+1 -1
View File
@@ -20,7 +20,7 @@ func TestStoppedPlaybackReportRemainsRetryableWhenEmbyIsDown(t *testing.T) {
defer upstream.Close()
s := &Server{
emby: emby.New(upstream.URL, upstream.URL, "Memby test", time.Second),
emby: emby.New(upstream.URL, upstream.URL, "MbyATV", "MbyGateway", time.Second),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
req := httptest.NewRequest(
+5
View File
@@ -140,6 +140,11 @@ var preferenceCatalogue = []preferenceDefinition{
Description: "Roll into the next episode when one finishes.",
Kind: preferenceToggle, Default: true,
},
{
Key: "playNextEpisodePreview", Name: "Next-episode recap or preview", Area: "Playback",
Description: "With auto-play on, play a matched YouTube recap or preview two minutes before the episode ends.",
Kind: preferenceToggle, Default: false,
},
{
Key: "showTenMinuteReminder", Name: "Ten-minute reminder", Area: "Playback",
Description: "Show the lower-third when ten minutes are left.",
+1 -1
View File
@@ -282,7 +282,7 @@ func TestInstallerLoginUsesEmbyWithoutCreatingTVSession(t *testing.T) {
ReleasePublishToken: "test-release-secret",
SyncUserID: "service-user", SyncAPIKey: "service-token",
},
emby: emby.New(upstream.URL, upstream.URL, "Memby test", 2*time.Second),
emby: emby.New(upstream.URL, upstream.URL, "MbyATV", "MbyGateway", 2*time.Second),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
form := url.Values{
+43 -18
View File
@@ -14,7 +14,6 @@ import (
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/trailer"
)
type remoteTrailer struct {
@@ -55,6 +54,7 @@ type trailerPlaybackResponse struct {
CandidateID string `json:"candidateId"`
Provider string `json:"provider"`
URL string `json:"url"`
SourceURL string `json:"sourceUrl,omitempty"`
Title string `json:"title"`
ItemID string `json:"itemId,omitempty"`
MediaSourceID string `json:"mediaSourceId,omitempty"`
@@ -62,6 +62,13 @@ type trailerPlaybackResponse struct {
PlayMethod string `json:"playMethod,omitempty"`
}
type trailerReportRequest struct {
CandidateID string `json:"candidateId"`
Provider string `json:"provider"`
Phase string `json:"phase"`
Reason string `json:"reason,omitempty"`
}
func (s *Server) handleTrailers(w http.ResponseWriter, r *http.Request, sess store.Session) {
itemID := strings.TrimSpace(r.PathValue("id"))
if itemID == "" {
@@ -107,37 +114,24 @@ func (s *Server) handleResolveTrailer(w http.ResponseWriter, r *http.Request, se
s.writeUpstreamError(r.Context(), w, err, "could not inspect trailers")
return
}
resolver := s.trailers
if resolver == nil {
resolver = trailer.New(nil)
}
for _, candidate := range s.preferredTrailerCandidates(r.Context(), sess, manifest) {
if excluded[candidate.ID] {
if candidate.SourceURL != "" {
resolver.Invalidate(trailer.Source{Provider: candidate.Provider, URL: candidate.SourceURL})
}
continue
}
if len(candidate.LocalItem) > 0 {
if resolved, resolveErr := s.resolveLocalTrailer(r.Context(), sess, manifest, candidate); resolveErr == nil {
s.rememberTrailerCandidate(r.Context(), sess, itemID, candidate.ID)
writeJSON(w, http.StatusOK, resolved)
return
}
continue
}
resolved, resolveErr := resolver.Resolve(r.Context(), trailer.Source{
Provider: candidate.Provider,
URL: candidate.SourceURL,
})
if resolveErr != nil {
continue
}
s.rememberTrailerCandidate(r.Context(), sess, itemID, candidate.ID)
// Remote pages are resolved on the television. YouTube signs direct media URLs for
// the resolving IP, so resolving here can make the URL unusable from the viewer's
// network and makes provider traffic appear to come from the gateway.
writeJSON(w, http.StatusOK, trailerPlaybackResponse{
CandidateID: candidate.ID,
Provider: candidate.Provider,
URL: resolved.URL,
SourceURL: candidate.SourceURL,
Title: trailerTitle(manifest.Title, candidate.Name),
PlayMethod: "DirectPlay",
})
@@ -146,6 +140,37 @@ func (s *Server) handleResolveTrailer(w http.ResponseWriter, r *http.Request, se
writeError(w, http.StatusNotFound, "no playable trailer is available")
}
func (s *Server) handleTrailerReport(w http.ResponseWriter, r *http.Request, sess store.Session) {
itemID := strings.TrimSpace(r.PathValue("id"))
var report trailerReportRequest
if itemID == "" || json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&report) != nil {
writeError(w, http.StatusBadRequest, "invalid trailer report")
return
}
report.CandidateID = strings.TrimSpace(report.CandidateID)
report.Provider = strings.ToLower(strings.TrimSpace(report.Provider))
report.Phase = strings.ToLower(strings.TrimSpace(report.Phase))
if report.CandidateID == "" || report.Provider == "" ||
(report.Phase != "started" && report.Phase != "failed" && report.Phase != "completed") {
writeError(w, http.StatusBadRequest, "invalid trailer report")
return
}
fields := []any{
"item_id", itemID,
"provider", report.Provider,
"candidate", report.CandidateID,
"source_ip", requestClientIP(r),
}
if reason := strings.TrimSpace(report.Reason); reason != "" {
fields = append(fields, "reason", reason)
}
s.loggerFor(r.Context()).Info("trailer playback "+report.Phase, fields...)
if report.Phase == "started" {
s.rememberTrailerCandidate(r.Context(), sess, itemID, report.CandidateID)
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) preferredTrailerCandidates(
ctx context.Context,
sess store.Session,
+38 -34
View File
@@ -14,17 +14,11 @@ import (
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/trailer"
)
type trailerRoundTripFunc func(*http.Request) (*http.Response, error)
func (fn trailerRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}
func TestResolveTrailerFallsThroughProviders(t *testing.T) {
func TestResolveTrailerSkipsRejectedProviderOnTheClientBehalf(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/LocalTrailers"):
@@ -43,35 +37,14 @@ func TestResolveTrailerFallsThroughProviders(t *testing.T) {
}))
defer upstream.Close()
resolverClient := &http.Client{Transport: trailerRoundTripFunc(func(request *http.Request) (*http.Response, error) {
status := http.StatusOK
body := ""
headers := http.Header{}
switch request.URL.Host {
case "trailers.apple.com":
status = http.StatusNotFound
case "www.youtube.com":
body = `{"playabilityStatus":{"status":"OK"},"streamingData":{"formats":[` +
`{"url":"https://media.example/trailer.mp4","mimeType":"video/mp4; codecs=avc1,mp4a","height":720}]}}`
headers.Set("Content-Type", "application/json")
case "media.example":
status = http.StatusPartialContent
headers.Set("Content-Type", "video/mp4")
default:
t.Fatalf("unexpected trailer request: %s", request.URL)
}
return &http.Response{
StatusCode: status, Header: headers,
Body: io.NopCloser(strings.NewReader(body)), Request: request,
}, nil
})}
server := &Server{
cfg: config.Config{ItemTTL: time.Minute},
emby: emby.New(upstream.URL, upstream.URL, "MbyATV", time.Second),
trailers: trailer.New(resolverClient),
emby: emby.New(upstream.URL, upstream.URL, "MbyATV", "MbyGateway", time.Second),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
request := httptest.NewRequest(http.MethodPost, "/v1/items/film-1/trailers/resolve", bytes.NewBufferString(`{}`))
appleID := trailerCandidateID("apple", "https://trailers.apple.com/missing.mov")
request := httptest.NewRequest(http.MethodPost, "/v1/items/film-1/trailers/resolve",
bytes.NewBufferString(`{"excludedCandidateIds":["`+appleID+`"]}`))
request.SetPathValue("id", "film-1")
recorder := httptest.NewRecorder()
server.handleResolveTrailer(recorder, request, store.Session{EmbyUserID: "user", EmbyToken: "token"})
@@ -82,11 +55,32 @@ func TestResolveTrailerFallsThroughProviders(t *testing.T) {
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Provider != "youtube" || response.URL != "https://media.example/trailer.mp4" {
if response.Provider != "youtube" || response.SourceURL != "https://youtu.be/dQw4w9WgXcQ" || response.URL != "" {
t.Fatalf("unexpected response: %+v", response)
}
}
func TestTrailerStartedLogUsesOriginalClientIP(t *testing.T) {
logger, events := serverlogging.NewBuffered(io.Discard, slog.LevelInfo, 10, serverlogging.FormatConsole)
server := &Server{log: logger}
request := httptest.NewRequest(http.MethodPost, "/v1/items/film-1/trailers/report",
bytes.NewBufferString(`{"candidateId":"youtube-1","provider":"youtube","phase":"started"}`))
request.SetPathValue("id", "film-1")
request.Header.Set("X-Forwarded-For", "203.0.113.42, 10.0.0.2")
request, _ = withRequestIdentity(request)
identify(request.Context(), store.Session{Username: "viewer", DeviceName: "Lounge TV"})
recorder := httptest.NewRecorder()
server.handleTrailerReport(recorder, request, store.Session{})
if recorder.Code != http.StatusNoContent {
t.Fatalf("status = %d", recorder.Code)
}
page := events.Events(0, 10)
if len(page.Events) != 1 || page.Events[0].Attributes["source_ip"] != "203.0.113.42" ||
page.Events[0].Message != "trailer playback started" {
t.Fatalf("unexpected event: %+v", page.Events)
}
}
func TestRemoteTrailerPriorityPrefersOfficialAppleThenYouTube(t *testing.T) {
candidates := []trailerCandidate{
{ID: "youtube-other", Provider: "youtube", Priority: remoteTrailerPriority("youtube", "Trailer")},
@@ -99,3 +93,13 @@ func TestRemoteTrailerPriorityPrefersOfficialAppleThenYouTube(t *testing.T) {
t.Fatalf("unexpected order: %+v", candidates)
}
}
func TestNextEpisodePreviewPreferenceIsOptional(t *testing.T) {
definition, ok := preferenceDefinitionFor("playNextEpisodePreview")
if !ok {
t.Fatal("playNextEpisodePreview is missing from the preference catalogue")
}
if definition.Kind != preferenceToggle || definition.Default != false {
t.Fatalf("definition = %+v, want an opt-in toggle", definition)
}
}
+1 -1
View File
@@ -1 +1 @@
0.1.33
0.1.34
+10
View File
@@ -9,6 +9,8 @@ import (
"strings"
"time"
_ "time/tzdata"
"github.com/ponzischeme89/memby/server/internal/emby"
)
type Config struct {
@@ -30,6 +32,13 @@ type Config struct {
// no business being the thing that identifies a client to a third party.
ClientName string
// GatewayClientName is reported instead for a request the gateway makes on its own
// behalf — the library sync, the health probe, device cleanup, and an operator
// signing into the admin console or the web installer. Those are the server asking,
// and reporting them as a television made Emby's device list claim a set that does
// not exist in the house.
GatewayClientName string
HomeTTL time.Duration
ItemTTL time.Duration
SearchTTL time.Duration
@@ -152,6 +161,7 @@ func Load() (Config, error) {
DatabaseURL: os.Getenv("MEMBY_DATABASE_URL"),
RedisURL: env("MEMBY_REDIS_URL", "redis://localhost:6379/0"),
ClientName: env("MEMBY_CLIENT_NAME", "MbyATV"),
GatewayClientName: env("MEMBY_GATEWAY_CLIENT_NAME", emby.DefaultGatewayClientName),
HomeTTL: duration("MEMBY_HOME_TTL", 60*time.Second),
ItemTTL: duration("MEMBY_ITEM_TTL", 10*time.Minute),
SearchTTL: duration("MEMBY_SEARCH_TTL", 5*time.Minute),
+48 -8
View File
@@ -25,6 +25,12 @@ type Client struct {
baseURL string
publicURL string
clientName string
// gatewayClientName identifies the requests the gateway makes on its own behalf, so
// Emby's device list separates a television from the server standing behind it. The
// admin and installer sign-ins are the ones an operator sees: those are the gateway
// asking, not a set in a living room, and reporting them as a television made the
// list claim a device that does not exist.
gatewayClientName string
// gatewayVersion labels the requests the gateway makes on its own behalf — the
// library sync, the health probe, device cleanup — which belong to no television and
// so have no app version to report.
@@ -42,6 +48,12 @@ type Credentials struct {
// as it reported in X-Memby-Version. Emby shows it beside the device, so a blank one
// makes every set in the house look like the same build.
ClientVersion string
// Gateway marks a request the gateway makes for itself — the library sync, the health
// probe, device cleanup, an operator signing into the admin console — rather than on
// behalf of a television. It is stated rather than inferred from a missing token or
// version: an old app reports neither, and misreading one as the server would put a
// television in Emby's list under the wrong name.
Gateway bool
}
type Device struct {
@@ -133,11 +145,23 @@ func (e *APIError) Error() string {
return fmt.Sprintf("emby: status %d: %s", e.StatusCode, e.Body)
}
func New(baseURL, publicURL, clientName string, timeout time.Duration) *Client {
// DefaultGatewayClientName is what Emby records for a request the gateway makes for
// itself. It is deliberately not the product name: this travels to whatever Emby does
// with its own logs, and it must never read as one of the household's televisions.
const DefaultGatewayClientName = "MbyGateway"
// New builds a client. clientName identifies a television on the wire to Emby and
// gatewayClientName identifies the gateway itself; a blank gateway name falls back to
// DefaultGatewayClientName rather than borrowing the television's.
func New(baseURL, publicURL, clientName, gatewayClientName string, timeout time.Duration) *Client {
if strings.TrimSpace(gatewayClientName) == "" {
gatewayClientName = DefaultGatewayClientName
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
publicURL: strings.TrimRight(publicURL, "/"),
clientName: clientName,
gatewayClientName: gatewayClientName,
gatewayVersion: buildinfo.Version(),
http: &http.Client{
Timeout: timeout,
@@ -150,16 +174,21 @@ func New(baseURL, publicURL, clientName string, timeout time.Duration) *Client {
}
}
// Authenticate signs a television in. clientVersion is the app version that set
// reported; it is what Emby stamps on the device record it creates here, so an empty one
// leaves the entry claiming the gateway's own build.
func (c *Client) Authenticate(ctx context.Context, username, password, deviceID, deviceName, clientVersion string) (*AuthResult, error) {
// Authenticate signs a television in. cred carries the device the record is created for
// and that set's ClientVersion — Emby stamps it on the record, so an empty one leaves the
// entry claiming the gateway's own build. cred.Gateway marks a sign-in the gateway is
// making for itself (the admin console and the web installer), which Emby then records
// under the gateway's own client name rather than as a television.
func (c *Client) Authenticate(ctx context.Context, username, password string, cred Credentials) (*AuthResult, error) {
body, err := json.Marshal(map[string]string{"Username": username, "Pw": password})
if err != nil {
return nil, err
}
req, err := c.newRequest(ctx, http.MethodPost, "/Users/AuthenticateByName", nil,
Credentials{DeviceID: deviceID, DeviceName: deviceName, ClientVersion: clientVersion},
Credentials{
DeviceID: cred.DeviceID, DeviceName: cred.DeviceName,
ClientVersion: cred.ClientVersion, Gateway: cred.Gateway,
},
bytes.NewReader(body))
if err != nil {
return nil, err
@@ -610,7 +639,8 @@ func (c *Client) SubtitleURL(cred Credentials, itemID, mediaSourceID string, ind
// Ping checks that Emby is reachable, for readiness probes.
func (c *Client) Ping(ctx context.Context) error {
req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil, Credentials{}, nil)
req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil,
Credentials{Gateway: true}, nil)
if err != nil {
return err
}
@@ -657,7 +687,13 @@ func (c *Client) newRequest(ctx context.Context, method, path string, params url
}
deviceName := strings.TrimSpace(cred.DeviceName)
if deviceName == "" {
// A gateway request is not a television, so it must not fall back to the
// unnamed-set placeholder: that put the server in Emby's device list wearing a
// name that reads as somebody's TV.
deviceName = "Memby TV"
if cred.Gateway {
deviceName = c.gatewayClientName
}
}
// The version Emby records is the television's app version, not a constant: every
// device in the dashboard read as one build before this, so there was no way to tell
@@ -666,10 +702,14 @@ func (c *Client) newRequest(ctx context.Context, method, path string, params url
if version == "" {
version = c.gatewayVersion
}
clientName := c.clientName
if cred.Gateway {
clientName = c.gatewayClientName
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Emby-Authorization", fmt.Sprintf(
`MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="%s"`,
c.clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID,
clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID,
strings.ReplaceAll(version, `"`, ""),
))
if cred.Token != "" {
@@ -13,7 +13,7 @@ import (
// name is never the product name, and the version is the set's own build rather than a
// constant that made every device look alike.
func TestAuthHeaderCarriesClientVersion(t *testing.T) {
client := New("http://emby:8096", "https://emby.example", "MbyATV", time.Second)
client := New("http://emby:8096", "https://emby.example", "MbyATV", "MbyGateway", time.Second)
req, err := client.newRequest(
context.Background(), http.MethodGet, "/Items", nil,
@@ -30,7 +30,7 @@ func TestAuthHeaderCarriesClientVersion(t *testing.T) {
}
func TestAuthHeaderFallsBackToGatewayVersion(t *testing.T) {
client := New("http://emby:8096", "https://emby.example", "MbyATV", time.Second)
client := New("http://emby:8096", "https://emby.example", "MbyATV", "MbyGateway", time.Second)
req, err := client.newRequest(
context.Background(), http.MethodGet, "/Items", nil, Credentials{}, nil,
@@ -46,3 +46,52 @@ func TestAuthHeaderFallsBackToGatewayVersion(t *testing.T) {
t.Fatalf("auth header %q does not carry the gateway build %q", got, client.gatewayVersion)
}
}
// A request the gateway makes for itself — the sync, the health probe, an operator
// signing into the admin console — is not a television, and Emby's device list said it
// was. It reports the gateway's own name and never falls back to the unnamed-set
// placeholder, which is what made the server read as somebody's TV.
func TestAuthHeaderNamesTheGatewayForItsOwnRequests(t *testing.T) {
client := New("http://emby:8096", "https://emby.example", "MbyATV", "MbyGateway", time.Second)
req, err := client.newRequest(
context.Background(), http.MethodGet, "/Items", nil, Credentials{Gateway: true}, nil,
)
if err != nil {
t.Fatal(err)
}
got := req.Header.Get("X-Emby-Authorization")
if !strings.Contains(got, `Client="MbyGateway"`) {
t.Fatalf("gateway request did not report the gateway client name: %q", got)
}
if strings.Contains(got, "Memby") {
t.Fatalf("gateway request carried the product name to Emby: %q", got)
}
}
// A television's request must keep reporting the television's client name, whatever the
// gateway calls itself — the two identities are separate rows in Emby's device list.
func TestAuthHeaderKeepsTelevisionClientName(t *testing.T) {
client := New("http://emby:8096", "https://emby.example", "MbyATV", "MbyGateway", time.Second)
req, err := client.newRequest(
context.Background(), http.MethodGet, "/Items", nil,
Credentials{DeviceID: "tv-1", DeviceName: "Living room", ClientVersion: "0.2.57"}, nil,
)
if err != nil {
t.Fatal(err)
}
if got := req.Header.Get("X-Emby-Authorization"); !strings.Contains(got, `Client="MbyATV"`) {
t.Fatalf("television request did not report the app client name: %q", got)
}
}
// A blank gateway name must not silently become the television's, which would put the
// server back in the device list as a set.
func TestGatewayClientNameFallsBackToDefault(t *testing.T) {
client := New("http://emby:8096", "https://emby.example", "MbyATV", " ", time.Second)
if client.gatewayClientName != DefaultGatewayClientName {
t.Fatalf("gateway client name = %q, want %q",
client.gatewayClientName, DefaultGatewayClientName)
}
}
@@ -11,6 +11,15 @@ func TestDirectPlayVideoCodecsIncludeHEVCOnlyForCapableClient(t *testing.T) {
}
}
// Emby shows the device profile's name in its playback device list, so it carries the
// client identity — never the product name, which is what it said before.
func TestAndroidTVProfileReportsTheClientIdentityNotTheProductName(t *testing.T) {
profile := androidTVDeviceProfile(PlaybackCapabilities{})
if got := profile["Name"]; got != "MbyATV" {
t.Fatalf("device profile name = %v, want MbyATV", got)
}
}
func TestAndroidTVProfileConstrainsCodecLevelAndResolution(t *testing.T) {
profile := androidTVDeviceProfile(PlaybackCapabilities{
H264Profiles: []string{"baseline", "main", "high"},
+1 -1
View File
@@ -27,7 +27,7 @@ func TestHideFromResumeUsesDedicatedEmbyEndpoint(t *testing.T) {
}))
defer upstream.Close()
client := New(upstream.URL, upstream.URL, "MbyATV", time.Second)
client := New(upstream.URL, upstream.URL, "MbyATV", "MbyGateway", time.Second)
got, err := client.HideFromResume(
context.Background(),
Credentials{UserID: "user-1", Token: "token"},
@@ -7,7 +7,7 @@ import (
)
func TestSubtitleURLUsesCanonicalVTTEndpoint(t *testing.T) {
client := New("http://emby:8096", "https://emby.example", "Memby", time.Second)
client := New("http://emby:8096", "https://emby.example", "MbyATV", "MbyGateway", time.Second)
got := client.SubtitleURL(
Credentials{Token: "a b"},
"item id",
+9 -1
View File
@@ -11,6 +11,14 @@ package emby
import "strconv"
// deviceProfileName is what Emby records against a playback session, and it appears in
// the dashboard's device and playback lists. It is the client identity on the wire, not
// the product name — it read as "Memby Android TV" there, which is exactly the name that
// has no business travelling to somebody else's logs — and it must match the literal the
// television sends on the direct path (DeviceProfile.embyAndroidTv), or one set playing
// both ways appears twice.
const deviceProfileName = "MbyATV"
var alwaysDecodableAudioCodecs = []string{
"aac", "mp3", "flac", "opus", "vorbis", "pcm_s16le", "pcm_s24le",
}
@@ -44,7 +52,7 @@ func androidTVDeviceProfile(capabilities PlaybackCapabilities) map[string]any {
audioCodecs := directPlayAudioCodecs(capabilities)
transcodeAudio := transcodeAudioCodecs(capabilities)
return map[string]any{
"Name": "Memby Android TV", "SupportedMediaTypes": "Video",
"Name": deviceProfileName, "SupportedMediaTypes": "Video",
"DirectPlayProfiles": []map[string]string{
{
"Container": "mkv,mp4,m4v,mov,ts,mpegts", "VideoCodec": videoCodecs,
+11 -2
View File
@@ -501,8 +501,8 @@ func (s *Service) recommendationUsers(ctx context.Context) ([]store.Session, err
EmbyUserID: user.ID,
EmbyToken: s.serviceCred.Token,
Username: user.Name,
DeviceID: "memby-for-you-builder",
DeviceName: "Memby For You builder",
DeviceID: builderDeviceID,
DeviceName: builderDeviceName,
}
}
out := make([]store.Session, 0, len(byID))
@@ -964,9 +964,18 @@ func parsedTime(value string) *time.Time {
return &parsed
}
// builderDeviceID and builderDeviceName stand in for a television when the gateway
// rebuilds a viewer's rows on its own. It is the server asking, so Emby records it under
// the gateway's client name rather than as a set in the house — see Credentials.Gateway.
const (
builderDeviceID = "memby-for-you-builder"
builderDeviceName = "MbyGateway For You"
)
func credentials(sess store.Session) emby.Credentials {
return emby.Credentials{
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
Gateway: sess.DeviceID == builderDeviceID,
}
}
+3
View File
@@ -267,6 +267,9 @@ func (s *Syncer) credentials(ctx context.Context) (emby.Credentials, error) {
UserID: sess.EmbyUserID,
Token: sess.EmbyToken,
DeviceID: "memby-gateway-sync",
// The token is borrowed from a television, but the import is the gateway's own
// work and must not appear in Emby's device list as that set.
Gateway: true,
}, nil
}
-428
View File
@@ -1,428 +0,0 @@
// Package trailer resolves remote trailer pages to native media streams.
package trailer
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"sync"
"time"
)
const (
maxPageBytes = 2 << 20
cacheTTL = 30 * time.Minute
)
var ErrUnavailable = errors.New("trailer unavailable")
type Source struct {
Provider string
URL string
}
type Result struct {
URL string
MimeType string
}
type Provider interface {
Name() string
Supports(string) bool
Resolve(context.Context, string) (Result, error)
}
type cacheEntry struct {
result Result
expiresAt time.Time
}
// Resolver is an ordered provider chain with a short-lived successful mapping cache.
// The cached value avoids repeating YouTube page resolution on Back → Trailer while its
// signed media URL is still useful; failures are never cached.
type Resolver struct {
providers []Provider
mu sync.Mutex
cache map[string]cacheEntry
}
func New(client *http.Client) *Resolver {
if client == nil {
client = &http.Client{Timeout: 8 * time.Second}
}
return &Resolver{
providers: []Provider{newAppleProvider(client), newYouTubeProvider(client)},
cache: map[string]cacheEntry{},
}
}
func (r *Resolver) Resolve(ctx context.Context, source Source) (Result, error) {
key := strings.ToLower(strings.TrimSpace(source.Provider)) + "\x00" + strings.TrimSpace(source.URL)
now := time.Now()
r.mu.Lock()
if cached, ok := r.cache[key]; ok && cached.expiresAt.After(now) {
r.mu.Unlock()
return cached.result, nil
}
delete(r.cache, key)
r.mu.Unlock()
for _, provider := range r.providers {
if source.Provider != "" && !strings.EqualFold(source.Provider, provider.Name()) {
continue
}
if !provider.Supports(source.URL) {
continue
}
result, err := provider.Resolve(ctx, source.URL)
if err != nil {
return Result{}, err
}
r.mu.Lock()
r.cache[key] = cacheEntry{result: result, expiresAt: now.Add(cacheTTL)}
if len(r.cache) > 128 {
for candidate, entry := range r.cache {
if entry.expiresAt.Before(now) {
delete(r.cache, candidate)
}
}
}
r.mu.Unlock()
return result, nil
}
return Result{}, ErrUnavailable
}
func (r *Resolver) Invalidate(source Source) {
key := strings.ToLower(strings.TrimSpace(source.Provider)) + "\x00" + strings.TrimSpace(source.URL)
r.mu.Lock()
delete(r.cache, key)
r.mu.Unlock()
}
type appleProvider struct{ client *http.Client }
func newAppleProvider(client *http.Client) Provider { return &appleProvider{client: client} }
func (*appleProvider) Name() string { return "apple" }
func (*appleProvider) Supports(raw string) bool {
parsed, err := url.Parse(raw)
return err == nil && (isHostOrSubdomain(parsed.Hostname(), "apple.com") ||
isHostOrSubdomain(parsed.Hostname(), "apple.co"))
}
func (p *appleProvider) Resolve(ctx context.Context, raw string) (Result, error) {
if looksLikeMediaURL(raw) {
return p.validate(ctx, raw)
}
body, err := fetchLimited(ctx, p.client, raw, maxPageBytes, "text/html")
if err != nil {
return Result{}, err
}
links := mediaLinks(string(body))
if len(links) == 0 {
return Result{}, ErrUnavailable
}
sort.SliceStable(links, func(i, j int) bool { return mediaQuality(links[i]) > mediaQuality(links[j]) })
for _, candidate := range links {
if result, err := p.validate(ctx, candidate); err == nil {
return result, nil
}
}
return Result{}, ErrUnavailable
}
func (p *appleProvider) validate(ctx context.Context, raw string) (Result, error) {
contentType, err := validateMediaURL(ctx, p.client, raw)
if err != nil {
return Result{}, err
}
return Result{URL: raw, MimeType: contentType}, nil
}
var appleMediaURL = regexp.MustCompile(`https?:\\?/\\?/[^"'<> ]+\.(?:mov|mp4|m3u8)(?:\?[^"'<> ]*)?`)
func mediaLinks(body string) []string {
matches := appleMediaURL.FindAllString(body, -1)
seen := map[string]bool{}
out := make([]string, 0, len(matches))
for _, match := range matches {
candidate := html.UnescapeString(strings.ReplaceAll(match, `\/`, `/`))
if !seen[candidate] {
seen[candidate] = true
out = append(out, candidate)
}
}
return out
}
func mediaQuality(raw string) int {
lower := strings.ToLower(raw)
for _, quality := range []int{2160, 1440, 1080, 720, 480, 360} {
if strings.Contains(lower, fmt.Sprintf("%d", quality)) {
return quality
}
}
return 0
}
type youTubeProvider struct{ client *http.Client }
func newYouTubeProvider(client *http.Client) Provider { return &youTubeProvider{client: client} }
func (*youTubeProvider) Name() string { return "youtube" }
func (*youTubeProvider) Supports(raw string) bool { return youtubeVideoID(raw) != "" }
func (p *youTubeProvider) Resolve(ctx context.Context, raw string) (Result, error) {
videoID := youtubeVideoID(raw)
if videoID == "" {
return Result{}, ErrUnavailable
}
responses := []func(context.Context, string) (youtubePlayer, error){
p.innerTubePlayer,
p.watchPagePlayer,
}
for _, load := range responses {
player, err := load(ctx, videoID)
if err != nil || !strings.EqualFold(player.PlayabilityStatus.Status, "OK") {
continue
}
// YouTube's formats list contains progressive audio+video streams. AdaptiveFormats
// are separate tracks and would begin silently if handed straight to Media3.
formats := player.StreamingData.Formats
sort.SliceStable(formats, func(i, j int) bool {
return formats[i].Height > formats[j].Height ||
(formats[i].Height == formats[j].Height && formats[i].Bitrate > formats[j].Bitrate)
})
for _, format := range formats {
// Native playback needs one progressive stream carrying both tracks. Adaptive
// video-only formats are deliberately skipped rather than starting silent.
if format.URL == "" || !strings.Contains(format.MimeType, "video/") {
continue
}
contentType, validationErr := validateMediaURL(ctx, p.client, format.URL)
if validationErr == nil {
return Result{URL: format.URL, MimeType: contentType}, nil
}
}
}
return Result{}, ErrUnavailable
}
type youtubePlayer struct {
PlayabilityStatus struct {
Status string `json:"status"`
} `json:"playabilityStatus"`
StreamingData struct {
Formats []youtubeFormat `json:"formats"`
AdaptiveFormats []youtubeFormat `json:"adaptiveFormats"`
} `json:"streamingData"`
}
type youtubeFormat struct {
URL string `json:"url"`
MimeType string `json:"mimeType"`
Height int `json:"height"`
Bitrate int `json:"bitrate"`
}
func (p *youTubeProvider) innerTubePlayer(ctx context.Context, videoID string) (youtubePlayer, error) {
payload := map[string]any{
"videoId": videoID, "contentCheckOk": true, "racyCheckOk": true,
"context": map[string]any{"client": map[string]any{
"clientName": "ANDROID", "clientVersion": "20.10.38", "hl": "en", "gl": "NZ",
}},
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://www.youtube.com/youtubei/v1/player", bytes.NewReader(body))
if err != nil {
return youtubePlayer{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "com.google.android.youtube/20.10.38 (Linux; U; Android 12) gzip")
return p.doPlayer(req)
}
func (p *youTubeProvider) watchPagePlayer(ctx context.Context, videoID string) (youtubePlayer, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://www.youtube.com/watch?v="+url.QueryEscape(videoID)+"&bpctr=9999999999&has_verified=1", nil)
if err != nil {
return youtubePlayer{}, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Linux; Android 12; Android TV) AppleWebKit/537.36 Chrome/122 Safari/537.36")
body, err := p.do(req)
if err != nil {
return youtubePlayer{}, err
}
for _, marker := range []string{"ytInitialPlayerResponse = ", `"playerResponse":`} {
if raw := balancedJSONObject(body, marker); raw != "" {
var player youtubePlayer
if json.Unmarshal([]byte(raw), &player) == nil {
return player, nil
}
}
}
return youtubePlayer{}, ErrUnavailable
}
func (p *youTubeProvider) doPlayer(req *http.Request) (youtubePlayer, error) {
body, err := p.do(req)
if err != nil {
return youtubePlayer{}, err
}
var player youtubePlayer
if err := json.Unmarshal([]byte(body), &player); err != nil {
return youtubePlayer{}, err
}
return player, nil
}
func (p *youTubeProvider) do(req *http.Request) (string, error) {
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", ErrUnavailable
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxPageBytes+1))
if err != nil || len(body) > maxPageBytes {
return "", ErrUnavailable
}
return string(body), nil
}
func youtubeVideoID(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
host := strings.TrimPrefix(strings.ToLower(parsed.Hostname()), "www.")
var id string
switch {
case host == "youtu.be":
id = strings.Trim(parsed.Path, "/")
case isHostOrSubdomain(host, "youtube.com"), isHostOrSubdomain(host, "youtube-nocookie.com"):
id = parsed.Query().Get("v")
if id == "" {
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if len(parts) == 2 && (parts[0] == "embed" || parts[0] == "shorts") {
id = parts[1]
}
}
}
if len(id) != 11 {
return ""
}
for _, char := range id {
if !(char == '-' || char == '_' || char >= 'a' && char <= 'z' ||
char >= 'A' && char <= 'Z' || char >= '0' && char <= '9') {
return ""
}
}
return id
}
func isHostOrSubdomain(host, root string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
root = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(root), "."))
return host == root || strings.HasSuffix(host, "."+root)
}
func balancedJSONObject(body, marker string) string {
start := strings.Index(body, marker)
if start < 0 {
return ""
}
start += len(marker)
for start < len(body) && body[start] != '{' {
start++
}
if start == len(body) {
return ""
}
depth, quoted, escaped := 0, false, false
for index := start; index < len(body); index++ {
char := body[index]
if quoted {
if escaped {
escaped = false
} else if char == '\\' {
escaped = true
} else if char == '"' {
quoted = false
}
continue
}
switch char {
case '"':
quoted = true
case '{':
depth++
case '}':
depth--
if depth == 0 {
return body[start : index+1]
}
}
}
return ""
}
func looksLikeMediaURL(raw string) bool {
path := strings.ToLower(strings.Split(raw, "?")[0])
return strings.HasSuffix(path, ".mov") || strings.HasSuffix(path, ".mp4") || strings.HasSuffix(path, ".m3u8")
}
func validateMediaURL(ctx context.Context, client *http.Client, raw string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return "", err
}
req.Header.Set("Range", "bytes=0-0")
req.Header.Set("User-Agent", "Memby trailer resolver")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return "", ErrUnavailable
}
contentType := strings.ToLower(strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0]))
if !strings.HasPrefix(contentType, "video/") && contentType != "application/vnd.apple.mpegurl" &&
contentType != "application/x-mpegurl" && contentType != "application/octet-stream" {
return "", ErrUnavailable
}
return contentType, nil
}
func fetchLimited(ctx context.Context, client *http.Client, raw string, limit int64, accept string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", accept)
req.Header.Set("User-Agent", "Mozilla/5.0 (Linux; Android TV) AppleWebKit/537.36 Safari/537.36")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, ErrUnavailable
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil || int64(len(body)) > limit {
return nil, ErrUnavailable
}
return body, nil
}
-116
View File
@@ -1,116 +0,0 @@
package trailer
import (
"context"
"io"
"net/http"
"strings"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}
func TestYouTubeVideoID(t *testing.T) {
for _, raw := range []string{
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://youtu.be/dQw4w9WgXcQ",
"https://www.youtube.com/embed/dQw4w9WgXcQ",
"https://youtube.com/shorts/dQw4w9WgXcQ",
} {
if got := youtubeVideoID(raw); got != "dQw4w9WgXcQ" {
t.Fatalf("youtubeVideoID(%q) = %q", raw, got)
}
}
if got := youtubeVideoID("https://example.com/watch?v=dQw4w9WgXcQ"); got != "" {
t.Fatalf("accepted a non-YouTube host: %q", got)
}
}
func TestProviderHostMatchingRejectsLookalikeDomains(t *testing.T) {
if newAppleProvider(http.DefaultClient).Supports("https://notapple.com/trailer.mov") {
t.Fatal("lookalike Apple host was accepted")
}
if youtubeVideoID("https://notyoutube.com/watch?v=dQw4w9WgXcQ") != "" {
t.Fatal("lookalike YouTube host was accepted")
}
}
func TestYouTubeResolverReturnsValidatedProgressiveStream(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
body := ""
status := http.StatusOK
headers := http.Header{}
switch request.URL.Host {
case "www.youtube.com":
body = `{"playabilityStatus":{"status":"OK"},"streamingData":{"formats":[` +
`{"url":"https://media.example/trailer.mp4","mimeType":"video/mp4; codecs=avc1,mp4a","height":720,"bitrate":1000}]}}`
headers.Set("Content-Type", "application/json")
case "media.example":
status = http.StatusPartialContent
headers.Set("Content-Type", "video/mp4")
default:
t.Fatalf("unexpected request to %s", request.URL)
}
return &http.Response{
StatusCode: status,
Header: headers,
Body: io.NopCloser(strings.NewReader(body)),
Request: request,
}, nil
})}
resolver := New(client)
result, err := resolver.Resolve(context.Background(), Source{
Provider: "youtube",
URL: "https://youtu.be/dQw4w9WgXcQ",
})
if err != nil {
t.Fatal(err)
}
if result.URL != "https://media.example/trailer.mp4" || result.MimeType != "video/mp4" {
t.Fatalf("unexpected result: %+v", result)
}
}
func TestApplePageChoosesBestValidatedStream(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
body := ""
headers := http.Header{}
status := http.StatusOK
if request.URL.Path == "/page" {
body = `<a href="https://trailers.apple.com/film_h720p.mov">720</a>` +
`<a href="https://trailers.apple.com/film_h1080p.mov">1080</a>`
headers.Set("Content-Type", "text/html")
} else {
status = http.StatusPartialContent
headers.Set("Content-Type", "video/quicktime")
}
return &http.Response{
StatusCode: status,
Header: headers,
Body: io.NopCloser(strings.NewReader(body)),
Request: request,
}, nil
})}
resolver := New(client)
result, err := resolver.Resolve(context.Background(), Source{
Provider: "apple", URL: "https://trailers.apple.com/page",
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(result.URL, "1080") {
t.Fatalf("did not choose the best stream: %+v", result)
}
}
func TestBalancedJSONObjectIgnoresBracesInsideStrings(t *testing.T) {
body := `before marker = {"value":"}" ,"nested":{"ok":true}} after`
if got := balancedJSONObject(body, "marker = "); got != `{"value":"}" ,"nested":{"ok":true}}` {
t.Fatalf("balanced object = %q", got)
}
}