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:
parent
57280ef470
commit
d044106f77
6 changed files with 761 additions and 149 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue