1.1.0 - automations, clean only mode, bug fixes
This commit is contained in:
+23
-3
@@ -8,6 +8,7 @@
|
||||
import ScanPanel from "./components/scan/ScanPanel.svelte";
|
||||
import HistoryPanel from "./components/HistoryPanel.svelte";
|
||||
import LibraryPanel from "./components/library/LibraryPanel.svelte";
|
||||
import AutomationList from "./routes/automation/AutomationList.svelte";
|
||||
import { Menu } from "lucide-svelte";
|
||||
import ToastHost from "./components/ToastHost.svelte";
|
||||
import { healthCheck } from "./lib/api.js";
|
||||
@@ -15,6 +16,8 @@
|
||||
|
||||
let currentView = "scanner";
|
||||
let apiConfigured = false;
|
||||
let apiReachable = true;
|
||||
let apiErrorMessage = "";
|
||||
let selectedFiles = [];
|
||||
let metadataProvider = "omdb";
|
||||
let scanPanelKey = 0;
|
||||
@@ -59,8 +62,12 @@
|
||||
try {
|
||||
const health = await healthCheck();
|
||||
apiConfigured = health.api_key_configured;
|
||||
apiReachable = true;
|
||||
apiErrorMessage = "";
|
||||
} catch (err) {
|
||||
console.error("Health check failed:", err);
|
||||
apiReachable = false;
|
||||
apiErrorMessage = "Backend unreachable. Start the server to use Sublogue.";
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
@@ -76,8 +83,12 @@
|
||||
try {
|
||||
const health = await healthCheck();
|
||||
apiConfigured = health.api_key_configured;
|
||||
apiReachable = true;
|
||||
apiErrorMessage = "";
|
||||
} catch (err) {
|
||||
console.error("Health check failed:", err);
|
||||
apiReachable = false;
|
||||
apiErrorMessage = "Backend unreachable. Start the server to use Sublogue.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,11 +154,18 @@
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !apiConfigured && currentView === "scanner"}
|
||||
{#if !apiReachable}
|
||||
<div class="border-b border-red-500/20 bg-red-500/10">
|
||||
<div class="px-6 md:px-8 py-3">
|
||||
<p class="text-[13px] text-red-200">{apiErrorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !apiConfigured && currentView === "scanner"}
|
||||
<div class="border-b border-yellow-500/10 bg-yellow-500/5">
|
||||
<div class="px-6 md:px-8 py-3">
|
||||
<p class="text-[13px] text-yellow-100">
|
||||
Configure a metadata source in Settings to get started
|
||||
<p class="text-[14px] text-red-100">
|
||||
Configure a metadata source in Settings > Integrations to get
|
||||
started
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,6 +188,8 @@
|
||||
{/key}
|
||||
{:else if currentView === "library"}
|
||||
<LibraryPanel />
|
||||
{:else if currentView === "automation"}
|
||||
<AutomationList />
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
Settings,
|
||||
History,
|
||||
Library,
|
||||
Zap,
|
||||
} from "lucide-svelte";
|
||||
import ThemeSelector from "./ThemeSelector.svelte";
|
||||
import sublogueLogo from "../assets/sublogue_v2.png";
|
||||
@@ -89,6 +90,24 @@
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full rounded-md py-1.5 text-[13px] font-semibold leading-none ${
|
||||
collapsed ? "justify-center px-0" : "justify-start px-2 gap-2"
|
||||
} ${
|
||||
currentView === "automation"
|
||||
? "bg-[color:var(--bg-hover)] text-white font-bold"
|
||||
: "text-text-secondary hover:text-white hover:bg-[color:var(--bg-hover)]"
|
||||
}`}
|
||||
on:click={() => onNavigate("automation")}
|
||||
aria-current={currentView === "automation" ? "page" : undefined}
|
||||
>
|
||||
<Zap class="h-4 w-4" />
|
||||
{#if !collapsed}
|
||||
Automations
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full rounded-md py-1.5 text-[13px] font-semibold leading-none ${
|
||||
@@ -150,28 +169,32 @@
|
||||
<ThemeSelector className="w-full" />
|
||||
{/if}
|
||||
<div
|
||||
class={`flex items-center rounded-md bg-white/5 px-3 py-2 text-xs ${collapsed ? "justify-center" : "justify-between"}`}
|
||||
class={`flex items-center px-3 py-2 text-xs ${collapsed ? "justify-center" : "justify-between"}`}
|
||||
>
|
||||
{#if !collapsed}
|
||||
<Badge className="bg-white/10 text-text-secondary"
|
||||
>v1.0.10 Release Candiate</Badge
|
||||
>
|
||||
<Badge className="text-text-secondary">Version: v1.1.0</Badge>
|
||||
{:else}
|
||||
<Badge className="bg-white/10 text-text-secondary">v</Badge>
|
||||
<Badge className="text-text-secondary">v</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://github.com/ponzischeme89/Sublogue"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={`inline-flex items-center rounded-md bg-white/5 px-3 py-2 text-xs text-text-tertiary hover:text-white hover:bg-[color:var(--bg-hover)] transition-colors ${collapsed ? "justify-center" : "gap-2"}`}
|
||||
<div
|
||||
class={`flex items-center px-3 py-2 text-xs ${collapsed ? "justify-center" : "justify-between"}`}
|
||||
>
|
||||
<Github class="h-4 w-4" />
|
||||
{#if !collapsed}
|
||||
Star on GitHub
|
||||
<span class="text-text-tertiary">© 2026 ponzischeme89</span>
|
||||
<a
|
||||
href="https://github.com/ponzischeme89/Sublogue"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center text-text-tertiary hover:text-white transition-colors"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<Github class="h-4 w-4" />
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-text-tertiary">©</span>
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,76 +1,76 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { getSettings, updateSettings } from '../lib/api.js'
|
||||
import { Skeleton } from '../lib/components/ui/skeleton'
|
||||
import GeneralSettings from './settings/GeneralSettings.svelte'
|
||||
import IntegrationsSettings from './settings/IntegrationsSettings.svelte'
|
||||
import FilenameCleaningSettings from './settings/FilenameCleaningSettings.svelte'
|
||||
import ScheduledScansSettings from './settings/ScheduledScansSettings.svelte'
|
||||
import FolderRulesSettings from './settings/FolderRulesSettings.svelte'
|
||||
import TasksSettings from './settings/TasksSettings.svelte'
|
||||
import { addToast } from '../lib/toastStore.js'
|
||||
import { Bolt, Calendar, Folder, Plug, Settings, Wand2 } from 'lucide-svelte'
|
||||
import { onMount } from "svelte";
|
||||
import { getSettings, updateSettings } from "../lib/api.js";
|
||||
import { Skeleton } from "../lib/components/ui/skeleton";
|
||||
import GeneralSettings from "./settings/GeneralSettings.svelte";
|
||||
import IntegrationsSettings from "./settings/IntegrationsSettings.svelte";
|
||||
import FilenameCleaningSettings from "./settings/FilenameCleaningSettings.svelte";
|
||||
import FolderRulesSettings from "./settings/FolderRulesSettings.svelte";
|
||||
import TasksSettings from "./settings/TasksSettings.svelte";
|
||||
import { addToast } from "../lib/toastStore.js";
|
||||
import { Bolt, Calendar, Folder, Plug, Settings, Wand2 } from "lucide-svelte";
|
||||
|
||||
let currentSection = 'general'
|
||||
let settings = {}
|
||||
let loading = false
|
||||
let saving = false
|
||||
let error = null
|
||||
let successMessage = null
|
||||
let currentSection = "general";
|
||||
let settings = {};
|
||||
let loading = false;
|
||||
let saving = false;
|
||||
let error = null;
|
||||
let successMessage = null;
|
||||
|
||||
const sections = [
|
||||
{ id: 'general', label: 'General', icon: 'settings' },
|
||||
{ id: 'folder-rules', label: 'Folder Rules', icon: 'folder' },
|
||||
{ id: 'scheduled', label: 'Scheduled Scans', icon: 'calendar' },
|
||||
{ id: 'cleanup', label: 'Cleanup', icon: 'wand' },
|
||||
{ id: 'integrations', label: 'Integrations', icon: 'plug' },
|
||||
{ id: 'tasks', label: 'Tasks', icon: 'bolt' }
|
||||
]
|
||||
{ id: "general", label: "General", icon: "settings" },
|
||||
{ id: "folder-rules", label: "Folder Rules", icon: "folder" },
|
||||
{ id: "cleanup", label: "Cleanup", icon: "wand" },
|
||||
{ id: "integrations", label: "Integrations", icon: "plug" },
|
||||
{ id: "tasks", label: "Tasks", icon: "bolt" },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
await loadSettings()
|
||||
})
|
||||
await loadSettings();
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
loading = true
|
||||
error = null
|
||||
const loadingStart = Date.now()
|
||||
loading = true;
|
||||
error = null;
|
||||
const loadingStart = Date.now();
|
||||
|
||||
try {
|
||||
settings = await getSettings()
|
||||
settings = await getSettings();
|
||||
} catch (err) {
|
||||
error = `Failed to load settings: ${err.message}`
|
||||
error = `Failed to load settings: ${err.message}`;
|
||||
} finally {
|
||||
const elapsed = Date.now() - loadingStart
|
||||
const minDelayMs = 500
|
||||
const elapsed = Date.now() - loadingStart;
|
||||
const minDelayMs = 500;
|
||||
if (elapsed < minDelayMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, minDelayMs - elapsed))
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, minDelayMs - elapsed),
|
||||
);
|
||||
}
|
||||
loading = false
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(updates) {
|
||||
saving = true
|
||||
error = null
|
||||
successMessage = null
|
||||
saving = true;
|
||||
error = null;
|
||||
successMessage = null;
|
||||
|
||||
try {
|
||||
const result = await updateSettings(updates)
|
||||
successMessage = result.message || 'Settings saved successfully'
|
||||
addToast({ message: successMessage, tone: 'success' })
|
||||
const result = await updateSettings(updates);
|
||||
successMessage = result.message || "Settings saved successfully";
|
||||
addToast({ message: successMessage, tone: "success" });
|
||||
|
||||
// Reload settings
|
||||
await loadSettings()
|
||||
await loadSettings();
|
||||
|
||||
setTimeout(() => {
|
||||
successMessage = null
|
||||
}, 3000)
|
||||
successMessage = null;
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
error = `Failed to save settings: ${err.message}`
|
||||
addToast({ message: error, tone: 'error' })
|
||||
error = `Failed to save settings: ${err.message}`;
|
||||
addToast({ message: error, tone: "error" });
|
||||
} finally {
|
||||
saving = false
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -116,66 +116,72 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-8 lg:gap-12">
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="w-full lg:w-48 flex-shrink-0">
|
||||
<nav class="space-y-0.5">
|
||||
{#each sections as section}
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-all border
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="w-full lg:w-48 flex-shrink-0">
|
||||
<nav class="space-y-0.5">
|
||||
{#each sections as section}
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-all border
|
||||
{currentSection === section.id
|
||||
? 'bg-white text-black border-white'
|
||||
: 'text-text-secondary hover:text-white hover:bg-bg-hover border-transparent'}"
|
||||
on:click={() => currentSection = section.id}
|
||||
? 'bg-white text-black border-white'
|
||||
: 'text-text-secondary hover:text-white hover:bg-bg-hover border-transparent'}"
|
||||
on:click={() => (currentSection = section.id)}
|
||||
>
|
||||
{#if section.icon === "settings"}
|
||||
<Settings class="w-4 h-4" />
|
||||
{:else if section.icon === "calendar"}
|
||||
<Calendar class="w-4 h-4" />
|
||||
{:else if section.icon === "bolt"}
|
||||
<Bolt class="w-4 h-4" />
|
||||
{:else if section.icon === "wand"}
|
||||
<Wand2 class="w-4 h-4" />
|
||||
{:else if section.icon === "plug"}
|
||||
<Plug class="w-4 h-4" />
|
||||
{:else if section.icon === "folder"}
|
||||
<Folder class="w-4 h-4" />
|
||||
{/if}
|
||||
<span class="text-[13px] font-medium">{section.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
{#if error}
|
||||
<div
|
||||
class="mb-6 px-5 py-4 bg-red-500/5 border border-red-500/20 rounded-xl"
|
||||
>
|
||||
{#if section.icon === 'settings'}
|
||||
<Settings class="w-4 h-4" />
|
||||
{:else if section.icon === 'calendar'}
|
||||
<Calendar class="w-4 h-4" />
|
||||
{:else if section.icon === 'bolt'}
|
||||
<Bolt class="w-4 h-4" />
|
||||
{:else if section.icon === 'wand'}
|
||||
<Wand2 class="w-4 h-4" />
|
||||
{:else if section.icon === 'plug'}
|
||||
<Plug class="w-4 h-4" />
|
||||
{:else if section.icon === 'folder'}
|
||||
<Folder class="w-4 h-4" />
|
||||
{/if}
|
||||
<span class="text-[13px] font-medium">{section.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
{#if error}
|
||||
<div class="mb-6 px-5 py-4 bg-red-500/5 border border-red-500/20 rounded-xl">
|
||||
<p class="text-[13px] text-red-300">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div class="mb-6 px-5 py-4 bg-green-500/5 border border-green-500/20 rounded-xl">
|
||||
<p class="text-[13px] text-green-300">{successMessage}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="rounded-xl border border-border bg-card/60 p-6 lg:p-8 shadow-sm">
|
||||
{#if currentSection === 'general'}
|
||||
<GeneralSettings {settings} {saving} onSave={handleSave} />
|
||||
{:else if currentSection === 'folder-rules'}
|
||||
<FolderRulesSettings {settings} />
|
||||
{:else if currentSection === 'scheduled'}
|
||||
<ScheduledScansSettings {settings} />
|
||||
{:else if currentSection === 'cleanup'}
|
||||
<FilenameCleaningSettings {settings} {saving} onSave={handleSave} />
|
||||
{:else if currentSection === 'integrations'}
|
||||
<IntegrationsSettings {settings} {saving} onSave={handleSave} />
|
||||
{:else if currentSection === 'tasks'}
|
||||
<TasksSettings />
|
||||
<p class="text-[13px] text-red-300">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div
|
||||
class="mb-6 px-5 py-4 bg-green-500/5 border border-green-500/20 rounded-xl"
|
||||
>
|
||||
<p class="text-[13px] text-green-300">{successMessage}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-border bg-card/60 p-6 lg:p-8 shadow-sm"
|
||||
>
|
||||
{#if currentSection === "general"}
|
||||
<GeneralSettings {settings} {saving} onSave={handleSave} />
|
||||
{:else if currentSection === "folder-rules"}
|
||||
<FolderRulesSettings {settings} />
|
||||
{:else if currentSection === "scheduled"}
|
||||
<ScheduledScansSettings {settings} />
|
||||
{:else if currentSection === "cleanup"}
|
||||
<FilenameCleaningSettings {settings} {saving} onSave={handleSave} />
|
||||
{:else if currentSection === "integrations"}
|
||||
<IntegrationsSettings {settings} {saving} onSave={handleSave} />
|
||||
{:else if currentSection === "tasks"}
|
||||
<TasksSettings />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
const toneStyles = {
|
||||
info: 'border-white/10 bg-bg-card text-text-primary',
|
||||
success: 'border-green-500/30 bg-green-500/10 text-green-100',
|
||||
success: 'border-emerald-500/50 bg-emerald-900/60 text-emerald-50',
|
||||
error: 'border-red-500/30 bg-red-500/10 text-red-100'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
export let disabled = false;
|
||||
export let metadataProvider = "omdb";
|
||||
export let metadataLanguage = "";
|
||||
export let cleanMode = false;
|
||||
export let activeIntegrations = {
|
||||
omdb: true,
|
||||
tmdb: true,
|
||||
@@ -43,6 +44,12 @@
|
||||
);
|
||||
$: 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,
|
||||
);
|
||||
|
||||
function goToPage(page) {
|
||||
currentPage = Math.max(1, Math.min(page, totalPages));
|
||||
@@ -814,6 +821,12 @@
|
||||
$: if (!hasActiveIntegrations && showMetadataDropup) {
|
||||
showMetadataDropup = false;
|
||||
}
|
||||
$: if (cleanMode && showMetadataDropup) {
|
||||
showMetadataDropup = false;
|
||||
}
|
||||
$: if (cleanMode && openSearchDropdown) {
|
||||
openSearchDropdown = null;
|
||||
}
|
||||
$: metadataDropdownOptions = [
|
||||
{
|
||||
value: "omdb",
|
||||
@@ -972,6 +985,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if !cleanMode}
|
||||
<!-- Metadata Source Selector -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@@ -1120,6 +1134,7 @@
|
||||
Bulk Match
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
on:click={handleBulkProcess}
|
||||
@@ -1127,7 +1142,7 @@
|
||||
size="sm"
|
||||
className="bg-white text-black hover:bg-white/90"
|
||||
>
|
||||
Add Subtitles to Selected
|
||||
{cleanMode ? "Clean Selected" : "Add Subtitles to Selected"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1151,7 +1166,9 @@
|
||||
<TableHead className="w-10"></TableHead>
|
||||
<TableHead className="w-28">Status</TableHead>
|
||||
<TableHead className="w-[38%]">Filename</TableHead>
|
||||
<TableHead className="w-[28%]">Matched Result</TableHead>
|
||||
<TableHead className="w-[28%]">
|
||||
{cleanMode ? "Clean Scan" : "Matched Result"}
|
||||
</TableHead>
|
||||
<TableHead className="w-64 text-right"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -1182,6 +1199,206 @@
|
||||
</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}
|
||||
{:else}
|
||||
{#each paginatedFiles as file (file.path)}
|
||||
<TableRow data-state={file.selected ? "selected" : undefined}>
|
||||
@@ -1230,6 +1447,15 @@
|
||||
</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}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -1359,9 +1585,15 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</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}
|
||||
{#if suggestedMatches[file.path]}
|
||||
<!-- Show quick apply button for suggested matches -->
|
||||
<Button
|
||||
@@ -1414,6 +1646,7 @@
|
||||
? "Update"
|
||||
: "Add Plot"}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -1422,6 +1655,36 @@
|
||||
{#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}
|
||||
<div class="grid grid-cols-3 gap-6 ml-16">
|
||||
<!-- Rating -->
|
||||
<div>
|
||||
@@ -1483,6 +1746,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/if}
|
||||
@@ -1569,7 +1833,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Bulk Apply Section -->
|
||||
{#if hasMatches || bulkApplying}
|
||||
{#if !cleanMode && (hasMatches || bulkApplying)}
|
||||
<div
|
||||
class="bg-blue-500/10 border border-blue-500/30 rounded-xl px-6 py-4"
|
||||
>
|
||||
@@ -2029,17 +2293,19 @@
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<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 !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}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,7 +82,8 @@
|
||||
let pendingTitleOverride = null;
|
||||
let resultsListRef = null;
|
||||
let pendingForceReprocess = false;
|
||||
let quoteStyle = 'sarcastic';
|
||||
let cleanOnly = false;
|
||||
let quoteStyle = "sarcastic";
|
||||
let initialLoading = true;
|
||||
let scanToastedError = false;
|
||||
let metadataOptions = [];
|
||||
@@ -273,7 +274,12 @@
|
||||
files = [...files, ...data.batch];
|
||||
|
||||
// Trigger auto-matching for the new batch if ResultsList is ready
|
||||
if (resultsListRef && data.batch && data.batch.length > 0) {
|
||||
if (
|
||||
!cleanOnly &&
|
||||
resultsListRef &&
|
||||
data.batch &&
|
||||
data.batch.length > 0
|
||||
) {
|
||||
console.log(
|
||||
"Auto-matching new batch of",
|
||||
data.batch.length,
|
||||
@@ -327,7 +333,7 @@
|
||||
Object.keys(existingMatches).length,
|
||||
);
|
||||
|
||||
if (resultsListRef) {
|
||||
if (!cleanOnly && resultsListRef) {
|
||||
// ALWAYS load existing matches from database first (even if empty, to reset state)
|
||||
console.log(
|
||||
"Loading existing matches into component:",
|
||||
@@ -372,7 +378,8 @@
|
||||
}
|
||||
scanToastedError = true;
|
||||
addToast({
|
||||
message: err.name === "AbortError" ? "Scan cancelled." : "Scan failed.",
|
||||
message:
|
||||
err.name === "AbortError" ? "Scan cancelled." : "Scan failed.",
|
||||
tone: err.name === "AbortError" ? "info" : "error",
|
||||
});
|
||||
},
|
||||
@@ -387,7 +394,8 @@
|
||||
}
|
||||
if (!scanToastedError) {
|
||||
addToast({
|
||||
message: err.name === "AbortError" ? "Scan cancelled." : "Scan failed.",
|
||||
message:
|
||||
err.name === "AbortError" ? "Scan cancelled." : "Scan failed.",
|
||||
tone: err.name === "AbortError" ? "info" : "error",
|
||||
});
|
||||
}
|
||||
@@ -502,6 +510,7 @@
|
||||
duration,
|
||||
pendingTitleOverride,
|
||||
forceReprocess,
|
||||
cleanOnly,
|
||||
);
|
||||
processingResults = response.results;
|
||||
|
||||
@@ -510,6 +519,15 @@
|
||||
files = files.map((file) => {
|
||||
const result = processingResults.find((r) => r.file === file.path);
|
||||
if (result) {
|
||||
if (result.clean_only) {
|
||||
return {
|
||||
...file,
|
||||
status: result.status || (result.success ? "Cleaned" : "Error"),
|
||||
summary: result.summary || file.summary || "",
|
||||
clean_only: true,
|
||||
clean_keywords: result.clean_keywords || [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...file,
|
||||
status: result.status || (result.success ? "Processed" : "Error"),
|
||||
@@ -554,10 +572,7 @@
|
||||
}
|
||||
|
||||
function normalizePath(path) {
|
||||
return (path || "")
|
||||
.replace(/\//g, "\\")
|
||||
.replace(/\\+$/, "")
|
||||
.toLowerCase();
|
||||
return (path || "").replace(/\//g, "\\").replace(/\\+$/, "").toLowerCase();
|
||||
}
|
||||
|
||||
function findFolderRuleForDirectory(path, rules) {
|
||||
@@ -668,370 +683,427 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Scan Panel -->
|
||||
<Card className="bg-card" skeletonFlash={false}>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base">Scan your SRT files</CardTitle>
|
||||
<CardDescription className="text-[13px]">
|
||||
Scan folders for subtitles missing plot summaries
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<!-- Scan Panel -->
|
||||
<Card className="bg-card" skeletonFlash={false}>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base">Scan Files</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="relative w-full">
|
||||
<Folder class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary" />
|
||||
<Input
|
||||
type="text"
|
||||
bind:value={directory}
|
||||
placeholder="C:\Movies or /media/movies"
|
||||
disabled={scanning}
|
||||
className="h-11 font-mono text-[13px] w-full pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
on:click={handleScan}
|
||||
disabled={scanning || !directory}
|
||||
size="lg"
|
||||
className="h-11 px-6 whitespace-nowrap w-full sm:w-auto"
|
||||
>
|
||||
<Scan class="h-4 w-4" />
|
||||
{scanning ? "Scanning..." : "Scan Directory"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<span class="text-[13px] text-text-secondary whitespace-nowrap inline-flex items-center gap-2">
|
||||
<Plug class="h-4 w-4 text-text-tertiary" />
|
||||
Metadata Source
|
||||
</span>
|
||||
|
||||
<Combobox
|
||||
items={metadataOptions}
|
||||
value={metadataProvider}
|
||||
disabled={scanning || activeMetadataOptions.length === 0}
|
||||
placeholder={
|
||||
activeMetadataOptions.length === 0
|
||||
? "Enable a provider"
|
||||
: "Select source"
|
||||
}
|
||||
className="w-full sm:min-w-[220px]"
|
||||
on:change={(event) => selectMetadataSource(event.detail.value)}
|
||||
>
|
||||
<Plug slot="icon" class="h-4 w-4 text-text-tertiary" />
|
||||
</Combobox>
|
||||
|
||||
<span class="text-[11px] text-text-tertiary sm:ml-2">
|
||||
{activeMetadataOptions.length === 0
|
||||
? "Enable an integration in Settings to select a source."
|
||||
: "Choose which API to use for fetching plot summaries"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="px-5 py-4 bg-red-500/5 border border-red-500/20 rounded-xl">
|
||||
<p class="text-[13px] text-red-300">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if scanning}
|
||||
<div
|
||||
class="px-6 py-5 bg-blue-500/5 border border-blue-500/20 rounded-xl"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-blue-500/40 border-t-blue-500 rounded-full animate-spin"
|
||||
></div>
|
||||
<span class="text-[13px] font-medium text-blue-300"
|
||||
>{scanProgress.message}</span
|
||||
>
|
||||
</div>
|
||||
<Button
|
||||
on:click={cancelScan}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-500/60 text-red-400 hover:bg-red-500/10 w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<CardContent className="space-y-4">
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="relative w-full">
|
||||
<Folder
|
||||
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
bind:value={directory}
|
||||
placeholder="C:\Movies or /media/movies"
|
||||
disabled={scanning}
|
||||
className="h-11 font-mono text-[13px] w-full pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
on:click={handleScan}
|
||||
disabled={scanning || !directory}
|
||||
size="lg"
|
||||
className="h-11 px-6 whitespace-nowrap w-full sm:w-auto"
|
||||
>
|
||||
<Scan class="h-4 w-4" />
|
||||
{scanning ? "Scanning..." : "Scan Directory"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if scanProgress.filesFound > 0}
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-[12px] text-text-secondary"
|
||||
>
|
||||
<span
|
||||
>Files found: <span class="text-white font-medium"
|
||||
>{scanProgress.filesFound}</span
|
||||
></span
|
||||
>
|
||||
<span class="text-text-tertiary">Scanning in progress...</span>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<span
|
||||
class="text-[13px] text-text-secondary whitespace-nowrap inline-flex items-center gap-2"
|
||||
>
|
||||
<Plug class="h-4 w-4 text-text-tertiary" />
|
||||
Metadata Source
|
||||
</span>
|
||||
|
||||
<Combobox
|
||||
items={metadataOptions}
|
||||
value={metadataProvider}
|
||||
disabled={scanning || activeMetadataOptions.length === 0}
|
||||
placeholder={activeMetadataOptions.length === 0
|
||||
? "Enable a provider"
|
||||
: "Select source"}
|
||||
className="w-full sm:min-w-[220px]"
|
||||
on:change={(event) => selectMetadataSource(event.detail.value)}
|
||||
>
|
||||
<Plug slot="icon" class="h-4 w-4 text-text-tertiary" />
|
||||
</Combobox>
|
||||
|
||||
<span class="text-[11px] text-text-tertiary sm:ml-2">
|
||||
{cleanOnly
|
||||
? "Clean-only mode skips metadata lookups."
|
||||
: activeMetadataOptions.length === 0
|
||||
? "Enable an integration in Settings to select a source."
|
||||
: "Choose which API to use for fetching plot summaries"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="flex items-center justify-between gap-4 rounded-xl border border-border bg-bg-secondary/40 px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<div class="text-[13px] font-medium">Run as "Clean-only scan"</div>
|
||||
<div class="text-[11px] text-text-tertiary">
|
||||
Remove watermark lines (YTS, YIFY, etc) without adding plots.
|
||||
</div>
|
||||
{#if scanProgress.startedAt}
|
||||
<div class="mt-2 text-[11px] text-text-tertiary">
|
||||
Started at {scanProgress.startedAt.toLocaleTimeString()}
|
||||
{#if scanProgress.estimatedFinishAt}
|
||||
· Estimated finish {scanProgress.estimatedFinishAt.toLocaleTimeString()}
|
||||
{/if}
|
||||
</div>
|
||||
<span class="relative inline-flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={cleanOnly}
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<span
|
||||
class="h-6 w-11 rounded-full border border-border bg-bg-card transition-colors peer-checked:bg-accent peer-checked:border-accent/60"
|
||||
></span>
|
||||
<span
|
||||
class="absolute left-0.5 h-5 w-5 rounded-full bg-text-tertiary transition-transform peer-checked:translate-x-5 peer-checked:bg-bg-primary"
|
||||
></span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="px-5 py-4 bg-red-500/5 border border-red-500/20 rounded-xl"
|
||||
>
|
||||
<p class="text-[13px] text-red-300">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if scanning}
|
||||
<div
|
||||
class="px-6 py-5 bg-blue-500/5 border border-blue-500/20 rounded-xl"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-blue-500/40 border-t-blue-500 rounded-full animate-spin"
|
||||
></div>
|
||||
<span class="text-[13px] font-medium text-blue-300"
|
||||
>{scanProgress.message}</span
|
||||
>
|
||||
</div>
|
||||
<Button
|
||||
on:click={cancelScan}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-500/60 text-red-400 hover:bg-red-500/10 w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if scanProgress.filesFound > 0}
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-[12px] text-text-secondary"
|
||||
>
|
||||
<span
|
||||
>Files found: <span class="text-white font-medium"
|
||||
>{scanProgress.filesFound}</span
|
||||
></span
|
||||
>
|
||||
<span class="text-text-tertiary">Scanning in progress...</span>
|
||||
</div>
|
||||
{#if scanProgress.startedAt}
|
||||
<div class="mt-2 text-[11px] text-text-tertiary">
|
||||
Started at {scanProgress.startedAt.toLocaleTimeString()}
|
||||
{#if scanProgress.estimatedFinishAt}
|
||||
· Estimated finish {scanProgress.estimatedFinishAt.toLocaleTimeString()}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div
|
||||
class="mt-3 h-1.5 bg-bg-secondary rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500 rounded-full animate-pulse"
|
||||
style="width: 100%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div
|
||||
class="mt-3 h-1.5 bg-bg-secondary rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500 rounded-full animate-pulse"
|
||||
style="width: 100%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Save Directory Prompt -->
|
||||
{#if showSaveDirectoryPrompt}
|
||||
<div class="bg-blue-500/5 border border-blue-500/20 rounded-2xl p-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Info class="w-5 h-5 text-blue-400" />
|
||||
<h3 class="text-[13px] font-medium text-blue-300">
|
||||
Save as Default Directory?
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-[11px] text-text-secondary">
|
||||
Would you like to make <span class="font-mono text-white"
|
||||
>{directory}</span
|
||||
> as your default scan directory?
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 w-full sm:w-auto">
|
||||
<Button
|
||||
on:click={dismissDirectoryPrompt}
|
||||
disabled={savingDirectory}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-text-secondary w-full sm:w-auto"
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Save Directory Prompt -->
|
||||
{#if showSaveDirectoryPrompt}
|
||||
<div class="bg-blue-500/5 border border-blue-500/20 rounded-2xl p-6">
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Info class="w-5 h-5 text-blue-400" />
|
||||
<h3 class="text-[13px] font-medium text-blue-300">
|
||||
Save as Default Directory?
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-[11px] text-text-secondary">
|
||||
Would you like to make <span class="font-mono text-white"
|
||||
>{directory}</span
|
||||
> as your default scan directory?
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 w-full sm:w-auto"
|
||||
>
|
||||
Not now
|
||||
</Button>
|
||||
<Button
|
||||
on:click={saveAsDefaultDirectory}
|
||||
disabled={savingDirectory}
|
||||
size="sm"
|
||||
className="bg-blue-500 text-white hover:bg-blue-600 w-full sm:w-auto"
|
||||
>
|
||||
{savingDirectory ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
on:click={dismissDirectoryPrompt}
|
||||
disabled={savingDirectory}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-text-secondary w-full sm:w-auto"
|
||||
>
|
||||
Not now
|
||||
</Button>
|
||||
<Button
|
||||
on:click={saveAsDefaultDirectory}
|
||||
disabled={savingDirectory}
|
||||
size="sm"
|
||||
className="bg-blue-500 text-white hover:bg-blue-600 w-full sm:w-auto"
|
||||
>
|
||||
{savingDirectory ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Results Area -->
|
||||
{#if hasScanned}
|
||||
<div class="space-y-6">
|
||||
{#if files.length > 0}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<h3 class="text-[13px] font-medium text-text-secondary">
|
||||
{files.length}
|
||||
{files.length !== 1 ? "files" : "file"} found
|
||||
</h3>
|
||||
<span class="text-[13px] text-text-tertiary">
|
||||
{selectedFilePaths.length} selected
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
on:click={clearResults}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-500 text-red-400 hover:bg-red-500/10 hover:text-red-300 w-full sm:w-auto"
|
||||
<!-- Results Area -->
|
||||
{#if hasScanned}
|
||||
<div class="space-y-6">
|
||||
{#if files.length > 0}
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
|
||||
>
|
||||
Clear Results
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ResultsList
|
||||
bind:this={resultsListRef}
|
||||
{files}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
disabled={!apiConfigured || processing}
|
||||
{metadataProvider}
|
||||
{metadataLanguage}
|
||||
activeIntegrations={{
|
||||
omdb: omdbEnabled,
|
||||
tmdb: tmdbEnabled,
|
||||
tvmaze: tvmazeEnabled,
|
||||
wikipedia: wikipediaEnabled,
|
||||
}}
|
||||
loading={processing}
|
||||
on:processSingle={handleProcessSingle}
|
||||
on:processBulk={handleProcessBulk}
|
||||
on:metadataSourceChange={handleMetadataSourceChange}
|
||||
/>
|
||||
{:else}
|
||||
<div class="border border-border rounded-2xl p-12 text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<FileText class="w-12 h-12 text-text-tertiary" />
|
||||
<div>
|
||||
<p class="text-[13px] text-text-secondary mb-1">
|
||||
No subtitle files found
|
||||
</p>
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
Try scanning a different directory
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<h3 class="text-[13px] font-medium text-text-secondary">
|
||||
{files.length}
|
||||
{files.length !== 1 ? "files" : "file"} found
|
||||
</h3>
|
||||
<span class="text-[13px] text-text-tertiary">
|
||||
{selectedFilePaths.length} selected
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
on:click={clearResults}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-text-secondary"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !scanning && showTutorial}
|
||||
<!-- First-time tutorial -->
|
||||
<div class="rounded-2xl border border-border bg-gradient-to-br from-[#101010] via-[#0c0c0c] to-[#0b0b0b] p-6 sm:p-8">
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
<div class="flex-1 space-y-4">
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[10px] uppercase tracking-[0.2em] text-text-tertiary">
|
||||
First time setup
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-semibold text-white">
|
||||
Enrich your subtitle library in minutes
|
||||
</h3>
|
||||
<p class="text-[13px] text-text-secondary">
|
||||
Follow the steps below to connect your metadata source and scan a directory.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 px-4 py-3">
|
||||
<TypewriterQuote style={quoteStyle} />
|
||||
<p class="text-[11px] text-text-tertiary mt-1">
|
||||
Enter a directory path above to begin.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
on:click={() => onOpenSettings && onOpenSettings()}
|
||||
size="sm"
|
||||
className="bg-white text-black hover:bg-white/90"
|
||||
>
|
||||
Open Settings
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => onOpenHistory && onOpenHistory()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-white/20 text-text-secondary hover:bg-white/10"
|
||||
className="border-red-500 text-red-400 hover:bg-red-500/10 hover:text-red-300 w-full sm:w-auto"
|
||||
>
|
||||
View History
|
||||
Clear Results
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 lg:max-w-sm w-full">
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-9 w-9 rounded-full bg-white text-black flex items-center justify-center text-xs font-semibold">
|
||||
1
|
||||
</div>
|
||||
<ResultsList
|
||||
bind:this={resultsListRef}
|
||||
{files}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
disabled={(!apiConfigured && !cleanOnly) || processing}
|
||||
{metadataProvider}
|
||||
{metadataLanguage}
|
||||
cleanMode={cleanOnly}
|
||||
activeIntegrations={{
|
||||
omdb: omdbEnabled,
|
||||
tmdb: tmdbEnabled,
|
||||
tvmaze: tvmazeEnabled,
|
||||
wikipedia: wikipediaEnabled,
|
||||
}}
|
||||
loading={processing}
|
||||
on:processSingle={handleProcessSingle}
|
||||
on:processBulk={handleProcessBulk}
|
||||
on:metadataSourceChange={handleMetadataSourceChange}
|
||||
/>
|
||||
{:else}
|
||||
<div class="border border-border rounded-2xl p-12 text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<FileText class="w-12 h-12 text-text-tertiary" />
|
||||
<div>
|
||||
<p class="text-[13px] font-medium text-white">Connect metadata</p>
|
||||
<p class="text-[13px] text-text-secondary mb-1">
|
||||
No subtitle files found
|
||||
</p>
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
Add an API key or enable TVmaze in Settings.
|
||||
Try scanning a different directory
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
on:click={clearResults}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-text-secondary"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !scanning && showTutorial}
|
||||
<!-- First-time tutorial -->
|
||||
<div
|
||||
class="rounded-2xl border border-border bg-gradient-to-br from-[#101010] via-[#0c0c0c] to-[#0b0b0b] p-6 sm:p-8"
|
||||
>
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
<div class="flex-1 space-y-4">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[10px] uppercase tracking-[0.2em] text-text-tertiary"
|
||||
>
|
||||
First time setup
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-semibold text-white">
|
||||
Enrich your subtitle library in minutes
|
||||
</h3>
|
||||
<p class="text-[13px] text-text-secondary">
|
||||
Follow the steps below to connect your metadata source and scan
|
||||
a directory.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 px-4 py-3">
|
||||
<TypewriterQuote style={quoteStyle} />
|
||||
<p class="text-[11px] text-text-tertiary mt-1">
|
||||
Enter a directory path above to begin.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
on:click={() => onOpenSettings && onOpenSettings()}
|
||||
size="sm"
|
||||
className="bg-white text-black hover:bg-white/90"
|
||||
>
|
||||
Open Settings
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => onOpenHistory && onOpenHistory()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-white/20 text-text-secondary hover:bg-white/10"
|
||||
>
|
||||
View History
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-9 w-9 rounded-full border border-white/20 text-white flex items-center justify-center text-xs font-semibold">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[13px] font-medium text-white">Choose a source</p>
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
Pick OMDb, TMDb, TVmaze, or both for the best match rate.
|
||||
</p>
|
||||
<div
|
||||
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 lg:max-w-sm w-full"
|
||||
>
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="h-9 w-9 rounded-full bg-white text-black flex items-center justify-center text-xs font-semibold"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[13px] font-medium text-white">
|
||||
Connect metadata
|
||||
</p>
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
Add an API key or enable TVmaze in Settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-9 w-9 rounded-full border border-white/20 text-white flex items-center justify-center text-xs font-semibold">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[13px] font-medium text-white">Scan & enrich</p>
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
Scan a folder and apply summaries to selected subtitles.
|
||||
</p>
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="h-9 w-9 rounded-full border border-white/20 text-white flex items-center justify-center text-xs font-semibold"
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[13px] font-medium text-white">
|
||||
Choose a source
|
||||
</p>
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
Pick OMDb, TMDb, TVmaze, or both for the best match rate.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div class="text-[10px] uppercase tracking-[0.2em] text-text-tertiary mb-3">
|
||||
Progress
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="h-9 w-9 rounded-full border border-white/20 text-white flex items-center justify-center text-xs font-semibold"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[13px] font-medium text-white">
|
||||
Scan, clean, enrich
|
||||
</p>
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
Scan a folder to clean subtitles and add plot summaries.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2 text-[12px] text-text-secondary">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>API key connected</span>
|
||||
{#if apiConfigured}
|
||||
<span class="text-green-400">Done</span>
|
||||
{:else}
|
||||
<span class="text-text-tertiary">Pending</span>
|
||||
{/if}
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div
|
||||
class="text-[10px] uppercase tracking-[0.2em] text-text-tertiary mb-3"
|
||||
>
|
||||
Progress
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Metadata source selected</span>
|
||||
{#if metadataSelected}
|
||||
<span class="text-green-400">Done</span>
|
||||
{:else}
|
||||
<span class="text-text-tertiary">Pending</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>First scan completed</span>
|
||||
{#if hasScanned}
|
||||
<span class="text-green-400">Done</span>
|
||||
{:else}
|
||||
<span class="text-text-tertiary">Pending</span>
|
||||
{/if}
|
||||
<div class="space-y-2 text-[12px] text-text-secondary">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>API key connected</span>
|
||||
{#if apiConfigured}
|
||||
<span class="text-green-400">Done</span>
|
||||
{:else}
|
||||
<span class="text-text-tertiary">Pending</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Metadata source selected</span>
|
||||
{#if metadataSelected}
|
||||
<span class="text-green-400">Done</span>
|
||||
{:else}
|
||||
<span class="text-text-tertiary">Pending</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>First scan completed</span>
|
||||
{#if hasScanned}
|
||||
<span class="text-green-400">Done</span>
|
||||
{:else}
|
||||
<span class="text-text-tertiary">Pending</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !scanning}
|
||||
<!-- Waiting State -->
|
||||
<div class="border border-border rounded-2xl p-12 text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="relative">
|
||||
<Scan class="w-16 h-16 text-text-tertiary animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<TypewriterQuote style={quoteStyle} />
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
Enter a directory path above to begin
|
||||
</p>
|
||||
{:else if !scanning}
|
||||
<!-- Waiting State -->
|
||||
<div class="border border-border rounded-2xl p-12 text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="relative">
|
||||
<Scan class="w-16 h-16 text-text-tertiary animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<TypewriterQuote style={quoteStyle} />
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
Enter a directory path above to begin
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
let stripKeywords = settings.strip_keywords !== false;
|
||||
let cleanSubtitleContent = settings.clean_subtitle_content !== false;
|
||||
let forceRemoveKeywords = Array.isArray(settings.clean_subtitle_force_remove)
|
||||
? settings.clean_subtitle_force_remove.join(", ")
|
||||
: settings.clean_subtitle_force_remove || "YTS, OpenSubtitles";
|
||||
|
||||
// Example filenames to demonstrate the cleaning
|
||||
const filenameExamples = [
|
||||
@@ -53,6 +56,10 @@
|
||||
onSave({
|
||||
strip_keywords: stripKeywords,
|
||||
clean_subtitle_content: cleanSubtitleContent,
|
||||
clean_subtitle_force_remove: forceRemoveKeywords
|
||||
.split(/[\n,]+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -165,6 +172,22 @@
|
||||
<!-- Collapsible Details -->
|
||||
{#if cleanSubtitleContent}
|
||||
<div class="space-y-4 pl-2 border-l-2 border-border ml-2">
|
||||
<div class="bg-bg-card border border-border rounded-xl p-4 space-y-3">
|
||||
<div class="text-[11px] text-text-tertiary uppercase tracking-wide">
|
||||
Force Remove Keywords
|
||||
</div>
|
||||
<textarea
|
||||
rows="3"
|
||||
bind:value={forceRemoveKeywords}
|
||||
class="w-full resize-none rounded-lg border border-white/10 bg-bg-primary/60 px-3 py-2 text-[12px] text-text-secondary placeholder:text-text-tertiary focus:border-accent focus:outline-none"
|
||||
placeholder="YTS, OpenSubtitles"
|
||||
></textarea>
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
Any subtitle block containing one of these strings (partial match)
|
||||
is removed entirely. Separate values with commas or new lines.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Example Transformations -->
|
||||
<div class="bg-bg-card border border-border rounded-xl overflow-hidden">
|
||||
<div class="px-4 py-2.5 border-b border-border bg-bg-secondary">
|
||||
|
||||
+61
-2
@@ -268,10 +268,16 @@ export async function searchTitle(query, mode = "quick", options = {}) {
|
||||
|
||||
/**
|
||||
* POST /api/process - Process files to add plot summaries
|
||||
* Body: { files: [string], duration: number, titleOverride?: object, forceReprocess?: boolean }
|
||||
* Body: { files: [string], duration: number, titleOverride?: object, forceReprocess?: boolean, clean_only?: boolean }
|
||||
* Returns: { success, results: [{file, success, status, summary, error?}] }
|
||||
*/
|
||||
export async function processFiles(files, duration, titleOverride = null, forceReprocess = false) {
|
||||
export async function processFiles(
|
||||
files,
|
||||
duration,
|
||||
titleOverride = null,
|
||||
forceReprocess = false,
|
||||
cleanOnly = false
|
||||
) {
|
||||
const body = { files, duration }
|
||||
|
||||
if (titleOverride) {
|
||||
@@ -282,6 +288,10 @@ export async function processFiles(files, duration, titleOverride = null, forceR
|
||||
body.forceReprocess = forceReprocess
|
||||
}
|
||||
|
||||
if (cleanOnly) {
|
||||
body.clean_only = true
|
||||
}
|
||||
|
||||
return apiFetch('/process', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
@@ -392,6 +402,55 @@ export async function getIntegrationUsage() {
|
||||
return apiFetch('/integrations/usage')
|
||||
}
|
||||
|
||||
// ============ AUTOMATION API ============
|
||||
|
||||
/**
|
||||
* GET /api/automation/rules - Get automation rules
|
||||
* Returns: { success, rules: [...] }
|
||||
*/
|
||||
export async function getAutomationRules() {
|
||||
return apiFetch('/automation/rules')
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/automation/rules - Create automation rule
|
||||
*/
|
||||
export async function createAutomationRule(rule) {
|
||||
return apiFetch('/automation/rules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(rule)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/automation/rules/<id> - Update automation rule
|
||||
*/
|
||||
export async function updateAutomationRule(ruleId, updates) {
|
||||
return apiFetch(`/automation/rules/${ruleId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/automation/rules/<id> - Delete automation rule
|
||||
*/
|
||||
export async function deleteAutomationRule(ruleId) {
|
||||
return apiFetch(`/automation/rules/${ruleId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/automation/rules/<id>/run - Run rule now
|
||||
*/
|
||||
export async function runAutomationRule(ruleId, dryRun = false) {
|
||||
return apiFetch(`/automation/rules/${ruleId}/run`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ dry_run: dryRun })
|
||||
})
|
||||
}
|
||||
|
||||
// ============ SUGGESTED MATCHES API ============
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,4 +21,4 @@
|
||||
restClass,
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
></div>
|
||||
|
||||
@@ -9,7 +9,7 @@ function createToastStore() {
|
||||
update((items) => items.filter((item) => item.id !== id))
|
||||
}
|
||||
|
||||
function addToast({ message, tone = 'info', duration = 3200 } = {}) {
|
||||
function addToast({ message, tone = 'info', duration = 5200 } = {}) {
|
||||
if (!message) return
|
||||
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const toast = { id, message, tone }
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
<script>
|
||||
import { Button } from "../../lib/components/ui/button";
|
||||
|
||||
export let rule = null;
|
||||
export let saving = false;
|
||||
export let onSave;
|
||||
export let onCancel;
|
||||
|
||||
let name = rule?.name || "";
|
||||
let schedule = rule?.schedule || "0 3 * * SUN";
|
||||
let enabled = rule?.enabled ?? true;
|
||||
let patterns = rule?.patterns ? [...rule.patterns] : ["YTS", "YIFY"];
|
||||
let targetFolders = rule?.target_folders
|
||||
? [...rule.target_folders]
|
||||
: ["/media/movies"];
|
||||
|
||||
$: if (rule) {
|
||||
name = rule.name || "";
|
||||
schedule = rule.schedule || "0 3 * * SUN";
|
||||
enabled = rule.enabled ?? true;
|
||||
patterns = rule.patterns ? [...rule.patterns] : [];
|
||||
targetFolders = rule.target_folders ? [...rule.target_folders] : [];
|
||||
}
|
||||
|
||||
function addPattern() {
|
||||
patterns = [...patterns, ""];
|
||||
}
|
||||
|
||||
function updatePattern(index, value) {
|
||||
patterns[index] = value;
|
||||
patterns = [...patterns];
|
||||
}
|
||||
|
||||
function removePattern(index) {
|
||||
patterns = patterns.filter((_, idx) => idx !== index);
|
||||
}
|
||||
|
||||
function addFolder() {
|
||||
targetFolders = [...targetFolders, ""];
|
||||
}
|
||||
|
||||
function updateFolder(index, value) {
|
||||
targetFolders[index] = value;
|
||||
targetFolders = [...targetFolders];
|
||||
}
|
||||
|
||||
function removeFolder(index) {
|
||||
targetFolders = targetFolders.filter((_, idx) => idx !== index);
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const cleanPatterns = patterns.map((p) => p.trim()).filter(Boolean);
|
||||
const cleanFolders = targetFolders.map((f) => f.trim()).filter(Boolean);
|
||||
|
||||
onSave({
|
||||
name: name.trim(),
|
||||
schedule: schedule.trim(),
|
||||
enabled,
|
||||
patterns: cleanPatterns,
|
||||
target_folders: cleanFolders,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-[15px] font-semibold text-text-primary">
|
||||
{rule ? "Edit automation rule" : "Create automation rule"}
|
||||
</h3>
|
||||
<p class="text-[12px] text-text-tertiary">
|
||||
Automations run on a cron schedule and process every .srt file in the
|
||||
selected folders.
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[10px] uppercase tracking-wide text-text-tertiary"
|
||||
>
|
||||
Cron
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-[11px] uppercase tracking-wide text-text-tertiary">
|
||||
Rule name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder="Clean YTS/YIFY lines"
|
||||
class="w-full rounded-lg border border-border bg-bg-card px-4 py-3 text-[13px] focus:outline-none focus:border-white/30"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-[11px] uppercase tracking-wide text-text-tertiary">
|
||||
Schedule
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={schedule}
|
||||
placeholder="0 3 * * SUN"
|
||||
class="w-full rounded-lg border border-border bg-bg-card px-4 py-3 text-[13px] font-mono focus:outline-none focus:border-white/30"
|
||||
/>
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
Example: <span class="font-mono">0 3 * * SUN</span> runs Sundays at 3am.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center justify-between gap-4 rounded-xl border border-border bg-bg-secondary/40 px-4 py-3">
|
||||
<div>
|
||||
<div class="text-[13px] font-medium">Enabled</div>
|
||||
<div class="text-[11px] text-text-tertiary">
|
||||
Disabled rules are saved but do not run.
|
||||
</div>
|
||||
</div>
|
||||
<span class="relative inline-flex items-center">
|
||||
<input type="checkbox" bind:checked={enabled} class="sr-only peer" />
|
||||
<span class="h-6 w-11 rounded-full border border-border bg-bg-card transition-colors peer-checked:bg-accent peer-checked:border-accent/60"></span>
|
||||
<span class="absolute left-0.5 h-5 w-5 rounded-full bg-text-tertiary transition-transform peer-checked:translate-x-5 peer-checked:bg-bg-primary"></span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[12px] font-medium text-text-primary">Patterns</p>
|
||||
<p class="text-[11px] text-text-tertiary">Lines containing these values are removed.</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-white/15 text-text-secondary hover:bg-white/10"
|
||||
on:click={addPattern}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each patterns as pattern, index}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={pattern}
|
||||
on:input={(e) => updatePattern(index, e.target.value)}
|
||||
placeholder="YTS"
|
||||
class="flex-1 rounded-lg border border-border bg-bg-card px-4 py-2 text-[13px] focus:outline-none focus:border-white/30"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-white/15 text-text-secondary hover:bg-white/10"
|
||||
on:click={() => removePattern(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[12px] font-medium text-text-primary">Target folders</p>
|
||||
<p class="text-[11px] text-text-tertiary">All .srt files under these paths are scanned.</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-white/15 text-text-secondary hover:bg-white/10"
|
||||
on:click={addFolder}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each targetFolders as folder, index}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={folder}
|
||||
on:input={(e) => updateFolder(index, e.target.value)}
|
||||
placeholder="/media/movies"
|
||||
class="flex-1 rounded-lg border border-border bg-bg-card px-4 py-2 text-[13px] font-mono focus:outline-none focus:border-white/30"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-white/15 text-text-secondary hover:bg-white/10"
|
||||
on:click={() => removeFolder(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-white/15 text-text-secondary hover:bg-white/10"
|
||||
on:click={() => onCancel && onCancel()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-white text-black hover:bg-white/90"
|
||||
on:click={handleSubmit}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Saving..." : "Save Rule"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,280 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
getAutomationRules,
|
||||
createAutomationRule,
|
||||
updateAutomationRule,
|
||||
deleteAutomationRule,
|
||||
runAutomationRule,
|
||||
} from "../../lib/api.js";
|
||||
import { Button } from "../../lib/components/ui/button";
|
||||
import { Skeleton } from "../../lib/components/ui/skeleton";
|
||||
import AutomationForm from "./AutomationForm.svelte";
|
||||
import { addToast } from "../../lib/toastStore.js";
|
||||
import { Play, RefreshCcw, Trash2, Edit3, Wand2 } from "lucide-svelte";
|
||||
|
||||
let rules = [];
|
||||
let loading = false;
|
||||
let saving = false;
|
||||
let error = null;
|
||||
let editingRule = null;
|
||||
let showForm = false;
|
||||
let dryRunByRule = {};
|
||||
let running = {};
|
||||
|
||||
async function loadRules() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const response = await getAutomationRules();
|
||||
rules = response.rules || [];
|
||||
} catch (err) {
|
||||
error = `Failed to load automation rules: ${err.message}`;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadRules);
|
||||
|
||||
function openCreate() {
|
||||
editingRule = null;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function openEdit(rule) {
|
||||
editingRule = rule;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
showForm = false;
|
||||
editingRule = null;
|
||||
}
|
||||
|
||||
async function handleSave(rule) {
|
||||
saving = true;
|
||||
try {
|
||||
if (editingRule) {
|
||||
await updateAutomationRule(editingRule.id, rule);
|
||||
addToast({ message: "Automation rule updated.", tone: "success" });
|
||||
} else {
|
||||
await createAutomationRule(rule);
|
||||
addToast({ message: "Automation rule created.", tone: "success" });
|
||||
}
|
||||
await loadRules();
|
||||
closeForm();
|
||||
} catch (err) {
|
||||
addToast({
|
||||
message: `Failed to save rule: ${err.message}`,
|
||||
tone: "error",
|
||||
});
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(rule) {
|
||||
try {
|
||||
await updateAutomationRule(rule.id, { enabled: !rule.enabled });
|
||||
await loadRules();
|
||||
} catch (err) {
|
||||
addToast({
|
||||
message: `Failed to update rule: ${err.message}`,
|
||||
tone: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRule(rule) {
|
||||
if (!confirm(`Delete rule "${rule.name}"?`)) return;
|
||||
try {
|
||||
await deleteAutomationRule(rule.id);
|
||||
addToast({ message: "Automation rule deleted.", tone: "success" });
|
||||
await loadRules();
|
||||
} catch (err) {
|
||||
addToast({
|
||||
message: `Failed to delete rule: ${err.message}`,
|
||||
tone: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function runRule(rule) {
|
||||
running[rule.id] = true;
|
||||
running = { ...running };
|
||||
try {
|
||||
const result = await runAutomationRule(
|
||||
rule.id,
|
||||
dryRunByRule[rule.id] === true,
|
||||
);
|
||||
const label = result.dry_run ? "Dry run" : "Run";
|
||||
addToast({
|
||||
message: `${label} complete. ${result.files_modified}/${result.files_scanned} modified.`,
|
||||
tone: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
addToast({
|
||||
message: `Run failed: ${err.message}`,
|
||||
tone: "error",
|
||||
});
|
||||
} finally {
|
||||
running[rule.id] = false;
|
||||
running = { ...running };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-text-primary">Automations</h2>
|
||||
<p class="text-[13px] text-text-secondary">
|
||||
Schedule cleanups that run on cron-style timings. Each rule scans the
|
||||
selected folders and removes lines matching your patterns.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-white/15 text-text-secondary hover:bg-white/10"
|
||||
on:click={loadRules}
|
||||
>
|
||||
<RefreshCcw class="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" className="bg-white text-black hover:bg-white/90" on:click={openCreate}>
|
||||
<Wand2 class="h-4 w-4" />
|
||||
New rule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<div class="rounded-2xl border border-border bg-card/60 p-6">
|
||||
<AutomationForm
|
||||
rule={editingRule}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-xl border border-red-500/20 bg-red-500/5 px-5 py-4">
|
||||
<p class="text-[13px] text-red-300">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(3) as _}
|
||||
<div class="rounded-2xl border border-border bg-card/60 p-6">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="mt-3 h-3 w-56" />
|
||||
<Skeleton className="mt-4 h-10 w-full" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if rules.length === 0}
|
||||
<div class="rounded-2xl border border-border bg-card/60 p-12 text-center">
|
||||
<p class="text-[13px] text-text-secondary">No automation rules yet.</p>
|
||||
<p class="text-[11px] text-text-tertiary mt-2">
|
||||
Create one to automatically clean subtitle files.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each rules as rule}
|
||||
<div class="rounded-2xl border border-border bg-card/60 p-6 space-y-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div class="space-y-1">
|
||||
<div class="text-[14px] font-semibold text-text-primary">
|
||||
{rule.name}
|
||||
</div>
|
||||
<div class="text-[12px] text-text-tertiary">
|
||||
Schedule: <span class="font-mono">{rule.schedule}</span>
|
||||
</div>
|
||||
<div class="text-[12px] text-text-tertiary">
|
||||
Targets: {rule.target_folders.length} folder
|
||||
{rule.target_folders.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-white/15 text-text-secondary hover:bg-white/10"
|
||||
on:click={() => openEdit(rule)}
|
||||
>
|
||||
<Edit3 class="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-white/15 text-text-secondary hover:bg-white/10"
|
||||
on:click={() => removeRule(rule)}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border bg-bg-secondary/40 p-4 space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<label class="flex items-center gap-2 text-[12px] text-text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.enabled}
|
||||
on:change={() => toggleEnabled(rule)}
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-[12px] text-text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={dryRunByRule[rule.id]}
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
Dry run
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-[11px] text-text-tertiary">
|
||||
{#each rule.patterns as pattern}
|
||||
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1">
|
||||
{pattern}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-[11px] text-text-tertiary">
|
||||
{#each rule.target_folders as folder}
|
||||
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 font-mono">
|
||||
{folder}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-white text-black hover:bg-white/90"
|
||||
on:click={() => runRule(rule)}
|
||||
disabled={running[rule.id]}
|
||||
>
|
||||
<Play class="h-4 w-4" />
|
||||
{running[rule.id] ? "Running..." : "Run now"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user