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.
-
-
- IMDb-IDs backfillen
+
+ {imdbBusy ? "Bezig…" : "IMDb-IDs backfillen"}
{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 };
}