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")