Add watchlist, next-episode playback, and fix TV shelf reorder.

Probe-driven continue-watching advances to the next episode; Android TV gets next controls, red focus rings, and a Watchlist tab between Home and Films.
This commit is contained in:
Jos Vooges | STH 2026-09-12 23:50:07 +02:00
parent 6de2c767bf
commit 07901c1d0c
11 changed files with 414 additions and 69 deletions

View file

@ -41,6 +41,7 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors
enum class MainTab(val label: String) {
Home("Home"),
Watchlist("Watchlist"),
Movies("Films"),
Series("Series"),
Tv("TV"),
@ -56,6 +57,7 @@ data class SubNavItem(
private fun MainTab.icon(): ImageVector = when (this) {
MainTab.Home -> Icons.Filled.Home
MainTab.Watchlist -> Icons.Filled.Star
MainTab.Movies -> Icons.Filled.PlayArrow
MainTab.Series -> Icons.Filled.Menu
MainTab.Tv -> Icons.Filled.Star

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24
targetSdk = 36
versionCode = 86
versionName = "0.14.23"
versionCode = 87
versionName = "0.14.24"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -151,6 +151,59 @@ class ApiClient(
JSONObject(execute(req))
}
suspend fun nextEpisode(episodeId: String): NextEpisode? = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/episodes/$episodeId/next").get().build()
parseObject(execute(req)) { o ->
val n = o.optJSONObject("next") ?: return@parseObject null
val mediaFileId = n.optString("mediaFileId").ifBlank { null } ?: return@parseObject null
NextEpisode(
id = n.getString("id"),
seriesId = n.getString("seriesId"),
seasonNumber = n.optInt("seasonNumber"),
episodeNumber = n.optInt("episodeNumber"),
title = n.optString("title").ifBlank { null },
seriesTitle = n.optString("seriesTitle").ifBlank { null },
subtitle = n.optString("subtitle").ifBlank { null },
mediaFileId = mediaFileId,
posterUrl = n.optString("posterUrl").ifBlank { null },
)
}
}
suspend fun listFavorites(): List<MediaItem> = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/favorites").get().build()
parseObject(execute(req)) { o ->
val arr = o.optJSONArray("items") ?: return@parseObject emptyList()
buildList {
for (i in 0 until arr.length()) {
val it = arr.optJSONObject(i) ?: continue
add(
MediaItem(
type = it.optString("type").ifBlank { "movie" },
id = it.getString("id"),
title = it.optString("title"),
year = it.optInt("year").takeIf { y -> y > 0 },
posterUrl = it.optString("posterUrl").ifBlank { null },
backdropUrl = it.optString("backdropUrl").ifBlank { null },
logoUrl = it.optString("logoUrl").ifBlank { null },
mediaFileId = it.optString("mediaFileId").ifBlank { null },
)
)
}
}
}
}
suspend fun toggleFavorite(type: String, id: String): Boolean = withContext(Dispatchers.IO) {
val body = JSONObject()
.put("type", type)
.put("id", id)
.toString()
.toRequestBody(jsonMedia)
val req = authed("$baseUrl/api/v1/client/favorites/toggle").post(body).build()
parseObject(execute(req)) { it.optBoolean("favorited") }
}
suspend fun play(mediaFileId: String): PlayResult = withContext(Dispatchers.IO) {
val body = JSONObject().put("mediaFileId", mediaFileId).toString().toRequestBody(jsonMedia)
val req = authed("$baseUrl/api/v1/client/play").post(body).build()
@ -602,6 +655,18 @@ data class MediaItem(
val badge: String? = null,
)
data class NextEpisode(
val id: String,
val seriesId: String,
val seasonNumber: Int,
val episodeNumber: Int,
val title: String?,
val seriesTitle: String?,
val subtitle: String?,
val mediaFileId: String,
val posterUrl: String?,
)
data class PlayResult(
val streamUrl: String,
val sessionId: String,

View file

@ -309,6 +309,7 @@ fun MovieDetailScreen(
var versions by remember { mutableStateOf<List<MediaVersion>>(emptyList()) }
var selectedVersionId by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var onWatchlist by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val playFocus = remember { FocusRequester() }
val scroll = rememberScrollState()
@ -327,6 +328,9 @@ fun MovieDetailScreen(
genres = o.optJSONArray("genres").stringList()
cast = o.optJSONArray("cast").stringList()
director = o.optJSONArray("director").stringList()
onWatchlist = runCatching {
api.listFavorites().any { it.id == movieId && it.type.equals("movie", true) }
}.getOrDefault(false)
trailerKey = o.optCleanString("trailerYoutubeKey")
val arr = o.optJSONArray("mediaFiles")
val list = mutableListOf<MediaVersion>()
@ -397,6 +401,11 @@ fun MovieDetailScreen(
openYoutubeTrailer(context, trailerKey!!)
}
}
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
scope.launch {
runCatching { onWatchlist = api.toggleFavorite("movie", movieId) }
}
}
DetailActionButton("Markeer als gezien") {
scope.launch {
runCatching { api.markWatched(movieId = movieId) }
@ -531,6 +540,8 @@ fun SeriesDetailScreen(
var seasons by remember { mutableStateOf<List<JSONObject>>(emptyList()) }
var selectedSeason by remember { mutableIntStateOf(0) }
var error by remember { mutableStateOf<String?>(null) }
var onWatchlist by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val firstEpFocus = remember { FocusRequester() }
LaunchedEffect(seriesId) {
@ -544,6 +555,9 @@ fun SeriesDetailScreen(
rating = o.optCleanString("rating")
genres = o.optJSONArray("genres").stringList()
trailerKey = o.optCleanString("trailerYoutubeKey")
onWatchlist = runCatching {
api.listFavorites().any { it.id == seriesId && it.type.equals("series", true) }
}.getOrDefault(false)
val arr = o.optJSONArray("seasons")
val list = mutableListOf<JSONObject>()
if (arr != null) {
@ -627,6 +641,11 @@ fun SeriesDetailScreen(
if (!trailerKey.isNullOrBlank()) {
DetailActionButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) }
}
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
scope.launch {
runCatching { onWatchlist = api.toggleFavorite("series", seriesId) }
}
}
}
if (seasons.size > 1) {
Spacer(Modifier.height(8.dp))

View file

@ -368,6 +368,13 @@ fun McNav() {
seriesId = dash(entry.arguments?.getString("seriesId")),
startMs = entry.arguments?.getLong("startMs") ?: 0L,
onBack = { nav.popBackStack() },
onPlayNext = { nextMediaFileId, nextEpisodeId, nextSeriesId ->
nav.navigate("player/$nextMediaFileId/EPISODE/-/$nextEpisodeId/$nextSeriesId/0") {
popUpTo("player/{mediaFileId}/{type}/{movieId}/{episodeId}/{seriesId}/{startMs}") {
inclusive = true
}
}
},
)
}
composable(
@ -655,6 +662,12 @@ private fun MainHub(
onSeeAll = onBrowse,
onPlay = onPlay,
)
MainTab.Watchlist -> WatchlistScreen(
api = api,
onAtTopBack = { focusTopNav() },
onOpenMovie = onOpenMovie,
onOpenSeries = onOpenSeries,
)
MainTab.Movies -> BrowseScreen(
api = api,
catalogId = movieCatalog,
@ -1702,73 +1715,61 @@ private fun SettingsScreen(
Spacer(Modifier.height(8.dp))
}
itemsIndexed(prefs) { index, p ->
Surface(
onClick = {
val next = prefs.toMutableList()
next[index] = p.copy(visible = !p.visible)
persist(next)
},
modifier = Modifier.fillMaxWidth(),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.015f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF1A1A22),
focusedContainerColor = Color(0xFF262632),
),
itemsIndexed(prefs, key = { _, p -> p.shelfId }) { index, p ->
// Geen clickable parent-Surface: anders vangen ↑/↓ geen OK op Android TV.
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(Color(0xFF1A1A22))
.padding(horizontal = 18.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
p.name,
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
Spacer(Modifier.height(3.dp))
Text(
if (p.visible) "Zichtbaar op Home" else "Verborgen",
color = if (p.visible) Color(0xFF8E8E9A) else Color(0xFF555555),
fontSize = 13.sp,
)
}
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
p.name,
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
Spacer(Modifier.height(3.dp))
Text(
if (p.visible) "Zichtbaar op Home" else "Verborgen",
color = if (p.visible) Color(0xFF8E8E9A) else Color(0xFF555555),
fontSize = 13.sp,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
McButton("↑") {
if (index > 0) {
val next = prefs.toMutableList()
val tmp = next[index - 1]
next[index - 1] = next[index]
next[index] = tmp
persist(next)
}
McButton("↑") {
if (index > 0) {
val next = prefs.toMutableList()
val tmp = next[index - 1]
next[index - 1] = next[index]
next[index] = tmp
persist(next)
}
McButton("↓") {
if (index < prefs.lastIndex) {
val next = prefs.toMutableList()
val tmp = next[index + 1]
next[index + 1] = next[index]
next[index] = tmp
persist(next)
}
}
Switch(
checked = p.visible,
onCheckedChange = { checked ->
val next = prefs.toMutableList()
next[index] = p.copy(visible = checked)
persist(next)
},
)
}
McButton("↓") {
if (index < prefs.lastIndex) {
val next = prefs.toMutableList()
val tmp = next[index + 1]
next[index + 1] = next[index]
next[index] = tmp
persist(next)
}
}
Switch(
checked = p.visible,
onCheckedChange = { checked ->
val next = prefs.toMutableList()
next[index] = p.copy(visible = checked)
persist(next)
},
)
}
}
}
@ -1787,6 +1788,82 @@ private fun SettingsScreen(
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun WatchlistScreen(
api: ApiClient,
onAtTopBack: () -> Unit,
onOpenMovie: (String) -> Unit,
onOpenSeries: (String) -> Unit,
) {
var items by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) {
loading = true
try {
items = api.listFavorites()
error = null
} catch (e: Exception) {
error = e.message
}
loading = false
runCatching { firstFocus.requestFocus() }
}
BackHandler(onBack = onAtTopBack)
Column(
Modifier
.fillMaxSize()
.background(Color(0xFF0B0B0F))
.padding(horizontal = 40.dp, vertical = 20.dp),
) {
Text("Watchlist", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(6.dp))
Text(
"Films en series die je later wilt kijken",
color = Color(0xFF8E8E9A),
fontSize = 14.sp,
)
Spacer(Modifier.height(16.dp))
when {
loading -> Text("Laden…", color = Color(0xFF8E8E9A))
error != null -> Text(error!!, color = McColors.Danger)
items.isEmpty() -> Text(
"Nog niets op je watchlist. Voeg titels toe vanaf de detailpagina.",
color = Color(0xFF8E8E9A),
fontSize = 15.sp,
)
else -> {
androidx.compose.foundation.lazy.grid.LazyVerticalGrid(
columns = androidx.compose.foundation.lazy.grid.GridCells.Adaptive(minSize = 124.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = PaddingValues(bottom = 32.dp),
modifier = Modifier.fillMaxSize(),
) {
items(items.size, key = { items[it].id }) { idx ->
val item = items[idx]
PosterCard(
item = item,
onClick = {
when (item.type.lowercase()) {
"series" -> onOpenSeries(item.id)
else -> onOpenMovie(item.id)
}
},
modifier = if (idx == 0) Modifier.focusRequester(firstFocus) else Modifier,
fillWidth = true,
)
}
}
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun SettingsActionRow(

View file

@ -28,6 +28,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -91,11 +92,13 @@ fun PlayerScreen(
seriesId: String?,
startMs: Long,
onBack: () -> Unit,
onPlayNext: ((mediaFileId: String, episodeId: String, seriesId: String) -> Unit)? = null,
) {
val context = LocalContext.current
val player = remember { buildTvPlayer(context) }
val controlsFocus = remember { FocusRequester() }
val catcherFocus = remember { FocusRequester() }
val nextOverlayFocus = remember { FocusRequester() }
val scope = rememberCoroutineScope()
var error by remember { mutableStateOf<String?>(null) }
@ -113,13 +116,23 @@ fun PlayerScreen(
var osLoading by remember { mutableStateOf(false) }
var osError by remember { mutableStateOf<String?>(null) }
var osBusy by remember { mutableStateOf(false) }
var nextEp by remember { mutableStateOf<nl.vonas.mediacluster.tv.data.NextEpisode?>(null) }
var showNextOverlay by remember { mutableStateOf(false) }
var nextCountdown by remember { mutableIntStateOf(10) }
var nextOverlayConsumed by remember { mutableStateOf(false) }
fun showControls() {
controls = true
}
fun hideControls() {
if (menu == null) controls = false
if (menu == null && !showNextOverlay) controls = false
}
fun goNext() {
val n = nextEp ?: return
showNextOverlay = false
onPlayNext?.invoke(n.mediaFileId, n.id, n.seriesId) ?: onBack()
}
fun togglePlay() {
@ -196,6 +209,10 @@ fun PlayerScreen(
BackHandler {
when {
showNextOverlay -> {
showNextOverlay = false
nextOverlayConsumed = true
}
menu == "os" -> menu = "text"
menu != null -> menu = null
controls -> {
@ -207,6 +224,13 @@ fun PlayerScreen(
}
LaunchedEffect(mediaFileId) {
nextEp = null
showNextOverlay = false
nextOverlayConsumed = false
nextCountdown = 10
if (mediaType == "EPISODE" && !episodeId.isNullOrBlank()) {
nextEp = runCatching { api.nextEpisode(episodeId) }.getOrNull()
}
try {
val play = api.play(mediaFileId)
embeddedHints = play.embeddedTracks
@ -242,6 +266,17 @@ fun PlayerScreen(
}
}
LaunchedEffect(showNextOverlay, nextEp) {
if (!showNextOverlay || nextEp == null) return@LaunchedEffect
nextCountdown = 10
runCatching { nextOverlayFocus.requestFocus() }
while (nextCountdown > 0 && showNextOverlay) {
kotlinx.coroutines.delay(1000)
nextCountdown--
}
if (showNextOverlay && nextCountdown <= 0) goNext()
}
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
@ -252,6 +287,12 @@ fun PlayerScreen(
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value)
textTracks = player.listTracks(C.TRACK_TYPE_TEXT, embeddedHintsState.value)
}
if (state == Player.STATE_ENDED && nextEp != null && !nextOverlayConsumed) {
nextOverlayConsumed = true
player.pause()
showNextOverlay = true
menu = null
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
@ -550,6 +591,11 @@ fun PlayerScreen(
verticalAlignment = Alignment.CenterVertically,
) {
NetflixIconButton(if (playing) "❚❚" else "▶", primary = true) { togglePlay() }
if (nextEp != null && onPlayNext != null) {
NetflixIconButton("⏭") {
goNext()
}
}
NetflixIconButton("↺ 10") {
player.seekBack()
showControls()
@ -572,6 +618,52 @@ fun PlayerScreen(
}
}
if (showNextOverlay && nextEp != null) {
val n = nextEp!!
Box(
Modifier
.fillMaxSize()
.background(Color(0xCC000000)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.width(520.dp)
.clip(RoundedCornerShape(16.dp))
.background(Color(0xFF14141C))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text("Volgende aflevering", color = Color(0xFF8E8E9A), fontSize = 14.sp)
Text(
n.seriesTitle ?: "Serie",
color = Color.White,
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
)
Text(
n.subtitle ?: "S${n.seasonNumber}E${n.episodeNumber}",
color = Color.White,
fontSize = 18.sp,
)
Text(
"Start over ${nextCountdown}s",
color = McColors.Brand,
fontSize = 15.sp,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
NetflixTextButton("Speel nu") { goNext() }
NetflixTextButton("Annuleren") {
showNextOverlay = false
nextOverlayConsumed = true
player.pause()
showControls()
}
}
}
}
}
if (menu != null) {
val isAudio = menu == "audio"
val isOs = menu == "os"

View file

@ -171,7 +171,7 @@ fun PosterCard(
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.10f),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = androidx.compose.foundation.BorderStroke(3.dp, Color.White),
border = androidx.compose.foundation.BorderStroke(3.dp, McColors.Brand),
shape = RoundedCornerShape(4.dp),
),
),

View file

@ -51,6 +51,7 @@ object HomeChrome {
enum class MainTab(val label: String) {
Home("Home"),
Watchlist("Watchlist"),
Movies("Films"),
Series("Series"),
Tv("TV"),

View file

@ -36,7 +36,7 @@ struct HomeScreen: View {
VStack(alignment: .leading, spacing: 22) {
if !app.favorites.mediaItems.isEmpty {
ShelfSection(title: "Favorieten", items: app.favorites.mediaItems)
ShelfSection(title: "Watchlist", items: app.favorites.mediaItems)
}
if !home.continueWatching.isEmpty {

View file

@ -535,6 +535,14 @@ export function registerViewerRoutes(
return viewers.seriesDetail(id, auth.profile?.isKids === true);
});
app.get("/api/v1/client/episodes/:id/next", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
requireProfile(auth);
const { id } = request.params as { id: string };
const next = await viewers.findNextPlayableEpisode(id);
return { next };
});
app.post("/api/v1/client/play", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
requireProfile(auth);

View file

@ -1564,7 +1564,11 @@ export class ViewerService {
profileId,
hiddenFromContinue: false,
completed: false,
positionMs: { gt: 30_000 },
OR: [
{ positionMs: { gt: 30_000 } },
// Up-next na afgeronde aflevering (geseed met positionMs = 0)
{ mediaType: "EPISODE", positionMs: { lte: 30_000 } },
],
},
orderBy: { updatedAt: "desc" },
take: 24,
@ -1618,6 +1622,7 @@ export class ViewerService {
positionMs: p.positionMs,
durationMs: p.durationMs,
progressId: p.id,
upNext: p.positionMs <= 30_000,
});
}
}
@ -1679,7 +1684,7 @@ export class ViewerService {
const ep = await prisma.episode.findUnique({ where: { id: body.episodeId } });
seriesId = ep?.seriesId ?? null;
}
return prisma.watchProgress.upsert({
const row = await prisma.watchProgress.upsert({
where: {
profileId_episodeId: { profileId, episodeId: body.episodeId },
},
@ -1707,6 +1712,81 @@ export class ViewerService {
: {}),
},
});
if (autoComplete) {
await this.seedNextEpisodeContinue(profileId, body.episodeId);
}
return row;
}
/** Volgende speelbare aflevering (zelfde seizoen of volgend seizoen). */
async findNextPlayableEpisode(episodeId: string) {
const current = await prisma.episode.findUnique({ where: { id: episodeId } });
if (!current) return null;
const episodes = await prisma.episode.findMany({
where: { seriesId: current.seriesId },
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
include: {
mediaFiles: {
where: { available: true },
orderBy: { updatedAt: "desc" },
take: 1,
},
series: { select: { id: true, title: true, posterUrl: true, backdropUrl: true } },
},
});
const idx = episodes.findIndex((e) => e.id === episodeId);
if (idx < 0) return null;
for (let i = idx + 1; i < episodes.length; i++) {
const ep = episodes[i];
const file = ep.mediaFiles[0];
if (!file) continue;
return {
id: ep.id,
seriesId: ep.seriesId,
seasonNumber: ep.seasonNumber,
episodeNumber: ep.episodeNumber,
title: ep.title,
seriesTitle: ep.series.title,
posterUrl: ep.series.posterUrl || ep.stillUrl,
backdropUrl: ep.series.backdropUrl,
mediaFileId: file.id,
subtitle: `S${ep.seasonNumber}E${ep.episodeNumber}${ep.title ? ` · ${ep.title}` : ""}`,
};
}
return null;
}
/** Zet volgende aflevering in Verder kijken na afronden. */
async seedNextEpisodeContinue(profileId: string, completedEpisodeId: string) {
const next = await this.findNextPlayableEpisode(completedEpisodeId);
if (!next) return null;
const existing = await prisma.watchProgress.findUnique({
where: { profileId_episodeId: { profileId, episodeId: next.id } },
});
// Niet overschrijven als gebruiker al serieus bezig is met die aflevering
if (existing && !existing.completed && existing.positionMs > 30_000) {
return existing;
}
return prisma.watchProgress.upsert({
where: { profileId_episodeId: { profileId, episodeId: next.id } },
create: {
profileId,
mediaType: "EPISODE",
episodeId: next.id,
seriesId: next.seriesId,
positionMs: 0,
durationMs: 0,
completed: false,
hiddenFromContinue: false,
},
update: {
seriesId: next.seriesId,
completed: false,
hiddenFromContinue: false,
positionMs: existing && existing.positionMs > 0 && !existing.completed ? existing.positionMs : 0,
updatedAt: new Date(),
},
});
}
async markWatched(profileId: string, opts: { movieId?: string; episodeId?: string }) {
@ -1721,7 +1801,7 @@ export class ViewerService {
});
}
if (opts.episodeId) {
return this.upsertProgress(profileId, {
const row = await this.upsertProgress(profileId, {
mediaType: "EPISODE",
episodeId: opts.episodeId,
positionMs: 1,
@ -1729,6 +1809,7 @@ export class ViewerService {
completed: true,
hiddenFromContinue: true,
});
return row;
}
throw new AppError("INVALID_REQUEST", "movieId of episodeId verplicht", 400);
}