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";
|
2026-01-18 22:07:00 +13:00
|
|
|
export let metadataLanguage = "";
|
2026-01-19 02:10:08 +13:00
|
|
|
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);
|
2026-01-19 02:10:08 +13:00
|
|
|
$: 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) {
|
2026-01-18 21:14:14 +13:00
|
|
|
// 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
|
2026-01-18 21:14:14 +13:00
|
|
|
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 {
|
2026-01-18 22:07:00 +13:00
|
|
|
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
|
2026-01-18 22:07:00 +13:00
|
|
|
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 {
|
2026-01-18 22:07:00 +13:00
|
|
|
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 {
|
2026-01-18 22:07:00 +13:00
|
|
|
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 {
|
2026-01-18 22:07:00 +13:00
|
|
|
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;
|
|
|
|
|
}
|
2026-01-19 02:10:08 +13:00
|
|
|
$: 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">
|
2026-01-19 02:10:08 +13:00
|
|
|
{#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>
|
2026-01-19 02:10:08 +13:00
|
|
|
{/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"
|
|
|
|
|
>
|
2026-01-19 02:10:08 +13:00
|
|
|
{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">
|
2026-01-18 01:14:10 +13:00
|
|
|
<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>
|
2026-01-18 01:14:10 +13:00
|
|
|
<TableHead className="w-[38%]">Filename</TableHead>
|
2026-01-19 02:10:08 +13:00
|
|
|
<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>
|
2026-01-18 01:14:10 +13:00
|
|
|
{#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}
|
2026-01-19 02:10:08 +13:00
|
|
|
{: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>
|
2026-01-18 01:14:10 +13:00
|
|
|
<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>
|
2026-01-18 01:14:10 +13:00
|
|
|
<TableCell className="min-w-0">
|
2026-01-19 02:10:08 +13:00
|
|
|
{#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
|
|
|
|
|
>
|
2026-01-18 01:14:10 +13:00
|
|
|
<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>
|
2026-01-19 02:10:08 +13:00
|
|
|
{/if}
|
2026-01-17 21:49:22 +13:00
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="w-64">
|
|
|
|
|
<div class="flex items-center justify-end gap-2">
|
2026-01-19 02:10:08 +13:00
|
|
|
{#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>
|
2026-01-19 02:10:08 +13:00
|
|
|
{/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">
|
2026-01-19 02:10:08 +13:00
|
|
|
{#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>
|
2026-01-19 02:10:08 +13:00
|
|
|
{/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 -->
|
2026-01-19 02:10:08 +13:00
|
|
|
{#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>
|
2026-01-19 02:10:08 +13:00
|
|
|
{#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}
|