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 ## 0.2.56 — 2026-08-12
- Added: Films, series and supported seasons can now play trailers with one press. - 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. - 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) 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. 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 `Emby*` class names (`EmbyRepository`, `EmbyApi`, `EmbyServiceFactory`, `EmbyModels`) are
kept on purpose: those types model *Emby's* API, and renaming them would make the code 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*`. 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` **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 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 `PlayerActivity` immediately and `POST /v1/items/{id}/trailers/resolve` selects a candidate.
stream from `internal/trailer`. Official Apple and labelled official YouTube sources come Official Apple and labelled official YouTube sources come first, followed by local Emby media
first, followed by local Emby media and then the remaining recognised remote trailers. The server validates direct and then the remaining recognised remote trailers. Local media is returned directly; Apple
media before returning it and caches only successful mappings. The player keeps the subject and YouTube pages are resolved and validated on the television, then played natively. That
and rejected candidate ids: an error or an eight-second stall before the first frame asks for boundary is deliberate: provider requests and any IP-bound media URL must originate from the
the next provider behind the same loading surface. Exhausting the chain closes the player and viewer's real client address, never the gateway. The player keeps the subject and rejected
returns to the still-composed detail page; it never shows the ordinary playback error pane. 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: Keep these boundaries:
- Metadata discovery may be warmed and cached, but local `PlaybackInfo` and remote stream - Metadata discovery may be warmed and cached, but local `PlaybackInfo` and remote stream
resolution begin only after the viewer presses Trailer. resolution begin only after the viewer presses Trailer.
- Provider details stay behind the resolver interface. Compose knows only whether a 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 - 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. 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 - 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 the gateway-assisted path because it owns metadata discovery and candidate ordering, but
putting provider scraping or expiring URLs on every television. 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 **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 `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 /`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. 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` **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 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 `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 // A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default. // source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.56" val defaultVersionName = "0.2.57"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.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) { class EmbyRepository(private val settings: SettingsStore) {
private val clientTrailerResolver = ClientTrailerResolver()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -1458,24 +1459,68 @@ class EmbyRepository(private val settings: SettingsStore) {
request: com.ponzischeme89.memby.data.model.TrailerPlaybackRequest, request: com.ponzischeme89.memby.data.model.TrailerPlaybackRequest,
): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback { ): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback {
if (ServerConfig.isGateway) { if (ServerConfig.isGateway) {
return runCatching { var excluded = request.excludedCandidateIds.distinct()
requireGateway().resolveTrailer( repeat(MAX_TRAILER_CANDIDATES) {
request.subjectId, val selected = runCatching {
com.ponzischeme89.memby.data.model.GatewayTrailerResolveRequest( requireGateway().resolveTrailer(
request.excludedCandidateIds, request.subjectId,
), com.ponzischeme89.memby.data.model.GatewayTrailerResolveRequest(excluded),
) )
}.recoverCatching { error -> }.recoverCatching { error ->
if (error is HttpException && error.code() == 404 && request.excludedCandidateIds.isEmpty()) { if (error is HttpException && error.code() == 404 && excluded.isEmpty()) {
resolveLegacyLocalTrailer(request) resolveLegacyLocalTrailer(request)
} else { } else {
throw error 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
} }
}.getOrThrow() return selected.copy(url = resolved.url)
}
throw NoSuchElementException("No playable trailer is available")
} }
return resolveLegacyLocalTrailer(request) 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( private suspend fun resolveLegacyLocalTrailer(
request: com.ponzischeme89.memby.data.model.TrailerPlaybackRequest, request: com.ponzischeme89.memby.data.model.TrailerPlaybackRequest,
): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback { ): 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. * point of the cache is that walking back into a page never asks again.
*/ */
private const val TRAILER_CACHE_SIZE = 64 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, // 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. // 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, val showTitleLogo: Boolean = true,
// Slide up a "next up" banner near the end of an episode and roll into the next one. // Slide up a "next up" banner near the end of an episode and roll into the next one.
val autoPlayNextEpisode: Boolean = true, 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. // Show the compact lower-third when playback crosses ten minutes remaining.
val showTenMinuteReminder: Boolean = true, val showTenMinuteReminder: Boolean = true,
// Turn a subtitle track on automatically, and which language wins when one is chosen. // 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. */ /** Playback and presentation choices, which sync alongside the home ones. */
val showTitleLogo: Boolean = true, val showTitleLogo: Boolean = true,
val autoPlayNextEpisode: Boolean = true, val autoPlayNextEpisode: Boolean = true,
val playNextEpisodePreview: Boolean = false,
val showTenMinuteReminder: Boolean = true, val showTenMinuteReminder: Boolean = true,
val subtitlesEnabled: Boolean = true, val subtitlesEnabled: Boolean = true,
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO, 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 CONFIRM_EXIT_MEMBY = booleanPreferencesKey("confirm_exit_memby")
val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo") val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo")
val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next_episode") 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 SHOW_TEN_MINUTE_REMINDER = booleanPreferencesKey("show_ten_minute_reminder")
val SUBTITLES_ENABLED = booleanPreferencesKey("subtitles_enabled") val SUBTITLES_ENABLED = booleanPreferencesKey("subtitles_enabled")
val SUBTITLE_LANGUAGE = stringPreferencesKey("subtitle_language") 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) { suspend fun setShowTenMinuteReminder(enabled: Boolean) {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
preferences[Keys.SHOW_TEN_MINUTE_REMINDER] = enabled 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.WELCOME_QUOTE_STYLE] = preferences.welcomeQuoteStyle
store[Keys.THEME_ID] = preferences.themeId store[Keys.THEME_ID] = preferences.themeId
store[Keys.AUTO_PLAY_NEXT] = preferences.autoPlayNextEpisode 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.SHOW_TEN_MINUTE_REMINDER] = preferences.showTenMinuteReminder
store[Keys.SUBTITLES_ENABLED] = preferences.subtitlesEnabled store[Keys.SUBTITLES_ENABLED] = preferences.subtitlesEnabled
store[Keys.SUBTITLE_LANGUAGE] = preferences.subtitleLanguage store[Keys.SUBTITLE_LANGUAGE] = preferences.subtitleLanguage
@@ -823,6 +835,7 @@ class SettingsStore(private val context: Context) {
welcomeQuoteStyle = preferences.welcomeQuoteStyle, welcomeQuoteStyle = preferences.welcomeQuoteStyle,
themeId = preferences.themeId, themeId = preferences.themeId,
autoPlayNextEpisode = preferences.autoPlayNextEpisode, autoPlayNextEpisode = preferences.autoPlayNextEpisode,
playNextEpisodePreview = preferences.playNextEpisodePreview,
showTenMinuteReminder = preferences.showTenMinuteReminder, showTenMinuteReminder = preferences.showTenMinuteReminder,
subtitlesEnabled = preferences.subtitlesEnabled, subtitlesEnabled = preferences.subtitlesEnabled,
subtitleLanguage = preferences.subtitleLanguage, subtitleLanguage = preferences.subtitleLanguage,
@@ -1249,6 +1262,7 @@ class SettingsStore(private val context: Context) {
preferencesRevision = previous?.preferencesRevision ?: 0, preferencesRevision = previous?.preferencesRevision ?: 0,
showTitleLogo = previous?.showTitleLogo ?: true, showTitleLogo = previous?.showTitleLogo ?: true,
autoPlayNextEpisode = previous?.autoPlayNextEpisode ?: true, autoPlayNextEpisode = previous?.autoPlayNextEpisode ?: true,
playNextEpisodePreview = previous?.playNextEpisodePreview ?: false,
showTenMinuteReminder = previous?.showTenMinuteReminder ?: true, showTenMinuteReminder = previous?.showTenMinuteReminder ?: true,
subtitlesEnabled = previous?.subtitlesEnabled ?: true, subtitlesEnabled = previous?.subtitlesEnabled ?: true,
subtitleLanguage = previous?.subtitleLanguage ?: SUBTITLE_LANGUAGE_AUTO, 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.HOME_HIDDEN_ROWS)
preferences.remove(Keys.SHOW_TITLE_LOGO) preferences.remove(Keys.SHOW_TITLE_LOGO)
preferences.remove(Keys.AUTO_PLAY_NEXT) preferences.remove(Keys.AUTO_PLAY_NEXT)
preferences.remove(Keys.PLAY_NEXT_EPISODE_PREVIEW)
preferences.remove(Keys.SHOW_TEN_MINUTE_REMINDER) preferences.remove(Keys.SHOW_TEN_MINUTE_REMINDER)
preferences.remove(Keys.SUBTITLES_ENABLED) preferences.remove(Keys.SUBTITLES_ENABLED)
preferences.remove(Keys.SUBTITLE_LANGUAGE) preferences.remove(Keys.SUBTITLE_LANGUAGE)
@@ -1397,6 +1412,7 @@ class SettingsStore(private val context: Context) {
preferences[Keys.HOME_HIDDEN_ROWS] = profile.homeHiddenRows preferences[Keys.HOME_HIDDEN_ROWS] = profile.homeHiddenRows
preferences[Keys.SHOW_TITLE_LOGO] = profile.showTitleLogo preferences[Keys.SHOW_TITLE_LOGO] = profile.showTitleLogo
preferences[Keys.AUTO_PLAY_NEXT] = profile.autoPlayNextEpisode 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.SHOW_TEN_MINUTE_REMINDER] = profile.showTenMinuteReminder
preferences[Keys.SUBTITLES_ENABLED] = profile.subtitlesEnabled preferences[Keys.SUBTITLES_ENABLED] = profile.subtitlesEnabled
preferences[Keys.SUBTITLE_LANGUAGE] = profile.subtitleLanguage preferences[Keys.SUBTITLE_LANGUAGE] = profile.subtitleLanguage
@@ -1439,6 +1455,7 @@ class SettingsStore(private val context: Context) {
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0, preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true, showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true, autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
playNextEpisodePreview = preferences[Keys.PLAY_NEXT_EPISODE_PREVIEW] ?: false,
showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true, showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true,
subtitlesEnabled = preferences[Keys.SUBTITLES_ENABLED] ?: true, subtitlesEnabled = preferences[Keys.SUBTITLES_ENABLED] ?: true,
subtitleLanguage = preferences[Keys.SUBTITLE_LANGUAGE] ?: SUBTITLE_LANGUAGE_AUTO, 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, confirmExitMemby = preferences[Keys.CONFIRM_EXIT_MEMBY] ?: false,
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true, showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true, autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
playNextEpisodePreview = preferences[Keys.PLAY_NEXT_EPISODE_PREVIEW] ?: false,
showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true, showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true,
subtitlesEnabled = preferences[Keys.SUBTITLES_ENABLED] ?: true, subtitlesEnabled = preferences[Keys.SUBTITLES_ENABLED] ?: true,
subtitleLanguage = preferences[Keys.SUBTITLE_LANGUAGE] ?: SUBTITLE_LANGUAGE_AUTO, 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. */ /** Trailer failures always advance the provider chain instead of opening the generic error pane. */
internal fun shouldFallbackTrailer(isTrailer: Boolean): Boolean = isTrailer 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 themeId: String = Settings.DEFAULT_THEME_ID,
val autoPlayNextEpisode: Boolean = true, val autoPlayNextEpisode: Boolean = true,
val playNextEpisodePreview: Boolean = false,
val showTenMinuteReminder: Boolean = true, val showTenMinuteReminder: Boolean = true,
/** /**
* Whether a subtitle track is turned on automatically, and which language wins when it * 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, welcomeQuoteStyle = welcomeQuoteStyle,
themeId = themeId, themeId = themeId,
autoPlayNextEpisode = autoPlayNextEpisode, autoPlayNextEpisode = autoPlayNextEpisode,
playNextEpisodePreview = playNextEpisodePreview,
showTenMinuteReminder = showTenMinuteReminder, showTenMinuteReminder = showTenMinuteReminder,
subtitlesEnabled = subtitlesEnabled, subtitlesEnabled = subtitlesEnabled,
subtitleLanguage = subtitleLanguage, subtitleLanguage = subtitleLanguage,
@@ -128,6 +130,7 @@ fun decodeUserPreferences(
welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle), welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle),
themeId = json.string("themeId", fallback.themeId), themeId = json.string("themeId", fallback.themeId),
autoPlayNextEpisode = json.boolean("autoPlayNextEpisode", fallback.autoPlayNextEpisode), autoPlayNextEpisode = json.boolean("autoPlayNextEpisode", fallback.autoPlayNextEpisode),
playNextEpisodePreview = json.boolean("playNextEpisodePreview", fallback.playNextEpisodePreview),
showTenMinuteReminder = json.boolean("showTenMinuteReminder", fallback.showTenMinuteReminder), showTenMinuteReminder = json.boolean("showTenMinuteReminder", fallback.showTenMinuteReminder),
subtitlesEnabled = json.boolean("subtitlesEnabled", fallback.subtitlesEnabled), subtitlesEnabled = json.boolean("subtitlesEnabled", fallback.subtitlesEnabled),
subtitleLanguage = json.string("subtitleLanguage", fallback.subtitleLanguage), subtitleLanguage = json.string("subtitleLanguage", fallback.subtitleLanguage),
@@ -161,6 +164,7 @@ fun UserPreferences.encode(): JsonObject = buildJsonObject {
put("welcomeQuoteStyle", welcomeQuoteStyle) put("welcomeQuoteStyle", welcomeQuoteStyle)
put("themeId", themeId) put("themeId", themeId)
put("autoPlayNextEpisode", autoPlayNextEpisode) put("autoPlayNextEpisode", autoPlayNextEpisode)
put("playNextEpisodePreview", playNextEpisodePreview)
put("showTenMinuteReminder", showTenMinuteReminder) put("showTenMinuteReminder", showTenMinuteReminder)
put("subtitlesEnabled", subtitlesEnabled) put("subtitlesEnabled", subtitlesEnabled)
put("subtitleLanguage", subtitleLanguage) 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) (element as? JsonPrimitive)?.takeIf { it.isString }?.content?.trim()?.takeIf(String::isNotEmpty)
}.distinct() }.distinct()
} }
@@ -98,7 +98,11 @@ data class DeviceProfile(
audio: DeviceAudioCapabilities = deviceAudioCapabilities, audio: DeviceAudioCapabilities = deviceAudioCapabilities,
passthrough: AudioPassthroughPreference = AudioPassthroughPreference.AUTOMATIC, passthrough: AudioPassthroughPreference = AudioPassthroughPreference.AUTOMATIC,
) = DeviceProfile( ) = 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( subtitleProfiles = listOf(
"srt", "subrip", "ass", "ssa", "vtt", "webvtt", "mov_text", "tx3g", "srt", "subrip", "ass", "ssa", "vtt", "webvtt", "mov_text", "tx3g",
).map { SubtitleProfile(it, "External") } + listOf( ).map { SubtitleProfile(it, "External") } + listOf(
@@ -58,6 +58,7 @@ data class GatewayTrailerPlayback(
val candidateId: String = "", val candidateId: String = "",
val provider: String = "", val provider: String = "",
val url: String = "", val url: String = "",
val sourceUrl: String = "",
val title: String = "", val title: String = "",
val itemId: String = "", val itemId: String = "",
val mediaSourceId: String = "", val mediaSourceId: String = "",
@@ -71,9 +72,18 @@ data class TrailerPlaybackRequest(
val subjectId: String, val subjectId: String,
val title: String, val title: String,
val posterUrl: String? = null, val posterUrl: String? = null,
val logoUrl: String? = null,
val excludedCandidateIds: List<String> = emptyList(), val excludedCandidateIds: List<String> = emptyList(),
) )
@Serializable
data class GatewayTrailerReport(
val candidateId: String,
val provider: String,
val phase: String,
val reason: String = "",
)
@Serializable @Serializable
data class GatewayDeviceNameRequest(val deviceName: String) data class GatewayDeviceNameRequest(val deviceName: String)
@@ -302,6 +302,12 @@ interface GatewayApi {
@Body body: com.ponzischeme89.memby.data.model.GatewayTrailerResolveRequest, @Body body: com.ponzischeme89.memby.data.model.GatewayTrailerResolveRequest,
): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback ): 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") @POST("v1/items/{id}/favorite")
suspend fun setFavorite(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData suspend fun setFavorite(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData
@@ -118,7 +118,11 @@ class MembyDreamService : DreamService() {
private fun launchTrailer(item: BaseItem) { private fun launchTrailer(item: BaseItem) {
val intent = PlayerActivity.trailerIntent( val intent = PlayerActivity.trailerIntent(
applicationContext, 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) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
finish() finish()
@@ -2264,6 +2264,7 @@ private fun HomeScreen(
subjectId = item.id, subjectId = item.id,
title = item.name, title = item.name,
posterUrl = repo.primaryUrl(item, maxWidth = 500), 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.IntroSegment
import com.ponzischeme89.memby.data.creditsWorthShowing import com.ponzischeme89.memby.data.creditsWorthShowing
import com.ponzischeme89.memby.data.NextEpisode import com.ponzischeme89.memby.data.NextEpisode
import com.ponzischeme89.memby.data.ResolvedRemoteTrailer
import com.ponzischeme89.memby.data.Playable import com.ponzischeme89.memby.data.Playable
import com.ponzischeme89.memby.data.PlayableSubtitle import com.ponzischeme89.memby.data.PlayableSubtitle
import com.ponzischeme89.memby.data.PlaybackRequest 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.subtitleLabelWithFlag
import com.ponzischeme89.memby.data.afterTrailerCandidate import com.ponzischeme89.memby.data.afterTrailerCandidate
import com.ponzischeme89.memby.data.shouldFallbackTrailer 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.EmbyPerson
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayPrerollEntry import com.ponzischeme89.memby.data.model.GatewayPrerollEntry
@@ -175,6 +177,10 @@ class PlayerActivity : ComponentActivity() {
private var pendingRequest: PlaybackRequest? = null private var pendingRequest: PlaybackRequest? = null
private var pendingTrailerRequest: TrailerPlaybackRequest? = null private var pendingTrailerRequest: TrailerPlaybackRequest? = null
private var trailerStartupTimeoutJob: Job? = 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 pendingResolveJob: Job? = null
private var pendingResolveGeneration = 0L private var pendingResolveGeneration = 0L
private var serviceAlertsMounted = false private var serviceAlertsMounted = false
@@ -281,6 +287,25 @@ class PlayerActivity : ComponentActivity() {
private var returningHomeAfterCompletion = false private var returningHomeAfterCompletion = false
private var nextUpJob: Job? = null private var nextUpJob: Job? = null
private var nextEpisodeLookupJob: 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 nextUpBanner: View? = null
private var nextUpCountdown: TextView? = null private var nextUpCountdown: TextView? = null
private var nextUpDismissed = false private var nextUpDismissed = false
@@ -658,7 +683,34 @@ class PlayerActivity : ComponentActivity() {
trailerStartupTimeoutJob = null trailerStartupTimeoutJob = null
endSeekBuffering() endSeekBuffering()
hidePlaybackLoading() 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) startPlaybackSession(playback)
} }
if (prerollActive && localPrerollPlayer == null) { if (prerollActive && localPrerollPlayer == null) {
@@ -737,6 +789,8 @@ class PlayerActivity : ComponentActivity() {
playWhenReady: Boolean, playWhenReady: Boolean,
) { ) {
val playback = player ?: return val playback = player ?: return
currentMediaUrl = url
currentMediaSubtitles = subtitles
val prepared = runCatching { val prepared = runCatching {
require(url.isNotBlank()) { "Playback URL is blank" } require(url.isNotBlank()) { "Playback URL is blank" }
playback.setMediaItem(mediaItem(url, subtitles), positionMs.coerceAtLeast(0L)) playback.setMediaItem(mediaItem(url, subtitles), positionMs.coerceAtLeast(0L))
@@ -745,6 +799,10 @@ class PlayerActivity : ComponentActivity() {
} }
if (prepared.isFailure) { if (prepared.isFailure) {
Log.e(PLAYBACK_LOG_TAG, "event=media_prepare_failed item=${itemId.orEmpty()}", prepared.exceptionOrNull()) Log.e(PLAYBACK_LOG_TAG, "event=media_prepare_failed item=${itemId.orEmpty()}", prepared.exceptionOrNull())
if (playingNextEpisodePreview) {
resumeEpisodeAfterPreviewFailure("prepare")
return
}
if (shouldFallbackTrailer(pendingTrailerRequest != null)) { if (shouldFallbackTrailer(pendingTrailerRequest != null)) {
fallbackToNextTrailer("prepare") fallbackToNextTrailer("prepare")
return return
@@ -783,6 +841,10 @@ class PlayerActivity : ComponentActivity() {
return@onSuccess return@onSuccess
} }
pendingTrailerRequest = afterTrailerCandidate(request, playable.candidateId) pendingTrailerRequest = afterTrailerCandidate(request, playable.candidateId)
currentTrailerCandidateId = playable.candidateId
currentTrailerProvider = playable.provider
currentTrailerSourceUrl = playable.sourceUrl
currentTrailerStartedReported = false
itemId = playable.itemId.takeIf(String::isNotBlank) itemId = playable.itemId.takeIf(String::isNotBlank)
mediaSourceId = playable.mediaSourceId mediaSourceId = playable.mediaSourceId
playSessionId = playable.playSessionId playSessionId = playable.playSessionId
@@ -806,6 +868,18 @@ class PlayerActivity : ComponentActivity() {
trailerStartupTimeoutJob?.cancel() trailerStartupTimeoutJob?.cancel()
trailerStartupTimeoutJob = null trailerStartupTimeoutJob = null
Log.w(PLAYBACK_LOG_TAG, "event=trailer_fallback reason=$reason") 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 { player?.apply {
stop() stop()
clearMediaItems() clearMediaItems()
@@ -1412,6 +1486,10 @@ class PlayerActivity : ComponentActivity() {
} }
private fun handlePlaybackError(error: PlaybackException) { private fun handlePlaybackError(error: PlaybackException) {
if (playingNextEpisodePreview) {
resumeEpisodeAfterPreviewFailure("player_${error.errorCodeName}")
return
}
if (shouldFallbackTrailer(pendingTrailerRequest != null)) { if (shouldFallbackTrailer(pendingTrailerRequest != null)) {
fallbackToNextTrailer("player_${error.errorCodeName}") fallbackToNextTrailer("player_${error.errorCodeName}")
return return
@@ -2586,11 +2664,14 @@ class PlayerActivity : ComponentActivity() {
*/ */
private fun prefetchNextEpisode() { private fun prefetchNextEpisode() {
nextEpisodeLookupJob?.cancel() nextEpisodeLookupJob?.cancel()
nextEpisodePreviewLookupJob?.cancel()
nextEpisode = null nextEpisode = null
nextEpisodePreview = null
previewWindowArmed = false
val id = itemId?.takeIf { it.isNotBlank() } ?: return val id = itemId?.takeIf { it.isNotBlank() } ?: return
nextEpisodeLookupJob = lifecycleScope.launch { nextEpisodeLookupJob = lifecycleScope.launch {
val enabled = ServiceLocator.repository.settingsFlow.first().autoPlayNextEpisode val playbackSettings = ServiceLocator.repository.settingsFlow.first()
val resolved = if (enabled) { val resolved = if (playbackSettings.autoPlayNextEpisode) {
// A next episode with no stream behind it is not a next episode. Kept as one // 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 // 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 // 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. // Playback may have moved on to another episode while this was in flight.
if (itemId == id) { if (itemId == id) {
nextEpisode = resolved nextEpisode = resolved
if (resolved != null && playbackSettings.playNextEpisodePreview) {
prefetchNextEpisodePreview(id, resolved)
}
// Extremely short episodes and hostile latency can reach Ended before the // Extremely short episodes and hostile latency can reach Ended before the
// lookup. The ended frame waits for this answer rather than leaving Home. // lookup. The ended frame waits for this answer rather than leaving Home.
if (player?.playbackState == Player.STATE_ENDED) { 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 * 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 * deterministic: pause freezes the countdown and seeking out of the final minute
@@ -2641,16 +2735,26 @@ class PlayerActivity : ComponentActivity() {
} }
private fun updateNextUpFromPlayhead() { private fun updateNextUpFromPlayhead() {
if (advancing) return if (advancing || playingNextEpisodePreview) return
val playback = player ?: return val playback = player ?: return
val next = nextEpisode ?: return val next = nextEpisode ?: return
val duration = playback.duration val duration = playback.duration
if (duration == C.TIME_UNSET || duration <= 0L) return 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) 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 // 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 // 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 // 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) @OptIn(UnstableApi::class)
private fun showNextUp(next: NextEpisode) { private fun showNextUp(next: NextEpisode) {
val banner = nextUpBanner ?: return val banner = nextUpBanner ?: return
@@ -3012,6 +3222,20 @@ class PlayerActivity : ComponentActivity() {
} }
private fun handlePlaybackEnded() { private fun handlePlaybackEnded() {
if (playingNextEpisodePreview) {
completeNextEpisodePreview()
return
}
pendingTrailerRequest?.let { request ->
if (currentTrailerCandidateId.isNotBlank()) {
ServiceLocator.repository.reportTrailer(
request.subjectId,
currentTrailerCandidateId,
currentTrailerProvider,
phase = "completed",
)
}
}
when (playbackCompletionAction( when (playbackCompletionAction(
hasNextEpisode = nextEpisode != null, hasNextEpisode = nextEpisode != null,
nextUpDismissed = nextUpDismissed, nextUpDismissed = nextUpDismissed,
@@ -3057,7 +3281,17 @@ class PlayerActivity : ComponentActivity() {
advancing = true advancing = true
nextUpJob?.cancel() nextUpJob?.cancel()
nextEpisodeLookupJob?.cancel() nextEpisodeLookupJob?.cancel()
nextEpisodePreviewLookupJob?.cancel()
nextEpisodePreviewTimeoutJob?.cancel()
nextEpisodeLookupJob = null nextEpisodeLookupJob = null
nextEpisodePreviewLookupJob?.cancel()
nextEpisodePreviewLookupJob = null
nextEpisodePreviewTimeoutJob?.cancel()
nextEpisodePreviewTimeoutJob = null
playingNextEpisodePreview = false
previewNextEpisode = null
nextEpisodePreview = null
previewWindowArmed = false
retryGeneration += 1 retryGeneration += 1
retryJob?.cancel() retryJob?.cancel()
subtitleStreamGeneration += 1 subtitleStreamGeneration += 1
@@ -4131,16 +4365,22 @@ class PlayerActivity : ComponentActivity() {
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
player?.takeUnless { relaunchingForNewIntent }?.let { playback -> 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) 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) } ?.let { outState.putString(STATE_URL, it) }
itemId?.let { outState.putString(STATE_ITEM_ID, it) } itemId?.let { outState.putString(STATE_ITEM_ID, it) }
outState.putString(STATE_MEDIA_SOURCE_ID, mediaSourceId) outState.putString(STATE_MEDIA_SOURCE_ID, mediaSourceId)
outState.putString(STATE_PLAY_SESSION_ID, playSessionId) outState.putString(STATE_PLAY_SESSION_ID, playSessionId)
outState.putString(STATE_PLAY_METHOD, playMethod) outState.putString(STATE_PLAY_METHOD, playMethod)
if (availableSubtitles.isNotEmpty()) { val savedSubtitles = if (savingPreview) previewResumeSubtitles else availableSubtitles
outState.putString(STATE_SUBTITLES, playerJson.encodeToString(availableSubtitles)) if (savedSubtitles.isNotEmpty()) {
outState.putString(STATE_SUBTITLES, playerJson.encodeToString(savedSubtitles))
} }
outState.putBoolean(STATE_SUBTITLES_ENABLED, subtitlePreference == true) outState.putBoolean(STATE_SUBTITLES_ENABLED, subtitlePreference == true)
outState.putString(STATE_SELECTED_SUBTITLE_ID, serverSubtitleId) outState.putString(STATE_SELECTED_SUBTITLE_ID, serverSubtitleId)
@@ -4148,10 +4388,10 @@ class PlayerActivity : ComponentActivity() {
outState.putBoolean(STATE_TRICKPLAY, trickplayAvailable) outState.putBoolean(STATE_TRICKPLAY, trickplayAvailable)
outState.putBoolean(STATE_SKIP_INTRO, skipIntroAvailable) outState.putBoolean(STATE_SKIP_INTRO, skipIntroAvailable)
outState.putBoolean(STATE_END_CREDITS, endCreditsAvailable) outState.putBoolean(STATE_END_CREDITS, endCreditsAvailable)
outState.putString(STATE_TITLE, playbackTitle) outState.putString(STATE_TITLE, if (savingPreview) previewResumeTitle else playbackTitle)
outState.putString(STATE_LOGO_URL, logoUrl) outState.putString(STATE_LOGO_URL, if (savingPreview) previewResumeLogoUrl else logoUrl)
outState.putString(STATE_OVERVIEW, pauseOverview) outState.putString(STATE_OVERVIEW, if (savingPreview) previewResumeOverview else pauseOverview)
outState.putString(STATE_POSTER_URL, pausePosterUrl) outState.putString(STATE_POSTER_URL, if (savingPreview) previewResumePosterUrl else pausePosterUrl)
outState.putString(STATE_EPISODE_CODE, prerollEpisodeCode) outState.putString(STATE_EPISODE_CODE, prerollEpisodeCode)
outState.putLong(STATE_RUNTIME_MS, prerollRuntimeMs) outState.putLong(STATE_RUNTIME_MS, prerollRuntimeMs)
pendingTrailerRequest?.let { pendingTrailerRequest?.let {
@@ -4250,6 +4490,8 @@ class PlayerActivity : ComponentActivity() {
encodedSubtitleJob?.cancel() encodedSubtitleJob?.cancel()
nextUpJob?.cancel() nextUpJob?.cancel()
nextEpisodeLookupJob?.cancel() nextEpisodeLookupJob?.cancel()
nextEpisodePreviewLookupJob?.cancel()
nextEpisodePreviewTimeoutJob?.cancel()
creditsSpeedJob?.cancel() creditsSpeedJob?.cancel()
creditsView?.animate()?.cancel() creditsView?.animate()?.cancel()
retryJob?.cancel() retryJob?.cancel()
@@ -4522,6 +4764,7 @@ class PlayerActivity : ComponentActivity() {
putExtra(EXTRA_ITEM_ID, request.subjectId) putExtra(EXTRA_ITEM_ID, request.subjectId)
putExtra(EXTRA_TITLE, request.title + " trailer") putExtra(EXTRA_TITLE, request.title + " trailer")
request.posterUrl?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_POSTER_URL, it) } 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_PREROLL_ENABLED, false)
putExtra(EXTRA_REQUEST_STARTED_AT_MS, SystemClock.elapsedRealtime()) 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. */ /** 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_LEAD_MS = 60_000L
private const val NEXT_UP_TICK_MS = 250L 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_ANIMATION_MS = 260L
private const val NEXT_UP_VIDEO_SCALE = 0.58f private const val NEXT_UP_VIDEO_SCALE = 0.58f
private const val NEXT_UP_VIDEO_SHIFT_X = 0.18f private const val NEXT_UP_VIDEO_SHIFT_X = 0.18f
@@ -30,7 +30,11 @@ class ScreensaverActivity : ComponentActivity() {
startActivity( startActivity(
PlayerActivity.trailerIntent( PlayerActivity.trailerIntent(
this, 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( internal data class SettingsPanelState(
val showLogo: Boolean = true, val showLogo: Boolean = true,
val autoPlayNext: Boolean = true, val autoPlayNext: Boolean = true,
val playNextEpisodePreview: Boolean = false,
val showTenMinuteReminder: Boolean = true, val showTenMinuteReminder: Boolean = true,
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS, val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE, val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
@@ -293,6 +294,7 @@ internal data class SettingsPanelActions(
val onClose: () -> Unit = {}, val onClose: () -> Unit = {},
val onShowLogoChanged: (Boolean) -> Unit = {}, val onShowLogoChanged: (Boolean) -> Unit = {},
val onAutoPlayNextChanged: (Boolean) -> Unit = {}, val onAutoPlayNextChanged: (Boolean) -> Unit = {},
val onPlayNextEpisodePreviewChanged: (Boolean) -> Unit = {},
val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {}, val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {},
val onSeekIntervalChanged: (Int) -> Unit = {}, val onSeekIntervalChanged: (Int) -> Unit = {},
val onSkipIntroModeChanged: (String) -> Unit = {}, val onSkipIntroModeChanged: (String) -> Unit = {},
@@ -354,6 +356,7 @@ fun SettingsSheet(
var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) } var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) }
var autoPlayNext by rememberSaveable { mutableStateOf(settings.autoPlayNextEpisode) } var autoPlayNext by rememberSaveable { mutableStateOf(settings.autoPlayNextEpisode) }
var playNextEpisodePreview by rememberSaveable { mutableStateOf(settings.playNextEpisodePreview) }
var showTenMinuteReminder by rememberSaveable { mutableStateOf(settings.showTenMinuteReminder) } var showTenMinuteReminder by rememberSaveable { mutableStateOf(settings.showTenMinuteReminder) }
var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) } var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) }
var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) } var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) }
@@ -448,6 +451,7 @@ fun SettingsSheet(
settings.hideWatchedMovies, settings.hideWatchedMovies,
settings.confirmExitMemby, settings.confirmExitMemby,
settings.autoPlayNextEpisode, settings.autoPlayNextEpisode,
settings.playNextEpisodePreview,
settings.showTenMinuteReminder, settings.showTenMinuteReminder,
settings.seekIntervalSeconds, settings.seekIntervalSeconds,
settings.skipIntroMode, settings.skipIntroMode,
@@ -458,6 +462,7 @@ fun SettingsSheet(
) { ) {
showLogo = settings.showTitleLogo showLogo = settings.showTitleLogo
autoPlayNext = settings.autoPlayNextEpisode autoPlayNext = settings.autoPlayNextEpisode
playNextEpisodePreview = settings.playNextEpisodePreview
showTenMinuteReminder = settings.showTenMinuteReminder showTenMinuteReminder = settings.showTenMinuteReminder
seekInterval = settings.seekIntervalSeconds seekInterval = settings.seekIntervalSeconds
skipIntroMode = settings.skipIntroMode skipIntroMode = settings.skipIntroMode
@@ -486,6 +491,7 @@ fun SettingsSheet(
val state = SettingsPanelState( val state = SettingsPanelState(
showLogo = showLogo, showLogo = showLogo,
autoPlayNext = autoPlayNext, autoPlayNext = autoPlayNext,
playNextEpisodePreview = playNextEpisodePreview,
showTenMinuteReminder = showTenMinuteReminder, showTenMinuteReminder = showTenMinuteReminder,
seekIntervalSeconds = seekInterval, seekIntervalSeconds = seekInterval,
skipIntroMode = skipIntroMode, skipIntroMode = skipIntroMode,
@@ -535,6 +541,11 @@ fun SettingsSheet(
autoPlayNext = it autoPlayNext = it
persistSetting { store.setAutoPlayNextEpisode(it) } persistSetting { store.setAutoPlayNextEpisode(it) }
}, },
onPlayNextEpisodePreviewChanged = {
onAnalyticsEvent("next_episode_preview", "toggle")
playNextEpisodePreview = it
persistSetting { store.setPlayNextEpisodePreview(it) }
},
onShowTenMinuteReminderChanged = { onShowTenMinuteReminderChanged = {
onAnalyticsEvent("playback_reminder", "toggle") onAnalyticsEvent("playback_reminder", "toggle")
showTenMinuteReminder = it showTenMinuteReminder = it
@@ -955,6 +966,14 @@ internal fun SettingsPanelContent(
onCheckedChange = actions.onAutoPlayNextChanged, onCheckedChange = actions.onAutoPlayNextChanged,
) )
SettingDivider() 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( SettingsChoiceRow(
title = "Opening titles", title = "Opening titles",
description = "What to do when an episode reaches its intro.", 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"]}""", """{"available":true,"providers":["local","apple","youtube"]}""",
) )
val playback = json.decodeFromString<GatewayTrailerPlayback>( 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) assertTrue(availability.available)
assertEquals(listOf("local", "apple", "youtube"), availability.providers) assertEquals(listOf("local", "apple", "youtube"), availability.providers)
assertEquals("youtube-a1", playback.candidateId) assertEquals("youtube-a1", playback.candidateId)
assertEquals("https://youtu.be/dQw4w9WgXcQ", playback.sourceUrl)
assertEquals("Arrival trailer", playback.title) assertEquals("Arrival trailer", playback.title)
} }
@@ -20,6 +20,16 @@ class PlaybackReportMathTest {
assertEquals(0L, millisecondsToTicks(-1L)) 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 @Test
fun deviceProfileExternalizesTextAndEncodesBitmapSubtitles() { fun deviceProfileExternalizesTextAndEncodesBitmapSubtitles() {
val profiles = DeviceProfile.embyAndroidTv().subtitleProfiles.associate { val profiles = DeviceProfile.embyAndroidTv().subtitleProfiles.associate {
@@ -22,4 +22,12 @@ class TrailerSupportTest {
assertTrue(shouldFallbackTrailer(isTrailer = true)) assertTrue(shouldFallbackTrailer(isTrailer = true))
assertFalse(shouldFallbackTrailer(isTrailer = false)) 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, showTitleLogo = false,
welcomeQuoteStyle = "homicidal", welcomeQuoteStyle = "homicidal",
autoPlayNextEpisode = false, autoPlayNextEpisode = false,
playNextEpisodePreview = true,
showTenMinuteReminder = false, showTenMinuteReminder = false,
seekIntervalSeconds = 30, seekIntervalSeconds = 30,
skipIntroMode = SKIP_INTRO_AUTO, skipIntroMode = SKIP_INTRO_AUTO,
@@ -142,6 +143,7 @@ class UserPreferencesTest {
showTitleLogo = false, showTitleLogo = false,
welcomeQuoteStyle = "positive", welcomeQuoteStyle = "positive",
autoPlayNextEpisode = false, autoPlayNextEpisode = false,
playNextEpisodePreview = true,
showTenMinuteReminder = false, showTenMinuteReminder = false,
forYouMinutes = 30, forYouMinutes = 30,
homeRowOrder = "recommended\nlatest", homeRowOrder = "recommended\nlatest",
@@ -160,6 +162,7 @@ class UserPreferencesTest {
showTitleLogo = false, showTitleLogo = false,
welcomeQuoteStyle = "positive", welcomeQuoteStyle = "positive",
autoPlayNextEpisode = false, autoPlayNextEpisode = false,
playNextEpisodePreview = true,
showTenMinuteReminder = false, showTenMinuteReminder = false,
forYouMinutes = 30, forYouMinutes = 30,
homeRowOrder = listOf("recommended", "latest"), 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 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 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 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 After authentication, `/updates/latest.apk` redirects to the current immutable, versioned
APK in the persistent `memby-releases` volume. Direct APK requests require either that 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_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_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_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_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_TTL` | `24h` | How long computed recommendation rows stay warm |
| `MEMBY_RECOMMEND_TIMEOUT` | `60s` | Bounds a background rebuild | | `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) 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) mdblistClient := mdblist.New(mdblist.DefaultBaseURL, cfg.UpstreamTimeout)
var sonarrClient *sonarr.Client var sonarrClient *sonarr.Client
if cfg.SonarrURL != "" { if cfg.SonarrURL != "" {
@@ -153,6 +156,7 @@ func run(log *slog.Logger, events *logging.Buffer) error {
UserID: cfg.SyncUserID, UserID: cfg.SyncUserID,
Token: cfg.SyncAPIKey, Token: cfg.SyncAPIKey,
DeviceID: "memby-gateway-sync", DeviceID: "memby-gateway-sync",
Gateway: true,
}, log.With("component", "library")) }, log.With("component", "library"))
var forYouService *foryou.Service var forYouService *foryou.Service
if tracearrClient != nil { if tracearrClient != nil {
@@ -163,7 +167,8 @@ func run(log *slog.Logger, events *logging.Buffer) error {
forYouService.ConfigureTimeContext(cfg.SonarrLocation) forYouService.ConfigureTimeContext(cfg.SonarrLocation)
forYouService.ConfigureHouseholdUsers(embyClient, emby.Credentials{ forYouService.ConfigureHouseholdUsers(embyClient, emby.Credentials{
UserID: cfg.SyncUserID, Token: cfg.SyncAPIKey, 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/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store" "github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/trailer"
) )
type Server struct { type Server struct {
@@ -86,7 +85,6 @@ type Server struct {
// embyHealth is the reachability probe's live finding, which /v1/status publishes so // 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. // a TV can show why playback stopped even if it missed the announcement.
embyHealth embyHealth embyHealth embyHealth
trailers *trailer.Resolver
} }
// Deps are the collaborators the API needs. A struct rather than positional arguments: // 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 { func New(cfg config.Config, deps Deps) *Server {
trailerTimeout := cfg.UpstreamTimeout
if trailerTimeout <= 0 || trailerTimeout > 8*time.Second {
trailerTimeout = 8 * time.Second
}
return &Server{ return &Server{
cfg: cfg, cfg: cfg,
emby: deps.Emby, emby: deps.Emby,
@@ -125,7 +119,6 @@ func New(cfg config.Config, deps Deps) *Server {
syncer: deps.Syncer, syncer: deps.Syncer,
log: deps.Log, log: deps.Log,
events: deps.Events, 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}/trailer", s.authed(s.handleTrailer))
v1.Handle("GET /v1/items/{id}/trailers", s.authed(s.handleTrailers)) 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/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}/intro", s.authed(s.handleIntro))
v1.Handle("GET /v1/items/{id}/trickplay", s.authed(s.handleTrickplay)) v1.Handle("GET /v1/items/{id}/trickplay", s.authed(s.handleTrickplay))
v1.Handle("GET /v1/items/{id}/trickplay/{frame}", s.authed(s.handleTrickplayFrame)) 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( 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 { if err != nil {
// Never echo Emby's body here: a failed sign-in is the one place a wrong // 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{ if err := s.emby.DeleteDevice(ctx, emby.Credentials{
UserID: s.cfg.SyncUserID, Token: s.cfg.SyncAPIKey, 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 { }, deviceID); err != nil {
s.loggerFor(ctx).Warn("emby device cleanup failed", s.loggerFor(ctx).Warn("emby device cleanup failed",
"removed_device_id", deviceID, "error", err) "removed_device_id", deviceID, "error", err)
+28 -4
View File
@@ -19,13 +19,30 @@ const (
installerSessionTTL = 30 * time.Minute installerSessionTTL = 30 * time.Minute
adminSessionTTL = 12 * time.Hour adminSessionTTL = 12 * time.Hour
installerDeviceID = "memby-web-installer" installerDeviceID = "memby-web-installer"
installerDeviceName = "Memby Web Installer"
// adminRenewWithin is how close to expiry a session must be before an operator's own // 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. // request re-issues it. Half the TTL avoids rewriting the cookie on every request.
adminRenewWithin = adminSessionTTL / 2 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 { func (s *Server) installerSecret() []byte {
if s.cfg.ReleasePublishToken == "" { if s.cfg.ReleasePublishToken == "" {
return nil return nil
@@ -192,8 +209,13 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
return 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( 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 { if err != nil {
s.loggerFor(r.Context()).Warn("installer Emby authentication failed", "username", username) 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. // it succeeded, so retire the upstream session immediately and never persist it.
if err := s.emby.Logout(r.Context(), emby.Credentials{ if err := s.emby.Logout(r.Context(), emby.Credentials{
UserID: auth.User.ID, Token: auth.AccessToken, UserID: auth.User.ID, Token: auth.AccessToken,
DeviceID: installerDeviceID, DeviceName: installerDeviceName, DeviceID: installerDeviceID, DeviceName: s.installerDeviceName(),
Gateway: true,
}); err != nil { }); err != nil {
s.loggerFor(r.Context()).Warn("installer Emby session cleanup failed", "error", err) s.loggerFor(r.Context()).Warn("installer Emby session cleanup failed", "error", err)
} }
if err := s.emby.DeleteDevice(r.Context(), emby.Credentials{ if err := s.emby.DeleteDevice(r.Context(), emby.Credentials{
UserID: s.cfg.SyncUserID, Token: s.cfg.SyncAPIKey, 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 { }, installerDeviceID); err != nil {
s.loggerFor(r.Context()).Error("installer Emby device cleanup failed", "error", err) s.loggerFor(r.Context()).Error("installer Emby device cleanup failed", "error", err)
s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.", s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.",
+28 -1
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"context" "context"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"strings" "strings"
@@ -173,7 +174,8 @@ func isPlaybackItemPath(path string) bool {
// Fetching a subtitle is two segments deep rather than one, and matching its trailing // 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 // "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. // 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 return true
} }
switch path[strings.LastIndex(path, "/")+1:] { switch path[strings.LastIndex(path, "/")+1:] {
@@ -182,3 +184,28 @@ func isPlaybackItemPath(path string) bool {
} }
return false 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/related": "details",
"/v1/items/42/playback": "playback", "/v1/items/42/playback": "playback",
"/v1/items/42/next": "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/subtitles/search": "playback",
"/v1/items/42/trickplay": "playback", "/v1/items/42/trickplay": "playback",
"/v1/items/42/trickplay/12.jpg": "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) { func TestIdentifyNamesTheViewerAndTelevision(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/v1/home", nil) request := httptest.NewRequest(http.MethodGet, "/v1/home", nil)
request.Header.Set("X-Memby-Version", "0.1.60") 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() defer upstream.Close()
s := &Server{ 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)), log: slog.New(slog.NewTextHandler(io.Discard, nil)),
} }
req := httptest.NewRequest( req := httptest.NewRequest(
+5
View File
@@ -140,6 +140,11 @@ var preferenceCatalogue = []preferenceDefinition{
Description: "Roll into the next episode when one finishes.", Description: "Roll into the next episode when one finishes.",
Kind: preferenceToggle, Default: true, 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", Key: "showTenMinuteReminder", Name: "Ten-minute reminder", Area: "Playback",
Description: "Show the lower-third when ten minutes are left.", 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", ReleasePublishToken: "test-release-secret",
SyncUserID: "service-user", SyncAPIKey: "service-token", 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)), log: slog.New(slog.NewTextHandler(io.Discard, nil)),
} }
form := url.Values{ 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/cache"
"github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store" "github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/trailer"
) )
type remoteTrailer struct { type remoteTrailer struct {
@@ -55,6 +54,7 @@ type trailerPlaybackResponse struct {
CandidateID string `json:"candidateId"` CandidateID string `json:"candidateId"`
Provider string `json:"provider"` Provider string `json:"provider"`
URL string `json:"url"` URL string `json:"url"`
SourceURL string `json:"sourceUrl,omitempty"`
Title string `json:"title"` Title string `json:"title"`
ItemID string `json:"itemId,omitempty"` ItemID string `json:"itemId,omitempty"`
MediaSourceID string `json:"mediaSourceId,omitempty"` MediaSourceID string `json:"mediaSourceId,omitempty"`
@@ -62,6 +62,13 @@ type trailerPlaybackResponse struct {
PlayMethod string `json:"playMethod,omitempty"` 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) { func (s *Server) handleTrailers(w http.ResponseWriter, r *http.Request, sess store.Session) {
itemID := strings.TrimSpace(r.PathValue("id")) itemID := strings.TrimSpace(r.PathValue("id"))
if itemID == "" { 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") s.writeUpstreamError(r.Context(), w, err, "could not inspect trailers")
return return
} }
resolver := s.trailers
if resolver == nil {
resolver = trailer.New(nil)
}
for _, candidate := range s.preferredTrailerCandidates(r.Context(), sess, manifest) { for _, candidate := range s.preferredTrailerCandidates(r.Context(), sess, manifest) {
if excluded[candidate.ID] { if excluded[candidate.ID] {
if candidate.SourceURL != "" {
resolver.Invalidate(trailer.Source{Provider: candidate.Provider, URL: candidate.SourceURL})
}
continue continue
} }
if len(candidate.LocalItem) > 0 { if len(candidate.LocalItem) > 0 {
if resolved, resolveErr := s.resolveLocalTrailer(r.Context(), sess, manifest, candidate); resolveErr == nil { 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) writeJSON(w, http.StatusOK, resolved)
return return
} }
continue continue
} }
resolved, resolveErr := resolver.Resolve(r.Context(), trailer.Source{ // Remote pages are resolved on the television. YouTube signs direct media URLs for
Provider: candidate.Provider, // the resolving IP, so resolving here can make the URL unusable from the viewer's
URL: candidate.SourceURL, // network and makes provider traffic appear to come from the gateway.
})
if resolveErr != nil {
continue
}
s.rememberTrailerCandidate(r.Context(), sess, itemID, candidate.ID)
writeJSON(w, http.StatusOK, trailerPlaybackResponse{ writeJSON(w, http.StatusOK, trailerPlaybackResponse{
CandidateID: candidate.ID, CandidateID: candidate.ID,
Provider: candidate.Provider, Provider: candidate.Provider,
URL: resolved.URL, SourceURL: candidate.SourceURL,
Title: trailerTitle(manifest.Title, candidate.Name), Title: trailerTitle(manifest.Title, candidate.Name),
PlayMethod: "DirectPlay", 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") 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( func (s *Server) preferredTrailerCandidates(
ctx context.Context, ctx context.Context,
sess store.Session, sess store.Session,
+40 -36
View File
@@ -14,17 +14,11 @@ import (
"github.com/ponzischeme89/memby/server/internal/config" "github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby" "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/store"
"github.com/ponzischeme89/memby/server/internal/trailer"
) )
type trailerRoundTripFunc func(*http.Request) (*http.Response, error) func TestResolveTrailerSkipsRejectedProviderOnTheClientBehalf(t *testing.T) {
func (fn trailerRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}
func TestResolveTrailerFallsThroughProviders(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case strings.HasSuffix(r.URL.Path, "/LocalTrailers"): case strings.HasSuffix(r.URL.Path, "/LocalTrailers"):
@@ -43,35 +37,14 @@ func TestResolveTrailerFallsThroughProviders(t *testing.T) {
})) }))
defer upstream.Close() 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{ server := &Server{
cfg: config.Config{ItemTTL: time.Minute}, cfg: config.Config{ItemTTL: time.Minute},
emby: emby.New(upstream.URL, upstream.URL, "MbyATV", time.Second), emby: emby.New(upstream.URL, upstream.URL, "MbyATV", "MbyGateway", time.Second),
trailers: trailer.New(resolverClient), log: slog.New(slog.NewTextHandler(io.Discard, nil)),
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") request.SetPathValue("id", "film-1")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
server.handleResolveTrailer(recorder, request, store.Session{EmbyUserID: "user", EmbyToken: "token"}) 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 { if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err) 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) 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) { func TestRemoteTrailerPriorityPrefersOfficialAppleThenYouTube(t *testing.T) {
candidates := []trailerCandidate{ candidates := []trailerCandidate{
{ID: "youtube-other", Provider: "youtube", Priority: remoteTrailerPriority("youtube", "Trailer")}, {ID: "youtube-other", Provider: "youtube", Priority: remoteTrailerPriority("youtube", "Trailer")},
@@ -99,3 +93,13 @@ func TestRemoteTrailerPriorityPrefersOfficialAppleThenYouTube(t *testing.T) {
t.Fatalf("unexpected order: %+v", candidates) 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" "strings"
"time" "time"
_ "time/tzdata" _ "time/tzdata"
"github.com/ponzischeme89/memby/server/internal/emby"
) )
type Config struct { type Config struct {
@@ -30,6 +32,13 @@ type Config struct {
// no business being the thing that identifies a client to a third party. // no business being the thing that identifies a client to a third party.
ClientName string 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 HomeTTL time.Duration
ItemTTL time.Duration ItemTTL time.Duration
SearchTTL time.Duration SearchTTL time.Duration
@@ -152,6 +161,7 @@ func Load() (Config, error) {
DatabaseURL: os.Getenv("MEMBY_DATABASE_URL"), DatabaseURL: os.Getenv("MEMBY_DATABASE_URL"),
RedisURL: env("MEMBY_REDIS_URL", "redis://localhost:6379/0"), RedisURL: env("MEMBY_REDIS_URL", "redis://localhost:6379/0"),
ClientName: env("MEMBY_CLIENT_NAME", "MbyATV"), ClientName: env("MEMBY_CLIENT_NAME", "MbyATV"),
GatewayClientName: env("MEMBY_GATEWAY_CLIENT_NAME", emby.DefaultGatewayClientName),
HomeTTL: duration("MEMBY_HOME_TTL", 60*time.Second), HomeTTL: duration("MEMBY_HOME_TTL", 60*time.Second),
ItemTTL: duration("MEMBY_ITEM_TTL", 10*time.Minute), ItemTTL: duration("MEMBY_ITEM_TTL", 10*time.Minute),
SearchTTL: duration("MEMBY_SEARCH_TTL", 5*time.Minute), SearchTTL: duration("MEMBY_SEARCH_TTL", 5*time.Minute),
+52 -12
View File
@@ -25,6 +25,12 @@ type Client struct {
baseURL string baseURL string
publicURL string publicURL string
clientName 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 // 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 // library sync, the health probe, device cleanup — which belong to no television and
// so have no app version to report. // 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 // 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. // makes every set in the house look like the same build.
ClientVersion string 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 { type Device struct {
@@ -133,12 +145,24 @@ func (e *APIError) Error() string {
return fmt.Sprintf("emby: status %d: %s", e.StatusCode, e.Body) 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{ return &Client{
baseURL: strings.TrimRight(baseURL, "/"), baseURL: strings.TrimRight(baseURL, "/"),
publicURL: strings.TrimRight(publicURL, "/"), publicURL: strings.TrimRight(publicURL, "/"),
clientName: clientName, clientName: clientName,
gatewayVersion: buildinfo.Version(), gatewayClientName: gatewayClientName,
gatewayVersion: buildinfo.Version(),
http: &http.Client{ http: &http.Client{
Timeout: timeout, Timeout: timeout,
Transport: &http.Transport{ Transport: &http.Transport{
@@ -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 // Authenticate signs a television in. cred carries the device the record is created for
// reported; it is what Emby stamps on the device record it creates here, so an empty one // and that set's ClientVersion — Emby stamps it on the record, so an empty one leaves the
// leaves the entry claiming the gateway's own build. // entry claiming the gateway's own build. cred.Gateway marks a sign-in the gateway is
func (c *Client) Authenticate(ctx context.Context, username, password, deviceID, deviceName, clientVersion string) (*AuthResult, error) { // 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}) body, err := json.Marshal(map[string]string{"Username": username, "Pw": password})
if err != nil { if err != nil {
return nil, err return nil, err
} }
req, err := c.newRequest(ctx, http.MethodPost, "/Users/AuthenticateByName", nil, 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)) bytes.NewReader(body))
if err != nil { if err != nil {
return nil, err 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. // Ping checks that Emby is reachable, for readiness probes.
func (c *Client) Ping(ctx context.Context) error { 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 { if err != nil {
return err return err
} }
@@ -657,7 +687,13 @@ func (c *Client) newRequest(ctx context.Context, method, path string, params url
} }
deviceName := strings.TrimSpace(cred.DeviceName) deviceName := strings.TrimSpace(cred.DeviceName)
if 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" deviceName = "Memby TV"
if cred.Gateway {
deviceName = c.gatewayClientName
}
} }
// The version Emby records is the television's app version, not a constant: every // 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 // 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 == "" { if version == "" {
version = c.gatewayVersion version = c.gatewayVersion
} }
clientName := c.clientName
if cred.Gateway {
clientName = c.gatewayClientName
}
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("X-Emby-Authorization", fmt.Sprintf( req.Header.Set("X-Emby-Authorization", fmt.Sprintf(
`MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="%s"`, `MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="%s"`,
c.clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID, clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID,
strings.ReplaceAll(version, `"`, ""), strings.ReplaceAll(version, `"`, ""),
)) ))
if cred.Token != "" { 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 // 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. // constant that made every device look alike.
func TestAuthHeaderCarriesClientVersion(t *testing.T) { 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( req, err := client.newRequest(
context.Background(), http.MethodGet, "/Items", nil, context.Background(), http.MethodGet, "/Items", nil,
@@ -30,7 +30,7 @@ func TestAuthHeaderCarriesClientVersion(t *testing.T) {
} }
func TestAuthHeaderFallsBackToGatewayVersion(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( req, err := client.newRequest(
context.Background(), http.MethodGet, "/Items", nil, Credentials{}, nil, 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) 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) { func TestAndroidTVProfileConstrainsCodecLevelAndResolution(t *testing.T) {
profile := androidTVDeviceProfile(PlaybackCapabilities{ profile := androidTVDeviceProfile(PlaybackCapabilities{
H264Profiles: []string{"baseline", "main", "high"}, H264Profiles: []string{"baseline", "main", "high"},
+1 -1
View File
@@ -27,7 +27,7 @@ func TestHideFromResumeUsesDedicatedEmbyEndpoint(t *testing.T) {
})) }))
defer upstream.Close() 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( got, err := client.HideFromResume(
context.Background(), context.Background(),
Credentials{UserID: "user-1", Token: "token"}, Credentials{UserID: "user-1", Token: "token"},
@@ -7,7 +7,7 @@ import (
) )
func TestSubtitleURLUsesCanonicalVTTEndpoint(t *testing.T) { 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( got := client.SubtitleURL(
Credentials{Token: "a b"}, Credentials{Token: "a b"},
"item id", "item id",
+9 -1
View File
@@ -11,6 +11,14 @@ package emby
import "strconv" 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{ var alwaysDecodableAudioCodecs = []string{
"aac", "mp3", "flac", "opus", "vorbis", "pcm_s16le", "pcm_s24le", "aac", "mp3", "flac", "opus", "vorbis", "pcm_s16le", "pcm_s24le",
} }
@@ -44,7 +52,7 @@ func androidTVDeviceProfile(capabilities PlaybackCapabilities) map[string]any {
audioCodecs := directPlayAudioCodecs(capabilities) audioCodecs := directPlayAudioCodecs(capabilities)
transcodeAudio := transcodeAudioCodecs(capabilities) transcodeAudio := transcodeAudioCodecs(capabilities)
return map[string]any{ return map[string]any{
"Name": "Memby Android TV", "SupportedMediaTypes": "Video", "Name": deviceProfileName, "SupportedMediaTypes": "Video",
"DirectPlayProfiles": []map[string]string{ "DirectPlayProfiles": []map[string]string{
{ {
"Container": "mkv,mp4,m4v,mov,ts,mpegts", "VideoCodec": videoCodecs, "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, EmbyUserID: user.ID,
EmbyToken: s.serviceCred.Token, EmbyToken: s.serviceCred.Token,
Username: user.Name, Username: user.Name,
DeviceID: "memby-for-you-builder", DeviceID: builderDeviceID,
DeviceName: "Memby For You builder", DeviceName: builderDeviceName,
} }
} }
out := make([]store.Session, 0, len(byID)) out := make([]store.Session, 0, len(byID))
@@ -964,9 +964,18 @@ func parsedTime(value string) *time.Time {
return &parsed 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 { func credentials(sess store.Session) emby.Credentials {
return emby.Credentials{ return emby.Credentials{
UserID: sess.EmbyUserID, Token: sess.EmbyToken, UserID: sess.EmbyUserID, Token: sess.EmbyToken,
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName, 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, UserID: sess.EmbyUserID,
Token: sess.EmbyToken, Token: sess.EmbyToken,
DeviceID: "memby-gateway-sync", 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 }, 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)
}
}