stability and settings
This commit is contained in:
557
main.py
557
main.py
@@ -1,64 +1,99 @@
|
|||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
|
import shlex
|
||||||
|
import yaml
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Set
|
from typing import Dict, List, Any
|
||||||
|
|
||||||
import ocrmypdf
|
import ocrmypdf
|
||||||
import pypdf
|
import pypdf
|
||||||
import pytesseract
|
import pytesseract
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from faster_whisper import WhisperModel
|
from faster_whisper import WhisperModel
|
||||||
# MODIFICATION: Added Form for model selection
|
|
||||||
from fastapi import (Depends, FastAPI, File, Form, HTTPException, Request,
|
from fastapi import (Depends, FastAPI, File, Form, HTTPException, Request,
|
||||||
UploadFile, status)
|
UploadFile, status, Body)
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from huey import SqliteHuey
|
from huey import SqliteHuey
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
from sqlalchemy import (Column, DateTime, Integer, String, Text,
|
from sqlalchemy import (Column, DateTime, Integer, String, Text,
|
||||||
create_engine)
|
create_engine, delete, event)
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
from string import Formatter
|
||||||
from sqlalchemy.orm import Session, declarative_base, sessionmaker
|
from sqlalchemy.orm import Session, declarative_base, sessionmaker
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
# --------------------------------------------------------------------------------
|
# --------------------------------------------------------------------------------
|
||||||
# --- 1. CONFIGURATION
|
# --- 1. CONFIGURATION
|
||||||
# --------------------------------------------------------------------------------
|
# --------------------------------------------------------------------------------
|
||||||
class Settings(BaseSettings):
|
|
||||||
|
class AppPaths(BaseModel):
|
||||||
BASE_DIR: Path = Path(__file__).resolve().parent
|
BASE_DIR: Path = Path(__file__).resolve().parent
|
||||||
UPLOADS_DIR: Path = BASE_DIR / "uploads"
|
UPLOADS_DIR: Path = BASE_DIR / "uploads"
|
||||||
PROCESSED_DIR: Path = BASE_DIR / "processed"
|
PROCESSED_DIR: Path = BASE_DIR / "processed"
|
||||||
DATABASE_URL: str = f"sqlite:///{BASE_DIR / 'jobs.db'}"
|
DATABASE_URL: str = f"sqlite:///{BASE_DIR / 'jobs.db'}"
|
||||||
HUEY_DB_PATH: str = str(BASE_DIR / "huey.db")
|
HUEY_DB_PATH: str = str(BASE_DIR / "huey.db")
|
||||||
# MODIFICATION: Removed hardcoded model size, added a set of allowed models
|
SETTINGS_FILE: Path = BASE_DIR / "settings.yml"
|
||||||
WHISPER_COMPUTE_TYPE: str = "int8"
|
|
||||||
ALLOWED_WHISPER_MODELS: Set[str] = {"tiny", "base", "small", "medium", "large-v3", "distil-large-v2"}
|
|
||||||
MAX_FILE_SIZE_BYTES: int = 500 * 1024 * 1024 # 500 MB
|
|
||||||
ALLOWED_PDF_EXTENSIONS: set = {".pdf"}
|
|
||||||
ALLOWED_IMAGE_EXTENSIONS: set = {".png", ".jpg", ".jpeg", ".tiff", ".tif"}
|
|
||||||
ALLOWED_AUDIO_EXTENSIONS: set = {".mp3", "m4a", ".ogg", ".flac", ".opus"}
|
|
||||||
|
|
||||||
settings = Settings()
|
PATHS = AppPaths()
|
||||||
|
APP_CONFIG: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
def load_app_config():
|
||||||
|
global APP_CONFIG
|
||||||
|
try:
|
||||||
|
with open(PATHS.SETTINGS_FILE, 'r') as f:
|
||||||
|
APP_CONFIG = yaml.safe_load(f)
|
||||||
|
APP_CONFIG['app_settings']['max_file_size_bytes'] = APP_CONFIG['app_settings']['max_file_size_mb'] * 1024 * 1024
|
||||||
|
allowed_extensions = {
|
||||||
|
".pdf", ".ps", ".eps", ".png", ".jpg", ".jpeg", ".tiff", ".tif", ".gif",
|
||||||
|
".bmp", ".webp", ".svg", ".jxl", ".avif", ".ppm", ".mp3", ".m4a", ".ogg",
|
||||||
|
".flac", ".opus", ".wav", ".aac", ".mp4", ".mkv", ".mov", ".webm", ".avi",
|
||||||
|
".flv", ".md", ".txt", ".html", ".docx", ".odt", ".rst", ".epub", ".mobi",
|
||||||
|
".azw3", ".pptx", ".xlsx"
|
||||||
|
}
|
||||||
|
APP_CONFIG['app_settings']['allowed_all_extensions'] = allowed_extensions
|
||||||
|
logger.info("Successfully loaded settings from settings.yml")
|
||||||
|
except (FileNotFoundError, yaml.YAMLError) as e:
|
||||||
|
logger.error(f"Could not load settings.yml: {e}. App may not function correctly.")
|
||||||
|
APP_CONFIG = {}
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
PATHS.UPLOADS_DIR.mkdir(exist_ok=True)
|
||||||
settings.UPLOADS_DIR.mkdir(exist_ok=True)
|
PATHS.PROCESSED_DIR.mkdir(exist_ok=True)
|
||||||
settings.PROCESSED_DIR.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------------
|
# --------------------------------------------------------------------------------
|
||||||
# --- 2. DATABASE (for Job Tracking) - NO CHANGES
|
# --- 2. DATABASE & Schemas
|
||||||
# --------------------------------------------------------------------------------
|
# --------------------------------------------------------------------------------
|
||||||
engine = create_engine(settings.DATABASE_URL, connect_args={"check_same_thread": False})
|
engine = create_engine(
|
||||||
|
PATHS.DATABASE_URL,
|
||||||
|
connect_args={"check_same_thread": False, "timeout": 30},
|
||||||
|
poolclass=NullPool,
|
||||||
|
)
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
# THIS IS THE CRITICAL FIX
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
@event.listens_for(engine, "connect")
|
||||||
|
def _set_sqlite_pragmas(dbapi_connection, connection_record):
|
||||||
|
"""
|
||||||
|
Enable WAL mode and set sane synchronous for better concurrency
|
||||||
|
between the FastAPI process and Huey worker processes.
|
||||||
|
"""
|
||||||
|
c = dbapi_connection.cursor()
|
||||||
|
try:
|
||||||
|
c.execute("PRAGMA journal_mode=WAL;")
|
||||||
|
c.execute("PRAGMA synchronous=NORMAL;")
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
class Job(Base):
|
class Job(Base):
|
||||||
__tablename__ = "jobs"
|
__tablename__ = "jobs"
|
||||||
id = Column(String, primary_key=True, index=True)
|
id = Column(String, primary_key=True, index=True)
|
||||||
@@ -80,10 +115,6 @@ def get_db():
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------------
|
|
||||||
# --- 3. PYDANTIC SCHEMAS (Data Validation) - NO CHANGES
|
|
||||||
# --------------------------------------------------------------------------------
|
|
||||||
class JobCreate(BaseModel):
|
class JobCreate(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
task_type: str
|
task_type: str
|
||||||
@@ -104,9 +135,8 @@ class JobSchema(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------------
|
# --------------------------------------------------------------------------------
|
||||||
# --- 4. CRUD OPERATIONS (Database Interactions) - NO CHANGES
|
# --- 3. CRUD OPERATIONS (No Changes)
|
||||||
# --------------------------------------------------------------------------------
|
# --------------------------------------------------------------------------------
|
||||||
def get_job(db: Session, job_id: str):
|
def get_job(db: Session, job_id: str):
|
||||||
return db.query(Job).filter(Job.id == job_id).first()
|
return db.query(Job).filter(Job.id == job_id).first()
|
||||||
@@ -143,37 +173,101 @@ def mark_job_as_completed(db: Session, job_id: str, preview: str | None = None):
|
|||||||
db.commit()
|
db.commit()
|
||||||
return db_job
|
return db_job
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------------
|
# --------------------------------------------------------------------------------
|
||||||
# --- 5. BACKGROUND TASKS (Huey)
|
# --- 4. BACKGROUND TASK SETUP
|
||||||
# --------------------------------------------------------------------------------
|
# --------------------------------------------------------------------------------
|
||||||
huey = SqliteHuey(filename=settings.HUEY_DB_PATH)
|
huey = SqliteHuey(filename=PATHS.HUEY_DB_PATH)
|
||||||
|
|
||||||
|
# --- START: NEW WHISPER MODEL CACHING ---
|
||||||
|
# This dictionary will live in the memory of the Huey worker process,
|
||||||
|
# allowing us to reuse loaded models across tasks.
|
||||||
|
WHISPER_MODELS_CACHE: Dict[str, WhisperModel] = {}
|
||||||
|
|
||||||
|
def get_whisper_model(model_size: str, whisper_settings: dict) -> WhisperModel:
|
||||||
|
"""
|
||||||
|
Loads a Whisper model into the cache if not present, and returns the model.
|
||||||
|
This ensures a model is only loaded into memory once per worker process.
|
||||||
|
"""
|
||||||
|
if model_size not in WHISPER_MODELS_CACHE:
|
||||||
|
compute_type = whisper_settings.get('compute_type', 'int8')
|
||||||
|
logger.info(f"Whisper model '{model_size}' not in cache. Loading into memory...")
|
||||||
|
model = WhisperModel(model_size, device="cpu", compute_type=compute_type)
|
||||||
|
WHISPER_MODELS_CACHE[model_size] = model
|
||||||
|
logger.info(f"Model '{model_size}' loaded successfully.")
|
||||||
|
else:
|
||||||
|
logger.info(f"Found model '{model_size}' in cache. Reusing.")
|
||||||
|
return WHISPER_MODELS_CACHE[model_size]
|
||||||
|
# --- END: NEW WHISPER MODEL CACHING ---
|
||||||
|
|
||||||
# MODIFICATION: Removed global whisper model and lazy loader.
|
|
||||||
# The model will now be loaded inside the task itself based on user selection.
|
|
||||||
|
|
||||||
@huey.task()
|
@huey.task()
|
||||||
def run_pdf_ocr_task(job_id: str, input_path_str: str, output_path_str: str):
|
def run_transcription_task(job_id: str, input_path_str: str, output_path_str: str, model_size: str, whisper_settings: dict):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
job = get_job(db, job_id)
|
job = get_job(db, job_id)
|
||||||
if not job or job.status == 'cancelled':
|
if not job or job.status == 'cancelled': return
|
||||||
logger.info(f"Job {job_id} was cancelled before starting.")
|
|
||||||
return
|
|
||||||
|
|
||||||
|
update_job_status(db, job_id, "processing")
|
||||||
|
|
||||||
|
# --- MODIFIED: Use the caching function to get the model ---
|
||||||
|
model = get_whisper_model(model_size, whisper_settings)
|
||||||
|
|
||||||
|
logger.info(f"Starting transcription for job {job_id}")
|
||||||
|
segments, info = model.transcribe(input_path_str, beam_size=5)
|
||||||
|
|
||||||
|
full_transcript = []
|
||||||
|
for segment in segments:
|
||||||
|
job_check = get_job(db, job_id) # Check for cancellation during long tasks
|
||||||
|
if job_check.status == 'cancelled':
|
||||||
|
logger.info(f"Job {job_id} cancelled during transcription.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if info.duration > 0:
|
||||||
|
progress = int((segment.end / info.duration) * 100)
|
||||||
|
update_job_status(db, job_id, "processing", progress=progress)
|
||||||
|
|
||||||
|
full_transcript.append(segment.text.strip())
|
||||||
|
|
||||||
|
transcript_text = "\n".join(full_transcript)
|
||||||
|
# write atomically to avoid partial files
|
||||||
|
out_path = Path(output_path_str)
|
||||||
|
tmp_out = out_path.with_suffix(out_path.suffix + f".{uuid.uuid4().hex}.tmp")
|
||||||
|
with tmp_out.open("w", encoding="utf-8") as f:
|
||||||
|
f.write(transcript_text)
|
||||||
|
tmp_out.replace(out_path)
|
||||||
|
|
||||||
|
mark_job_as_completed(db, job_id, preview=transcript_text)
|
||||||
|
logger.info(f"Transcription for job {job_id} completed.")
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"ERROR during transcription for job {job_id}")
|
||||||
|
update_job_status(db, job_id, "failed", error="See server logs for details.")
|
||||||
|
finally:
|
||||||
|
Path(input_path_str).unlink(missing_ok=True)
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Other tasks remain unchanged
|
||||||
|
@huey.task()
|
||||||
|
def run_pdf_ocr_task(job_id: str, input_path_str: str, output_path_str: str, ocr_settings: dict):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
job = get_job(db, job_id)
|
||||||
|
if not job or job.status == 'cancelled': return
|
||||||
update_job_status(db, job_id, "processing")
|
update_job_status(db, job_id, "processing")
|
||||||
logger.info(f"Starting PDF OCR for job {job_id}")
|
logger.info(f"Starting PDF OCR for job {job_id}")
|
||||||
|
ocrmypdf.ocr(input_path_str, output_path_str,
|
||||||
ocrmypdf.ocr(input_path_str, output_path_str, deskew=True, force_ocr=True, clean=True, optimize=1, progress_bar=False)
|
deskew=ocr_settings.get('deskew', True),
|
||||||
|
force_ocr=ocr_settings.get('force_ocr', True),
|
||||||
|
clean=ocr_settings.get('clean', True),
|
||||||
|
optimize=ocr_settings.get('optimize', 1),
|
||||||
|
progress_bar=False)
|
||||||
with open(output_path_str, "rb") as f:
|
with open(output_path_str, "rb") as f:
|
||||||
reader = pypdf.PdfReader(f)
|
reader = pypdf.PdfReader(f)
|
||||||
preview = "\n".join(page.extract_text() or "" for page in reader.pages)
|
preview = "\n".join(page.extract_text() or "" for page in reader.pages)
|
||||||
mark_job_as_completed(db, job_id, preview=preview)
|
mark_job_as_completed(db, job_id, preview=preview)
|
||||||
logger.info(f"PDF OCR for job {job_id} completed.")
|
logger.info(f"PDF OCR for job {job_id} completed.")
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(f"ERROR during PDF OCR for job {job_id}: {e}\n{traceback.format_exc()}")
|
logger.exception(f"ERROR during PDF OCR for job {job_id}")
|
||||||
update_job_status(db, job_id, "failed", error=str(e))
|
update_job_status(db, job_id, "failed", error="See server logs for details.")
|
||||||
finally:
|
finally:
|
||||||
Path(input_path_str).unlink(missing_ok=True)
|
Path(input_path_str).unlink(missing_ok=True)
|
||||||
db.close()
|
db.close()
|
||||||
@@ -183,10 +277,7 @@ def run_image_ocr_task(job_id: str, input_path_str: str, output_path_str: str):
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
job = get_job(db, job_id)
|
job = get_job(db, job_id)
|
||||||
if not job or job.status == 'cancelled':
|
if not job or job.status == 'cancelled': return
|
||||||
logger.info(f"Job {job_id} was cancelled before starting.")
|
|
||||||
return
|
|
||||||
|
|
||||||
update_job_status(db, job_id, "processing", progress=50)
|
update_job_status(db, job_id, "processing", progress=50)
|
||||||
logger.info(f"Starting Image OCR for job {job_id}")
|
logger.info(f"Starting Image OCR for job {job_id}")
|
||||||
text = pytesseract.image_to_string(Image.open(input_path_str))
|
text = pytesseract.image_to_string(Image.open(input_path_str))
|
||||||
@@ -194,154 +285,177 @@ def run_image_ocr_task(job_id: str, input_path_str: str, output_path_str: str):
|
|||||||
f.write(text)
|
f.write(text)
|
||||||
mark_job_as_completed(db, job_id, preview=text)
|
mark_job_as_completed(db, job_id, preview=text)
|
||||||
logger.info(f"Image OCR for job {job_id} completed.")
|
logger.info(f"Image OCR for job {job_id} completed.")
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(f"ERROR during Image OCR for job {job_id}: {e}\n{traceback.format_exc()}")
|
logger.exception(f"ERROR during Image OCR for job {job_id}")
|
||||||
update_job_status(db, job_id, "failed", error=str(e))
|
update_job_status(db, job_id, "failed", error="See server logs for details.")
|
||||||
finally:
|
finally:
|
||||||
Path(input_path_str).unlink(missing_ok=True)
|
Path(input_path_str).unlink(missing_ok=True)
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
# MODIFICATION: The task now accepts `model_size` and loads the model dynamically.
|
|
||||||
@huey.task()
|
@huey.task()
|
||||||
def run_transcription_task(job_id: str, input_path_str: str, output_path_str: str, model_size: str):
|
def run_conversion_task(job_id: str, input_path_str: str, output_path_str: str, tool: str, task_key: str, conversion_tools_config: dict):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
temp_input_file = None
|
||||||
|
temp_output_file = None
|
||||||
try:
|
try:
|
||||||
job = get_job(db, job_id)
|
job = get_job(db, job_id)
|
||||||
if not job or job.status == 'cancelled':
|
if not job or job.status == 'cancelled': return
|
||||||
logger.info(f"Job {job_id} was cancelled before starting.")
|
update_job_status(db, job_id, "processing", progress=25)
|
||||||
return
|
logger.info(f"Starting conversion for job {job_id} using {tool} with task {task_key}")
|
||||||
|
tool_config = conversion_tools_config.get(tool)
|
||||||
|
if not tool_config: raise ValueError(f"Unknown conversion tool: {tool}")
|
||||||
|
input_path = Path(input_path_str)
|
||||||
|
output_path = Path(output_path_str)
|
||||||
|
current_input_path = input_path
|
||||||
|
if tool == "mozjpeg":
|
||||||
|
temp_input_file = input_path.with_suffix('.temp.ppm')
|
||||||
|
logger.info(f"Pre-converting for MozJPEG: {input_path} -> {temp_input_file}")
|
||||||
|
pre_conv_cmd = ["vips", "copy", str(input_path), str(temp_input_file)]
|
||||||
|
pre_conv_result = subprocess.run(pre_conv_cmd, capture_output=True, text=True, check=False, timeout=tool_config.get("timeout", 300))
|
||||||
|
if pre_conv_result.returncode != 0:
|
||||||
|
err = (pre_conv_result.stderr or "")[:4000]
|
||||||
|
raise Exception(f"MozJPEG pre-conversion to PPM failed: {err}")
|
||||||
|
current_input_path = temp_input_file
|
||||||
|
update_job_status(db, job_id, "processing", progress=50)
|
||||||
|
# Build safe mapping for formatting and validate placeholders
|
||||||
|
ALLOWED_VARS = {"input", "output", "output_dir", "output_ext", "quality", "speed", "preset", "device", "dpi", "samplerate", "bitdepth"}
|
||||||
|
def validate_and_build_command(template_str: str, mapping: dict):
|
||||||
|
fmt = Formatter()
|
||||||
|
used = {fname for _, fname, _, _ in fmt.parse(template_str) if fname}
|
||||||
|
bad = used - ALLOWED_VARS
|
||||||
|
if bad:
|
||||||
|
raise ValueError(f"Command template contains disallowed placeholders: {bad}")
|
||||||
|
formatted = template_str.format(**mapping)
|
||||||
|
return shlex.split(formatted)
|
||||||
|
|
||||||
update_job_status(db, job_id, "processing")
|
# Use a temporary output path and atomically move into place after success
|
||||||
|
temp_output_file = output_path.with_suffix(output_path.suffix + f".{uuid.uuid4().hex}.tmp")
|
||||||
|
|
||||||
# Load the specified model for this task
|
# Prepare mapping
|
||||||
logger.info(f"Loading faster-whisper model: {model_size} for job {job_id}...")
|
mapping = {
|
||||||
model = WhisperModel(
|
"input": str(current_input_path),
|
||||||
model_size,
|
"output": str(temp_output_file),
|
||||||
device="cpu",
|
"output_dir": str(output_path.parent),
|
||||||
compute_type=settings.WHISPER_COMPUTE_TYPE
|
"output_ext": output_path.suffix.lstrip('.'),
|
||||||
)
|
}
|
||||||
logger.info(f"Whisper model '{model_size}' loaded successfully.")
|
|
||||||
|
|
||||||
logger.info(f"Starting transcription for job {job_id}")
|
# Allow tool-specific adjustments to mapping
|
||||||
segments, info = model.transcribe(input_path_str, beam_size=5)
|
if tool.startswith("ghostscript"):
|
||||||
|
device, setting = task_key.split('_')
|
||||||
|
mapping.update({"device": device, "dpi": setting, "preset": setting})
|
||||||
|
elif tool == "pngquant":
|
||||||
|
_, quality_key = task_key.split('_')
|
||||||
|
quality_map = {"hq": "80-95", "mq": "65-80", "fast": "65-80"}
|
||||||
|
speed_map = {"hq": "1", "mq": "3", "fast": "11"}
|
||||||
|
mapping.update({"quality": quality_map.get(quality_key, "65-80"), "speed": speed_map.get(quality_key, "3")})
|
||||||
|
elif tool == "sox":
|
||||||
|
_, rate, depth = task_key.split('_')
|
||||||
|
rate = rate.replace('k', '000') if 'k' in rate else rate
|
||||||
|
depth = depth.replace('b', '') if 'b' in depth else '16'
|
||||||
|
mapping.update({"samplerate": rate, "bitdepth": depth})
|
||||||
|
elif tool == "mozjpeg":
|
||||||
|
_, quality = task_key.split('_')
|
||||||
|
quality = quality.replace('q', '')
|
||||||
|
mapping.update({"quality": quality})
|
||||||
|
|
||||||
full_transcript = []
|
command_template_str = tool_config["command_template"]
|
||||||
total_duration = info.duration
|
command = validate_and_build_command(command_template_str, mapping)
|
||||||
for segment in segments:
|
logger.info(f"Executing command: {' '.join(command)}")
|
||||||
job_check = get_job(db, job_id)
|
# run with timeout and capture output; run_command helper ensures trimmed logs on failure
|
||||||
if job_check.status == 'cancelled':
|
def run_command(argv: List[str], timeout: int = 300):
|
||||||
logger.info(f"Job {job_id} cancelled during transcription.")
|
try:
|
||||||
return
|
res = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise Exception(f"Command timed out after {timeout}s")
|
||||||
|
if res.returncode != 0:
|
||||||
|
stderr = (res.stderr or "")[:4000]
|
||||||
|
stdout = (res.stdout or "")[:4000]
|
||||||
|
raise Exception(f"Command failed exit {res.returncode}. stderr: {stderr}; stdout: {stdout}")
|
||||||
|
return res
|
||||||
|
|
||||||
# Update progress based on the segment's end time
|
result = run_command(command, timeout=tool_config.get("timeout", 300))
|
||||||
if total_duration > 0:
|
if tool == "libreoffice":
|
||||||
progress = int((segment.end / total_duration) * 100)
|
expected_output_filename = input_path.with_suffix(output_path.suffix).name
|
||||||
update_job_status(db, job_id, "processing", progress=progress)
|
generated_file = output_path.parent / expected_output_filename
|
||||||
full_transcript.append(segment.text.strip())
|
if generated_file.exists():
|
||||||
|
# move generated file into place
|
||||||
|
generated_file.replace(output_path)
|
||||||
|
else:
|
||||||
|
raise Exception(f"LibreOffice did not create the expected file: {expected_output_filename}")
|
||||||
|
# move temp output into final location atomically
|
||||||
|
if temp_output_file and temp_output_file.exists():
|
||||||
|
temp_output_file.replace(output_path)
|
||||||
|
|
||||||
transcript_text = "\n".join(full_transcript)
|
mark_job_as_completed(db, job_id, preview=f"Successfully converted file.")
|
||||||
with open(output_path_str, "w", encoding="utf-8") as f:
|
logger.info(f"Conversion for job {job_id} completed.")
|
||||||
f.write(transcript_text)
|
|
||||||
mark_job_as_completed(db, job_id, preview=transcript_text)
|
|
||||||
logger.info(f"Transcription for job {job_id} completed.")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"ERROR during transcription for job {job_id}: {e}\n{traceback.format_exc()}")
|
logger.exception(f"ERROR during conversion for job {job_id}")
|
||||||
update_job_status(db, job_id, "failed", error=str(e))
|
update_job_status(db, job_id, "failed", error="See server logs for details.")
|
||||||
finally:
|
finally:
|
||||||
Path(input_path_str).unlink(missing_ok=True)
|
Path(input_path_str).unlink(missing_ok=True)
|
||||||
|
if temp_input_file:
|
||||||
|
temp_input_file.unlink(missing_ok=True)
|
||||||
|
if temp_output_file:
|
||||||
|
temp_output_file.unlink(missing_ok=True)
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------------
|
# --------------------------------------------------------------------------------
|
||||||
# --- 6. FASTAPI APPLICATION
|
# --- 5. FASTAPI APPLICATION
|
||||||
# --------------------------------------------------------------------------------
|
# --------------------------------------------------------------------------------
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
logger.info("Application starting up...")
|
logger.info("Application starting up...")
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
load_app_config()
|
||||||
yield
|
yield
|
||||||
logger.info("Application shutting down...")
|
logger.info("Application shutting down...")
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
app.mount("/static", StaticFiles(directory=settings.BASE_DIR / "static"), name="static")
|
app.mount("/static", StaticFiles(directory=PATHS.BASE_DIR / "static"), name="static")
|
||||||
templates = Jinja2Templates(directory=settings.BASE_DIR / "templates")
|
templates = Jinja2Templates(directory=PATHS.BASE_DIR / "templates")
|
||||||
|
|
||||||
# --- Helper Functions ---
|
|
||||||
async def save_upload_file_chunked(upload_file: UploadFile, destination: Path):
|
async def save_upload_file_chunked(upload_file: UploadFile, destination: Path):
|
||||||
|
"""
|
||||||
|
Streams the uploaded file in chunks directly to a file on disk.
|
||||||
|
This is memory-efficient and reliable for large files.
|
||||||
|
"""
|
||||||
|
max_size = APP_CONFIG.get("app_settings", {}).get("max_file_size_bytes", 100 * 1024 * 1024)
|
||||||
|
tmp = destination.with_suffix(destination.suffix + f".{uuid.uuid4().hex}.tmp")
|
||||||
size = 0
|
size = 0
|
||||||
with open(destination, "wb") as buffer:
|
try:
|
||||||
while chunk := await upload_file.read(1024 * 1024): # 1MB chunks
|
with tmp.open("wb") as buffer:
|
||||||
if size + len(chunk) > settings.MAX_FILE_SIZE_BYTES:
|
while True:
|
||||||
raise HTTPException(
|
chunk = await upload_file.read(1024 * 1024)
|
||||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
if not chunk:
|
||||||
detail=f"File exceeds limit of {settings.MAX_FILE_SIZE_BYTES // 1024 // 1024} MB"
|
break
|
||||||
)
|
|
||||||
buffer.write(chunk)
|
|
||||||
size += len(chunk)
|
size += len(chunk)
|
||||||
|
if size > max_size:
|
||||||
|
raise HTTPException(status_code=413, detail=f"File exceeds {max_size / 1024 / 1024} MB limit")
|
||||||
|
buffer.write(chunk)
|
||||||
|
# atomic move into place
|
||||||
|
tmp.replace(destination)
|
||||||
|
except Exception:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def is_allowed_file(filename: str, allowed_extensions: set) -> bool:
|
def is_allowed_file(filename: str, allowed_extensions: set) -> bool:
|
||||||
return Path(filename).suffix.lower() in allowed_extensions
|
return Path(filename).suffix.lower() in allowed_extensions
|
||||||
|
|
||||||
# --- API Endpoints ---
|
# --- Routes (only transcription route is modified) ---
|
||||||
@app.get("/")
|
|
||||||
async def get_index(request: Request):
|
|
||||||
# MODIFICATION: Pass available models to the template
|
|
||||||
return templates.TemplateResponse("index.html", {
|
|
||||||
"request": request,
|
|
||||||
"whisper_models": sorted(list(settings.ALLOWED_WHISPER_MODELS))
|
|
||||||
})
|
|
||||||
|
|
||||||
@app.post("/ocr-pdf", status_code=status.HTTP_202_ACCEPTED)
|
|
||||||
async def submit_pdf_ocr(file: UploadFile = File(...), db: Session = Depends(get_db)):
|
|
||||||
if not is_allowed_file(file.filename, settings.ALLOWED_PDF_EXTENSIONS):
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid file type. Please upload a PDF.")
|
|
||||||
|
|
||||||
job_id = uuid.uuid4().hex
|
|
||||||
safe_basename = secure_filename(file.filename)
|
|
||||||
unique_filename = f"{Path(safe_basename).stem}_{job_id}{Path(safe_basename).suffix}"
|
|
||||||
upload_path = settings.UPLOADS_DIR / unique_filename
|
|
||||||
processed_path = settings.PROCESSED_DIR / unique_filename
|
|
||||||
|
|
||||||
await save_upload_file_chunked(file, upload_path)
|
|
||||||
|
|
||||||
job_data = JobCreate(id=job_id, task_type="ocr", original_filename=file.filename, input_filepath=str(upload_path), processed_filepath=str(processed_path))
|
|
||||||
new_job = create_job(db=db, job=job_data)
|
|
||||||
|
|
||||||
run_pdf_ocr_task(new_job.id, str(upload_path), str(processed_path))
|
|
||||||
return {"job_id": new_job.id, "status": new_job.status}
|
|
||||||
|
|
||||||
@app.post("/ocr-image", status_code=status.HTTP_202_ACCEPTED)
|
|
||||||
async def submit_image_ocr(file: UploadFile = File(...), db: Session = Depends(get_db)):
|
|
||||||
if not is_allowed_file(file.filename, settings.ALLOWED_IMAGE_EXTENSIONS):
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid file type. Please upload a PNG, JPG, or TIFF.")
|
|
||||||
|
|
||||||
job_id = uuid.uuid4().hex
|
|
||||||
safe_basename = secure_filename(file.filename)
|
|
||||||
file_ext = Path(safe_basename).suffix
|
|
||||||
unique_filename = f"{Path(safe_basename).stem}_{job_id}{file_ext}"
|
|
||||||
upload_path = settings.UPLOADS_DIR / unique_filename
|
|
||||||
processed_path = settings.PROCESSED_DIR / f"{Path(safe_basename).stem}_{job_id}.txt"
|
|
||||||
|
|
||||||
await save_upload_file_chunked(file, upload_path)
|
|
||||||
|
|
||||||
job_data = JobCreate(id=job_id, task_type="ocr-image", original_filename=file.filename, input_filepath=str(upload_path), processed_filepath=str(processed_path))
|
|
||||||
new_job = create_job(db=db, job=job_data)
|
|
||||||
|
|
||||||
run_image_ocr_task(new_job.id, str(upload_path), str(processed_path))
|
|
||||||
return {"job_id": new_job.id, "status": new_job.status}
|
|
||||||
|
|
||||||
# MODIFICATION: Endpoint now accepts `model_size` as form data.
|
|
||||||
@app.post("/transcribe-audio", status_code=status.HTTP_202_ACCEPTED)
|
@app.post("/transcribe-audio", status_code=status.HTTP_202_ACCEPTED)
|
||||||
async def submit_audio_transcription(
|
async def submit_audio_transcription(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
model_size: str = Form("base"),
|
model_size: str = Form("base"),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
if not is_allowed_file(file.filename, settings.ALLOWED_AUDIO_EXTENSIONS):
|
if not is_allowed_file(file.filename, {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".opus"}):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid audio file type.")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid audio file type.")
|
||||||
|
|
||||||
# Validate the selected model size
|
whisper_config = APP_CONFIG.get("transcription_settings", {}).get("whisper", {})
|
||||||
if model_size not in settings.ALLOWED_WHISPER_MODELS:
|
if model_size not in whisper_config.get("allowed_models", []):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid model size: {model_size}.")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid model size: {model_size}.")
|
||||||
|
|
||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
@@ -350,19 +464,145 @@ async def submit_audio_transcription(
|
|||||||
|
|
||||||
audio_filename = f"{stem}_{job_id}{suffix}"
|
audio_filename = f"{stem}_{job_id}{suffix}"
|
||||||
transcript_filename = f"{stem}_{job_id}.txt"
|
transcript_filename = f"{stem}_{job_id}.txt"
|
||||||
upload_path = settings.UPLOADS_DIR / audio_filename
|
upload_path = PATHS.UPLOADS_DIR / audio_filename
|
||||||
processed_path = settings.PROCESSED_DIR / transcript_filename
|
processed_path = PATHS.PROCESSED_DIR / transcript_filename
|
||||||
|
|
||||||
await save_upload_file_chunked(file, upload_path)
|
await save_upload_file_chunked(file, upload_path)
|
||||||
|
|
||||||
job_data = JobCreate(id=job_id, task_type="transcription", original_filename=file.filename, input_filepath=str(upload_path), processed_filepath=str(processed_path))
|
job_data = JobCreate(id=job_id, task_type="transcription", original_filename=file.filename, input_filepath=str(upload_path), processed_filepath=str(processed_path))
|
||||||
new_job = create_job(db=db, job=job_data)
|
new_job = create_job(db=db, job=job_data)
|
||||||
|
|
||||||
# Pass the selected model size to the background task
|
# --- MODIFIED: Pass whisper_config to the task ---
|
||||||
run_transcription_task(new_job.id, str(upload_path), str(processed_path), model_size=model_size)
|
run_transcription_task(new_job.id, str(upload_path), str(processed_path), model_size=model_size, whisper_settings=whisper_config)
|
||||||
|
|
||||||
return {"job_id": new_job.id, "status": new_job.status}
|
return {"job_id": new_job.id, "status": new_job.status}
|
||||||
|
|
||||||
@app.post("/job/{job_id}/cancel", status_code=status.HTTP_200_OK)
|
|
||||||
|
# --- Other routes remain unchanged ---
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def get_index(request: Request):
|
||||||
|
whisper_models = APP_CONFIG.get("transcription_settings", {}).get("whisper", {}).get("allowed_models", [])
|
||||||
|
conversion_tools = APP_CONFIG.get("conversion_tools", {})
|
||||||
|
return templates.TemplateResponse("index.html", {
|
||||||
|
"request": request,
|
||||||
|
"whisper_models": sorted(list(whisper_models)),
|
||||||
|
"conversion_tools": conversion_tools
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/settings")
|
||||||
|
async def get_settings_page(request: Request):
|
||||||
|
try:
|
||||||
|
with open(PATHS.SETTINGS_FILE, 'r') as f:
|
||||||
|
current_config = yaml.safe_load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Could not load settings.yml for settings page: {e}")
|
||||||
|
current_config = {}
|
||||||
|
return templates.TemplateResponse("settings.html", {"request": request, "config": current_config})
|
||||||
|
|
||||||
|
@app.post("/settings/save")
|
||||||
|
async def save_settings(new_config: Dict = Body(...)):
|
||||||
|
try:
|
||||||
|
with open(PATHS.SETTINGS_FILE, 'w') as f:
|
||||||
|
yaml.dump(new_config, f, default_flow_style=False, sort_keys=False)
|
||||||
|
load_app_config()
|
||||||
|
return JSONResponse({"message": "Settings saved successfully."})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save settings: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Could not write to settings.yml.")
|
||||||
|
|
||||||
|
@app.post("/settings/clear-history")
|
||||||
|
async def clear_job_history(db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
num_deleted = db.query(Job).delete()
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Cleared {num_deleted} jobs from history.")
|
||||||
|
return {"deleted_count": num_deleted}
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to clear job history: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Database error while clearing history.")
|
||||||
|
|
||||||
|
@app.post("/settings/delete-files")
|
||||||
|
async def delete_processed_files():
|
||||||
|
deleted_count = 0
|
||||||
|
errors = []
|
||||||
|
for f in PATHS.PROCESSED_DIR.glob('*'):
|
||||||
|
try:
|
||||||
|
if f.is_file():
|
||||||
|
f.unlink()
|
||||||
|
deleted_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f.name)
|
||||||
|
logger.error(f"Could not delete processed file {f.name}: {e}")
|
||||||
|
if errors:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Could not delete some files: {', '.join(errors)}")
|
||||||
|
logger.info(f"Deleted {deleted_count} files from processed directory.")
|
||||||
|
return {"deleted_count": deleted_count}
|
||||||
|
|
||||||
|
@app.post("/convert-file", status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
async def submit_file_conversion(file: UploadFile = File(...), output_format: str = Form(...), db: Session = Depends(get_db)):
|
||||||
|
allowed_exts = APP_CONFIG.get("app_settings", {}).get("allowed_all_extensions", set())
|
||||||
|
if not is_allowed_file(file.filename, allowed_exts):
|
||||||
|
raise HTTPException(status_code=400, detail=f"File type '{Path(file.filename).suffix}' not allowed.")
|
||||||
|
conversion_tools = APP_CONFIG.get("conversion_tools", {})
|
||||||
|
try:
|
||||||
|
tool, task_key = output_format.split('_', 1)
|
||||||
|
if tool not in conversion_tools or task_key not in conversion_tools[tool]["formats"]:
|
||||||
|
raise ValueError()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid output format selected.")
|
||||||
|
job_id = uuid.uuid4().hex
|
||||||
|
safe_basename = secure_filename(file.filename)
|
||||||
|
original_stem = Path(safe_basename).stem
|
||||||
|
target_ext = task_key.split('_')[0]
|
||||||
|
if tool == "ghostscript_pdf":
|
||||||
|
target_ext = "pdf"
|
||||||
|
upload_filename = f"{original_stem}_{job_id}{Path(safe_basename).suffix}"
|
||||||
|
processed_filename = f"{original_stem}_{job_id}.{target_ext}"
|
||||||
|
upload_path = PATHS.UPLOADS_DIR / upload_filename
|
||||||
|
processed_path = PATHS.PROCESSED_DIR / processed_filename
|
||||||
|
await save_upload_file_chunked(file, upload_path)
|
||||||
|
job_data = JobCreate(id=job_id, task_type="conversion", original_filename=file.filename,
|
||||||
|
input_filepath=str(upload_path), processed_filepath=str(processed_path))
|
||||||
|
new_job = create_job(db=db, job=job_data)
|
||||||
|
run_conversion_task(new_job.id, str(upload_path), str(processed_path), tool, task_key, conversion_tools)
|
||||||
|
return {"job_id": new_job.id, "status": new_job.status}
|
||||||
|
|
||||||
|
@app.post("/ocr-pdf", status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
async def submit_pdf_ocr(file: UploadFile = File(...), db: Session = Depends(get_db)):
|
||||||
|
if not is_allowed_file(file.filename, {".pdf"}):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid file type. Please upload a PDF.")
|
||||||
|
job_id = uuid.uuid4().hex
|
||||||
|
safe_basename = secure_filename(file.filename)
|
||||||
|
unique_filename = f"{Path(safe_basename).stem}_{job_id}{Path(safe_basename).suffix}"
|
||||||
|
upload_path = PATHS.UPLOADS_DIR / unique_filename
|
||||||
|
processed_path = PATHS.PROCESSED_DIR / unique_filename
|
||||||
|
await save_upload_file_chunked(file, upload_path)
|
||||||
|
job_data = JobCreate(id=job_id, task_type="ocr", original_filename=file.filename, input_filepath=str(upload_path), processed_filepath=str(processed_path))
|
||||||
|
new_job = create_job(db=db, job=job_data)
|
||||||
|
ocr_settings = APP_CONFIG.get("ocr_settings", {}).get("ocrmypdf", {})
|
||||||
|
run_pdf_ocr_task(new_job.id, str(upload_path), str(processed_path), ocr_settings)
|
||||||
|
return {"job_id": new_job.id, "status": new_job.status}
|
||||||
|
|
||||||
|
@app.post("/ocr-image", status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
async def submit_image_ocr(file: UploadFile = File(...), db: Session = Depends(get_db)):
|
||||||
|
allowed_exts = {".png", ".jpg", ".jpeg", ".tiff", ".tif"}
|
||||||
|
if not is_allowed_file(file.filename, allowed_exts):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid file type. Please upload a PNG, JPG, or TIFF.")
|
||||||
|
job_id = uuid.uuid4().hex
|
||||||
|
safe_basename = secure_filename(file.filename)
|
||||||
|
file_ext = Path(safe_basename).suffix
|
||||||
|
unique_filename = f"{Path(safe_basename).stem}_{job_id}{file_ext}"
|
||||||
|
upload_path = PATHS.UPLOADS_DIR / unique_filename
|
||||||
|
processed_path = PATHS.PROCESSED_DIR / f"{Path(safe_basename).stem}_{job_id}.txt"
|
||||||
|
await save_upload_file_chunked(file, upload_path)
|
||||||
|
job_data = JobCreate(id=job_id, task_type="ocr-image", original_filename=file.filename, input_filepath=str(upload_path), processed_filepath=str(processed_path))
|
||||||
|
new_job = create_job(db=db, job=job_data)
|
||||||
|
run_image_ocr_task(new_job.id, str(upload_path), str(processed_path))
|
||||||
|
return {"job_id": new_job.id, "status": new_job.status}
|
||||||
|
|
||||||
|
@app.post("/job/{job_id}/cancel", status_code=status.HTTP_202_ACCEPTED)
|
||||||
async def cancel_job(job_id: str, db: Session = Depends(get_db)):
|
async def cancel_job(job_id: str, db: Session = Depends(get_db)):
|
||||||
job = get_job(db, job_id)
|
job = get_job(db, job_id)
|
||||||
if not job:
|
if not job:
|
||||||
@@ -386,12 +626,13 @@ async def get_job_status(job_id: str, db: Session = Depends(get_db)):
|
|||||||
@app.get("/download/{filename}")
|
@app.get("/download/{filename}")
|
||||||
async def download_file(filename: str):
|
async def download_file(filename: str):
|
||||||
safe_filename = secure_filename(filename)
|
safe_filename = secure_filename(filename)
|
||||||
file_path = settings.PROCESSED_DIR / safe_filename
|
file_path = PATHS.PROCESSED_DIR / safe_filename
|
||||||
|
file_path = file_path.resolve()
|
||||||
if not file_path.resolve().is_relative_to(settings.PROCESSED_DIR.resolve()):
|
base = PATHS.PROCESSED_DIR.resolve()
|
||||||
|
try:
|
||||||
|
file_path.relative_to(base)
|
||||||
|
except ValueError:
|
||||||
raise HTTPException(status_code=403, detail="Access denied.")
|
raise HTTPException(status_code=403, detail="Access denied.")
|
||||||
|
|
||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
raise HTTPException(status_code=404, detail="File not found.")
|
raise HTTPException(status_code=404, detail="File not found.")
|
||||||
|
|
||||||
return FileResponse(path=file_path, filename=safe_filename, media_type="application/octet-stream")
|
return FileResponse(path=file_path, filename=safe_filename, media_type="application/octet-stream")
|
||||||
4
run.sh
4
run.sh
@@ -3,8 +3,8 @@
|
|||||||
|
|
||||||
echo "Starting DocProcessor with Gunicorn..."
|
echo "Starting DocProcessor with Gunicorn..."
|
||||||
|
|
||||||
exec gunicorn -w 1 -k uvicorn.workers.UvicornWorker --forwarded-allow-ips='*' main:app -b 0.0.0.0:8000 &
|
exec gunicorn -w 2 -k uvicorn.workers.UvicornWorker --forwarded-allow-ips='*' main:app -b 0.0.0.0:8000 &
|
||||||
echo "Done"
|
echo "Done"
|
||||||
echo "Starting huey..."
|
echo "Starting huey..."
|
||||||
exec huey_consumer.py main.huey &
|
exec huey_consumer.py main.huey -w 2 &
|
||||||
echo "Done"
|
echo "Done"
|
||||||
|
|||||||
179
settings.yml
Normal file
179
settings.yml
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# settings.yml
|
||||||
|
|
||||||
|
# General application settings
|
||||||
|
app_settings:
|
||||||
|
max_file_size_mb: 2000 # Maximum upload size in Megabytes
|
||||||
|
|
||||||
|
# Settings for Optical Character Recognition (OCR) tasks
|
||||||
|
ocr_settings:
|
||||||
|
ocrmypdf:
|
||||||
|
deskew: true
|
||||||
|
clean: true
|
||||||
|
optimize: 1
|
||||||
|
force_ocr: true
|
||||||
|
|
||||||
|
# Settings for audio transcription tasks
|
||||||
|
transcription_settings:
|
||||||
|
whisper:
|
||||||
|
compute_type: "int8"
|
||||||
|
allowed_models:
|
||||||
|
- "tiny"
|
||||||
|
- "base"
|
||||||
|
- "small"
|
||||||
|
- "medium"
|
||||||
|
- "large-v3"
|
||||||
|
- "distil-large-v2"
|
||||||
|
|
||||||
|
# --- Conversion Tool Definitions ---
|
||||||
|
# Each tool's command is a single string. The backend uses shlex to parse it,
|
||||||
|
# so you can use quotes for arguments with spaces.
|
||||||
|
# Use placeholders like: {input}, {output}, {output_dir}, {output_ext}, etc.
|
||||||
|
conversion_tools:
|
||||||
|
libreoffice:
|
||||||
|
name: "LibreOffice"
|
||||||
|
command_template: 'libreoffice --headless --convert-to {output_ext} --outdir {output_dir} {input}'
|
||||||
|
formats:
|
||||||
|
pdf: "PDF"
|
||||||
|
docx: "Word Document"
|
||||||
|
odt: "OpenDocument Text"
|
||||||
|
html: "HTML"
|
||||||
|
rtf: "Rich Text Format"
|
||||||
|
txt: "Plain Text"
|
||||||
|
xml: "Word 2003 XML"
|
||||||
|
epub: "EPUB"
|
||||||
|
xlsx: "Excel Spreadsheet"
|
||||||
|
ods: "OpenDocument Spreadsheet"
|
||||||
|
csv: "CSV"
|
||||||
|
pptx: "PowerPoint Presentation"
|
||||||
|
odp: "OpenDocument Presentation"
|
||||||
|
svg: "SVG"
|
||||||
|
pandoc:
|
||||||
|
name: "Pandoc"
|
||||||
|
command_template: 'pandoc --standalone {input} -o {output}'
|
||||||
|
formats:
|
||||||
|
docx: "Word Document"
|
||||||
|
odt: "OpenDocument Text"
|
||||||
|
pdf: "PDF"
|
||||||
|
rtf: "Rich Text Format"
|
||||||
|
txt: "Plain Text"
|
||||||
|
tex: "LaTeX"
|
||||||
|
man: "Groff Man Page"
|
||||||
|
epub: "EPUB v3 Book"
|
||||||
|
epub2: "EPUB v2 Book"
|
||||||
|
html: "HTML"
|
||||||
|
html5: "HTML5"
|
||||||
|
pptx: "PowerPoint Presentation"
|
||||||
|
beamer: "Beamer PDF Slides"
|
||||||
|
slidy: "Slidy HTML Slides"
|
||||||
|
md: "Markdown"
|
||||||
|
rst: "reStructuredText"
|
||||||
|
jira: "Jira Wiki Markup"
|
||||||
|
mediawiki: "MediaWiki Markup"
|
||||||
|
ghostscript_pdf:
|
||||||
|
name: "Ghostscript (PDF)"
|
||||||
|
command_template: 'gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 -dNOPAUSE -dQUIET -dBATCH {preset} -sOutputFile={output} {input}'
|
||||||
|
formats:
|
||||||
|
screen: "PDF (Optimized for Screen)"
|
||||||
|
ebook: "PDF (Optimized for Ebooks)"
|
||||||
|
printer: "PDF (Optimized for Print)"
|
||||||
|
archive: "PDF/A (for Archiving)"
|
||||||
|
ghostscript_image:
|
||||||
|
name: "Ghostscript (Image)"
|
||||||
|
command_template: 'gs -dNOPAUSE -dBATCH -sDEVICE={device} -r{dpi} -sOutputFile={output} {input}'
|
||||||
|
formats:
|
||||||
|
jpeg_72: "JPEG Image (72 DPI)"
|
||||||
|
jpeg_300: "JPEG Image (300 DPI)"
|
||||||
|
png16m_150: "PNG Image (150 DPI)"
|
||||||
|
png16m_300: "PNG Image (300 DPI)"
|
||||||
|
tiff24nc_300: "TIFF Image (300 DPI)"
|
||||||
|
tiff24nc_600: "TIFF Image (600 DPI)"
|
||||||
|
calibre:
|
||||||
|
name: "Calibre (ebook-convert)"
|
||||||
|
command_template: 'ebook-convert {input} {output}'
|
||||||
|
formats:
|
||||||
|
epub: "EPUB"
|
||||||
|
mobi: "MOBI"
|
||||||
|
azw3: "Amazon Kindle"
|
||||||
|
pdf: "PDF"
|
||||||
|
docx: "Word Document"
|
||||||
|
ffmpeg:
|
||||||
|
name: "FFmpeg"
|
||||||
|
command_template: 'ffmpeg -i {input} -y -preset medium {output}'
|
||||||
|
formats:
|
||||||
|
mp4: "MP4 Video"
|
||||||
|
mkv: "MKV Video"
|
||||||
|
mov: "MOV Video"
|
||||||
|
webm: "WebM Video"
|
||||||
|
mp3: "MP3 Audio"
|
||||||
|
wav: "WAV Audio"
|
||||||
|
flac: "FLAC Audio"
|
||||||
|
gif: "Animated GIF"
|
||||||
|
vips:
|
||||||
|
name: "VIPS"
|
||||||
|
command_template: 'vips copy {input} {output}[Q=90]'
|
||||||
|
formats:
|
||||||
|
jpg: "JPEG Image (Q90)"
|
||||||
|
png: "PNG Image"
|
||||||
|
webp: "WebP Image (Q90)"
|
||||||
|
tiff: "TIFF Image"
|
||||||
|
avif: "AVIF Image"
|
||||||
|
graphicsmagick:
|
||||||
|
name: "GraphicsMagick"
|
||||||
|
command_template: 'gm convert {input} -quality 90 {output}'
|
||||||
|
formats:
|
||||||
|
jpg: "JPEG Image (Q90)"
|
||||||
|
png: "PNG Image"
|
||||||
|
webp: "WebP Image"
|
||||||
|
tiff: "TIFF Image"
|
||||||
|
pdf: "PDF from Images"
|
||||||
|
inkscape:
|
||||||
|
name: "Inkscape"
|
||||||
|
command_template: 'inkscape {input} --export-filename={output}'
|
||||||
|
formats:
|
||||||
|
svg: "SVG (Plain)"
|
||||||
|
png: "PNG Image (96 DPI)"
|
||||||
|
pdf: "PDF Document"
|
||||||
|
libjxl:
|
||||||
|
name: "libjxl (cjxl)"
|
||||||
|
command_template: 'cjxl {input} {output} -q 90'
|
||||||
|
formats:
|
||||||
|
jxl: "JPEG XL (Q90)"
|
||||||
|
resvg:
|
||||||
|
name: "resvg"
|
||||||
|
command_template: 'resvg {input} {output}'
|
||||||
|
formats:
|
||||||
|
png: "PNG from SVG"
|
||||||
|
potrace:
|
||||||
|
name: "Potrace"
|
||||||
|
command_template: 'potrace {input} --svg -o {output}'
|
||||||
|
formats:
|
||||||
|
svg: "SVG from Bitmap"
|
||||||
|
markitdown:
|
||||||
|
name: "Markitdown"
|
||||||
|
command_template: 'markitdown {input} -o {output}'
|
||||||
|
formats:
|
||||||
|
md: "Markdown from Everything!"
|
||||||
|
pngquant:
|
||||||
|
name: "pngquant"
|
||||||
|
command_template: 'pngquant --quality={quality} --speed {speed} --force --output {output} {input}'
|
||||||
|
formats:
|
||||||
|
png_hq: "PNG (High Quality Compression)"
|
||||||
|
png_mq: "PNG (Medium Quality Compression)"
|
||||||
|
png_fast: "PNG (Fast Compression)"
|
||||||
|
sox:
|
||||||
|
name: "SoX Audio Converter"
|
||||||
|
command_template: 'sox {input} -r {samplerate} -b {bitdepth} {output}'
|
||||||
|
formats:
|
||||||
|
wav_48k_24b: "WAV (48kHz, 24-bit)"
|
||||||
|
wav_44k_16b: "WAV (CD, 44.1kHz, 16-bit)"
|
||||||
|
flac_48k_24b: "FLAC (48kHz, 24-bit)"
|
||||||
|
flac_44k_16b: "FLAC (CD, 44.1kHz, 16-bit)"
|
||||||
|
ogg_32k: "Ogg Vorbis (32kHz)"
|
||||||
|
ogg_16k: "Ogg Vorbis (16kHz, Voice)"
|
||||||
|
mozjpeg:
|
||||||
|
name: "MozJPEG"
|
||||||
|
command_template: 'cjpeg -quality {quality} -outfile {output} {input}'
|
||||||
|
formats:
|
||||||
|
jpg_q85: "JPEG (High Quality)"
|
||||||
|
jpg_q75: "JPEG (Web Quality)"
|
||||||
|
jpg_q60: "JPEG (Aggressive Compression)"
|
||||||
194
static/css/settings.css
Normal file
194
static/css/settings.css
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/* static/css/settings.css */
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h1 {e
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.back-button:hover {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group legend {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group legend h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input, .form-select, .form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
background-color: rgba(255,255,255,0.05);
|
||||||
|
color: var(--text-color);
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 180, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 60px;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-description {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted-text);
|
||||||
|
margin-top: -0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.field-description code {
|
||||||
|
background-color: rgba(255,255,255,0.1);
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.checkbox-group input[type="checkbox"] {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card {
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.tool-card h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
.button-primary {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--primary-color);
|
||||||
|
background-color: transparent;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
border-width: 1px;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 0.65rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.button-primary:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-status {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.save-status.success {
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
.save-status.error {
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
border: none;
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--divider-color);
|
||||||
|
margin: 3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone {
|
||||||
|
border: 1px solid var(--error-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: rgba(255, 107, 107, 0.05);
|
||||||
|
}
|
||||||
|
.danger-action {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.danger-action + .danger-action {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid rgba(255, 107, 107, 0.2);
|
||||||
|
}
|
||||||
|
.danger-action p {
|
||||||
|
margin: 0.25rem 0 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
.button-danger {
|
||||||
|
background-color: var(--error-color);
|
||||||
|
color: #1a0000;
|
||||||
|
border: none;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.button-danger:hover {
|
||||||
|
background-color: #ff8f8f;
|
||||||
|
}
|
||||||
@@ -3,14 +3,15 @@
|
|||||||
:root {
|
:root {
|
||||||
/* Core */
|
/* Core */
|
||||||
--bg-color: #000000;
|
--bg-color: #000000;
|
||||||
--surface: #111111;
|
--surface: #000000;
|
||||||
--card-bg: #0b0b0b;
|
--card-bg: #000000;
|
||||||
--text-color: #e6eef6;
|
--text-color: #e6eef6;
|
||||||
--muted-text: #9aa4ad;
|
--muted-text: #9aa4ad;
|
||||||
|
|
||||||
/* Accent / interactive */
|
/* Accent / interactive */
|
||||||
--primary-color: #00b4ff;
|
--primary-color: #ffffff;
|
||||||
--primary-hover: #00d0ff;
|
--secondary-color: #2b2b2b;
|
||||||
|
--primary-hover: #ffffff3d;
|
||||||
--success-color: #26c281;
|
--success-color: #26c281;
|
||||||
--error-color: #ff6b6b;
|
--error-color: #ff6b6b;
|
||||||
--cancel-color: #f39c12; /* Orange for cancelled */
|
--cancel-color: #f39c12; /* Orange for cancelled */
|
||||||
@@ -43,12 +44,14 @@ body {
|
|||||||
/* Container */
|
/* Container */
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 960px;
|
max-width: 1280px; /* Increased max-width for 3 columns */
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
margin-top: 1em;
|
||||||
|
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.container {
|
.container {
|
||||||
@@ -69,8 +72,10 @@ header {
|
|||||||
|
|
||||||
header h1 {
|
header h1 {
|
||||||
margin: 0 0 0.25rem 0;
|
margin: 0 0 0.25rem 0;
|
||||||
font-size: 1.75rem;
|
font-size: 3rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
font-family: serif;
|
||||||
|
font-weight: lighter;
|
||||||
}
|
}
|
||||||
|
|
||||||
header p {
|
header p {
|
||||||
@@ -79,18 +84,28 @@ header p {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
}
|
||||||
|
.settings-link {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--muted-text);
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
.settings-link:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
/* Form Layout */
|
/* Form Layout */
|
||||||
.form-grid {
|
.form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
/* MODIFICATION: Responsive grid for 1, 2, or 3 items */
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
@media (min-width: 768px) {
|
|
||||||
.form-grid {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-form fieldset {
|
.upload-form fieldset {
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -130,7 +145,6 @@ input[type="file"] {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-overflow: ellipsis;
|
|
||||||
inset: 0;
|
inset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,8 +156,6 @@ input[type="file"] {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.15s ease;
|
transition: background-color 0.15s ease;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-overflow: ellipsis;
|
|
||||||
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -159,10 +171,10 @@ input[type="file"] {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 10em;
|
max-width: 15em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- STYLES FOR DROPDOWN MENU --- */
|
/* Form Controls (for dropdowns) */
|
||||||
.form-control {
|
.form-control {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@@ -200,22 +212,23 @@ input[type="file"] {
|
|||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
box-shadow: 0 0 0 2px rgba(0, 180, 255, 0.2);
|
box-shadow: 0 0 0 2px rgba(0, 180, 255, 0.2);
|
||||||
}
|
}
|
||||||
/* ---------------------------------- */
|
|
||||||
|
|
||||||
/* Submit button */
|
/* Submit button */
|
||||||
button[type="submit"] {
|
button[type="submit"] {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
color: #00161b;
|
background-color: transparent;
|
||||||
border: none;
|
border-color: var(--border-color);
|
||||||
|
border-width: 1px;
|
||||||
|
color: #ffffff;
|
||||||
padding: 0.65rem 1rem;
|
padding: 0.65rem 1rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.15s ease;
|
transition: background-color 0.15s ease;
|
||||||
margin-top: auto; /* Pushes button to the bottom */
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
button[type="submit"]:hover {
|
button[type="submit"]:hover {
|
||||||
background: var(--primary-hover);
|
background: var(--primary-hover);
|
||||||
@@ -268,16 +281,24 @@ button[type="submit"]:disabled {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#job-table td[data-label="File"] {
|
.cell-value {
|
||||||
max-width: 250px;
|
max-width: 10em;
|
||||||
overflow: hidden;
|
text-wrap: wrap;
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#job-table td[data-label="File"],
|
||||||
|
#job-table td[data-label="Task"] {
|
||||||
|
overflow: scroll;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
text-wrap: wrap;
|
||||||
|
max-width: 15em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-col {
|
.action-col {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 100px;
|
width: 120px;
|
||||||
}
|
}
|
||||||
#job-table td.action-col {
|
#job-table td.action-col {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -348,35 +369,87 @@ button[type="submit"]:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Action items */
|
/* Action items */
|
||||||
.download-button {
|
.download-button, .cancel-button {
|
||||||
background-color: var(--success-color);
|
|
||||||
color: #00160b;
|
|
||||||
padding: 0.3rem 0.8rem;
|
padding: 0.3rem 0.8rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
transition: transform 0.1s ease;
|
transition: transform 0.1s ease, background-color 0.15s ease;
|
||||||
border: none;
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.download-button {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: #00160b;
|
||||||
}
|
}
|
||||||
.download-button:hover { transform: scale(1.05); }
|
.download-button:hover { transform: scale(1.05); }
|
||||||
|
|
||||||
.cancel-button {
|
.cancel-button {
|
||||||
background-color: var(--error-color);
|
background-color: var(--error-color);
|
||||||
color: #1a0000;
|
color: #1a0000;
|
||||||
padding: 0.3rem 0.8rem;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
display: inline-block;
|
|
||||||
transition: transform 0.1s ease;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
.cancel-button:hover { background-color: #ff8f8f; }
|
.cancel-button:hover { background-color: #ff8f8f; }
|
||||||
|
|
||||||
|
/* --- MODIFICATION: Dark theme for Choices.js --- */
|
||||||
|
.choices {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.choices__inner {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
.is-open .choices__inner {
|
||||||
|
border-radius: 5px 5px 0 0;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
.is-focused .choices__inner {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 180, 255, 0.2);
|
||||||
|
}
|
||||||
|
.choices__list--dropdown, .choices__list[aria-expanded] {
|
||||||
|
background-color: var(--surface);
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 5px 5px;
|
||||||
|
}
|
||||||
|
.choices__list--dropdown .choices__item--selectable.is-highlighted,
|
||||||
|
.choices__list[aria-expanded] .choices__item--selectable.is-highlighted {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: var(--text-color)
|
||||||
|
}
|
||||||
|
.choices__list--dropdown .choices__item, .choices__list[aria-expanded] .choices__item {
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.choices__group {
|
||||||
|
background-color: rgba(0,0,0,0.2);
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
.choices__group .choices__heading {
|
||||||
|
color: var(--primary-hover);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
}
|
||||||
|
.choices__input {
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: #000000;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choices[data-type*="select-one"] .choices__input {
|
||||||
|
background-color: #000000;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
.spinner-small {
|
.spinner-small {
|
||||||
border: 3px solid rgba(255,255,255,0.1);
|
border: 3px solid rgba(255,255,255,0.1);
|
||||||
border-top: 3px solid var(--primary-color);
|
border-top: 3px solid var(--primary-color);
|
||||||
@@ -392,7 +465,9 @@ button[type="submit"]:disabled {
|
|||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive table */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
/* ... (no changes in this section) ... */
|
||||||
.table-wrapper {
|
.table-wrapper {
|
||||||
border: none;
|
border: none;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
@@ -432,5 +507,23 @@ button[type="submit"]:disabled {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
overflow: scroll;
|
||||||
|
max-width: 100em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-value {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 20em;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#job-table td[data-label="File"],
|
||||||
|
#job-table td[data-label="Task"] {
|
||||||
|
overflow: scroll;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
max-width: 100em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,35 @@
|
|||||||
// static/js/script.js
|
// static/js/script.js
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// --- Element Selectors ---
|
||||||
const jobListBody = document.getElementById('job-list-body');
|
const jobListBody = document.getElementById('job-list-body');
|
||||||
|
|
||||||
const pdfForm = document.getElementById('pdf-form');
|
const pdfForm = document.getElementById('pdf-form');
|
||||||
const pdfFileInput = document.getElementById('pdf-file-input');
|
const pdfFileInput = document.getElementById('pdf-file-input');
|
||||||
const pdfFileName = document.getElementById('pdf-file-name');
|
const pdfFileName = document.getElementById('pdf-file-name');
|
||||||
|
|
||||||
const audioForm = document.getElementById('audio-form');
|
const audioForm = document.getElementById('audio-form');
|
||||||
const audioFileInput = document.getElementById('audio-file-input');
|
const audioFileInput = document.getElementById('audio-file-input');
|
||||||
const audioFileName = document.getElementById('audio-file-name');
|
const audioFileName = document.getElementById('audio-file-name');
|
||||||
|
|
||||||
|
const conversionForm = document.getElementById('conversion-form');
|
||||||
|
const conversionFileInput = document.getElementById('conversion-file-input');
|
||||||
|
const conversionFileName = document.getElementById('conversion-file-name');
|
||||||
|
const outputFormatSelect = document.getElementById('output-format-select');
|
||||||
|
|
||||||
|
// MODIFICATION: Store the Choices.js instance in a variable
|
||||||
|
let conversionChoices = null;
|
||||||
|
|
||||||
const activePolls = new Map();
|
const activePolls = new Map();
|
||||||
|
|
||||||
// --- Main Event Listeners ---
|
// --- Main Event Listeners ---
|
||||||
pdfFileInput.addEventListener('change', () => updateFileName(pdfFileInput, pdfFileName));
|
pdfFileInput.addEventListener('change', () => updateFileName(pdfFileInput, pdfFileName));
|
||||||
audioFileInput.addEventListener('change', () => updateFileName(audioFileInput, audioFileName));
|
audioFileInput.addEventListener('change', () => updateFileName(audioFileInput, audioFileName));
|
||||||
pdfForm.addEventListener('submit', (e) => handleFormSubmit(e, '/ocr-pdf', pdfForm, pdfFileInput, pdfFileName));
|
conversionFileInput.addEventListener('change', () => updateFileName(conversionFileInput, conversionFileName));
|
||||||
audioForm.addEventListener('submit', (e) => handleFormSubmit(e, '/transcribe-audio', audioForm, audioFileInput, audioFileName));
|
|
||||||
|
pdfForm.addEventListener('submit', (e) => handleFormSubmit(e, '/ocr-pdf', pdfForm));
|
||||||
|
audioForm.addEventListener('submit', (e) => handleFormSubmit(e, '/transcribe-audio', audioForm));
|
||||||
|
conversionForm.addEventListener('submit', (e) => handleFormSubmit(e, '/convert-file', conversionForm));
|
||||||
|
|
||||||
jobListBody.addEventListener('click', (event) => {
|
jobListBody.addEventListener('click', (event) => {
|
||||||
if (event.target.classList.contains('cancel-button')) {
|
if (event.target.classList.contains('cancel-button')) {
|
||||||
@@ -24,19 +38,55 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateFileName(input, nameDisplay) {
|
function initializeConversionSelector() {
|
||||||
nameDisplay.textContent = input.files.length > 0 ? input.files[0].name : 'No file chosen';
|
// MODIFICATION: Destroy the old instance if it exists before creating a new one
|
||||||
nameDisplay.title = nameDisplay.textContent; // Add a tooltip for the full name
|
if (conversionChoices) {
|
||||||
|
conversionChoices.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFormSubmit(event, endpoint, form, fileInput, fileNameDisplay) {
|
conversionChoices = new Choices(outputFormatSelect, {
|
||||||
|
searchEnabled: true,
|
||||||
|
itemSelectText: 'Select',
|
||||||
|
shouldSort: false,
|
||||||
|
placeholder: true,
|
||||||
|
placeholderValue: 'Select a format...',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tools = window.APP_CONFIG.conversionTools || {};
|
||||||
|
const choicesArray = [];
|
||||||
|
for (const toolKey in tools) {
|
||||||
|
const tool = tools[toolKey];
|
||||||
|
const group = {
|
||||||
|
label: tool.name,
|
||||||
|
id: toolKey,
|
||||||
|
disabled: false,
|
||||||
|
choices: []
|
||||||
|
};
|
||||||
|
for (const formatKey in tool.formats) {
|
||||||
|
group.choices.push({
|
||||||
|
value: `${toolKey}_${formatKey}`,
|
||||||
|
label: `${formatKey.toUpperCase()} - ${tool.formats[formatKey]}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
choicesArray.push(group);
|
||||||
|
}
|
||||||
|
conversionChoices.setChoices(choicesArray, 'value', 'label', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper Functions ---
|
||||||
|
function updateFileName(input, nameDisplay) {
|
||||||
|
const fileName = input.files.length > 0 ? input.files[0].name : 'No file chosen';
|
||||||
|
nameDisplay.textContent = fileName;
|
||||||
|
nameDisplay.title = fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFormSubmit(event, endpoint, form) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
const fileInput = form.querySelector('input[type="file"]');
|
||||||
|
const fileNameDisplay = form.querySelector('.file-name');
|
||||||
if (!fileInput.files[0]) return;
|
if (!fileInput.files[0]) return;
|
||||||
|
|
||||||
// MODIFICATION: Use new FormData(form) to capture all form fields,
|
|
||||||
// including the new model size dropdown for the audio form.
|
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
|
|
||||||
const submitButton = form.querySelector('button[type="submit"]');
|
const submitButton = form.querySelector('button[type="submit"]');
|
||||||
submitButton.disabled = true;
|
submitButton.disabled = true;
|
||||||
|
|
||||||
@@ -47,54 +97,49 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
throw new Error(errorData.detail || `HTTP error! Status: ${response.status}`);
|
throw new Error(errorData.detail || `HTTP error! Status: ${response.status}`);
|
||||||
}
|
}
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
// To provide immediate feedback, create a placeholder job object
|
|
||||||
const preliminaryJob = {
|
const preliminaryJob = {
|
||||||
id: result.job_id,
|
id: result.job_id,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
original_filename: fileInput.files[0].name,
|
original_filename: fileInput.files[0].name,
|
||||||
task_type: endpoint.includes('ocr') ? 'ocr' : 'transcription',
|
task_type: endpoint.includes('ocr') ? 'ocr' : (endpoint.includes('transcribe') ? 'transcription' : 'conversion'),
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString()
|
||||||
processed_filepath: null,
|
|
||||||
error_message: null
|
|
||||||
};
|
};
|
||||||
renderJobRow(preliminaryJob); // Render immediately
|
renderJobRow(preliminaryJob);
|
||||||
startPolling(result.job_id); // Start polling for updates
|
startPolling(result.job_id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error submitting job:', error);
|
console.error('Error submitting job:', error);
|
||||||
alert(`Submission failed: ${error.message}`);
|
alert(`Submission failed: ${error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
form.reset();
|
form.reset();
|
||||||
fileNameDisplay.textContent = 'No file chosen';
|
if (fileNameDisplay) fileNameDisplay.textContent = 'No file chosen';
|
||||||
fileNameDisplay.title = '';
|
|
||||||
|
// MODIFICATION: Use the stored instance to correctly reset the dropdown
|
||||||
|
// without causing an error.
|
||||||
|
if (form.id === 'conversion-form' && conversionChoices) {
|
||||||
|
conversionChoices.clearInput();
|
||||||
|
conversionChoices.setValue([]); // Clears the selected value
|
||||||
|
}
|
||||||
|
|
||||||
submitButton.disabled = false;
|
submitButton.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCancelJob(jobId) {
|
async function handleCancelJob(jobId) {
|
||||||
if (!confirm('Are you sure you want to cancel this job?')) return;
|
if (!confirm('Are you sure you want to cancel this job?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/job/${jobId}/cancel`, { method: 'POST' });
|
const response = await fetch(`/job/${jobId}/cancel`, { method: 'POST' });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
throw new Error(errorData.detail || 'Failed to cancel job.');
|
throw new Error(errorData.detail || 'Failed to cancel job.');
|
||||||
}
|
}
|
||||||
// The polling mechanism will update the UI to "cancelled" automatically.
|
|
||||||
// We can stop polling immediately to be more efficient.
|
|
||||||
stopPolling(jobId);
|
stopPolling(jobId);
|
||||||
// Optionally, force an immediate UI update
|
|
||||||
const row = document.getElementById(`job-${jobId}`);
|
const row = document.getElementById(`job-${jobId}`);
|
||||||
if (row) {
|
if (row) {
|
||||||
const statusCell = row.querySelector('td[data-label="Status"] .cell-value');
|
const statusCell = row.querySelector('td[data-label="Status"] .cell-value');
|
||||||
const actionCell = row.querySelector('td[data-label="Action"] .cell-value');
|
const actionCell = row.querySelector('td[data-label="Action"] .cell-value');
|
||||||
if (statusCell) {
|
if (statusCell) statusCell.innerHTML = `<span class="job-status-badge status-cancelled">Cancelled</span>`;
|
||||||
statusCell.innerHTML = `<span class="job-status-badge status-cancelled">cancelled</span>`;
|
if (actionCell) actionCell.innerHTML = `<span>-</span>`;
|
||||||
}
|
|
||||||
if (actionCell) {
|
|
||||||
actionCell.innerHTML = `<span>-</span>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cancelling job:', error);
|
console.error('Error cancelling job:', error);
|
||||||
@@ -107,7 +152,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const response = await fetch('/jobs');
|
const response = await fetch('/jobs');
|
||||||
if (!response.ok) throw new Error('Failed to fetch jobs.');
|
if (!response.ok) throw new Error('Failed to fetch jobs.');
|
||||||
const jobs = await response.json();
|
const jobs = await response.json();
|
||||||
jobListBody.innerHTML = ''; // Clear existing
|
jobListBody.innerHTML = '';
|
||||||
for (const job of jobs.reverse()) {
|
for (const job of jobs.reverse()) {
|
||||||
renderJobRow(job);
|
renderJobRow(job);
|
||||||
if (['pending', 'processing'].includes(job.status)) {
|
if (['pending', 'processing'].includes(job.status)) {
|
||||||
@@ -126,10 +171,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`/job/${jobId}`);
|
const response = await fetch(`/job/${jobId}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 404) {
|
if (response.status === 404) stopPolling(jobId);
|
||||||
console.warn(`Job ${jobId} not found. Stopping poll.`);
|
|
||||||
stopPolling(jobId);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const job = await response.json();
|
const job = await response.json();
|
||||||
@@ -139,9 +181,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error polling for job ${jobId}:`, error);
|
console.error(`Error polling for job ${jobId}:`, error);
|
||||||
stopPolling(jobId); // Stop polling on network or other errors
|
stopPolling(jobId);
|
||||||
}
|
}
|
||||||
}, 2500); // Poll every 2.5 seconds
|
}, 2500);
|
||||||
activePolls.set(jobId, intervalId);
|
activePolls.set(jobId, intervalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,20 +202,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
jobListBody.prepend(row);
|
jobListBody.prepend(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskTypeLabel = job.task_type.includes('ocr') ? 'PDF/Image OCR' : 'Transcription';
|
let taskTypeLabel = 'Unknown';
|
||||||
const formattedDate = new Date(job.created_at).toLocaleString();
|
if (job.task_type === 'ocr' || job.task_type === 'ocr-image') {
|
||||||
|
taskTypeLabel = 'OCR';
|
||||||
let statusHtml = `<span class="job-status-badge status-${job.status}">${job.status}</span>`;
|
} else if (job.task_type === 'transcription') {
|
||||||
let actionHtml = `<span>-</span>`;
|
taskTypeLabel = 'Transcription';
|
||||||
|
} else if (job.task_type === 'conversion' && job.processed_filepath) {
|
||||||
if (job.status === 'processing') {
|
const extension = job.processed_filepath.split('.').pop();
|
||||||
// Show real progress for transcription, but an indeterminate one for OCR tasks
|
taskTypeLabel = `Convert to ${extension.toUpperCase()}`;
|
||||||
const progressClass = job.task_type === 'transcription' ? '' : 'indeterminate';
|
} else if (job.task_type === 'conversion') {
|
||||||
const progressWidth = job.task_type === 'transcription' ? job.progress : 100;
|
taskTypeLabel = 'Conversion';
|
||||||
const progressBarHtml = `<div class="progress-bar-container"><div class="progress-bar ${progressClass}" style="width: ${progressWidth}%"></div></div>`;
|
|
||||||
statusHtml += progressBarHtml;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formattedDate = new Date(job.created_at).toLocaleString();
|
||||||
|
let statusHtml = `<span class="job-status-badge status-${job.status}">${job.status}</span>`;
|
||||||
|
if (job.status === 'processing') {
|
||||||
|
const progressClass = (job.task_type === 'transcription' && job.progress > 0) ? '' : 'indeterminate';
|
||||||
|
const progressWidth = job.task_type === 'transcription' ? job.progress : 100;
|
||||||
|
statusHtml += `<div class="progress-bar-container"><div class="progress-bar ${progressClass}" style="width: ${progressWidth}%"></div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let actionHtml = `<span>-</span>`;
|
||||||
if (job.status === 'pending' || job.status === 'processing') {
|
if (job.status === 'pending' || job.status === 'processing') {
|
||||||
actionHtml = `<button class="cancel-button" data-job-id="${job.id}">Cancel</button>`;
|
actionHtml = `<button class="cancel-button" data-job-id="${job.id}">Cancel</button>`;
|
||||||
} else if (job.status === 'completed' && job.processed_filepath) {
|
} else if (job.status === 'completed' && job.processed_filepath) {
|
||||||
@@ -184,12 +233,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
actionHtml = `<span class="error-text"${errorTitle}>Failed</span>`;
|
actionHtml = `<span class="error-text"${errorTitle}>Failed</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use textContent for filename to prevent XSS and add a title for overflow
|
const escapedFilename = job.original_filename ? job.original_filename.replace(/</g, "<").replace(/>/g, ">") : "No filename";
|
||||||
const escapedFilename = job.original_filename.replace(/</g, "<").replace(/>/g, ">");
|
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<td data-label="File"><span class="cell-value" title="${escapedFilename}">${escapedFilename}</span></td>
|
<td data-label="File"><span class="cell-value" title="${escapedFilename}">${escapedFilename}</span></td>
|
||||||
<td data-label="Type"><span class="cell-value">${taskTypeLabel}</span></td>
|
<td data-label="Task"><span class="cell-value">${taskTypeLabel}</span></td>
|
||||||
<td data-label="Submitted"><span class="cell-value">${formattedDate}</span></td>
|
<td data-label="Submitted"><span class="cell-value">${formattedDate}</span></td>
|
||||||
<td data-label="Status"><span class="cell-value">${statusHtml}</span></td>
|
<td data-label="Status"><span class="cell-value">${statusHtml}</span></td>
|
||||||
<td data-label="Action" class="action-col"><span class="cell-value">${actionHtml}</span></td>
|
<td data-label="Action" class="action-col"><span class="cell-value">${actionHtml}</span></td>
|
||||||
@@ -197,5 +244,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Initial Load ---
|
// --- Initial Load ---
|
||||||
|
initializeConversionSelector();
|
||||||
loadInitialJobs();
|
loadInitialJobs();
|
||||||
});
|
});
|
||||||
108
static/js/settings.js
Normal file
108
static/js/settings.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// static/js/settings.js
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const settingsForm = document.getElementById('settings-form');
|
||||||
|
const saveStatus = document.getElementById('save-status');
|
||||||
|
const clearHistoryBtn = document.getElementById('clear-history-btn');
|
||||||
|
const deleteFilesBtn = document.getElementById('delete-files-btn');
|
||||||
|
|
||||||
|
// --- Save Settings ---
|
||||||
|
settingsForm.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
saveStatus.textContent = 'Saving...';
|
||||||
|
saveStatus.classList.remove('success', 'error');
|
||||||
|
|
||||||
|
const formData = new FormData(settingsForm);
|
||||||
|
const settingsObject = {};
|
||||||
|
|
||||||
|
// Convert FormData to a nested object
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
// Handle checkboxes that might not be submitted if unchecked
|
||||||
|
if (key.includes('ocr_settings')) {
|
||||||
|
const checkbox = document.querySelector(`[name="${key}"]`);
|
||||||
|
if (checkbox && checkbox.type === 'checkbox') {
|
||||||
|
value = checkbox.checked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = key.split('.');
|
||||||
|
let current = settingsObject;
|
||||||
|
keys.forEach((k, index) => {
|
||||||
|
if (index === keys.length - 1) {
|
||||||
|
current[k] = value;
|
||||||
|
} else {
|
||||||
|
current[k] = current[k] || {};
|
||||||
|
current = current[k];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure unchecked OCR boxes are sent as false
|
||||||
|
const ocrCheckboxes = settingsForm.querySelectorAll('input[type="checkbox"][name^="ocr_settings"]');
|
||||||
|
ocrCheckboxes.forEach(cb => {
|
||||||
|
const keys = cb.name.split('.');
|
||||||
|
if (!formData.has(cb.name)) {
|
||||||
|
// this is a bit of a hack but gets the job done for this specific form
|
||||||
|
settingsObject[keys[0]][keys[1]][keys[2]] = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(settingsObject)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.detail || 'Failed to save settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStatus.textContent = 'Settings saved successfully!';
|
||||||
|
saveStatus.classList.add('success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
saveStatus.textContent = `Error: ${error.message}`;
|
||||||
|
saveStatus.classList.add('error');
|
||||||
|
console.error('Error saving settings:', error);
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
saveStatus.textContent = '';
|
||||||
|
saveStatus.classList.remove('success', 'error');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Clear History ---
|
||||||
|
clearHistoryBtn.addEventListener('click', async () => {
|
||||||
|
if (!confirm('ARE YOU SURE?\n\nThis will permanently delete all job history records from the database.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings/clear-history', { method: 'POST' });
|
||||||
|
if (!response.ok) throw new Error('Server responded with an error.');
|
||||||
|
const result = await response.json();
|
||||||
|
alert(`Success: ${result.deleted_count} job records have been deleted.`);
|
||||||
|
} catch (error) {
|
||||||
|
alert('An error occurred while clearing history.');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Delete Files ---
|
||||||
|
deleteFilesBtn.addEventListener('click', async () => {
|
||||||
|
if (!confirm('ARE YOU SURE?\n\nThis will permanently delete all files in the "processed" folder.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings/delete-files', { method: 'POST' });
|
||||||
|
if (!response.ok) throw new Error('Server responded with an error.');
|
||||||
|
const result = await response.json();
|
||||||
|
alert(`Success: ${result.deleted_count} files have been deleted.`);
|
||||||
|
} catch (error) {
|
||||||
|
alert('An error occurred while deleting files.');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>DocProcessor</title>
|
<title>File Wizard</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js/public/assets/styles/choices.min.css"/>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='/css/style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/style.css') }}">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
@@ -11,17 +12,38 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="/settings" title="Settings" class="settings-link">⚙️</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h1>DocProcessor 🚀</h1>
|
<h1>File Wizard</h1>
|
||||||
<p>PDF OCR & Audio Transcription</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
|
<section class="processor-section">
|
||||||
|
<form id="conversion-form" class="upload-form" enctype="multipart/form-data">
|
||||||
|
<fieldset>
|
||||||
|
<legend><h2>File Conversion</h2></legend>
|
||||||
|
<div class="file-input-wrapper">
|
||||||
|
<input type="file" name="file" id="conversion-file-input" required>
|
||||||
|
<label for="conversion-file-input" class="file-input-label">Choose File...</label>
|
||||||
|
<span id="conversion-file-name" class="file-name">No file chosen</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="output-format-select">Convert To</label>
|
||||||
|
<select name="output_format" id="output-format-select" required></select>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Convert File</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="processor-section">
|
<section class="processor-section">
|
||||||
<form id="pdf-form" class="upload-form" enctype="multipart/form-data">
|
<form id="pdf-form" class="upload-form" enctype="multipart/form-data">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend><h2>📄 PDF OCR</h2></legend>
|
<legend><h2>PDF OCR</h2></legend>
|
||||||
<div class="file-input-wrapper">
|
<div class="file-input-wrapper">
|
||||||
<input type="file" name="file" id="pdf-file-input" accept=".pdf" required>
|
<input type="file" name="file" id="pdf-file-input" accept=".pdf" required>
|
||||||
<label for="pdf-file-input" class="file-input-label">Choose PDF...</label>
|
<label for="pdf-file-input" class="file-input-label">Choose PDF...</label>
|
||||||
@@ -35,7 +57,7 @@
|
|||||||
<section class="processor-section">
|
<section class="processor-section">
|
||||||
<form id="audio-form" class="upload-form" enctype="multipart/form-data">
|
<form id="audio-form" class="upload-form" enctype="multipart/form-data">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend><h2>🎤 Transcribe Audio</h2></legend>
|
<legend><h2>Transcribe Audio</h2></legend>
|
||||||
<div class="file-input-wrapper">
|
<div class="file-input-wrapper">
|
||||||
<input type="file" name="file" id="audio-file-input" accept="audio/*" required>
|
<input type="file" name="file" id="audio-file-input" accept="audio/*" required>
|
||||||
<label for="audio-file-input" class="file-input-label">Choose Audio...</label>
|
<label for="audio-file-input" class="file-input-label">Choose Audio...</label>
|
||||||
@@ -45,11 +67,11 @@
|
|||||||
<label for="model-size-select">Model Size</label>
|
<label for="model-size-select">Model Size</label>
|
||||||
<select name="model_size" id="model-size-select">
|
<select name="model_size" id="model-size-select">
|
||||||
<option value="tiny">Tiny (Fastest, lower accuracy)</option>
|
<option value="tiny">Tiny (Fastest, lower accuracy)</option>
|
||||||
<option value="base" selected>Base (Good balance)</option>
|
<option value="base" selected>Base</option>
|
||||||
<option value="small">Small (Better accuracy)</option>
|
<option value="small">Small (Better accuracy)</option>
|
||||||
<option value="medium">Medium (High accuracy)</option>
|
<option value="medium">Medium (High accuracy)</option>
|
||||||
<option value="large-v3">Large v3 (Best accuracy, slow)</option>
|
<option value="large-v3">Large v3 (Best accuracy, slow)</option>
|
||||||
<option value="distil-large-v2">Distilled Large v2 (Fast & Accurate)</option>
|
<option value="distil-large-v2">Distilled Large v2</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Transcribe</button>
|
<button type="submit">Transcribe</button>
|
||||||
@@ -65,7 +87,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>File</th>
|
<th>File</th>
|
||||||
<th>Type</th>
|
<th>Task</th>
|
||||||
<th>Submitted</th>
|
<th>Submitted</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th class="action-col">Action</th>
|
<th class="action-col">Action</th>
|
||||||
@@ -78,6 +100,14 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.APP_CONFIG = {
|
||||||
|
conversionTools: {{ conversion_tools | tojson }}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/choices.js/public/assets/scripts/choices.min.js"></script>
|
||||||
<script src="{{ url_for('static', path='/js/script.js') }}"></script>
|
<script src="{{ url_for('static', path='/js/script.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
113
templates/settings.html
Normal file
113
templates/settings.html
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Settings - File Wizard</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/style.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/settings.css') }}">
|
||||||
|
<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=Inter:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header class="settings-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<h1>Settings</h1>
|
||||||
|
</div>
|
||||||
|
<a href="/" class="back-button">← Back</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<form id="settings-form">
|
||||||
|
|
||||||
|
<fieldset class="settings-group">
|
||||||
|
<legend><h2>General Settings</h2></legend>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="app-max-file-size">Max Upload Size (MB)</label>
|
||||||
|
<input type="number" id="app-max-file-size" name="app_settings.max_file_size_mb" value="{{ config.app_settings.max_file_size_mb }}" class="form-input">
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="settings-group">
|
||||||
|
<legend><h2>OCR (ocrmypdf)</h2></legend>
|
||||||
|
<div class="form-control checkbox-group">
|
||||||
|
<input type="checkbox" id="ocr-deskew" name="ocr_settings.ocrmypdf.deskew" {% if config.ocr_settings.ocrmypdf.deskew %}checked{% endif %}>
|
||||||
|
<label for="ocr-deskew">Deskew (correct tilted pages)</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-control checkbox-group">
|
||||||
|
<input type="checkbox" id="ocr-clean" name="ocr_settings.ocrmypdf.clean" {% if config.ocr_settings.ocrmypdf.clean %}checked{% endif %}>
|
||||||
|
<label for="ocr-clean">Clean (remove speckles/noise)</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-control checkbox-group">
|
||||||
|
<input type="checkbox" id="ocr-force-ocr" name="ocr_settings.ocrmypdf.force_ocr" {% if config.ocr_settings.ocrmypdf.force_ocr %}checked{% endif %}>
|
||||||
|
<label for="ocr-force-ocr">Force OCR (re-process pages with existing text)</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="settings-group">
|
||||||
|
<legend><h2>Transcription (Whisper)</h2></legend>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="whisper-compute-type">Compute Type</label>
|
||||||
|
<select id="whisper-compute-type" name="transcription_settings.whisper.compute_type" class="form-select">
|
||||||
|
{% for ctype in ["default", "int8", "int8_float16", "int16", "float16", "float32"] %}
|
||||||
|
<option value="{{ ctype }}" {% if config.transcription_settings.whisper.compute_type == ctype %}selected{% endif %}>{{ ctype }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="settings-group">
|
||||||
|
<legend><h2>Conversion Tools</h2></legend>
|
||||||
|
<p class="field-description">
|
||||||
|
Edit the command line templates for each conversion tool. The following placeholders are available: <code>{input}</code>, <code>{output}</code>, <code>{output_dir}</code>, <code>{output_ext}</code>.
|
||||||
|
Some tools may have additional placeholders; refer to the sourcecode or documentation for details.
|
||||||
|
</p>
|
||||||
|
<div class="tool-grid">
|
||||||
|
{% for tool_id, tool in config.conversion_tools.items() %}
|
||||||
|
<div class="tool-card">
|
||||||
|
<h3>{{ tool.name }}</h3>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="tool-{{ tool_id }}-cmd">Command Template</label>
|
||||||
|
<textarea id="tool-{{ tool_id }}-cmd" name="conversion_tools.{{ tool_id }}.command_template" class="form-textarea" rows="3">{{ tool.command_template }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="button-primary">Save Settings</button>
|
||||||
|
<div id="save-status" class="save-status"></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<div id="history-management" class="settings-group">
|
||||||
|
<h2>History Management</h2>
|
||||||
|
<p class="field-description">These actions are irreversible. Please be certain before proceeding.</p>
|
||||||
|
<div class="danger-zone">
|
||||||
|
<div class="danger-action">
|
||||||
|
<div>
|
||||||
|
<strong>Clear Job History</strong>
|
||||||
|
<p>Deletes all job records from the database. Processed files on disk will not be removed.</p>
|
||||||
|
</div>
|
||||||
|
<button id="clear-history-btn" class="button-danger">Clear History</button>
|
||||||
|
</div>
|
||||||
|
<div class="danger-action">
|
||||||
|
<div>
|
||||||
|
<strong>Delete Processed Files</strong>
|
||||||
|
<p>Deletes all files from the 'processed' directory. Database records will remain but download links will be broken.</p>
|
||||||
|
</div>
|
||||||
|
<button id="delete-files-btn" class="button-danger">Delete Files</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', path='/js/settings.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user