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
+5 -3
View File
@@ -13,9 +13,10 @@ Why? Because if the cast list and IMDb/RT rating show up in the first minute, my
## Features
- Insert plot summaries into existing .srt files without shifting timings
- Fetch metadata (plot, runtime, director, cast, IMDb/RT ratings) using OMDb, TMDb, TVMaze and Wikipedia - add these integrations under Settings before scanning
- Preserve original dialogue and timing with safe insertion logic
- Preserve original dialogue and timing with safe insertion logic while cleaning watermarks (YTS, OpenSubtitles, etc.)
- Automation rules can run cleanup-only mode on schedules, or combined cleanup + metadata enrichment
- Folder Rules to have seperate logic for different folders (for example TV shows could have runtime but not actors, etc)
- Clean, fast web UI for scanning and batch processing built with Svelte + Python/Flask
- Clean, fast web UI for scanning and batch processing built with Svelte (frontend) + Python/Flask (server)
- Three themes included: OLED, Ocean, and Dracula White
## Screenshots
@@ -27,7 +28,8 @@ Why? Because if the cast list and IMDb/RT rating show up in the first minute, my
</div>
## Getting started
To get started installing Sublogue, expand on your posion below. Personally, I recommend Komodo. It's great.
To get started installing Sublogue, expand on your posion below. Personally, I recommend **Komodo**. It's great.
<details>
<summary>⚓ Docker Compose</summary>
Create `data/` and `media/` folders next to the compose file, then run:
+380
View File
@@ -0,0 +1,380 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sublogue Documentation</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap"
rel="stylesheet" />
<style>
[x-cloak] {
display: none !important;
}
</style>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
body: ["Inter", "sans-serif"],
display: ["Space Grotesk", "sans-serif"],
mono: ["IBM Plex Mono", "monospace"],
},
colors: {
night: {
900: "#000000",
800: "#07090d",
700: "#0c1018",
600: "#121826",
500: "#182235",
},
ember: {
500: "#ff6b3d",
600: "#e95a2e",
},
},
boxShadow: {
glow: "0 0 30px rgba(255, 107, 61, 0.25)",
},
},
},
};
</script>
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body class="bg-night-900 text-slate-100 font-body overflow-x-hidden">
<div class="min-h-screen bg-night-900">
<header class="border-b border-white/10" x-data="{ open: false }">
<div
class="mx-auto flex max-w-6xl flex-col gap-4 px-4 py-5 sm:px-6 md:flex-row md:items-center md:justify-between">
<div class="flex w-full items-center justify-between gap-4 md:w-auto">
<a href="index.html" class="flex items-center gap-3">
<img src="sublogue_v2.png" alt="Sublogue" class="h-10 w-auto max-w-full sm:h-12" />
<div class="min-w-0">
<p class="text-lg font-semibold tracking-wide font-display">Sublogue</p>
<p class="text-sm text-slate-400 truncate">Subtitle metadata orchestration</p>
</div>
</a>
<button
class="ml-auto inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-night-800/80 text-slate-200 transition hover:border-ember-500/60 hover:text-white md:hidden"
type="button" aria-label="Toggle menu" aria-controls="mobile-menu" :aria-expanded="open.toString()"
@click="open = !open">
<i data-lucide="menu" class="h-5 w-5"></i>
</button>
</div>
<div class="hidden flex-wrap items-center justify-start gap-3 md:flex md:justify-end">
<a class="text-sm text-slate-300 hover:text-white" href="index.html">Overview</a>
<a class="text-sm text-slate-300 hover:text-white" href="#features">Features</a>
<a class="text-sm text-slate-300 hover:text-white" href="#integrations">Integrations</a>
<a class="text-sm text-slate-300 hover:text-white" href="#getting-started">Getting started</a>
</div>
<div id="mobile-menu" x-show="open" x-cloak x-transition.scale.origin.top.duration.200ms
class="flex flex-col gap-3 rounded-2xl border border-white/10 bg-night-800/90 p-4 text-sm shadow-2xl md:hidden">
<a class="text-slate-200 hover:text-white" href="index.html" @click="open = false">Overview</a>
<a class="text-slate-200 hover:text-white" href="#features" @click="open = false">Features</a>
<a class="text-slate-200 hover:text-white" href="#integrations" @click="open = false">Integrations</a>
<a class="text-slate-200 hover:text-white" href="#getting-started" @click="open = false">Getting started</a>
</div>
</div>
</header>
<main class="mx-auto max-w-6xl px-4 sm:px-6">
<section class="grid gap-8 pb-10 pt-12 md:pb-12 md:pt-16 lg:grid-cols-[1.1fr_0.9fr]">
<div>
<p class="text-sm uppercase tracking-[0.3em] text-ember-500">Documentation</p>
<h1 class="mt-4 text-3xl font-semibold leading-tight font-display sm:text-4xl md:text-5xl">
Sublogue usage and setup
</h1>
<p class="mt-5 text-base text-slate-300 sm:text-lg">
Sublogue is a lightweight, open source tool that enriches subtitle files with metadata and plot
summaries. It keeps timing intact, respects safe insertion windows, and lets you choose the metadata
provider per scan or per folder.
</p>
<p class="mt-4 text-sm text-slate-400">
This page mirrors the README but adds extra context, limits, and setup notes so you can move from
install to first scan without guesswork.
</p>
</div>
<div class="rounded-3xl border border-white/10 bg-night-800/70 p-6 shadow-xl sm:p-7">
<div class="flex items-center gap-3">
<i data-lucide="file-text" class="h-6 w-6 text-ember-500"></i>
<h2 class="text-lg font-semibold font-display">Quick checklist</h2>
</div>
<ul class="mt-5 space-y-3 text-sm text-slate-300">
<li class="flex items-start gap-3">
<i data-lucide="check" class="mt-1 h-4 w-4 text-ember-500"></i>
Install the container and map /config + /media volumes.
</li>
<li class="flex items-start gap-3">
<i data-lucide="check" class="mt-1 h-4 w-4 text-ember-500"></i>
Enable integrations and add API keys where required.
</li>
<li class="flex items-start gap-3">
<i data-lucide="check" class="mt-1 h-4 w-4 text-ember-500"></i>
Scan a folder and review matches before applying.
</li>
<li class="flex items-start gap-3">
<i data-lucide="check" class="mt-1 h-4 w-4 text-ember-500"></i>
Use per-folder rules for language and formatting differences.
</li>
</ul>
</div>
</section>
<section id="features" class="mb-12 md:mb-16">
<div class="flex items-center gap-3">
<i data-lucide="sparkles" class="h-6 w-6 text-ember-500"></i>
<h2 class="text-2xl font-semibold font-display">Features</h2>
</div>
<p class="mt-4 text-sm text-slate-300 sm:text-base">
Sublogue focuses on safe subtitle enrichment. It never shifts existing timecodes, and only inserts
content when there is a safe gap at the start or end of the file.
</p>
<div class="mt-6 grid gap-6 md:grid-cols-2">
<div class="rounded-2xl border border-white/10 bg-night-800/70 p-6">
<h3 class="text-lg font-semibold font-display">Metadata injection</h3>
<p class="mt-3 text-sm text-slate-300">
Pull plot, runtime, ratings, and credits from OMDb, TMDb, TVmaze, or Wikipedia and insert them in a
clean, readable format.
</p>
</div>
<div class="rounded-2xl border border-white/10 bg-night-800/70 p-6">
<h3 class="text-lg font-semibold font-display">Safe timing</h3>
<p class="mt-3 text-sm text-slate-300">
Sublogue refuses to insert if there is not enough gap, so subtitle timing stays intact.
</p>
</div>
<div class="rounded-2xl border border-white/10 bg-night-800/70 p-6">
<h3 class="text-lg font-semibold font-display">Folder rules</h3>
<p class="mt-3 text-sm text-slate-300">
Override metadata source, language, and formatting per directory for mixed media libraries.
</p>
</div>
<div class="rounded-2xl border border-white/10 bg-night-800/70 p-6">
<h3 class="text-lg font-semibold font-display">Web UI</h3>
<p class="mt-3 text-sm text-slate-300">
Scan folders, batch process results, and track history from a lightweight Svelte UI.
</p>
</div>
</div>
</section>
<section id="integrations" class="mb-12 md:mb-16">
<div class="flex items-center gap-3">
<i data-lucide="plug" class="h-6 w-6 text-ember-500"></i>
<h2 class="text-2xl font-semibold font-display">Integrations</h2>
</div>
<p class="mt-4 text-sm text-slate-300 sm:text-base">
Each provider has a different signup and usage policy. Wikipedia is strict by design: exact title
matches with year validation are required to reduce false matches from an open encyclopedia.
</p>
<div class="mt-6 overflow-x-auto rounded-2xl border border-white/10 bg-night-800/70">
<table class="min-w-full text-sm text-slate-200">
<thead class="bg-night-900/60 text-xs uppercase tracking-wide text-slate-400">
<tr>
<th class="px-4 py-3 text-left">Provider</th>
<th class="px-4 py-3 text-left">Signup or API key</th>
<th class="px-4 py-3 text-left">Rate limits</th>
<th class="px-4 py-3 text-left">Notes</th>
</tr>
</thead>
<tbody class="divide-y divide-white/10">
<tr>
<td class="px-4 py-3 font-semibold">OMDb</td>
<td class="px-4 py-3">
<a class="text-ember-500 hover:text-ember-600" href="https://www.omdbapi.com/apikey.aspx"
target="_blank" rel="noopener noreferrer">Get an API key</a>
</td>
<td class="px-4 py-3">Free tier has a daily cap</td>
<td class="px-4 py-3">Best for movie matching</td>
</tr>
<tr>
<td class="px-4 py-3 font-semibold">TMDb</td>
<td class="px-4 py-3">
<a class="text-ember-500 hover:text-ember-600" href="https://www.themoviedb.org/settings/api"
target="_blank" rel="noopener noreferrer">Request an API key</a>
</td>
<td class="px-4 py-3">Per-second rate limit</td>
<td class="px-4 py-3">Great for localization and posters</td>
</tr>
<tr>
<td class="px-4 py-3 font-semibold">TVmaze</td>
<td class="px-4 py-3">
<a class="text-ember-500 hover:text-ember-600" href="https://www.tvmaze.com/api" target="_blank"
rel="noopener noreferrer">View API docs</a>
</td>
<td class="px-4 py-3">Polite usage limits</td>
<td class="px-4 py-3">No API key required</td>
</tr>
<tr>
<td class="px-4 py-3 font-semibold">Wikipedia</td>
<td class="px-4 py-3">
<a class="text-ember-500 hover:text-ember-600" href="https://www.mediawiki.org/wiki/API:Main_page"
target="_blank" rel="noopener noreferrer">MediaWiki API</a>
</td>
<td class="px-4 py-3">No hard limits, be polite</td>
<td class="px-4 py-3">Strict title matching</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="screenshots" class="mb-12 md:mb-16">
<div class="flex items-center gap-3">
<i data-lucide="image" class="h-6 w-6 text-ember-500"></i>
<h2 class="text-2xl font-semibold font-display">Screenshots</h2>
</div>
<p class="mt-4 text-sm text-slate-300 sm:text-base">
The interface is designed for fast scans and quick metadata validation. These screenshots show the scan
results and settings panels.
</p>
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<img src="screenshots/screenshot_scan.png" alt="Scan results"
class="rounded-2xl border border-white/10 shadow-lg" />
<img src="screenshots/screenshot_settings.png" alt="Settings panel"
class="rounded-2xl border border-white/10 shadow-lg" />
</div>
</section>
<section id="getting-started" class="mb-12 md:mb-16">
<div class="flex items-center gap-3">
<i data-lucide="rocket" class="h-6 w-6 text-ember-500"></i>
<h2 class="text-2xl font-semibold font-display">Getting started</h2>
</div>
<p class="mt-4 text-sm text-slate-300 sm:text-base">
Sublogue runs as a single container. Map /config for settings and /media for subtitles, then open the web
UI at http://localhost:5000.
</p>
<div class="mt-6 grid gap-6 lg:grid-cols-3">
<div class="rounded-2xl border border-white/10 bg-night-800/70 p-6">
<h3 class="text-lg font-semibold font-display">Docker Compose</h3>
<p class="mt-3 text-sm text-slate-300">
Create data/ and media/ folders next to your compose file.
</p>
<pre
class="mt-4 overflow-x-auto whitespace-pre-wrap break-words rounded-2xl border border-white/10 bg-night-900/90 p-4 text-xs font-mono leading-relaxed text-emerald-200 shadow-[0_12px_30px_rgba(0,0,0,0.45)]"><code>version: "3.9"
services:
sublogue:
image: ghcr.io/ponzischeme89/sublogue:latest
container_name: sublogue
restart: unless-stopped
environment:
- TZ=Pacific/Auckland
volumes:
- ./data:/config
- ./media:/media
ports:
- "5000:5000"</code></pre>
</div>
<div class="rounded-2xl border border-white/10 bg-night-800/70 p-6">
<h3 class="text-lg font-semibold font-display">Unraid</h3>
<p class="mt-3 text-sm text-slate-300">
Use the bundled template and map host paths into /config and /media.
</p>
<div class="mt-4 rounded-2xl border border-white/10 bg-night-900/70 p-4 text-sm text-slate-200">
<p>/mnt/user/appdata/sublogue -&gt; /config</p>
<p class="mt-2">/mnt/user/appdata/sublogue/media -&gt; /media</p>
</div>
</div>
<div class="rounded-2xl border border-white/10 bg-night-800/70 p-6">
<h3 class="text-lg font-semibold font-display">Komodo</h3>
<p class="mt-3 text-sm text-slate-300">
Create a new stack and attach your external network.
</p>
<pre
class="mt-4 overflow-x-auto whitespace-pre-wrap break-words rounded-2xl border border-white/10 bg-night-900/90 p-4 text-xs font-mono leading-relaxed text-emerald-200 shadow-[0_12px_30px_rgba(0,0,0,0.45)]"><code>version: "3.9"
services:
sublogue:
image: ghcr.io/ponzischeme89/sublogue:latest
container_name: sublogue
ports:
- "5000:5000"
environment:
- TZ=Etc/UTC
- PUID=1000
- PGID=1000
volumes:
- /volume1/Docker/sublogue/data:/config
- /volume1/Media:/media
restart: unless-stopped
networks:
- npm_network
networks:
npm_network:
external: true</code></pre>
</div>
</div>
</section>
<section id="limitations" class="mb-12 md:mb-16">
<div class="flex items-center gap-3">
<i data-lucide="alert-triangle" class="h-6 w-6 text-ember-500"></i>
<h2 class="text-2xl font-semibold font-display">Limitations</h2>
</div>
<ul class="mt-4 space-y-2 text-sm text-slate-300 sm:text-base">
<li>API limits vary per provider and can throttle large scans.</li>
<li>Metadata gaps appear when providers do not have a title or plot.</li>
<li>Only TMDb supports localization. OMDb and TVmaze are English-first.</li>
<li>Long plots are inserted as-is. TVs may split them across screens.</li>
<li>Only .srt files are supported.</li>
<li>Reprocessing a file can insert multiple plot blocks.</li>
</ul>
</section>
<section id="roadmap" class="mb-12 md:mb-16">
<div class="flex items-center gap-3">
<i data-lucide="map" class="h-6 w-6 text-ember-500"></i>
<h2 class="text-2xl font-semibold font-display">Roadmap</h2>
</div>
<ul class="mt-4 grid gap-2 text-sm text-slate-300 sm:grid-cols-2 sm:text-base">
<li>More UI themes and high contrast variants</li>
<li>Poster and backdrop previews in results</li>
<li>Duplicate plot detection and auto-avoidance</li>
<li>Automatic backoff and retry logic</li>
<li>Short plot mode for long summaries</li>
<li>Expanded localization for plots and cast</li>
<li>Subtitle format support beyond SRT</li>
<li>Optional local cache for metadata lookups</li>
</ul>
</section>
<section id="support" class="mb-12 md:mb-16">
<div class="rounded-3xl border border-ember-500/30 bg-night-800/70 p-6 sm:p-8">
<div class="flex items-center gap-3">
<i data-lucide="message-circle" class="h-6 w-6 text-ember-500"></i>
<h2 class="text-2xl font-semibold font-display">Support</h2>
</div>
<p class="mt-4 text-sm text-slate-300 sm:text-base">
If Sublogue helps your library, consider starring the repo or sharing it with someone who cares about
clean subtitles. Bug reports and feature requests are welcome.
</p>
</div>
</section>
</main>
<footer class="border-t border-white/10">
<div
class="mx-auto flex max-w-6xl flex-col gap-4 px-4 py-8 text-sm text-slate-400 sm:px-6 md:flex-row md:items-center md:justify-between">
<p>&copy 2026 ponzischeme89. Released under AGLPL-3.0 licence.</p>
<div class="flex items-center gap-3">
<i data-lucide="github" class="h-4 w-4"></i>
<span>github.com/ponzischeme89/Sublogue</span>
</div>
</div>
</footer>
</div>
<script>
lucide.createIcons();
</script>
</body>
</html>
+3 -1
View File
@@ -70,6 +70,7 @@
<div class="hidden flex-wrap items-center justify-start gap-3 md:flex md:justify-end">
<a class="text-sm text-slate-300 hover:text-white" href="#install">Install</a>
<a class="text-sm text-slate-300 hover:text-white" href="#integrations">Integrations</a>
<a class="text-sm text-slate-300 hover:text-white" href="documentation.html">Documentation</a>
<a class="rounded-full border border-ember-500/60 px-4 py-2 text-sm font-semibold text-ember-500 hover:bg-ember-500 hover:text-night-900"
href="#quickstart">Quickstart</a>
</div>
@@ -77,6 +78,7 @@
class="flex flex-col gap-3 rounded-2xl border border-white/10 bg-night-800/90 p-4 text-sm shadow-2xl md:hidden">
<a class="text-slate-200 hover:text-white" href="#install" @click="open = false">Install</a>
<a class="text-slate-200 hover:text-white" href="#integrations" @click="open = false">Integrations</a>
<a class="text-slate-200 hover:text-white" href="documentation.html" @click="open = false">Documentation</a>
<a class="rounded-full border border-ember-500/60 px-4 py-2 text-center font-semibold text-ember-500 hover:bg-ember-500 hover:text-night-900"
href="#quickstart" @click="open = false">Quickstart</a>
</div>
@@ -449,4 +451,4 @@ You are early.</code></pre>
</script>
</body>
</html>
</html>
+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>
+289 -20
View File
@@ -1,9 +1,12 @@
import asyncio
import atexit
import json
import os
import threading
import time
import re
import uuid
import signal
from difflib import SequenceMatcher
from datetime import datetime, timezone
from pathlib import Path
@@ -18,9 +21,12 @@ from core.tvmaze_client import TVMazeClient
from core.wikipedia_client import WikipediaClient
from core.subtitle_processor import SubtitleProcessor, SubtitleFormatOptions, SUBLOGUE_TOKEN_PATTERN, SUBLOGUE_SENTINEL
from core.keyword_stripper import get_stripper
from core.keyword_stripper import get_stripper
from core.file_scanner import FileScanner
from core.database import DatabaseManager
from logging_utils import configure_logging, get_logger
from automations.engine import AutomationEngine
from apscheduler.triggers.cron import CronTrigger
# Configure logging
configure_logging()
@@ -39,6 +45,7 @@ tmdb_client = None
tvmaze_client = None
wikipedia_client = None
processor = None
automation_engine = None
# In-memory scan state (still used for current session)
scan_state = {
@@ -71,7 +78,11 @@ def perform_scheduled_scan(directory):
start_time = time.time()
files = []
for batch in FileScanner.scan_directory(directory, batch_size=10):
for batch in FileScanner.scan_directory(
directory,
batch_size=10,
detect_cleanup_keywords=True,
):
files.extend(batch)
scan_duration_ms = int((time.time() - start_time) * 1000)
@@ -129,6 +140,31 @@ def start_scheduled_scan_worker():
scheduled_scan_thread.start()
def start_automation_engine():
"""Start automation scheduler once."""
global automation_engine
if automation_engine is None:
automation_engine = AutomationEngine(db)
automation_engine.start()
def stop_automation_engine():
"""Stop automation scheduler."""
if automation_engine:
automation_engine.shutdown()
def start_automation_engine_async():
"""Start automation scheduler in a background thread."""
def _run():
try:
start_automation_engine()
except Exception as e:
logger.exception("Automation engine failed to start: %s", e)
threading.Thread(target=_run, daemon=True).start()
def initialize_clients():
"""Initialize OMDb, TMDb, TVmaze clients and processor with current API keys"""
global omdb_client, tmdb_client, tvmaze_client, wikipedia_client, processor
@@ -189,6 +225,8 @@ def initialize_clients():
else:
logger.warning("No metadata providers configured")
_apply_cleanup_keywords_from_settings()
# Migrate existing settings to database on startup
def migrate_settings():
@@ -220,6 +258,27 @@ def _get_str_setting(key: str, default: str) -> str:
return default
return str(value)
def _normalize_keyword_list(value) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(v).strip() for v in value if str(v).strip()]
if isinstance(value, str):
parts = re.split(r"[\n,]+", value)
return [p.strip() for p in parts if p.strip()]
return [str(value).strip()] if str(value).strip() else []
def _apply_cleanup_keywords_from_settings():
keywords = _normalize_keyword_list(db.get_setting("clean_subtitle_force_remove", []))
get_stripper().set_force_remove_keywords(keywords)
def _ensure_automation_engine():
global automation_engine
if automation_engine is None:
automation_engine = AutomationEngine(db)
automation_engine.start()
return automation_engine
def _get_folder_rule_for_path(file_path: str, rules: list[dict]) -> dict | None:
"""Pick the most specific folder rule that matches the file path."""
if not rules:
@@ -386,6 +445,15 @@ migrate_settings()
initialize_clients()
def _handle_shutdown(signum, frame):
stop_automation_engine()
raise SystemExit(0)
atexit.register(stop_automation_engine)
signal.signal(signal.SIGTERM, _handle_shutdown)
# ============ SETTINGS ENDPOINTS ============
@app.route('/api/settings', methods=['GET'])
@@ -411,6 +479,8 @@ def get_settings():
settings["strip_keywords"] = True
if "clean_subtitle_content" not in settings:
settings["clean_subtitle_content"] = True
if "clean_subtitle_force_remove" not in settings:
settings["clean_subtitle_force_remove"] = ["YTS", "OpenSubtitles"]
if "omdb_enabled" not in settings:
settings["omdb_enabled"] = False
if "tmdb_enabled" not in settings:
@@ -467,6 +537,11 @@ def update_settings():
db.set_setting("strip_keywords", bool(data["strip_keywords"]))
if "clean_subtitle_content" in data:
db.set_setting("clean_subtitle_content", bool(data["clean_subtitle_content"]))
if "clean_subtitle_force_remove" in data:
db.set_setting(
"clean_subtitle_force_remove",
_normalize_keyword_list(data["clean_subtitle_force_remove"]),
)
if "omdb_enabled" in data:
db.set_setting("omdb_enabled", bool(data["omdb_enabled"]))
if "tmdb_enabled" in data:
@@ -510,6 +585,160 @@ def update_settings():
}), 500
# ============ AUTOMATION ENDPOINTS ============
@app.route('/api/automation/rules', methods=['GET'])
def get_automation_rules():
try:
rules = db.get_automation_rules()
return jsonify({
"success": True,
"rules": rules
})
except Exception as e:
logger.error(f"Error fetching automation rules: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
@app.route('/api/automation/rules', methods=['POST'])
def create_automation_rule():
try:
data = request.json or {}
name = (data.get("name") or "").strip()
schedule = (data.get("schedule") or "").strip()
patterns = data.get("patterns") or []
target_folders = data.get("target_folders") or []
enabled = bool(data.get("enabled", True))
if not name:
return jsonify({"success": False, "error": "Name is required"}), 400
if not schedule:
return jsonify({"success": False, "error": "Schedule is required"}), 400
try:
CronTrigger.from_crontab(schedule)
except ValueError:
return jsonify({"success": False, "error": "Invalid cron schedule"}), 400
rule_id = data.get("id") or str(uuid.uuid4())
saved = db.upsert_automation_rule({
"id": rule_id,
"name": name,
"schedule": schedule,
"enabled": enabled,
"patterns": patterns,
"target_folders": target_folders
})
if not saved:
return jsonify({"success": False, "error": "Failed to save rule"}), 500
engine = _ensure_automation_engine()
engine.reload_rules()
return jsonify({
"success": True,
"rule": db.get_automation_rule(rule_id)
})
except Exception as e:
logger.error(f"Error creating automation rule: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
@app.route('/api/automation/rules/<rule_id>', methods=['PUT'])
def update_automation_rule(rule_id):
try:
existing = db.get_automation_rule(rule_id)
if not existing:
return jsonify({"success": False, "error": "Rule not found"}), 404
data = request.json or {}
name = (data.get("name") or existing["name"]).strip()
schedule = (data.get("schedule") or existing["schedule"]).strip()
patterns = data.get("patterns", existing["patterns"])
target_folders = data.get("target_folders", existing["target_folders"])
enabled = bool(data.get("enabled", existing["enabled"]))
if not name:
return jsonify({"success": False, "error": "Name is required"}), 400
if not schedule:
return jsonify({"success": False, "error": "Schedule is required"}), 400
try:
CronTrigger.from_crontab(schedule)
except ValueError:
return jsonify({"success": False, "error": "Invalid cron schedule"}), 400
saved = db.upsert_automation_rule({
"id": rule_id,
"name": name,
"schedule": schedule,
"enabled": enabled,
"patterns": patterns,
"target_folders": target_folders
})
if not saved:
return jsonify({"success": False, "error": "Failed to update rule"}), 500
engine = _ensure_automation_engine()
engine.reload_rules()
return jsonify({
"success": True,
"rule": db.get_automation_rule(rule_id)
})
except Exception as e:
logger.error(f"Error updating automation rule: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
@app.route('/api/automation/rules/<rule_id>', methods=['DELETE'])
def delete_automation_rule(rule_id):
try:
deleted = db.delete_automation_rule(rule_id)
if not deleted:
return jsonify({"success": False, "error": "Rule not found"}), 404
engine = _ensure_automation_engine()
engine.reload_rules()
return jsonify({
"success": True
})
except Exception as e:
logger.error(f"Error deleting automation rule: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
@app.route('/api/automation/rules/<rule_id>/run', methods=['POST'])
def run_automation_rule(rule_id):
try:
data = request.json or {}
dry_run = bool(data.get("dry_run", False))
engine = _ensure_automation_engine()
result = engine.run_rule_now(rule_id, dry_run=dry_run)
if not result.get("success"):
return jsonify(result), 404
return jsonify(result)
except Exception as e:
logger.error(f"Error running automation rule: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
# ============ SCAN ENDPOINTS ============
@app.route('/api/scan/start', methods=['POST'])
@@ -535,7 +764,11 @@ def start_scan():
# Perform scan - collect batches into a flat list
files = []
for batch in FileScanner.scan_directory(directory, batch_size=10):
for batch in FileScanner.scan_directory(
directory,
batch_size=10,
detect_cleanup_keywords=True,
):
files.extend(batch)
# Calculate scan duration
@@ -631,7 +864,11 @@ def stream_scan():
batch_count = 0
# Stream batches as they're found
for batch in FileScanner.scan_directory(directory, batch_size=10):
for batch in FileScanner.scan_directory(
directory,
batch_size=10,
detect_cleanup_keywords=True,
):
if client_closed.is_set():
logger.info("Client disconnected, stopping scan loop")
scan_state["scanning"] = False
@@ -1306,6 +1543,7 @@ def process_files():
duration = data.get("duration", db.get_setting("duration", 40))
title_override = data.get("titleOverride", None) # Optional title override
force_reprocess = data.get("forceReprocess", False) # Optional force flag
clean_only = bool(data.get("clean_only", False))
if not file_paths:
return jsonify({
@@ -1313,12 +1551,22 @@ def process_files():
"error": "No files specified"
}), 400
if not processor:
if not processor and not clean_only:
return jsonify({
"success": False,
"error": "Metadata provider not configured"
}), 400
processor_instance = processor
if clean_only and processor_instance is None:
processor_instance = SubtitleProcessor(
omdb_client,
tmdb_client,
tvmaze_client,
wikipedia_client,
preferred_source=_get_str_setting("preferred_source", "omdb"),
)
# Load default format options from settings
format_options = get_format_options_from_settings()
@@ -1351,18 +1599,25 @@ def process_files():
preferred_source = rule.get("preferred_source") if rule else None
language = rule.get("language") if rule else None
result = asyncio.run(processor.process_file(
file_path,
duration,
force_reprocess=force_reprocess,
title_override=title_override,
format_options=effective_format,
strip_keywords=strip_keywords,
clean_subtitle_content=clean_subtitle_content,
insertion_position=insertion_position or default_insertion_position,
preferred_source=preferred_source or default_preferred_source,
language=language,
))
if clean_only:
result = processor_instance.clean_file(
file_path,
clean_subtitle_content=clean_subtitle_content,
)
result["clean_only"] = True
else:
result = asyncio.run(processor_instance.process_file(
file_path,
duration,
force_reprocess=force_reprocess,
title_override=title_override,
format_options=effective_format,
strip_keywords=strip_keywords,
clean_subtitle_content=clean_subtitle_content,
insertion_position=insertion_position or default_insertion_position,
preferred_source=preferred_source or default_preferred_source,
language=language,
))
# Track success/failure
if result["success"]:
@@ -1386,7 +1641,9 @@ def process_files():
"success": result["success"],
"status": result.get("status", "Unknown"),
"summary": result.get("summary", ""),
"error": result.get("error")
"error": result.get("error"),
"clean_only": result.get("clean_only", False),
"clean_keywords": result.get("clean_keywords", []),
})
# Update scan state
@@ -1394,7 +1651,10 @@ def process_files():
if file_info["path"] == file_path:
file_info["status"] = result.get("status", "Unknown")
file_info["summary"] = result.get("summary", "") if isinstance(result.get("summary"), str) else ""
file_info["has_plot"] = result["success"]
if not result.get("clean_only"):
file_info["has_plot"] = result["success"]
else:
file_info["clean_keywords"] = result.get("clean_keywords", [])
break
except Exception as e:
@@ -1417,7 +1677,9 @@ def process_files():
"success": False,
"status": "Error",
"summary": "",
"error": str(e)
"error": str(e),
"clean_only": clean_only,
"clean_keywords": [],
})
# Complete the run in database
@@ -1795,4 +2057,11 @@ if __name__ == '__main__':
logger.info("Starting Sublogue API server on http://localhost:5000")
if os.environ.get("WERKZEUG_RUN_MAIN") == "true" or not app.debug:
start_scheduled_scan_worker()
app.run(debug=True, host='0.0.0.0', port=5000)
start_automation_engine_async()
os.environ.pop("FLASK_RUN_FROM_CLI", None)
try:
logger.info("Launching Flask app on 0.0.0.0:5000")
app.run(debug=True, use_reloader=False, host='0.0.0.0', port=5000)
except Exception as e:
logger.exception("Flask server failed to start: %s", e)
raise
+4
View File
@@ -0,0 +1,4 @@
from .models import AutomationRule
from .engine import AutomationEngine
__all__ = ["AutomationRule", "AutomationEngine"]
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from pathlib import Path
from typing import Iterable, List, Tuple
from logging_utils import get_logger
from core.subtitle_processor import SubtitleBlock, parse_srt, format_srt
logger = get_logger(__name__)
def enumerate_srt_files(folders: Iterable[str]) -> List[Path]:
files: List[Path] = []
for folder in folders:
if not folder:
continue
path = Path(folder)
if not path.exists():
logger.warning("Automation folder does not exist: %s", folder)
continue
if not path.is_dir():
continue
files.extend([p for p in path.rglob("*.srt") if p.is_file()])
return files
def remove_lines_matching_patterns(file_path: str, patterns: List[str], dry_run: bool = False) -> Tuple[bool, int]:
"""Remove subtitle lines containing any of the provided patterns."""
if not patterns:
return False, 0
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
content = path.read_text(encoding="utf-8", errors="ignore")
blocks = parse_srt(content)
lowered_patterns = [p.lower() for p in patterns if p]
removed_lines = 0
updated_blocks: List[SubtitleBlock] = []
for block in blocks:
lines = block.text.splitlines()
kept_lines = []
for line in lines:
line_lower = line.lower()
if any(pattern in line_lower for pattern in lowered_patterns):
removed_lines += 1
continue
kept_lines.append(line)
if kept_lines:
updated_blocks.append(
SubtitleBlock(
index=block.index,
start_time=block.start_time,
end_time=block.end_time,
text="\n".join(kept_lines).strip(),
)
)
if removed_lines == 0:
return False, 0
renumbered = [
SubtitleBlock(i + 1, b.start_time, b.end_time, b.text)
for i, b in enumerate(updated_blocks)
]
if not dry_run:
path.write_text(format_srt(renumbered), encoding="utf-8")
return True, removed_lines
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
import threading
from typing import Dict, List, Optional
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from logging_utils import get_logger
from automations.actions import enumerate_srt_files, remove_lines_matching_patterns
from automations.models import AutomationRule
logger = get_logger(__name__)
class AutomationEngine:
"""Scheduler for automation rules."""
def __init__(self, db_manager):
self.db_manager = db_manager
self._scheduler = BackgroundScheduler(daemon=True)
self._lock = threading.Lock()
self._started = False
def start(self):
with self._lock:
if self._started:
return
self._scheduler.start()
self._started = True
self.reload_rules()
logger.info("Automation scheduler started")
def shutdown(self):
with self._lock:
if not self._started:
return
self._scheduler.shutdown(wait=False)
self._started = False
logger.info("Automation scheduler stopped")
def reload_rules(self):
"""Reload all automation rules from storage."""
with self._lock:
self._scheduler.remove_all_jobs()
rules = self._load_rules()
for rule in rules:
if not rule.enabled:
continue
self._schedule_rule(rule)
def run_rule_now(self, rule_id: str, dry_run: bool = False) -> dict:
rule = self._get_rule(rule_id)
if not rule:
return {"success": False, "error": "Rule not found"}
return self._execute_rule(rule, dry_run=dry_run)
def _load_rules(self) -> List[AutomationRule]:
rules_raw = self.db_manager.get_automation_rules()
return [AutomationRule.from_dict(r) for r in rules_raw]
def _get_rule(self, rule_id: str) -> Optional[AutomationRule]:
raw = self.db_manager.get_automation_rule(rule_id)
return AutomationRule.from_dict(raw) if raw else None
def _schedule_rule(self, rule: AutomationRule):
try:
trigger = CronTrigger.from_crontab(rule.schedule)
except ValueError as e:
logger.error("Invalid cron schedule for rule %s: %s", rule.id, e)
return
self._scheduler.add_job(
self._run_rule_job,
trigger=trigger,
args=[rule.id],
id=f"automation:{rule.id}",
replace_existing=True,
misfire_grace_time=300,
max_instances=1,
)
def _run_rule_job(self, rule_id: str):
rule = self._get_rule(rule_id)
if not rule or not rule.enabled:
return
self._execute_rule(rule, dry_run=False)
def _execute_rule(self, rule: AutomationRule, dry_run: bool) -> dict:
files = enumerate_srt_files(rule.target_folders)
modified = 0
total_removed = 0
errors: List[str] = []
for file_path in files:
try:
did_modify, removed_lines = remove_lines_matching_patterns(
str(file_path),
rule.patterns,
dry_run=dry_run,
)
if did_modify:
modified += 1
total_removed += removed_lines
self.db_manager.add_automation_log(
rule_id=rule.id,
file_path=str(file_path),
modified=did_modify,
removed_lines=removed_lines,
dry_run=dry_run,
error_message=None,
)
except Exception as e:
errors.append(f"{file_path}: {e}")
self.db_manager.add_automation_log(
rule_id=rule.id,
file_path=str(file_path),
modified=False,
removed_lines=0,
dry_run=dry_run,
error_message=str(e),
)
return {
"success": True,
"rule_id": rule.id,
"files_scanned": len(files),
"files_modified": modified,
"removed_lines": total_removed,
"dry_run": dry_run,
"errors": errors,
}
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import List
@dataclass(slots=True)
class AutomationRule:
id: str
name: str
schedule: str
enabled: bool
patterns: List[str]
target_folders: List[str]
@staticmethod
def from_dict(data: dict) -> "AutomationRule":
return AutomationRule(
id=str(data.get("id", "")),
name=str(data.get("name", "")),
schedule=str(data.get("schedule", "")),
enabled=bool(data.get("enabled", True)),
patterns=list(data.get("patterns", []) or []),
target_folders=list(data.get("target_folders", []) or []),
)
+140
View File
@@ -184,6 +184,40 @@ class FolderRule(Base):
return f"<FolderRule(id={self.id}, directory='{self.directory}')>"
class AutomationRule(Base):
"""Automation rules for scheduled tasks"""
__tablename__ = 'automation_rules'
id = Column(String(64), primary_key=True)
name = Column(String(255), nullable=False)
schedule = Column(String(100), nullable=False)
enabled = Column(Boolean, default=True, nullable=False)
patterns = Column(Text, nullable=False) # JSON list
target_folders = Column(Text, nullable=False) # JSON list
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<AutomationRule(id='{self.id}', name='{self.name}', enabled={self.enabled})>"
class AutomationLog(Base):
"""Automation run log entries"""
__tablename__ = 'automation_logs'
id = Column(Integer, primary_key=True)
rule_id = Column(String(64), nullable=False, index=True)
file_path = Column(String(500), nullable=False)
modified = Column(Boolean, default=False)
removed_lines = Column(Integer, default=0)
dry_run = Column(Boolean, default=False)
error_message = Column(Text)
run_at = Column(DateTime, default=datetime.utcnow, nullable=False)
def __repr__(self):
return f"<AutomationLog(rule_id='{self.rule_id}', file_path='{self.file_path}')>"
class DatabaseManager:
"""Manages database connections and operations"""
@@ -1033,6 +1067,112 @@ class DatabaseManager:
finally:
session.close()
# ============ AUTOMATION RULES OPERATIONS ============
def get_automation_rules(self):
"""Get all automation rules"""
session = self.get_session()
try:
rules = session.query(AutomationRule).order_by(AutomationRule.created_at.asc()).all()
result = []
for rule in rules:
result.append({
"id": rule.id,
"name": rule.name,
"schedule": rule.schedule,
"enabled": rule.enabled,
"patterns": json.loads(rule.patterns) if rule.patterns else [],
"target_folders": json.loads(rule.target_folders) if rule.target_folders else [],
"created_at": rule.created_at.isoformat() if rule.created_at else None,
"updated_at": rule.updated_at.isoformat() if rule.updated_at else None
})
return result
finally:
session.close()
def get_automation_rule(self, rule_id):
"""Get a single automation rule"""
session = self.get_session()
try:
rule = session.query(AutomationRule).filter_by(id=rule_id).first()
if not rule:
return None
return {
"id": rule.id,
"name": rule.name,
"schedule": rule.schedule,
"enabled": rule.enabled,
"patterns": json.loads(rule.patterns) if rule.patterns else [],
"target_folders": json.loads(rule.target_folders) if rule.target_folders else [],
"created_at": rule.created_at.isoformat() if rule.created_at else None,
"updated_at": rule.updated_at.isoformat() if rule.updated_at else None
}
finally:
session.close()
def upsert_automation_rule(self, rule_data):
"""Create or update an automation rule"""
session = self.get_session()
try:
rule_id = rule_data["id"]
rule = session.query(AutomationRule).filter_by(id=rule_id).first()
if not rule:
rule = AutomationRule(id=rule_id)
session.add(rule)
rule.name = rule_data["name"]
rule.schedule = rule_data["schedule"]
rule.enabled = bool(rule_data.get("enabled", True))
rule.patterns = json.dumps(rule_data.get("patterns", []))
rule.target_folders = json.dumps(rule_data.get("target_folders", []))
session.commit()
return True
except Exception as e:
session.rollback()
logger.error(f"Error saving automation rule: {e}")
return False
finally:
session.close()
def delete_automation_rule(self, rule_id):
"""Delete an automation rule"""
session = self.get_session()
try:
rule = session.query(AutomationRule).filter_by(id=rule_id).first()
if rule:
session.delete(rule)
session.commit()
return True
return False
except Exception as e:
session.rollback()
logger.error(f"Error deleting automation rule: {e}")
return False
finally:
session.close()
def add_automation_log(self, rule_id, file_path, modified, removed_lines, dry_run=False, error_message=None):
"""Add an automation log entry"""
session = self.get_session()
try:
entry = AutomationLog(
rule_id=rule_id,
file_path=file_path,
modified=bool(modified),
removed_lines=int(removed_lines or 0),
dry_run=bool(dry_run),
error_message=error_message
)
session.add(entry)
session.commit()
except Exception as e:
session.rollback()
logger.error(f"Error saving automation log: {e}")
raise
finally:
session.close()
# ============ MAINTENANCE OPERATIONS ============
def clear_settings(self, keep_api_keys=False):
+21 -6
View File
@@ -18,6 +18,7 @@ logger = get_logger("FileScanner")
import sys
sys.path.insert(0, str(Path(__file__).parent))
from subtitle_processor import parse_srt, SUBLOGUE_SENTINEL, SUBLOGUE_TOKEN_PATTERN
from keyword_stripper import get_stripper
class FileScanner:
@@ -45,6 +46,7 @@ class FileScanner:
directory_path: str | Path,
batch_size: int = DEFAULT_BATCH_SIZE,
follow_symlinks: bool = False,
detect_cleanup_keywords: bool = False,
) -> Generator[List[Dict], None, None]:
"""
Recursively scan a directory tree for .srt files.
@@ -106,8 +108,11 @@ class FileScanner:
# Plot detection
# --------------------------------------------
content = None
try:
plot_marker_count = cls._count_plot_markers(file_path)
if detect_cleanup_keywords:
content = file_path.read_text(encoding="utf-8", errors="ignore")
plot_marker_count = cls._count_plot_markers(file_path, content=content)
has_plot = plot_marker_count > 0
logger.debug(
"Plot check for %s: %s",
@@ -126,7 +131,7 @@ class FileScanner:
if has_plot:
try:
metadata = cls._extract_metadata(file_path)
metadata = cls._extract_metadata(file_path, content=content)
logger.debug(
"Extracted metadata from %s: %s",
file_path.name,
@@ -138,6 +143,13 @@ class FileScanner:
file_path.name, e
)
clean_keywords = []
if detect_cleanup_keywords and content:
try:
clean_keywords = get_stripper().detect_subtitle_watermarks(content)
except Exception as e:
logger.debug("Cleanup keyword detection failed: %s", e)
status = "Has Plot" if has_plot else "Not Loaded"
if plot_marker_count > 1:
status = "Duplicate Plot"
@@ -156,6 +168,7 @@ class FileScanner:
"imdb_rating": metadata.get("imdb_rating"),
"rating": metadata.get("imdb_rating"),
"runtime": metadata.get("runtime"),
"clean_keywords": clean_keywords,
"selected": False,
})
@@ -216,14 +229,15 @@ class FileScanner:
)
@classmethod
def _count_plot_markers(cls, file_path: Path) -> int:
def _count_plot_markers(cls, file_path: Path, content: str | None = None) -> int:
"""
Count Sublogue plot markers to detect duplicates.
"""
logger.debug("Scanning for plot markers in %s", file_path.name)
try:
content = file_path.read_text(encoding="utf-8", errors="ignore")
if content is None:
content = file_path.read_text(encoding="utf-8", errors="ignore")
lower_content = content.lower()
generated_count = lower_content.count("generated by sublogue")
if generated_count > 0:
@@ -237,14 +251,15 @@ class FileScanner:
return 0
@classmethod
def _extract_metadata(cls, file_path: Path) -> Dict:
def _extract_metadata(cls, file_path: Path, content: str | None = None) -> Dict:
"""
Extract title, year, rating, runtime, and plot
from Sublogue-generated subtitles.
"""
logger.debug("Extracting metadata from %s", file_path.name)
content = file_path.read_text(encoding="utf-8", errors="ignore")
if content is None:
content = file_path.read_text(encoding="utf-8", errors="ignore")
blocks = parse_srt(content)
metadata = {
+64
View File
@@ -199,6 +199,39 @@ class KeywordStripper:
r"sign\s+up\s+(now|today|free)",
]
# Force-remove entire subtitle blocks if these appear anywhere in a line.
# Partial matches are intentional (e.g. "OpenSubtitles.org").
SUBTITLE_FORCE_REMOVE = [
r"yts",
r"opensubtitles?",
]
_custom_force_remove_keywords: List[str] = []
# Labels used for reporting detected watermark keywords in clean-only scans
SUBTITLE_WATERMARK_LABELS = [
(r"yts\.mx|yts\.am|yts\.lt|yts\.ag|\byts\b", "YTS"),
(r"\byify\b", "YIFY"),
(r"\brarbg\b", "RARBG"),
(r"\beztv\b", "EZTV"),
(r"\bettv\b", "ETTV"),
(r"torrentgalaxy|\btgx\b", "TorrentGalaxy"),
(r"1337x", "1337x"),
(r"limetorrents?", "LimeTorrents"),
(r"\bevo\b", "EVO"),
(r"\bpsa\b", "PSA"),
(r"\bfgt\b", "FGT"),
(r"opensubtitles?", "OpenSubtitles"),
(r"sub\.?scene|subscene", "Subscene"),
(r"addic7ed", "Addic7ed"),
(r"podnapisi", "Podnapisi"),
(r"yifysubtitles?", "YIFY Subtitles"),
(r"legendas\.?tv", "LegendasTV"),
(r"shooter\.?cn", "ShooterCN"),
(r"subhd", "SubHD"),
(r"www\.[a-z0-9\-]+\.(com|org|net|io|tv|mx|am|lt|ag)|https?://", "URL"),
]
# Patterns that indicate an ENTIRE subtitle block should be removed
# (not just the matching text, but the whole block)
SUBTITLE_BLOCK_REMOVERS = [
@@ -234,6 +267,11 @@ class KeywordStripper:
def c(p):
return re.compile(p, re.IGNORECASE | re.VERBOSE)
custom_force_remove = [
re.escape(k) for k in cls._custom_force_remove_keywords if k
]
combined_force_remove = cls.SUBTITLE_FORCE_REMOVE + custom_force_remove
cls._compiled = {
"junk": c("|".join([
cls.QUALITY,
@@ -255,6 +293,9 @@ class KeywordStripper:
"subtitle_block_removers": [
re.compile(p, re.IGNORECASE | re.MULTILINE) for p in cls.SUBTITLE_BLOCK_REMOVERS
],
"subtitle_force_remove": [
re.compile(p, re.IGNORECASE) for p in combined_force_remove
],
}
return cls._compiled
@@ -348,6 +389,11 @@ class KeywordStripper:
if not line:
continue
# Hard kill-switch: if a line mentions these sources, drop the whole block.
for pattern in rx["subtitle_force_remove"]:
if pattern.search(line):
return True
# Check if this line matches any block remover pattern
is_ad_line = False
for pattern in rx["subtitle_block_removers"]:
@@ -469,6 +515,24 @@ class KeywordStripper:
return cleaned
def detect_subtitle_watermarks(self, text: str) -> List[str]:
"""Detect known subtitle watermark keywords in raw subtitle text."""
detected = []
for pattern, label in self.SUBTITLE_WATERMARK_LABELS:
if re.search(pattern, text, re.IGNORECASE):
detected.append(label)
for keyword in self._custom_force_remove_keywords:
if keyword and re.search(re.escape(keyword), text, re.IGNORECASE):
detected.append(keyword)
return detected
def set_force_remove_keywords(self, keywords: List[str]) -> None:
"""Set custom force-remove keywords and refresh regex cache."""
type(self)._custom_force_remove_keywords = [
k.strip() for k in (keywords or []) if k and k.strip()
]
type(self)._compiled = None
# -----------------------------
# SINGLETON HELPERS
+112
View File
@@ -1373,6 +1373,8 @@ class SubtitleProcessor:
# ─────────────────────────────────────────────────────────────
original = file_path.read_text(encoding="utf-8", errors="ignore")
subs = parse_srt(original)
stripper = get_stripper()
detected_keywords = stripper.detect_subtitle_watermarks(original)
if not subs:
return self._fail("No valid subtitle blocks found")
@@ -1540,6 +1542,116 @@ class SubtitleProcessor:
logger.error(f"Could not acquire lock for {file_path.name}: {e}")
return self._fail(f"File is being processed by another task: {e}")
def clean_file(
self,
file_path: str | Path,
clean_subtitle_content: bool = True,
) -> dict:
"""Clean ad/watermark content from a subtitle file without inserting plots."""
file_path = Path(file_path)
if not file_path.exists():
return self._fail("File not found")
if file_path.stat().st_size > self.MAX_SRT_BYTES:
return self._fail("Subtitle file too large")
try:
with file_lock(file_path, timeout=30.0):
original = file_path.read_text(encoding="utf-8", errors="ignore")
subs = parse_srt(original)
if not subs:
return self._fail("No valid subtitle blocks found")
original_blocks = subs
removed_count = 0
modified_count = 0
if clean_subtitle_content:
cleaned_blocks: List[SubtitleBlock] = []
for block in original_blocks:
text = block.text
if stripper.should_remove_subtitle_block(text):
removed_count += 1
continue
cleaned_text = stripper.clean_subtitle_text(text)
if not cleaned_text.strip():
removed_count += 1
continue
if cleaned_text != text:
modified_count += 1
cleaned_blocks.append(
SubtitleBlock(
block.index,
block.start_time,
block.end_time,
cleaned_text,
)
)
else:
cleaned_blocks = list(original_blocks)
sanitized = sanitize_all_blocks(cleaned_blocks)
if len(sanitized) < len(cleaned_blocks):
removed_count += len(cleaned_blocks) - len(sanitized)
if not sanitized:
return self._fail("No dialogue subtitles found after cleaning")
renumbered = [
SubtitleBlock(i + 1, b.start_time, b.end_time, b.text)
for i, b in enumerate(sanitized)
]
changed = len(renumbered) != len(original_blocks)
if not changed:
for updated, original_block in zip(renumbered, original_blocks):
if (
updated.start_time != original_block.start_time
or updated.end_time != original_block.end_time
or updated.text != original_block.text
):
changed = True
break
if not changed:
return {
"success": True,
"status": "Skipped",
"summary": "No changes needed",
"removed_blocks": 0,
"modified_blocks": 0,
"clean_keywords": detected_keywords,
}
tmp = file_path.with_suffix(".srt.tmp")
tmp.write_text(format_srt(renumbered), encoding="utf-8")
tmp.replace(file_path)
summary = (
f"Removed {removed_count} ad blocks, modified {modified_count} blocks"
if clean_subtitle_content
else "Cleaned subtitle content"
)
return {
"success": True,
"status": "Cleaned",
"summary": summary,
"removed_blocks": removed_count,
"modified_blocks": modified_count,
"clean_keywords": detected_keywords,
}
except FileLockError as e:
logger.error(f"Could not acquire lock for {file_path.name}: {e}")
return self._fail(f"File is being processed by another task: {e}")
# ========================================================
# Metadata fetching
# ========================================================
+3
View File
@@ -6,6 +6,9 @@ aiohttp>=3.9.0
# Database
sqlalchemy>=2.0.0
# Scheduler
apscheduler>=3.10.4
# Environment
python-dotenv>=1.0.0