From f2d53a573f5a49370a579f706a02fd7665ae53e6 Mon Sep 17 00:00:00 2001 From: "Troll (Hermes Agent)" Date: Wed, 5 Aug 2026 22:29:01 +0000 Subject: [PATCH] Initial commit: image+audio to YouTube MP4 queue --- .env.example | 8 ++ .gitignore | 9 ++ Dockerfile | 17 +++ README.md | 68 ++++++++++++ VERSION | 1 + app.py | 208 +++++++++++++++++++++++++++++++++++++ config.py | 36 +++++++ docker-compose.yml | 25 +++++ init_db.py | 7 ++ models.py | 134 ++++++++++++++++++++++++ requirements.txt | 5 + templates/admin/login.html | 16 +++ templates/base.html | 115 ++++++++++++++++++++ templates/index.html | 99 ++++++++++++++++++ worker.py | 78 ++++++++++++++ 15 files changed, 826 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 VERSION create mode 100644 app.py create mode 100644 config.py create mode 100644 docker-compose.yml create mode 100644 init_db.py create mode 100644 models.py create mode 100644 requirements.txt create mode 100644 templates/admin/login.html create mode 100644 templates/base.html create mode 100644 templates/index.html create mode 100644 worker.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ca4b4c0 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +APP_SECRET_KEY= +ADMIN_PASSWORD= +PUBLIC_BASE_URL=http://localhost:5000 +INTERNAL_PORT=5000 +HOST_PORT=127.0.0.1:5000 +DATABASE=/app/data/app.db +UPLOAD_FOLDER=/app/uploads +OUTPUT_FOLDER=/app/outputs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87feb93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +venv/ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +.env.local +*.db +*.sqlite3 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2893b6a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +CMD ["gunicorn", "-b", "0.0.0.0:5000", "-w", "2", "--timeout", "300", "app:app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..60265ca --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# Dionysis Media Video + +**Version:** `v0.1.0` + +A small Flask web app that combines a still image with an audio file into a YouTube-ready MP4 video. + +## Features + +- Upload an image (PNG/JPG/WebP/etc.) and an audio file (MP3/WAV/etc.). +- Background worker queue processes jobs one at a time. +- Queue shows status: Waiting, Processing, Complete, or Error. +- Download completed videos from the queue. +- Click an Error status to see the traceback and retry (move to end of queue). +- Delete queued jobs unless they are currently processing. + +## Tech Stack + +- Python 3.12 + Flask + Gunicorn +- SQLite for the queue +- ffmpeg + Pillow for video encoding +- Docker / Portainer deployment + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `APP_SECRET_KEY` | Flask secret key | generated at startup | +| `ADMIN_PASSWORD` | Plain password for `/admin` | none (when unset, login is disabled and admin is open) | +| `PUBLIC_BASE_URL` | External URL for download links | `http://localhost:5000` | +| `INTERNAL_PORT` | Port Gunicorn listens on | `5000` | +| `DATABASE` | SQLite path | `/app/data/app.db` | +| `UPLOAD_FOLDER` | Raw uploads directory | `/app/uploads` | +| `OUTPUT_FOLDER` | Completed videos directory | `/app/outputs` | + +## Local Development + +```bash +cd ~/workspace/dionysis-media-video +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +python init_db.py +flask --app app run +``` + +Open http://localhost:5000. + +## Deployment + +Build and run with Docker: + +```bash +docker compose up --build -d +``` + +For Portainer, use the repository stack URL: + +``` +https://gitlab.hallsworth.ca/yrtria/dionysis-media-video.git +``` + +Branch: `main`, Compose path: `docker-compose.yml`. + +Then run once inside the container: + +```bash +python init_db.py +``` diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6c6aa7c --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..37f6c5b --- /dev/null +++ b/app.py @@ -0,0 +1,208 @@ +import os +import secrets +import threading +import time +from pathlib import Path + +from flask import ( + Flask, render_template, request, redirect, url_for, flash, + send_from_directory, jsonify, abort +) +from werkzeug.utils import secure_filename + +from config import Config +from models import JobStore +from worker import process_job + +app = Flask(__name__) +app.config.from_object(Config) +app.config['MAX_CONTENT_LENGTH'] = Config.MAX_UPLOAD_SIZE + +_cfg = Config() +store = JobStore(_cfg.DATABASE) + +_worker_thread = None +_worker_stop = threading.Event() + + +def _allowed_file(filename, extensions): + return '.' in filename and filename.rsplit('.', 1)[1].lower() in extensions + + +_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff'} +_AUDIO_EXTENSIONS = {'mp3', 'wav', 'flac', 'aac', 'm4a', 'ogg', 'wma'} + + +def _ensure_dirs(): + _cfg.UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True) + _cfg.OUTPUT_FOLDER.mkdir(parents=True, exist_ok=True) + + +_ensure_dirs() + + +def _worker_loop(): + while not _worker_stop.is_set(): + job = store.pop_next_waiting() + if job is None: + time.sleep(1) + continue + try: + process_job(job['id'], store, _cfg) + except Exception: + # error_message already stored; keep worker alive + pass + + +def start_worker(): + global _worker_thread + if _worker_thread is None or not _worker_thread.is_alive(): + _worker_stop.clear() + _worker_thread = threading.Thread(target=_worker_loop, daemon=True) + _worker_thread.start() + + +start_worker() + + +def _admin_required(): + if _cfg.ADMIN_PASSWORD: + if request.path.startswith('/admin') and request.path != url_for('admin_login'): + if request.form.get('password') == _cfg.ADMIN_PASSWORD: + return None + if request.args.get('password') == _cfg.ADMIN_PASSWORD: + return None + return abort(401) + return None + + +@app.before_request +def _before_request(): + # If admin password is set, protect the whole app except health check. + if _cfg.ADMIN_PASSWORD and request.path != '/health': + if request.path == url_for('admin_login'): + return None + if request.form.get('password') == _cfg.ADMIN_PASSWORD: + return None + if request.args.get('password') == _cfg.ADMIN_PASSWORD: + return None + return abort(401) + _admin_required() + + +@app.route('/') +def index(): + jobs = store.list_jobs() + return render_template('index.html', jobs=jobs, cfg=_cfg) + + +@app.route('/upload', methods=['POST']) +def upload(): + title = request.form.get('title', '').strip() or 'Untitled' + image = request.files.get('image') + audio = request.files.get('audio') + + if not image or not image.filename: + flash('Please select an image file.', 'error') + return redirect(url_for('index')) + if not audio or not audio.filename: + flash('Please select an audio file.', 'error') + return redirect(url_for('index')) + + image_ext = image.filename.rsplit('.', 1)[1].lower() + audio_ext = audio.filename.rsplit('.', 1)[1].lower() + if not _allowed_file(image.filename, _IMAGE_EXTENSIONS): + flash(f'Unsupported image format: {image_ext}', 'error') + return redirect(url_for('index')) + if not _allowed_file(audio.filename, _AUDIO_EXTENSIONS): + flash(f'Unsupported audio format: {audio_ext}', 'error') + return redirect(url_for('index')) + + _ensure_dirs() + safe_title = secure_filename(title).replace('.', '_') or 'untitled' + job_prefix = f"{int(time.time())}-{secrets.token_hex(4)}" + image_filename = f"{job_prefix}-img-{safe_title}.{image_ext}" + audio_filename = f"{job_prefix}-aud-{safe_title}.{audio_ext}" + + image.save(_cfg.UPLOAD_FOLDER / image_filename) + audio.save(_cfg.UPLOAD_FOLDER / audio_filename) + + job_id = store.create_job(title, image_filename, audio_filename) + flash(f'Job #{job_id} added to the queue.', 'success') + return redirect(url_for('index')) + + +@app.route('/delete/', methods=['POST']) +def delete_job(job_id): + job = store.get_job(job_id) + if job is None: + flash('Job not found.', 'error') + return redirect(url_for('index')) + if job['status'] == 'processing': + flash('Cannot delete a job that is currently processing.', 'error') + return redirect(url_for('index')) + + deleted = store.delete_job(job_id) + if deleted: + # Best-effort file cleanup + try: + (_cfg.UPLOAD_FOLDER / job['image_file']).unlink(missing_ok=True) + (_cfg.UPLOAD_FOLDER / job['audio_file']).unlink(missing_ok=True) + if job['output_file']: + (_cfg.OUTPUT_FOLDER / job['output_file']).unlink(missing_ok=True) + except Exception: + pass + flash(f'Job #{job_id} deleted.', 'success') + else: + flash('Job could not be deleted.', 'error') + return redirect(url_for('index')) + + +@app.route('/retry/', methods=['POST']) +def retry_job(job_id): + job = store.get_job(job_id) + if job is None: + flash('Job not found.', 'error') + return redirect(url_for('index')) + if job['status'] != 'error': + flash('Only error jobs can be retried.', 'error') + return redirect(url_for('index')) + + store.move_to_end(job_id) + flash(f'Job #{job_id} moved to the end of the queue for retry.', 'success') + return redirect(url_for('index')) + + +@app.route('/download/') +def download(job_id): + job = store.get_job(job_id) + if job is None or job['status'] != 'complete' or not job['output_file']: + abort(404) + return send_from_directory(_cfg.OUTPUT_FOLDER, job['output_file'], as_attachment=True) + + +@app.route('/admin/login', methods=['GET', 'POST']) +def admin_login(): + if not _cfg.ADMIN_PASSWORD: + return redirect(url_for('index')) + if request.method == 'POST': + if request.form.get('password') == _cfg.ADMIN_PASSWORD: + return redirect(url_for('index', password=request.form.get('password'))) + flash('Incorrect password.', 'error') + return render_template('admin/login.html') + + +@app.route('/health') +def health(): + return jsonify({ + 'ok': True, + 'version': _cfg.VERSION, + 'queue': { + row['status']: sum(1 for r in store.list_jobs() if r['status'] == row['status']) + for row in store.list_jobs() + } or {} + }) + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=_cfg.INTERNAL_PORT, debug=True) diff --git a/config.py b/config.py new file mode 100644 index 0000000..99a7bb2 --- /dev/null +++ b/config.py @@ -0,0 +1,36 @@ +import os +import secrets +from pathlib import Path + +BASE_DIR = Path(__file__).parent.resolve() + + +def env_default(name, default=None): + return os.environ.get(name, default) + + +class Config: + VERSION = (BASE_DIR / 'VERSION').read_text().strip() + SECRET_KEY = env_default('APP_SECRET_KEY', secrets.token_hex(32)) + + ADMIN_PASSWORD = env_default('ADMIN_PASSWORD') + + PUBLIC_BASE_URL = env_default('PUBLIC_BASE_URL', 'http://localhost:5000').rstrip('/') + INTERNAL_PORT = int(env_default('INTERNAL_PORT', '5000')) + + DATABASE = Path(env_default('DATABASE', str(BASE_DIR / 'data' / 'app.db'))) + UPLOAD_FOLDER = Path(env_default('UPLOAD_FOLDER', str(BASE_DIR / 'uploads'))) + OUTPUT_FOLDER = Path(env_default('OUTPUT_FOLDER', str(BASE_DIR / 'outputs'))) + + MAX_UPLOAD_SIZE = 500 * 1024 * 1024 # 500 MB + + VIDEO_WIDTH = 1920 + VIDEO_HEIGHT = 1080 + VIDEO_FPS = 30 + VIDEO_BITRATE = '5000k' + AUDIO_BITRATE = '192k' + VIDEO_CODEC = 'libx264' + AUDIO_CODEC = 'aac' + OUTPUT_FORMAT = 'mp4' + + YOUTUBE_PRESET = True # use faststart + yuv420p diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..447415a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + app: + build: + context: https://gitlab.hallsworth.ca/yrtria/dionysis-media-video.git#main + container_name: dionysis-media-video + restart: unless-stopped + environment: + - APP_SECRET_KEY=${APP_SECRET_KEY} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + - PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:5000} + - INTERNAL_PORT=${INTERNAL_PORT:-5000} + - DATABASE=${DATABASE:-/app/data/app.db} + - UPLOAD_FOLDER=${UPLOAD_FOLDER:-/app/uploads} + - OUTPUT_FOLDER=${OUTPUT_FOLDER:-/app/outputs} + ports: + - "${HOST_PORT:-127.0.0.1:5000}:${INTERNAL_PORT:-5000}" + volumes: + - dmv-data:/app/data + - dmv-uploads:/app/uploads + - dmv-outputs:/app/outputs + +volumes: + dmv-data: + dmv-uploads: + dmv-outputs: diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..2a42227 --- /dev/null +++ b/init_db.py @@ -0,0 +1,7 @@ +from config import Config +from models import JobStore + +if __name__ == '__main__': + cfg = Config() + store = JobStore(cfg.DATABASE) + print(f"Database ready: {cfg.DATABASE}") diff --git a/models.py b/models.py new file mode 100644 index 0000000..ed31e6d --- /dev/null +++ b/models.py @@ -0,0 +1,134 @@ +import sqlite3 +import threading +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT, + image_file TEXT NOT NULL, + audio_file TEXT NOT NULL, + output_file TEXT, + status TEXT DEFAULT 'waiting' NOT NULL, + error_message TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, + position INTEGER DEFAULT 0 NOT NULL +); +""" + +COLUMNS = { + 'id': 'INTEGER', + 'title': 'TEXT', + 'image_file': 'TEXT', + 'audio_file': 'TEXT', + 'output_file': 'TEXT', + 'status': 'TEXT', + 'error_message': 'TEXT', + 'created_at': 'TEXT', + 'updated_at': 'TEXT', + 'position': 'INTEGER', +} + +_lock = threading.Lock() + + +def _connect(db_path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(str(db_path), check_same_thread=False) + conn.row_factory = sqlite3.Row + return conn + + +class JobStore: + def __init__(self, db_path: Path): + self.db_path = Path(db_path) + self.local = threading.local() + self.init_db() + + def _conn(self) -> sqlite3.Connection: + if not hasattr(self.local, 'conn') or self.local.conn is None: + self.local.conn = _connect(self.db_path) + return self.local.conn + + def init_db(self): + with _lock: + self.db_path.parent.mkdir(parents=True, exist_ok=True) + conn = self._conn() + conn.executescript(SCHEMA) + conn.commit() + self._migrate_columns() + + def _migrate_columns(self): + conn = self._conn() + existing = { + row['name']: row['type'] + for row in conn.execute("PRAGMA table_info(jobs)") + } + for col, col_type in COLUMNS.items(): + if col not in existing: + conn.execute(f"ALTER TABLE jobs ADD COLUMN {col} {col_type}") + conn.commit() + + def create_job(self, title: str, image_file: str, audio_file: str) -> int: + conn = self._conn() + max_pos = conn.execute("SELECT COALESCE(MAX(position), 0) FROM jobs").fetchone()[0] + cur = conn.execute( + """INSERT INTO jobs (title, image_file, audio_file, status, position) + VALUES (?, ?, ?, 'waiting', ?)""", + (title, image_file, audio_file, int(max_pos) + 1) + ) + conn.commit() + return cur.lastrowid + + def list_jobs(self) -> list[sqlite3.Row]: + conn = self._conn() + return conn.execute( + "SELECT * FROM jobs ORDER BY position ASC, id ASC" + ).fetchall() + + def get_job(self, job_id: int) -> sqlite3.Row | None: + conn = self._conn() + row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone() + return row + + def update_status(self, job_id: int, status: str, error_message: str | None = None, output_file: str | None = None): + conn = self._conn() + conn.execute( + """UPDATE jobs + SET status = ?, error_message = ?, output_file = ?, updated_at = ? + WHERE id = ?""", + (status, error_message, output_file, datetime.utcnow().isoformat(), job_id) + ) + conn.commit() + + def delete_job(self, job_id: int) -> bool: + conn = self._conn() + cur = conn.execute("DELETE FROM jobs WHERE id = ? AND status != 'processing'", (job_id,)) + conn.commit() + return cur.rowcount > 0 + + def move_to_end(self, job_id: int) -> bool: + conn = self._conn() + max_pos = conn.execute("SELECT COALESCE(MAX(position), 0) FROM jobs").fetchone()[0] + cur = conn.execute( + "UPDATE jobs SET status = 'waiting', error_message = NULL, position = ? WHERE id = ?", + (int(max_pos) + 1, job_id) + ) + conn.commit() + return cur.rowcount > 0 + + def pop_next_waiting(self) -> sqlite3.Row | None: + conn = self._conn() + with _lock: + row = conn.execute( + "SELECT * FROM jobs WHERE status = 'waiting' ORDER BY position ASC, id ASC LIMIT 1" + ).fetchone() + if row: + conn.execute( + "UPDATE jobs SET status = 'processing', updated_at = ? WHERE id = ?", + (datetime.utcnow().isoformat(), row['id']) + ) + conn.commit() + return row diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..53a1c2e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +flask>=3.0 +gunicorn>=22.0 +Pillow>=10.0 +python-dotenv>=1.0 +Werkzeug>=3.0 diff --git a/templates/admin/login.html b/templates/admin/login.html new file mode 100644 index 0000000..9485f5b --- /dev/null +++ b/templates/admin/login.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block title %}Admin Login — Dionysis Media Video{% endblock %} + +{% block content %} +
+

