From 84e09bdf364c279059371c629812295c14b32567 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Thu, 17 Sep 2026 01:39:25 +0200 Subject: [PATCH] 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. --- apps/android-tv/app/build.gradle.kts | 4 +- .../mediacluster/tv/data/SessionStore.kt | 17 +++ .../mediacluster/tv/playback/PlayerFactory.kt | 21 ++-- .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 43 ++++++- .../vonas/mediacluster/tv/ui/PlayerScreen.kt | 21 +++- .../src/metadata/filename-parser.ts | 26 ++++ apps/master-api/src/metadata/omdb.ts | 29 ++++- apps/master-api/src/metadata/service.ts | 119 ++++++++++++++++++ node/media-node/internal/scanner/parser.go | 36 ++++++ .../internal/scanner/parser_test.go | 13 ++ 10 files changed, 315 insertions(+), 14 deletions(-) diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 0917e67..d4da596 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 36 - versionCode = 112 - versionName = "0.14.49" + versionCode = 113 + versionName = "0.14.50" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt index 7786ec1..3ce7cc5 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt @@ -25,6 +25,8 @@ class SessionStore(private val context: Context) { private val tvFavoritesKey = stringPreferencesKey("tv_favorites") private val tvLastCategoryKey = stringPreferencesKey("tv_last_category") private val tvLastStreamKey = intPreferencesKey("tv_last_stream") + /** Lokaal per device — niet gesynchroniseerd met account. */ + private val audioPassthroughKey = stringPreferencesKey("audio_passthrough") val refreshToken: Flow = context.dataStore.data.map { it[tokenKey] } val apiBase: Flow = context.dataStore.data.map { @@ -68,6 +70,15 @@ class SessionStore(private val context: Context) { 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 = context.dataStore.data.map { prefs -> + prefs[audioPassthroughKey] == "1" + } + suspend fun setToken(token: String?) { context.dataStore.edit { prefs -> 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 { if (raw.isNullOrBlank()) return emptySet() return runCatching { diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt index a56154f..786da7a 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt @@ -28,15 +28,20 @@ import okhttp3.OkHttpClient import java.util.concurrent.TimeUnit /** - * Hardware-video + FFmpeg-audio (prefer). - * DTS/TrueHD/E-AC3 falen vaak stil bij HDMI-passthrough naar een TV zonder decoder; - * FFmpeg decodeert naar PCM zodat geluid altijd via de TV/speakers werkt. + * Hardware-video + configureerbare audio. + * - PCM (standaard): FFmpeg prefer → decode naar PCM (veilig op TV zonder AVR-decoder). + * - Passthrough: MediaCodec first → HDMI bitstream als device/AVR dat ondersteunt; FFmpeg fallback. */ @OptIn(UnstableApi::class) -private class McRenderersFactory(context: Context) : NextRenderersFactory(context) { +private class McRenderersFactory( + context: Context, + private val audioPassthrough: Boolean, +) : NextRenderersFactory(context) { init { setEnableDecoderFallback(true) - setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON) + setExtensionRendererMode( + if (audioPassthrough) EXTENSION_RENDERER_MODE_ON else EXTENSION_RENDERER_MODE_PREFER, + ) } override fun buildAudioRenderers( @@ -51,7 +56,7 @@ private class McRenderersFactory(context: Context) : NextRenderersFactory(contex ) { super.buildAudioRenderers( context, - EXTENSION_RENDERER_MODE_PREFER, + if (audioPassthrough) EXTENSION_RENDERER_MODE_ON else EXTENSION_RENDERER_MODE_PREFER, mediaCodecSelector, enableDecoderFallback, audioSink, @@ -142,7 +147,7 @@ private class LiveRenderersFactory(context: Context) : NextRenderersFactory(cont } @OptIn(UnstableApi::class) -fun buildTvPlayer(context: Context): ExoPlayer { +fun buildTvPlayer(context: Context, audioPassthrough: Boolean = false): ExoPlayer { val trackSelector = DefaultTrackSelector(context).apply { parameters = buildUponParameters() .setExceedRendererCapabilitiesIfNecessary(true) @@ -153,7 +158,7 @@ fun buildTvPlayer(context: Context): ExoPlayer { .build() } - val renderersFactory = McRenderersFactory(context.applicationContext) + val renderersFactory = McRenderersFactory(context.applicationContext, audioPassthrough) val audioAttributes = AudioAttributes.Builder() .setUsage(C.USAGE_MEDIA) diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 55459e5..7b6e3f2 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -306,6 +306,7 @@ fun McNav() { composable("main") { MainHub( api = api, + store = store, tab = hubTab, onTabChange = { hubTabName = it.name }, homeListState = homeListState, @@ -466,6 +467,7 @@ fun McNav() { @Composable private fun MainHub( api: ApiClient, + store: SessionStore, tab: MainTab, onTabChange: (MainTab) -> Unit, homeListState: androidx.compose.foundation.lazy.LazyListState, @@ -828,6 +830,7 @@ private fun MainHub( ) MainTab.Settings -> SettingsScreen( api = api, + store = store, profileAvatarId = profileAvatarId, profileShowAvatar = profileShowAvatar, profileName = profileName, @@ -1689,6 +1692,7 @@ private fun ShelfBlock( @Composable private fun SettingsScreen( api: ApiClient, + store: SessionStore, profileAvatarId: String? = null, profileShowAvatar: Boolean = true, profileName: String? = null, @@ -1702,6 +1706,7 @@ private fun SettingsScreen( var error by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val firstFocus = remember { FocusRequester() } + val audioPassthrough by store.audioPassthrough.collectAsState(initial = false) LaunchedEffect(Unit) { try { @@ -1748,7 +1753,7 @@ private fun SettingsScreen( ) Spacer(Modifier.height(6.dp)) Text( - "Profiel, account en home-indeling", + "Profiel, account, afspelen en home-indeling", color = Color(0xFF8E8E9A), 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 { Spacer(Modifier.height(16.dp)) SectionLabel("Home-planken") @@ -2041,6 +2072,7 @@ private fun SettingsActionRow( title: String, subtitle: String, danger: Boolean = false, + trailing: String? = null, onClick: () -> Unit, ) { Surface( @@ -2069,6 +2101,15 @@ private fun SettingsActionRow( Spacer(Modifier.height(3.dp)) 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) } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt index 6e09b96..d9833ad 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt @@ -65,10 +65,12 @@ import androidx.tv.material3.Surface import androidx.tv.material3.Text import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import nl.vonas.mediacluster.tv.cast.BindCastPlayerRemote 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.playback.PlayerTrack import nl.vonas.mediacluster.tv.playback.applySideloadedSubtitle @@ -98,7 +100,24 @@ fun PlayerScreen( onPlayNext: ((mediaFileId: String, episodeId: String, seriesId: String) -> Unit)? = null, ) { val context = LocalContext.current - val player = remember { buildTvPlayer(context) } + val store = remember { SessionStore(context) } + var audioPassthrough by remember { mutableStateOf(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 catcherFocus = remember { FocusRequester() } val nextOverlayFocus = remember { FocusRequester() } diff --git a/apps/master-api/src/metadata/filename-parser.ts b/apps/master-api/src/metadata/filename-parser.ts index 327d3dc..059e1e2 100644 --- a/apps/master-api/src/metadata/filename-parser.ts +++ b/apps/master-api/src/metadata/filename-parser.ts @@ -97,6 +97,20 @@ function parseEpisodeNumber(base: string): number | 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 { const m = s.match(YEAR_PAREN); 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 season = seasonFromDir; const title = showFromDir || cleanTitle(stripYearParen(parent)); diff --git a/apps/master-api/src/metadata/omdb.ts b/apps/master-api/src/metadata/omdb.ts index a5aa827..b2fac60 100644 --- a/apps/master-api/src/metadata/omdb.ts +++ b/apps/master-api/src/metadata/omdb.ts @@ -130,16 +130,41 @@ export class OmdbClient { if (!/^tt\d+$/i.test(id)) return null; const data = (await this.get({ i: id, plot: "short" })) as OmdbTitleResponse | 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 { + const t = title.trim(); + if (!t) return null; + const params: Record = { + 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 = data.Genre && data.Genre !== "N/A" ? data.Genre.split(",").map((g) => g.trim()).filter(Boolean) : []; return { - imdbId: data.imdbID, + imdbId: data.imdbID!, title: data.Title?.trim() || "Onbekend", year: parseYear(data.Year), 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), genres, }; diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index 799f5f3..b3cf8d1 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -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) { 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 ( series?.tmdbId && 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 { + 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 { + 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 { + 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 { + 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) { if (!this.tmdbApiKey) return null; const cacheKey = `${title.toLowerCase()}::${year ?? ""}`; diff --git a/node/media-node/internal/scanner/parser.go b/node/media-node/internal/scanner/parser.go index a408b7b..db0a682 100644 --- a/node/media-node/internal/scanner/parser.go +++ b/node/media-node/internal/scanner/parser.go @@ -118,6 +118,27 @@ func parseSeriesFromPath(path string) map[string]interface{} { 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) season := seasonFromDir title := showFromDir @@ -196,6 +217,21 @@ func parseEpisodeNumber(base string) int { 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 { if m := yearParenRe.FindStringSubmatch(s); len(m) >= 2 { return m[1] diff --git a/node/media-node/internal/scanner/parser_test.go b/node/media-node/internal/scanner/parser_test.go index 5ad3a6f..bbd7e7d 100644 --- a/node/media-node/internal/scanner/parser_test.go +++ b/node/media-node/internal/scanner/parser_test.go @@ -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) { if parseMovieFromPath(`/media/movies/Foo (2020)/Foo-sample.mkv`) != nil { t.Fatal("sample should be skipped")