import { useEffect, useMemo, useRef, useState } from 'react'; import { Dialog } from 'primereact/dialog'; import { Button } from 'primereact/button'; import { DataTable } from 'primereact/datatable'; import { Column } from 'primereact/column'; import { InputText } from 'primereact/inputtext'; function normalizeWorkflowKind(value) { const raw = String(value || '').trim().toLowerCase(); if (['film', 'movie', 'feature'].includes(raw)) { return 'film'; } if (['series', 'tv', 'season', 'episode', 'tv_series', 'tv-season', 'tv_season'].includes(raw)) { return 'series'; } return null; } function normalizeResultType(value) { const raw = String(value || '').trim().toLowerCase(); if (raw === 'series') { return 'series'; } if (raw === 'movie' || raw === 'film' || raw === 'movies') { return 'movie'; } return null; } function normalizeMetadataRow(row = {}) { const workflowKind = normalizeWorkflowKind( row?.workflowKind || row?.kind || row?.metadataKind || null ); const metadataKindRaw = String(row?.metadataKind || '').trim().toLowerCase(); const metadataKind = metadataKindRaw || (workflowKind === 'series' ? 'series' : 'movie'); const resultType = normalizeResultType(row?.resultType) || (workflowKind === 'series' ? 'series' : (workflowKind === 'film' ? 'movie' : null)) || ((metadataKind === 'series' || metadataKind === 'season') ? 'series' : 'movie'); const normalizedWorkflowKind = workflowKind || (resultType === 'series' ? 'series' : 'film'); return { ...row, metadataKind, resultType, workflowKind: normalizedWorkflowKind }; } export default function MetadataSelectionDialog({ visible, context, onHide, onSubmit, onSearch, busy }) { const toSearchTitleCase = (value) => String(value || '') .toLowerCase() .replace(/\b([a-z\u00c0-\u024f])([a-z\u00c0-\u024f0-9']*)/g, (_match, first, rest) => ( `${first.toUpperCase()}${rest}` )); const stripTrailingDiscHint = (value) => { let working = String(value || '').trim(); if (!working) { return ''; } const patterns = [ /\b(?:disc|disk|dvd|bd|br|cd|part|pt|vol|volume|season|staffel|episode|ep)\s*(?:\d{1,3}|[ivxlcdm]{1,8})\b$/i, /\b(?:[a-z]{1,3}\d{1,4}|\d{1,4}[a-z]{1,3})\b$/i ]; for (let i = 0; i < 4; i += 1) { const before = working; for (const pattern of patterns) { working = working.replace(pattern, '').trim(); } working = working.replace(/[-_.,;:]+$/g, '').trim(); if (working === before || !working) { break; } } return working; }; const buildSearchVariants = (rawValue) => { const raw = String(rawValue || '').trim(); if (!raw) { return []; } if (/^tt\d{6,12}$/i.test(raw)) { return [raw.toLowerCase()]; } const variants = []; const pushUnique = (candidate) => { const normalized = String(candidate || '').replace(/\s+/g, ' ').trim(); if (!normalized) { return; } if (!variants.some((entry) => entry.toLowerCase() === normalized.toLowerCase())) { variants.push(normalized); } }; const separatorNormalized = raw .replace(/[_]+/g, ' ') .replace(/[.]+/g, ' ') .replace(/\s+/g, ' ') .trim(); const punctuationReduced = separatorNormalized .replace(/[|/\\]+/g, ' ') .replace(/\s+/g, ' ') .trim(); const stripped = stripTrailingDiscHint(punctuationReduced); pushUnique(raw); pushUnique(separatorNormalized); pushUnique(punctuationReduced); pushUnique(toSearchTitleCase(separatorNormalized)); pushUnique(stripped); pushUnique(toSearchTitleCase(stripped)); return variants.slice(0, 6); }; const normalizeDetectedTitleForMetadataInput = (rawValue) => { const raw = String(rawValue || '').trim(); if (!raw) { return ''; } const separatorNormalized = raw .replace(/[_]+/g, ' ') .replace(/[.]+/g, ' ') .replace(/\s+/g, ' ') .trim(); const stripped = stripTrailingDiscHint(separatorNormalized); const preferred = stripped || separatorNormalized || raw; return toSearchTitleCase(preferred); }; const parseDiscNumber = (rawValue) => { const text = String(rawValue ?? '').trim(); if (!/^\d+$/.test(text)) { return null; } const parsed = Number(text); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); }; const [selected, setSelected] = useState(null); const [query, setQuery] = useState(''); const [manualTitle, setManualTitle] = useState(''); const [manualYear, setManualYear] = useState(''); const [manualImdb, setManualImdb] = useState(''); const [manualDiscNumber, setManualDiscNumber] = useState(''); const [extraResults, setExtraResults] = useState([]); const [hasSearchOverride, setHasSearchOverride] = useState(false); const [searchBusy, setSearchBusy] = useState(false); const [searchError, setSearchError] = useState(''); const [submitError, setSubmitError] = useState(''); const [conflictState, setConflictState] = useState(null); const [conflictExistingDiscNumber, setConflictExistingDiscNumber] = useState(''); const [conflictNewDiscNumber, setConflictNewDiscNumber] = useState(''); const [conflictError, setConflictError] = useState(''); const [seasonFilter, setSeasonFilter] = useState(''); const [resultFilter, setResultFilter] = useState('all'); const [discValidationError, setDiscValidationError] = useState(''); const activeSearchRequestRef = useRef(0); const autoSearchDoneRef = useRef(false); const wasVisibleRef = useRef(false); const lastVisibleContextIdentityRef = useRef(null); const mediaProfile = String( context?.mediaProfile || context?.selectedMetadata?.mediaProfile || '' ).trim().toLowerCase(); const metadataProvider = 'tmdb'; const providerLabel = 'TMDb'; const supportsExternalIdInput = false; const contextJobId = Number(context?.jobId || 0) || null; const contextIdentity = contextJobId ? `job:${contextJobId}` : `detected:${String(context?.detectedTitle || '').trim().toLowerCase()}`; useEffect(() => { if (!visible) { wasVisibleRef.current = false; lastVisibleContextIdentityRef.current = null; return; } const openingNow = !wasVisibleRef.current; const contextChanged = lastVisibleContextIdentityRef.current !== contextIdentity; if (!openingNow && !contextChanged) { return; } const selectedMetadata = context?.selectedMetadata || {}; const rawDefaultTitle = selectedMetadata.title || context?.detectedTitle || ''; const defaultTitle = normalizeDetectedTitleForMetadataInput(rawDefaultTitle) || rawDefaultTitle; const defaultYear = selectedMetadata.year ? String(selectedMetadata.year) : ''; const defaultImdb = selectedMetadata.imdbId || ''; setSelected(null); setQuery(defaultTitle); setManualTitle(defaultTitle); setManualYear(defaultYear); setManualImdb(defaultImdb); setManualDiscNumber(''); setExtraResults([]); setHasSearchOverride(false); setSearchBusy(false); setSearchError(''); setSubmitError(''); setConflictState(null); setConflictExistingDiscNumber(''); setConflictNewDiscNumber(''); setConflictError(''); setSeasonFilter(''); setResultFilter('all'); setDiscValidationError(''); autoSearchDoneRef.current = false; activeSearchRequestRef.current += 1; wasVisibleRef.current = true; lastVisibleContextIdentityRef.current = contextIdentity; }, [visible, contextIdentity, context]); useEffect(() => { if (submitError) { setSubmitError(''); } }, [manualDiscNumber, seasonFilter, selected, resultFilter, conflictExistingDiscNumber, conflictNewDiscNumber]); const rows = useMemo(() => { const base = Array.isArray(context?.metadataCandidates) ? context.metadataCandidates : []; const sourceRows = hasSearchOverride ? extraResults : base; const all = Array.isArray(sourceRows) ? sourceRows : []; const map = new Map(); all.forEach((item) => { const normalized = normalizeMetadataRow(item); const rowType = normalizeResultType(normalized?.resultType) || 'movie'; const rowKey = String( normalized?.providerId || `${rowType}:${normalized?.imdbId || normalized?.tmdbId || '-'}:${normalized?.seasonNumber || '-'}:${normalized?.title || '-'}:${normalized?.year || '-'}` ).trim(); if (!rowKey) { return; } map.set(rowKey, { ...normalized, _metadataKey: rowKey }); }); return Array.from(map.values()); }, [context, extraResults, hasSearchOverride]); const seriesRowsAvailable = useMemo( () => rows.some((row) => normalizeResultType(row?.resultType) === 'series'), [rows] ); const selectedResultType = normalizeResultType(selected?.resultType) || (normalizeWorkflowKind(selected?.workflowKind) === 'series' ? 'series' : 'movie'); const selectedIsSeries = Boolean(selected) && selectedResultType === 'series'; const showSeasonFilter = resultFilter !== 'movies' && seriesRowsAvailable; const filteredRows = useMemo(() => { let output = [...rows]; if (resultFilter === 'movies') { output = output.filter((row) => normalizeResultType(row?.resultType) !== 'series'); } else if (resultFilter === 'series') { output = output.filter((row) => normalizeResultType(row?.resultType) === 'series'); } if (showSeasonFilter) { const normalizedFilter = String(seasonFilter || '').trim().toLowerCase(); if (normalizedFilter) { output = output.filter((row) => { if (normalizeResultType(row?.resultType) !== 'series') { return false; } const seasonNo = String(row?.seasonNumber ?? '').trim().toLowerCase(); const seasonName = String(row?.seasonName || '').trim().toLowerCase(); return seasonNo.includes(normalizedFilter) || seasonName.includes(normalizedFilter); }); } } return output; }, [rows, resultFilter, seasonFilter, showSeasonFilter]); const effectiveDiscNumber = parseDiscNumber(manualDiscNumber); const hasValidDiscNumber = effectiveDiscNumber !== null; const requiresDiscNumber = metadataProvider === 'tmdb' && selectedIsSeries && (mediaProfile === 'dvd' || mediaProfile === 'bluray'); useEffect(() => { if (!discValidationError) { return; } if (!requiresDiscNumber || hasValidDiscNumber) { setDiscValidationError(''); } }, [discValidationError, requiresDiscNumber, hasValidDiscNumber]); const titleWithPosterBody = (row) => (