Admin Login

+
+ + +
+ +
+
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..3d9d32a --- /dev/null +++ b/templates/base.html @@ -0,0 +1,115 @@ + + + + + + {% block title %}Dionysis Media Video{% endblock %} + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..df7045b --- /dev/null +++ b/templates/index.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} + +{% block title %}Queue — Dionysis Media Video{% endblock %} + +{% block content %} +
+

Dionysis Media Video

+

Combine an image and an audio file into a YouTube-ready 1080p MP4 video. Uploads are queued and processed in order.

+
+ +
+

New Job

+
+ + + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ {% if cfg.ADMIN_PASSWORD %} +

This app is password protected. Add ?password=... to URLs or use /admin/login.

+ {% endif %}
+ +
+

Queue

+ {% if jobs %} + + + + + + + + + + + + + {% for job in jobs %} + + + + + + + + + {% endfor %} + +
#IDTitleStatusCreatedUpdatedActions
#{{ job.id }}{{ job.title }} + {% if job.status == 'error' %} + {{ job.status }} + + {% else %} + {{ job.status }} + {% endif %} + {{ job.created_at }}{{ job.updated_at }} + {% if job.status == 'complete' %} + Download MP4 + {% endif %} + {% if job.status == 'error' %} +
+ +
+ {% endif %} + {% if job.status != 'processing' %} +
+ +
+ {% endif %} +
+ {% else %} +
No jobs yet. Upload an image and audio file above.
+ {% endif %} +
+ + + + +{% endblock %} diff --git a/worker.py b/worker.py new file mode 100644 index 0000000..8d2e4ea --- /dev/null +++ b/worker.py @@ -0,0 +1,78 @@ +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +from PIL import Image + +from config import Config +from models import JobStore + + +def _ffmpeg_available() -> bool: + return shutil.which('ffmpeg') is not None + + +def _prepare_image(image_path: Path, output_path: Path, width: int, height: int) -> None: + img = Image.open(image_path) + img = img.convert('RGB') + + # Fit inside target box, then letterbox/pillarbox with black to exact 16:9 + img.thumbnail((width, height), Image.LANCZOS) + + canvas = Image.new('RGB', (width, height), (0, 0, 0)) + x = (width - img.width) // 2 + y = (height - img.height) // 2 + canvas.paste(img, (x, y)) + canvas.save(output_path, 'JPEG', quality=95) + + +def _build_video(image_path: Path, audio_path: Path, output_path: Path, cfg: Config) -> None: + if not _ffmpeg_available(): + raise RuntimeError('ffmpeg is not installed or not on PATH') + + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + prepared_image = tmp / 'prepared.jpg' + _prepare_image(image_path, prepared_image, cfg.VIDEO_WIDTH, cfg.VIDEO_HEIGHT) + + cmd = [ + 'ffmpeg', + '-y', + '-loop', '1', + '-framerate', str(cfg.VIDEO_FPS), + '-i', str(prepared_image), + '-i', str(audio_path), + '-c:v', cfg.VIDEO_CODEC, + '-pix_fmt', 'yuv420p', + '-preset', 'medium', + '-b:v', cfg.VIDEO_BITRATE, + '-c:a', cfg.AUDIO_CODEC, + '-b:a', cfg.AUDIO_BITRATE, + '-movflags', '+faststart', + '-shortest', + str(output_path), + ] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f'ffmpeg failed: {result.stderr}') + + +def process_job(job_id: int, store: JobStore, cfg: Config) -> None: + job = store.get_job(job_id) + if job is None: + return + + image_path = cfg.UPLOAD_FOLDER / job['image_file'] + audio_path = cfg.UPLOAD_FOLDER / job['audio_file'] + output_filename = f"job-{job_id}.{cfg.OUTPUT_FORMAT}" + output_path = cfg.OUTPUT_FOLDER / output_filename + + try: + cfg.OUTPUT_FOLDER.mkdir(parents=True, exist_ok=True) + _build_video(image_path, audio_path, output_path, cfg) + store.update_status(job_id, 'complete', output_file=output_filename) + except Exception as exc: + store.update_status(job_id, 'error', error_message=str(exc)) + raise