diff --git a/CHANGELOG.md b/CHANGELOG.md index 561daab..d090364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 0.2.57 — 2026-08-12 +- Fixed: Televisions now identify themselves to Emby as MbyATV everywhere, including in the playback devices list, instead of reporting the app's own name. +- Fixed: Signing in from the admin console or the web installer is now recorded by Emby as MbyGateway, so the server is no longer listed as though it were a television. +- Fixed: Traliers show the logo from the show in the on screen player vs text. +- Added: Smart search for next episode recap/preview episode from YouTube that plays 2 mins before show end +- Added: Optional setting for next episode recap/preview. +- Fixed: Server logs for playing traliers. +- Fixed: The source IP address for each tralier play should come from the Client's real IP address, not from the server. + ## 0.2.56 — 2026-08-12 - Added: Films, series and supported seasons can now play trailers with one press. - Improved: Memby prefers official Apple and YouTube trailers, plays them natively and automatically tries another source when one is unavailable, blocked or fails during playback. diff --git a/CLAUDE.md b/CLAUDE.md index f83fbaf..f31efce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,24 @@ in Emby's dashboard read as the same build and there was no way to tell which se behind. A request the gateway makes for itself (library sync, health probe, device cleanup) carries no session, and reports the gateway's own `buildinfo.Version()` instead. +**The header is not the only thing Emby writes down.** The **device profile** sent with +`PlaybackInfo` is named in Emby's playback device list too, and it read `Memby Android TV` +on both paths — the product name, in the one place it must never be. It is now `MbyATV` +(`deviceProfileName` in `internal/emby/device_profile.go`, the literal in +`DeviceProfile.embyAndroidTv`), and like the header the two must agree or one television +playing both ways appears as two clients. + +**And the gateway is not a television.** `Credentials.Gateway` marks a request the server +makes on its own behalf, and Emby records those under `MEMBY_GATEWAY_CLIENT_NAME` +(**`MbyGateway`**, `emby.DefaultGatewayClientName`) with a device name to match, so the +sync, the health probe, device cleanup and an operator signing into the admin console or +the web installer are separable from the sets in the house. Two things to preserve: the +flag is **stated, never inferred** from a missing token or version — an old APK reports +neither, and reading one as the server would file a television under the wrong name — and +a gateway request with no device name falls back to the gateway's name rather than to +`store.DefaultDeviceName`, the placeholder that made the server read as somebody's unnamed +TV. + `Emby*` class names (`EmbyRepository`, `EmbyApi`, `EmbyServiceFactory`, `EmbyModels`) are kept on purpose: those types model *Emby's* API, and renaming them would make the code lie about what it talks to. App-identity types are `Memby*`. @@ -747,24 +765,31 @@ network answered. Things to preserve: **Trailers are a provider chain, not ordinary title playback.** `GET /v1/items/{id}/trailers` is the cheap availability answer used while a detail page is warmed; the Play press opens -`PlayerActivity` immediately and `POST /v1/items/{id}/trailers/resolve` selects one native -stream from `internal/trailer`. Official Apple and labelled official YouTube sources come -first, followed by local Emby media and then the remaining recognised remote trailers. The server validates direct -media before returning it and caches only successful mappings. The player keeps the subject -and rejected candidate ids: an error or an eight-second stall before the first frame asks for -the next provider behind the same loading surface. Exhausting the chain closes the player and -returns to the still-composed detail page; it never shows the ordinary playback error pane. +`PlayerActivity` immediately and `POST /v1/items/{id}/trailers/resolve` selects a candidate. +Official Apple and labelled official YouTube sources come first, followed by local Emby media +and then the remaining recognised remote trailers. Local media is returned directly; Apple +and YouTube pages are resolved and validated on the television, then played natively. That +boundary is deliberate: provider requests and any IP-bound media URL must originate from the +viewer's real client address, never the gateway. The player keeps the subject and rejected +candidate ids: an error or an eight-second stall before the first frame asks for the next +provider behind the same loading surface. Exhausting the chain closes the player and returns +to the still-composed detail page; it never shows the ordinary playback error pane. Keep these boundaries: - Metadata discovery may be warmed and cached, but local `PlaybackInfo` and remote stream resolution begin only after the viewer presses Trailer. - Provider details stay behind the resolver interface. Compose knows only whether a trailer - exists, and the player knows only how to ask for the next candidate. + exists, and the player knows only how to ask for the next candidate. The client resolver + caches only mappings that passed a media probe; a failed player source is evicted before + fallback. - A trailer never uses the Memby pre-roll. Back and natural completion return to the screen that launched it, while the normal native controls and aspect-ratio handling remain intact. + The player's title treatment receives the subject's logo URL, with text only as its fallback. - Direct-to-Emby mode retains local trailers. Remote Apple and YouTube resolution belongs to - the gateway, which is the only surface that can validate and cache those mappings without - putting provider scraping or expiring URLs on every television. + the gateway-assisted path because it owns metadata discovery and candidate ordering, but + provider resolution itself stays on the television. `POST /v1/items/{id}/trailers/report` + records started, failed and completed attempts with the forwarded client address; successful + candidates become the gateway's next first choice only after a real first frame. **A detail overlay seeds its settings from `repository.currentSettings`**, never `Settings.EMPTY`. Collecting a flow with an empty initial value draws the first frame under @@ -1317,6 +1342,18 @@ inside the running player instead of relaunching the activity, so `itemId`/`play /`stopReported` must all be reset together or the outgoing episode is never reported stopped; and a movie simply resolves to null, which is why nothing special-cases item type. +The optional **next-episode recap or preview** extends that same auto-advance path. Once the +next episode is known, the television searches YouTube in the background using series name, +episode code and title, ranks official previews and recaps above reactions, reviews and +breakdowns, resolves the best playable native stream, and caches the result. Playback never +waits for this work. If the setting is on and the playhead naturally crosses two minutes +remaining, the preview replaces the closing credits and then advances to the episode. Seeking +straight into that window does not trigger it. A failure before the first preview frame +silently restores the current episode at its saved position; a failure after that frame moves +on to the next episode, because the outgoing episode has already been closed. Preview time is +never reported to Emby as episode progress, and no YouTube account or embedded web player is +involved. + **Skipping is Left and Right, and it does not open anything.** `ui/player/SeekControls.kt` holds the arithmetic and the wording; `PlayerActivity.dispatchKeyEvent` owns the keys and `player_seek_indicator.xml` is the centred chip that says what just happened. How far one diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aa194a0..1f9e59a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,7 +42,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.56" +val defaultVersionName = "0.2.57" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/ClientTrailerResolver.kt b/app/src/main/java/com/ponzischeme89/memby/data/ClientTrailerResolver.kt new file mode 100644 index 0000000..1ac7862 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/ClientTrailerResolver.kt @@ -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() + + 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("&", "&") } + .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 { 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 { + 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 { + val results = mutableListOf() + 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() +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index 0125cc5..543d79b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -211,6 +211,7 @@ data class PlaybackRequest( ) class EmbyRepository(private val settings: SettingsStore) { + private val clientTrailerResolver = ClientTrailerResolver() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -1458,24 +1459,68 @@ class EmbyRepository(private val settings: SettingsStore) { request: com.ponzischeme89.memby.data.model.TrailerPlaybackRequest, ): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback { if (ServerConfig.isGateway) { - return runCatching { - requireGateway().resolveTrailer( - request.subjectId, - com.ponzischeme89.memby.data.model.GatewayTrailerResolveRequest( - request.excludedCandidateIds, - ), - ) - }.recoverCatching { error -> - if (error is HttpException && error.code() == 404 && request.excludedCandidateIds.isEmpty()) { - resolveLegacyLocalTrailer(request) - } else { - throw error + var excluded = request.excludedCandidateIds.distinct() + repeat(MAX_TRAILER_CANDIDATES) { + val selected = runCatching { + requireGateway().resolveTrailer( + request.subjectId, + com.ponzischeme89.memby.data.model.GatewayTrailerResolveRequest(excluded), + ) + }.recoverCatching { error -> + if (error is HttpException && error.code() == 404 && excluded.isEmpty()) { + resolveLegacyLocalTrailer(request) + } else { + throw error + } + }.getOrThrow() + if (selected.sourceUrl.isBlank()) return selected + val resolved = runCatching { + clientTrailerResolver.resolve(selected.provider, selected.sourceUrl) + }.getOrElse { error -> + reportTrailer( + request.subjectId, selected.candidateId, selected.provider, + phase = "failed", reason = "resolve_${error.javaClass.simpleName}", + ) + excluded = (excluded + selected.candidateId).distinct() + return@repeat } - }.getOrThrow() + return selected.copy(url = resolved.url) + } + throw NoSuchElementException("No playable trailer is available") } return resolveLegacyLocalTrailer(request) } + suspend fun nextEpisodePreview(next: NextEpisode): ResolvedRemoteTrailer? = + runCatching { clientTrailerResolver.searchNextEpisodePreview(next) }.getOrNull() + + fun reportTrailer( + itemId: String, + candidateId: String, + provider: String, + phase: String, + reason: String = "", + ) { + if (!ServerConfig.isGateway || itemId.isBlank() || candidateId.isBlank()) return + scope.launch { + runCatching { + requireGateway().reportTrailer( + itemId, + com.ponzischeme89.memby.data.model.GatewayTrailerReport( + candidateId = candidateId, + provider = provider, + phase = phase, + reason = reason, + ), + ) + } + } + } + + fun rejectTrailerSource(provider: String, sourceUrl: String) { + if (sourceUrl.isNotBlank()) clientTrailerResolver.invalidate(provider, sourceUrl) + } + private suspend fun resolveLegacyLocalTrailer( request: com.ponzischeme89.memby.data.model.TrailerPlaybackRequest, ): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback { @@ -2848,6 +2893,7 @@ private const val RELATED_LIMIT = 12 * point of the cache is that walking back into a page never asks again. */ private const val TRAILER_CACHE_SIZE = 64 +private const val MAX_TRAILER_CANDIDATES = 12 // Long enough that walking back and forth between a row and a detail page never re-asks, // short enough that a newly watched title drops out of "more like this" the same evening. diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt index 581384b..54a1bef 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt @@ -317,6 +317,8 @@ data class Settings( val showTitleLogo: Boolean = true, // Slide up a "next up" banner near the end of an episode and roll into the next one. val autoPlayNextEpisode: Boolean = true, + // Play a short, matched YouTube recap or preview before rolling into the next episode. + val playNextEpisodePreview: Boolean = false, // Show the compact lower-third when playback crosses ten minutes remaining. val showTenMinuteReminder: Boolean = true, // Turn a subtitle track on automatically, and which language wins when one is chosen. @@ -490,6 +492,7 @@ data class EmbyProfile( /** Playback and presentation choices, which sync alongside the home ones. */ val showTitleLogo: Boolean = true, val autoPlayNextEpisode: Boolean = true, + val playNextEpisodePreview: Boolean = false, val showTenMinuteReminder: Boolean = true, val subtitlesEnabled: Boolean = true, val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO, @@ -539,6 +542,7 @@ class SettingsStore(private val context: Context) { val CONFIRM_EXIT_MEMBY = booleanPreferencesKey("confirm_exit_memby") val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo") val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next_episode") + val PLAY_NEXT_EPISODE_PREVIEW = booleanPreferencesKey("play_next_episode_preview") val SHOW_TEN_MINUTE_REMINDER = booleanPreferencesKey("show_ten_minute_reminder") val SUBTITLES_ENABLED = booleanPreferencesKey("subtitles_enabled") val SUBTITLE_LANGUAGE = stringPreferencesKey("subtitle_language") @@ -671,6 +675,13 @@ class SettingsStore(private val context: Context) { } } + suspend fun setPlayNextEpisodePreview(enabled: Boolean) { + context.dataStore.edit { preferences -> + preferences[Keys.PLAY_NEXT_EPISODE_PREVIEW] = enabled + updateActiveProfile(preferences) { it.copy(playNextEpisodePreview = enabled) } + } + } + suspend fun setShowTenMinuteReminder(enabled: Boolean) { context.dataStore.edit { preferences -> preferences[Keys.SHOW_TEN_MINUTE_REMINDER] = enabled @@ -799,6 +810,7 @@ class SettingsStore(private val context: Context) { store[Keys.WELCOME_QUOTE_STYLE] = preferences.welcomeQuoteStyle store[Keys.THEME_ID] = preferences.themeId store[Keys.AUTO_PLAY_NEXT] = preferences.autoPlayNextEpisode + store[Keys.PLAY_NEXT_EPISODE_PREVIEW] = preferences.playNextEpisodePreview store[Keys.SHOW_TEN_MINUTE_REMINDER] = preferences.showTenMinuteReminder store[Keys.SUBTITLES_ENABLED] = preferences.subtitlesEnabled store[Keys.SUBTITLE_LANGUAGE] = preferences.subtitleLanguage @@ -823,6 +835,7 @@ class SettingsStore(private val context: Context) { welcomeQuoteStyle = preferences.welcomeQuoteStyle, themeId = preferences.themeId, autoPlayNextEpisode = preferences.autoPlayNextEpisode, + playNextEpisodePreview = preferences.playNextEpisodePreview, showTenMinuteReminder = preferences.showTenMinuteReminder, subtitlesEnabled = preferences.subtitlesEnabled, subtitleLanguage = preferences.subtitleLanguage, @@ -1249,6 +1262,7 @@ class SettingsStore(private val context: Context) { preferencesRevision = previous?.preferencesRevision ?: 0, showTitleLogo = previous?.showTitleLogo ?: true, autoPlayNextEpisode = previous?.autoPlayNextEpisode ?: true, + playNextEpisodePreview = previous?.playNextEpisodePreview ?: false, showTenMinuteReminder = previous?.showTenMinuteReminder ?: true, subtitlesEnabled = previous?.subtitlesEnabled ?: true, subtitleLanguage = previous?.subtitleLanguage ?: SUBTITLE_LANGUAGE_AUTO, @@ -1340,6 +1354,7 @@ class SettingsStore(private val context: Context) { preferences.remove(Keys.HOME_HIDDEN_ROWS) preferences.remove(Keys.SHOW_TITLE_LOGO) preferences.remove(Keys.AUTO_PLAY_NEXT) + preferences.remove(Keys.PLAY_NEXT_EPISODE_PREVIEW) preferences.remove(Keys.SHOW_TEN_MINUTE_REMINDER) preferences.remove(Keys.SUBTITLES_ENABLED) preferences.remove(Keys.SUBTITLE_LANGUAGE) @@ -1397,6 +1412,7 @@ class SettingsStore(private val context: Context) { preferences[Keys.HOME_HIDDEN_ROWS] = profile.homeHiddenRows preferences[Keys.SHOW_TITLE_LOGO] = profile.showTitleLogo preferences[Keys.AUTO_PLAY_NEXT] = profile.autoPlayNextEpisode + preferences[Keys.PLAY_NEXT_EPISODE_PREVIEW] = profile.playNextEpisodePreview preferences[Keys.SHOW_TEN_MINUTE_REMINDER] = profile.showTenMinuteReminder preferences[Keys.SUBTITLES_ENABLED] = profile.subtitlesEnabled preferences[Keys.SUBTITLE_LANGUAGE] = profile.subtitleLanguage @@ -1439,6 +1455,7 @@ class SettingsStore(private val context: Context) { preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0, showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true, autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true, + playNextEpisodePreview = preferences[Keys.PLAY_NEXT_EPISODE_PREVIEW] ?: false, showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true, subtitlesEnabled = preferences[Keys.SUBTITLES_ENABLED] ?: true, subtitleLanguage = preferences[Keys.SUBTITLE_LANGUAGE] ?: SUBTITLE_LANGUAGE_AUTO, @@ -1476,6 +1493,7 @@ class SettingsStore(private val context: Context) { confirmExitMemby = preferences[Keys.CONFIRM_EXIT_MEMBY] ?: false, showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true, autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true, + playNextEpisodePreview = preferences[Keys.PLAY_NEXT_EPISODE_PREVIEW] ?: false, showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true, subtitlesEnabled = preferences[Keys.SUBTITLES_ENABLED] ?: true, subtitleLanguage = preferences[Keys.SUBTITLE_LANGUAGE] ?: SUBTITLE_LANGUAGE_AUTO, diff --git a/app/src/main/java/com/ponzischeme89/memby/data/TrailerSupport.kt b/app/src/main/java/com/ponzischeme89/memby/data/TrailerSupport.kt index ee1dd82..ad49c68 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/TrailerSupport.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/TrailerSupport.kt @@ -14,3 +14,11 @@ internal fun afterTrailerCandidate( /** Trailer failures always advance the provider chain instead of opening the generic error pane. */ internal fun shouldFallbackTrailer(isTrailer: Boolean): Boolean = isTrailer + +internal fun shouldStartNextEpisodePreview( + armed: Boolean, + dismissed: Boolean, + remainingMs: Long, + leadMs: Long, + hasPreview: Boolean, +): Boolean = armed && !dismissed && hasPreview && remainingMs in 1L..leadMs diff --git a/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt b/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt index 02b3217..123489f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt @@ -44,6 +44,7 @@ data class UserPreferences( */ val themeId: String = Settings.DEFAULT_THEME_ID, val autoPlayNextEpisode: Boolean = true, + val playNextEpisodePreview: Boolean = false, val showTenMinuteReminder: Boolean = true, /** * Whether a subtitle track is turned on automatically, and which language wins when it @@ -86,6 +87,7 @@ fun Settings.toUserPreferences(): UserPreferences = UserPreferences( welcomeQuoteStyle = welcomeQuoteStyle, themeId = themeId, autoPlayNextEpisode = autoPlayNextEpisode, + playNextEpisodePreview = playNextEpisodePreview, showTenMinuteReminder = showTenMinuteReminder, subtitlesEnabled = subtitlesEnabled, subtitleLanguage = subtitleLanguage, @@ -128,6 +130,7 @@ fun decodeUserPreferences( welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle), themeId = json.string("themeId", fallback.themeId), autoPlayNextEpisode = json.boolean("autoPlayNextEpisode", fallback.autoPlayNextEpisode), + playNextEpisodePreview = json.boolean("playNextEpisodePreview", fallback.playNextEpisodePreview), showTenMinuteReminder = json.boolean("showTenMinuteReminder", fallback.showTenMinuteReminder), subtitlesEnabled = json.boolean("subtitlesEnabled", fallback.subtitlesEnabled), subtitleLanguage = json.string("subtitleLanguage", fallback.subtitleLanguage), @@ -161,6 +164,7 @@ fun UserPreferences.encode(): JsonObject = buildJsonObject { put("welcomeQuoteStyle", welcomeQuoteStyle) put("themeId", themeId) put("autoPlayNextEpisode", autoPlayNextEpisode) + put("playNextEpisodePreview", playNextEpisodePreview) put("showTenMinuteReminder", showTenMinuteReminder) put("subtitlesEnabled", subtitlesEnabled) put("subtitleLanguage", subtitleLanguage) @@ -190,4 +194,3 @@ private fun JsonObject.stringList(key: String, fallback: List): List = emptyList(), ) +@Serializable +data class GatewayTrailerReport( + val candidateId: String, + val provider: String, + val phase: String, + val reason: String = "", +) + @Serializable data class GatewayDeviceNameRequest(val deviceName: String) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt index c2689af..59fb897 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt @@ -302,6 +302,12 @@ interface GatewayApi { @Body body: com.ponzischeme89.memby.data.model.GatewayTrailerResolveRequest, ): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback + @POST("v1/items/{id}/trailers/report") + suspend fun reportTrailer( + @Path("id") itemId: String, + @Body body: com.ponzischeme89.memby.data.model.GatewayTrailerReport, + ) + @POST("v1/items/{id}/favorite") suspend fun setFavorite(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData diff --git a/app/src/main/java/com/ponzischeme89/memby/screensaver/MembyDreamService.kt b/app/src/main/java/com/ponzischeme89/memby/screensaver/MembyDreamService.kt index ffc8484..1b63cd0 100644 --- a/app/src/main/java/com/ponzischeme89/memby/screensaver/MembyDreamService.kt +++ b/app/src/main/java/com/ponzischeme89/memby/screensaver/MembyDreamService.kt @@ -118,7 +118,11 @@ class MembyDreamService : DreamService() { private fun launchTrailer(item: BaseItem) { val intent = PlayerActivity.trailerIntent( applicationContext, - TrailerPlaybackRequest(subjectId = item.id, title = item.name), + TrailerPlaybackRequest( + subjectId = item.id, + title = item.name, + logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item, 720), + ), ) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) finish() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index 62e187e..0549410 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -2264,6 +2264,7 @@ private fun HomeScreen( subjectId = item.id, title = item.name, posterUrl = repo.primaryUrl(item, maxWidth = 500), + logoUrl = repo.logoUrl(item, maxWidth = 720), ), ), ) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index 7525914..1598dec 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -65,6 +65,7 @@ import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS import com.ponzischeme89.memby.data.IntroSegment import com.ponzischeme89.memby.data.creditsWorthShowing import com.ponzischeme89.memby.data.NextEpisode +import com.ponzischeme89.memby.data.ResolvedRemoteTrailer import com.ponzischeme89.memby.data.Playable import com.ponzischeme89.memby.data.PlayableSubtitle import com.ponzischeme89.memby.data.PlaybackRequest @@ -80,6 +81,7 @@ import com.ponzischeme89.memby.data.selectSubtitleId import com.ponzischeme89.memby.data.subtitleLabelWithFlag import com.ponzischeme89.memby.data.afterTrailerCandidate import com.ponzischeme89.memby.data.shouldFallbackTrailer +import com.ponzischeme89.memby.data.shouldStartNextEpisodePreview import com.ponzischeme89.memby.data.model.EmbyPerson import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.GatewayPrerollEntry @@ -175,6 +177,10 @@ class PlayerActivity : ComponentActivity() { private var pendingRequest: PlaybackRequest? = null private var pendingTrailerRequest: TrailerPlaybackRequest? = null private var trailerStartupTimeoutJob: Job? = null + private var currentTrailerCandidateId = "" + private var currentTrailerProvider = "" + private var currentTrailerSourceUrl = "" + private var currentTrailerStartedReported = false private var pendingResolveJob: Job? = null private var pendingResolveGeneration = 0L private var serviceAlertsMounted = false @@ -281,6 +287,25 @@ class PlayerActivity : ComponentActivity() { private var returningHomeAfterCompletion = false private var nextUpJob: Job? = null private var nextEpisodeLookupJob: Job? = null + private var nextEpisodePreviewLookupJob: Job? = null + private var nextEpisodePreviewTimeoutJob: Job? = null + private var nextEpisodePreview: ResolvedRemoteTrailer? = null + private var previewNextEpisode: NextEpisode? = null + private var previewWindowArmed = false + private var playingNextEpisodePreview = false + private var previewOutgoingReported = false + private var previewResumeUrl = "" + private var previewResumeSubtitles: List = 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 = emptyList() private var nextUpBanner: View? = null private var nextUpCountdown: TextView? = null private var nextUpDismissed = false @@ -658,7 +683,34 @@ class PlayerActivity : ComponentActivity() { trailerStartupTimeoutJob = null endSeekBuffering() hidePlaybackLoading() - if (!playbackStarted && !prerollActive) { + pendingTrailerRequest?.let { request -> + if (!currentTrailerStartedReported && currentTrailerCandidateId.isNotBlank()) { + currentTrailerStartedReported = true + ServiceLocator.repository.reportTrailer( + request.subjectId, + currentTrailerCandidateId, + currentTrailerProvider, + phase = "started", + ) + } + } + if (playingNextEpisodePreview) { + nextEpisodePreviewTimeoutJob?.cancel() + nextEpisodePreviewTimeoutJob = null + reportNextEpisodePreview("started") + if (!previewOutgoingReported) { + previewOutgoingReported = true + val completedId = itemId + if (!completedId.isNullOrBlank() && previewResumePlaybackStarted) { + PlaybackStopWorker.enqueue( + this@PlayerActivity, + playbackSession(completedId), + previewResumeDurationMs, + ) + } + } + } + if (!playbackStarted && !prerollActive && !playingNextEpisodePreview) { startPlaybackSession(playback) } if (prerollActive && localPrerollPlayer == null) { @@ -737,6 +789,8 @@ class PlayerActivity : ComponentActivity() { playWhenReady: Boolean, ) { val playback = player ?: return + currentMediaUrl = url + currentMediaSubtitles = subtitles val prepared = runCatching { require(url.isNotBlank()) { "Playback URL is blank" } playback.setMediaItem(mediaItem(url, subtitles), positionMs.coerceAtLeast(0L)) @@ -745,6 +799,10 @@ class PlayerActivity : ComponentActivity() { } if (prepared.isFailure) { Log.e(PLAYBACK_LOG_TAG, "event=media_prepare_failed item=${itemId.orEmpty()}", prepared.exceptionOrNull()) + if (playingNextEpisodePreview) { + resumeEpisodeAfterPreviewFailure("prepare") + return + } if (shouldFallbackTrailer(pendingTrailerRequest != null)) { fallbackToNextTrailer("prepare") return @@ -783,6 +841,10 @@ class PlayerActivity : ComponentActivity() { return@onSuccess } pendingTrailerRequest = afterTrailerCandidate(request, playable.candidateId) + currentTrailerCandidateId = playable.candidateId + currentTrailerProvider = playable.provider + currentTrailerSourceUrl = playable.sourceUrl + currentTrailerStartedReported = false itemId = playable.itemId.takeIf(String::isNotBlank) mediaSourceId = playable.mediaSourceId playSessionId = playable.playSessionId @@ -806,6 +868,18 @@ class PlayerActivity : ComponentActivity() { trailerStartupTimeoutJob?.cancel() trailerStartupTimeoutJob = null Log.w(PLAYBACK_LOG_TAG, "event=trailer_fallback reason=$reason") + pendingTrailerRequest?.let { request -> + if (currentTrailerCandidateId.isNotBlank()) { + ServiceLocator.repository.reportTrailer( + request.subjectId, + currentTrailerCandidateId, + currentTrailerProvider, + phase = "failed", + reason = reason, + ) + ServiceLocator.repository.rejectTrailerSource(currentTrailerProvider, currentTrailerSourceUrl) + } + } player?.apply { stop() clearMediaItems() @@ -1412,6 +1486,10 @@ class PlayerActivity : ComponentActivity() { } private fun handlePlaybackError(error: PlaybackException) { + if (playingNextEpisodePreview) { + resumeEpisodeAfterPreviewFailure("player_${error.errorCodeName}") + return + } if (shouldFallbackTrailer(pendingTrailerRequest != null)) { fallbackToNextTrailer("player_${error.errorCodeName}") return @@ -2586,11 +2664,14 @@ class PlayerActivity : ComponentActivity() { */ private fun prefetchNextEpisode() { nextEpisodeLookupJob?.cancel() + nextEpisodePreviewLookupJob?.cancel() nextEpisode = null + nextEpisodePreview = null + previewWindowArmed = false val id = itemId?.takeIf { it.isNotBlank() } ?: return nextEpisodeLookupJob = lifecycleScope.launch { - val enabled = ServiceLocator.repository.settingsFlow.first().autoPlayNextEpisode - val resolved = if (enabled) { + val playbackSettings = ServiceLocator.repository.settingsFlow.first() + val resolved = if (playbackSettings.autoPlayNextEpisode) { // A next episode with no stream behind it is not a next episode. Kept as one // it would put up a banner and a countdown promising something that cannot be // played, and then — because the countdown runs itself out — swap it in @@ -2612,6 +2693,9 @@ class PlayerActivity : ComponentActivity() { // Playback may have moved on to another episode while this was in flight. if (itemId == id) { nextEpisode = resolved + if (resolved != null && playbackSettings.playNextEpisodePreview) { + prefetchNextEpisodePreview(id, resolved) + } // Extremely short episodes and hostile latency can reach Ended before the // lookup. The ended frame waits for this answer rather than leaving Home. if (player?.playbackState == Player.STATE_ENDED) { @@ -2625,6 +2709,16 @@ class PlayerActivity : ComponentActivity() { } } + private fun prefetchNextEpisodePreview(currentItemId: String, next: NextEpisode) { + nextEpisodePreviewLookupJob?.cancel() + nextEpisodePreviewLookupJob = lifecycleScope.launch { + val preview = ServiceLocator.repository.nextEpisodePreview(next) + if (itemId == currentItemId && nextEpisode?.itemId == next.itemId) { + nextEpisodePreview = preview + } + } + } + /** * Watches the media playhead rather than wall-clock time. This makes the transition * deterministic: pause freezes the countdown and seeking out of the final minute @@ -2641,16 +2735,26 @@ class PlayerActivity : ComponentActivity() { } private fun updateNextUpFromPlayhead() { - if (advancing) return + if (advancing || playingNextEpisodePreview) return val playback = player ?: return val next = nextEpisode ?: return val duration = playback.duration if (duration == C.TIME_UNSET || duration <= 0L) return - // Evaluated first, because whether the pane is up decides what the banner may do. - updateEndCreditsFromPlayhead(playback, next) - val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L) + if (remainingMs > NEXT_EPISODE_PREVIEW_LEAD_MS) previewWindowArmed = true + if (shouldStartNextEpisodePreview( + armed = previewWindowArmed, + dismissed = nextUpDismissed, + remainingMs = remainingMs, + leadMs = NEXT_EPISODE_PREVIEW_LEAD_MS, + hasPreview = nextEpisodePreview != null, + ) && startNextEpisodePreview(playback, next) + ) return + + // Evaluated after the preview: both take over the closing-credits presentation, and + // a resolved preview is the more specific next-episode experience. + updateEndCreditsFromPlayhead(playback, next) // The pane says what is on next already, and shrinks the same picture by a different // amount. Raising the banner over it would fight for the transform and print the // episode twice, so the countdown moves into the pane instead — and it is only worth @@ -2683,6 +2787,112 @@ class PlayerActivity : ComponentActivity() { } } + private fun startNextEpisodePreview(playback: Player, next: NextEpisode): Boolean { + val preview = nextEpisodePreview ?: return false + if (playingNextEpisodePreview || preview.url.isBlank()) return false + + previewResumeUrl = currentMediaUrl + previewResumeSubtitles = currentMediaSubtitles + previewResumePositionMs = playback.currentPosition.coerceAtLeast(0L) + previewResumeDurationMs = playback.duration.coerceAtLeast(previewResumePositionMs) + previewResumeTitle = playbackTitle + previewResumeLogoUrl = logoUrl + previewResumePosterUrl = pausePosterUrl + previewResumeOverview = pauseOverview + previewResumePlaybackStarted = playbackStarted + previewResumeStopReported = stopReported + previewOutgoingReported = false + previewNextEpisode = next + playingNextEpisodePreview = true + stopReported = true // Prevent preview positions being reported against the episode. + + stopProgressUploading() + hideNextUp() + if (creditsActive) leaveEndCredits(restoreSpeed = true) + playbackTitle = "Next: ${nextTitle(next)}" + logoUrl = next.logoUrl + pausePosterUrl = next.imageUrl + pauseOverview = next.overview + bindTitleArtwork(playbackTitle, logoUrl) + setUpPlaybackIdentity(playbackTitle) + renderedFirstFrame = false + showPlaybackLoading(title = "Finding the next episode…", hint = "Starting recap or preview") + startMedia(preview.url, emptyList(), 0L, playWhenReady = true) + nextEpisodePreviewTimeoutJob?.cancel() + nextEpisodePreviewTimeoutJob = lifecycleScope.launch { + delay(NEXT_EPISODE_PREVIEW_STARTUP_TIMEOUT_MS) + if (playingNextEpisodePreview && !renderedFirstFrame) { + resumeEpisodeAfterPreviewFailure("startup_timeout") + } + } + return true + } + + private fun resumeEpisodeAfterPreviewFailure(reason: String) { + if (!playingNextEpisodePreview) return + val next = previewNextEpisode + nextEpisodePreview?.let { + ServiceLocator.repository.rejectTrailerSource(it.provider, it.sourceUrl) + } + reportNextEpisodePreview("failed", reason) + nextEpisodePreviewTimeoutJob?.cancel() + nextEpisodePreviewTimeoutJob = null + nextEpisodePreview = null + playingNextEpisodePreview = false + previewNextEpisode = null + if (previewOutgoingReported) { + next?.let(::playNext) ?: returnHomeAfterCompletion() + return + } + playbackTitle = previewResumeTitle + logoUrl = previewResumeLogoUrl + pausePosterUrl = previewResumePosterUrl + pauseOverview = previewResumeOverview + playbackStarted = previewResumePlaybackStarted + stopReported = previewResumeStopReported + bindTitleArtwork(playbackTitle, logoUrl) + setUpPlaybackIdentity(playbackTitle) + renderedFirstFrame = false + if (previewResumeUrl.isBlank()) { + // The original stream should always be known, but losing the optional preview + // must never strand the viewer on a generic error pane. + next?.let(::playNext) ?: finish() + return + } + showPlaybackLoading(title = playbackTitle, hint = "Returning to the episode") + startMedia( + previewResumeUrl, + previewResumeSubtitles, + previewResumePositionMs, + playWhenReady = true, + ) + if (playbackStarted && !stopReported) startProgressReporting() + } + + private fun reportNextEpisodePreview(phase: String, reason: String = "") { + val preview = nextEpisodePreview ?: return + val subject = previewNextEpisode?.itemId ?: return + ServiceLocator.repository.reportTrailer( + subject, + preview.candidateId, + preview.provider, + phase, + reason, + ) + } + + private fun completeNextEpisodePreview() { + if (!playingNextEpisodePreview) return + reportNextEpisodePreview("completed") + nextEpisodePreviewTimeoutJob?.cancel() + nextEpisodePreviewTimeoutJob = null + val next = previewNextEpisode + playingNextEpisodePreview = false + previewNextEpisode = null + nextEpisodePreview = null + next?.let(::playNext) ?: returnHomeAfterCompletion() + } + @OptIn(UnstableApi::class) private fun showNextUp(next: NextEpisode) { val banner = nextUpBanner ?: return @@ -3012,6 +3222,20 @@ class PlayerActivity : ComponentActivity() { } private fun handlePlaybackEnded() { + if (playingNextEpisodePreview) { + completeNextEpisodePreview() + return + } + pendingTrailerRequest?.let { request -> + if (currentTrailerCandidateId.isNotBlank()) { + ServiceLocator.repository.reportTrailer( + request.subjectId, + currentTrailerCandidateId, + currentTrailerProvider, + phase = "completed", + ) + } + } when (playbackCompletionAction( hasNextEpisode = nextEpisode != null, nextUpDismissed = nextUpDismissed, @@ -3057,7 +3281,17 @@ class PlayerActivity : ComponentActivity() { advancing = true nextUpJob?.cancel() nextEpisodeLookupJob?.cancel() + nextEpisodePreviewLookupJob?.cancel() + nextEpisodePreviewTimeoutJob?.cancel() nextEpisodeLookupJob = null + nextEpisodePreviewLookupJob?.cancel() + nextEpisodePreviewLookupJob = null + nextEpisodePreviewTimeoutJob?.cancel() + nextEpisodePreviewTimeoutJob = null + playingNextEpisodePreview = false + previewNextEpisode = null + nextEpisodePreview = null + previewWindowArmed = false retryGeneration += 1 retryJob?.cancel() subtitleStreamGeneration += 1 @@ -4131,16 +4365,22 @@ class PlayerActivity : ComponentActivity() { override fun onSaveInstanceState(outState: Bundle) { player?.takeUnless { relaunchingForNewIntent }?.let { playback -> - outState.putLong(STATE_POSITION_MS, playback.currentPosition.coerceAtLeast(0L)) + val savingPreview = playingNextEpisodePreview && previewResumeUrl.isNotBlank() + outState.putLong( + STATE_POSITION_MS, + if (savingPreview) previewResumePositionMs else playback.currentPosition.coerceAtLeast(0L), + ) outState.putBoolean(STATE_PLAY_WHEN_READY, playback.playWhenReady) - playback.currentMediaItem?.localConfiguration?.uri?.toString()?.takeIf(String::isNotBlank) + (if (savingPreview) previewResumeUrl else playback.currentMediaItem?.localConfiguration?.uri?.toString()) + ?.takeIf(String::isNotBlank) ?.let { outState.putString(STATE_URL, it) } itemId?.let { outState.putString(STATE_ITEM_ID, it) } outState.putString(STATE_MEDIA_SOURCE_ID, mediaSourceId) outState.putString(STATE_PLAY_SESSION_ID, playSessionId) outState.putString(STATE_PLAY_METHOD, playMethod) - if (availableSubtitles.isNotEmpty()) { - outState.putString(STATE_SUBTITLES, playerJson.encodeToString(availableSubtitles)) + val savedSubtitles = if (savingPreview) previewResumeSubtitles else availableSubtitles + if (savedSubtitles.isNotEmpty()) { + outState.putString(STATE_SUBTITLES, playerJson.encodeToString(savedSubtitles)) } outState.putBoolean(STATE_SUBTITLES_ENABLED, subtitlePreference == true) outState.putString(STATE_SELECTED_SUBTITLE_ID, serverSubtitleId) @@ -4148,10 +4388,10 @@ class PlayerActivity : ComponentActivity() { outState.putBoolean(STATE_TRICKPLAY, trickplayAvailable) outState.putBoolean(STATE_SKIP_INTRO, skipIntroAvailable) outState.putBoolean(STATE_END_CREDITS, endCreditsAvailable) - outState.putString(STATE_TITLE, playbackTitle) - outState.putString(STATE_LOGO_URL, logoUrl) - outState.putString(STATE_OVERVIEW, pauseOverview) - outState.putString(STATE_POSTER_URL, pausePosterUrl) + outState.putString(STATE_TITLE, if (savingPreview) previewResumeTitle else playbackTitle) + outState.putString(STATE_LOGO_URL, if (savingPreview) previewResumeLogoUrl else logoUrl) + outState.putString(STATE_OVERVIEW, if (savingPreview) previewResumeOverview else pauseOverview) + outState.putString(STATE_POSTER_URL, if (savingPreview) previewResumePosterUrl else pausePosterUrl) outState.putString(STATE_EPISODE_CODE, prerollEpisodeCode) outState.putLong(STATE_RUNTIME_MS, prerollRuntimeMs) pendingTrailerRequest?.let { @@ -4250,6 +4490,8 @@ class PlayerActivity : ComponentActivity() { encodedSubtitleJob?.cancel() nextUpJob?.cancel() nextEpisodeLookupJob?.cancel() + nextEpisodePreviewLookupJob?.cancel() + nextEpisodePreviewTimeoutJob?.cancel() creditsSpeedJob?.cancel() creditsView?.animate()?.cancel() retryJob?.cancel() @@ -4522,6 +4764,7 @@ class PlayerActivity : ComponentActivity() { putExtra(EXTRA_ITEM_ID, request.subjectId) putExtra(EXTRA_TITLE, request.title + " trailer") request.posterUrl?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_POSTER_URL, it) } + request.logoUrl?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_LOGO_URL, it) } putExtra(EXTRA_PREROLL_ENABLED, false) putExtra(EXTRA_REQUEST_STARTED_AT_MS, SystemClock.elapsedRealtime()) } @@ -4683,6 +4926,8 @@ class PlayerActivity : ComponentActivity() { /** Show against the still-playing final minute, using media time for determinism. */ private const val NEXT_UP_LEAD_MS = 60_000L private const val NEXT_UP_TICK_MS = 250L + private const val NEXT_EPISODE_PREVIEW_LEAD_MS = 120_000L + private const val NEXT_EPISODE_PREVIEW_STARTUP_TIMEOUT_MS = 8_000L private const val NEXT_UP_ANIMATION_MS = 260L private const val NEXT_UP_VIDEO_SCALE = 0.58f private const val NEXT_UP_VIDEO_SHIFT_X = 0.18f diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverActivity.kt index 4bd1a8c..9f586c5 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverActivity.kt @@ -30,7 +30,11 @@ class ScreensaverActivity : ComponentActivity() { startActivity( PlayerActivity.trailerIntent( this, - TrailerPlaybackRequest(subjectId = item.id, title = item.name), + TrailerPlaybackRequest( + subjectId = item.id, + title = item.name, + logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item, 720), + ), ), ) }, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index eb73091..bebadff 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -233,6 +233,7 @@ private val RailEdge = Color.White.copy(alpha = 0.10f) internal data class SettingsPanelState( val showLogo: Boolean = true, val autoPlayNext: Boolean = true, + val playNextEpisodePreview: Boolean = false, val showTenMinuteReminder: Boolean = true, val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS, val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE, @@ -293,6 +294,7 @@ internal data class SettingsPanelActions( val onClose: () -> Unit = {}, val onShowLogoChanged: (Boolean) -> Unit = {}, val onAutoPlayNextChanged: (Boolean) -> Unit = {}, + val onPlayNextEpisodePreviewChanged: (Boolean) -> Unit = {}, val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {}, val onSeekIntervalChanged: (Int) -> Unit = {}, val onSkipIntroModeChanged: (String) -> Unit = {}, @@ -354,6 +356,7 @@ fun SettingsSheet( var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) } var autoPlayNext by rememberSaveable { mutableStateOf(settings.autoPlayNextEpisode) } + var playNextEpisodePreview by rememberSaveable { mutableStateOf(settings.playNextEpisodePreview) } var showTenMinuteReminder by rememberSaveable { mutableStateOf(settings.showTenMinuteReminder) } var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) } var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) } @@ -448,6 +451,7 @@ fun SettingsSheet( settings.hideWatchedMovies, settings.confirmExitMemby, settings.autoPlayNextEpisode, + settings.playNextEpisodePreview, settings.showTenMinuteReminder, settings.seekIntervalSeconds, settings.skipIntroMode, @@ -458,6 +462,7 @@ fun SettingsSheet( ) { showLogo = settings.showTitleLogo autoPlayNext = settings.autoPlayNextEpisode + playNextEpisodePreview = settings.playNextEpisodePreview showTenMinuteReminder = settings.showTenMinuteReminder seekInterval = settings.seekIntervalSeconds skipIntroMode = settings.skipIntroMode @@ -486,6 +491,7 @@ fun SettingsSheet( val state = SettingsPanelState( showLogo = showLogo, autoPlayNext = autoPlayNext, + playNextEpisodePreview = playNextEpisodePreview, showTenMinuteReminder = showTenMinuteReminder, seekIntervalSeconds = seekInterval, skipIntroMode = skipIntroMode, @@ -535,6 +541,11 @@ fun SettingsSheet( autoPlayNext = it persistSetting { store.setAutoPlayNextEpisode(it) } }, + onPlayNextEpisodePreviewChanged = { + onAnalyticsEvent("next_episode_preview", "toggle") + playNextEpisodePreview = it + persistSetting { store.setPlayNextEpisodePreview(it) } + }, onShowTenMinuteReminderChanged = { onAnalyticsEvent("playback_reminder", "toggle") showTenMinuteReminder = it @@ -955,6 +966,14 @@ internal fun SettingsPanelContent( onCheckedChange = actions.onAutoPlayNextChanged, ) SettingDivider() + SettingsToggleRow( + title = "Next-episode recap or preview", + description = "With auto-play on, find a matching YouTube recap or preview and play it " + + "two minutes before the episode ends.", + checked = state.playNextEpisodePreview, + onCheckedChange = actions.onPlayNextEpisodePreviewChanged, + ) + SettingDivider() SettingsChoiceRow( title = "Opening titles", description = "What to do when an episode reaches its intro.", diff --git a/app/src/test/java/com/ponzischeme89/memby/data/ClientTrailerResolverTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/ClientTrailerResolverTest.kt new file mode 100644 index 0000000..06de66a --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/ClientTrailerResolverTest.kt @@ -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")) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt index 7b7e0e3..6489030 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt @@ -36,12 +36,13 @@ class GatewayPayloadTest { """{"available":true,"providers":["local","apple","youtube"]}""", ) val playback = json.decodeFromString( - """{"candidateId":"youtube-a1","provider":"youtube","url":"https://media.example/trailer.mp4","title":"Arrival trailer","playMethod":"DirectPlay"}""", + """{"candidateId":"youtube-a1","provider":"youtube","sourceUrl":"https://youtu.be/dQw4w9WgXcQ","title":"Arrival trailer","playMethod":"DirectPlay"}""", ) assertTrue(availability.available) assertEquals(listOf("local", "apple", "youtube"), availability.providers) assertEquals("youtube-a1", playback.candidateId) + assertEquals("https://youtu.be/dQw4w9WgXcQ", playback.sourceUrl) assertEquals("Arrival trailer", playback.title) } diff --git a/app/src/test/java/com/ponzischeme89/memby/data/PlaybackReportMathTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/PlaybackReportMathTest.kt index b81102d..27f1977 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/PlaybackReportMathTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/PlaybackReportMathTest.kt @@ -20,6 +20,16 @@ class PlaybackReportMathTest { assertEquals(0L, millisecondsToTicks(-1L)) } + /** + * Emby lists the device profile's name against a playback session, so it must carry + * the client identity rather than the product name — and it must be the same literal + * the gateway sends, or one television playing both ways appears as two clients. + */ + @Test + fun deviceProfileReportsTheClientIdentityNotTheProductName() { + assertEquals("MbyATV", DeviceProfile.embyAndroidTv().name) + } + @Test fun deviceProfileExternalizesTextAndEncodesBitmapSubtitles() { val profiles = DeviceProfile.embyAndroidTv().subtitleProfiles.associate { diff --git a/app/src/test/java/com/ponzischeme89/memby/data/TrailerSupportTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/TrailerSupportTest.kt index 2f4aa4f..b79c374 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/TrailerSupportTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/TrailerSupportTest.kt @@ -22,4 +22,12 @@ class TrailerSupportTest { assertTrue(shouldFallbackTrailer(isTrailer = true)) assertFalse(shouldFallbackTrailer(isTrailer = false)) } + + @Test + fun nextEpisodePreviewNeedsANaturalTwoMinuteWindow() { + assertTrue(shouldStartNextEpisodePreview(true, false, 120_000, 120_000, true)) + assertFalse(shouldStartNextEpisodePreview(false, false, 60_000, 120_000, true)) + assertFalse(shouldStartNextEpisodePreview(true, true, 60_000, 120_000, true)) + assertFalse(shouldStartNextEpisodePreview(true, false, 120_001, 120_000, true)) + } } diff --git a/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt index 12c22a1..f72ca13 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt @@ -27,6 +27,7 @@ class UserPreferencesTest { showTitleLogo = false, welcomeQuoteStyle = "homicidal", autoPlayNextEpisode = false, + playNextEpisodePreview = true, showTenMinuteReminder = false, seekIntervalSeconds = 30, skipIntroMode = SKIP_INTRO_AUTO, @@ -142,6 +143,7 @@ class UserPreferencesTest { showTitleLogo = false, welcomeQuoteStyle = "positive", autoPlayNextEpisode = false, + playNextEpisodePreview = true, showTenMinuteReminder = false, forYouMinutes = 30, homeRowOrder = "recommended\nlatest", @@ -160,6 +162,7 @@ class UserPreferencesTest { showTitleLogo = false, welcomeQuoteStyle = "positive", autoPlayNextEpisode = false, + playNextEpisodePreview = true, showTenMinuteReminder = false, forYouMinutes = 30, homeRowOrder = listOf("recommended", "latest"), diff --git a/server/README.md b/server/README.md index 92d7cf7..950a87d 100644 --- a/server/README.md +++ b/server/README.md @@ -449,7 +449,9 @@ shown, it asks for an Emby household username and password and verifies them dir against Emby. Credentials are never stored. The temporary Emby access token is logged out immediately, and the browser receives a secure, HTTP-only, same-site installer cookie valid for 30 minutes. This browser login does not create a Memby TV session and therefore -does not appear in the user's signed-in device list. +does not appear in the user's signed-in device list. Emby records the password check +under the gateway's own client name (`MEMBY_GATEWAY_CLIENT_NAME`), not a television's, so +its temporary device record is never mistaken for a set in the house. After authentication, `/updates/latest.apk` redirects to the current immutable, versioned APK in the persistent `memby-releases` volume. Direct APK requests require either that @@ -619,6 +621,7 @@ can review and revoke signed-in TVs from the app's Settings screen. | `MEMBY_SERVER_MEMORY_LIMIT` | `512m` | Compose hard memory ceiling for the server container | | `MEMBY_TIMEZONE` | `Pacific/Auckland` | Local day and time labels for schedule rows | | `MEMBY_CLIENT_NAME` | `MbyATV` | Client name sent to Emby; must match the app's direct path | +| `MEMBY_GATEWAY_CLIENT_NAME` | `MbyGateway` | Client name Emby records for the gateway's own requests, including an admin or installer sign-in | | `MEMBY_HOME_TTL` | `60s` | Also `MEMBY_ITEM_TTL`, `MEMBY_SEARCH_TTL`, `MEMBY_SCREENSAVER_TTL` | | `MEMBY_RECOMMEND_TTL` | `24h` | How long computed recommendation rows stay warm | | `MEMBY_RECOMMEND_TIMEOUT` | `60s` | Bounds a background rebuild | diff --git a/server/cmd/memby-server/main.go b/server/cmd/memby-server/main.go index 942b528..43ef8c0 100644 --- a/server/cmd/memby-server/main.go +++ b/server/cmd/memby-server/main.go @@ -108,7 +108,10 @@ func run(log *slog.Logger, events *logging.Buffer) error { log.Warn("could not clear interrupted sync runs", "error", err) } - embyClient := emby.New(cfg.EmbyURL, cfg.EmbyPublicURL, cfg.ClientName, cfg.UpstreamTimeout) + embyClient := emby.New( + cfg.EmbyURL, cfg.EmbyPublicURL, cfg.ClientName, cfg.GatewayClientName, + cfg.UpstreamTimeout, + ) mdblistClient := mdblist.New(mdblist.DefaultBaseURL, cfg.UpstreamTimeout) var sonarrClient *sonarr.Client if cfg.SonarrURL != "" { @@ -153,6 +156,7 @@ func run(log *slog.Logger, events *logging.Buffer) error { UserID: cfg.SyncUserID, Token: cfg.SyncAPIKey, DeviceID: "memby-gateway-sync", + Gateway: true, }, log.With("component", "library")) var forYouService *foryou.Service if tracearrClient != nil { @@ -163,7 +167,8 @@ func run(log *slog.Logger, events *logging.Buffer) error { forYouService.ConfigureTimeContext(cfg.SonarrLocation) forYouService.ConfigureHouseholdUsers(embyClient, emby.Credentials{ UserID: cfg.SyncUserID, Token: cfg.SyncAPIKey, - DeviceID: "memby-for-you-builder", DeviceName: "Memby For You builder", + DeviceID: "memby-for-you-builder", DeviceName: "MbyGateway For You", + Gateway: true, }) } diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 4da2b0f..4a0381f 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -35,7 +35,6 @@ import ( "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/store" - "github.com/ponzischeme89/memby/server/internal/trailer" ) type Server struct { @@ -86,7 +85,6 @@ type Server struct { // embyHealth is the reachability probe's live finding, which /v1/status publishes so // a TV can show why playback stopped even if it missed the announcement. embyHealth embyHealth - trailers *trailer.Resolver } // Deps are the collaborators the API needs. A struct rather than positional arguments: @@ -107,10 +105,6 @@ type Deps struct { } func New(cfg config.Config, deps Deps) *Server { - trailerTimeout := cfg.UpstreamTimeout - if trailerTimeout <= 0 || trailerTimeout > 8*time.Second { - trailerTimeout = 8 * time.Second - } return &Server{ cfg: cfg, emby: deps.Emby, @@ -125,7 +119,6 @@ func New(cfg config.Config, deps Deps) *Server { syncer: deps.Syncer, log: deps.Log, events: deps.Events, - trailers: trailer.New(&http.Client{Timeout: trailerTimeout}), } } @@ -201,6 +194,7 @@ func (s *Server) Routes() http.Handler { v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer)) v1.Handle("GET /v1/items/{id}/trailers", s.authed(s.handleTrailers)) v1.Handle("POST /v1/items/{id}/trailers/resolve", s.authed(s.handleResolveTrailer)) + v1.Handle("POST /v1/items/{id}/trailers/report", s.authed(s.handleTrailerReport)) v1.Handle("GET /v1/items/{id}/intro", s.authed(s.handleIntro)) v1.Handle("GET /v1/items/{id}/trickplay", s.authed(s.handleTrickplay)) v1.Handle("GET /v1/items/{id}/trickplay/{frame}", s.authed(s.handleTrickplayFrame)) diff --git a/server/internal/api/auth.go b/server/internal/api/auth.go index 6686233..f9bdd25 100644 --- a/server/internal/api/auth.go +++ b/server/internal/api/auth.go @@ -69,7 +69,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { } auth, err := s.emby.Authenticate( - r.Context(), req.Username, req.Password, req.DeviceID, req.DeviceName, clientVersion(r), + r.Context(), req.Username, req.Password, + emby.Credentials{ + DeviceID: req.DeviceID, DeviceName: req.DeviceName, + ClientVersion: clientVersion(r), + }, ) if err != nil { // Never echo Emby's body here: a failed sign-in is the one place a wrong @@ -250,7 +254,8 @@ func (s *Server) retireEmbyDevice(ctx context.Context, deviceID string) { } if err := s.emby.DeleteDevice(ctx, emby.Credentials{ UserID: s.cfg.SyncUserID, Token: s.cfg.SyncAPIKey, - DeviceID: "memby-gateway", DeviceName: "Memby Gateway", + DeviceID: "memby-gateway", DeviceName: s.gatewayDeviceName(), + Gateway: true, }, deviceID); err != nil { s.loggerFor(ctx).Warn("emby device cleanup failed", "removed_device_id", deviceID, "error", err) diff --git a/server/internal/api/installer_auth.go b/server/internal/api/installer_auth.go index 10c418c..f635837 100644 --- a/server/internal/api/installer_auth.go +++ b/server/internal/api/installer_auth.go @@ -19,13 +19,30 @@ const ( installerSessionTTL = 30 * time.Minute adminSessionTTL = 12 * time.Hour installerDeviceID = "memby-web-installer" - installerDeviceName = "Memby Web Installer" // adminRenewWithin is how close to expiry a session must be before an operator's own // request re-issues it. Half the TTL avoids rewriting the cookie on every request. adminRenewWithin = adminSessionTTL / 2 ) +// gatewayDeviceName is what Emby records for a device row the gateway creates for itself. +// It follows the gateway's client name so one operator-set word covers both halves of how +// the server identifies itself, and it is deliberately never the product name — Emby's +// device list is read by whoever runs the server, and an entry called "Memby …" there +// reads as one of the household's televisions. +func (s *Server) gatewayDeviceName() string { + if name := strings.TrimSpace(s.cfg.GatewayClientName); name != "" { + return name + } + return emby.DefaultGatewayClientName +} + +// installerDeviceName separates the temporary record an admin or installer sign-in +// creates from the gateway's own, so a password check is recognisable while it exists. +func (s *Server) installerDeviceName() string { + return s.gatewayDeviceName() + " Installer" +} + func (s *Server) installerSecret() []byte { if s.cfg.ReleasePublishToken == "" { return nil @@ -192,8 +209,13 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) { return } + // Gateway, not a television: this sign-in is the admin console or the web installer + // checking a password, so Emby records it under the gateway's own client name. auth, err := s.emby.Authenticate( - r.Context(), username, password, installerDeviceID, installerDeviceName, "", + r.Context(), username, password, + emby.Credentials{ + DeviceID: installerDeviceID, DeviceName: s.installerDeviceName(), Gateway: true, + }, ) if err != nil { s.loggerFor(r.Context()).Warn("installer Emby authentication failed", "username", username) @@ -204,13 +226,15 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) { // it succeeded, so retire the upstream session immediately and never persist it. if err := s.emby.Logout(r.Context(), emby.Credentials{ UserID: auth.User.ID, Token: auth.AccessToken, - DeviceID: installerDeviceID, DeviceName: installerDeviceName, + DeviceID: installerDeviceID, DeviceName: s.installerDeviceName(), + Gateway: true, }); err != nil { s.loggerFor(r.Context()).Warn("installer Emby session cleanup failed", "error", err) } if err := s.emby.DeleteDevice(r.Context(), emby.Credentials{ UserID: s.cfg.SyncUserID, Token: s.cfg.SyncAPIKey, - DeviceID: "memby-gateway", DeviceName: "Memby Gateway", + DeviceID: "memby-gateway", DeviceName: s.gatewayDeviceName(), + Gateway: true, }, installerDeviceID); err != nil { s.loggerFor(r.Context()).Error("installer Emby device cleanup failed", "error", err) s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.", diff --git a/server/internal/api/logcontext.go b/server/internal/api/logcontext.go index 88838a7..946e294 100644 --- a/server/internal/api/logcontext.go +++ b/server/internal/api/logcontext.go @@ -3,6 +3,7 @@ package api import ( "context" "log/slog" + "net" "net/http" "strings" @@ -173,7 +174,8 @@ func isPlaybackItemPath(path string) bool { // Fetching a subtitle is two segments deep rather than one, and matching its trailing // "search" on its own would claim any future per-item search as playback. A seek // preview is the same shape: the frame number is the last segment, not the word. - if strings.Contains(path, "/subtitles/") || strings.Contains(path, "/trickplay") { + if strings.Contains(path, "/subtitles/") || strings.Contains(path, "/trickplay") || + strings.Contains(path, "/trailers") { return true } switch path[strings.LastIndex(path, "/")+1:] { @@ -182,3 +184,28 @@ func isPlaybackItemPath(path string) bool { } return false } + +// requestClientIP is the viewer-facing address recorded for trailer playback. The first +// Forwarded address is the original client when the gateway is behind its normal reverse +// proxy; direct deployments fall back to RemoteAddr. This value is for operational logs, +// never authentication or access control. +func requestClientIP(r *http.Request) string { + for _, value := range strings.Split(r.Header.Get("X-Forwarded-For"), ",") { + if ip := net.ParseIP(strings.TrimSpace(value)); ip != nil { + return ip.String() + } + } + if ip := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); ip != nil { + return ip.String() + } + host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + if err == nil { + if ip := net.ParseIP(host); ip != nil { + return ip.String() + } + } + if ip := net.ParseIP(strings.TrimSpace(r.RemoteAddr)); ip != nil { + return ip.String() + } + return "unknown" +} diff --git a/server/internal/api/logging_test.go b/server/internal/api/logging_test.go index a9740f5..a1e632f 100644 --- a/server/internal/api/logging_test.go +++ b/server/internal/api/logging_test.go @@ -48,6 +48,8 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) { "/v1/items/42/related": "details", "/v1/items/42/playback": "playback", "/v1/items/42/next": "playback", + "/v1/items/42/trailers/resolve": "playback", + "/v1/items/42/trailers/report": "playback", "/v1/items/42/subtitles/search": "playback", "/v1/items/42/trickplay": "playback", "/v1/items/42/trickplay/12.jpg": "playback", @@ -69,6 +71,15 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) { } } +func TestRequestClientIPPrefersOriginalForwardedAddress(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/v1/items/42/trailers/report", nil) + request.RemoteAddr = "10.0.0.2:41234" + request.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.2") + if got := requestClientIP(request); got != "203.0.113.9" { + t.Fatalf("client ip = %q", got) + } +} + func TestIdentifyNamesTheViewerAndTelevision(t *testing.T) { request := httptest.NewRequest(http.MethodGet, "/v1/home", nil) request.Header.Set("X-Memby-Version", "0.1.60") diff --git a/server/internal/api/playback_report_test.go b/server/internal/api/playback_report_test.go index e548d64..2761c83 100644 --- a/server/internal/api/playback_report_test.go +++ b/server/internal/api/playback_report_test.go @@ -20,7 +20,7 @@ func TestStoppedPlaybackReportRemainsRetryableWhenEmbyIsDown(t *testing.T) { defer upstream.Close() s := &Server{ - emby: emby.New(upstream.URL, upstream.URL, "Memby test", time.Second), + emby: emby.New(upstream.URL, upstream.URL, "MbyATV", "MbyGateway", time.Second), log: slog.New(slog.NewTextHandler(io.Discard, nil)), } req := httptest.NewRequest( diff --git a/server/internal/api/preferences.go b/server/internal/api/preferences.go index ab7c9b9..fc3774d 100644 --- a/server/internal/api/preferences.go +++ b/server/internal/api/preferences.go @@ -140,6 +140,11 @@ var preferenceCatalogue = []preferenceDefinition{ Description: "Roll into the next episode when one finishes.", Kind: preferenceToggle, Default: true, }, + { + Key: "playNextEpisodePreview", Name: "Next-episode recap or preview", Area: "Playback", + Description: "With auto-play on, play a matched YouTube recap or preview two minutes before the episode ends.", + Kind: preferenceToggle, Default: false, + }, { Key: "showTenMinuteReminder", Name: "Ten-minute reminder", Area: "Playback", Description: "Show the lower-third when ten minutes are left.", diff --git a/server/internal/api/release_test.go b/server/internal/api/release_test.go index be62e11..1395b45 100644 --- a/server/internal/api/release_test.go +++ b/server/internal/api/release_test.go @@ -282,7 +282,7 @@ func TestInstallerLoginUsesEmbyWithoutCreatingTVSession(t *testing.T) { ReleasePublishToken: "test-release-secret", SyncUserID: "service-user", SyncAPIKey: "service-token", }, - emby: emby.New(upstream.URL, upstream.URL, "Memby test", 2*time.Second), + emby: emby.New(upstream.URL, upstream.URL, "MbyATV", "MbyGateway", 2*time.Second), log: slog.New(slog.NewTextHandler(io.Discard, nil)), } form := url.Values{ diff --git a/server/internal/api/trailers.go b/server/internal/api/trailers.go index 8f38e40..94b03f4 100644 --- a/server/internal/api/trailers.go +++ b/server/internal/api/trailers.go @@ -14,7 +14,6 @@ import ( "github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/store" - "github.com/ponzischeme89/memby/server/internal/trailer" ) type remoteTrailer struct { @@ -55,6 +54,7 @@ type trailerPlaybackResponse struct { CandidateID string `json:"candidateId"` Provider string `json:"provider"` URL string `json:"url"` + SourceURL string `json:"sourceUrl,omitempty"` Title string `json:"title"` ItemID string `json:"itemId,omitempty"` MediaSourceID string `json:"mediaSourceId,omitempty"` @@ -62,6 +62,13 @@ type trailerPlaybackResponse struct { PlayMethod string `json:"playMethod,omitempty"` } +type trailerReportRequest struct { + CandidateID string `json:"candidateId"` + Provider string `json:"provider"` + Phase string `json:"phase"` + Reason string `json:"reason,omitempty"` +} + func (s *Server) handleTrailers(w http.ResponseWriter, r *http.Request, sess store.Session) { itemID := strings.TrimSpace(r.PathValue("id")) if itemID == "" { @@ -107,37 +114,24 @@ func (s *Server) handleResolveTrailer(w http.ResponseWriter, r *http.Request, se s.writeUpstreamError(r.Context(), w, err, "could not inspect trailers") return } - resolver := s.trailers - if resolver == nil { - resolver = trailer.New(nil) - } for _, candidate := range s.preferredTrailerCandidates(r.Context(), sess, manifest) { if excluded[candidate.ID] { - if candidate.SourceURL != "" { - resolver.Invalidate(trailer.Source{Provider: candidate.Provider, URL: candidate.SourceURL}) - } continue } if len(candidate.LocalItem) > 0 { if resolved, resolveErr := s.resolveLocalTrailer(r.Context(), sess, manifest, candidate); resolveErr == nil { - s.rememberTrailerCandidate(r.Context(), sess, itemID, candidate.ID) writeJSON(w, http.StatusOK, resolved) return } continue } - resolved, resolveErr := resolver.Resolve(r.Context(), trailer.Source{ - Provider: candidate.Provider, - URL: candidate.SourceURL, - }) - if resolveErr != nil { - continue - } - s.rememberTrailerCandidate(r.Context(), sess, itemID, candidate.ID) + // Remote pages are resolved on the television. YouTube signs direct media URLs for + // the resolving IP, so resolving here can make the URL unusable from the viewer's + // network and makes provider traffic appear to come from the gateway. writeJSON(w, http.StatusOK, trailerPlaybackResponse{ CandidateID: candidate.ID, Provider: candidate.Provider, - URL: resolved.URL, + SourceURL: candidate.SourceURL, Title: trailerTitle(manifest.Title, candidate.Name), PlayMethod: "DirectPlay", }) @@ -146,6 +140,37 @@ func (s *Server) handleResolveTrailer(w http.ResponseWriter, r *http.Request, se writeError(w, http.StatusNotFound, "no playable trailer is available") } +func (s *Server) handleTrailerReport(w http.ResponseWriter, r *http.Request, sess store.Session) { + itemID := strings.TrimSpace(r.PathValue("id")) + var report trailerReportRequest + if itemID == "" || json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&report) != nil { + writeError(w, http.StatusBadRequest, "invalid trailer report") + return + } + report.CandidateID = strings.TrimSpace(report.CandidateID) + report.Provider = strings.ToLower(strings.TrimSpace(report.Provider)) + report.Phase = strings.ToLower(strings.TrimSpace(report.Phase)) + if report.CandidateID == "" || report.Provider == "" || + (report.Phase != "started" && report.Phase != "failed" && report.Phase != "completed") { + writeError(w, http.StatusBadRequest, "invalid trailer report") + return + } + fields := []any{ + "item_id", itemID, + "provider", report.Provider, + "candidate", report.CandidateID, + "source_ip", requestClientIP(r), + } + if reason := strings.TrimSpace(report.Reason); reason != "" { + fields = append(fields, "reason", reason) + } + s.loggerFor(r.Context()).Info("trailer playback "+report.Phase, fields...) + if report.Phase == "started" { + s.rememberTrailerCandidate(r.Context(), sess, itemID, report.CandidateID) + } + w.WriteHeader(http.StatusNoContent) +} + func (s *Server) preferredTrailerCandidates( ctx context.Context, sess store.Session, diff --git a/server/internal/api/trailers_test.go b/server/internal/api/trailers_test.go index a76c227..25278ef 100644 --- a/server/internal/api/trailers_test.go +++ b/server/internal/api/trailers_test.go @@ -14,17 +14,11 @@ import ( "github.com/ponzischeme89/memby/server/internal/config" "github.com/ponzischeme89/memby/server/internal/emby" + serverlogging "github.com/ponzischeme89/memby/server/internal/logging" "github.com/ponzischeme89/memby/server/internal/store" - "github.com/ponzischeme89/memby/server/internal/trailer" ) -type trailerRoundTripFunc func(*http.Request) (*http.Response, error) - -func (fn trailerRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { - return fn(request) -} - -func TestResolveTrailerFallsThroughProviders(t *testing.T) { +func TestResolveTrailerSkipsRejectedProviderOnTheClientBehalf(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "/LocalTrailers"): @@ -43,35 +37,14 @@ func TestResolveTrailerFallsThroughProviders(t *testing.T) { })) defer upstream.Close() - resolverClient := &http.Client{Transport: trailerRoundTripFunc(func(request *http.Request) (*http.Response, error) { - status := http.StatusOK - body := "" - headers := http.Header{} - switch request.URL.Host { - case "trailers.apple.com": - status = http.StatusNotFound - case "www.youtube.com": - body = `{"playabilityStatus":{"status":"OK"},"streamingData":{"formats":[` + - `{"url":"https://media.example/trailer.mp4","mimeType":"video/mp4; codecs=avc1,mp4a","height":720}]}}` - headers.Set("Content-Type", "application/json") - case "media.example": - status = http.StatusPartialContent - headers.Set("Content-Type", "video/mp4") - default: - t.Fatalf("unexpected trailer request: %s", request.URL) - } - return &http.Response{ - StatusCode: status, Header: headers, - Body: io.NopCloser(strings.NewReader(body)), Request: request, - }, nil - })} server := &Server{ - cfg: config.Config{ItemTTL: time.Minute}, - emby: emby.New(upstream.URL, upstream.URL, "MbyATV", time.Second), - trailers: trailer.New(resolverClient), - log: slog.New(slog.NewTextHandler(io.Discard, nil)), + cfg: config.Config{ItemTTL: time.Minute}, + emby: emby.New(upstream.URL, upstream.URL, "MbyATV", "MbyGateway", time.Second), + log: slog.New(slog.NewTextHandler(io.Discard, nil)), } - request := httptest.NewRequest(http.MethodPost, "/v1/items/film-1/trailers/resolve", bytes.NewBufferString(`{}`)) + appleID := trailerCandidateID("apple", "https://trailers.apple.com/missing.mov") + request := httptest.NewRequest(http.MethodPost, "/v1/items/film-1/trailers/resolve", + bytes.NewBufferString(`{"excludedCandidateIds":["`+appleID+`"]}`)) request.SetPathValue("id", "film-1") recorder := httptest.NewRecorder() server.handleResolveTrailer(recorder, request, store.Session{EmbyUserID: "user", EmbyToken: "token"}) @@ -82,11 +55,32 @@ func TestResolveTrailerFallsThroughProviders(t *testing.T) { if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { t.Fatal(err) } - if response.Provider != "youtube" || response.URL != "https://media.example/trailer.mp4" { + if response.Provider != "youtube" || response.SourceURL != "https://youtu.be/dQw4w9WgXcQ" || response.URL != "" { t.Fatalf("unexpected response: %+v", response) } } +func TestTrailerStartedLogUsesOriginalClientIP(t *testing.T) { + logger, events := serverlogging.NewBuffered(io.Discard, slog.LevelInfo, 10, serverlogging.FormatConsole) + server := &Server{log: logger} + request := httptest.NewRequest(http.MethodPost, "/v1/items/film-1/trailers/report", + bytes.NewBufferString(`{"candidateId":"youtube-1","provider":"youtube","phase":"started"}`)) + request.SetPathValue("id", "film-1") + request.Header.Set("X-Forwarded-For", "203.0.113.42, 10.0.0.2") + request, _ = withRequestIdentity(request) + identify(request.Context(), store.Session{Username: "viewer", DeviceName: "Lounge TV"}) + recorder := httptest.NewRecorder() + server.handleTrailerReport(recorder, request, store.Session{}) + if recorder.Code != http.StatusNoContent { + t.Fatalf("status = %d", recorder.Code) + } + page := events.Events(0, 10) + if len(page.Events) != 1 || page.Events[0].Attributes["source_ip"] != "203.0.113.42" || + page.Events[0].Message != "trailer playback started" { + t.Fatalf("unexpected event: %+v", page.Events) + } +} + func TestRemoteTrailerPriorityPrefersOfficialAppleThenYouTube(t *testing.T) { candidates := []trailerCandidate{ {ID: "youtube-other", Provider: "youtube", Priority: remoteTrailerPriority("youtube", "Trailer")}, @@ -99,3 +93,13 @@ func TestRemoteTrailerPriorityPrefersOfficialAppleThenYouTube(t *testing.T) { t.Fatalf("unexpected order: %+v", candidates) } } + +func TestNextEpisodePreviewPreferenceIsOptional(t *testing.T) { + definition, ok := preferenceDefinitionFor("playNextEpisodePreview") + if !ok { + t.Fatal("playNextEpisodePreview is missing from the preference catalogue") + } + if definition.Kind != preferenceToggle || definition.Default != false { + t.Fatalf("definition = %+v, want an opt-in toggle", definition) + } +} diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index 50140e3..9dd1793 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.33 +0.1.34 diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 46524b3..c6a8018 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -9,6 +9,8 @@ import ( "strings" "time" _ "time/tzdata" + + "github.com/ponzischeme89/memby/server/internal/emby" ) type Config struct { @@ -30,6 +32,13 @@ type Config struct { // no business being the thing that identifies a client to a third party. ClientName string + // GatewayClientName is reported instead for a request the gateway makes on its own + // behalf — the library sync, the health probe, device cleanup, and an operator + // signing into the admin console or the web installer. Those are the server asking, + // and reporting them as a television made Emby's device list claim a set that does + // not exist in the house. + GatewayClientName string + HomeTTL time.Duration ItemTTL time.Duration SearchTTL time.Duration @@ -152,6 +161,7 @@ func Load() (Config, error) { DatabaseURL: os.Getenv("MEMBY_DATABASE_URL"), RedisURL: env("MEMBY_REDIS_URL", "redis://localhost:6379/0"), ClientName: env("MEMBY_CLIENT_NAME", "MbyATV"), + GatewayClientName: env("MEMBY_GATEWAY_CLIENT_NAME", emby.DefaultGatewayClientName), HomeTTL: duration("MEMBY_HOME_TTL", 60*time.Second), ItemTTL: duration("MEMBY_ITEM_TTL", 10*time.Minute), SearchTTL: duration("MEMBY_SEARCH_TTL", 5*time.Minute), diff --git a/server/internal/emby/client.go b/server/internal/emby/client.go index fbab5ba..a6e24f7 100644 --- a/server/internal/emby/client.go +++ b/server/internal/emby/client.go @@ -25,6 +25,12 @@ type Client struct { baseURL string publicURL string clientName string + // gatewayClientName identifies the requests the gateway makes on its own behalf, so + // Emby's device list separates a television from the server standing behind it. The + // admin and installer sign-ins are the ones an operator sees: those are the gateway + // asking, not a set in a living room, and reporting them as a television made the + // list claim a device that does not exist. + gatewayClientName string // gatewayVersion labels the requests the gateway makes on its own behalf — the // library sync, the health probe, device cleanup — which belong to no television and // so have no app version to report. @@ -42,6 +48,12 @@ type Credentials struct { // as it reported in X-Memby-Version. Emby shows it beside the device, so a blank one // makes every set in the house look like the same build. ClientVersion string + // Gateway marks a request the gateway makes for itself — the library sync, the health + // probe, device cleanup, an operator signing into the admin console — rather than on + // behalf of a television. It is stated rather than inferred from a missing token or + // version: an old app reports neither, and misreading one as the server would put a + // television in Emby's list under the wrong name. + Gateway bool } type Device struct { @@ -133,12 +145,24 @@ func (e *APIError) Error() string { return fmt.Sprintf("emby: status %d: %s", e.StatusCode, e.Body) } -func New(baseURL, publicURL, clientName string, timeout time.Duration) *Client { +// DefaultGatewayClientName is what Emby records for a request the gateway makes for +// itself. It is deliberately not the product name: this travels to whatever Emby does +// with its own logs, and it must never read as one of the household's televisions. +const DefaultGatewayClientName = "MbyGateway" + +// New builds a client. clientName identifies a television on the wire to Emby and +// gatewayClientName identifies the gateway itself; a blank gateway name falls back to +// DefaultGatewayClientName rather than borrowing the television's. +func New(baseURL, publicURL, clientName, gatewayClientName string, timeout time.Duration) *Client { + if strings.TrimSpace(gatewayClientName) == "" { + gatewayClientName = DefaultGatewayClientName + } return &Client{ - baseURL: strings.TrimRight(baseURL, "/"), - publicURL: strings.TrimRight(publicURL, "/"), - clientName: clientName, - gatewayVersion: buildinfo.Version(), + baseURL: strings.TrimRight(baseURL, "/"), + publicURL: strings.TrimRight(publicURL, "/"), + clientName: clientName, + gatewayClientName: gatewayClientName, + gatewayVersion: buildinfo.Version(), http: &http.Client{ Timeout: timeout, 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 -// reported; it is what Emby stamps on the device record it creates here, so an empty one -// leaves the entry claiming the gateway's own build. -func (c *Client) Authenticate(ctx context.Context, username, password, deviceID, deviceName, clientVersion string) (*AuthResult, error) { +// Authenticate signs a television in. cred carries the device the record is created for +// and that set's ClientVersion — Emby stamps it on the record, so an empty one leaves the +// entry claiming the gateway's own build. cred.Gateway marks a sign-in the gateway is +// making for itself (the admin console and the web installer), which Emby then records +// under the gateway's own client name rather than as a television. +func (c *Client) Authenticate(ctx context.Context, username, password string, cred Credentials) (*AuthResult, error) { body, err := json.Marshal(map[string]string{"Username": username, "Pw": password}) if err != nil { return nil, err } req, err := c.newRequest(ctx, http.MethodPost, "/Users/AuthenticateByName", nil, - Credentials{DeviceID: deviceID, DeviceName: deviceName, ClientVersion: clientVersion}, + Credentials{ + DeviceID: cred.DeviceID, DeviceName: cred.DeviceName, + ClientVersion: cred.ClientVersion, Gateway: cred.Gateway, + }, bytes.NewReader(body)) if err != nil { return nil, err @@ -610,7 +639,8 @@ func (c *Client) SubtitleURL(cred Credentials, itemID, mediaSourceID string, ind // Ping checks that Emby is reachable, for readiness probes. func (c *Client) Ping(ctx context.Context) error { - req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil, Credentials{}, nil) + req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil, + Credentials{Gateway: true}, nil) if err != nil { return err } @@ -657,7 +687,13 @@ func (c *Client) newRequest(ctx context.Context, method, path string, params url } deviceName := strings.TrimSpace(cred.DeviceName) if deviceName == "" { + // A gateway request is not a television, so it must not fall back to the + // unnamed-set placeholder: that put the server in Emby's device list wearing a + // name that reads as somebody's TV. deviceName = "Memby TV" + if cred.Gateway { + deviceName = c.gatewayClientName + } } // The version Emby records is the television's app version, not a constant: every // device in the dashboard read as one build before this, so there was no way to tell @@ -666,10 +702,14 @@ func (c *Client) newRequest(ctx context.Context, method, path string, params url if version == "" { version = c.gatewayVersion } + clientName := c.clientName + if cred.Gateway { + clientName = c.gatewayClientName + } req.Header.Set("Accept", "application/json") req.Header.Set("X-Emby-Authorization", fmt.Sprintf( `MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="%s"`, - c.clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID, + clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID, strings.ReplaceAll(version, `"`, ""), )) if cred.Token != "" { diff --git a/server/internal/emby/client_auth_header_test.go b/server/internal/emby/client_auth_header_test.go index 8eb6de3..3c51689 100644 --- a/server/internal/emby/client_auth_header_test.go +++ b/server/internal/emby/client_auth_header_test.go @@ -13,7 +13,7 @@ import ( // name is never the product name, and the version is the set's own build rather than a // constant that made every device look alike. func TestAuthHeaderCarriesClientVersion(t *testing.T) { - client := New("http://emby:8096", "https://emby.example", "MbyATV", time.Second) + client := New("http://emby:8096", "https://emby.example", "MbyATV", "MbyGateway", time.Second) req, err := client.newRequest( context.Background(), http.MethodGet, "/Items", nil, @@ -30,7 +30,7 @@ func TestAuthHeaderCarriesClientVersion(t *testing.T) { } func TestAuthHeaderFallsBackToGatewayVersion(t *testing.T) { - client := New("http://emby:8096", "https://emby.example", "MbyATV", time.Second) + client := New("http://emby:8096", "https://emby.example", "MbyATV", "MbyGateway", time.Second) req, err := client.newRequest( context.Background(), http.MethodGet, "/Items", nil, Credentials{}, nil, @@ -46,3 +46,52 @@ func TestAuthHeaderFallsBackToGatewayVersion(t *testing.T) { t.Fatalf("auth header %q does not carry the gateway build %q", got, client.gatewayVersion) } } + +// A request the gateway makes for itself — the sync, the health probe, an operator +// signing into the admin console — is not a television, and Emby's device list said it +// was. It reports the gateway's own name and never falls back to the unnamed-set +// placeholder, which is what made the server read as somebody's TV. +func TestAuthHeaderNamesTheGatewayForItsOwnRequests(t *testing.T) { + client := New("http://emby:8096", "https://emby.example", "MbyATV", "MbyGateway", time.Second) + + req, err := client.newRequest( + context.Background(), http.MethodGet, "/Items", nil, Credentials{Gateway: true}, nil, + ) + if err != nil { + t.Fatal(err) + } + got := req.Header.Get("X-Emby-Authorization") + if !strings.Contains(got, `Client="MbyGateway"`) { + t.Fatalf("gateway request did not report the gateway client name: %q", got) + } + if strings.Contains(got, "Memby") { + t.Fatalf("gateway request carried the product name to Emby: %q", got) + } +} + +// A television's request must keep reporting the television's client name, whatever the +// gateway calls itself — the two identities are separate rows in Emby's device list. +func TestAuthHeaderKeepsTelevisionClientName(t *testing.T) { + client := New("http://emby:8096", "https://emby.example", "MbyATV", "MbyGateway", time.Second) + + req, err := client.newRequest( + context.Background(), http.MethodGet, "/Items", nil, + Credentials{DeviceID: "tv-1", DeviceName: "Living room", ClientVersion: "0.2.57"}, nil, + ) + if err != nil { + t.Fatal(err) + } + if got := req.Header.Get("X-Emby-Authorization"); !strings.Contains(got, `Client="MbyATV"`) { + t.Fatalf("television request did not report the app client name: %q", got) + } +} + +// A blank gateway name must not silently become the television's, which would put the +// server back in the device list as a set. +func TestGatewayClientNameFallsBackToDefault(t *testing.T) { + client := New("http://emby:8096", "https://emby.example", "MbyATV", " ", time.Second) + if client.gatewayClientName != DefaultGatewayClientName { + t.Fatalf("gateway client name = %q, want %q", + client.gatewayClientName, DefaultGatewayClientName) + } +} diff --git a/server/internal/emby/client_playback_test.go b/server/internal/emby/client_playback_test.go index 0ae6a52..97d8488 100644 --- a/server/internal/emby/client_playback_test.go +++ b/server/internal/emby/client_playback_test.go @@ -11,6 +11,15 @@ func TestDirectPlayVideoCodecsIncludeHEVCOnlyForCapableClient(t *testing.T) { } } +// Emby shows the device profile's name in its playback device list, so it carries the +// client identity — never the product name, which is what it said before. +func TestAndroidTVProfileReportsTheClientIdentityNotTheProductName(t *testing.T) { + profile := androidTVDeviceProfile(PlaybackCapabilities{}) + if got := profile["Name"]; got != "MbyATV" { + t.Fatalf("device profile name = %v, want MbyATV", got) + } +} + func TestAndroidTVProfileConstrainsCodecLevelAndResolution(t *testing.T) { profile := androidTVDeviceProfile(PlaybackCapabilities{ H264Profiles: []string{"baseline", "main", "high"}, diff --git a/server/internal/emby/client_resume_test.go b/server/internal/emby/client_resume_test.go index b18719b..948666e 100644 --- a/server/internal/emby/client_resume_test.go +++ b/server/internal/emby/client_resume_test.go @@ -27,7 +27,7 @@ func TestHideFromResumeUsesDedicatedEmbyEndpoint(t *testing.T) { })) defer upstream.Close() - client := New(upstream.URL, upstream.URL, "MbyATV", time.Second) + client := New(upstream.URL, upstream.URL, "MbyATV", "MbyGateway", time.Second) got, err := client.HideFromResume( context.Background(), Credentials{UserID: "user-1", Token: "token"}, diff --git a/server/internal/emby/client_subtitles_test.go b/server/internal/emby/client_subtitles_test.go index 0a95152..84d9a9f 100644 --- a/server/internal/emby/client_subtitles_test.go +++ b/server/internal/emby/client_subtitles_test.go @@ -7,7 +7,7 @@ import ( ) func TestSubtitleURLUsesCanonicalVTTEndpoint(t *testing.T) { - client := New("http://emby:8096", "https://emby.example", "Memby", time.Second) + client := New("http://emby:8096", "https://emby.example", "MbyATV", "MbyGateway", time.Second) got := client.SubtitleURL( Credentials{Token: "a b"}, "item id", diff --git a/server/internal/emby/device_profile.go b/server/internal/emby/device_profile.go index dc8e591..7395418 100644 --- a/server/internal/emby/device_profile.go +++ b/server/internal/emby/device_profile.go @@ -11,6 +11,14 @@ package emby import "strconv" +// deviceProfileName is what Emby records against a playback session, and it appears in +// the dashboard's device and playback lists. It is the client identity on the wire, not +// the product name — it read as "Memby Android TV" there, which is exactly the name that +// has no business travelling to somebody else's logs — and it must match the literal the +// television sends on the direct path (DeviceProfile.embyAndroidTv), or one set playing +// both ways appears twice. +const deviceProfileName = "MbyATV" + var alwaysDecodableAudioCodecs = []string{ "aac", "mp3", "flac", "opus", "vorbis", "pcm_s16le", "pcm_s24le", } @@ -44,7 +52,7 @@ func androidTVDeviceProfile(capabilities PlaybackCapabilities) map[string]any { audioCodecs := directPlayAudioCodecs(capabilities) transcodeAudio := transcodeAudioCodecs(capabilities) return map[string]any{ - "Name": "Memby Android TV", "SupportedMediaTypes": "Video", + "Name": deviceProfileName, "SupportedMediaTypes": "Video", "DirectPlayProfiles": []map[string]string{ { "Container": "mkv,mp4,m4v,mov,ts,mpegts", "VideoCodec": videoCodecs, diff --git a/server/internal/foryou/service.go b/server/internal/foryou/service.go index 243be42..ea89b1c 100644 --- a/server/internal/foryou/service.go +++ b/server/internal/foryou/service.go @@ -501,8 +501,8 @@ func (s *Service) recommendationUsers(ctx context.Context) ([]store.Session, err EmbyUserID: user.ID, EmbyToken: s.serviceCred.Token, Username: user.Name, - DeviceID: "memby-for-you-builder", - DeviceName: "Memby For You builder", + DeviceID: builderDeviceID, + DeviceName: builderDeviceName, } } out := make([]store.Session, 0, len(byID)) @@ -964,9 +964,18 @@ func parsedTime(value string) *time.Time { return &parsed } +// builderDeviceID and builderDeviceName stand in for a television when the gateway +// rebuilds a viewer's rows on its own. It is the server asking, so Emby records it under +// the gateway's client name rather than as a set in the house — see Credentials.Gateway. +const ( + builderDeviceID = "memby-for-you-builder" + builderDeviceName = "MbyGateway For You" +) + func credentials(sess store.Session) emby.Credentials { return emby.Credentials{ UserID: sess.EmbyUserID, Token: sess.EmbyToken, DeviceID: sess.DeviceID, DeviceName: sess.DeviceName, + Gateway: sess.DeviceID == builderDeviceID, } } diff --git a/server/internal/library/syncer.go b/server/internal/library/syncer.go index da7dca4..215cb17 100644 --- a/server/internal/library/syncer.go +++ b/server/internal/library/syncer.go @@ -267,6 +267,9 @@ func (s *Syncer) credentials(ctx context.Context) (emby.Credentials, error) { UserID: sess.EmbyUserID, Token: sess.EmbyToken, DeviceID: "memby-gateway-sync", + // The token is borrowed from a television, but the import is the gateway's own + // work and must not appear in Emby's device list as that set. + Gateway: true, }, nil } diff --git a/server/internal/trailer/resolver.go b/server/internal/trailer/resolver.go deleted file mode 100644 index 4ac538b..0000000 --- a/server/internal/trailer/resolver.go +++ /dev/null @@ -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 -} diff --git a/server/internal/trailer/resolver_test.go b/server/internal/trailer/resolver_test.go deleted file mode 100644 index c2f4371..0000000 --- a/server/internal/trailer/resolver_test.go +++ /dev/null @@ -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 = `720` + - `1080` - 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) - } -}