0.2.56 - Reliable trailer playback

This commit is contained in:
ponzischeme89
2026-08-12 11:05:07 +12:00
parent 9777eb0952
commit f2d052dbf6
22 changed files with 1438 additions and 55 deletions
+10
View File
@@ -1,3 +1,13 @@
## 0.2.56 — 2026-08-12
- Added: Films, series and supported seasons can now play trailers with one press.
- Improved: Memby prefers official Apple and YouTube trailers, plays them natively and automatically tries another source when one is unavailable, blocked or fails during playback.
- Fixed: Trailer playback no longer needs a second Play press, opens a broken web player or leaves the viewer on a generic playback error.
- Fixed: Back now returns cleanly to the detail page after a trailer, and Memby returns there automatically when no playable source remains.
- Improved: Successful trailer sources are remembered for faster repeat playback without unnecessary provider requests.
- Added: Selected labels, feature switches and safe presentation settings can now be managed by the Memby server, while complete bundled defaults keep the app working normally when the server is slow or unavailable.
- Improved: Search now has a dedicated Request action for finding the exact film or series to add, with clearer in-library and already-requested states.
- Fixed: Film and series requests now use the intended monitored request workflow and provide clearer results when a title cannot be requested.
## 0.2.53 — 2026-08-11 ## 0.2.53 — 2026-08-11
- Fixed: Removed the home-screen message asking viewers to open the For You page. - Fixed: Removed the home-screen message asking viewers to open the For You page.
- Improved: The play button used by the mini hero cards now appears over every focused poster. - Improved: The play button used by the mini hero cards now appears over every focused poster.
+21
View File
@@ -745,6 +745,27 @@ network answered. Things to preserve:
- **An episode is keyed on its series**, not on itself — that is what its own page will ask - **An episode is keyed on its series**, not on itself — that is what its own page will ask
for, and it is what makes one warm serve a whole row of Continue Watching. for, and it is what makes one warm serve a whole row of Continue Watching.
**Trailers are a provider chain, not ordinary title playback.** `GET /v1/items/{id}/trailers`
is the cheap availability answer used while a detail page is warmed; the Play press opens
`PlayerActivity` immediately and `POST /v1/items/{id}/trailers/resolve` selects one native
stream from `internal/trailer`. Official Apple and labelled official YouTube sources come
first, followed by local Emby media and then the remaining recognised remote trailers. The server validates direct
media before returning it and caches only successful mappings. The player keeps the subject
and rejected candidate ids: an error or an eight-second stall before the first frame asks for
the next provider behind the same loading surface. Exhausting the chain closes the player and
returns to the still-composed detail page; it never shows the ordinary playback error pane.
Keep these boundaries:
- Metadata discovery may be warmed and cached, but local `PlaybackInfo` and remote stream
resolution begin only after the viewer presses Trailer.
- Provider details stay behind the resolver interface. Compose knows only whether a trailer
exists, and the player knows only how to ask for the next candidate.
- 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.
- Direct-to-Emby mode retains local trailers. Remote Apple and YouTube resolution belongs to
the gateway, which is the only surface that can validate and cache those mappings without
putting provider scraping or expiring URLs on every television.
**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
default preferences and then recomposes the entire page — and restarts the effects keyed on default preferences and then recomposes the entire page — and restarts the effects keyed on
+1 -1
View File
@@ -42,7 +42,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the // A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default. // source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.54" val defaultVersionName = "0.2.56"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -277,6 +277,9 @@ class EmbyRepository(private val settings: SettingsStore) {
private val trailerCache = private val trailerCache =
LinkedHashMap<String, CachedTrailer>(TRAILER_CACHE_SIZE, 0.75f, true) LinkedHashMap<String, CachedTrailer>(TRAILER_CACHE_SIZE, 0.75f, true)
private val trailerInFlight = mutableMapOf<String, Deferred<CachedTrailer?>>() private val trailerInFlight = mutableMapOf<String, Deferred<CachedTrailer?>>()
private val trailerAvailabilityCache =
LinkedHashMap<String, Boolean>(TRAILER_CACHE_SIZE, 0.75f, true)
private val trailerAvailabilityInFlight = mutableMapOf<String, Deferred<Boolean?>>()
fun cachedHome(): HomeCache? = settings.homeCache(snapshot) fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
@@ -1409,6 +1412,88 @@ class EmbyRepository(private val settings: SettingsStore) {
return inFlight.await()?.value return inFlight.await()?.value
} }
/**
* Whether the detail page should offer Trailer. In gateway mode this includes every
* registered provider but does not resolve a stream or create a playback session.
*/
suspend fun hasTrailer(itemId: String): Boolean {
if (itemId.isBlank()) return false
val request = trailerMutex.withLock {
trailerAvailabilityCache[itemId]?.let { return it }
trailerAvailabilityInFlight[itemId] ?: scope.async(start = CoroutineStart.LAZY) {
try {
val loaded = if (ServerConfig.isGateway) {
runCatching { requireGateway().trailers(itemId).available }
.recoverCatching { error ->
if (error is HttpException && error.code() == 404) {
getLocalTrailer(itemId) != null
} else {
throw error
}
}
.getOrNull()
} else {
getLocalTrailer(itemId) != null
}
if (loaded != null) trailerMutex.withLock {
trailerAvailabilityCache[itemId] = loaded
while (trailerAvailabilityCache.size > TRAILER_CACHE_SIZE) {
trailerAvailabilityCache.entries.iterator().run { next(); remove() }
}
}
loaded
} finally {
trailerMutex.withLock { trailerAvailabilityInFlight.remove(itemId) }
}
}.also {
trailerAvailabilityInFlight[itemId] = it
it.start()
}
}
return request.await() ?: false
}
/** Resolves one provider candidate after the player is already open. */
suspend fun resolveTrailer(
request: com.ponzischeme89.memby.data.model.TrailerPlaybackRequest,
): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback {
if (ServerConfig.isGateway) {
return runCatching {
requireGateway().resolveTrailer(
request.subjectId,
com.ponzischeme89.memby.data.model.GatewayTrailerResolveRequest(
request.excludedCandidateIds,
),
)
}.recoverCatching { error ->
if (error is HttpException && error.code() == 404 && request.excludedCandidateIds.isEmpty()) {
resolveLegacyLocalTrailer(request)
} else {
throw error
}
}.getOrThrow()
}
return resolveLegacyLocalTrailer(request)
}
private suspend fun resolveLegacyLocalTrailer(
request: com.ponzischeme89.memby.data.model.TrailerPlaybackRequest,
): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback {
val local = getLocalTrailer(request.subjectId)
?: throw NoSuchElementException("No local trailer is available")
val playable = resolvePlayableForLaunch(local)
return com.ponzischeme89.memby.data.model.GatewayTrailerPlayback(
candidateId = "local-${local.id}",
provider = "local",
url = playable.url,
title = request.title + " trailer",
itemId = playable.itemId,
mediaSourceId = playable.mediaSourceId,
playSessionId = playable.playSessionId,
playMethod = playable.playMethod,
)
}
private fun newTrailerRequest(itemId: String): Deferred<CachedTrailer?> { private fun newTrailerRequest(itemId: String): Deferred<CachedTrailer?> {
val request = scope.async(start = CoroutineStart.LAZY) { val request = scope.async(start = CoroutineStart.LAZY) {
try { try {
@@ -1893,6 +1978,9 @@ class EmbyRepository(private val settings: SettingsStore) {
// but the request in flight carries the outgoing session, and the next profile // but the request in flight carries the outgoing session, and the next profile
// may be signed into a different server entirely. // may be signed into a different server entirely.
trailerCache.clear() trailerCache.clear()
trailerAvailabilityCache.clear()
trailerAvailabilityInFlight.values.forEach { it.cancel() }
trailerAvailabilityInFlight.clear()
trailerInFlight.values.forEach { it.cancel() } trailerInFlight.values.forEach { it.cancel() }
trailerInFlight.clear() trailerInFlight.clear()
} }
@@ -0,0 +1,16 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
/** Records a rejected provider candidate without changing the trailer's screen identity. */
internal fun afterTrailerCandidate(
request: TrailerPlaybackRequest,
candidateId: String,
): TrailerPlaybackRequest = request.copy(
excludedCandidateIds = (request.excludedCandidateIds + candidateId.trim())
.filter(String::isNotBlank)
.distinct(),
)
/** Trailer failures always advance the provider chain instead of opening the generic error pane. */
internal fun shouldFallbackTrailer(isTrailer: Boolean): Boolean = isTrailer
@@ -40,6 +40,40 @@ data class GatewayDevices(
val devices: List<GatewayDevice> = emptyList(), val devices: List<GatewayDevice> = emptyList(),
) )
/** Cheap detail-page answer; actual stream resolution begins only after Trailer is pressed. */
@Serializable
data class GatewayTrailerAvailability(
val available: Boolean = false,
val providers: List<String> = emptyList(),
)
@Serializable
data class GatewayTrailerResolveRequest(
val excludedCandidateIds: List<String> = emptyList(),
)
/** One native stream selected by the gateway's ordered trailer-provider chain. */
@Serializable
data class GatewayTrailerPlayback(
val candidateId: String = "",
val provider: String = "",
val url: String = "",
val title: String = "",
val itemId: String = "",
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
)
/** Persistable player request so provider fallback survives Activity recreation. */
@Serializable
data class TrailerPlaybackRequest(
val subjectId: String,
val title: String,
val posterUrl: String? = null,
val excludedCandidateIds: List<String> = emptyList(),
)
@Serializable @Serializable
data class GatewayDeviceNameRequest(val deviceName: String) data class GatewayDeviceNameRequest(val deviceName: String)
@@ -289,6 +289,19 @@ interface GatewayApi {
@GET("v1/items/{id}/trailer") @GET("v1/items/{id}/trailer")
suspend fun trailer(@Path("id") itemId: String): BaseItem suspend fun trailer(@Path("id") itemId: String): BaseItem
/** Whether local, Apple or YouTube trailer metadata exists for this item. */
@GET("v1/items/{id}/trailers")
suspend fun trailers(
@Path("id") itemId: String,
): com.ponzischeme89.memby.data.model.GatewayTrailerAvailability
/** Resolve the best candidate not already rejected by this player session. */
@POST("v1/items/{id}/trailers/resolve")
suspend fun resolveTrailer(
@Path("id") itemId: String,
@Body body: com.ponzischeme89.memby.data.model.GatewayTrailerResolveRequest,
): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback
@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
@@ -19,6 +19,8 @@ import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.ponzischeme89.memby.ui.player.PlayerActivity import com.ponzischeme89.memby.ui.player.PlayerActivity
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
import com.ponzischeme89.memby.ui.screensaver.ScreensaverActions import com.ponzischeme89.memby.ui.screensaver.ScreensaverActions
import com.ponzischeme89.memby.ui.screensaver.ScreensaverContent import com.ponzischeme89.memby.ui.screensaver.ScreensaverContent
import com.ponzischeme89.memby.ui.theme.MembyTheme import com.ponzischeme89.memby.ui.theme.MembyTheme
@@ -60,7 +62,7 @@ class MembyDreamService : DreamService() {
setContent { setContent {
MembyTheme { MembyTheme {
ScreensaverContent( ScreensaverContent(
onPlay = { url, title -> launchPlayback(url, title) }, onPlay = ::launchTrailer,
onExit = { finish() }, onExit = { finish() },
actions = actions, actions = actions,
) )
@@ -113,8 +115,11 @@ class MembyDreamService : DreamService() {
* the dream" race some TV builds exhibit. Uses the application context because * the dream" race some TV builds exhibit. Uses the application context because
* this service is being torn down. * this service is being torn down.
*/ */
private fun launchPlayback(url: String, title: String) { private fun launchTrailer(item: BaseItem) {
val intent = PlayerActivity.intent(applicationContext, url, title) val intent = PlayerActivity.trailerIntent(
applicationContext,
TrailerPlaybackRequest(subjectId = item.id, title = item.name),
)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
finish() finish()
mainHandler.postDelayed({ mainHandler.postDelayed({
@@ -448,7 +448,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
if (!seriesId.isNullOrBlank()) { if (!seriesId.isNullOrBlank()) {
launch { runCatching { repository.getSeriesEpisodes(seriesId) } } launch { runCatching { repository.getSeriesEpisodes(seriesId) } }
} }
launch { runCatching { repository.getLocalTrailer(item.id) } } launch { runCatching { repository.hasTrailer(item.id) } }
} }
} }
@@ -129,6 +129,7 @@ import com.ponzischeme89.memby.data.model.MyShow
import com.ponzischeme89.memby.data.model.NotificationsResponse import com.ponzischeme89.memby.data.model.NotificationsResponse
import com.ponzischeme89.memby.data.model.RecommendationOnboarding import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import com.ponzischeme89.memby.data.model.UserNotification import com.ponzischeme89.memby.data.model.UserNotification
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
import com.ponzischeme89.memby.data.model.RecommendationPerson import com.ponzischeme89.memby.data.model.RecommendationPerson
import com.ponzischeme89.memby.data.ServerConfig import com.ponzischeme89.memby.data.ServerConfig
import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig
@@ -2042,7 +2043,10 @@ private fun HomeScreen(
var rowListFocusRestoreRequest by remember { mutableStateOf(0) } var rowListFocusRestoreRequest by remember { mutableStateOf(0) }
val playbackLauncher = rememberLauncherForActivityResult( val playbackLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(), contract = ActivityResultContracts.StartActivityForResult(),
) { ) { result ->
if (PlayerActivity.trailerUnavailable(result.resultCode, result.data)) {
Toast.makeText(context, "No playable trailer is available", Toast.LENGTH_SHORT).show()
}
homeViewModel.trackJourney( homeViewModel.trackJourney(
category = "playback", action = "stop", screen = "player", category = "playback", action = "stop", screen = "player",
feature = "playback", target = selectedDestination.name.lowercase(), feature = "playback", target = selectedDestination.name.lowercase(),
@@ -2071,6 +2075,9 @@ private fun HomeScreen(
myShowsLoading = false myShowsLoading = false
notificationsLoading = false notificationsLoading = false
kotlinx.coroutines.delay(32L) kotlinx.coroutines.delay(32L)
// Trailer playback leaves its detail page composed. Let Compose restore the
// exact hero action instead of moving focus to the home card behind it.
if (detailsItem != null) return@launch
if (returnRowId != null && returnItemId != null) { if (returnRowId != null && returnItemId != null) {
requestFirstAvailableFocus( requestFirstAvailableFocus(
cardReturnFocusRequester, cardReturnFocusRequester,
@@ -2240,6 +2247,32 @@ private fun HomeScreen(
} }
} }
} }
val playTrailer: (BaseItem) -> Unit = playTrailer@{ item ->
if (launchingItem != null) return@playTrailer
launchingItem = item
homeViewModel.trackJourney(
category = "playback", action = "trailer", screen = "details",
feature = "trailer", source = "details", target = "player",
itemName = item.name, itemType = item.type,
)
homeViewModel.flushAnalytics()
val launched = runCatching {
playbackLauncher.launch(
PlayerActivity.trailerIntent(
context,
TrailerPlaybackRequest(
subjectId = item.id,
title = item.name,
posterUrl = repo.primaryUrl(item, maxWidth = 500),
),
),
)
}
if (launched.isFailure) {
launchingItem = null
Toast.makeText(context, "Couldnt open the trailer", Toast.LENGTH_SHORT).show()
}
}
val rows = remember( val rows = remember(
homeContent, homeContent,
@@ -3258,6 +3291,7 @@ private fun HomeScreen(
detailsAiringNotice = null detailsAiringNotice = null
playItem(it) playItem(it)
}, },
onPlayTrailer = playTrailer,
onToggleFavorite = { item, saved -> onToggleFavorite = { item, saved ->
homeViewModel.trackJourney( homeViewModel.trackJourney(
category = "library", action = if (saved) "favourite" else "unfavourite", category = "library", action = if (saved) "favourite" else "unfavourite",
@@ -3933,6 +3967,7 @@ private fun FocusedDetailsOverlay(
selected: BaseItem, selected: BaseItem,
restorePosition: Boolean, restorePosition: Boolean,
onPlay: (BaseItem) -> Unit, onPlay: (BaseItem) -> Unit,
onPlayTrailer: (BaseItem) -> Unit,
onToggleFavorite: (BaseItem, Boolean) -> Unit, onToggleFavorite: (BaseItem, Boolean) -> Unit,
isMyShow: Boolean, isMyShow: Boolean,
onToggleMyShow: (BaseItem, Boolean) -> Unit, onToggleMyShow: (BaseItem, Boolean) -> Unit,
@@ -3947,6 +3982,7 @@ private fun FocusedDetailsOverlay(
SeriesDetailsOverlay( SeriesDetailsOverlay(
item = item, item = item,
onPlay = onPlay, onPlay = onPlay,
onPlayTrailer = onPlayTrailer,
onToggleFavorite = onToggleFavorite, onToggleFavorite = onToggleFavorite,
isMyShow = isMyShow, isMyShow = isMyShow,
onToggleMyShow = onToggleMyShow, onToggleMyShow = onToggleMyShow,
@@ -3972,6 +4008,7 @@ private fun FocusedDetailsOverlay(
MediaDetailsOverlay( MediaDetailsOverlay(
item = item, item = item,
onPlay = onPlay, onPlay = onPlay,
onPlayTrailer = onPlayTrailer,
onToggleFavorite = onToggleFavorite, onToggleFavorite = onToggleFavorite,
onTogglePlayed = onTogglePlayed, onTogglePlayed = onTogglePlayed,
onClose = onClose, onClose = onClose,
@@ -46,6 +46,7 @@ import kotlinx.coroutines.delay
fun MediaDetailsOverlay( fun MediaDetailsOverlay(
item: BaseItem, item: BaseItem,
onPlay: (BaseItem) -> Unit, onPlay: (BaseItem) -> Unit,
onPlayTrailer: (BaseItem) -> Unit = onPlay,
onToggleFavorite: (BaseItem, Boolean) -> Unit, onToggleFavorite: (BaseItem, Boolean) -> Unit,
onTogglePlayed: (BaseItem, Boolean) -> Unit, onTogglePlayed: (BaseItem, Boolean) -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
@@ -62,7 +63,7 @@ fun MediaDetailsOverlay(
related = ServiceLocator.repository.getRelated(item) related = ServiceLocator.repository.getRelated(item)
} }
LaunchedEffect(item.id) { LaunchedEffect(item.id) {
trailer = ServiceLocator.repository.getLocalTrailer(item.id) trailer = item.takeIf { ServiceLocator.repository.hasTrailer(item.id) }
} }
LaunchedEffect(item.id, settings.showRatingsStrip) { LaunchedEffect(item.id, settings.showRatingsStrip) {
ratings = if (settings.showRatingsStrip) ServiceLocator.repository.getRatings(item) else emptyList() ratings = if (settings.showRatingsStrip) ServiceLocator.repository.getRatings(item) else emptyList()
@@ -70,6 +71,7 @@ fun MediaDetailsOverlay(
MediaDetailContent( MediaDetailContent(
item = item, item = item,
onPlay = onPlay, onPlay = onPlay,
onPlayTrailer = onPlayTrailer,
onToggleFavorite = onToggleFavorite, onToggleFavorite = onToggleFavorite,
onTogglePlayed = onTogglePlayed, onTogglePlayed = onTogglePlayed,
onOpenItem = onOpenItem, onOpenItem = onOpenItem,
@@ -91,6 +93,7 @@ fun MediaDetailsOverlay(
internal fun MediaDetailContent( internal fun MediaDetailContent(
item: BaseItem, item: BaseItem,
onPlay: (BaseItem) -> Unit, onPlay: (BaseItem) -> Unit,
onPlayTrailer: (BaseItem) -> Unit = onPlay,
onToggleFavorite: (BaseItem, Boolean) -> Unit, onToggleFavorite: (BaseItem, Boolean) -> Unit,
onTogglePlayed: (BaseItem, Boolean) -> Unit, onTogglePlayed: (BaseItem, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -201,7 +204,7 @@ internal fun MediaDetailContent(
), ),
) )
trailer?.let { trailer?.let {
add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlay(it) })) add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlayTrailer(item) }))
} }
add( add(
DetailHeroAction( DetailHeroAction(
@@ -103,6 +103,7 @@ import java.util.TimeZone
fun SeriesDetailsOverlay( fun SeriesDetailsOverlay(
item: BaseItem, item: BaseItem,
onPlay: (BaseItem) -> Unit, onPlay: (BaseItem) -> Unit,
onPlayTrailer: (BaseItem) -> Unit = onPlay,
onToggleFavorite: (BaseItem, Boolean) -> Unit, onToggleFavorite: (BaseItem, Boolean) -> Unit,
isMyShow: Boolean, isMyShow: Boolean,
onToggleMyShow: (BaseItem, Boolean) -> Unit, onToggleMyShow: (BaseItem, Boolean) -> Unit,
@@ -145,7 +146,7 @@ fun SeriesDetailsOverlay(
related = repository.getRelated(item) related = repository.getRelated(item)
} }
LaunchedEffect(item.id) { LaunchedEffect(item.id) {
trailer = repository.getLocalTrailer(item.id) trailer = item.takeIf { repository.hasTrailer(item.id) }
} }
LaunchedEffect(item.id, settings.showRatingsStrip) { LaunchedEffect(item.id, settings.showRatingsStrip) {
ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList() ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList()
@@ -156,6 +157,7 @@ fun SeriesDetailsOverlay(
episodes = episodes, episodes = episodes,
loadFailed = loadFailed, loadFailed = loadFailed,
onPlay = onPlay, onPlay = onPlay,
onPlayTrailer = onPlayTrailer,
onToggleFavorite = onToggleFavorite, onToggleFavorite = onToggleFavorite,
isMyShow = isMyShow, isMyShow = isMyShow,
onToggleMyShow = onToggleMyShow, onToggleMyShow = onToggleMyShow,
@@ -178,6 +180,7 @@ internal fun SeriesDetailContent(
episodes: List<BaseItem>?, episodes: List<BaseItem>?,
loadFailed: Boolean, loadFailed: Boolean,
onPlay: (BaseItem) -> Unit, onPlay: (BaseItem) -> Unit,
onPlayTrailer: (BaseItem) -> Unit = onPlay,
onToggleFavorite: (BaseItem, Boolean) -> Unit, onToggleFavorite: (BaseItem, Boolean) -> Unit,
isMyShow: Boolean, isMyShow: Boolean,
onToggleMyShow: (BaseItem, Boolean) -> Unit, onToggleMyShow: (BaseItem, Boolean) -> Unit,
@@ -353,7 +356,7 @@ internal fun SeriesDetailContent(
)) ))
} }
trailer?.let { trailer?.let {
add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlay(it) })) add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlayTrailer(item) }))
} }
}, },
) { visibleTab -> ) { visibleTab ->
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.ui.player
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.AlertDialog import android.app.AlertDialog
import android.app.Activity
import android.animation.ObjectAnimator import android.animation.ObjectAnimator
import android.animation.ValueAnimator import android.animation.ValueAnimator
import android.content.Context import android.content.Context
@@ -77,12 +78,15 @@ import com.ponzischeme89.memby.data.normalizeSkipIntroMode
import com.ponzischeme89.memby.data.resolveCast import com.ponzischeme89.memby.data.resolveCast
import com.ponzischeme89.memby.data.selectSubtitleId 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.shouldFallbackTrailer
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
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
import com.ponzischeme89.memby.data.model.GatewaySeasonFinale import com.ponzischeme89.memby.data.model.GatewaySeasonFinale
import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
import com.ponzischeme89.memby.data.playback.AudioPassthroughMode import com.ponzischeme89.memby.data.playback.AudioPassthroughMode
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
import com.ponzischeme89.memby.data.playback.SurroundCodec import com.ponzischeme89.memby.data.playback.SurroundCodec
@@ -169,6 +173,8 @@ class PlayerActivity : ComponentActivity() {
* re-prepare nothing. * re-prepare nothing.
*/ */
private var pendingRequest: PlaybackRequest? = null private var pendingRequest: PlaybackRequest? = null
private var pendingTrailerRequest: TrailerPlaybackRequest? = null
private var trailerStartupTimeoutJob: Job? = null
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
@@ -417,8 +423,13 @@ class PlayerActivity : ComponentActivity() {
val url = savedInstanceState?.getString(STATE_URL)?.takeIf(String::isNotBlank) val url = savedInstanceState?.getString(STATE_URL)?.takeIf(String::isNotBlank)
?: intent.getStringExtra(EXTRA_URL)?.takeIf(String::isNotBlank) ?: intent.getStringExtra(EXTRA_URL)?.takeIf(String::isNotBlank)
val request = decodeRequest(intent.getStringExtra(EXTRA_PLAYBACK_REQUEST)) val request = decodeRequest(intent.getStringExtra(EXTRA_PLAYBACK_REQUEST))
pendingRequest = request.takeIf { url == null } val trailerRequest = decodeTrailerRequest(
if (url == null && request == null) { savedInstanceState?.getString(STATE_TRAILER_REQUEST)
?: intent.getStringExtra(EXTRA_TRAILER_REQUEST),
)
pendingTrailerRequest = trailerRequest
pendingRequest = request.takeIf { url == null && trailerRequest == null }
if (url == null && request == null && trailerRequest == null) {
finish() finish()
return return
} }
@@ -439,7 +450,7 @@ class PlayerActivity : ComponentActivity() {
val resumePositionMs = restoredPositionMs val resumePositionMs = restoredPositionMs
?: intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L) ?: intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L)
initialResumePositionMs = resumePositionMs.coerceAtLeast(0L) initialResumePositionMs = resumePositionMs.coerceAtLeast(0L)
val prerollEnabled = intent.getBooleanExtra(EXTRA_PREROLL_ENABLED, true) val prerollEnabled = intent.getBooleanExtra(EXTRA_PREROLL_ENABLED, true) && trailerRequest == null
configuredPrerollDurationMs = intent.getLongExtra( configuredPrerollDurationMs = intent.getLongExtra(
EXTRA_PREROLL_DURATION_MS, EXTRA_PREROLL_DURATION_MS,
DEFAULT_PREROLL_DURATION_MS, DEFAULT_PREROLL_DURATION_MS,
@@ -555,6 +566,10 @@ class PlayerActivity : ComponentActivity() {
prerollActive = false prerollActive = false
disposeLocalPreroll(reuse = false) disposeLocalPreroll(reuse = false)
prerollView?.visibility = View.GONE prerollView?.visibility = View.GONE
if (pendingTrailerRequest != null) {
finishTrailerUnavailable()
return
}
showPlaybackError( showPlaybackError(
PlaybackFailure( PlaybackFailure(
title = "Couldnt start the video player", title = "Couldnt start the video player",
@@ -639,6 +654,8 @@ class PlayerActivity : ComponentActivity() {
override fun onRenderedFirstFrame() { override fun onRenderedFirstFrame() {
renderedFirstFrame = true renderedFirstFrame = true
trailerStartupTimeoutJob?.cancel()
trailerStartupTimeoutJob = null
endSeekBuffering() endSeekBuffering()
hidePlaybackLoading() hidePlaybackLoading()
if (!playbackStarted && !prerollActive) { if (!playbackStarted && !prerollActive) {
@@ -670,6 +687,8 @@ class PlayerActivity : ComponentActivity() {
resumePositionMs, resumePositionMs,
playWhenReady = restoredPlayWhenReady ?: !showPreroll, playWhenReady = restoredPlayWhenReady ?: !showPreroll,
) )
} else if (trailerRequest != null) {
resolvePendingTrailer()
} else { } else {
resolvePendingStream( resolvePendingStream(
requireNotNull(request), requireNotNull(request),
@@ -726,6 +745,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 (shouldFallbackTrailer(pendingTrailerRequest != null)) {
fallbackToNextTrailer("prepare")
return
}
showPlaybackError( showPlaybackError(
PlaybackFailure( PlaybackFailure(
title = getString(R.string.playback_server_unreachable), title = getString(R.string.playback_server_unreachable),
@@ -740,6 +763,73 @@ class PlayerActivity : ComponentActivity() {
endFirstFrameTrace() endFirstFrameTrace()
firstFrameTraceCookie = PlaybackTraceSections.nextCookie() firstFrameTraceCookie = PlaybackTraceSections.nextCookie()
PlaybackTraceSections.begin(PlaybackTraceSections.FIRST_FRAME, firstFrameTraceCookie) PlaybackTraceSections.begin(PlaybackTraceSections.FIRST_FRAME, firstFrameTraceCookie)
scheduleTrailerStartupTimeout()
}
private fun resolvePendingTrailer() {
val request = pendingTrailerRequest ?: return
showPlaybackLoading(
title = if (request.excludedCandidateIds.isEmpty()) "Finding trailer…" else "Finding another trailer…",
hint = "Checking the best available source",
)
val generation = ++pendingResolveGeneration
pendingResolveJob?.cancel()
pendingResolveJob = lifecycleScope.launch {
runCatching { ServiceLocator.repository.resolveTrailer(request) }
.onSuccess { playable ->
if (generation != pendingResolveGeneration || isFinishing || isDestroyed) return@onSuccess
if (playable.url.isBlank() || playable.candidateId.isBlank()) {
finishTrailerUnavailable()
return@onSuccess
}
pendingTrailerRequest = afterTrailerCandidate(request, playable.candidateId)
itemId = playable.itemId.takeIf(String::isNotBlank)
mediaSourceId = playable.mediaSourceId
playSessionId = playable.playSessionId
playMethod = playable.playMethod
playbackTitle = playable.title.ifBlank { request.title + " trailer" }
bindTitleArtwork(playbackTitle, logoUrl)
setUpPlaybackIdentity(playbackTitle)
startMedia(playable.url, emptyList(), 0L, playWhenReady = true)
}
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
if (generation != pendingResolveGeneration || isFinishing || isDestroyed) return@onFailure
Log.w(PLAYBACK_LOG_TAG, "event=trailer_sources_exhausted subject=${request.subjectId}", error)
finishTrailerUnavailable()
}
}
}
private fun fallbackToNextTrailer(reason: String) {
if (!shouldFallbackTrailer(pendingTrailerRequest != null) || isFinishing || isDestroyed) return
trailerStartupTimeoutJob?.cancel()
trailerStartupTimeoutJob = null
Log.w(PLAYBACK_LOG_TAG, "event=trailer_fallback reason=$reason")
player?.apply {
stop()
clearMediaItems()
}
// Each candidate gets its own first-frame deadline. A previous provider may have
// drawn briefly before failing, which must not exempt the replacement from it.
renderedFirstFrame = false
hidePlaybackError()
resolvePendingTrailer()
}
private fun scheduleTrailerStartupTimeout() {
trailerStartupTimeoutJob?.cancel()
if (pendingTrailerRequest == null || renderedFirstFrame) return
trailerStartupTimeoutJob = lifecycleScope.launch {
delay(TRAILER_STARTUP_TIMEOUT_MS)
if (!renderedFirstFrame) fallbackToNextTrailer("startup_timeout")
}
}
private fun finishTrailerUnavailable() {
trailerStartupTimeoutJob?.cancel()
setResult(RESULT_TRAILER_UNAVAILABLE, Intent().putExtra(EXTRA_TRAILER_UNAVAILABLE, true))
finish()
} }
private fun beginLaunchTrace() { private fun beginLaunchTrace() {
@@ -1322,6 +1412,10 @@ class PlayerActivity : ComponentActivity() {
} }
private fun handlePlaybackError(error: PlaybackException) { private fun handlePlaybackError(error: PlaybackException) {
if (shouldFallbackTrailer(pendingTrailerRequest != null)) {
fallbackToNextTrailer("player_${error.errorCodeName}")
return
}
if (prerollActive || fullscreenPlayerParent != null) { if (prerollActive || fullscreenPlayerParent != null) {
prerollActive = false prerollActive = false
findViewById<View>(R.id.player_preroll_video_host).animate().cancel() findViewById<View>(R.id.player_preroll_video_host).animate().cancel()
@@ -4060,6 +4154,9 @@ class PlayerActivity : ComponentActivity() {
outState.putString(STATE_POSTER_URL, pausePosterUrl) outState.putString(STATE_POSTER_URL, 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 {
outState.putString(STATE_TRAILER_REQUEST, playerJson.encodeToString(it))
}
} }
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
} }
@@ -4360,6 +4457,8 @@ class PlayerActivity : ComponentActivity() {
private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_id" private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_id"
private const val EXTRA_PLAY_METHOD = "extra_play_method" private const val EXTRA_PLAY_METHOD = "extra_play_method"
private const val EXTRA_PLAYBACK_REQUEST = "extra_playback_request" private const val EXTRA_PLAYBACK_REQUEST = "extra_playback_request"
private const val EXTRA_TRAILER_REQUEST = "extra_trailer_request"
private const val EXTRA_TRAILER_UNAVAILABLE = "extra_trailer_unavailable"
private const val STATE_POSITION_MS = "state_position_ms" private const val STATE_POSITION_MS = "state_position_ms"
private const val STATE_PLAY_WHEN_READY = "state_play_when_ready" private const val STATE_PLAY_WHEN_READY = "state_play_when_ready"
private const val STATE_URL = "state_url" private const val STATE_URL = "state_url"
@@ -4380,11 +4479,14 @@ class PlayerActivity : ComponentActivity() {
private const val STATE_POSTER_URL = "state_poster_url" private const val STATE_POSTER_URL = "state_poster_url"
private const val STATE_EPISODE_CODE = "state_episode_code" private const val STATE_EPISODE_CODE = "state_episode_code"
private const val STATE_RUNTIME_MS = "state_runtime_ms" private const val STATE_RUNTIME_MS = "state_runtime_ms"
private const val STATE_TRAILER_REQUEST = "state_trailer_request"
private const val PLAYER_PREFERENCES = "player_preferences" private const val PLAYER_PREFERENCES = "player_preferences"
private const val SUBTITLE_SIZE_KEY = "subtitle_size" private const val SUBTITLE_SIZE_KEY = "subtitle_size"
private const val PICTURE_MODE_KEY = "picture_mode" private const val PICTURE_MODE_KEY = "picture_mode"
private const val SUBTITLE_BOTTOM_PADDING_FRACTION = 0.095f private const val SUBTITLE_BOTTOM_PADDING_FRACTION = 0.095f
private val playerJson = Json { ignoreUnknownKeys = true } private val playerJson = Json { ignoreUnknownKeys = true }
private const val TRAILER_STARTUP_TIMEOUT_MS = 8_000L
private const val RESULT_TRAILER_UNAVAILABLE = Activity.RESULT_FIRST_USER + 17
/** /**
* Starts playback without a stream, letting the player resolve one while it starts. * Starts playback without a stream, letting the player resolve one while it starts.
@@ -4414,6 +4516,20 @@ class PlayerActivity : ComponentActivity() {
putExtra(EXTRA_REQUEST_STARTED_AT_MS, requestStartedAtMs) putExtra(EXTRA_REQUEST_STARTED_AT_MS, requestStartedAtMs)
} }
fun trailerIntent(context: Context, request: TrailerPlaybackRequest): Intent =
Intent(context, PlayerActivity::class.java).apply {
putExtra(EXTRA_TRAILER_REQUEST, playerJson.encodeToString(request))
putExtra(EXTRA_ITEM_ID, request.subjectId)
putExtra(EXTRA_TITLE, request.title + " trailer")
request.posterUrl?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_POSTER_URL, it) }
putExtra(EXTRA_PREROLL_ENABLED, false)
putExtra(EXTRA_REQUEST_STARTED_AT_MS, SystemClock.elapsedRealtime())
}
fun trailerUnavailable(resultCode: Int, data: Intent?): Boolean =
resultCode == RESULT_TRAILER_UNAVAILABLE &&
data?.getBooleanExtra(EXTRA_TRAILER_UNAVAILABLE, false) == true
fun intent( fun intent(
context: Context, context: Context,
url: String, url: String,
@@ -4479,6 +4595,11 @@ class PlayerActivity : ComponentActivity() {
encoded?.let { runCatching { playerJson.decodeFromString<PlaybackRequest>(it) }.getOrNull() } encoded?.let { runCatching { playerJson.decodeFromString<PlaybackRequest>(it) }.getOrNull() }
?.takeIf { it.itemId.isNotBlank() } ?.takeIf { it.itemId.isNotBlank() }
private fun decodeTrailerRequest(encoded: String?): TrailerPlaybackRequest? =
encoded?.let {
runCatching { playerJson.decodeFromString<TrailerPlaybackRequest>(it) }.getOrNull()
}?.takeIf { it.subjectId.isNotBlank() }
private fun mediaItem(url: String, subtitles: List<PlayableSubtitle>): MediaItem { private fun mediaItem(url: String, subtitles: List<PlayableSubtitle>): MediaItem {
val configurations = subtitles.filter { val configurations = subtitles.filter {
it.deliveryMethod.equals("External", true) && it.url.isNotBlank() && it.mimeType.isNotBlank() it.deliveryMethod.equals("External", true) && it.url.isNotBlank() && it.mimeType.isNotBlank()
@@ -6,6 +6,7 @@ import android.view.WindowManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import com.ponzischeme89.memby.ui.player.PlayerActivity import com.ponzischeme89.memby.ui.player.PlayerActivity
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
import com.ponzischeme89.memby.ui.theme.MembyTheme import com.ponzischeme89.memby.ui.theme.MembyTheme
/** In-app preview of the screensaver, launched from the home screen. */ /** In-app preview of the screensaver, launched from the home screen. */
@@ -25,8 +26,13 @@ class ScreensaverActivity : ComponentActivity() {
setContent { setContent {
MembyTheme { MembyTheme {
ScreensaverContent( ScreensaverContent(
onPlay = { url, title -> onPlay = { item ->
startActivity(PlayerActivity.intent(this, url, title)) startActivity(
PlayerActivity.trailerIntent(
this,
TrailerPlaybackRequest(subjectId = item.id, title = item.name),
),
)
}, },
onExit = { finish() }, onExit = { finish() },
startupMessage = intent.getStringExtra(EXTRA_STARTUP_MESSAGE), startupMessage = intent.getStringExtra(EXTRA_STARTUP_MESSAGE),
@@ -132,7 +132,7 @@ class ScreensaverActions {
*/ */
@Composable @Composable
fun ScreensaverContent( fun ScreensaverContent(
onPlay: (url: String, title: String) -> Unit, onPlay: (BaseItem) -> Unit,
onExit: () -> Unit, onExit: () -> Unit,
actions: ScreensaverActions? = null, actions: ScreensaverActions? = null,
startupMessage: String? = null, startupMessage: String? = null,
@@ -163,7 +163,7 @@ fun ScreensaverContent(
@Composable @Composable
private fun Slideshow( private fun Slideshow(
onPlay: (url: String, title: String) -> Unit, onPlay: (BaseItem) -> Unit,
onExit: () -> Unit, onExit: () -> Unit,
actions: ScreensaverActions?, actions: ScreensaverActions?,
showTitleLogo: Boolean, showTitleLogo: Boolean,
@@ -385,43 +385,13 @@ private fun Slideshow(
if (playbackLaunching) return if (playbackLaunching) return
val target = item ?: return val target = item ?: return
playbackLaunching = true playbackLaunching = true
toast = "Finding trailer…" if (runCatching { currentOnPlay(target) }.isFailure) {
scope.launch { toast = "Couldnt open the trailer."
runCatching { repo.getLocalTrailer(target.id) }
.onSuccess { trailer ->
if (trailer == null) {
toast = "No trailer is available for ${target.name}."
playbackLaunching = false
} else {
runCatching { repo.resolvePlayable(trailer) }
.onSuccess { playable ->
if (playable.url.isBlank()) {
toast = "The trailer is unavailable right now."
playbackLaunching = false
} else if (runCatching {
currentOnPlay(playable.url, "${target.name} trailer")
}.isFailure
) {
toast = "Couldnt start the trailer."
playbackLaunching = false
} else {
// Keep rapid Select/media-key repeats gated while the
// activity hand-off occurs, then re-arm on return.
delay(1_000L)
playbackLaunching = false playbackLaunching = false
return
} }
} // Keep rapid Select/media-key repeats gated while the Activity hand-off occurs.
.onFailure { scope.launch { delay(1_000L); playbackLaunching = false }
toast = friendlyEmbyError(it)
playbackLaunching = false
}
}
}
.onFailure {
toast = friendlyEmbyError(it)
playbackLaunching = false
}
}
} }
fun setFavorite(desired: Boolean) { fun setFavorite(desired: Boolean) {
@@ -13,6 +13,8 @@ import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayIntro import com.ponzischeme89.memby.data.model.GatewayIntro
import com.ponzischeme89.memby.data.model.GatewayTrickplay import com.ponzischeme89.memby.data.model.GatewayTrickplay
import com.ponzischeme89.memby.data.model.GatewayFeatures import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.GatewayTrailerAvailability
import com.ponzischeme89.memby.data.model.GatewayTrailerPlayback
import com.ponzischeme89.memby.data.model.RecommendationOnboarding import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -28,6 +30,21 @@ import org.junit.Test
* side, this fails before a TV ever sees it. * side, this fails before a TV ever sees it.
*/ */
class GatewayPayloadTest { class GatewayPayloadTest {
@Test
fun `decodes trailer availability and resolved provider candidate`() {
val availability = json.decodeFromString<GatewayTrailerAvailability>(
"""{"available":true,"providers":["local","apple","youtube"]}""",
)
val playback = json.decodeFromString<GatewayTrailerPlayback>(
"""{"candidateId":"youtube-a1","provider":"youtube","url":"https://media.example/trailer.mp4","title":"Arrival trailer","playMethod":"DirectPlay"}""",
)
assertTrue(availability.available)
assertEquals(listOf("local", "apple", "youtube"), availability.providers)
assertEquals("youtube-a1", playback.candidateId)
assertEquals("Arrival trailer", playback.title)
}
@Test @Test
fun `partially populated items decode without inventing an identity`() { fun `partially populated items decode without inventing an identity`() {
val item = json.decodeFromString<BaseItem>( val item = json.decodeFromString<BaseItem>(
@@ -0,0 +1,25 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class TrailerSupportTest {
@Test
fun rejectedCandidatesAreRecordedOnceInOrder() {
val request = TrailerPlaybackRequest("film", "A Film", excludedCandidateIds = listOf("apple"))
val next = afterTrailerCandidate(afterTrailerCandidate(request, "youtube"), "apple")
assertEquals(listOf("apple", "youtube"), next.excludedCandidateIds)
assertEquals("film", next.subjectId)
}
@Test
fun everyTrailerFailureUsesProviderFallback() {
assertTrue(shouldFallbackTrailer(isTrailer = true))
assertFalse(shouldFallbackTrailer(isTrailer = false))
}
}
+9
View File
@@ -35,6 +35,7 @@ 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 {
@@ -85,6 +86,7 @@ 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:
@@ -105,6 +107,10 @@ 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,
@@ -119,6 +125,7 @@ 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}),
} }
} }
@@ -192,6 +199,8 @@ func (s *Server) Routes() http.Handler {
// fetching a sidecar sends none of Memby's headers. // fetching a sidecar sends none of Memby's headers.
v1.Handle("GET /v1/subtitles/{file}", s.authed(s.handleStoredSubtitle)) v1.Handle("GET /v1/subtitles/{file}", s.authed(s.handleStoredSubtitle))
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("POST /v1/items/{id}/trailers/resolve", s.authed(s.handleResolveTrailer))
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))
+360
View File
@@ -0,0 +1,360 @@
package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"sort"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/trailer"
)
type remoteTrailer struct {
URL string `json:"Url"`
Name string `json:"Name"`
}
type trailerSubject struct {
Name string `json:"Name"`
RemoteTrailers []remoteTrailer `json:"RemoteTrailers"`
}
type trailerCandidate struct {
ID string `json:"id"`
Provider string `json:"provider"`
Name string `json:"name,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
LocalItem json.RawMessage `json:"localItem,omitempty"`
Priority int `json:"priority"`
}
type trailerManifest struct {
SubjectID string `json:"subjectId"`
Title string `json:"title"`
Candidates []trailerCandidate `json:"candidates"`
}
type trailerAvailability struct {
Available bool `json:"available"`
Providers []string `json:"providers"`
}
type resolveTrailerRequest struct {
ExcludedCandidateIDs []string `json:"excludedCandidateIds"`
}
type trailerPlaybackResponse struct {
CandidateID string `json:"candidateId"`
Provider string `json:"provider"`
URL string `json:"url"`
Title string `json:"title"`
ItemID string `json:"itemId,omitempty"`
MediaSourceID string `json:"mediaSourceId,omitempty"`
PlaySessionID string `json:"playSessionId,omitempty"`
PlayMethod string `json:"playMethod,omitempty"`
}
func (s *Server) handleTrailers(w http.ResponseWriter, r *http.Request, sess store.Session) {
itemID := strings.TrimSpace(r.PathValue("id"))
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
manifest, err := s.trailerManifest(r.Context(), sess, itemID)
if err != nil {
s.writeUpstreamError(r.Context(), w, err, "could not inspect trailers")
return
}
providers := make([]string, 0, len(manifest.Candidates))
seen := map[string]bool{}
for _, candidate := range manifest.Candidates {
if !seen[candidate.Provider] {
seen[candidate.Provider] = true
providers = append(providers, candidate.Provider)
}
}
writeJSON(w, http.StatusOK, trailerAvailability{
Available: len(manifest.Candidates) > 0,
Providers: providers,
})
}
func (s *Server) handleResolveTrailer(w http.ResponseWriter, r *http.Request, sess store.Session) {
itemID := strings.TrimSpace(r.PathValue("id"))
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
var request resolveTrailerRequest
if r.Body != nil && json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&request) != nil {
writeError(w, http.StatusBadRequest, "invalid trailer request")
return
}
excluded := make(map[string]bool, len(request.ExcludedCandidateIDs))
for _, id := range request.ExcludedCandidateIDs {
excluded[strings.TrimSpace(id)] = true
}
manifest, err := s.trailerManifest(r.Context(), sess, itemID)
if err != nil {
s.writeUpstreamError(r.Context(), w, err, "could not inspect trailers")
return
}
resolver := s.trailers
if resolver == nil {
resolver = trailer.New(nil)
}
for _, candidate := range s.preferredTrailerCandidates(r.Context(), sess, manifest) {
if excluded[candidate.ID] {
if candidate.SourceURL != "" {
resolver.Invalidate(trailer.Source{Provider: candidate.Provider, URL: candidate.SourceURL})
}
continue
}
if len(candidate.LocalItem) > 0 {
if resolved, resolveErr := s.resolveLocalTrailer(r.Context(), sess, manifest, candidate); resolveErr == nil {
s.rememberTrailerCandidate(r.Context(), sess, itemID, candidate.ID)
writeJSON(w, http.StatusOK, resolved)
return
}
continue
}
resolved, resolveErr := resolver.Resolve(r.Context(), trailer.Source{
Provider: candidate.Provider,
URL: candidate.SourceURL,
})
if resolveErr != nil {
continue
}
s.rememberTrailerCandidate(r.Context(), sess, itemID, candidate.ID)
writeJSON(w, http.StatusOK, trailerPlaybackResponse{
CandidateID: candidate.ID,
Provider: candidate.Provider,
URL: resolved.URL,
Title: trailerTitle(manifest.Title, candidate.Name),
PlayMethod: "DirectPlay",
})
return
}
writeError(w, http.StatusNotFound, "no playable trailer is available")
}
func (s *Server) preferredTrailerCandidates(
ctx context.Context,
sess store.Session,
manifest trailerManifest,
) []trailerCandidate {
out := append([]trailerCandidate(nil), manifest.Candidates...)
if s.cache == nil {
return out
}
preferred, err := s.cache.Get(ctx, cache.UserKey(sess.EmbyUserID, "trailer-success:v1:"+manifest.SubjectID))
if err != nil || len(preferred) == 0 {
return out
}
id := string(preferred)
sort.SliceStable(out, func(i, j int) bool {
return out[i].ID == id && out[j].ID != id
})
return out
}
func (s *Server) rememberTrailerCandidate(
ctx context.Context,
sess store.Session,
itemID string,
candidateID string,
) {
if s.cache != nil && candidateID != "" {
_ = s.cache.Set(
ctx,
cache.UserKey(sess.EmbyUserID, "trailer-success:v1:"+itemID),
[]byte(candidateID),
30*time.Minute,
)
}
}
func (s *Server) resolveLocalTrailer(
ctx context.Context,
sess store.Session,
manifest trailerManifest,
candidate trailerCandidate,
) (trailerPlaybackResponse, error) {
item, err := emby.Summarise(candidate.LocalItem)
if err != nil || item.ID == "" {
return trailerPlaybackResponse{}, errors.New("unreadable local trailer")
}
_, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
ctx,
credentials(sess),
item.ID,
0,
nil,
"",
false,
s.effectivePlaybackCapabilities(ctx, sess),
)
streamURL := s.emby.StreamURL(credentials(sess), item.ID)
if negotiatedURL != "" {
streamURL = negotiatedURL
}
if streamURL == "" {
return trailerPlaybackResponse{}, errors.New("empty local trailer stream")
}
return trailerPlaybackResponse{
CandidateID: candidate.ID,
Provider: candidate.Provider,
URL: streamURL,
Title: trailerTitle(manifest.Title, candidate.Name),
ItemID: item.ID,
MediaSourceID: mediaSourceID,
PlaySessionID: playSessionID,
PlayMethod: playMethod,
}, nil
}
func (s *Server) trailerManifest(ctx context.Context, sess store.Session, itemID string) (trailerManifest, error) {
key := cache.UserKey(sess.EmbyUserID, "trailers:v2:"+itemID)
if s.cache != nil {
if raw, err := s.cache.Get(ctx, key); err == nil {
var cached trailerManifest
if json.Unmarshal(raw, &cached) == nil {
return cached, nil
}
}
}
type localResult struct {
items *emby.ItemsResult
err error
}
type itemResult struct {
raw json.RawMessage
err error
}
localResultCh := make(chan localResult, 1)
itemResultCh := make(chan itemResult, 1)
go func() {
items, err := s.emby.LocalTrailers(ctx, credentials(sess), itemID)
localResultCh <- localResult{items: items, err: err}
}()
go func() {
raw, err := s.emby.Item(ctx, credentials(sess), itemID, "RemoteTrailers")
itemResultCh <- itemResult{raw: raw, err: err}
}()
localAnswer, itemAnswer := <-localResultCh, <-itemResultCh
locals, localErr := localAnswer.items, localAnswer.err
item, itemErr := itemAnswer.raw, itemAnswer.err
if localErr != nil && itemErr != nil {
return trailerManifest{}, errors.Join(localErr, itemErr)
}
partial := localErr != nil || itemErr != nil
manifest := trailerManifest{SubjectID: itemID, Candidates: []trailerCandidate{}}
if itemErr == nil {
var subject trailerSubject
if json.Unmarshal(item, &subject) == nil {
manifest.Title = strings.TrimSpace(subject.Name)
for _, remote := range subject.RemoteTrailers {
provider := trailerProvider(remote.URL)
if provider == "" {
continue
}
manifest.Candidates = append(manifest.Candidates, trailerCandidate{
ID: trailerCandidateID(provider, remote.URL),
Provider: provider,
Name: strings.TrimSpace(remote.Name),
SourceURL: strings.TrimSpace(remote.URL),
Priority: remoteTrailerPriority(provider, remote.Name),
})
}
}
}
if localErr == nil && locals != nil {
for _, raw := range locals.Items {
summary, summaryErr := emby.Summarise(raw)
if summaryErr != nil || summary.ID == "" {
continue
}
manifest.Candidates = append(manifest.Candidates, trailerCandidate{
ID: trailerCandidateID("local", summary.ID),
Provider: "local",
Name: summary.Name,
LocalItem: raw,
Priority: 20,
})
}
}
manifest.Candidates = uniqueTrailerCandidates(manifest.Candidates)
sort.SliceStable(manifest.Candidates, func(i, j int) bool {
return manifest.Candidates[i].Priority < manifest.Candidates[j].Priority
})
if partial && len(manifest.Candidates) == 0 {
return trailerManifest{}, errors.Join(localErr, itemErr)
}
if encoded, err := json.Marshal(manifest); err == nil && s.cache != nil && !partial {
_ = s.cache.Set(ctx, key, encoded, s.cfg.ItemTTL)
}
return manifest, nil
}
func trailerProvider(raw string) string {
value := strings.ToLower(raw)
switch {
case strings.Contains(value, "youtube.com/"), strings.Contains(value, "youtube-nocookie.com/"), strings.Contains(value, "youtu.be/"):
return "youtube"
case strings.Contains(value, "apple.com/"), strings.Contains(value, "apple.co/"):
return "apple"
default:
return ""
}
}
func remoteTrailerPriority(provider, name string) int {
official := strings.Contains(strings.ToLower(name), "official")
switch {
case provider == "apple":
// Apple-hosted trailer media is an official first-party source even when Emby
// supplies no useful label for it.
return 0
case provider == "youtube" && official:
return 10
default:
return 30
}
}
func trailerCandidateID(provider, source string) string {
digest := sha256.Sum256([]byte(provider + "\x00" + strings.TrimSpace(source)))
return provider + "-" + hex.EncodeToString(digest[:8])
}
func uniqueTrailerCandidates(candidates []trailerCandidate) []trailerCandidate {
seen := map[string]bool{}
out := make([]trailerCandidate, 0, len(candidates))
for _, candidate := range candidates {
if candidate.ID == "" || seen[candidate.ID] {
continue
}
seen[candidate.ID] = true
out = append(out, candidate)
}
return out
}
func trailerTitle(subject, candidate string) string {
if strings.TrimSpace(subject) != "" {
return strings.TrimSpace(subject) + " trailer"
}
if strings.TrimSpace(candidate) != "" {
return strings.TrimSpace(candidate)
}
return "Trailer"
}
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/trailer"
)
type trailerRoundTripFunc func(*http.Request) (*http.Response, error)
func (fn trailerRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}
func TestResolveTrailerFallsThroughProviders(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/LocalTrailers"):
writeJSON(w, http.StatusOK, []any{})
case strings.Contains(r.URL.Path, "/Items/film-1"):
writeJSON(w, http.StatusOK, map[string]any{
"Id": "film-1", "Name": "A Film",
"RemoteTrailers": []map[string]string{
{"Name": "Official trailer", "Url": "https://trailers.apple.com/missing.mov"},
{"Name": "Official trailer", "Url": "https://youtu.be/dQw4w9WgXcQ"},
},
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
resolverClient := &http.Client{Transport: trailerRoundTripFunc(func(request *http.Request) (*http.Response, error) {
status := http.StatusOK
body := ""
headers := http.Header{}
switch request.URL.Host {
case "trailers.apple.com":
status = http.StatusNotFound
case "www.youtube.com":
body = `{"playabilityStatus":{"status":"OK"},"streamingData":{"formats":[` +
`{"url":"https://media.example/trailer.mp4","mimeType":"video/mp4; codecs=avc1,mp4a","height":720}]}}`
headers.Set("Content-Type", "application/json")
case "media.example":
status = http.StatusPartialContent
headers.Set("Content-Type", "video/mp4")
default:
t.Fatalf("unexpected trailer request: %s", request.URL)
}
return &http.Response{
StatusCode: status, Header: headers,
Body: io.NopCloser(strings.NewReader(body)), Request: request,
}, nil
})}
server := &Server{
cfg: config.Config{ItemTTL: time.Minute},
emby: emby.New(upstream.URL, upstream.URL, "MbyATV", time.Second),
trailers: trailer.New(resolverClient),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
request := httptest.NewRequest(http.MethodPost, "/v1/items/film-1/trailers/resolve", bytes.NewBufferString(`{}`))
request.SetPathValue("id", "film-1")
recorder := httptest.NewRecorder()
server.handleResolveTrailer(recorder, request, store.Session{EmbyUserID: "user", EmbyToken: "token"})
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
var response trailerPlaybackResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Provider != "youtube" || response.URL != "https://media.example/trailer.mp4" {
t.Fatalf("unexpected response: %+v", response)
}
}
func TestRemoteTrailerPriorityPrefersOfficialAppleThenYouTube(t *testing.T) {
candidates := []trailerCandidate{
{ID: "youtube-other", Provider: "youtube", Priority: remoteTrailerPriority("youtube", "Trailer")},
{ID: "apple-official", Provider: "apple", Priority: remoteTrailerPriority("apple", "Trailer")},
{ID: "youtube-official", Provider: "youtube", Priority: remoteTrailerPriority("youtube", "Official trailer")},
{ID: "local", Provider: "local", Priority: 20},
}
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].Priority < candidates[j].Priority })
if candidates[0].ID != "apple-official" || candidates[1].ID != "youtube-official" || candidates[2].ID != "local" {
t.Fatalf("unexpected order: %+v", candidates)
}
}
+428
View File
@@ -0,0 +1,428 @@
// Package trailer resolves remote trailer pages to native media streams.
package trailer
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"sync"
"time"
)
const (
maxPageBytes = 2 << 20
cacheTTL = 30 * time.Minute
)
var ErrUnavailable = errors.New("trailer unavailable")
type Source struct {
Provider string
URL string
}
type Result struct {
URL string
MimeType string
}
type Provider interface {
Name() string
Supports(string) bool
Resolve(context.Context, string) (Result, error)
}
type cacheEntry struct {
result Result
expiresAt time.Time
}
// Resolver is an ordered provider chain with a short-lived successful mapping cache.
// The cached value avoids repeating YouTube page resolution on Back → Trailer while its
// signed media URL is still useful; failures are never cached.
type Resolver struct {
providers []Provider
mu sync.Mutex
cache map[string]cacheEntry
}
func New(client *http.Client) *Resolver {
if client == nil {
client = &http.Client{Timeout: 8 * time.Second}
}
return &Resolver{
providers: []Provider{newAppleProvider(client), newYouTubeProvider(client)},
cache: map[string]cacheEntry{},
}
}
func (r *Resolver) Resolve(ctx context.Context, source Source) (Result, error) {
key := strings.ToLower(strings.TrimSpace(source.Provider)) + "\x00" + strings.TrimSpace(source.URL)
now := time.Now()
r.mu.Lock()
if cached, ok := r.cache[key]; ok && cached.expiresAt.After(now) {
r.mu.Unlock()
return cached.result, nil
}
delete(r.cache, key)
r.mu.Unlock()
for _, provider := range r.providers {
if source.Provider != "" && !strings.EqualFold(source.Provider, provider.Name()) {
continue
}
if !provider.Supports(source.URL) {
continue
}
result, err := provider.Resolve(ctx, source.URL)
if err != nil {
return Result{}, err
}
r.mu.Lock()
r.cache[key] = cacheEntry{result: result, expiresAt: now.Add(cacheTTL)}
if len(r.cache) > 128 {
for candidate, entry := range r.cache {
if entry.expiresAt.Before(now) {
delete(r.cache, candidate)
}
}
}
r.mu.Unlock()
return result, nil
}
return Result{}, ErrUnavailable
}
func (r *Resolver) Invalidate(source Source) {
key := strings.ToLower(strings.TrimSpace(source.Provider)) + "\x00" + strings.TrimSpace(source.URL)
r.mu.Lock()
delete(r.cache, key)
r.mu.Unlock()
}
type appleProvider struct{ client *http.Client }
func newAppleProvider(client *http.Client) Provider { return &appleProvider{client: client} }
func (*appleProvider) Name() string { return "apple" }
func (*appleProvider) Supports(raw string) bool {
parsed, err := url.Parse(raw)
return err == nil && (isHostOrSubdomain(parsed.Hostname(), "apple.com") ||
isHostOrSubdomain(parsed.Hostname(), "apple.co"))
}
func (p *appleProvider) Resolve(ctx context.Context, raw string) (Result, error) {
if looksLikeMediaURL(raw) {
return p.validate(ctx, raw)
}
body, err := fetchLimited(ctx, p.client, raw, maxPageBytes, "text/html")
if err != nil {
return Result{}, err
}
links := mediaLinks(string(body))
if len(links) == 0 {
return Result{}, ErrUnavailable
}
sort.SliceStable(links, func(i, j int) bool { return mediaQuality(links[i]) > mediaQuality(links[j]) })
for _, candidate := range links {
if result, err := p.validate(ctx, candidate); err == nil {
return result, nil
}
}
return Result{}, ErrUnavailable
}
func (p *appleProvider) validate(ctx context.Context, raw string) (Result, error) {
contentType, err := validateMediaURL(ctx, p.client, raw)
if err != nil {
return Result{}, err
}
return Result{URL: raw, MimeType: contentType}, nil
}
var appleMediaURL = regexp.MustCompile(`https?:\\?/\\?/[^"'<> ]+\.(?:mov|mp4|m3u8)(?:\?[^"'<> ]*)?`)
func mediaLinks(body string) []string {
matches := appleMediaURL.FindAllString(body, -1)
seen := map[string]bool{}
out := make([]string, 0, len(matches))
for _, match := range matches {
candidate := html.UnescapeString(strings.ReplaceAll(match, `\/`, `/`))
if !seen[candidate] {
seen[candidate] = true
out = append(out, candidate)
}
}
return out
}
func mediaQuality(raw string) int {
lower := strings.ToLower(raw)
for _, quality := range []int{2160, 1440, 1080, 720, 480, 360} {
if strings.Contains(lower, fmt.Sprintf("%d", quality)) {
return quality
}
}
return 0
}
type youTubeProvider struct{ client *http.Client }
func newYouTubeProvider(client *http.Client) Provider { return &youTubeProvider{client: client} }
func (*youTubeProvider) Name() string { return "youtube" }
func (*youTubeProvider) Supports(raw string) bool { return youtubeVideoID(raw) != "" }
func (p *youTubeProvider) Resolve(ctx context.Context, raw string) (Result, error) {
videoID := youtubeVideoID(raw)
if videoID == "" {
return Result{}, ErrUnavailable
}
responses := []func(context.Context, string) (youtubePlayer, error){
p.innerTubePlayer,
p.watchPagePlayer,
}
for _, load := range responses {
player, err := load(ctx, videoID)
if err != nil || !strings.EqualFold(player.PlayabilityStatus.Status, "OK") {
continue
}
// YouTube's formats list contains progressive audio+video streams. AdaptiveFormats
// are separate tracks and would begin silently if handed straight to Media3.
formats := player.StreamingData.Formats
sort.SliceStable(formats, func(i, j int) bool {
return formats[i].Height > formats[j].Height ||
(formats[i].Height == formats[j].Height && formats[i].Bitrate > formats[j].Bitrate)
})
for _, format := range formats {
// Native playback needs one progressive stream carrying both tracks. Adaptive
// video-only formats are deliberately skipped rather than starting silent.
if format.URL == "" || !strings.Contains(format.MimeType, "video/") {
continue
}
contentType, validationErr := validateMediaURL(ctx, p.client, format.URL)
if validationErr == nil {
return Result{URL: format.URL, MimeType: contentType}, nil
}
}
}
return Result{}, ErrUnavailable
}
type youtubePlayer struct {
PlayabilityStatus struct {
Status string `json:"status"`
} `json:"playabilityStatus"`
StreamingData struct {
Formats []youtubeFormat `json:"formats"`
AdaptiveFormats []youtubeFormat `json:"adaptiveFormats"`
} `json:"streamingData"`
}
type youtubeFormat struct {
URL string `json:"url"`
MimeType string `json:"mimeType"`
Height int `json:"height"`
Bitrate int `json:"bitrate"`
}
func (p *youTubeProvider) innerTubePlayer(ctx context.Context, videoID string) (youtubePlayer, error) {
payload := map[string]any{
"videoId": videoID, "contentCheckOk": true, "racyCheckOk": true,
"context": map[string]any{"client": map[string]any{
"clientName": "ANDROID", "clientVersion": "20.10.38", "hl": "en", "gl": "NZ",
}},
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://www.youtube.com/youtubei/v1/player", bytes.NewReader(body))
if err != nil {
return youtubePlayer{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "com.google.android.youtube/20.10.38 (Linux; U; Android 12) gzip")
return p.doPlayer(req)
}
func (p *youTubeProvider) watchPagePlayer(ctx context.Context, videoID string) (youtubePlayer, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://www.youtube.com/watch?v="+url.QueryEscape(videoID)+"&bpctr=9999999999&has_verified=1", nil)
if err != nil {
return youtubePlayer{}, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Linux; Android 12; Android TV) AppleWebKit/537.36 Chrome/122 Safari/537.36")
body, err := p.do(req)
if err != nil {
return youtubePlayer{}, err
}
for _, marker := range []string{"ytInitialPlayerResponse = ", `"playerResponse":`} {
if raw := balancedJSONObject(body, marker); raw != "" {
var player youtubePlayer
if json.Unmarshal([]byte(raw), &player) == nil {
return player, nil
}
}
}
return youtubePlayer{}, ErrUnavailable
}
func (p *youTubeProvider) doPlayer(req *http.Request) (youtubePlayer, error) {
body, err := p.do(req)
if err != nil {
return youtubePlayer{}, err
}
var player youtubePlayer
if err := json.Unmarshal([]byte(body), &player); err != nil {
return youtubePlayer{}, err
}
return player, nil
}
func (p *youTubeProvider) do(req *http.Request) (string, error) {
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", ErrUnavailable
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxPageBytes+1))
if err != nil || len(body) > maxPageBytes {
return "", ErrUnavailable
}
return string(body), nil
}
func youtubeVideoID(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
host := strings.TrimPrefix(strings.ToLower(parsed.Hostname()), "www.")
var id string
switch {
case host == "youtu.be":
id = strings.Trim(parsed.Path, "/")
case isHostOrSubdomain(host, "youtube.com"), isHostOrSubdomain(host, "youtube-nocookie.com"):
id = parsed.Query().Get("v")
if id == "" {
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if len(parts) == 2 && (parts[0] == "embed" || parts[0] == "shorts") {
id = parts[1]
}
}
}
if len(id) != 11 {
return ""
}
for _, char := range id {
if !(char == '-' || char == '_' || char >= 'a' && char <= 'z' ||
char >= 'A' && char <= 'Z' || char >= '0' && char <= '9') {
return ""
}
}
return id
}
func isHostOrSubdomain(host, root string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
root = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(root), "."))
return host == root || strings.HasSuffix(host, "."+root)
}
func balancedJSONObject(body, marker string) string {
start := strings.Index(body, marker)
if start < 0 {
return ""
}
start += len(marker)
for start < len(body) && body[start] != '{' {
start++
}
if start == len(body) {
return ""
}
depth, quoted, escaped := 0, false, false
for index := start; index < len(body); index++ {
char := body[index]
if quoted {
if escaped {
escaped = false
} else if char == '\\' {
escaped = true
} else if char == '"' {
quoted = false
}
continue
}
switch char {
case '"':
quoted = true
case '{':
depth++
case '}':
depth--
if depth == 0 {
return body[start : index+1]
}
}
}
return ""
}
func looksLikeMediaURL(raw string) bool {
path := strings.ToLower(strings.Split(raw, "?")[0])
return strings.HasSuffix(path, ".mov") || strings.HasSuffix(path, ".mp4") || strings.HasSuffix(path, ".m3u8")
}
func validateMediaURL(ctx context.Context, client *http.Client, raw string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return "", err
}
req.Header.Set("Range", "bytes=0-0")
req.Header.Set("User-Agent", "Memby trailer resolver")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return "", ErrUnavailable
}
contentType := strings.ToLower(strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0]))
if !strings.HasPrefix(contentType, "video/") && contentType != "application/vnd.apple.mpegurl" &&
contentType != "application/x-mpegurl" && contentType != "application/octet-stream" {
return "", ErrUnavailable
}
return contentType, nil
}
func fetchLimited(ctx context.Context, client *http.Client, raw string, limit int64, accept string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", accept)
req.Header.Set("User-Agent", "Mozilla/5.0 (Linux; Android TV) AppleWebKit/537.36 Safari/537.36")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, ErrUnavailable
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil || int64(len(body)) > limit {
return nil, ErrUnavailable
}
return body, nil
}
+116
View File
@@ -0,0 +1,116 @@
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)
}
}