Files
sublogue/frontend/src/components/scan/ResultsList.svelte
T

2530 lines
90 KiB
Svelte
Raw Normal View History

2026-01-17 21:49:22 +13:00
<script>
import { createEventDispatcher } from "svelte";
2026-01-18 23:18:38 +13:00
import StatusBadge from "../StatusBadge.svelte";
import { Button } from "../../lib/components/ui/button";
import { Skeleton } from "../../lib/components/ui/skeleton";
2026-01-17 21:49:22 +13:00
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
2026-01-18 23:18:38 +13:00
} from "../../lib/components/ui/table";
2026-01-17 21:49:22 +13:00
import {
searchTitle,
saveSuggestedMatches,
processBatch,
2026-01-18 23:18:38 +13:00
} from "../../lib/api.js";
2026-01-17 21:49:22 +13:00
const dispatch = createEventDispatcher();
export let files = [];
export let onSelectionChange = () => {};
export let disabled = false;
export let metadataProvider = "omdb";
export let metadataLanguage = "";
export let cleanMode = false;
2026-01-18 23:36:29 +13:00
export let activeIntegrations = {
omdb: true,
tmdb: true,
tvmaze: true,
wikipedia: true,
};
2026-01-17 21:49:22 +13:00
export let loading = false;
// Pagination
let currentPage = 1;
let itemsPerPage = 50;
$: totalPages = Math.ceil(files.length / itemsPerPage);
$: paginatedFiles = files.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage,
);
$: pageStart = (currentPage - 1) * itemsPerPage + 1;
$: pageEnd = Math.min(currentPage * itemsPerPage, files.length);
$: cleanModeWithKeywords = paginatedFiles.filter(
(file) => file.clean_keywords && file.clean_keywords.length > 0,
);
$: cleanModeNoKeywords = paginatedFiles.filter(
(file) => !file.clean_keywords || file.clean_keywords.length === 0,
);
2026-01-17 21:49:22 +13:00
function goToPage(page) {
currentPage = Math.max(1, Math.min(page, totalPages));
}
function nextPage() {
if (currentPage < totalPages) currentPage++;
}
function prevPage() {
if (currentPage > 1) currentPage--;
}
// Reset to page 1 when files change
$: if (files) currentPage = 1;
let showPreview = false;
let previewFile = null;
let showTitleSelector = false;
let titleSelectorFile = null;
let searchResults = [];
let searching = false;
let searchError = null;
let showMetadataDropup = false;
let hoveredPlot = null;
let tooltipPosition = { x: 0, y: 0 };
let openSearchDropdown = null;
let searchingInline = {};
let inlineSearchResults = {};
let searchInputValues = {};
let dropdownPosition = { top: 0, right: 0 };
let buttonRefs = {};
let expandedRows = {};
let bulkMatching = false;
let matchingProgress = {};
let suggestedMatches = {};
let bulkApplying = false;
let bulkApplyProgress = { current: 0, total: 0, currentFile: "" };
let bulkApplyResults = [];
let bulkApplyComplete = null; // { successful, failed, total } - shown for 5 seconds after completion
let hoveredDisabledIntegration = null;
$: selectedFiles = files.filter((f) => f.selected);
$: selectedCount = selectedFiles.length;
$: allSelected = files.length > 0 && files.every((f) => f.selected);
$: allSelectedOnPage =
paginatedFiles.length > 0 && paginatedFiles.every((f) => f.selected);
// Only count suggested matches for files that don't already have plots
$: matchedFilesCount = Object.keys(suggestedMatches).filter((filePath) => {
const file = files.find((f) => f.path === filePath);
return file && file.status !== "Has Plot" && !file.has_plot;
}).length;
// Show bulk apply section if there are matches OR if matching is in progress
$: hasMatches =
matchedFilesCount > 0 || Object.keys(matchingProgress).length > 0;
// Debug logging for reactivity
$: {
console.log("=== ResultsList Reactive Update ===");
console.log("Files count:", files.length);
console.log(
"SuggestedMatches count:",
Object.keys(suggestedMatches).length,
);
console.log("SuggestedMatches keys:", Object.keys(suggestedMatches));
console.log(
"MatchingProgress count:",
Object.keys(matchingProgress).length,
);
console.log("MatchingProgress keys:", Object.keys(matchingProgress));
console.log("PaginatedFiles count:", paginatedFiles.length);
}
function handlePlotMouseEnter(event, file) {
if (file.plot || file.summary) {
hoveredPlot = file;
updateTooltipPosition(event);
}
}
function handlePlotMouseMove(event) {
if (hoveredPlot) {
updateTooltipPosition(event);
}
}
function handlePlotMouseLeave() {
hoveredPlot = null;
}
function updateTooltipPosition(event) {
tooltipPosition = {
x: event.clientX + 10,
y: event.clientY + 10,
};
}
function toggleSearchDropdown(file, event) {
if (openSearchDropdown === file.path) {
openSearchDropdown = null;
return;
}
// Calculate position from button
const button = event.currentTarget;
const rect = button.getBoundingClientRect();
// Position dropdown to appear above button, ensuring it's visible
const dropdownHeight = 380; // Reduced from 450 due to more compact design
const viewportHeight = window.innerHeight;
// Try to position above the button first
let top = rect.top - dropdownHeight - 10; // 10px gap
// If it would go off the top of the screen, position it below instead
if (top < 20) {
top = Math.min(rect.bottom + 10, viewportHeight - dropdownHeight - 20);
}
// Ensure it's always within viewport bounds
top = Math.max(20, Math.min(top, viewportHeight - dropdownHeight - 20));
dropdownPosition = {
top: top,
right: window.innerWidth - rect.right,
};
openSearchDropdown = file.path;
// Initialize with extracted title from filename (preserving year if present)
if (!searchInputValues[file.path]) {
const cleanTitle = extractSearchableTitle(file.name, file.title);
searchInputValues[file.path] = cleanTitle;
searchInputValues = { ...searchInputValues };
}
// Perform initial search
performSearch(file);
}
function handleAddPlotWithSearch(file, event) {
// Svelte Button dispatches a custom event; the native MouseEvent is in detail.
const nativeEvent = event?.detail ?? event;
2026-01-17 21:49:22 +13:00
// Open dropdown for search
toggleSearchDropdown(file, nativeEvent);
2026-01-17 21:49:22 +13:00
}
function handleQuickAddPlot(file) {
// Close dropdown and process with auto-detection
openSearchDropdown = null;
handleProcessSingle(file);
}
async function performSearch(file) {
const query = searchInputValues[file.path]?.trim();
if (!query) return;
searchingInline[file.path] = true;
searchingInline = { ...searchingInline };
try {
const response = await searchTitle(query, "quick", {
preferredSource: metadataProvider,
language: metadataLanguage,
});
2026-01-17 21:49:22 +13:00
inlineSearchResults[file.path] = response.results || [];
inlineSearchResults = { ...inlineSearchResults };
} catch (err) {
console.error("Failed to search for titles:", err);
inlineSearchResults[file.path] = [];
inlineSearchResults = { ...inlineSearchResults };
} finally {
searchingInline[file.path] = false;
searchingInline = { ...searchingInline };
}
}
function handleSearchInput(file, event) {
searchInputValues[file.path] = event.target.value;
searchInputValues = { ...searchInputValues };
}
function handleSearchKeydown(file, event) {
if (event.key === "Enter") {
performSearch(file);
}
}
function selectInlineTitle(file, selectedResult) {
// Store as suggested match instead of immediately processing
handleManualMatch(file, selectedResult);
openSearchDropdown = null;
}
function toggleSelection(file) {
file.selected = !file.selected;
files = [...files];
onSelectionChange(selectedFiles.map((f) => f.path));
}
function toggleAll() {
const newState = !allSelected;
files = files.map((f) => ({ ...f, selected: newState }));
onSelectionChange(selectedFiles.map((f) => f.path));
}
function toggleAllOnPage() {
const newState = !allSelectedOnPage;
const pagePaths = new Set(paginatedFiles.map((f) => f.path));
files = files.map((f) =>
pagePaths.has(f.path) ? { ...f, selected: newState } : f,
);
onSelectionChange(selectedFiles.map((f) => f.path));
}
async function handleProcessSingle(file) {
// Show title selector first
titleSelectorFile = file;
searching = true;
searchError = null;
showTitleSelector = true;
// Extract clean title from filename (preserving year for better matching)
const cleanTitle = extractSearchableTitle(file.name, file.title);
try {
// Use "full" mode for manual search to get multiple results to choose from
const response = await searchTitle(cleanTitle, "full", {
preferredSource: metadataProvider,
language: metadataLanguage,
});
2026-01-17 21:49:22 +13:00
searchResults = response.results || [];
} catch (err) {
searchError = "Failed to search for titles. Please try again.";
searchResults = [];
} finally {
searching = false;
}
}
function selectTitle(selectedResult) {
// Dispatch with selected title info
// Force reprocess if file already has a plot (this is an update operation)
dispatch("processSingle", {
files: [titleSelectorFile.path],
titleOverride: selectedResult,
forceReprocess:
titleSelectorFile.status === "Has Plot" ||
titleSelectorFile.has_plot === true,
});
closeTitleSelector();
}
function skipTitleSelection() {
// Process with automatic title detection
// Force reprocess if file already has a plot (this is an update operation)
dispatch("processSingle", {
files: [titleSelectorFile.path],
forceReprocess:
titleSelectorFile.status === "Has Plot" ||
titleSelectorFile.has_plot === true,
});
closeTitleSelector();
}
function closeTitleSelector() {
showTitleSelector = false;
titleSelectorFile = null;
searchResults = [];
searchError = null;
}
function handleBulkProcess() {
const selectedPaths = selectedFiles.map((f) => f.path);
dispatch("processBulk", { files: selectedPaths });
}
async function handleBulkMatch() {
bulkMatching = true;
matchingProgress = {};
for (const file of selectedFiles) {
matchingProgress[file.path] = "matching";
matchingProgress = { ...matchingProgress };
// Extract title and perform search
const cleanTitle = extractSearchableTitle(file.name, file.title);
try {
const response = await searchTitle(cleanTitle, "quick", {
preferredSource: metadataProvider,
language: metadataLanguage,
});
2026-01-17 21:49:22 +13:00
const results = response.results || [];
if (results.length > 0) {
// Store the top result as a suggestion (don't apply yet)
const topResult = results[0];
suggestedMatches[file.path] = topResult;
suggestedMatches = { ...suggestedMatches };
matchingProgress[file.path] = "matched";
} else {
matchingProgress[file.path] = "no-match";
}
} catch (err) {
console.error(`Failed to match ${file.name}:`, err);
matchingProgress[file.path] = "error";
}
matchingProgress = { ...matchingProgress };
// Small delay to avoid overwhelming the API
await new Promise((resolve) => setTimeout(resolve, 300));
}
bulkMatching = false;
// Clear progress indicators after 3 seconds
setTimeout(() => {
matchingProgress = {};
}, 3000);
}
async function handleBulkApply() {
// Filter to only files that don't already have plots
const filesToProcess = Object.keys(suggestedMatches)
.filter((filePath) => {
const file = files.find((f) => f.path === filePath);
return file && file.status !== "Has Plot" && !file.has_plot;
})
.map((path) => ({
path,
titleOverride: suggestedMatches[path],
}));
if (filesToProcess.length === 0) {
console.log("No files to process");
return;
}
bulkApplying = true;
bulkApplyProgress = {
current: 0,
total: filesToProcess.length,
currentFile: "",
};
bulkApplyResults = [];
try {
await processBatch(filesToProcess, null, {
onStart: (data) => {
console.log("Bulk apply started:", data.total, "files");
bulkApplyProgress = { ...bulkApplyProgress, total: data.total };
},
onProgress: (data) => {
bulkApplyProgress = {
current: data.current,
total: data.total,
currentFile: data.file,
};
},
onResult: (data) => {
bulkApplyResults = [...bulkApplyResults, data];
// Update the file status in the list
const fileIndex = files.findIndex(
(f) => f.path === data.file || f.name === data.file,
);
if (fileIndex !== -1 && data.success) {
files[fileIndex] = {
...files[fileIndex],
status: "Has Plot",
has_plot: true,
};
files = [...files];
}
// Remove from suggested matches as it's been processed
const matchPath = Object.keys(suggestedMatches).find(
(p) => p === data.file || p.endsWith(data.file),
);
if (matchPath) {
delete suggestedMatches[matchPath];
suggestedMatches = { ...suggestedMatches };
}
},
onComplete: (data) => {
console.log(
"Bulk apply complete:",
data.successful,
"/",
data.total,
"successful",
);
bulkApplying = false;
// Dispatch event to notify parent of completion
dispatch("processComplete", {
successful: data.successful,
failed: data.failed,
total: data.total,
});
},
onError: (error) => {
console.error("Bulk apply error:", error);
bulkApplying = false;
},
});
} catch (err) {
console.error("Bulk apply failed:", err);
bulkApplying = false;
}
}
function removeSuggestedMatch(filePath) {
delete suggestedMatches[filePath];
suggestedMatches = { ...suggestedMatches };
}
function handleManualMatch(file, selectedResult) {
// User manually selected a different match
suggestedMatches[file.path] = selectedResult;
suggestedMatches = { ...suggestedMatches };
}
// Export function for parent component to trigger auto-match
export async function autoMatchAll() {
console.log("=== Starting autoMatchAll ===");
console.log(
"Current suggestedMatches count:",
Object.keys(suggestedMatches).length,
);
console.log("suggestedMatches paths:", Object.keys(suggestedMatches));
console.log(
"Current matchingProgress count:",
Object.keys(matchingProgress).length,
);
console.log("Current files count:", files.length);
console.log(
"Files:",
files.map((f) => ({
name: f.name,
path: f.path,
has_plot: f.has_plot,
status: f.status,
})),
);
bulkMatching = true;
// Clear old matching progress for files not in current scan
const currentFilePaths = files.map((f) => f.path);
matchingProgress = Object.fromEntries(
Object.entries(matchingProgress).filter(([path]) =>
currentFilePaths.includes(path),
),
);
// Don't reset suggestedMatches - preserve any loaded from database
for (const file of files) {
// Show matching status for files with plots (for visual feedback)
// Don't create suggested matches for them
if (file.status === "Has Plot" || file.has_plot === true) {
console.log(`File ${file.name} already has plot, showing as matched`);
matchingProgress = {
...matchingProgress,
[file.path]: "matched",
};
continue;
}
// Skip files that already have a suggested match
if (suggestedMatches[file.path]) {
console.log(`Skipping ${file.name} - already has suggested match`);
// Still show the matched status for files with existing matches
matchingProgress = {
...matchingProgress,
[file.path]: "matched",
};
continue;
}
matchingProgress = {
...matchingProgress,
[file.path]: "matching",
};
const cleanTitle = extractSearchableTitle(file.name, file.title);
console.log(
`Auto-matching ${file.name} with search query: "${cleanTitle}"`,
);
try {
const response = await searchTitle(cleanTitle, "quick", {
preferredSource: metadataProvider,
language: metadataLanguage,
});
2026-01-17 21:49:22 +13:00
const results = response.results || [];
if (results.length > 0) {
const topResult = results[0];
console.log(
`Match found for ${file.name}: ${topResult.title} (${topResult.year})`,
);
// Create new objects to trigger reactivity
suggestedMatches = {
...suggestedMatches,
[file.path]: topResult,
};
matchingProgress = {
...matchingProgress,
[file.path]: "matched",
};
} else {
console.log(
`No match found for ${file.name} with query "${cleanTitle}"`,
);
matchingProgress = {
...matchingProgress,
[file.path]: "no-match",
};
}
} catch (err) {
console.error(`Failed to match ${file.name}:`, err);
matchingProgress = {
...matchingProgress,
[file.path]: "error",
};
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
bulkMatching = false;
console.log("=== autoMatchAll complete ===");
console.log("Final matchingProgress:", matchingProgress);
console.log(
"Final suggestedMatches count:",
Object.keys(suggestedMatches).length,
);
console.log("Final suggestedMatches:", suggestedMatches);
console.log(
"Sample file paths from files array:",
files.slice(0, 3).map((f) => f.path),
);
// Save all suggested matches to database
try {
await saveSuggestedMatches(suggestedMatches);
console.log("Saved suggested matches to database");
} catch (err) {
console.error("Failed to save suggested matches:", err);
}
// Keep the matching progress visible (don't clear it after 3 seconds)
// User can manually clear by rescanning or clicking Clear Results
}
// Export function to auto-match a specific batch of files (for streaming)
export async function autoMatchBatch(batch) {
console.log("=== Starting autoMatchBatch ===");
console.log("Batch size:", batch.length);
for (const file of batch) {
// Skip files that already have plots
if (file.status === "Has Plot" || file.has_plot === true) {
console.log(`File ${file.name} already has plot, showing as matched`);
matchingProgress = {
...matchingProgress,
[file.path]: "matched",
};
continue;
}
// Skip files that already have a suggested match
if (suggestedMatches[file.path]) {
console.log(`Skipping ${file.name} - already has suggested match`);
matchingProgress = {
...matchingProgress,
[file.path]: "matched",
};
continue;
}
matchingProgress = {
...matchingProgress,
[file.path]: "matching",
};
const cleanTitle = extractSearchableTitle(file.name, file.title);
console.log(
`Auto-matching ${file.name} with search query: "${cleanTitle}"`,
);
try {
const response = await searchTitle(cleanTitle, "quick", {
preferredSource: metadataProvider,
language: metadataLanguage,
});
2026-01-17 21:49:22 +13:00
const results = response.results || [];
if (results.length > 0) {
const topResult = results[0];
console.log(
`Match found for ${file.name}: ${topResult.title} (${topResult.year})`,
);
suggestedMatches = {
...suggestedMatches,
[file.path]: topResult,
};
matchingProgress = {
...matchingProgress,
[file.path]: "matched",
};
// Save this match to database immediately
try {
await saveSuggestedMatches({ [file.path]: topResult });
} catch (err) {
console.error("Failed to save suggested match for", file.name, err);
}
} else {
console.log(
`No match found for ${file.name} with query "${cleanTitle}"`,
);
matchingProgress = {
...matchingProgress,
[file.path]: "no-match",
};
}
} catch (err) {
console.error(`Failed to match ${file.name}:`, err);
matchingProgress = {
...matchingProgress,
[file.path]: "error",
};
}
// Small delay between searches to avoid rate limiting
await new Promise((resolve) => setTimeout(resolve, 300));
}
console.log("=== autoMatchBatch complete ===");
}
// Export function to load suggested matches from scan result
export function loadSuggestedMatches(matches) {
console.log(
"loadSuggestedMatches called with",
Object.keys(matches || {}).length,
"matches",
);
suggestedMatches = { ...(matches || {}) };
// Also populate matching progress for visual indicators
const newProgress = {};
for (const filePath in suggestedMatches) {
newProgress[filePath] = "matched";
}
matchingProgress = { ...matchingProgress, ...newProgress };
console.log(
"Updated suggestedMatches:",
Object.keys(suggestedMatches).length,
);
console.log(
"Updated matchingProgress:",
Object.keys(matchingProgress).length,
);
}
// Export function to load matching progress from scan result
export function loadMatchingProgress(progress) {
matchingProgress = progress || {};
}
// Export function to clear all matching state
export function clearMatchingState() {
console.log("=== Clearing all ResultsList state ===");
// Reset ALL state variables to guarantee clean slate
matchingProgress = {};
suggestedMatches = {};
searchingInline = {};
inlineSearchResults = {};
searchInputValues = {};
expandedRows = {};
openSearchDropdown = null;
bulkMatching = false;
showTitleSelector = false;
titleSelectorFile = null;
searchResults = [];
searching = false;
searchError = null;
showMetadataDropup = false;
hoveredPlot = null;
showPreview = false;
previewFile = null;
console.log("ResultsList state cleared successfully");
}
function openPreview(file) {
previewFile = file;
showPreview = true;
}
function closePreview() {
showPreview = false;
previewFile = null;
}
function getMediaType(file) {
return file.media_type || "N/A";
}
function getTitle(file) {
return file.title || file.name.replace(/\.[^/.]+$/, "");
}
function getRating(file) {
return file.imdb_rating || file.rating || "N/A";
}
function getRuntime(file) {
return file.runtime || "N/A";
}
function getPlotPreview(file) {
if (!file.plot && !file.summary) return "Not loaded";
const text = file.plot || file.summary;
return text.length > 100 ? text.substring(0, 100) + "..." : text;
}
function hasMetadata(file) {
// Check if file has meaningful metadata to preview
return !!(
file.plot ||
file.summary ||
file.title ||
file.imdb_rating ||
file.rating ||
file.runtime
);
}
function toggleMetadataDropup() {
if (!hasActiveIntegrations) return;
showMetadataDropup = !showMetadataDropup;
}
function selectMetadataSource(source) {
metadataProvider = source;
showMetadataDropup = false;
// Dispatch event to notify parent component
dispatch("metadataSourceChange", { source });
}
function getMetadataSourceLabel() {
if (!metadataProvider) return "Select source";
if (metadataProvider === "both") return "OMDb + TMDb";
if (metadataProvider === "tvmaze") return "TVmaze";
2026-01-18 23:36:29 +13:00
if (metadataProvider === "wikipedia") return "Wikipedia";
2026-01-17 21:49:22 +13:00
return metadataProvider.toUpperCase();
}
$: omdbActive = activeIntegrations?.omdb ?? false;
$: tmdbActive = activeIntegrations?.tmdb ?? false;
$: tvmazeActive = activeIntegrations?.tvmaze ?? false;
2026-01-18 23:36:29 +13:00
$: wikipediaActive = activeIntegrations?.wikipedia ?? false;
$: hasActiveIntegrations = omdbActive || tmdbActive || tvmazeActive || wikipediaActive;
2026-01-17 21:49:22 +13:00
$: if (!hasActiveIntegrations && showMetadataDropup) {
showMetadataDropup = false;
}
$: if (cleanMode && showMetadataDropup) {
showMetadataDropup = false;
}
$: if (cleanMode && openSearchDropdown) {
openSearchDropdown = null;
}
2026-01-17 21:49:22 +13:00
$: metadataDropdownOptions = [
{
value: "omdb",
label: "OMDb",
description: "Open Movie Database",
enabled: omdbActive,
},
{
value: "tmdb",
label: "TMDb",
description: "The Movie Database",
enabled: tmdbActive,
},
{
value: "tvmaze",
label: "TVmaze",
description: "TV metadata without an API key",
enabled: tvmazeActive,
},
2026-01-18 23:36:29 +13:00
{
value: "wikipedia",
label: "Wikipedia",
description: "Strict encyclopedia summaries",
enabled: wikipediaActive,
},
2026-01-17 21:49:22 +13:00
{
value: "both",
label: "Both",
description: "OMDb + TMDb fallback",
enabled: omdbActive && tmdbActive,
},
];
function toggleRowExpand(filePath) {
expandedRows[filePath] = !expandedRows[filePath];
expandedRows = { ...expandedRows };
}
/**
* Sanitizes movie title by removing empty parentheses while preserving valid years
* @param {string} title - The title to sanitize
* @returns {string} - The sanitized title
*
* Examples:
* "Xeno ()" → "Xeno"
* "Xeno ( )" → "Xeno"
* "Xeno (2025)" → "Xeno (2025)"
* "Xeno" → "Xeno"
*/
function sanitizeTitle(title) {
if (!title) return title;
// Remove empty parentheses (with optional whitespace inside) and trim
return title
.replace(/\(\s*\)/g, "") // Remove () or ( ) or ( ), etc.
.trim();
}
/**
* Extracts a clean, searchable title from a filename or existing title
* Removes year, language codes, and quality tags for better OMDb matching
* @param {string} filename - The filename to extract from (e.g., "Xeno (2025).srt")
* @param {string} existingTitle - Optional existing title from metadata
* @returns {string} - Clean title ready for search (without year)
*
* Examples:
* "Eternity (2025).en.srt" → "Eternity"
* "The.Matrix.1999.srt" → "The Matrix"
* "Inception_2010.720p.BluRay.srt" → "Inception"
* "People We Meet on Vacation (2026).srt" → "People We Meet on Vacation"
*/
function extractSearchableTitle(filename, existingTitle = null) {
// If we have an existing title from metadata, prefer it
if (existingTitle) {
return sanitizeTitle(existingTitle);
}
// Remove language codes and subtitle format extensions (e.g., .en.srt, .en.sdh.srt, .eng.srt)
let title = filename
.replace(
/\.(en|eng|es|fr|de|it|pt|ru|ja|zh|ko|ar)\.(sdh|cc|hi|forced)\.srt$/i,
".srt",
) // Remove language.format.srt
.replace(/\.(en|eng|es|fr|de|it|pt|ru|ja|zh|ko|ar)\.srt$/i, ".srt") // Remove language.srt
.replace(/\.[^/.]+$/, ""); // Remove final extension
// Extract year if present (including in parentheses like "Movie (2024)")
const yearInParensMatch = title.match(/\((\d{4})\)/);
const yearStandaloneMatch = title.match(/\b(19|20)\d{2}\b/);
const year = yearInParensMatch
? yearInParensMatch[1]
: yearStandaloneMatch
? yearStandaloneMatch[0]
: null;
// Remove common release group tags and quality indicators
title = title
.replace(/\[[^\]]+\]/g, "") // Remove anything in square brackets [RARBG], [YTS], etc.
.replace(
/\b(REPACK|PROPER|UNRATED|EXTENDED|DIRECTORS?.CUT|DC|THEATRICAL|IMAX)\b/gi,
"",
) // Remove release types
.replace(
/\b(720p|1080p|2160p|4k|UHD|HD|SD|BluRay|BRRip|WEB-?DL|WEBRip|HDRip|DVDRip|BDRip)\b/gi,
"",
) // Remove quality tags
.replace(/\b(x264|x265|H\.?264|H\.?265|HEVC|AAC|AC3|DTS|DD5\.1)\b/gi, "") // Remove codec tags
.replace(/\b(MULTI|DUAL|SUBBED|DUBBED)\b/gi, "") // Remove audio tags
.replace(/\-[A-Z0-9]+$/i, "") // Remove release group at end (-RARBG, -YTS, etc.)
.replace(/[._\-]+/g, " ") // Replace separators with spaces
.replace(/\s+/g, " ") // Normalize multiple spaces
.trim();
// Remove year from the title - OMDb search works better with just the title
// The year can cause false negatives (e.g., "Eternity 2025" returns nothing, but "Eternity" finds the movie)
const titleWithoutYear = title
.replace(/\(?\d{4}\)?/g, "")
.replace(/\s+/g, " ")
.trim();
// Final cleanup
return sanitizeTitle(titleWithoutYear).trim();
}
</script>
{#if files.length === 0}
<div class="py-16 text-center">
<p class="text-text-secondary">No subtitle files found.</p>
<p class="text-text-tertiary text-sm mt-1">
Start a scan to discover SRT files.
</p>
</div>
{:else}
<div class="space-y-4">
<!-- Bulk Action Toolbar -->
{#if selectedCount > 0}
<div class="bg-accent/10 border border-accent/30 rounded-xl px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<svg
class="w-5 h-5 text-accent"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-sm font-medium">
{selectedCount}
{selectedCount === 1 ? "file" : "files"} selected
</span>
</div>
<div class="flex items-center gap-3">
{#if !cleanMode}
2026-01-17 21:49:22 +13:00
<!-- Metadata Source Selector -->
<div class="relative">
<button
on:click={toggleMetadataDropup}
class="px-4 py-2.5 bg-bg-card border border-white/[0.08] hover:border-white/20
text-[13px] font-medium rounded-xl transition-all flex items-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed"
disabled={!hasActiveIntegrations}
>
<svg
class="w-4 h-4 text-text-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"
/>
</svg>
<span>{getMetadataSourceLabel()}</span>
<svg
class="w-3.5 h-3.5 text-text-tertiary transition-transform {showMetadataDropup
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<!-- Dropup Menu -->
{#if showMetadataDropup}
<div
class="absolute top-full mt-2 right-0 w-56 bg-bg-card border border-white/[0.08] rounded-xl shadow-2xl overflow-visible z-50"
>
<div
class="px-3 py-2 border-b border-white/[0.08] bg-bg-secondary"
>
<p
class="text-[11px] text-text-tertiary uppercase tracking-wide"
>
Metadata Source
</p>
</div>
<div class="py-1">
{#each metadataDropdownOptions as option}
<button
on:click={() => option.enabled && selectMetadataSource(option.value)}
class="w-full px-4 py-2.5 text-left transition-colors flex items-center justify-between group {option.enabled
? 'hover:bg-bg-hover'
: 'opacity-40 cursor-not-allowed'}"
disabled={!option.enabled}
on:mouseenter={() =>
(hoveredDisabledIntegration = option.enabled ? null : option.value)}
on:mouseleave={() => (hoveredDisabledIntegration = null)}
>
<div class="flex items-center gap-3">
<div
class="w-2 h-2 rounded-full {metadataProvider ===
option.value
? 'bg-accent'
: 'bg-white/[0.08]'}"
></div>
<div>
<div class="text-[13px] font-medium">
{option.label}
</div>
<div class="text-[11px] text-text-tertiary">
{option.description}
</div>
</div>
</div>
{#if metadataProvider === option.value}
<svg
class="w-4 h-4 text-accent"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{/if}
{#if hoveredDisabledIntegration === option.value}
<div
class="pointer-events-none absolute right-0 top-1/2 -translate-y-1/2 translate-x-full ml-2 whitespace-nowrap rounded-lg border border-white/10 bg-bg-card px-3 py-2 text-[11px] text-text-secondary shadow-[0_12px_30px_rgba(0,0,0,0.35)] z-50"
>
Enable this in Settings under Integrations.
</div>
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
<Button
on:click={handleBulkMatch}
disabled={disabled || bulkMatching}
size="sm"
className="bg-blue-500 text-white hover:bg-blue-600 gap-2"
>
{#if bulkMatching}
<svg
class="w-4 h-4 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Matching...
{:else}
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
Bulk Match
{/if}
</Button>
{/if}
2026-01-17 21:49:22 +13:00
<Button
on:click={handleBulkProcess}
{disabled}
size="sm"
className="bg-white text-black hover:bg-white/90"
>
{cleanMode ? "Clean Selected" : "Add Subtitles to Selected"}
2026-01-17 21:49:22 +13:00
</Button>
</div>
</div>
</div>
{/if}
<!-- Table -->
<div class="rounded-lg border border-border bg-card overflow-hidden">
<div class="max-h-[650px] overflow-y-auto overflow-x-auto">
<Table className="w-full table-fixed">
2026-01-17 21:49:22 +13:00
<TableHeader className="sticky top-0 bg-muted/60 backdrop-blur border-b border-border">
<TableRow className="uppercase tracking-wider">
<TableHead className="w-12">
<input
type="checkbox"
checked={allSelectedOnPage}
on:change={toggleAllOnPage}
class="h-4 w-4 rounded border-input bg-background text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</TableHead>
<TableHead className="w-10"></TableHead>
<TableHead className="w-28">Status</TableHead>
<TableHead className="w-[38%]">Filename</TableHead>
<TableHead className="w-[28%]">
{cleanMode ? "Clean Scan" : "Matched Result"}
</TableHead>
2026-01-17 21:49:22 +13:00
<TableHead className="w-64 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#if loading && files.length === 0}
2026-01-17 21:49:22 +13:00
{#each Array(6) as _}
<TableRow>
<TableCell className="w-12">
<Skeleton className="h-4 w-4 rounded" />
</TableCell>
<TableCell className="w-10">
<Skeleton className="h-4 w-4" />
</TableCell>
<TableCell className="w-28">
<Skeleton className="h-5 w-20" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-64" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-48" />
</TableCell>
<TableCell className="w-64">
<div class="flex items-center justify-end gap-2">
<Skeleton className="h-8 w-24" />
<Skeleton className="h-8 w-20" />
</div>
</TableCell>
</TableRow>
{/each}
{:else if cleanMode}
{#if cleanModeWithKeywords.length > 0}
<TableRow className="bg-bg-secondary/60">
<TableCell colspan="6">
<div class="text-[11px] uppercase tracking-wide text-text-tertiary">
Results With Keywords
</div>
</TableCell>
</TableRow>
{#each cleanModeWithKeywords as file (file.path)}
<TableRow data-state={file.selected ? "selected" : undefined}>
<TableCell className="w-12">
<input
type="checkbox"
checked={file.selected}
on:change={() => toggleSelection(file)}
class="h-4 w-4 rounded border-input bg-background text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</TableCell>
<TableCell className="w-10">
<button
on:click={() => toggleRowExpand(file.path)}
class="text-text-tertiary hover:text-foreground transition-colors"
title="Toggle details"
>
<svg
class="w-4 h-4 transition-transform {expandedRows[
file.path
]
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</TableCell>
<TableCell className="w-28">
<StatusBadge status={file.status} />
</TableCell>
<TableCell className="min-w-0">
<span
class="text-[13px] font-mono truncate block"
title={file.name}
>
{file.name}
</span>
</TableCell>
<TableCell className="min-w-0">
<div class="text-[12px] text-text-secondary">
Detected: {file.clean_keywords.join(", ")}
</div>
</TableCell>
<TableCell className="w-64">
<div class="flex items-center justify-end gap-2">
<span class="text-[11px] text-text-tertiary">
Clean only
</span>
</div>
</TableCell>
</TableRow>
{#if expandedRows[file.path]}
<TableRow className="bg-muted/40">
<TableCell colspan="6">
<div class="ml-16 space-y-3">
<div>
<div
class="text-[10px] text-text-tertiary uppercase tracking-wide mb-1.5"
>
Detected Keywords
</div>
<div class="text-[13px] text-text-secondary">
{file.clean_keywords.join(", ")}
</div>
</div>
<div>
<div
class="text-[10px] text-text-tertiary uppercase tracking-wide mb-1.5"
>
Cleanup Summary
</div>
<div
class="text-[13px] text-text-secondary leading-relaxed bg-bg-primary/50 border border-white/[0.08] rounded-lg p-3"
>
{file.summary || "No changes needed"}
</div>
</div>
</div>
</TableCell>
</TableRow>
{/if}
{/each}
{/if}
{#if cleanModeNoKeywords.length > 0}
<TableRow className="bg-bg-secondary/60">
<TableCell colspan="6">
<div class="text-[11px] uppercase tracking-wide text-text-tertiary">
Clean Files (No Keywords Found)
</div>
</TableCell>
</TableRow>
{#each cleanModeNoKeywords as file (file.path)}
<TableRow data-state={file.selected ? "selected" : undefined}>
<TableCell className="w-12">
<input
type="checkbox"
checked={file.selected}
on:change={() => toggleSelection(file)}
class="h-4 w-4 rounded border-input bg-background text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</TableCell>
<TableCell className="w-10">
<button
on:click={() => toggleRowExpand(file.path)}
class="text-text-tertiary hover:text-foreground transition-colors"
title="Toggle details"
>
<svg
class="w-4 h-4 transition-transform {expandedRows[
file.path
]
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</TableCell>
<TableCell className="w-28">
<StatusBadge status={file.status} />
</TableCell>
<TableCell className="min-w-0">
<span
class="text-[13px] font-mono truncate block"
title={file.name}
>
{file.name}
</span>
</TableCell>
<TableCell className="min-w-0">
<div class="text-[12px] text-text-secondary">
Detected: None
</div>
</TableCell>
<TableCell className="w-64">
<div class="flex items-center justify-end gap-2">
<span class="text-[11px] text-text-tertiary">
Clean only
</span>
</div>
</TableCell>
</TableRow>
{#if expandedRows[file.path]}
<TableRow className="bg-muted/40">
<TableCell colspan="6">
<div class="ml-16 space-y-3">
<div>
<div
class="text-[10px] text-text-tertiary uppercase tracking-wide mb-1.5"
>
Detected Keywords
</div>
<div class="text-[13px] text-text-secondary">
None
</div>
</div>
<div>
<div
class="text-[10px] text-text-tertiary uppercase tracking-wide mb-1.5"
>
Cleanup Summary
</div>
<div
class="text-[13px] text-text-secondary leading-relaxed bg-bg-primary/50 border border-white/[0.08] rounded-lg p-3"
>
{file.summary || "No changes needed"}
</div>
</div>
</div>
</TableCell>
</TableRow>
{/if}
{/each}
{/if}
2026-01-17 21:49:22 +13:00
{:else}
{#each paginatedFiles as file (file.path)}
<TableRow data-state={file.selected ? "selected" : undefined}>
<TableCell className="w-12">
<input
type="checkbox"
checked={file.selected}
on:change={() => toggleSelection(file)}
class="h-4 w-4 rounded border-input bg-background text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</TableCell>
<TableCell className="w-10">
<button
on:click={() => toggleRowExpand(file.path)}
class="text-text-tertiary hover:text-foreground transition-colors"
title="Toggle details"
>
<svg
class="w-4 h-4 transition-transform {expandedRows[
file.path
]
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</TableCell>
<TableCell className="w-28">
<StatusBadge status={file.status} />
</TableCell>
<TableCell className="min-w-0">
2026-01-17 21:49:22 +13:00
<span
class="text-[13px] font-mono truncate block"
title={file.name}
>
{file.name}
</span>
</TableCell>
<TableCell className="min-w-0">
{#if cleanMode}
<div class="text-[12px] text-text-secondary">
{#if file.clean_keywords && file.clean_keywords.length > 0}
Detected: {file.clean_keywords.join(", ")}
{:else}
Detected: None
{/if}
</div>
{:else}
2026-01-17 21:49:22 +13:00
<div class="flex items-center gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-[13px] truncate block">
{getTitle(file)}
</span>
{#if file.title}
<span
class="px-2 py-0.5 text-[10px] font-medium bg-accent/20 text-accent rounded-full whitespace-nowrap flex-shrink-0"
>
</span>
{/if}
</div>
<!-- Show suggested match -->
{#if suggestedMatches[file.path]}
<div class="flex items-center gap-2 mt-1">
<span class="text-[11px] text-blue-400"
>→ Matched:</span
>
<span class="text-[11px] text-blue-300 truncate block"
2026-01-17 21:49:22 +13:00
>{suggestedMatches[file.path]?.title || "Unknown"} ({suggestedMatches[
file.path
]?.year || "N/A"})</span
>
<button
on:click={() => removeSuggestedMatch(file.path)}
class="text-blue-400/60 hover:text-blue-300 transition-colors"
title="Remove suggestion"
>
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/if}
</div>
<!-- Bulk match status indicator -->
{#if matchingProgress[file.path]}
{#if matchingProgress[file.path] === "matching"}
<div
class="flex items-center gap-1.5 px-2 py-0.5 bg-blue-500/20 text-blue-400 rounded-full text-[10px] font-medium flex-shrink-0"
>
<svg
class="w-3 h-3 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Matching
</div>
{:else if matchingProgress[file.path] === "matched"}
<div
class="flex items-center gap-1.5 px-2 py-0.5 bg-green-500/20 text-green-400 rounded-full text-[10px] font-medium flex-shrink-0"
>
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
Matched
</div>
{:else if matchingProgress[file.path] === "no-match"}
<div
class="flex items-center gap-1.5 px-2 py-0.5 bg-yellow-500/20 text-yellow-400 rounded-full text-[10px] font-medium flex-shrink-0"
>
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
No Match
</div>
{:else if matchingProgress[file.path] === "error"}
<div
class="flex items-center gap-1.5 px-2 py-0.5 bg-red-500/20 text-red-400 rounded-full text-[10px] font-medium flex-shrink-0"
>
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
Error
</div>
{/if}
{/if}
</div>
{/if}
2026-01-17 21:49:22 +13:00
</TableCell>
<TableCell className="w-64">
<div class="flex items-center justify-end gap-2">
{#if cleanMode}
<span class="text-[11px] text-text-tertiary">
Clean only
</span>
{:else}
2026-01-17 21:49:22 +13:00
{#if suggestedMatches[file.path]}
<!-- Show quick apply button for suggested matches -->
<Button
on:click={() => {
dispatch("processSingle", {
files: [file.path],
titleOverride: suggestedMatches[file.path],
forceReprocess:
file.status === "Has Plot" ||
file.has_plot === true,
});
removeSuggestedMatch(file.path);
}}
{disabled}
size="sm"
className="bg-blue-500 text-white hover:bg-blue-600 whitespace-nowrap"
title="Apply suggested match"
>
Apply Match
</Button>
{/if}
<Button
on:click={(e) => handleAddPlotWithSearch(file, e)}
disabled={disabled || file.status === "Skipped"}
size="sm"
variant="outline"
className="border-green-500 text-green-500 hover:bg-green-500/10 whitespace-nowrap gap-1.5"
title={suggestedMatches[file.path]
? "Search for different match"
: file.status === "Has Plot"
? "Update plot for this file"
: "Add plot to this file"}
>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{suggestedMatches[file.path]
? "Search"
: file.status === "Has Plot"
? "Update"
: "Add Plot"}
</Button>
{/if}
2026-01-17 21:49:22 +13:00
</div>
</TableCell>
</TableRow>
<!-- Expandable details row -->
{#if expandedRows[file.path]}
<TableRow className="bg-muted/40">
<TableCell colspan="6">
{#if cleanMode}
<div class="ml-16 space-y-3">
<div>
<div
class="text-[10px] text-text-tertiary uppercase tracking-wide mb-1.5"
>
Detected Keywords
</div>
<div class="text-[13px] text-text-secondary">
{#if file.clean_keywords && file.clean_keywords.length > 0}
{file.clean_keywords.join(", ")}
{:else}
None
{/if}
</div>
</div>
<div>
<div
class="text-[10px] text-text-tertiary uppercase tracking-wide mb-1.5"
>
Cleanup Summary
</div>
<div
class="text-[13px] text-text-secondary leading-relaxed bg-bg-primary/50 border border-white/[0.08] rounded-lg p-3"
>
{file.summary || "No changes needed"}
</div>
</div>
</div>
{:else}
2026-01-17 21:49:22 +13:00
<div class="grid grid-cols-3 gap-6 ml-16">
<!-- Rating -->
<div>
<div
class="text-[10px] text-text-tertiary uppercase tracking-wide mb-1.5"
>
Rating
</div>
<div class="text-[13px] text-text-secondary">
{getRating(file) !== "N/A"
? "⭐ " + getRating(file)
: "N/A"}
</div>
</div>
<!-- Runtime -->
<div>
<div
class="text-[10px] text-text-tertiary uppercase tracking-wide mb-1.5"
>
Runtime
</div>
<div class="text-[13px] text-text-secondary">
{getRuntime(file) !== "N/A"
? "⏱ " + getRuntime(file)
: "N/A"}
</div>
</div>
<!-- Year -->
<div>
<div
class="text-[10px] text-text-tertiary uppercase tracking-wide mb-1.5"
>
Year
</div>
<div class="text-[13px] text-text-secondary">
{file.year || "N/A"}
</div>
</div>
<!-- Plot - Full width -->
<div class="col-span-3">
<div
class="text-[10px] text-text-tertiary uppercase tracking-wide mb-1.5"
>
Plot Summary
</div>
<div
class="text-[13px] text-text-secondary leading-relaxed bg-bg-primary/50 border border-white/[0.08] rounded-lg p-3"
>
{#if file.plot || file.summary}
{file.plot || file.summary}
{:else}
<span class="text-text-tertiary italic"
>Not loaded</span
>
{/if}
</div>
</div>
</div>
{/if}
2026-01-17 21:49:22 +13:00
</TableCell>
</TableRow>
{/if}
{/each}
{/if}
</TableBody>
</Table>
</div>
<!-- Pagination Controls -->
{#if totalPages > 1}
<div
class="px-6 py-4 bg-bg-secondary border-t border-white/[0.08] flex items-center justify-between"
>
<div class="text-[13px] text-text-secondary">
Showing <span class="text-white font-medium"
>{pageStart}-{pageEnd}</span
>
of <span class="text-white font-medium">{files.length}</span> files
</div>
<div class="flex items-center gap-2">
<button
on:click={prevPage}
disabled={currentPage === 1}
class="px-3 py-1.5 text-[13px] text-text-secondary hover:text-white hover:bg-bg-hover disabled:opacity-30 disabled:cursor-not-allowed rounded-lg transition-all"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<div class="flex items-center gap-1">
{#each Array(totalPages) as _, i}
{@const page = i + 1}
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
<button
on:click={() => goToPage(page)}
class="min-w-[32px] px-2 py-1.5 text-[13px] rounded-lg transition-all {page ===
currentPage
? 'bg-accent text-white font-medium'
: 'text-text-secondary hover:text-white hover:bg-bg-hover'}"
>
{page}
</button>
{:else if page === currentPage - 2 || page === currentPage + 2}
<span class="px-2 text-text-tertiary">...</span>
{/if}
{/each}
</div>
<button
on:click={nextPage}
disabled={currentPage === totalPages}
class="px-3 py-1.5 text-[13px] text-text-secondary hover:text-white hover:bg-bg-hover disabled:opacity-30 disabled:cursor-not-allowed rounded-lg transition-all"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
</div>
{/if}
</div>
<!-- Bulk Apply Section -->
{#if !cleanMode && (hasMatches || bulkApplying)}
2026-01-17 21:49:22 +13:00
<div
class="bg-blue-500/10 border border-blue-500/30 rounded-xl px-6 py-4"
>
{#if bulkApplying}
<!-- Inline Progress Display -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="relative">
<svg
class="w-5 h-5 text-blue-400 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</div>
<div>
<span class="text-sm font-medium text-blue-200">
Processing {bulkApplyProgress.current} of {bulkApplyProgress.total}
files...
</span>
<p class="text-xs text-blue-300/70 mt-0.5 truncate max-w-md">
{bulkApplyProgress.currentFile || "Starting..."}
</p>
</div>
</div>
<span class="text-[13px] font-medium text-blue-300 tabular-nums">
{Math.round(
(bulkApplyProgress.current / bulkApplyProgress.total) * 100,
) || 0}%
</span>
</div>
<!-- Progress Bar -->
<div
class="w-full bg-blue-900/30 rounded-full h-1.5 overflow-hidden"
>
<div
class="bg-blue-400 h-full rounded-full transition-all duration-300 ease-out"
style="width: {(bulkApplyProgress.current /
bulkApplyProgress.total) *
100 || 0}%"
></div>
</div>
<!-- Recent Results -->
{#if bulkApplyResults.length > 0}
<div class="flex flex-wrap gap-2 mt-2">
{#each bulkApplyResults.slice(-5) as result}
<span
class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] {result.success
? 'bg-green-500/20 text-green-300'
: 'bg-red-500/20 text-red-300'}"
>
{#if result.success}
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{:else}
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{/if}
{result.file}
</span>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Normal Ready State -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<svg
class="w-5 h-5 text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
{#if matchedFilesCount > 0}
<span class="text-sm font-medium text-blue-200">
{matchedFilesCount}
{matchedFilesCount === 1 ? "file" : "files"} matched and ready
</span>
<p class="text-xs text-blue-300/70 mt-0.5">
Review the suggested matches above, then click "Bulk Apply"
to process all
</p>
{:else}
<span class="text-sm font-medium text-blue-200">
Matching complete
</span>
<p class="text-xs text-blue-300/70 mt-0.5">
All files have been processed or matched
</p>
{/if}
</div>
</div>
{#if matchedFilesCount > 0}
<Button
on:click={handleBulkApply}
{disabled}
size="sm"
className="bg-blue-500 text-white hover:bg-blue-600 gap-2"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
Bulk Apply
</Button>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Search Dropdown (rendered outside table with fixed positioning) -->
{#if openSearchDropdown}
{@const file = files.find((f) => f.path === openSearchDropdown)}
{#if file}
<div
class="fixed w-96 bg-bg-card border border-white/10 rounded-xl overflow-hidden z-[100]"
style="top: {dropdownPosition.top}px; right: {dropdownPosition.right}px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.3);"
>
<!-- Header -->
<div
class="px-4 py-3 border-b border-white/[0.08] bg-bg-secondary sticky top-0 z-10"
>
<div class="flex items-center justify-between mb-3">
<p class="text-[11px] text-text-tertiary uppercase tracking-wide">
{file.status === "Has Plot" ? "Update Plot" : "Add Plot"}
</p>
<button
on:click={() => (openSearchDropdown = null)}
class="text-text-tertiary hover:text-white transition-colors"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Search Input -->
<div class="relative mb-3">
<input
type="text"
value={searchInputValues[file.path] || ""}
on:input={(e) => handleSearchInput(file, e)}
on:keydown={(e) => handleSearchKeydown(file, e)}
placeholder="Search for title..."
class="w-full px-3 py-2 pr-10 text-[13px] bg-bg-primary border border-white/20 focus:border-accent rounded-lg transition-all focus:outline-none"
/>
{#if searchingInline[file.path]}
<div class="absolute right-3 top-1/2 -translate-y-1/2">
<div
class="w-4 h-4 border-2 border-accent/30 border-t-accent rounded-full animate-spin"
></div>
</div>
{:else}
<button
on:click={() => performSearch(file)}
disabled={!searchInputValues[file.path]?.trim()}
class="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-text-secondary hover:text-accent transition-colors disabled:opacity-30"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</button>
{/if}
</div>
<!-- Quick Action Button -->
<button
on:click={() => handleQuickAddPlot(file)}
class="w-full px-3 py-2 text-[12px] text-accent hover:text-white border border-accent/30 hover:border-accent hover:bg-accent/10 rounded-lg transition-all"
>
Use Auto-Detection (Skip Search)
</button>
</div>
<!-- Results Section -->
<div class="max-h-64 overflow-y-auto">
{#if inlineSearchResults[file.path]?.length === 0}
<div class="px-4 py-8 text-center">
<p class="text-[13px] text-text-secondary">
No results found for "{searchInputValues[file.path]}"
</p>
<p class="text-[11px] text-text-tertiary mt-2">
Try a different search term or use auto-detection
</p>
</div>
{:else if inlineSearchResults[file.path]}
<div class="divide-y divide-white/[0.08]">
{#each [...inlineSearchResults[file.path]].sort((a, b) => {
const yearA = parseInt(a.year) || 0;
const yearB = parseInt(b.year) || 0;
return yearB - yearA;
}) as result}
<button
on:click={() => selectInlineTitle(file, result)}
class="w-full text-left px-4 py-2.5 hover:bg-accent/10 transition-colors"
>
<div class="flex items-start gap-2.5">
{#if result.poster && result.poster !== "N/A"}
<img
src={result.poster}
alt={result.title}
class="w-10 h-14 object-cover rounded flex-shrink-0"
/>
{:else}
<div
class="w-10 h-14 bg-bg-secondary rounded flex items-center justify-center flex-shrink-0"
>
<svg
class="w-5 h-5 text-text-tertiary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"
/>
</svg>
</div>
{/if}
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 mb-0.5">
<span class="text-[12px] font-medium truncate"
>{result.title}</span
>
<span class="text-[10px] text-text-tertiary flex-shrink-0"
>({result.year})</span
>
</div>
<div
class="flex items-center gap-2.5 text-[10px] text-text-secondary mb-1"
>
<span class="capitalize"
>{result.media_type || "movie"}</span
>
{#if result.imdb_rating && result.imdb_rating !== "N/A"}
<span>{result.imdb_rating}</span>
{/if}
{#if result.runtime && result.runtime !== "N/A"}
<span>{result.runtime}</span>
{/if}
</div>
<p class="text-[10px] text-text-tertiary line-clamp-1">
{result.plot}
</p>
</div>
</div>
</button>
{/each}
</div>
{/if}
</div>
</div>
{/if}
{/if}
<!-- Preview Modal -->
{#if showPreview && previewFile}
<div
class="fixed inset-0 bg-black/95 flex items-center justify-center z-50 p-4"
on:click={closePreview}
role="button"
tabindex="-1"
on:keydown={(e) => e.key === "Escape" && closePreview()}
>
<div
class="bg-bg-card border border-border rounded-2xl p-8 max-w-3xl w-full max-h-[90vh] overflow-y-auto"
on:click|stopPropagation
role="dialog"
tabindex="-1"
on:keydown
>
<div class="flex items-start justify-between mb-6">
<div class="flex-1">
<h3 class="text-lg font-medium mb-1">{getTitle(previewFile)}</h3>
<p class="text-sm text-text-secondary font-mono">
{previewFile.name}
</p>
</div>
<button
on:click={closePreview}
class="text-text-secondary hover:text-white transition-colors"
>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="space-y-6">
<!-- Status -->
<div>
<div class="text-xs text-text-tertiary mb-2 uppercase tracking-wide">
Status
</div>
<StatusBadge status={previewFile.status} />
</div>
<!-- Metadata Grid -->
<div class="grid grid-cols-2 gap-6">
<div>
<div
class="text-xs text-text-tertiary mb-2 uppercase tracking-wide"
>
Media Type
</div>
<div class="text-sm capitalize">{getMediaType(previewFile)}</div>
</div>
<div>
<div
class="text-xs text-text-tertiary mb-2 uppercase tracking-wide"
>
Rating
</div>
<div class="text-sm">{getRating(previewFile)}</div>
</div>
<div>
<div
class="text-xs text-text-tertiary mb-2 uppercase tracking-wide"
>
Runtime
</div>
<div class="text-sm">{getRuntime(previewFile)}</div>
</div>
<div>
<div
class="text-xs text-text-tertiary mb-2 uppercase tracking-wide"
>
Year
</div>
<div class="text-sm">{previewFile.year || "N/A"}</div>
</div>
</div>
<!-- Plot -->
<div>
<div class="text-xs text-text-tertiary mb-2 uppercase tracking-wide">
Plot Summary
</div>
<div
class="text-sm text-text-secondary leading-relaxed bg-bg-primary/50 border border-white/[0.08] rounded-xl p-4"
>
{#if previewFile.plot || previewFile.summary}
{previewFile.plot || previewFile.summary}
{:else}
<span class="text-text-tertiary italic"
>No plot summary available. Process this file to fetch metadata.</span
>
{/if}
</div>
</div>
<!-- File Path -->
<div>
<div class="text-xs text-text-tertiary mb-2 uppercase tracking-wide">
File Path
</div>
<div
class="text-xs text-text-secondary font-mono bg-bg-primary/50 border border-white/[0.08] rounded-xl p-3 break-all"
>
{previewFile.path}
</div>
</div>
<!-- Actions -->
<div
class="flex items-center justify-end gap-3 pt-4 border-t border-white/[0.08]"
>
<button
on:click={closePreview}
class="px-5 py-2.5 text-text-secondary hover:text-white text-[13px] transition-colors"
>
Close
</button>
{#if !cleanMode}
<button
on:click={() => {
handleProcessSingle(previewFile);
closePreview();
}}
disabled={disabled || previewFile.status === "Has Plot"}
class="px-5 py-2.5 bg-white hover:bg-white/90 disabled:opacity-30 disabled:cursor-not-allowed
text-black text-[13px] font-medium rounded-xl transition-all"
>
Add Subtitles
</button>
{/if}
2026-01-17 21:49:22 +13:00
</div>
</div>
</div>
</div>
{/if}
<!-- Title Selector Modal -->
{#if showTitleSelector && titleSelectorFile}
<div
class="fixed inset-0 bg-black/95 flex items-center justify-center z-50 p-4"
on:click={closeTitleSelector}
role="button"
tabindex="-1"
on:keydown={(e) => e.key === "Escape" && closeTitleSelector()}
>
<div
class="bg-bg-card border border-border rounded-2xl p-8 max-w-4xl w-full max-h-[90vh] overflow-y-auto"
on:click|stopPropagation
role="dialog"
tabindex="-1"
on:keydown
>
<div class="flex items-start justify-between mb-6">
<div>
<h3 class="text-lg font-medium mb-1">Select Correct Title</h3>
<p class="text-sm text-text-secondary">
Choose the correct match for: {titleSelectorFile.name}
</p>
</div>
<button
on:click={closeTitleSelector}
class="text-text-secondary hover:text-white transition-colors"
>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{#if searching}
<div class="flex items-center justify-center py-16">
<div class="flex flex-col items-center gap-4">
<div
class="w-8 h-8 border-4 border-accent/30 border-t-accent rounded-full animate-spin"
></div>
<p class="text-sm text-text-secondary">Searching for matches...</p>
</div>
</div>
{:else if searchError}
<div class="bg-red-500/5 border border-red-500/20 rounded-xl p-6 mb-6">
<p class="text-sm text-red-300">{searchError}</p>
</div>
<div class="flex justify-end gap-3">
<button
on:click={closeTitleSelector}
class="px-5 py-2.5 text-text-secondary hover:text-white text-[13px] transition-colors"
>
Cancel
</button>
<button
on:click={skipTitleSelection}
class="px-5 py-2.5 bg-white hover:bg-white/90 text-black text-[13px] font-medium rounded-xl transition-all"
>
Use Auto-Detection
</button>
</div>
{:else if searchResults.length === 0}
<div
class="bg-yellow-500/5 border border-yellow-500/20 rounded-xl p-6 mb-6"
>
<p class="text-sm text-yellow-200">
No matches found. Would you like to use automatic title detection
instead?
</p>
</div>
<div class="flex justify-end gap-3">
<button
on:click={closeTitleSelector}
class="px-5 py-2.5 text-text-secondary hover:text-white text-[13px] transition-colors"
>
Cancel
</button>
<button
on:click={skipTitleSelection}
class="px-5 py-2.5 bg-white hover:bg-white/90 text-black text-[13px] font-medium rounded-xl transition-all"
>
Use Auto-Detection
</button>
</div>
{:else}
<div class="space-y-3 mb-6">
{#each searchResults as result}
<button
on:click={() => selectTitle(result)}
class="w-full text-left bg-bg-primary/50 hover:bg-accent/10 border border-white/[0.08] hover:border-accent/30 rounded-xl p-4 transition-all"
>
<div class="flex items-start gap-4">
{#if result.poster && result.poster !== "N/A"}
<img
src={result.poster}
alt={result.title}
class="w-16 h-24 object-cover rounded"
/>
{:else}
<div
class="w-16 h-24 bg-bg-secondary rounded flex items-center justify-center"
>
<svg
class="w-8 h-8 text-text-tertiary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"
/>
</svg>
</div>
{/if}
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 mb-1">
<h4 class="text-base font-medium">{result.title}</h4>
<span class="text-sm text-text-tertiary"
>({result.year})</span
>
</div>
<div
class="flex items-center gap-4 text-xs text-text-secondary mb-2"
>
<span class="capitalize"
>{result.media_type || "movie"}</span
>
{#if result.runtime && result.runtime !== "N/A"}
<span>{result.runtime}</span>
{/if}
{#if result.imdb_rating && result.imdb_rating !== "N/A"}
<span>{result.imdb_rating}</span>
{/if}
</div>
<p class="text-xs text-text-tertiary line-clamp-2">
{result.plot}
</p>
</div>
</div>
</button>
{/each}
</div>
<div
class="flex justify-between items-center pt-4 border-t border-white/[0.08]"
>
<button
on:click={skipTitleSelection}
class="text-sm text-text-secondary hover:text-white transition-colors"
>
None of these? Use auto-detection →
</button>
<button
on:click={closeTitleSelector}
class="px-5 py-2.5 text-text-secondary hover:text-white text-[13px] transition-colors"
>
Cancel
</button>
</div>
{/if}
</div>
</div>
{/if}
<!-- Modern Custom Tooltip -->
{#if hoveredPlot}
<div
class="fixed z-[100] pointer-events-none"
style="left: {tooltipPosition.x}px; top: {tooltipPosition.y}px;"
>
<div
class="bg-bg-card border-2 border-accent/30 rounded-xl shadow-2xl p-4 max-w-md animate-in fade-in duration-150"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-0.5">
<svg
class="w-4 h-4 text-accent"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="flex-1 min-w-0">
<div
class="text-[11px] text-accent font-medium uppercase tracking-wide mb-1.5"
>
Full Plot
</div>
<div class="text-[13px] text-text-secondary leading-relaxed">
{hoveredPlot.plot || hoveredPlot.summary}
</div>
</div>
</div>
</div>
</div>
{/if}