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