From f2d052dbf6849b8ba875397636e8e286fbd3d72f Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Wed, 12 Aug 2026 11:05:07 +1200 Subject: [PATCH] 0.2.56 - Reliable trailer playback --- CHANGELOG.md | 10 + CLAUDE.md | 21 + app/build.gradle.kts | 2 +- .../memby/data/EmbyRepository.kt | 88 ++++ .../memby/data/TrailerSupport.kt | 16 + .../memby/data/model/GatewayModels.kt | 34 ++ .../memby/data/remote/GatewayApi.kt | 13 + .../memby/screensaver/MembyDreamService.kt | 11 +- .../ponzischeme89/memby/ui/HomeViewModel.kt | 2 +- .../ponzischeme89/memby/ui/MainActivity.kt | 43 +- .../memby/ui/MediaDetailsOverlay.kt | 7 +- .../memby/ui/SeriesDetailsOverlay.kt | 7 +- .../memby/ui/player/PlayerActivity.kt | 127 +++++- .../ui/screensaver/ScreensaverActivity.kt | 10 +- .../ui/screensaver/ScreensaverContent.kt | 46 +- .../memby/data/GatewayPayloadTest.kt | 17 + .../memby/data/TrailerSupportTest.kt | 25 + server/internal/api/api.go | 9 + server/internal/api/trailers.go | 360 +++++++++++++++ server/internal/api/trailers_test.go | 101 +++++ server/internal/trailer/resolver.go | 428 ++++++++++++++++++ server/internal/trailer/resolver_test.go | 116 +++++ 22 files changed, 1438 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/TrailerSupport.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/data/TrailerSupportTest.kt create mode 100644 server/internal/api/trailers.go create mode 100644 server/internal/api/trailers_test.go create mode 100644 server/internal/trailer/resolver.go create mode 100644 server/internal/trailer/resolver_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index bb6cdf2..561daab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 65d3dc6..f83fbaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 490dbc9..aa194a0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,7 +42,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.54" +val defaultVersionName = "0.2.56" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index 2c69301..0125cc5 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -277,6 +277,9 @@ class EmbyRepository(private val settings: SettingsStore) { private val trailerCache = LinkedHashMap(TRAILER_CACHE_SIZE, 0.75f, true) private val trailerInFlight = mutableMapOf>() + private val trailerAvailabilityCache = + LinkedHashMap(TRAILER_CACHE_SIZE, 0.75f, true) + private val trailerAvailabilityInFlight = mutableMapOf>() 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 { 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() } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/TrailerSupport.kt b/app/src/main/java/com/ponzischeme89/memby/data/TrailerSupport.kt new file mode 100644 index 0000000..ee1dd82 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/TrailerSupport.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index 6d3a752..838add3 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -40,6 +40,40 @@ data class GatewayDevices( val devices: List = 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 = emptyList(), +) + +@Serializable +data class GatewayTrailerResolveRequest( + val excludedCandidateIds: List = 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 = emptyList(), +) + @Serializable data class GatewayDeviceNameRequest(val deviceName: String) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt index a00678e..c2689af 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/screensaver/MembyDreamService.kt b/app/src/main/java/com/ponzischeme89/memby/screensaver/MembyDreamService.kt index 4b059a9..ffc8484 100644 --- a/app/src/main/java/com/ponzischeme89/memby/screensaver/MembyDreamService.kt +++ b/app/src/main/java/com/ponzischeme89/memby/screensaver/MembyDreamService.kt @@ -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({ diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index 8c7e264..06c6268 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -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) } } } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index 0b81101..62e187e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt index 763dea7..c449f3f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt @@ -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( diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt index 15ad65f..0710d65 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt @@ -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?, 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 -> diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index 832b6fd..7525914 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -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(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(it) }.getOrNull() } ?.takeIf { it.itemId.isNotBlank() } + private fun decodeTrailerRequest(encoded: String?): TrailerPlaybackRequest? = + encoded?.let { + runCatching { playerJson.decodeFromString(it) }.getOrNull() + }?.takeIf { it.subjectId.isNotBlank() } + private fun mediaItem(url: String, subtitles: List): MediaItem { val configurations = subtitles.filter { it.deliveryMethod.equals("External", true) && it.url.isNotBlank() && it.mimeType.isNotBlank() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverActivity.kt index 340f0af..4bd1a8c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverActivity.kt @@ -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), diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt index a7ca478..0331cdb 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt @@ -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) { diff --git a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt index f8c12b9..7b7e0e3 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt @@ -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( + """{"available":true,"providers":["local","apple","youtube"]}""", + ) + val playback = json.decodeFromString( + """{"candidateId":"youtube-a1","provider":"youtube","url":"https://media.example/trailer.mp4","title":"Arrival trailer","playMethod":"DirectPlay"}""", + ) + + 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( diff --git a/app/src/test/java/com/ponzischeme89/memby/data/TrailerSupportTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/TrailerSupportTest.kt new file mode 100644 index 0000000..2f4aa4f --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/TrailerSupportTest.kt @@ -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)) + } +} diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 6fceffa..4da2b0f 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -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)) diff --git a/server/internal/api/trailers.go b/server/internal/api/trailers.go new file mode 100644 index 0000000..8f38e40 --- /dev/null +++ b/server/internal/api/trailers.go @@ -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" +} diff --git a/server/internal/api/trailers_test.go b/server/internal/api/trailers_test.go new file mode 100644 index 0000000..a76c227 --- /dev/null +++ b/server/internal/api/trailers_test.go @@ -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) + } +} diff --git a/server/internal/trailer/resolver.go b/server/internal/trailer/resolver.go new file mode 100644 index 0000000..4ac538b --- /dev/null +++ b/server/internal/trailer/resolver.go @@ -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 +} diff --git a/server/internal/trailer/resolver_test.go b/server/internal/trailer/resolver_test.go new file mode 100644 index 0000000..c2f4371 --- /dev/null +++ b/server/internal/trailer/resolver_test.go @@ -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 = `720` + + `1080` + headers.Set("Content-Type", "text/html") + } else { + status = http.StatusPartialContent + headers.Set("Content-Type", "video/quicktime") + } + return &http.Response{ + StatusCode: status, + Header: headers, + Body: io.NopCloser(strings.NewReader(body)), + Request: request, + }, nil + })} + resolver := New(client) + result, err := resolver.Resolve(context.Background(), Source{ + Provider: "apple", URL: "https://trailers.apple.com/page", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result.URL, "1080") { + t.Fatalf("did not choose the best stream: %+v", result) + } +} + +func TestBalancedJSONObjectIgnoresBracesInsideStrings(t *testing.T) { + body := `before marker = {"value":"}" ,"nested":{"ok":true}} after` + if got := balancedJSONObject(body, "marker = "); got != `{"value":"}" ,"nested":{"ok":true}}` { + t.Fatalf("balanced object = %q", got) + } +}