diff --git a/Dockerfile b/Dockerfile index 72a8114..b6fc9fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # STAGE 1: BUILDER # This stage installs build tools and Python dependencies -FROM python:3.13.7-slim AS builder +FROM python:3.12.11-slim AS builder ENV PYTHONUNBUFFERED=1 \ DEBIAN_FRONTEND=noninteractive @@ -23,7 +23,7 @@ RUN pip install --no-cache-dir -r requirements.txt # STAGE 2: FINAL # This is the lean, final image for running the application -FROM python:3.13.7-slim +FROM python:3.12.11-slim ENV PYTHONUNBUFFERED=1 \ DEBIAN_FRONTEND=noninteractive @@ -49,6 +49,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ jpegoptim \ libsox-fmt-mp3 \ lame \ + libportaudio2 \ + libportaudiocpp0 \ + portaudio19-dev \ # Runtime libraries for Python packages libxml2 \ # Process manager @@ -59,7 +62,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app # Copy installed Python packages from the builder stage -COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages +COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages COPY --from=builder /usr/local/bin /usr/local/bin # Copy supervisor config and application code diff --git a/main.py b/main.py index 9065391..f48eb0b 100644 --- a/main.py +++ b/main.py @@ -22,7 +22,7 @@ import sys import re import importlib import collections.abc - +import time import ocrmypdf import pypdf import pytesseract @@ -40,6 +40,7 @@ from sqlalchemy import (Column, DateTime, Integer, String, Text, create_engine, delete, event) from sqlalchemy.orm import Session, declarative_base, sessionmaker from sqlalchemy.pool import NullPool +from sqlalchemy.exc import OperationalError from string import Formatter from werkzeug.utils import secure_filename from typing import List as TypingList @@ -828,7 +829,7 @@ def list_kokoro_voices_cli(timeout: int = 60) -> List[str]: voices = [] voice_pattern = re.compile(r'^\s*\d+\.\s+([a-z]{2,3}_[a-zA-Z0-9]+)$') for line in out.splitlines(): - line = line.strip() + line = line.strip() match = voice_pattern.match(line) if match: voices.append(match.group(1)) @@ -870,7 +871,7 @@ def list_kokoro_languages_cli(timeout: int = 60) -> List[str]: if not out: logger.warning("Kokoro TTS language list returned no output.") return [] - + languages = [] lang_pattern = re.compile(r'^\s*([a-z]{2,3}(?:-[a-z]{2,3})?)$') for line in out.splitlines(): @@ -1010,7 +1011,7 @@ def run_tts_task(job_id: str, input_path_str: str, output_path_str: str, model_n command_template_str = kokoro_settings.get("command_template") if not command_template_str: raise ValueError("Kokoro TTS command_template is not defined in settings.") - + try: lang, voice_name = actual_model_name.split('/', 1) except ValueError: @@ -1028,7 +1029,7 @@ def run_tts_task(job_id: str, input_path_str: str, output_path_str: str, model_n command = validate_and_build_command(command_template_str, mapping) logger.info(f"Executing Kokoro TTS command: {' '.join(command)}") run_command(command, timeout=kokoro_settings.get("timeout", 300)) - + if not tmp_out.exists(): raise FileNotFoundError("Kokoro TTS command did not produce an output file.") @@ -1250,9 +1251,39 @@ async def download_kokoro_models_if_missing(): @asynccontextmanager async def lifespan(app: FastAPI): logger.info("Application starting up...") - Base.metadata.create_all(bind=engine) + # Base.metadata.create_all(bind=engine) + + create_attempts = 3 + for attempt in range(1, create_attempts + 1): + try: + # use engine.begin() to ensure the DDL runs in a connection/transaction context + with engine.begin() as conn: + Base.metadata.create_all(bind=conn) + logger.info("Database tables ensured (create_all succeeded).") + break + except OperationalError as oe: + # Some SQLite drivers raise an OperationalError when two processes try to create the same table at once. + msg = str(oe).lower() + # If we see "already exists" we treat this as a race and retry briefly. + if "already exists" in msg or ("table" in msg and "already exists" in msg): + logger.warning( + "Database table creation race detected (attempt %d/%d): %s. Retrying...", + attempt, + create_attempts, + oe, + ) + time.sleep(0.5) + continue + else: + logger.exception("Database initialization failed with OperationalError.") + raise + except Exception: + logger.exception("Unexpected error during DB initialization.") + raise + + load_app_config() - + # Download required models on startup if shutil.which("kokoro-tts"): await download_kokoro_models_if_missing() @@ -1597,7 +1628,7 @@ def is_allowed_callback_url(url: str, allowed: List[str]) -> bool: @app.get("/api/v1/tts-voices") async def get_tts_voices_list(user: dict = Depends(require_user)): global AVAILABLE_TTS_VOICES_CACHE - + kokoro_available = shutil.which("kokoro-tts") is not None piper_available = PiperVoice is not None @@ -1626,7 +1657,7 @@ async def get_tts_voices_list(user: dict = Depends(require_user)): all_voices.append({ "id": f"kokoro/{lang}/{voice}", "name": f"Kokoro ({lang}): {voice}", - "local": False + "local": False }) AVAILABLE_TTS_VOICES_CACHE = sorted(all_voices, key=lambda x: x['name']) @@ -1747,7 +1778,7 @@ async def api_upload_chunk( webhook_config = APP_CONFIG.get("webhook_settings", {}) if not webhook_config.get("enabled", False) or not webhook_config.get("allow_chunked_api_uploads", False): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Chunked API uploads are disabled.") - + return await upload_chunk(chunk, upload_id, chunk_number, user) @app.post("/api/v1/upload/finalize", status_code=status.HTTP_202_ACCEPTED, tags=["Webhook API"]) @@ -1758,7 +1789,7 @@ async def api_finalize_upload( webhook_config = APP_CONFIG.get("webhook_settings", {}) if not webhook_config.get("enabled", False) or not webhook_config.get("allow_chunked_api_uploads", False): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Chunked API uploads are disabled.") - + # Validate callback URL if provided for a webhook job if payload.callback_url and not is_allowed_callback_url(payload.callback_url, webhook_config.get("allowed_callback_urls", [])): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Provided callback_url is not allowed.") @@ -1883,7 +1914,7 @@ async def save_settings( with tmp_path.open("w", encoding="utf8") as f: yaml.safe_dump(merged_config, f, default_flow_style=False, sort_keys=False) - + tmp_path.replace(PATHS.SETTINGS_FILE) logger.info(f"Admin '{user.get('email')}' updated settings.yml.") load_app_config() @@ -1974,4 +2005,4 @@ async def health(): @app.get('/favicon.ico', include_in_schema=False) async def favicon(): - return FileResponse(str(PATHS.BASE_DIR / 'static' / 'favicon.png')) \ No newline at end of file + return FileResponse(str(PATHS.BASE_DIR / 'static' / 'favicon.png'))