1.1.0 - automations, clean only mode, bug fixes

This commit is contained in:
ponzischeme89
2026-01-19 02:10:08 +13:00
parent 93e8b38e24
commit 9345ac4331
25 changed files with 2690 additions and 499 deletions
+23 -3
View File
@@ -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>
+37 -14
View File
@@ -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">&copy; 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">&copy;</span>
{/if}
</a>
</div>
</div>
</div>
</aside>
+109 -103
View File
@@ -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}
+1 -1
View File
@@ -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>
+280 -14
View File
@@ -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>
+402 -330
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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>