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 [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() {
|
|||
<p className="muted">
|
||||
Duplicaten + dode files + IMDb-backfill na Unraid pad-migratie.
|
||||
</p>
|
||||
<button type="button" onClick={repairLibrary}>
|
||||
Bibliotheek herstellen
|
||||
<button type="button" onClick={repairLibrary} disabled={repairBusy}>
|
||||
{repairBusy ? "Bezig…" : "Bibliotheek herstellen"}
|
||||
</button>
|
||||
{repairMsg && <p className="muted tiny">{repairMsg}</p>}
|
||||
</div>
|
||||
<div className="tool-block">
|
||||
<h3 className="card-subtitle">Duplicaten</h3>
|
||||
<p className="muted">Zelfde titel samenvoegen (TMDB wint).</p>
|
||||
<button type="button" onClick={dedupe}>
|
||||
Duplicaten samenvoegen
|
||||
<button type="button" onClick={dedupe} disabled={dedupeBusy}>
|
||||
{dedupeBusy ? "Bezig…" : "Duplicaten samenvoegen"}
|
||||
</button>
|
||||
{dedupeMsg && <p className="muted tiny">{dedupeMsg}</p>}
|
||||
</div>
|
||||
|
|
@ -254,8 +284,8 @@ export default function DashboardPage() {
|
|||
{data.moviesMissingImdb ?? "?"} · Series: {data.seriesWithImdb ?? "?"} / zonder{" "}
|
||||
{data.seriesMissingImdb ?? "?"}
|
||||
</p>
|
||||
<button type="button" onClick={backfillImdb}>
|
||||
IMDb-IDs backfillen
|
||||
<button type="button" onClick={backfillImdb} disabled={imdbBusy}>
|
||||
{imdbBusy ? "Bezig…" : "IMDb-IDs backfillen"}
|
||||
</button>
|
||||
{imdbMsg && <p className="muted tiny">{imdbMsg}</p>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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<List<String>>(emptyList()) }
|
||||
var director by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
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) }
|
||||
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<Pair<String, String>>()
|
||||
val list = mutableListOf<MediaVersion>()
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<MetadataService["backfillAllImdbIds"]>>;
|
||||
}> {
|
||||
let moviesMerged = 0;
|
||||
let seriesMerged = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
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<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). */
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue