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:
parent
db8e3e572f
commit
84e09bdf36
10 changed files with 315 additions and 14 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 ?? ""}`;
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue