Fix Plex-style episode parsing and add OMDb scan matching plus TV audio passthrough.

Recognize S10E01 Title.mkv under Season folders, fall back to OMDb when TMDB misses, and let Android TV choose PCM vs HDMI passthrough per device.
This commit is contained in:
Jos Vooges | STH 2026-09-17 01:39:25 +02:00
parent db8e3e572f
commit 84e09bdf36
10 changed files with 315 additions and 14 deletions

View file

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

View file

@ -25,6 +25,8 @@ class SessionStore(private val context: Context) {
private val tvFavoritesKey = stringPreferencesKey("tv_favorites") private val tvFavoritesKey = stringPreferencesKey("tv_favorites")
private val tvLastCategoryKey = stringPreferencesKey("tv_last_category") private val tvLastCategoryKey = stringPreferencesKey("tv_last_category")
private val tvLastStreamKey = intPreferencesKey("tv_last_stream") private val tvLastStreamKey = intPreferencesKey("tv_last_stream")
/** Lokaal per device — niet gesynchroniseerd met account. */
private val audioPassthroughKey = stringPreferencesKey("audio_passthrough")
val refreshToken: Flow<String?> = context.dataStore.data.map { it[tokenKey] } val refreshToken: Flow<String?> = context.dataStore.data.map { it[tokenKey] }
val apiBase: Flow<String> = context.dataStore.data.map { val apiBase: Flow<String> = context.dataStore.data.map {
@ -68,6 +70,15 @@ class SessionStore(private val context: Context) {
prefs[tvLastStreamKey] prefs[tvLastStreamKey]
} }
/**
* Audio voor films/series: false = FFmpeg→PCM (standaard, veilig),
* true = MediaCodec-first (HDMI bitstream/passthrough als device+AVR dat kunnen).
* Alleen op dit apparaat; overleeft login/logout.
*/
val audioPassthrough: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[audioPassthroughKey] == "1"
}
suspend fun setToken(token: String?) { suspend fun setToken(token: String?) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
if (token.isNullOrBlank()) { if (token.isNullOrBlank()) {
@ -169,6 +180,12 @@ class SessionStore(private val context: Context) {
} }
} }
suspend fun setAudioPassthrough(enabled: Boolean) {
context.dataStore.edit { prefs ->
prefs[audioPassthroughKey] = if (enabled) "1" else "0"
}
}
private fun parseIntSet(raw: String?): Set<Int> { private fun parseIntSet(raw: String?): Set<Int> {
if (raw.isNullOrBlank()) return emptySet() if (raw.isNullOrBlank()) return emptySet()
return runCatching { return runCatching {

View file

@ -28,15 +28,20 @@ import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
* Hardware-video + FFmpeg-audio (prefer). * Hardware-video + configureerbare audio.
* DTS/TrueHD/E-AC3 falen vaak stil bij HDMI-passthrough naar een TV zonder decoder; * - PCM (standaard): FFmpeg prefer → decode naar PCM (veilig op TV zonder AVR-decoder).
* FFmpeg decodeert naar PCM zodat geluid altijd via de TV/speakers werkt. * - Passthrough: MediaCodec first → HDMI bitstream als device/AVR dat ondersteunt; FFmpeg fallback.
*/ */
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private class McRenderersFactory(context: Context) : NextRenderersFactory(context) { private class McRenderersFactory(
context: Context,
private val audioPassthrough: Boolean,
) : NextRenderersFactory(context) {
init { init {
setEnableDecoderFallback(true) setEnableDecoderFallback(true)
setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON) setExtensionRendererMode(
if (audioPassthrough) EXTENSION_RENDERER_MODE_ON else EXTENSION_RENDERER_MODE_PREFER,
)
} }
override fun buildAudioRenderers( override fun buildAudioRenderers(
@ -51,7 +56,7 @@ private class McRenderersFactory(context: Context) : NextRenderersFactory(contex
) { ) {
super.buildAudioRenderers( super.buildAudioRenderers(
context, context,
EXTENSION_RENDERER_MODE_PREFER, if (audioPassthrough) EXTENSION_RENDERER_MODE_ON else EXTENSION_RENDERER_MODE_PREFER,
mediaCodecSelector, mediaCodecSelector,
enableDecoderFallback, enableDecoderFallback,
audioSink, audioSink,
@ -142,7 +147,7 @@ private class LiveRenderersFactory(context: Context) : NextRenderersFactory(cont
} }
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
fun buildTvPlayer(context: Context): ExoPlayer { fun buildTvPlayer(context: Context, audioPassthrough: Boolean = false): ExoPlayer {
val trackSelector = DefaultTrackSelector(context).apply { val trackSelector = DefaultTrackSelector(context).apply {
parameters = buildUponParameters() parameters = buildUponParameters()
.setExceedRendererCapabilitiesIfNecessary(true) .setExceedRendererCapabilitiesIfNecessary(true)
@ -153,7 +158,7 @@ fun buildTvPlayer(context: Context): ExoPlayer {
.build() .build()
} }
val renderersFactory = McRenderersFactory(context.applicationContext) val renderersFactory = McRenderersFactory(context.applicationContext, audioPassthrough)
val audioAttributes = AudioAttributes.Builder() val audioAttributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA) .setUsage(C.USAGE_MEDIA)

View file

@ -306,6 +306,7 @@ fun McNav() {
composable("main") { composable("main") {
MainHub( MainHub(
api = api, api = api,
store = store,
tab = hubTab, tab = hubTab,
onTabChange = { hubTabName = it.name }, onTabChange = { hubTabName = it.name },
homeListState = homeListState, homeListState = homeListState,
@ -466,6 +467,7 @@ fun McNav() {
@Composable @Composable
private fun MainHub( private fun MainHub(
api: ApiClient, api: ApiClient,
store: SessionStore,
tab: MainTab, tab: MainTab,
onTabChange: (MainTab) -> Unit, onTabChange: (MainTab) -> Unit,
homeListState: androidx.compose.foundation.lazy.LazyListState, homeListState: androidx.compose.foundation.lazy.LazyListState,
@ -828,6 +830,7 @@ private fun MainHub(
) )
MainTab.Settings -> SettingsScreen( MainTab.Settings -> SettingsScreen(
api = api, api = api,
store = store,
profileAvatarId = profileAvatarId, profileAvatarId = profileAvatarId,
profileShowAvatar = profileShowAvatar, profileShowAvatar = profileShowAvatar,
profileName = profileName, profileName = profileName,
@ -1689,6 +1692,7 @@ private fun ShelfBlock(
@Composable @Composable
private fun SettingsScreen( private fun SettingsScreen(
api: ApiClient, api: ApiClient,
store: SessionStore,
profileAvatarId: String? = null, profileAvatarId: String? = null,
profileShowAvatar: Boolean = true, profileShowAvatar: Boolean = true,
profileName: String? = null, profileName: String? = null,
@ -1702,6 +1706,7 @@ private fun SettingsScreen(
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }
val audioPassthrough by store.audioPassthrough.collectAsState(initial = false)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
try { try {
@ -1748,7 +1753,7 @@ private fun SettingsScreen(
) )
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text( Text(
"Profiel, account en home-indeling", "Profiel, account, afspelen en home-indeling",
color = Color(0xFF8E8E9A), color = Color(0xFF8E8E9A),
fontSize = 15.sp, fontSize = 15.sp,
) )
@ -1867,6 +1872,32 @@ private fun SettingsScreen(
} }
} }
item {
Spacer(Modifier.height(16.dp))
SectionLabel("Afspelen")
Spacer(Modifier.height(6.dp))
Text(
"Alleen op dit apparaat — niet gekoppeld aan je account.",
color = Color(0xFF8E8E9A),
fontSize = 14.sp,
)
Spacer(Modifier.height(10.dp))
SettingsActionRow(
title = "Audio-uitgang",
subtitle = if (audioPassthrough) {
"Passthrough — bitstream naar AVR (DTS / Dolby 5.1). Tik om naar PCM te gaan."
} else {
"Compatibiliteit (PCM) — standaard. Tik voor HDMI-passthrough."
},
trailing = if (audioPassthrough) "Passthrough" else "PCM",
onClick = {
scope.launch {
store.setAudioPassthrough(!audioPassthrough)
}
},
)
}
item { item {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
SectionLabel("Home-planken") SectionLabel("Home-planken")
@ -2041,6 +2072,7 @@ private fun SettingsActionRow(
title: String, title: String,
subtitle: String, subtitle: String,
danger: Boolean = false, danger: Boolean = false,
trailing: String? = null,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
Surface( Surface(
@ -2069,6 +2101,15 @@ private fun SettingsActionRow(
Spacer(Modifier.height(3.dp)) Spacer(Modifier.height(3.dp))
Text(subtitle, color = Color(0xFF8E8E9A), fontSize = 13.sp) Text(subtitle, color = Color(0xFF8E8E9A), fontSize = 13.sp)
} }
if (trailing != null) {
Text(
trailing,
color = McColors.Brand,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(end = 10.dp),
)
}
Text("›", color = Color(0xFF888888), fontSize = 26.sp, fontWeight = FontWeight.Light) Text("›", color = Color(0xFF888888), fontSize = 26.sp, fontWeight = FontWeight.Light)
} }
} }

View file

@ -65,10 +65,12 @@ import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.cast.BindCastPlayerRemote import nl.vonas.mediacluster.tv.cast.BindCastPlayerRemote
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.data.SubtitleHit import nl.vonas.mediacluster.tv.data.SubtitleHit
import nl.vonas.mediacluster.tv.playback.PlayerTrack import nl.vonas.mediacluster.tv.playback.PlayerTrack
import nl.vonas.mediacluster.tv.playback.applySideloadedSubtitle import nl.vonas.mediacluster.tv.playback.applySideloadedSubtitle
@ -98,7 +100,24 @@ fun PlayerScreen(
onPlayNext: ((mediaFileId: String, episodeId: String, seriesId: String) -> Unit)? = null, onPlayNext: ((mediaFileId: String, episodeId: String, seriesId: String) -> Unit)? = null,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val player = remember { buildTvPlayer(context) } val store = remember { SessionStore(context) }
var audioPassthrough by remember { mutableStateOf<Boolean?>(null) }
LaunchedEffect(Unit) {
audioPassthrough = store.audioPassthrough.first()
}
val passthroughMode = audioPassthrough
if (passthroughMode == null) {
Box(
Modifier.fillMaxSize().background(Color.Black),
contentAlignment = Alignment.Center,
) {
Text("Laden…", color = Color.White, fontSize = 16.sp)
}
return
}
val player = remember(passthroughMode) {
buildTvPlayer(context, audioPassthrough = passthroughMode)
}
val controlsFocus = remember { FocusRequester() } val controlsFocus = remember { FocusRequester() }
val catcherFocus = remember { FocusRequester() } val catcherFocus = remember { FocusRequester() }
val nextOverlayFocus = remember { FocusRequester() } val nextOverlayFocus = remember { FocusRequester() }

View file

@ -97,6 +97,20 @@ function parseEpisodeNumber(base: string): number | undefined {
return undefined; return undefined;
} }
/** Plex-style: "S10E01 The Night Owl" (geen show-naam vóór SxxExx). */
function parseLeadingSeasonEpisode(
base: string
): { season: number; episode: number } | undefined {
const m = base.match(/^S(\d{1,2})E(\d{1,3})(?=$|[\.\s_-])/i);
if (!m) return undefined;
const season = parseInt(m[1], 10);
const episode = parseInt(m[2], 10);
if (!Number.isFinite(season) || !Number.isFinite(episode) || season <= 0 || episode <= 0) {
return undefined;
}
return { season, episode };
}
function stripYearParen(s: string): string { function stripYearParen(s: string): string {
const m = s.match(YEAR_PAREN); const m = s.match(YEAR_PAREN);
return m ? m[1] : s; return m ? m[1] : s;
@ -142,6 +156,18 @@ export function parseSeriesFromPath(filePath: string): ParsedSeriesFilename | nu
}; };
} }
// Plex: Show/Season 10/S10E01 The Night Owl.mkv (titel komt uit de map, niet uit de afleveringstitel)
const leading = parseLeadingSeasonEpisode(base);
if (leading) {
const season = seasonFromDir ?? leading.season;
const title =
showFromDir ||
(!seasonFromDir ? cleanTitle(stripYearParen(parent)) : undefined);
if (title && season > 0) {
return { title, season, episode: leading.episode, ...extractTags(base) };
}
}
const episode = parseEpisodeNumber(base); const episode = parseEpisodeNumber(base);
const season = seasonFromDir; const season = seasonFromDir;
const title = showFromDir || cleanTitle(stripYearParen(parent)); const title = showFromDir || cleanTitle(stripYearParen(parent));

View file

@ -130,16 +130,41 @@ export class OmdbClient {
if (!/^tt\d+$/i.test(id)) return null; if (!/^tt\d+$/i.test(id)) return null;
const data = (await this.get({ i: id, plot: "short" })) as OmdbTitleResponse | null; const data = (await this.get({ i: id, plot: "short" })) as OmdbTitleResponse | null;
if (!data || data.Response === "False" || !data.imdbID) return null; if (!data || data.Response === "False" || !data.imdbID) return null;
return this.mapTitle(data);
}
/** Exacte titel-lookup (scanner) — beter dan Search voor Serienaam-map. */
async getByTitle(
title: string,
type: "movie" | "series",
year?: number | null
): Promise<OmdbTitle | null> {
const t = title.trim();
if (!t) return null;
const params: Record<string, string> = {
t,
type: type === "series" ? "series" : "movie",
plot: "short",
};
if (year && Number.isFinite(year) && year > 1800) {
params.y = String(year);
}
const data = (await this.get(params)) as OmdbTitleResponse | null;
if (!data || data.Response === "False" || !data.imdbID) return null;
return this.mapTitle(data);
}
private mapTitle(data: OmdbTitleResponse): OmdbTitle {
const genres = const genres =
data.Genre && data.Genre !== "N/A" data.Genre && data.Genre !== "N/A"
? data.Genre.split(",").map((g) => g.trim()).filter(Boolean) ? data.Genre.split(",").map((g) => g.trim()).filter(Boolean)
: []; : [];
return { return {
imdbId: data.imdbID, imdbId: data.imdbID!,
title: data.Title?.trim() || "Onbekend", title: data.Title?.trim() || "Onbekend",
year: parseYear(data.Year), year: parseYear(data.Year),
overview: data.Plot && data.Plot !== "N/A" ? data.Plot.trim() : null, overview: data.Plot && data.Plot !== "N/A" ? data.Plot.trim() : null,
posterUrl: this.resolvePosterUrl(data.imdbID, data.Poster), posterUrl: this.resolvePosterUrl(data.imdbID!, data.Poster),
type: mapType(data.Type), type: mapType(data.Type),
genres, genres,
}; };

View file

@ -673,6 +673,12 @@ export class MetadataService {
} }
} }
// OMDb/IMDb fallback (vaak betere dekking voor obscure/IMDb-only titles).
if (!movie) {
movie = await this.matchMovieViaOmdb(title, year);
if (movie) await this.absorbOrphanMovies(movie, title, year);
}
if (!movie) { if (!movie) {
movie = await this.findExistingMovie(title, year); movie = await this.findExistingMovie(title, year);
} }
@ -743,6 +749,11 @@ export class MetadataService {
} }
} }
if (!series) {
series = await this.matchSeriesViaOmdb(title);
if (series) await this.absorbOrphanSeries(series, title);
}
if ( if (
series?.tmdbId && series?.tmdbId &&
this.tmdbApiKey && this.tmdbApiKey &&
@ -2544,6 +2555,114 @@ export class MetadataService {
}); });
} }
/** OMDb titel-match voor scanner (na TMDB). Alleen bij confident title match. */
private async matchMovieViaOmdb(
title: string,
year?: number | null
): Promise<MovieRow | null> {
const omdbCli = await this.omdbClient();
if (!omdbCli) return null;
let hit = await omdbCli.getByTitle(title, "movie", year ?? undefined);
if (!hit || !seriesTitlesMatchConfident(title, hit.title)) {
const search = await omdbCli.search(title, "movie");
const best = search.find(
(h) =>
seriesTitlesMatchConfident(title, h.title) &&
(year == null || h.year == null || Math.abs(h.year - year) <= 1)
);
if (!best) return null;
hit = await omdbCli.getByImdbId(best.imdbId);
}
if (!hit || hit.type === "series" || hit.type === "episode") return null;
if (!seriesTitlesMatchConfident(title, hit.title)) return null;
return this.upsertMovieFromOmdbTitle(hit);
}
private async matchSeriesViaOmdb(title: string): Promise<SeriesRow | null> {
const omdbCli = await this.omdbClient();
if (!omdbCli) return null;
let hit = await omdbCli.getByTitle(title, "series");
if (!hit || !seriesTitlesMatchConfident(title, hit.title)) {
const search = await omdbCli.search(title, "series");
const best = search.find((h) => seriesTitlesMatchConfident(title, h.title));
if (!best) return null;
hit = await omdbCli.getByImdbId(best.imdbId);
}
if (!hit || (hit.type !== "series" && hit.type !== "other")) return null;
if (!seriesTitlesMatchConfident(title, hit.title)) return null;
return this.upsertSeriesFromOmdbTitle(hit);
}
private async upsertMovieFromOmdbTitle(omdb: {
imdbId: string;
title: string;
year: number | null;
overview: string | null;
posterUrl: string | null;
genres: string[];
}): Promise<MovieRow> {
const data = {
title: omdb.title,
year: omdb.year,
overview: omdb.overview,
posterUrl: omdb.posterUrl,
imdbId: omdb.imdbId,
...(omdb.genres.length ? { genres: omdb.genres } : {}),
};
try {
return await prisma.movie.upsert({
where: { imdbId: omdb.imdbId },
create: data,
update: data,
});
} catch {
const existing = await prisma.movie.findFirst({
where: { OR: [{ imdbId: omdb.imdbId }, { title: { equals: omdb.title, mode: "insensitive" } }] },
});
if (existing) {
return prisma.movie.update({ where: { id: existing.id }, data });
}
throw new Error(`OMDb movie upsert failed for ${omdb.imdbId}`);
}
}
private async upsertSeriesFromOmdbTitle(omdb: {
imdbId: string;
title: string;
year: number | null;
overview: string | null;
posterUrl: string | null;
genres: string[];
}): Promise<SeriesRow> {
const data = {
title: omdb.title,
year: omdb.year,
overview: omdb.overview,
posterUrl: omdb.posterUrl,
imdbId: omdb.imdbId,
...(omdb.genres.length ? { genres: omdb.genres } : {}),
};
try {
return await prisma.series.upsert({
where: { imdbId: omdb.imdbId },
create: data,
update: data,
});
} catch {
const existing = await prisma.series.findFirst({
where: { OR: [{ imdbId: omdb.imdbId }, { title: { equals: omdb.title, mode: "insensitive" } }] },
});
if (existing) {
return prisma.series.update({ where: { id: existing.id }, data });
}
throw new Error(`OMDb series upsert failed for ${omdb.imdbId}`);
}
}
private async searchTmdbMovie(title: string, year?: number) { private async searchTmdbMovie(title: string, year?: number) {
if (!this.tmdbApiKey) return null; if (!this.tmdbApiKey) return null;
const cacheKey = `${title.toLowerCase()}::${year ?? ""}`; const cacheKey = `${title.toLowerCase()}::${year ?? ""}`;

View file

@ -118,6 +118,27 @@ func parseSeriesFromPath(path string) map[string]interface{} {
return fromFile return fromFile
} }
// Plex: Show/Season 10/S10E01 The Night Owl.mkv
if seas, ep, ok := parseLeadingSeasonEpisode(base); ok {
season := seasonFromDir
if season <= 0 {
season = seas
}
title := showFromDir
if title == "" && seasonFromDir <= 0 {
title = cleanTitle(stripYearParen(parent))
}
if title != "" && season > 0 && ep > 0 {
result := map[string]interface{}{
"title": title,
"season": season,
"episode": ep,
}
attachTags(result, base)
return result
}
}
episode := parseEpisodeNumber(base) episode := parseEpisodeNumber(base)
season := seasonFromDir season := seasonFromDir
title := showFromDir title := showFromDir
@ -196,6 +217,21 @@ func parseEpisodeNumber(base string) int {
return 0 return 0
} }
// parseLeadingSeasonEpisode: "S10E01 The Night Owl" (geen show-naam vóór SxxExx).
func parseLeadingSeasonEpisode(base string) (season, episode int, ok bool) {
re := regexp.MustCompile(`(?i)^S(\d{1,2})E(\d{1,3})(?:$|[\.\s_-])`)
m := re.FindStringSubmatch(base)
if len(m) < 3 {
return 0, 0, false
}
season = atoi(m[1])
episode = atoi(m[2])
if season <= 0 || episode <= 0 {
return 0, 0, false
}
return season, episode, true
}
func stripYearParen(s string) string { func stripYearParen(s string) string {
if m := yearParenRe.FindStringSubmatch(s); len(m) >= 2 { if m := yearParenRe.FindStringSubmatch(s); len(m) >= 2 {
return m[1] return m[1]

View file

@ -87,6 +87,19 @@ func TestParseSeriesSeasonFolderEpisodeOnly(t *testing.T) {
} }
} }
func TestParseSeriesPlexLeadingSxxExx(t *testing.T) {
p := parseSeriesFromPath(`/media/series/The Blacklist/Season 10/S10E01 The Night Owl.mkv`)
if p == nil {
t.Fatal("expected parse result")
}
if p["title"] != "The Blacklist" {
t.Fatalf("title=%v", p["title"])
}
if p["season"] != 10 || p["episode"] != 1 {
t.Fatalf("season/episode=%v/%v", p["season"], p["episode"])
}
}
func TestSkipSample(t *testing.T) { func TestSkipSample(t *testing.T) {
if parseMovieFromPath(`/media/movies/Foo (2020)/Foo-sample.mkv`) != nil { if parseMovieFromPath(`/media/movies/Foo (2020)/Foo-sample.mkv`) != nil {
t.Fatal("sample should be skipped") t.Fatal("sample should be skipped")