0.2.56 - Reliable trailer playback
This commit is contained in:
@@ -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
|
||||
- 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.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`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
|
||||
|
||||
@@ -42,7 +42,7 @@ val projectNoticeText =
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.54"
|
||||
val defaultVersionName = "0.2.56"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -277,6 +277,9 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
private val trailerCache =
|
||||
LinkedHashMap<String, CachedTrailer>(TRAILER_CACHE_SIZE, 0.75f, true)
|
||||
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)
|
||||
|
||||
@@ -1409,6 +1412,88 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
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?> {
|
||||
val request = scope.async(start = CoroutineStart.LAZY) {
|
||||
try {
|
||||
@@ -1893,6 +1978,9 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
// but the request in flight carries the outgoing session, and the next profile
|
||||
// may be signed into a different server entirely.
|
||||
trailerCache.clear()
|
||||
trailerAvailabilityCache.clear()
|
||||
trailerAvailabilityInFlight.values.forEach { it.cancel() }
|
||||
trailerAvailabilityInFlight.clear()
|
||||
trailerInFlight.values.forEach { it.cancel() }
|
||||
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(),
|
||||
)
|
||||
|
||||
/** 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
|
||||
data class GatewayDeviceNameRequest(val deviceName: String)
|
||||
|
||||
|
||||
@@ -289,6 +289,19 @@ interface GatewayApi {
|
||||
@GET("v1/items/{id}/trailer")
|
||||
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")
|
||||
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.setViewTreeSavedStateRegistryOwner
|
||||
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.ScreensaverContent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||
@@ -60,7 +62,7 @@ class MembyDreamService : DreamService() {
|
||||
setContent {
|
||||
MembyTheme {
|
||||
ScreensaverContent(
|
||||
onPlay = { url, title -> launchPlayback(url, title) },
|
||||
onPlay = ::launchTrailer,
|
||||
onExit = { finish() },
|
||||
actions = actions,
|
||||
)
|
||||
@@ -113,8 +115,11 @@ class MembyDreamService : DreamService() {
|
||||
* the dream" race some TV builds exhibit. Uses the application context because
|
||||
* this service is being torn down.
|
||||
*/
|
||||
private fun launchPlayback(url: String, title: String) {
|
||||
val intent = PlayerActivity.intent(applicationContext, url, title)
|
||||
private fun launchTrailer(item: BaseItem) {
|
||||
val intent = PlayerActivity.trailerIntent(
|
||||
applicationContext,
|
||||
TrailerPlaybackRequest(subjectId = item.id, title = item.name),
|
||||
)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
finish()
|
||||
mainHandler.postDelayed({
|
||||
|
||||
@@ -448,7 +448,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
if (!seriesId.isNullOrBlank()) {
|
||||
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.RecommendationOnboarding
|
||||
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.ServerConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig
|
||||
@@ -2042,7 +2043,10 @@ private fun HomeScreen(
|
||||
var rowListFocusRestoreRequest by remember { mutableStateOf(0) }
|
||||
val playbackLauncher = rememberLauncherForActivityResult(
|
||||
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(
|
||||
category = "playback", action = "stop", screen = "player",
|
||||
feature = "playback", target = selectedDestination.name.lowercase(),
|
||||
@@ -2068,9 +2072,12 @@ private fun HomeScreen(
|
||||
if (it is kotlinx.coroutines.CancellationException) throw it
|
||||
notificationsError = friendlyEmbyError(it)
|
||||
}
|
||||
myShowsLoading = false
|
||||
notificationsLoading = false
|
||||
myShowsLoading = false
|
||||
notificationsLoading = false
|
||||
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) {
|
||||
requestFirstAvailableFocus(
|
||||
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, "Couldn’t open the trailer", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
val rows = remember(
|
||||
homeContent,
|
||||
@@ -3258,6 +3291,7 @@ private fun HomeScreen(
|
||||
detailsAiringNotice = null
|
||||
playItem(it)
|
||||
},
|
||||
onPlayTrailer = playTrailer,
|
||||
onToggleFavorite = { item, saved ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "library", action = if (saved) "favourite" else "unfavourite",
|
||||
@@ -3933,6 +3967,7 @@ private fun FocusedDetailsOverlay(
|
||||
selected: BaseItem,
|
||||
restorePosition: Boolean,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onPlayTrailer: (BaseItem) -> Unit,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
isMyShow: Boolean,
|
||||
onToggleMyShow: (BaseItem, Boolean) -> Unit,
|
||||
@@ -3947,6 +3982,7 @@ private fun FocusedDetailsOverlay(
|
||||
SeriesDetailsOverlay(
|
||||
item = item,
|
||||
onPlay = onPlay,
|
||||
onPlayTrailer = onPlayTrailer,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
isMyShow = isMyShow,
|
||||
onToggleMyShow = onToggleMyShow,
|
||||
@@ -3972,6 +4008,7 @@ private fun FocusedDetailsOverlay(
|
||||
MediaDetailsOverlay(
|
||||
item = item,
|
||||
onPlay = onPlay,
|
||||
onPlayTrailer = onPlayTrailer,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
onTogglePlayed = onTogglePlayed,
|
||||
onClose = onClose,
|
||||
|
||||
@@ -46,6 +46,7 @@ import kotlinx.coroutines.delay
|
||||
fun MediaDetailsOverlay(
|
||||
item: BaseItem,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onPlayTrailer: (BaseItem) -> Unit = onPlay,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
onTogglePlayed: (BaseItem, Boolean) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
@@ -62,7 +63,7 @@ fun MediaDetailsOverlay(
|
||||
related = ServiceLocator.repository.getRelated(item)
|
||||
}
|
||||
LaunchedEffect(item.id) {
|
||||
trailer = ServiceLocator.repository.getLocalTrailer(item.id)
|
||||
trailer = item.takeIf { ServiceLocator.repository.hasTrailer(item.id) }
|
||||
}
|
||||
LaunchedEffect(item.id, settings.showRatingsStrip) {
|
||||
ratings = if (settings.showRatingsStrip) ServiceLocator.repository.getRatings(item) else emptyList()
|
||||
@@ -70,6 +71,7 @@ fun MediaDetailsOverlay(
|
||||
MediaDetailContent(
|
||||
item = item,
|
||||
onPlay = onPlay,
|
||||
onPlayTrailer = onPlayTrailer,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
onTogglePlayed = onTogglePlayed,
|
||||
onOpenItem = onOpenItem,
|
||||
@@ -91,6 +93,7 @@ fun MediaDetailsOverlay(
|
||||
internal fun MediaDetailContent(
|
||||
item: BaseItem,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onPlayTrailer: (BaseItem) -> Unit = onPlay,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
onTogglePlayed: (BaseItem, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -201,7 +204,7 @@ internal fun MediaDetailContent(
|
||||
),
|
||||
)
|
||||
trailer?.let {
|
||||
add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlay(it) }))
|
||||
add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlayTrailer(item) }))
|
||||
}
|
||||
add(
|
||||
DetailHeroAction(
|
||||
|
||||
@@ -103,6 +103,7 @@ import java.util.TimeZone
|
||||
fun SeriesDetailsOverlay(
|
||||
item: BaseItem,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onPlayTrailer: (BaseItem) -> Unit = onPlay,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
isMyShow: Boolean,
|
||||
onToggleMyShow: (BaseItem, Boolean) -> Unit,
|
||||
@@ -145,7 +146,7 @@ fun SeriesDetailsOverlay(
|
||||
related = repository.getRelated(item)
|
||||
}
|
||||
LaunchedEffect(item.id) {
|
||||
trailer = repository.getLocalTrailer(item.id)
|
||||
trailer = item.takeIf { repository.hasTrailer(item.id) }
|
||||
}
|
||||
LaunchedEffect(item.id, settings.showRatingsStrip) {
|
||||
ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList()
|
||||
@@ -156,6 +157,7 @@ fun SeriesDetailsOverlay(
|
||||
episodes = episodes,
|
||||
loadFailed = loadFailed,
|
||||
onPlay = onPlay,
|
||||
onPlayTrailer = onPlayTrailer,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
isMyShow = isMyShow,
|
||||
onToggleMyShow = onToggleMyShow,
|
||||
@@ -178,6 +180,7 @@ internal fun SeriesDetailContent(
|
||||
episodes: List<BaseItem>?,
|
||||
loadFailed: Boolean,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onPlayTrailer: (BaseItem) -> Unit = onPlay,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
isMyShow: Boolean,
|
||||
onToggleMyShow: (BaseItem, Boolean) -> Unit,
|
||||
@@ -353,7 +356,7 @@ internal fun SeriesDetailContent(
|
||||
))
|
||||
}
|
||||
trailer?.let {
|
||||
add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlay(it) }))
|
||||
add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlayTrailer(item) }))
|
||||
}
|
||||
},
|
||||
) { visibleTab ->
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.AlertDialog
|
||||
import android.app.Activity
|
||||
import android.animation.ObjectAnimator
|
||||
import android.animation.ValueAnimator
|
||||
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.selectSubtitleId
|
||||
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.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayPrerollEntry
|
||||
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
||||
import com.ponzischeme89.memby.data.model.GatewaySeasonFinale
|
||||
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.AudioPassthroughPreference
|
||||
import com.ponzischeme89.memby.data.playback.SurroundCodec
|
||||
@@ -169,6 +173,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
* re-prepare nothing.
|
||||
*/
|
||||
private var pendingRequest: PlaybackRequest? = null
|
||||
private var pendingTrailerRequest: TrailerPlaybackRequest? = null
|
||||
private var trailerStartupTimeoutJob: Job? = null
|
||||
private var pendingResolveJob: Job? = null
|
||||
private var pendingResolveGeneration = 0L
|
||||
private var serviceAlertsMounted = false
|
||||
@@ -417,8 +423,13 @@ class PlayerActivity : ComponentActivity() {
|
||||
val url = savedInstanceState?.getString(STATE_URL)?.takeIf(String::isNotBlank)
|
||||
?: intent.getStringExtra(EXTRA_URL)?.takeIf(String::isNotBlank)
|
||||
val request = decodeRequest(intent.getStringExtra(EXTRA_PLAYBACK_REQUEST))
|
||||
pendingRequest = request.takeIf { url == null }
|
||||
if (url == null && request == null) {
|
||||
val trailerRequest = decodeTrailerRequest(
|
||||
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()
|
||||
return
|
||||
}
|
||||
@@ -439,7 +450,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
val resumePositionMs = restoredPositionMs
|
||||
?: intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 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(
|
||||
EXTRA_PREROLL_DURATION_MS,
|
||||
DEFAULT_PREROLL_DURATION_MS,
|
||||
@@ -555,6 +566,10 @@ class PlayerActivity : ComponentActivity() {
|
||||
prerollActive = false
|
||||
disposeLocalPreroll(reuse = false)
|
||||
prerollView?.visibility = View.GONE
|
||||
if (pendingTrailerRequest != null) {
|
||||
finishTrailerUnavailable()
|
||||
return
|
||||
}
|
||||
showPlaybackError(
|
||||
PlaybackFailure(
|
||||
title = "Couldn’t start the video player",
|
||||
@@ -639,6 +654,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
|
||||
override fun onRenderedFirstFrame() {
|
||||
renderedFirstFrame = true
|
||||
trailerStartupTimeoutJob?.cancel()
|
||||
trailerStartupTimeoutJob = null
|
||||
endSeekBuffering()
|
||||
hidePlaybackLoading()
|
||||
if (!playbackStarted && !prerollActive) {
|
||||
@@ -670,6 +687,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
resumePositionMs,
|
||||
playWhenReady = restoredPlayWhenReady ?: !showPreroll,
|
||||
)
|
||||
} else if (trailerRequest != null) {
|
||||
resolvePendingTrailer()
|
||||
} else {
|
||||
resolvePendingStream(
|
||||
requireNotNull(request),
|
||||
@@ -726,6 +745,10 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
if (prepared.isFailure) {
|
||||
Log.e(PLAYBACK_LOG_TAG, "event=media_prepare_failed item=${itemId.orEmpty()}", prepared.exceptionOrNull())
|
||||
if (shouldFallbackTrailer(pendingTrailerRequest != null)) {
|
||||
fallbackToNextTrailer("prepare")
|
||||
return
|
||||
}
|
||||
showPlaybackError(
|
||||
PlaybackFailure(
|
||||
title = getString(R.string.playback_server_unreachable),
|
||||
@@ -740,6 +763,73 @@ class PlayerActivity : ComponentActivity() {
|
||||
endFirstFrameTrace()
|
||||
firstFrameTraceCookie = PlaybackTraceSections.nextCookie()
|
||||
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() {
|
||||
@@ -1322,6 +1412,10 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun handlePlaybackError(error: PlaybackException) {
|
||||
if (shouldFallbackTrailer(pendingTrailerRequest != null)) {
|
||||
fallbackToNextTrailer("player_${error.errorCodeName}")
|
||||
return
|
||||
}
|
||||
if (prerollActive || fullscreenPlayerParent != null) {
|
||||
prerollActive = false
|
||||
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_EPISODE_CODE, prerollEpisodeCode)
|
||||
outState.putLong(STATE_RUNTIME_MS, prerollRuntimeMs)
|
||||
pendingTrailerRequest?.let {
|
||||
outState.putString(STATE_TRAILER_REQUEST, playerJson.encodeToString(it))
|
||||
}
|
||||
}
|
||||
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_METHOD = "extra_play_method"
|
||||
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_PLAY_WHEN_READY = "state_play_when_ready"
|
||||
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_EPISODE_CODE = "state_episode_code"
|
||||
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 SUBTITLE_SIZE_KEY = "subtitle_size"
|
||||
private const val PICTURE_MODE_KEY = "picture_mode"
|
||||
private const val SUBTITLE_BOTTOM_PADDING_FRACTION = 0.095f
|
||||
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.
|
||||
@@ -4414,6 +4516,20 @@ class PlayerActivity : ComponentActivity() {
|
||||
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(
|
||||
context: Context,
|
||||
url: String,
|
||||
@@ -4479,6 +4595,11 @@ class PlayerActivity : ComponentActivity() {
|
||||
encoded?.let { runCatching { playerJson.decodeFromString<PlaybackRequest>(it) }.getOrNull() }
|
||||
?.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 {
|
||||
val configurations = subtitles.filter {
|
||||
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.compose.setContent
|
||||
import com.ponzischeme89.memby.ui.player.PlayerActivity
|
||||
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
|
||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||
|
||||
/** In-app preview of the screensaver, launched from the home screen. */
|
||||
@@ -25,8 +26,13 @@ class ScreensaverActivity : ComponentActivity() {
|
||||
setContent {
|
||||
MembyTheme {
|
||||
ScreensaverContent(
|
||||
onPlay = { url, title ->
|
||||
startActivity(PlayerActivity.intent(this, url, title))
|
||||
onPlay = { item ->
|
||||
startActivity(
|
||||
PlayerActivity.trailerIntent(
|
||||
this,
|
||||
TrailerPlaybackRequest(subjectId = item.id, title = item.name),
|
||||
),
|
||||
)
|
||||
},
|
||||
onExit = { finish() },
|
||||
startupMessage = intent.getStringExtra(EXTRA_STARTUP_MESSAGE),
|
||||
|
||||
@@ -132,7 +132,7 @@ class ScreensaverActions {
|
||||
*/
|
||||
@Composable
|
||||
fun ScreensaverContent(
|
||||
onPlay: (url: String, title: String) -> Unit,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onExit: () -> Unit,
|
||||
actions: ScreensaverActions? = null,
|
||||
startupMessage: String? = null,
|
||||
@@ -163,7 +163,7 @@ fun ScreensaverContent(
|
||||
|
||||
@Composable
|
||||
private fun Slideshow(
|
||||
onPlay: (url: String, title: String) -> Unit,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onExit: () -> Unit,
|
||||
actions: ScreensaverActions?,
|
||||
showTitleLogo: Boolean,
|
||||
@@ -385,43 +385,13 @@ private fun Slideshow(
|
||||
if (playbackLaunching) return
|
||||
val target = item ?: return
|
||||
playbackLaunching = true
|
||||
toast = "Finding trailer…"
|
||||
scope.launch {
|
||||
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 = "Couldn’t 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
|
||||
}
|
||||
}
|
||||
.onFailure {
|
||||
toast = friendlyEmbyError(it)
|
||||
playbackLaunching = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure {
|
||||
toast = friendlyEmbyError(it)
|
||||
playbackLaunching = false
|
||||
}
|
||||
if (runCatching { currentOnPlay(target) }.isFailure) {
|
||||
toast = "Couldn’t open the trailer."
|
||||
playbackLaunching = false
|
||||
return
|
||||
}
|
||||
// Keep rapid Select/media-key repeats gated while the Activity hand-off occurs.
|
||||
scope.launch { delay(1_000L); playbackLaunching = false }
|
||||
}
|
||||
|
||||
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.GatewayTrickplay
|
||||
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 kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -28,6 +30,21 @@ import org.junit.Test
|
||||
* side, this fails before a TV ever sees it.
|
||||
*/
|
||||
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
|
||||
fun `partially populated items decode without inventing an identity`() {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
"github.com/ponzischeme89/memby/server/internal/trailer"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
@@ -85,6 +86,7 @@ type Server struct {
|
||||
// embyHealth is the reachability probe's live finding, which /v1/status publishes so
|
||||
// a TV can show why playback stopped even if it missed the announcement.
|
||||
embyHealth embyHealth
|
||||
trailers *trailer.Resolver
|
||||
}
|
||||
|
||||
// Deps are the collaborators the API needs. A struct rather than positional arguments:
|
||||
@@ -105,6 +107,10 @@ type Deps struct {
|
||||
}
|
||||
|
||||
func New(cfg config.Config, deps Deps) *Server {
|
||||
trailerTimeout := cfg.UpstreamTimeout
|
||||
if trailerTimeout <= 0 || trailerTimeout > 8*time.Second {
|
||||
trailerTimeout = 8 * time.Second
|
||||
}
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
emby: deps.Emby,
|
||||
@@ -119,6 +125,7 @@ func New(cfg config.Config, deps Deps) *Server {
|
||||
syncer: deps.Syncer,
|
||||
log: deps.Log,
|
||||
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.
|
||||
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}/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}/trickplay", s.authed(s.handleTrickplay))
|
||||
v1.Handle("GET /v1/items/{id}/trickplay/{frame}", s.authed(s.handleTrickplayFrame))
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user