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