From d044106f7770c5c3608e5c68feba25aab96a9c96 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 1 Sep 2026 14:51:26 +0200 Subject: [PATCH] Fix slow library repair and add TV quality badges on detail pages. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch-merge duplicates instead of O(n²) scans, prune duplicate file rows, cap IMDb backfill per run, and improve admin dashboard feedback. Android TV shows compact quality logos and version picker on movie details (0.8.2). Co-authored-by: Cursor --- apps/admin-ui/src/app/dashboard/page.tsx | 124 ++++--- apps/android-tv/app/build.gradle.kts | 4 +- .../vonas/mediacluster/tv/ui/DetailScreens.kt | 132 +++++-- .../tv/ui/components/QualityBadges.kt | 338 ++++++++++++++++++ apps/master-api/src/admin/routes.ts | 8 +- apps/master-api/src/metadata/service.ts | 304 ++++++++++++---- 6 files changed, 761 insertions(+), 149 deletions(-) create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/QualityBadges.kt diff --git a/apps/admin-ui/src/app/dashboard/page.tsx b/apps/admin-ui/src/app/dashboard/page.tsx index 6256b2d..d15e547 100644 --- a/apps/admin-ui/src/app/dashboard/page.tsx +++ b/apps/admin-ui/src/app/dashboard/page.tsx @@ -50,6 +50,9 @@ export default function DashboardPage() { const [dedupeMsg, setDedupeMsg] = useState(""); const [imdbMsg, setImdbMsg] = useState(""); const [repairMsg, setRepairMsg] = useState(""); + const [repairBusy, setRepairBusy] = useState(false); + const [dedupeBusy, setDedupeBusy] = useState(false); + const [imdbBusy, setImdbBusy] = useState(false); const load = useCallback(() => { fetch("/api/v1/admin/dashboard", { credentials: "include" }) @@ -73,26 +76,39 @@ export default function DashboardPage() { async function repairLibrary() { if ( !confirm( - "Bibliotheek herstellen na pad-migratie?\n\n• Duplicaten samenvoegen naar IMDb/TMDB-records\n• Dode (unavailable) bestanden verwijderen\n• Ontbrekende IMDb-IDs backfillen\n\nDit kan enkele minuten duren." + "Bibliotheek herstellen na pad-migratie?\n\n• Duplicaten samenvoegen naar IMDb/TMDB-records\n• Dode (unavailable) bestanden verwijderen\n• Dubbele file-rijen opruimen\n• Ontbrekende IMDb-IDs backfillen (max ~400 per run)\n\nDuurt meestal 1–3 minuten. Herhaal indien nodig." ) ) { return; } - setRepairMsg("Bezig…"); - const res = await fetch("/api/v1/admin/library/repair", { - method: "POST", - credentials: "include", - }); - const json = await res.json(); - if (!res.ok) { - setRepairMsg(json.error?.message ?? "Mislukt"); - return; + setRepairBusy(true); + setRepairMsg("Bezig — duplicaten en dode files opruimen…"); + try { + const res = await fetch("/api/v1/admin/library/repair", { + method: "POST", + credentials: "include", + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + setRepairMsg(json.error?.message ?? `Mislukt (HTTP ${res.status})`); + return; + } + const imdb = json.imdb ?? {}; + const imdbRemaining = + (imdb.movies?.remaining ?? 0) + (imdb.series?.remaining ?? 0); + setRepairMsg( + `Klaar: ${json.moviesMerged ?? 0} film-merges, ${json.seriesMerged ?? 0} serie-merges, ${json.staleFilesRemoved ?? 0} dode files, ${json.duplicateFilesRemoved ?? 0} dubbele files · IMDb +${imdb.movies?.updated ?? 0} films, +${imdb.series?.updated ?? 0} series${imdbRemaining > 0 ? ` · nog ${imdbRemaining} IMDb open (herhaal of gebruik backfill-knop)` : ""}. Herlaad daarna de Stremio-addon.` + ); + load(); + } catch (err) { + setRepairMsg( + err instanceof Error + ? `Verbinding mislukt: ${err.message}` + : "Verbinding mislukt — timeout of server offline?" + ); + } finally { + setRepairBusy(false); } - const imdb = json.imdb ?? {}; - setRepairMsg( - `Klaar: ${json.moviesMerged ?? 0} film-merges, ${json.seriesMerged ?? 0} serie-merges, ${json.staleFilesRemoved ?? 0} dode files weg · IMDb films +${imdb.movies?.updated ?? 0}, series +${imdb.series?.updated ?? 0}. Herlaad daarna de Stremio-addon.` - ); - load(); } async function backfillImdb() { @@ -103,22 +119,29 @@ export default function DashboardPage() { ) { return; } + setImdbBusy(true); setImdbMsg("Bezig…"); - const res = await fetch("/api/v1/admin/library/backfill-imdb", { - method: "POST", - credentials: "include", - }); - const json = await res.json(); - if (!res.ok) { - setImdbMsg(json.error?.message ?? "Mislukt"); - return; + try { + const res = await fetch("/api/v1/admin/library/backfill-imdb", { + method: "POST", + credentials: "include", + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + setImdbMsg(json.error?.message ?? "Mislukt"); + return; + } + const movies = json.movies ?? json; + const series = json.series ?? {}; + setImdbMsg( + `Films: gecontroleerd ${movies.checked ?? 0}, bijgewerkt ${movies.updated ?? 0}, open ${movies.remaining ?? 0} · Series: gecontroleerd ${series.checked ?? 0}, bijgewerkt ${series.updated ?? 0}, open ${series.remaining ?? 0}` + ); + load(); + } catch (err) { + setImdbMsg(err instanceof Error ? `Mislukt: ${err.message}` : "Mislukt"); + } finally { + setImdbBusy(false); } - const movies = json.movies ?? json; - const series = json.series ?? {}; - setImdbMsg( - `Films: gecontroleerd ${movies.checked ?? 0}, bijgewerkt ${movies.updated ?? 0}, open ${movies.remaining ?? 0} · Series: gecontroleerd ${series.checked ?? 0}, bijgewerkt ${series.updated ?? 0}, open ${series.remaining ?? 0}` - ); - load(); } async function dedupe() { @@ -129,20 +152,27 @@ export default function DashboardPage() { ) { return; } + setDedupeBusy(true); setDedupeMsg("Bezig…"); - const res = await fetch("/api/v1/admin/library/dedupe", { - method: "POST", - credentials: "include", - }); - const json = await res.json(); - if (!res.ok) { - setDedupeMsg(json.error?.message ?? "Mislukt"); - return; + try { + const res = await fetch("/api/v1/admin/library/dedupe", { + method: "POST", + credentials: "include", + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + setDedupeMsg(json.error?.message ?? "Mislukt"); + return; + } + setDedupeMsg( + `Klaar: ${json.moviesMerged} film-duplicaten, ${json.seriesMerged} serie-duplicaten, ${json.emptied} lege records opgeruimd.` + ); + load(); + } catch (err) { + setDedupeMsg(err instanceof Error ? `Mislukt: ${err.message}` : "Mislukt"); + } finally { + setDedupeBusy(false); } - setDedupeMsg( - `Klaar: ${json.moviesMerged} film-duplicaten, ${json.seriesMerged} serie-duplicaten, ${json.emptied} lege records opgeruimd.` - ); - load(); } return ( @@ -234,16 +264,16 @@ export default function DashboardPage() {

Duplicaten + dode files + IMDb-backfill na Unraid pad-migratie.

- {repairMsg &&

{repairMsg}

}

Duplicaten

Zelfde titel samenvoegen (TMDB wint).

- {dedupeMsg &&

{dedupeMsg}

}
@@ -254,8 +284,8 @@ export default function DashboardPage() { {data.moviesMissingImdb ?? "?"} · Series: {data.seriesWithImdb ?? "?"} / zonder{" "} {data.seriesMissingImdb ?? "?"}

- {imdbMsg &&

{imdbMsg}

} diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 48fb57d..0fb73b3 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 35 - versionCode = 17 - versionName = "0.8.0" + versionCode = 19 + versionName = "0.8.2" 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/ui/DetailScreens.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt index 91af488..ca315bd 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt @@ -4,6 +4,8 @@ import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri import androidx.activity.compose.BackHandler +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -55,6 +57,10 @@ import coil.compose.AsyncImage import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.ui.components.MediaVersion +import nl.vonas.mediacluster.tv.ui.components.SelectedQualityLine +import nl.vonas.mediacluster.tv.ui.components.VersionQualityMark +import nl.vonas.mediacluster.tv.ui.components.parseMediaVersion import nl.vonas.mediacluster.tv.ui.theme.McColors import org.json.JSONArray import org.json.JSONObject @@ -270,9 +276,9 @@ private fun DetailActionButton( ) { Text( text = label, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), color = Color.Unspecified, - fontSize = 14.sp, + fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, ) @@ -300,10 +306,12 @@ fun MovieDetailScreen( var cast by remember { mutableStateOf>(emptyList()) } var director by remember { mutableStateOf>(emptyList()) } var trailerKey by remember { mutableStateOf(null) } - var files by remember { mutableStateOf>>(emptyList()) } + var versions by remember { mutableStateOf>(emptyList()) } + var selectedVersionId by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val playFocus = remember { FocusRequester() } + val scroll = rememberScrollState() LaunchedEffect(movieId) { try { @@ -321,41 +329,43 @@ fun MovieDetailScreen( director = o.optJSONArray("director").stringList() trailerKey = o.optCleanString("trailerYoutubeKey") val arr = o.optJSONArray("mediaFiles") - val list = mutableListOf>() + val list = mutableListOf() if (arr != null) { for (i in 0 until arr.length()) { val f = arr.getJSONObject(i) - val fromApi = f.optCleanString("label") - val label = fromApi ?: buildList { - f.optCleanString("resolution")?.let(::add) - f.optCleanString("hdrType")?.let(::add) - f.optCleanString("videoCodec")?.let(::add) - f.optCleanString("audioCodec")?.let(::add) - f.optCleanString("releaseName")?.let { if (isEmpty()) add(it.take(42)) } - f.optCleanString("nodeName")?.let { if (isEmpty()) add(it) } - }.joinToString(" · ").ifBlank { "Versie ${i + 1}" } - list += f.getString("id") to label + list += parseMediaVersion( + id = f.getString("id"), + resolution = f.optCleanString("resolution"), + hdrType = f.optCleanString("hdrType"), + audioCodec = f.optCleanString("audioCodec"), + videoCodec = f.optCleanString("videoCodec"), + releaseName = f.optCleanString("releaseName"), + label = f.optCleanString("label"), + ) } } - files = list + versions = list.sortedByDescending { it.rank } + selectedVersionId = versions.firstOrNull()?.id } catch (e: Exception) { error = e.message } } - LaunchedEffect(files) { - if (files.isNotEmpty()) { + LaunchedEffect(versions) { + if (versions.isNotEmpty()) { delay(80) runCatching { playFocus.requestFocus() } } } + val selected = versions.firstOrNull { it.id == selectedVersionId } ?: versions.firstOrNull() val metaParts = buildList { year?.let { add(it.toString()) } formatRuntime(runtime)?.let(::add) - genres.take(3).forEach(::add) + genres.take(2).forEach(::add) rating?.let { add("★ $it") } } + val playLabel = selected?.let { "Afspelen · ${it.shortLabel}" } ?: "Afspelen" BackHandler(onBack = onBack) Box(Modifier.fillMaxSize()) { @@ -364,13 +374,15 @@ fun MovieDetailScreen( Modifier .fillMaxHeight() .fillMaxWidth(0.58f) - .padding(start = 44.dp, end = 20.dp, top = 28.dp, bottom = 28.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .padding(start = 44.dp, end = 20.dp, top = 24.dp, bottom = 24.dp) + .verticalScroll(scroll), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { DetailBackButton(onClick = onBack) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(4.dp)) DetailLogoOrTitle(logoUrl, title) MetaChips(metaParts) + SelectedQualityLine(selected) if (director.isNotEmpty()) { Text( "Regie: ${director.take(2).joinToString(", ")}", @@ -385,26 +397,26 @@ fun MovieDetailScreen( overview, color = McColors.TextDim, fontSize = 15.sp, - maxLines = 5, + maxLines = 4, overflow = TextOverflow.Ellipsis, - lineHeight = 22.sp, + lineHeight = 21.sp, ) } if (cast.isNotEmpty()) { Text( - "Met: ${cast.take(5).joinToString(", ")}", + "Met: ${cast.take(4).joinToString(", ")}", color = Color(0xFF8A8A8A), fontSize = 13.sp, - maxLines = 2, + maxLines = 1, overflow = TextOverflow.Ellipsis, ) } if (error != null) Text(error!!, color = McColors.Danger) Spacer(Modifier.height(4.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - files.firstOrNull()?.let { (id, _) -> - DetailActionButton("Afspelen", primary = true, focusRequester = playFocus) { - onPlay(id, title, 0) + selected?.let { ver -> + DetailActionButton(playLabel, primary = true, focusRequester = playFocus) { + onPlay(ver.id, title, 0) } } if (!trailerKey.isNullOrBlank()) { @@ -419,14 +431,25 @@ fun MovieDetailScreen( } } } - if (files.size > 1) { - Spacer(Modifier.height(4.dp)) - Text("Versies", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(files) { (id, label) -> - DetailActionButton(label.take(36), primary = id == files.first().first) { - onPlay(id, title, 0) - } + if (versions.size > 1) { + Spacer(Modifier.height(2.dp)) + Text( + "Kwaliteit", + color = Color(0xFF8F8F8F), + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.6.sp, + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(end = 8.dp, bottom = 10.dp), + ) { + items(versions, key = { it.id }) { ver -> + VersionPickChip( + version = ver, + selected = ver.id == selected?.id, + onClick = { selectedVersionId = ver.id }, + ) } } } @@ -449,6 +472,43 @@ fun MovieDetailScreen( } } +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun VersionPickChip( + version: MediaVersion, + selected: Boolean, + onClick: () -> Unit, +) { + val shape = RoundedCornerShape(5.dp) + Surface( + onClick = onClick, + shape = ClickableSurfaceDefaults.shape(shape = shape), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), + glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)), + colors = ClickableSurfaceDefaults.colors( + containerColor = if (selected) Color(0xEE222222) else Color(0x99101010), + focusedContainerColor = Color(0xFF2C2C2C), + contentColor = Color.White, + focusedContentColor = Color.White, + ), + border = ClickableSurfaceDefaults.border( + border = Border( + border = androidx.compose.foundation.BorderStroke( + if (selected) 1.5.dp else 1.dp, + if (selected) Color.White else Color(0x33FFFFFF), + ), + shape = shape, + ), + focusedBorder = Border( + border = androidx.compose.foundation.BorderStroke(1.5.dp, Color.White), + shape = shape, + ), + ), + ) { + VersionQualityMark(version = version, selected = selected) + } +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun SeriesDetailScreen( diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/QualityBadges.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/QualityBadges.kt new file mode 100644 index 0000000..23665f8 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/QualityBadges.kt @@ -0,0 +1,338 @@ +package nl.vonas.mediacluster.tv.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Text + +/** Resolutie als primaire “logo”-identiteit van een versie. */ +enum class ResolutionLogo { + UHD_4K, + FHD_1080, + HD_720, + SD, +} + +/** Extra beeld/geluid — alleen herkenbare logo’s, geen codecs. */ +enum class FeatureLogo { + DOLBY_VISION, + HDR, + ATMOS, + DTS_X, +} + +data class MediaVersion( + val id: String, + val resolution: ResolutionLogo?, + val features: List, +) { + val shortLabel: String + get() = when (resolution) { + ResolutionLogo.UHD_4K -> "4K" + ResolutionLogo.FHD_1080 -> "1080p" + ResolutionLogo.HD_720 -> "720p" + ResolutionLogo.SD -> "SD" + null -> if (features.isNotEmpty()) features.first().let { + when (it) { + FeatureLogo.ATMOS -> "Atmos" + FeatureLogo.DTS_X -> "DTS:X" + FeatureLogo.DOLBY_VISION -> "Vision" + FeatureLogo.HDR -> "HDR" + } + } else "Versie" + } + + val rank: Int + get() = when (resolution) { + ResolutionLogo.UHD_4K -> 400 + ResolutionLogo.FHD_1080 -> 300 + ResolutionLogo.HD_720 -> 200 + ResolutionLogo.SD -> 100 + null -> 0 + } + features.size * 10 +} + +fun parseMediaVersion( + id: String, + resolution: String? = null, + hdrType: String? = null, + audioCodec: String? = null, + videoCodec: String? = null, + releaseName: String? = null, + label: String? = null, +): MediaVersion { + val hay = listOf(resolution, hdrType, audioCodec, videoCodec, releaseName, label) + .mapNotNull { it?.trim()?.takeIf(String::isNotEmpty) } + .joinToString(" ") + + return MediaVersion( + id = id, + resolution = parseResolutionLogo(hay), + features = parseFeatureLogos(hay), + ) +} + +fun parseResolutionLogo(hay: String): ResolutionLogo? { + if (hay.isBlank()) return null + return when { + Regex("""(?:^|[^\d])(2160\s*p?|3840\s*[x×]\s*2160|4\s*k|uhd)(?:[^\d]|$)""", RegexOption.IGNORE_CASE) + .containsMatchIn(hay) -> ResolutionLogo.UHD_4K + Regex("""(?:^|[^\d])(1080\s*p?|1920\s*[x×]\s*1080|fhd)(?:[^\d]|$)""", RegexOption.IGNORE_CASE) + .containsMatchIn(hay) -> ResolutionLogo.FHD_1080 + Regex("""(?:^|[^\d])(720\s*p?|1280\s*[x×]\s*720)(?:[^\d]|$)""", RegexOption.IGNORE_CASE) + .containsMatchIn(hay) -> ResolutionLogo.HD_720 + Regex("""(?:^|[^\d])(480\s*p?|576\s*p?|sd)(?:[^\d]|$)""", RegexOption.IGNORE_CASE) + .containsMatchIn(hay) -> ResolutionLogo.SD + else -> null + } +} + +fun parseFeatureLogos(hay: String): List { + if (hay.isBlank()) return emptyList() + val out = linkedSetOf() + when { + Regex("""dolby\s*vision|\bdovi\b|\bdv\b""", RegexOption.IGNORE_CASE).containsMatchIn(hay) -> + out += FeatureLogo.DOLBY_VISION + Regex("""hdr10\+?|\bhdr\b""", RegexOption.IGNORE_CASE).containsMatchIn(hay) -> + out += FeatureLogo.HDR + } + when { + Regex("""atmos""", RegexOption.IGNORE_CASE).containsMatchIn(hay) -> + out += FeatureLogo.ATMOS + Regex("""dts[\s.:_-]?x\b""", RegexOption.IGNORE_CASE).containsMatchIn(hay) -> + out += FeatureLogo.DTS_X + } + return out.toList() +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun SelectedQualityLine( + version: MediaVersion?, + modifier: Modifier = Modifier, +) { + if (version == null) return + val logos = buildList { + version.resolution?.let(::add) + addAll(version.features) + } + if (logos.isEmpty()) return + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + version.resolution?.let { ResolutionLogoChip(it, compact = true) } + version.features.forEach { FeatureLogoChip(it, compact = true) } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun ResolutionLogoChip( + logo: ResolutionLogo, + compact: Boolean = false, + emphasized: Boolean = false, +) { + val h = if (compact) 20.dp else 26.dp + val shape = RoundedCornerShape(3.dp) + when (logo) { + ResolutionLogo.UHD_4K -> LogoPlate( + text = "4K", + height = h, + bg = if (emphasized) Color.White else Color(0xFFF2F2F2), + fg = Color.Black, + weight = FontWeight.Black, + size = if (compact) 11.sp else 13.sp, + tracking = 0.8.sp, + shape = shape, + minWidth = if (compact) 32.dp else 40.dp, + ) + ResolutionLogo.FHD_1080 -> LogoPlate( + text = "1080p", + height = h, + bg = Color(0xFF1A1A1A), + fg = Color.White, + weight = FontWeight.Bold, + size = if (compact) 10.sp else 12.sp, + tracking = 0.2.sp, + shape = shape, + border = Color(0xAAFFFFFF), + minWidth = if (compact) 44.dp else 54.dp, + ) + ResolutionLogo.HD_720 -> LogoPlate( + text = "720p", + height = h, + bg = Color.Transparent, + fg = Color(0xFFE0E0E0), + weight = FontWeight.SemiBold, + size = if (compact) 10.sp else 12.sp, + tracking = 0.2.sp, + shape = shape, + border = Color(0x77FFFFFF), + minWidth = if (compact) 40.dp else 48.dp, + ) + ResolutionLogo.SD -> LogoPlate( + text = "SD", + height = h, + bg = Color.Transparent, + fg = Color(0xFFBDBDBD), + weight = FontWeight.SemiBold, + size = if (compact) 10.sp else 12.sp, + tracking = 0.4.sp, + shape = shape, + border = Color(0x55FFFFFF), + minWidth = if (compact) 28.dp else 34.dp, + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun FeatureLogoChip( + logo: FeatureLogo, + compact: Boolean = false, +) { + val h = if (compact) 20.dp else 22.dp + val shape = RoundedCornerShape(3.dp) + when (logo) { + FeatureLogo.DOLBY_VISION -> Box( + Modifier + .height(h) + .widthIn(min = if (compact) 40.dp else 48.dp) + .clip(shape) + .background(Brush.horizontalGradient(listOf(Color(0xFF4A1FFF), Color(0xFFB44CFF)))) + .padding(horizontal = 6.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "VISION", + color = Color.White, + fontSize = if (compact) 8.sp else 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.7.sp, + ) + } + FeatureLogo.HDR -> LogoPlate( + text = "HDR", + height = h, + bg = Color(0xFFE8A317), + fg = Color.Black, + weight = FontWeight.Black, + size = if (compact) 9.sp else 10.sp, + tracking = 0.5.sp, + shape = shape, + minWidth = if (compact) 34.dp else 38.dp, + ) + FeatureLogo.ATMOS -> LogoPlate( + text = "ATMOS", + height = h, + bg = Color(0xFF0E0E0E), + fg = Color.White, + weight = FontWeight.Bold, + size = if (compact) 8.sp else 9.sp, + tracking = 1.0.sp, + shape = shape, + border = Color(0xFF9A9A9A), + minWidth = if (compact) 46.dp else 52.dp, + ) + FeatureLogo.DTS_X -> LogoPlate( + text = "DTS:X", + height = h, + bg = Color(0xFF0B3D91), + fg = Color.White, + weight = FontWeight.Bold, + size = if (compact) 8.sp else 9.sp, + tracking = 0.2.sp, + shape = shape, + minWidth = if (compact) 40.dp else 46.dp, + ) + } +} + +@Composable +private fun LogoPlate( + text: String, + height: Dp, + bg: Color, + fg: Color, + weight: FontWeight, + size: TextUnit, + tracking: TextUnit, + shape: RoundedCornerShape, + border: Color? = null, + minWidth: Dp = 0.dp, +) { + Box( + Modifier + .height(height) + .widthIn(min = minWidth) + .clip(shape) + .background(bg) + .then( + if (border != null) Modifier.border(1.dp, border, shape) else Modifier, + ) + .padding(horizontal = 6.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text, + color = fg, + fontSize = size, + fontWeight = weight, + letterSpacing = tracking, + maxLines = 1, + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun VersionQualityMark( + version: MediaVersion, + selected: Boolean, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + ) { + if (version.resolution != null) { + ResolutionLogoChip(version.resolution, compact = false, emphasized = selected) + } else { + Text( + "?", + color = Color(0xFFAAAAAA), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + } + if (version.features.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + version.features.take(2).forEach { FeatureLogoChip(it, compact = true) } + } + } + } +} diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index f1ee1c1..8262269 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -520,8 +520,14 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) return result; }); - app.post("/api/v1/admin/library/repair", { preHandler: requireAdmin }, async () => { + app.post("/api/v1/admin/library/repair", { preHandler: requireAdmin }, async (request) => { + request.log.info("library repair started"); + const started = Date.now(); const result = await metadata.repairLibraryAfterPathMigration(); + request.log.info( + { ...result, durationMs: Date.now() - started }, + "library repair finished" + ); return result; }); diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index 44ae892..39e7cbf 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -455,10 +455,8 @@ export class MetadataService { * so Cinemeta tt-streams and local catalogs share one record with all files. */ async consolidateSeries(seriesId: string): Promise<{ merged: number; canonicalId: string }> { - const seed = await prisma.series.findUnique({ where: { id: seriesId } }); - if (!seed) return { merged: 0, canonicalId: seriesId }; - - const all = await prisma.series.findMany({ + const seed = await prisma.series.findUnique({ + where: { id: seriesId }, select: { id: true, title: true, @@ -468,14 +466,10 @@ export class MetadataService { _count: { select: { episodes: true } }, }, }); - const norm = normalizeSeriesKey(seed.title); - const group = all.filter((s) => { - if (s.id === seed.id) return true; - if (seed.tmdbId != null && s.tmdbId === seed.tmdbId) return true; - if (seed.imdbId && s.imdbId === seed.imdbId) return true; - if (s.tmdbId != null && seed.tmdbId != null && s.tmdbId === seed.tmdbId) return true; - return normalizeSeriesKey(s.title) === norm; - }); + if (!seed) return { merged: 0, canonicalId: seriesId }; + + const group = await this.findDuplicateSeriesGroup(seed); + if (group.length < 2) return { merged: 0, canonicalId: seed.id }; const canonical = pickBestSeries(group); let merged = 0; @@ -850,10 +844,8 @@ export class MetadataService { * so Cinemeta tt-lookups see the new available files after a path migration. */ async consolidateMovies(movieId: string): Promise<{ merged: number; canonicalId: string }> { - const seed = await prisma.movie.findUnique({ where: { id: movieId } }); - if (!seed) return { merged: 0, canonicalId: movieId }; - - const all = await prisma.movie.findMany({ + const seed = await prisma.movie.findUnique({ + where: { id: movieId }, select: { id: true, title: true, @@ -864,15 +856,10 @@ export class MetadataService { _count: { select: { mediaFiles: true } }, }, }); - const norm = normalizeSeriesKey(seed.title); - const group = all.filter((m) => { - if (m.id === seed.id) return true; - if (seed.tmdbId != null && m.tmdbId === seed.tmdbId) return true; - if (seed.imdbId && m.imdbId === seed.imdbId) return true; - if (normalizeSeriesKey(m.title) !== norm) return false; - if (seed.year && m.year && seed.year !== m.year) return false; - return true; - }); + if (!seed) return { merged: 0, canonicalId: movieId }; + + const group = await this.findDuplicateMovieGroup(seed); + if (group.length < 2) return { merged: 0, canonicalId: seed.id }; const canonical = pickBestMovie(group); let merged = 0; @@ -910,46 +897,17 @@ export class MetadataService { /** * After path migrations: merge duplicates toward IMDb holders, drop dead - * unavailable files, then backfill missing IMDb ids. + * unavailable files, prune duplicate file rows, then backfill missing IMDb ids. */ async repairLibraryAfterPathMigration(): Promise<{ moviesMerged: number; seriesMerged: number; staleFilesRemoved: number; + duplicateFilesRemoved: number; imdb: Awaited>; }> { - let moviesMerged = 0; - let seriesMerged = 0; - - const movies = await prisma.movie.findMany({ - select: { id: true }, - orderBy: { createdAt: "asc" }, - }); - const seenMovies = new Set(); - for (const m of movies) { - if (seenMovies.has(m.id)) continue; - if (!(await prisma.movie.findUnique({ where: { id: m.id }, select: { id: true } }))) { - continue; - } - const { merged, canonicalId } = await this.consolidateMovies(m.id); - seenMovies.add(canonicalId); - moviesMerged += merged; - } - - const seriesList = await prisma.series.findMany({ - select: { id: true }, - orderBy: { createdAt: "asc" }, - }); - const seenSeries = new Set(); - for (const s of seriesList) { - if (seenSeries.has(s.id)) continue; - if (!(await prisma.series.findUnique({ where: { id: s.id }, select: { id: true } }))) { - continue; - } - const { merged, canonicalId } = await this.consolidateSeries(s.id); - seenSeries.add(canonicalId); - seriesMerged += merged; - } + const moviesMerged = await this.mergeAllDuplicateMovies(); + const seriesMerged = await this.mergeAllDuplicateSeries(); // Unavailable rows left after the /media/movies → /mnt/user migration const staleFilesRemoved = ( @@ -958,9 +916,227 @@ export class MetadataService { }) ).count; - const imdb = await this.backfillAllImdbIds({ delayMs: 80 }); + const duplicateFilesRemoved = await this.removeDuplicateMediaFiles(); - return { moviesMerged, seriesMerged, staleFilesRemoved, imdb }; + // Cap IMDb backfill so repair finishes in reasonable time; run again if needed. + const imdb = await this.backfillAllImdbIds({ delayMs: 80, limit: 400 }); + + return { moviesMerged, seriesMerged, staleFilesRemoved, duplicateFilesRemoved, imdb }; + } + + private movieGroupKey(m: { imdbId: string | null; tmdbId: number | null; title: string; year: number | null }): string { + if (m.imdbId) return `imdb:${m.imdbId.toLowerCase()}`; + if (m.tmdbId != null) return `tmdb:${m.tmdbId}`; + return `title:${normalizeSeriesKey(m.title)}::${m.year ?? ""}`; + } + + private seriesGroupKey(s: { imdbId: string | null; tmdbId: number | null; title: string }): string { + if (s.imdbId) return `imdb:${s.imdbId.toLowerCase()}`; + if (s.tmdbId != null) return `tmdb:${s.tmdbId}`; + return `title:${normalizeSeriesKey(s.title)}`; + } + + private async findDuplicateMovieGroup( + seed: MoviePick & { title: string; year: number | null } + ): Promise> { + const norm = normalizeSeriesKey(seed.title); + const orClauses: Array> = [ + { title: { equals: seed.title, mode: "insensitive" } }, + ]; + if (seed.tmdbId != null) orClauses.push({ tmdbId: seed.tmdbId }); + if (seed.imdbId) orClauses.push({ imdbId: seed.imdbId }); + + const candidates = await prisma.movie.findMany({ + where: { OR: orClauses }, + select: { + id: true, + title: true, + year: true, + tmdbId: true, + imdbId: true, + createdAt: true, + _count: { select: { mediaFiles: true } }, + }, + }); + + return candidates.filter((m) => { + if (m.id === seed.id) return true; + if (seed.tmdbId != null && m.tmdbId === seed.tmdbId) return true; + if (seed.imdbId && m.imdbId === seed.imdbId) return true; + if (normalizeSeriesKey(m.title) !== norm) return false; + if (seed.year && m.year && seed.year !== m.year) return false; + return true; + }); + } + + private async findDuplicateSeriesGroup( + seed: SeriesPick & { title: string } + ): Promise> { + const norm = normalizeSeriesKey(seed.title); + const orClauses: Array> = [ + { title: { equals: seed.title, mode: "insensitive" } }, + ]; + if (seed.tmdbId != null) orClauses.push({ tmdbId: seed.tmdbId }); + if (seed.imdbId) orClauses.push({ imdbId: seed.imdbId }); + + const candidates = await prisma.series.findMany({ + where: { OR: orClauses }, + select: { + id: true, + title: true, + tmdbId: true, + imdbId: true, + createdAt: true, + _count: { select: { episodes: true } }, + }, + }); + + return candidates.filter((s) => { + if (s.id === seed.id) return true; + if (seed.tmdbId != null && s.tmdbId === seed.tmdbId) return true; + if (seed.imdbId && s.imdbId === seed.imdbId) return true; + return normalizeSeriesKey(s.title) === norm; + }); + } + + private async mergeAllDuplicateMovies(): Promise { + const all = await prisma.movie.findMany({ + select: { + id: true, + title: true, + year: true, + tmdbId: true, + imdbId: true, + createdAt: true, + _count: { select: { mediaFiles: true } }, + }, + orderBy: { createdAt: "asc" }, + }); + + const groups = new Map(); + for (const m of all) { + const key = this.movieGroupKey(m); + const list = groups.get(key) ?? []; + list.push(m); + groups.set(key, list); + } + + let merged = 0; + for (const group of groups.values()) { + if (group.length < 2) continue; + const canonical = pickBestMovie(group); + for (const orphan of group) { + if (orphan.id === canonical.id) continue; + try { + await prisma.mediaFile.updateMany({ + where: { movieId: orphan.id }, + data: { movieId: canonical.id }, + }); + if ((!canonical.imdbId && orphan.imdbId) || (!canonical.tmdbId && orphan.tmdbId != null)) { + await prisma.movie + .update({ + where: { id: canonical.id }, + data: { + ...(canonical.imdbId ? {} : orphan.imdbId ? { imdbId: orphan.imdbId } : {}), + ...(canonical.tmdbId != null + ? {} + : orphan.tmdbId != null + ? { tmdbId: orphan.tmdbId } + : {}), + }, + }) + .catch(() => undefined); + } + await prisma.movie.delete({ where: { id: orphan.id } }); + merged += 1; + } catch { + // unique conflicts — skip + } + } + } + return merged; + } + + private async mergeAllDuplicateSeries(): Promise { + const all = await prisma.series.findMany({ + select: { + id: true, + title: true, + tmdbId: true, + imdbId: true, + createdAt: true, + _count: { select: { episodes: true } }, + }, + orderBy: { createdAt: "asc" }, + }); + + const groups = new Map(); + for (const s of all) { + const key = this.seriesGroupKey(s); + const list = groups.get(key) ?? []; + list.push(s); + groups.set(key, list); + } + + let merged = 0; + for (const group of groups.values()) { + if (group.length < 2) continue; + const canonical = pickBestSeries(group); + for (const orphan of group) { + if (orphan.id === canonical.id) continue; + try { + await this.mergeSeriesInto(canonical.id, orphan.id); + merged += 1; + } catch { + // skip conflicts + } + } + } + return merged; + } + + /** Drop duplicate file rows after path migrations (same node + title + size). */ + private async removeDuplicateMediaFiles(): Promise { + const files = await prisma.mediaFile.findMany({ + where: { OR: [{ movieId: { not: null } }, { episodeId: { not: null } }] }, + select: { + id: true, + nodeId: true, + movieId: true, + episodeId: true, + releaseName: true, + sizeBytes: true, + available: true, + lastSeen: true, + }, + orderBy: { lastSeen: "desc" }, + }); + + const groups = new Map(); + for (const f of files) { + const mediaKey = f.movieId ?? f.episodeId ?? ""; + const release = (f.releaseName ?? "").trim().toLowerCase(); + const key = `${f.nodeId}::${mediaKey}::${release}::${f.sizeBytes.toString()}`; + const list = groups.get(key) ?? []; + list.push(f); + groups.set(key, list); + } + + const toDelete: string[] = []; + for (const group of groups.values()) { + if (group.length < 2) continue; + const sorted = [...group].sort((a, b) => { + if (a.available !== b.available) return a.available ? -1 : 1; + return b.lastSeen.getTime() - a.lastSeen.getTime(); + }); + for (const dup of sorted.slice(1)) { + toDelete.push(dup.id); + } + } + + if (toDelete.length === 0) return 0; + const result = await prisma.mediaFile.deleteMany({ where: { id: { in: toDelete } } }); + return result.count; } /** Admin: search TMDB by title (or resolve a single IMDb id). */ @@ -1358,13 +1534,15 @@ export class MetadataService { } /** Backfill IMDb for movies and series (catalog/meta for series stays private mcseries-). */ - async backfillAllImdbIds(options?: { delayMs?: number }): Promise<{ + async backfillAllImdbIds(options?: { delayMs?: number; limit?: number }): Promise<{ movies: { checked: number; updated: number; remaining: number; failed: number }; series: { checked: number; updated: number; remaining: number; failed: number }; }> { const delayMs = options?.delayMs ?? 120; - const movies = await this.backfillMovieImdbIds({ delayMs }); - const series = await this.backfillSeriesImdbIds({ delayMs }); + const perTypeLimit = + options?.limit && options.limit > 0 ? Math.ceil(options.limit / 2) : undefined; + const movies = await this.backfillMovieImdbIds({ delayMs, limit: perTypeLimit }); + const series = await this.backfillSeriesImdbIds({ delayMs, limit: perTypeLimit }); return { movies, series }; }