Fix slow library repair and add TV quality badges on detail pages.

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 <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-01 14:51:26 +02:00
parent 57280ef470
commit d044106f77
6 changed files with 761 additions and 149 deletions

View file

@ -50,6 +50,9 @@ export default function DashboardPage() {
const [dedupeMsg, setDedupeMsg] = useState(""); const [dedupeMsg, setDedupeMsg] = useState("");
const [imdbMsg, setImdbMsg] = useState(""); const [imdbMsg, setImdbMsg] = useState("");
const [repairMsg, setRepairMsg] = useState(""); const [repairMsg, setRepairMsg] = useState("");
const [repairBusy, setRepairBusy] = useState(false);
const [dedupeBusy, setDedupeBusy] = useState(false);
const [imdbBusy, setImdbBusy] = useState(false);
const load = useCallback(() => { const load = useCallback(() => {
fetch("/api/v1/admin/dashboard", { credentials: "include" }) fetch("/api/v1/admin/dashboard", { credentials: "include" })
@ -73,26 +76,39 @@ export default function DashboardPage() {
async function repairLibrary() { async function repairLibrary() {
if ( if (
!confirm( !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; return;
} }
setRepairMsg("Bezig…"); setRepairBusy(true);
const res = await fetch("/api/v1/admin/library/repair", { setRepairMsg("Bezig — duplicaten en dode files opruimen…");
method: "POST", try {
credentials: "include", const res = await fetch("/api/v1/admin/library/repair", {
}); method: "POST",
const json = await res.json(); credentials: "include",
if (!res.ok) { });
setRepairMsg(json.error?.message ?? "Mislukt"); const json = await res.json().catch(() => ({}));
return; 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() { async function backfillImdb() {
@ -103,22 +119,29 @@ export default function DashboardPage() {
) { ) {
return; return;
} }
setImdbBusy(true);
setImdbMsg("Bezig…"); setImdbMsg("Bezig…");
const res = await fetch("/api/v1/admin/library/backfill-imdb", { try {
method: "POST", const res = await fetch("/api/v1/admin/library/backfill-imdb", {
credentials: "include", method: "POST",
}); credentials: "include",
const json = await res.json(); });
if (!res.ok) { const json = await res.json().catch(() => ({}));
setImdbMsg(json.error?.message ?? "Mislukt"); if (!res.ok) {
return; 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() { async function dedupe() {
@ -129,20 +152,27 @@ export default function DashboardPage() {
) { ) {
return; return;
} }
setDedupeBusy(true);
setDedupeMsg("Bezig…"); setDedupeMsg("Bezig…");
const res = await fetch("/api/v1/admin/library/dedupe", { try {
method: "POST", const res = await fetch("/api/v1/admin/library/dedupe", {
credentials: "include", method: "POST",
}); credentials: "include",
const json = await res.json(); });
if (!res.ok) { const json = await res.json().catch(() => ({}));
setDedupeMsg(json.error?.message ?? "Mislukt"); if (!res.ok) {
return; 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 ( return (
@ -234,16 +264,16 @@ export default function DashboardPage() {
<p className="muted"> <p className="muted">
Duplicaten + dode files + IMDb-backfill na Unraid pad-migratie. Duplicaten + dode files + IMDb-backfill na Unraid pad-migratie.
</p> </p>
<button type="button" onClick={repairLibrary}> <button type="button" onClick={repairLibrary} disabled={repairBusy}>
Bibliotheek herstellen {repairBusy ? "Bezig…" : "Bibliotheek herstellen"}
</button> </button>
{repairMsg && <p className="muted tiny">{repairMsg}</p>} {repairMsg && <p className="muted tiny">{repairMsg}</p>}
</div> </div>
<div className="tool-block"> <div className="tool-block">
<h3 className="card-subtitle">Duplicaten</h3> <h3 className="card-subtitle">Duplicaten</h3>
<p className="muted">Zelfde titel samenvoegen (TMDB wint).</p> <p className="muted">Zelfde titel samenvoegen (TMDB wint).</p>
<button type="button" onClick={dedupe}> <button type="button" onClick={dedupe} disabled={dedupeBusy}>
Duplicaten samenvoegen {dedupeBusy ? "Bezig…" : "Duplicaten samenvoegen"}
</button> </button>
{dedupeMsg && <p className="muted tiny">{dedupeMsg}</p>} {dedupeMsg && <p className="muted tiny">{dedupeMsg}</p>}
</div> </div>
@ -254,8 +284,8 @@ export default function DashboardPage() {
{data.moviesMissingImdb ?? "?"} · Series: {data.seriesWithImdb ?? "?"} / zonder{" "} {data.moviesMissingImdb ?? "?"} · Series: {data.seriesWithImdb ?? "?"} / zonder{" "}
{data.seriesMissingImdb ?? "?"} {data.seriesMissingImdb ?? "?"}
</p> </p>
<button type="button" onClick={backfillImdb}> <button type="button" onClick={backfillImdb} disabled={imdbBusy}>
IMDb-IDs backfillen {imdbBusy ? "Bezig…" : "IMDb-IDs backfillen"}
</button> </button>
{imdbMsg && <p className="muted tiny">{imdbMsg}</p>} {imdbMsg && <p className="muted tiny">{imdbMsg}</p>}
</div> </div>

View file

@ -12,8 +12,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 17 versionCode = 19
versionName = "0.8.0" versionName = "0.8.2"
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")

View file

@ -4,6 +4,8 @@ import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -55,6 +57,10 @@ import coil.compose.AsyncImage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient 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 nl.vonas.mediacluster.tv.ui.theme.McColors
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
@ -270,9 +276,9 @@ private fun DetailActionButton(
) { ) {
Text( Text(
text = label, text = label,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
color = Color.Unspecified, color = Color.Unspecified,
fontSize = 14.sp, fontSize = 13.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
) )
@ -300,10 +306,12 @@ fun MovieDetailScreen(
var cast by remember { mutableStateOf<List<String>>(emptyList()) } var cast by remember { mutableStateOf<List<String>>(emptyList()) }
var director by remember { mutableStateOf<List<String>>(emptyList()) } var director by remember { mutableStateOf<List<String>>(emptyList()) }
var trailerKey by remember { mutableStateOf<String?>(null) } var trailerKey by remember { mutableStateOf<String?>(null) }
var files by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) } var versions by remember { mutableStateOf<List<MediaVersion>>(emptyList()) }
var selectedVersionId by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val playFocus = remember { FocusRequester() } val playFocus = remember { FocusRequester() }
val scroll = rememberScrollState()
LaunchedEffect(movieId) { LaunchedEffect(movieId) {
try { try {
@ -321,41 +329,43 @@ fun MovieDetailScreen(
director = o.optJSONArray("director").stringList() director = o.optJSONArray("director").stringList()
trailerKey = o.optCleanString("trailerYoutubeKey") trailerKey = o.optCleanString("trailerYoutubeKey")
val arr = o.optJSONArray("mediaFiles") val arr = o.optJSONArray("mediaFiles")
val list = mutableListOf<Pair<String, String>>() val list = mutableListOf<MediaVersion>()
if (arr != null) { if (arr != null) {
for (i in 0 until arr.length()) { for (i in 0 until arr.length()) {
val f = arr.getJSONObject(i) val f = arr.getJSONObject(i)
val fromApi = f.optCleanString("label") list += parseMediaVersion(
val label = fromApi ?: buildList { id = f.getString("id"),
f.optCleanString("resolution")?.let(::add) resolution = f.optCleanString("resolution"),
f.optCleanString("hdrType")?.let(::add) hdrType = f.optCleanString("hdrType"),
f.optCleanString("videoCodec")?.let(::add) audioCodec = f.optCleanString("audioCodec"),
f.optCleanString("audioCodec")?.let(::add) videoCodec = f.optCleanString("videoCodec"),
f.optCleanString("releaseName")?.let { if (isEmpty()) add(it.take(42)) } releaseName = f.optCleanString("releaseName"),
f.optCleanString("nodeName")?.let { if (isEmpty()) add(it) } label = f.optCleanString("label"),
}.joinToString(" · ").ifBlank { "Versie ${i + 1}" } )
list += f.getString("id") to label
} }
} }
files = list versions = list.sortedByDescending { it.rank }
selectedVersionId = versions.firstOrNull()?.id
} catch (e: Exception) { } catch (e: Exception) {
error = e.message error = e.message
} }
} }
LaunchedEffect(files) { LaunchedEffect(versions) {
if (files.isNotEmpty()) { if (versions.isNotEmpty()) {
delay(80) delay(80)
runCatching { playFocus.requestFocus() } runCatching { playFocus.requestFocus() }
} }
} }
val selected = versions.firstOrNull { it.id == selectedVersionId } ?: versions.firstOrNull()
val metaParts = buildList { val metaParts = buildList {
year?.let { add(it.toString()) } year?.let { add(it.toString()) }
formatRuntime(runtime)?.let(::add) formatRuntime(runtime)?.let(::add)
genres.take(3).forEach(::add) genres.take(2).forEach(::add)
rating?.let { add("★ $it") } rating?.let { add("★ $it") }
} }
val playLabel = selected?.let { "Afspelen · ${it.shortLabel}" } ?: "Afspelen"
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
@ -364,13 +374,15 @@ fun MovieDetailScreen(
Modifier Modifier
.fillMaxHeight() .fillMaxHeight()
.fillMaxWidth(0.58f) .fillMaxWidth(0.58f)
.padding(start = 44.dp, end = 20.dp, top = 28.dp, bottom = 28.dp), .padding(start = 44.dp, end = 20.dp, top = 24.dp, bottom = 24.dp)
verticalArrangement = Arrangement.spacedBy(12.dp), .verticalScroll(scroll),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
DetailBackButton(onClick = onBack) DetailBackButton(onClick = onBack)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(4.dp))
DetailLogoOrTitle(logoUrl, title) DetailLogoOrTitle(logoUrl, title)
MetaChips(metaParts) MetaChips(metaParts)
SelectedQualityLine(selected)
if (director.isNotEmpty()) { if (director.isNotEmpty()) {
Text( Text(
"Regie: ${director.take(2).joinToString(", ")}", "Regie: ${director.take(2).joinToString(", ")}",
@ -385,26 +397,26 @@ fun MovieDetailScreen(
overview, overview,
color = McColors.TextDim, color = McColors.TextDim,
fontSize = 15.sp, fontSize = 15.sp,
maxLines = 5, maxLines = 4,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
lineHeight = 22.sp, lineHeight = 21.sp,
) )
} }
if (cast.isNotEmpty()) { if (cast.isNotEmpty()) {
Text( Text(
"Met: ${cast.take(5).joinToString(", ")}", "Met: ${cast.take(4).joinToString(", ")}",
color = Color(0xFF8A8A8A), color = Color(0xFF8A8A8A),
fontSize = 13.sp, fontSize = 13.sp,
maxLines = 2, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
if (error != null) Text(error!!, color = McColors.Danger) if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
files.firstOrNull()?.let { (id, _) -> selected?.let { ver ->
DetailActionButton("Afspelen", primary = true, focusRequester = playFocus) { DetailActionButton(playLabel, primary = true, focusRequester = playFocus) {
onPlay(id, title, 0) onPlay(ver.id, title, 0)
} }
} }
if (!trailerKey.isNullOrBlank()) { if (!trailerKey.isNullOrBlank()) {
@ -419,14 +431,25 @@ fun MovieDetailScreen(
} }
} }
} }
if (files.size > 1) { if (versions.size > 1) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(2.dp))
Text("Versies", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) Text(
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { "Kwaliteit",
items(files) { (id, label) -> color = Color(0xFF8F8F8F),
DetailActionButton(label.take(36), primary = id == files.first().first) { fontSize = 11.sp,
onPlay(id, title, 0) 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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun SeriesDetailScreen( fun SeriesDetailScreen(

View file

@ -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<FeatureLogo>,
) {
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<FeatureLogo> {
if (hay.isBlank()) return emptyList()
val out = linkedSetOf<FeatureLogo>()
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) }
}
}
}
}

View file

@ -520,8 +520,14 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
return result; 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(); const result = await metadata.repairLibraryAfterPathMigration();
request.log.info(
{ ...result, durationMs: Date.now() - started },
"library repair finished"
);
return result; return result;
}); });

View file

@ -455,10 +455,8 @@ export class MetadataService {
* so Cinemeta tt-streams and local catalogs share one record with all files. * so Cinemeta tt-streams and local catalogs share one record with all files.
*/ */
async consolidateSeries(seriesId: string): Promise<{ merged: number; canonicalId: string }> { async consolidateSeries(seriesId: string): Promise<{ merged: number; canonicalId: string }> {
const seed = await prisma.series.findUnique({ where: { id: seriesId } }); const seed = await prisma.series.findUnique({
if (!seed) return { merged: 0, canonicalId: seriesId }; where: { id: seriesId },
const all = await prisma.series.findMany({
select: { select: {
id: true, id: true,
title: true, title: true,
@ -468,14 +466,10 @@ export class MetadataService {
_count: { select: { episodes: true } }, _count: { select: { episodes: true } },
}, },
}); });
const norm = normalizeSeriesKey(seed.title); if (!seed) return { merged: 0, canonicalId: seriesId };
const group = all.filter((s) => {
if (s.id === seed.id) return true; const group = await this.findDuplicateSeriesGroup(seed);
if (seed.tmdbId != null && s.tmdbId === seed.tmdbId) return true; if (group.length < 2) return { merged: 0, canonicalId: seed.id };
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;
});
const canonical = pickBestSeries(group); const canonical = pickBestSeries(group);
let merged = 0; let merged = 0;
@ -850,10 +844,8 @@ export class MetadataService {
* so Cinemeta tt-lookups see the new available files after a path migration. * so Cinemeta tt-lookups see the new available files after a path migration.
*/ */
async consolidateMovies(movieId: string): Promise<{ merged: number; canonicalId: string }> { async consolidateMovies(movieId: string): Promise<{ merged: number; canonicalId: string }> {
const seed = await prisma.movie.findUnique({ where: { id: movieId } }); const seed = await prisma.movie.findUnique({
if (!seed) return { merged: 0, canonicalId: movieId }; where: { id: movieId },
const all = await prisma.movie.findMany({
select: { select: {
id: true, id: true,
title: true, title: true,
@ -864,15 +856,10 @@ export class MetadataService {
_count: { select: { mediaFiles: true } }, _count: { select: { mediaFiles: true } },
}, },
}); });
const norm = normalizeSeriesKey(seed.title); if (!seed) return { merged: 0, canonicalId: movieId };
const group = all.filter((m) => {
if (m.id === seed.id) return true; const group = await this.findDuplicateMovieGroup(seed);
if (seed.tmdbId != null && m.tmdbId === seed.tmdbId) return true; if (group.length < 2) return { merged: 0, canonicalId: seed.id };
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;
});
const canonical = pickBestMovie(group); const canonical = pickBestMovie(group);
let merged = 0; let merged = 0;
@ -910,46 +897,17 @@ export class MetadataService {
/** /**
* After path migrations: merge duplicates toward IMDb holders, drop dead * 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<{ async repairLibraryAfterPathMigration(): Promise<{
moviesMerged: number; moviesMerged: number;
seriesMerged: number; seriesMerged: number;
staleFilesRemoved: number; staleFilesRemoved: number;
duplicateFilesRemoved: number;
imdb: Awaited<ReturnType<MetadataService["backfillAllImdbIds"]>>; imdb: Awaited<ReturnType<MetadataService["backfillAllImdbIds"]>>;
}> { }> {
let moviesMerged = 0; const moviesMerged = await this.mergeAllDuplicateMovies();
let seriesMerged = 0; const seriesMerged = await this.mergeAllDuplicateSeries();
const movies = await prisma.movie.findMany({
select: { id: true },
orderBy: { createdAt: "asc" },
});
const seenMovies = new Set<string>();
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<string>();
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;
}
// Unavailable rows left after the /media/movies → /mnt/user migration // Unavailable rows left after the /media/movies → /mnt/user migration
const staleFilesRemoved = ( const staleFilesRemoved = (
@ -958,9 +916,227 @@ export class MetadataService {
}) })
).count; ).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<Array<MoviePick & { title: string; year: number | null }>> {
const norm = normalizeSeriesKey(seed.title);
const orClauses: Array<Record<string, unknown>> = [
{ 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<Array<SeriesPick & { title: string }>> {
const norm = normalizeSeriesKey(seed.title);
const orClauses: Array<Record<string, unknown>> = [
{ 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<number> {
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<string, typeof all>();
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<number> {
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<string, typeof all>();
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<number> {
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<string, typeof files>();
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). */ /** 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-). */ /** 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 }; movies: { checked: number; updated: number; remaining: number; failed: number };
series: { checked: number; updated: number; remaining: number; failed: number }; series: { checked: number; updated: number; remaining: number; failed: number };
}> { }> {
const delayMs = options?.delayMs ?? 120; const delayMs = options?.delayMs ?? 120;
const movies = await this.backfillMovieImdbIds({ delayMs }); const perTypeLimit =
const series = await this.backfillSeriesImdbIds({ delayMs }); 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 }; return { movies, series };
} }