1.0.6 - Multi language support (TMDb), Per folder rule settings

This commit is contained in:
ponzischeme89
2026-01-18 22:07:00 +13:00
parent 675c3ef959
commit 170694bc28
13 changed files with 892 additions and 45 deletions
+1
View File
@@ -14,6 +14,7 @@ Why? Because if the cast list and IMDb/RT rating show up in the first minute, my
- Insert plot summaries into existing .srt files without shifting timings
- Fetch metadata (plot, runtime, director, cast, IMDb/RT ratings) using OMDb, TMDb, and TVMaze — add these integrations under Settings before scanning
- Preserve original dialogue and timing with safe insertion logic
- 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
- Three themes included: OLED, Ocean, and Dracula White
+1 -1
View File
@@ -157,7 +157,7 @@
>
{#if !collapsed}
<Badge className="bg-white/10 text-text-secondary"
>v1.0.5 Release Candiate</Badge
>v1.0.6 Release Candiate</Badge
>
{:else}
<Badge className="bg-white/10 text-text-secondary">v</Badge>
+21 -5
View File
@@ -23,6 +23,7 @@
export let onSelectionChange = () => {};
export let disabled = false;
export let metadataProvider = "omdb";
export let metadataLanguage = "";
export let activeIntegrations = { omdb: true, tmdb: true, tvmaze: true };
export let loading = false;
@@ -198,7 +199,10 @@
searchingInline = { ...searchingInline };
try {
const response = await searchTitle(query);
const response = await searchTitle(query, "quick", {
preferredSource: metadataProvider,
language: metadataLanguage,
});
inlineSearchResults[file.path] = response.results || [];
inlineSearchResults = { ...inlineSearchResults };
} catch (err) {
@@ -261,7 +265,10 @@
try {
// Use "full" mode for manual search to get multiple results to choose from
const response = await searchTitle(cleanTitle, "full");
const response = await searchTitle(cleanTitle, "full", {
preferredSource: metadataProvider,
language: metadataLanguage,
});
searchResults = response.results || [];
} catch (err) {
searchError = "Failed to search for titles. Please try again.";
@@ -320,7 +327,10 @@
const cleanTitle = extractSearchableTitle(file.name, file.title);
try {
const response = await searchTitle(cleanTitle);
const response = await searchTitle(cleanTitle, "quick", {
preferredSource: metadataProvider,
language: metadataLanguage,
});
const results = response.results || [];
if (results.length > 0) {
@@ -520,7 +530,10 @@
);
try {
const response = await searchTitle(cleanTitle);
const response = await searchTitle(cleanTitle, "quick", {
preferredSource: metadataProvider,
language: metadataLanguage,
});
const results = response.results || [];
if (results.length > 0) {
@@ -620,7 +633,10 @@
);
try {
const response = await searchTitle(cleanTitle);
const response = await searchTitle(cleanTitle, "quick", {
preferredSource: metadataProvider,
language: metadataLanguage,
});
const results = response.results || [];
if (results.length > 0) {
+57 -1
View File
@@ -6,6 +6,7 @@
updateSettings,
processFiles,
clearAllSuggestedMatches,
getFolderRules,
} from "../lib/api.js";
import ResultsList from "./ResultsList.svelte";
import TypewriterQuote from "./TypewriterQuote.svelte";
@@ -63,6 +64,7 @@
let lastScan = null;
let selectedFilePaths = [];
let metadataProvider = "omdb";
let metadataLanguage = "";
let omdbEnabled = false;
let tmdbEnabled = false;
let tvmazeEnabled = false;
@@ -126,6 +128,9 @@
export let onOpenHistory = null;
export { selectedFilePaths, metadataProvider };
let onboardingComplete = false;
let hasScannedBefore = false;
let folderRules = [];
let activeFolderRule = null;
onMount(async () => {
const initialTimer = setTimeout(() => {
@@ -152,6 +157,14 @@
if (typeof localStorage !== "undefined") {
onboardingComplete =
localStorage.getItem("sublogue_onboarding_complete") === "true";
hasScannedBefore =
localStorage.getItem("sublogue_has_scanned") === "true";
}
try {
const rulesResponse = await getFolderRules();
folderRules = rulesResponse.rules || [];
} catch (err) {
console.error("Failed to load folder rules:", err);
}
} catch (err) {
console.error("Failed to load initial data:", err);
@@ -234,6 +247,10 @@
// Save to store
scanResults.setScanResults(files, directory);
if (typeof localStorage !== "undefined") {
localStorage.setItem("sublogue_has_scanned", "true");
hasScannedBefore = true;
}
// Show save prompt if scanning a new directory
if (isDifferentDirectory) {
@@ -484,6 +501,31 @@
metadataProvider = event.detail.source;
}
function normalizePath(path) {
return (path || "")
.replace(/\//g, "\\")
.replace(/\\+$/, "")
.toLowerCase();
}
function findFolderRuleForDirectory(path, rules) {
const target = normalizePath(path);
if (!target) return null;
let bestRule = null;
let bestLength = -1;
for (const rule of rules) {
const dir = normalizePath(rule.directory);
if (!dir) continue;
if (target === dir || target.startsWith(dir + "\\")) {
if (dir.length > bestLength) {
bestLength = dir.length;
bestRule = rule;
}
}
}
return bestRule;
}
function formatMetadataLabel(source) {
if (source === "both") return "OMDb + TMDb";
if (source === "tvmaze") return "TVmaze";
@@ -521,6 +563,19 @@
$: failureCount = processingResults?.filter((r) => !r.success).length || 0;
$: metadataSelected = !!metadataProvider;
$: resolveMetadataProvider(activeMetadataOptions);
$: if (folderRules.length && directory) {
const matchedRule = findFolderRuleForDirectory(directory, folderRules);
activeFolderRule = matchedRule;
if (matchedRule) {
if (matchedRule.preferred_source) {
metadataProvider = matchedRule.preferred_source;
}
metadataLanguage = matchedRule.language || "";
} else {
activeFolderRule = null;
metadataLanguage = "";
}
}
$: allStepsComplete = apiConfigured && metadataSelected && hasScanned;
$: if (allStepsComplete && !onboardingComplete) {
onboardingComplete = true;
@@ -528,7 +583,7 @@
localStorage.setItem("sublogue_onboarding_complete", "true");
}
}
$: showTutorial = !onboardingComplete && !hasScanned;
$: showTutorial = !onboardingComplete && !hasScanned && !hasScannedBefore;
</script>
<div class="space-y-8">
@@ -746,6 +801,7 @@
onSelectionChange={handleSelectionChange}
disabled={!apiConfigured || processing}
{metadataProvider}
{metadataLanguage}
activeIntegrations={{
omdb: omdbEnabled,
tmdb: tmdbEnabled,
+7 -1
View File
@@ -6,9 +6,10 @@
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, Plug, Settings, Wand2 } from 'lucide-svelte'
import { Bolt, Calendar, Folder, Plug, Settings, Wand2 } from 'lucide-svelte'
let currentSection = 'general'
let settings = {}
@@ -19,6 +20,7 @@
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' },
@@ -135,6 +137,8 @@
<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>
@@ -159,6 +163,8 @@
<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'}
@@ -22,6 +22,7 @@
className={className}
dropup={true}
searchable={false}
displayPrefix="Theme: "
on:change={handleThemeChange}
>
<svelte:fragment slot="icon">
@@ -0,0 +1,283 @@
<script>
import { onMount } from 'svelte'
import { getFolderRules, saveFolderRule, deleteFolderRule } from '../../lib/api.js'
import { addToast } from '../../lib/toastStore.js'
export let settings = {}
let rules = []
let loading = false
let error = null
let editingDirectory = null
let directory = ''
let preferredSource = settings.preferred_source || 'omdb'
let insertionPosition = settings.insertion_position || 'start'
let language = ''
let titleBold = settings.subtitle_title_bold !== false
let plotItalic = settings.subtitle_plot_italic !== false
let showDirector = settings.subtitle_show_director === true
let showActors = settings.subtitle_show_actors === true
let showReleased = settings.subtitle_show_released === true
let showGenre = settings.subtitle_show_genre === true
const sourceOptions = [
{ value: 'omdb', label: 'OMDb' },
{ value: 'tmdb', label: 'TMDb' },
{ value: 'tvmaze', label: 'TVmaze' },
{ value: 'both', label: 'OMDb + TMDb' }
]
function resetForm() {
editingDirectory = null
directory = ''
preferredSource = settings.preferred_source || 'omdb'
insertionPosition = settings.insertion_position || 'start'
language = ''
titleBold = settings.subtitle_title_bold !== false
plotItalic = settings.subtitle_plot_italic !== false
showDirector = settings.subtitle_show_director === true
showActors = settings.subtitle_show_actors === true
showReleased = settings.subtitle_show_released === true
showGenre = settings.subtitle_show_genre === true
}
async function loadRules() {
loading = true
error = null
try {
const result = await getFolderRules()
rules = result.rules || []
} catch (err) {
error = `Failed to load folder rules: ${err.message}`
} finally {
loading = false
}
}
onMount(loadRules)
function editRule(rule) {
editingDirectory = rule.directory
directory = rule.directory
preferredSource = rule.preferred_source || settings.preferred_source || 'omdb'
insertionPosition = rule.insertion_position || settings.insertion_position || 'start'
language = rule.language || ''
titleBold = rule.subtitle_title_bold ?? (settings.subtitle_title_bold !== false)
plotItalic = rule.subtitle_plot_italic ?? (settings.subtitle_plot_italic !== false)
showDirector = rule.subtitle_show_director ?? (settings.subtitle_show_director === true)
showActors = rule.subtitle_show_actors ?? (settings.subtitle_show_actors === true)
showReleased = rule.subtitle_show_released ?? (settings.subtitle_show_released === true)
showGenre = rule.subtitle_show_genre ?? (settings.subtitle_show_genre === true)
}
async function saveRule() {
if (!directory.trim()) {
error = 'Directory is required'
return
}
try {
await saveFolderRule({
directory: directory.trim(),
preferred_source: preferredSource,
insertion_position: insertionPosition,
language: language.trim() || null,
subtitle_title_bold: titleBold,
subtitle_plot_italic: plotItalic,
subtitle_show_director: showDirector,
subtitle_show_actors: showActors,
subtitle_show_released: showReleased,
subtitle_show_genre: showGenre
})
addToast({ message: 'Folder rule saved.', tone: 'success' })
await loadRules()
resetForm()
} catch (err) {
error = `Failed to save folder rule: ${err.message}`
addToast({ message: error, tone: 'error' })
}
}
async function removeRule(rule) {
try {
await deleteFolderRule(rule.directory)
addToast({ message: 'Folder rule removed.', tone: 'success' })
await loadRules()
if (editingDirectory === rule.directory) {
resetForm()
}
} catch (err) {
error = `Failed to delete folder rule: ${err.message}`
addToast({ message: error, tone: 'error' })
}
}
</script>
<div class="space-y-6">
<div>
<h2 class="text-lg font-semibold text-text-primary">Per-folder rules</h2>
<p class="text-[13px] text-text-secondary">
Override metadata source and subtitle formatting per directory.
</p>
</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}
<div class="rounded-xl border border-border bg-bg-secondary/40 p-5 space-y-4">
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<label class="block text-[12px] text-text-tertiary uppercase tracking-wide">Directory</label>
<input
type="text"
bind:value={directory}
placeholder="C:\\Movies"
class="w-full px-4 py-3 bg-bg-card border border-border rounded-lg text-[13px] font-mono focus:outline-none focus:border-white/25 focus:ring-2 focus:ring-ring transition-all"
/>
</div>
<div class="space-y-2">
<label class="block text-[12px] text-text-tertiary uppercase tracking-wide">Metadata source</label>
<select
bind:value={preferredSource}
class="w-full px-4 py-3 bg-bg-card border border-border rounded-lg text-[13px] focus:outline-none focus:border-white/25 focus:ring-2 focus:ring-ring transition-all"
>
{#each sourceOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<label class="block text-[12px] text-text-tertiary uppercase tracking-wide">Insertion position</label>
<select
bind:value={insertionPosition}
class="w-full px-4 py-3 bg-bg-card border border-border rounded-lg text-[13px] focus:outline-none focus:border-white/25 focus:ring-2 focus:ring-ring transition-all"
>
<option value="start">Start</option>
<option value="end">End</option>
<option value="index">Index 1</option>
</select>
</div>
<div class="space-y-2">
<label class="block text-[12px] text-text-tertiary uppercase tracking-wide">
Language (TMDb)
</label>
<input
type="text"
bind:value={language}
placeholder="it-IT"
class="w-full px-4 py-3 bg-bg-card border border-border rounded-lg text-[13px] font-mono focus:outline-none focus:border-white/25 focus:ring-2 focus:ring-ring transition-all"
/>
<p class="text-[11px] text-text-tertiary">
Used for TMDb localized plots (e.g. it-IT, fr-FR). OMDb is always English.
</p>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<label class="flex items-center justify-between gap-3 rounded-xl border border-border bg-bg-card px-4 py-3">
<div class="text-[13px] font-medium">Bold Title</div>
<input type="checkbox" bind:checked={titleBold} class="h-4 w-4" />
</label>
<label class="flex items-center justify-between gap-3 rounded-xl border border-border bg-bg-card px-4 py-3">
<div class="text-[13px] font-medium">Italic Plot</div>
<input type="checkbox" bind:checked={plotItalic} class="h-4 w-4" />
</label>
<label class="flex items-center justify-between gap-3 rounded-xl border border-border bg-bg-card px-4 py-3">
<div class="text-[13px] font-medium">Director</div>
<input type="checkbox" bind:checked={showDirector} class="h-4 w-4" />
</label>
<label class="flex items-center justify-between gap-3 rounded-xl border border-border bg-bg-card px-4 py-3">
<div class="text-[13px] font-medium">Cast</div>
<input type="checkbox" bind:checked={showActors} class="h-4 w-4" />
</label>
<label class="flex items-center justify-between gap-3 rounded-xl border border-border bg-bg-card px-4 py-3">
<div class="text-[13px] font-medium">Release Date</div>
<input type="checkbox" bind:checked={showReleased} class="h-4 w-4" />
</label>
<label class="flex items-center justify-between gap-3 rounded-xl border border-border bg-bg-card px-4 py-3">
<div class="text-[13px] font-medium">Genre</div>
<input type="checkbox" bind:checked={showGenre} class="h-4 w-4" />
</label>
</div>
<div class="flex flex-wrap gap-3">
<button
type="button"
on:click={saveRule}
class="px-5 py-2.5 bg-white text-black text-[13px] font-medium rounded-lg hover:bg-white/90 transition-all"
>
{editingDirectory ? 'Update rule' : 'Add rule'}
</button>
{#if editingDirectory}
<button
type="button"
on:click={resetForm}
class="px-5 py-2.5 text-text-secondary text-[13px] rounded-lg border border-border hover:text-white hover:bg-bg-hover transition-all"
>
Cancel
</button>
{/if}
</div>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-[13px] font-semibold text-text-primary">Active rules</h3>
<button
type="button"
on:click={loadRules}
class="text-[12px] text-text-tertiary hover:text-white transition-colors"
>
Refresh
</button>
</div>
{#if loading}
<div class="text-[12px] text-text-tertiary">Loading rules...</div>
{:else if rules.length === 0}
<div class="rounded-xl border border-border bg-bg-secondary/30 px-4 py-4 text-[12px] text-text-tertiary">
No folder rules yet.
</div>
{:else}
<div class="space-y-3">
{#each rules as rule}
<div class="rounded-xl border border-border bg-bg-secondary/30 px-4 py-3 flex flex-col gap-2">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-[13px] font-medium text-text-primary">{rule.directory}</div>
<div class="text-[11px] text-text-tertiary">
{rule.preferred_source || settings.preferred_source || 'omdb'} ·
{rule.insertion_position || settings.insertion_position || 'start'} ·
{rule.language || 'default language'}
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
on:click={() => editRule(rule)}
class="text-[12px] text-text-secondary hover:text-white transition-colors"
>
Edit
</button>
<button
type="button"
on:click={() => removeRule(rule)}
class="text-[12px] text-red-300 hover:text-red-200 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
+41 -2
View File
@@ -74,6 +74,38 @@ export async function updateSettings(settings) {
})
}
// ============ FOLDER RULES API ============
/**
* GET /api/folder-rules - Fetch folder rules
* Returns: { success, rules: [...] }
*/
export async function getFolderRules() {
return apiFetch('/folder-rules')
}
/**
* POST /api/folder-rules - Create or update a folder rule
* Body: { directory, preferred_source?, insertion_position?, language?, subtitle_*? }
* Returns: { success }
*/
export async function saveFolderRule(rule) {
return apiFetch('/folder-rules', {
method: 'POST',
body: JSON.stringify(rule)
})
}
/**
* DELETE /api/folder-rules/<directory> - Delete a folder rule
* Returns: { success }
*/
export async function deleteFolderRule(directory) {
return apiFetch(`/folder-rules/${encodeURIComponent(directory)}`, {
method: 'DELETE'
})
}
// ============ SCAN API ============
/**
@@ -218,10 +250,17 @@ export async function getScanStatus() {
* - "full": Returns multiple results to choose from (2 API calls) - good for manual search
* Returns: { success, results: [{title, year, plot, runtime, imdb_rating, media_type, poster, imdb_id}] }
*/
export async function searchTitle(query, mode = "quick") {
export async function searchTitle(query, mode = "quick", options = {}) {
const body = { query, mode }
if (options.preferredSource) {
body.preferred_source = options.preferredSource
}
if (options.language) {
body.language = options.language
}
return apiFetch('/search', {
method: 'POST',
body: JSON.stringify({ query, mode })
body: JSON.stringify(body)
})
}
@@ -13,6 +13,7 @@
export let className = ''
export let searchable = true
export let dropup = false
export let displayPrefix = ''
let open = false
let search = ''
@@ -87,7 +88,7 @@
<span class="flex items-center gap-2">
<slot name="icon" />
<span class={selectedLabel ? 'text-foreground' : 'text-muted-foreground'}>
{selectedLabel || placeholder}
{selectedLabel ? `${displayPrefix}${selectedLabel}` : placeholder}
</span>
</span>
<svg
+208 -4
View File
@@ -208,6 +208,43 @@ def _get_str_setting(key: str, default: str) -> str:
return default
return str(value)
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:
return None
normalized_file = os.path.normcase(os.path.abspath(file_path))
best_rule = None
best_len = -1
for rule in rules:
directory = rule.get("directory")
if not directory:
continue
normalized_dir = os.path.normcase(os.path.abspath(directory))
normalized_dir = normalized_dir.rstrip(os.sep)
prefix = normalized_dir + os.sep
if normalized_file == normalized_dir or normalized_file.startswith(prefix):
if len(normalized_dir) > best_len:
best_len = len(normalized_dir)
best_rule = rule
return best_rule
def _merge_format_options(base_options: SubtitleFormatOptions, rule: dict | None) -> SubtitleFormatOptions:
"""Merge folder rule overrides into format options."""
if not rule:
return base_options
def _override_bool(key: str, current: bool) -> bool:
value = rule.get(key)
return current if value is None else bool(value)
return SubtitleFormatOptions(
title_bold=_override_bool("subtitle_title_bold", base_options.title_bold),
plot_italic=_override_bool("subtitle_plot_italic", base_options.plot_italic),
show_director=_override_bool("subtitle_show_director", base_options.show_director),
show_actors=_override_bool("subtitle_show_actors", base_options.show_actors),
show_released=_override_bool("subtitle_show_released", base_options.show_released),
show_genre=_override_bool("subtitle_show_genre", base_options.show_genre),
)
def get_format_options_from_settings() -> SubtitleFormatOptions:
"""Load subtitle formatting options from database settings."""
@@ -670,6 +707,63 @@ def save_suggested_matches():
}), 500
@app.route('/api/folder-rules', methods=['GET'])
def get_folder_rules():
"""Get all folder rules"""
try:
rules = db.get_all_folder_rules()
return jsonify({
"success": True,
"rules": rules
})
except Exception as e:
logger.error(f"Error fetching folder rules: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
@app.route('/api/folder-rules', methods=['POST'])
def save_folder_rule():
"""Create or update a folder rule"""
try:
data = request.json or {}
directory = data.get("directory", "").strip()
if not directory:
return jsonify({
"success": False,
"error": "Directory is required"
}), 400
success = db.upsert_folder_rule(directory, data)
return jsonify({
"success": success
})
except Exception as e:
logger.error(f"Error saving folder rule: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
@app.route('/api/folder-rules/<path:directory>', methods=['DELETE'])
def delete_folder_rule(directory):
"""Delete a folder rule for a directory"""
try:
success = db.delete_folder_rule(directory)
return jsonify({
"success": success
})
except Exception as e:
logger.error(f"Error deleting folder rule: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
@app.route('/api/suggested-matches/<path:file_path>', methods=['DELETE'])
def delete_suggested_match(file_path):
"""Delete a suggested match for a file"""
@@ -797,7 +891,89 @@ def search_title():
"error": "API not configured"
}), 400
preferred_source = data.get("preferred_source") or _get_str_setting("preferred_source", "omdb")
language = data.get("language")
results = []
if preferred_source == "tmdb" and tmdb_client:
try:
import aiohttp
import asyncio
async def tmdb_search(title, mode, language=None):
"""TMDb search with optional language support (1 API call)"""
start_time = time.time()
url = f"{tmdb_client.base_url}/search/multi"
params = {
"api_key": tmdb_client.api_key,
"query": title
}
if language:
params["language"] = language
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params) as resp:
response_time_ms = int((time.time() - start_time) * 1000)
if resp.status != 200:
db.track_api_call(
provider="tmdb",
endpoint="/search/multi",
success=False,
response_time_ms=response_time_ms,
call_count=1
)
return []
data = await resp.json()
items = [
item for item in data.get("results", [])
if item.get("media_type") in ("movie", "tv")
]
if mode == "quick":
items = items[:1]
else:
items = items[:5]
results = []
for item in items:
title_value = item.get("title") or item.get("name")
date_value = item.get("release_date") or item.get("first_air_date") or ""
year = date_value[:4] if date_value else "N/A"
poster_path = item.get("poster_path")
poster = f"https://image.tmdb.org/t/p/w185{poster_path}" if poster_path else None
vote_average = item.get("vote_average")
imdb_rating = f"{vote_average:.1f}/10" if isinstance(vote_average, (int, float)) else "N/A"
results.append({
"title": title_value,
"year": year,
"plot": item.get("overview") or "No plot available",
"runtime": None,
"imdb_rating": imdb_rating,
"media_type": item.get("media_type"),
"poster": poster,
"imdb_id": None
})
db.track_api_call(
provider="tmdb",
endpoint="/search/multi",
success=True,
response_time_ms=response_time_ms,
call_count=1
)
return results
results = asyncio.run(tmdb_search(query, mode, language))
return jsonify({
"success": True,
"results": results
})
except Exception as e:
logger.error(f"Error searching TMDb: {e}")
if omdb_client:
try:
import aiohttp
@@ -986,7 +1162,7 @@ def process_files():
"error": "Metadata provider not configured"
}), 400
# Load format options from settings
# Load default format options from settings
format_options = get_format_options_from_settings()
# Load strip_keywords setting (default True for better matching)
@@ -995,6 +1171,12 @@ def process_files():
# Load clean_subtitle_content setting (default True for ad removal)
clean_subtitle_content = _get_bool_setting("clean_subtitle_content", True)
# Load default insertion position and preferred source
default_insertion_position = _get_str_setting("insertion_position", "start")
default_preferred_source = _get_str_setting("preferred_source", "omdb")
folder_rules = db.get_all_folder_rules()
# Create a processing run in database
run_id = db.create_run(total_files=len(file_paths))
@@ -1006,14 +1188,23 @@ def process_files():
for file_path in file_paths:
try:
# Process file asynchronously with optional title override
rule = _get_folder_rule_for_path(file_path, folder_rules)
effective_format = _merge_format_options(format_options, rule)
insertion_position = rule.get("insertion_position") if rule else None
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=format_options,
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
@@ -1124,7 +1315,7 @@ def process_batch():
successful_count = 0
failed_count = 0
# Load format options from settings
# Load default format options from settings
format_options = get_format_options_from_settings()
# Load strip_keywords setting (default True for better matching)
@@ -1133,6 +1324,10 @@ def process_batch():
# Load clean_subtitle_content setting (default True for ad removal)
clean_subtitle_content = _get_bool_setting("clean_subtitle_content", True)
default_insertion_position = _get_str_setting("insertion_position", "start")
default_preferred_source = _get_str_setting("preferred_source", "omdb")
folder_rules = db.get_all_folder_rules()
# Create a processing run
run_id = db.create_run(total_files=total)
@@ -1148,14 +1343,23 @@ def process_batch():
try:
# Process file with title override (no API call needed - data is pre-fetched)
rule = _get_folder_rule_for_path(file_path, folder_rules)
effective_format = _merge_format_options(format_options, rule)
insertion_position = rule.get("insertion_position") if rule else None
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=True, # Always reprocess when applying matches
title_override=title_override,
format_options=format_options,
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 result["success"]:
+114
View File
@@ -139,6 +139,28 @@ class SuggestedMatch(Base):
return f"<SuggestedMatch(id={self.id}, file_name='{self.file_name}', matched_title='{self.matched_title}')>"
class FolderRule(Base):
"""Folder-specific rules that override default settings"""
__tablename__ = 'folder_rules'
id = Column(Integer, primary_key=True)
directory = Column(String(500), nullable=False, unique=True, index=True)
preferred_source = Column(String(50))
insertion_position = Column(String(50))
language = Column(String(20))
subtitle_title_bold = Column(Boolean)
subtitle_plot_italic = Column(Boolean)
subtitle_show_director = Column(Boolean)
subtitle_show_actors = Column(Boolean)
subtitle_show_released = Column(Boolean)
subtitle_show_genre = Column(Boolean)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<FolderRule(id={self.id}, directory='{self.directory}')>"
class DatabaseManager:
"""Manages database connections and operations"""
@@ -759,6 +781,98 @@ class DatabaseManager:
finally:
session.close()
# ============ FOLDER RULES OPERATIONS ============
def get_folder_rule(self, directory):
"""Get a folder rule for a specific directory"""
session = self.get_session()
try:
rule = session.query(FolderRule).filter_by(directory=directory).first()
if not rule:
return None
return {
"directory": rule.directory,
"preferred_source": rule.preferred_source,
"insertion_position": rule.insertion_position,
"language": rule.language,
"subtitle_title_bold": rule.subtitle_title_bold,
"subtitle_plot_italic": rule.subtitle_plot_italic,
"subtitle_show_director": rule.subtitle_show_director,
"subtitle_show_actors": rule.subtitle_show_actors,
"subtitle_show_released": rule.subtitle_show_released,
"subtitle_show_genre": rule.subtitle_show_genre,
}
finally:
session.close()
def get_all_folder_rules(self):
"""Get all folder rules"""
session = self.get_session()
try:
rules = session.query(FolderRule).order_by(FolderRule.directory.asc()).all()
return [
{
"directory": rule.directory,
"preferred_source": rule.preferred_source,
"insertion_position": rule.insertion_position,
"language": rule.language,
"subtitle_title_bold": rule.subtitle_title_bold,
"subtitle_plot_italic": rule.subtitle_plot_italic,
"subtitle_show_director": rule.subtitle_show_director,
"subtitle_show_actors": rule.subtitle_show_actors,
"subtitle_show_released": rule.subtitle_show_released,
"subtitle_show_genre": rule.subtitle_show_genre,
}
for rule in rules
]
finally:
session.close()
def upsert_folder_rule(self, directory, rule_data):
"""Create or update a folder rule"""
session = self.get_session()
try:
rule = session.query(FolderRule).filter_by(directory=directory).first()
if not rule:
rule = FolderRule(directory=directory)
session.add(rule)
rule.preferred_source = rule_data.get("preferred_source")
rule.insertion_position = rule_data.get("insertion_position")
rule.language = rule_data.get("language")
rule.subtitle_title_bold = rule_data.get("subtitle_title_bold")
rule.subtitle_plot_italic = rule_data.get("subtitle_plot_italic")
rule.subtitle_show_director = rule_data.get("subtitle_show_director")
rule.subtitle_show_actors = rule_data.get("subtitle_show_actors")
rule.subtitle_show_released = rule_data.get("subtitle_show_released")
rule.subtitle_show_genre = rule_data.get("subtitle_show_genre")
session.commit()
return True
except Exception as e:
session.rollback()
logger.error(f"Error saving folder rule: {e}")
return False
finally:
session.close()
def delete_folder_rule(self, directory):
"""Delete a folder rule for a directory"""
session = self.get_session()
try:
rule = session.query(FolderRule).filter_by(directory=directory).first()
if rule:
session.delete(rule)
session.commit()
return True
return False
except Exception as e:
session.rollback()
logger.error(f"Error deleting folder rule: {e}")
return False
finally:
session.close()
# ============ MAINTENANCE OPERATIONS ============
def clear_settings(self, keep_api_keys=False):
+135 -19
View File
@@ -1106,11 +1106,12 @@ def build_intro_blocks(
elif available_time_ms >= int(MIN_DURATION_SECONDS * 1000):
block_end_ms = safe_end_time(first_subtitle_start_ms - min_safe_gap_ms)
# If we can fit at least a brief title, show it
# If we can fit a brief header, include title + ratings/runtime
brief_text = (
f"{SUBLOGUE_SENTINEL}\n"
f"{title} ({year})\n"
f"— Generated by Sublogue"
f"{title_line}\n"
f"{info_line}\n"
f"- Generated by Sublogue"
)
blocks.append(SubtitleBlock(1, 0, block_end_ms, brief_text))
@@ -1123,6 +1124,102 @@ def build_intro_blocks(
return blocks
def build_outro_blocks(
movie: dict,
plot: str,
last_subtitle_end_ms: int,
min_safe_gap_ms: int = 500,
format_options: SubtitleFormatOptions = None,
) -> List[SubtitleBlock]:
"""
Build outro blocks that appear AFTER the last real subtitle.
This avoids any overlap by placing new blocks after the final subtitle end time.
"""
if format_options is None:
format_options = DEFAULT_FORMAT_OPTIONS
title = movie.get("title", "Unknown Title")
year = movie.get("year", "")
imdb_rating = movie.get("imdb_rating") or movie.get("imdbRating") or "N/A"
if not imdb_rating or imdb_rating in ("", "N/A", None):
imdb_rating = "N/A"
rt_rating = movie.get("rotten_tomatoes") or movie.get("rottenTomatoes") or "N/A"
if not rt_rating or rt_rating in ("", "N/A", None):
rt_rating = "N/A"
runtime_raw = movie.get("runtime") or movie.get("Runtime") or "N/A"
if runtime_raw and runtime_raw != "N/A":
runtime_match = re.search(r'(\d+)', str(runtime_raw))
runtime = f"{runtime_match.group(1)} min" if runtime_match else runtime_raw
else:
runtime = "N/A"
director = movie.get("director") or movie.get("Director") or "N/A"
actors = movie.get("actors") or movie.get("Actors") or "N/A"
released = movie.get("released") or movie.get("Released") or "N/A"
genre = movie.get("genre") or movie.get("Genre") or "N/A"
title_display = f"<b>{title}</b>" if format_options.title_bold else title
title_line = f"{title_display} ({year})"
info_line = f"? IMDb: {imdb_rating} ?? RT: {rt_rating} ? {runtime}"
extra_lines = []
if format_options.show_director and director != "N/A":
extra_lines.append(f"?? Director: {director}")
if format_options.show_actors and actors != "N/A":
actor_list = actors.split(", ")
if len(actor_list) > 3:
actors_display = ", ".join(actor_list[:3]) + "..."
else:
actors_display = actors
extra_lines.append(f"?? Cast: {actors_display}")
if format_options.show_released and released != "N/A":
extra_lines.append(f"?? Released: {released}")
if format_options.show_genre and genre != "N/A":
extra_lines.append(f"?? Genre: {genre}")
header_parts = [
SUBLOGUE_SENTINEL,
title_line,
info_line,
]
if extra_lines:
header_parts.extend(extra_lines)
header_parts.append("")
header_parts.append("- Generated by Sublogue")
header_text = "\n".join(header_parts)
plot_chunks = _split_plot_into_display_chunks(plot)
def format_plot_chunk(chunk_text: str, is_first_chunk: bool) -> str:
wrapped = wrap_for_tv(chunk_text)
if format_options.plot_italic:
wrapped = f"<i>{wrapped}</i>"
prefix = "Plot: " if is_first_chunk else ""
return f"{SUBLOGUE_SENTINEL}\n{prefix}{wrapped}"
blocks = []
current_ms = last_subtitle_end_ms + min_safe_gap_ms
header_duration_ms = calculate_reading_duration_ms(header_text)
header_end_ms = current_ms + header_duration_ms
blocks.append(SubtitleBlock(1, current_ms, header_end_ms, header_text))
current_ms = header_end_ms
for i, chunk in enumerate(plot_chunks):
chunk_text = format_plot_chunk(chunk, is_first_chunk=(i == 0))
chunk_duration_ms = calculate_reading_duration_ms(chunk)
chunk_end_ms = current_ms + chunk_duration_ms
blocks.append(SubtitleBlock(len(blocks) + 1, current_ms, chunk_end_ms, chunk_text))
current_ms = chunk_end_ms
return blocks
# ============================================================
# Processor
# ============================================================
@@ -1169,6 +1266,9 @@ class SubtitleProcessor:
format_options: SubtitleFormatOptions = None,
strip_keywords: bool = True,
clean_subtitle_content: bool = True,
insertion_position: str = "start",
preferred_source: str | None = None,
language: str | None = None,
) -> dict:
"""
Process a subtitle file to add plot information.
@@ -1238,6 +1338,8 @@ class SubtitleProcessor:
is_series=is_series,
season=season,
episode=episode,
preferred_source=preferred_source,
language=language,
)
if not movie:
return self._fail("No metadata found")
@@ -1343,13 +1445,22 @@ class SubtitleProcessor:
# These will NEVER overlap with or shift existing subtitles
# Returns EMPTY list if insufficient gap
# ─────────────────────────────────────────────────────────────
intro_blocks = build_intro_blocks(
movie,
plot,
first_subtitle_start_ms=first_subtitle_start_ms,
min_safe_gap_ms=500, # 500ms safety buffer before first subtitle
format_options=format_options,
)
if insertion_position == "end":
intro_blocks = build_outro_blocks(
movie,
plot,
last_subtitle_end_ms=last_original_timing,
min_safe_gap_ms=500, # 500ms safety buffer after last subtitle
format_options=format_options,
)
else:
intro_blocks = build_intro_blocks(
movie,
plot,
first_subtitle_start_ms=first_subtitle_start_ms,
min_safe_gap_ms=500, # 500ms safety buffer before first subtitle
format_options=format_options,
)
# ─────────────────────────────────────────────────────────────
# PHASE 5: Combine intro + original subtitles
@@ -1357,7 +1468,7 @@ class SubtitleProcessor:
# NOTE: We're ONLY renumbering indices (1, 2, 3...), NOT timestamps!
# The start_time and end_time of clean_subs are PRESERVED EXACTLY.
# ─────────────────────────────────────────────────────────────
final = intro_blocks + clean_subs
final = clean_subs + intro_blocks if insertion_position == "end" else intro_blocks + clean_subs
# Renumber all blocks sequentially (index only, timing unchanged)
renumbered = [
@@ -1368,7 +1479,7 @@ class SubtitleProcessor:
# Verify timing preservation (sanity check)
num_intro = len(intro_blocks)
if len(renumbered) > num_intro:
preserved_first = renumbered[num_intro]
preserved_first = renumbered[0] if insertion_position == "end" else renumbered[num_intro]
if preserved_first.start_time != first_subtitle_start_ms:
logger.error(
f"TIMING CORRUPTION DETECTED! Original first subtitle was at "
@@ -1431,6 +1542,8 @@ class SubtitleProcessor:
is_series: bool = False,
season: Optional[int] = None,
episode: Optional[int] = None,
preferred_source: str | None = None,
language: str | None = None,
) -> Optional[dict]:
"""
Fetch metadata from configured sources with fallback.
@@ -1442,14 +1555,15 @@ class SubtitleProcessor:
Year validation ensures we don't match wrong movies (e.g., "Eternity 2025"
shouldn't match "From Here to Eternity 1953").
"""
logger.info("Fetching metadata for '%s' (year=%s)", movie_name, year)
source_preference = preferred_source or self.preferred_source
logger.info("Fetching metadata for '%s' (year=%s, source=%s)", movie_name, year, source_preference)
result = None
omdb_type = "series" if is_series else "movie"
tmdb_type = "tv" if is_series else "movie"
# Try preferred source first
if self.preferred_source == "tvmaze" and self.tvmaze_client and is_series:
if source_preference == "tvmaze" and self.tvmaze_client and is_series:
result = await self.tvmaze_client.fetch_summary(
movie_name,
year=year,
@@ -1459,18 +1573,19 @@ class SubtitleProcessor:
if result:
logger.info("Found metadata via TVmaze: %s (%s)", result.get("title"), result.get("year"))
return result
elif self.preferred_source == "tmdb" and self.tmdb_client:
elif source_preference == "tmdb" and self.tmdb_client:
result = await self.tmdb_client.fetch_summary(
movie_name,
media_type=tmdb_type,
year=year,
season=season,
episode=episode,
language=language,
)
if result:
logger.info("Found metadata via TMDb: %s (%s)", result.get("title"), result.get("year"))
return result
elif self.preferred_source == "omdb" and self.omdb_client:
elif source_preference == "omdb" and self.omdb_client:
result = await self.omdb_client.fetch_summary(
movie_name,
media_type=omdb_type,
@@ -1483,7 +1598,7 @@ class SubtitleProcessor:
return result
# Fallback to other source
if not result and self.omdb_client and self.preferred_source != "omdb":
if not result and self.omdb_client and source_preference != "omdb":
result = await self.omdb_client.fetch_summary(
movie_name,
media_type=omdb_type,
@@ -1495,19 +1610,20 @@ class SubtitleProcessor:
logger.info("Found metadata via OMDb (fallback): %s (%s)", result.get("title"), result.get("year"))
return result
if not result and self.tmdb_client and self.preferred_source != "tmdb":
if not result and self.tmdb_client and source_preference != "tmdb":
result = await self.tmdb_client.fetch_summary(
movie_name,
media_type=tmdb_type,
year=year,
season=season,
episode=episode,
language=language,
)
if result:
logger.info("Found metadata via TMDb (fallback): %s (%s)", result.get("title"), result.get("year"))
return result
if not result and self.tvmaze_client and self.preferred_source != "tvmaze" and is_series:
if not result and self.tvmaze_client and source_preference != "tvmaze" and is_series:
result = await self.tvmaze_client.fetch_summary(
movie_name,
year=year,
+21 -11
View File
@@ -18,7 +18,7 @@ class TMDbClient:
self.semaphore = asyncio.Semaphore(5) # Limit concurrent requests
self.db_manager = db_manager
async def search_movie(self, title, year=None):
async def search_movie(self, title, year=None, language=None):
"""
Search for a movie by title
@@ -37,6 +37,8 @@ class TMDbClient:
}
if year:
params["year"] = year
if language:
params["language"] = language
try:
start_time = time.time()
@@ -84,7 +86,7 @@ class TMDbClient:
logging.error(f"Error searching TMDb for movie '{title}': {e}")
return None
async def search_tv(self, title, year=None):
async def search_tv(self, title, year=None, language=None):
"""
Search for a TV series by title
@@ -103,6 +105,8 @@ class TMDbClient:
}
if year:
params["first_air_date_year"] = year
if language:
params["language"] = language
try:
start_time = time.time()
@@ -150,7 +154,7 @@ class TMDbClient:
logging.error(f"Error searching TMDb for TV '{title}': {e}")
return None
async def get_movie_details(self, movie_id):
async def get_movie_details(self, movie_id, language=None):
"""
Get detailed movie information
@@ -163,6 +167,8 @@ class TMDbClient:
async with self.semaphore:
url = f"{self.base_url}/movie/{movie_id}"
params = {"api_key": self.api_key}
if language:
params["language"] = language
try:
async with aiohttp.ClientSession() as session:
@@ -177,7 +183,7 @@ class TMDbClient:
logging.error(f"Error getting TMDb movie details for ID {movie_id}: {e}")
return None
async def get_tv_details(self, tv_id):
async def get_tv_details(self, tv_id, language=None):
"""
Get detailed TV series information
@@ -190,6 +196,8 @@ class TMDbClient:
async with self.semaphore:
url = f"{self.base_url}/tv/{tv_id}"
params = {"api_key": self.api_key}
if language:
params["language"] = language
try:
async with aiohttp.ClientSession() as session:
@@ -204,7 +212,7 @@ class TMDbClient:
logging.error(f"Error getting TMDb TV details for ID {tv_id}: {e}")
return None
async def get_tv_season(self, tv_id, season_number):
async def get_tv_season(self, tv_id, season_number, language=None):
"""
Get TV season information
@@ -218,6 +226,8 @@ class TMDbClient:
async with self.semaphore:
url = f"{self.base_url}/tv/{tv_id}/season/{season_number}"
params = {"api_key": self.api_key}
if language:
params["language"] = language
try:
async with aiohttp.ClientSession() as session:
@@ -232,7 +242,7 @@ class TMDbClient:
logging.error(f"Error getting TMDb season data: {e}")
return None
async def fetch_summary(self, title, media_type="movie", year=None, season=None, episode=None):
async def fetch_summary(self, title, media_type="movie", year=None, season=None, episode=None, language=None):
"""
Fetch summary for movie or TV series
@@ -251,14 +261,14 @@ class TMDbClient:
try:
if media_type == "tv":
# Search for TV series
search_result = await self.search_tv(title, year)
search_result = await self.search_tv(title, year, language=language)
if not search_result:
return None
tv_id = search_result["id"]
# Get detailed TV info
tv_details = await self.get_tv_details(tv_id)
tv_details = await self.get_tv_details(tv_id, language=language)
if not tv_details:
return None
@@ -266,7 +276,7 @@ class TMDbClient:
# If specific season/episode requested, try to get that plot
if season is not None:
season_data = await self.get_tv_season(tv_id, season)
season_data = await self.get_tv_season(tv_id, season, language=language)
if season_data and episode is not None:
episodes = season_data.get("episodes", [])
for ep in episodes:
@@ -291,14 +301,14 @@ class TMDbClient:
else: # movie
# Search for movie
search_result = await self.search_movie(title, year)
search_result = await self.search_movie(title, year, language=language)
if not search_result:
return None
movie_id = search_result["id"]
# Get detailed movie info
movie_details = await self.get_movie_details(movie_id)
movie_details = await self.get_movie_details(movie_id, language=language)
if not movie_details:
return None