Refactor Nginx configuration and update project dependencies

- Cleaned up the Nginx configuration for the Coolify deployment, ensuring proper formatting and organization.
- Updated the `pyproject.toml` file to maintain consistent formatting.
- Reformatted the `requirements.txt` file for better readability while keeping the dependencies unchanged.
This commit is contained in:
zwitschi committed 2026-09-03 15:05:27 +02:00
1 parent 3b8c30c289
commit a4b874598d
88 files changed
+9766 -9766

No files matched your search

+127 -127
View File
@@ -1,127 +1,127 @@
"""Authentication service: password hashing, JWT creation/verification, token management."""
import os
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from ..db import get_conn, get_write_lock
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ACCESS_TOKEN_EXPIRE_MINUTES = 15
REFRESH_TOKEN_EXPIRE_DAYS = 7
ALGORITHM = "HS256"
def _secret() -> str:
secret = os.getenv("JWT_SECRET")
if not secret:
raise RuntimeError("JWT_SECRET environment variable is not set.")
return secret
# --- Password ---
def hash_password(plain: str) -> str:
return _pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return _pwd_context.verify(plain, hashed)
# --- Tokens ---
def create_access_token(user_id: str, email: str, role: str) -> str:
expire = datetime.now(timezone.utc) + \
timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {
"sub": user_id,
"email": email,
"role": role,
"exp": expire,
"type": "access",
}
return jwt.encode(payload, _secret(), algorithm=ALGORITHM)
def create_refresh_token(user_id: str, jti: str) -> str:
expire = datetime.now(timezone.utc) + \
timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
payload = {
"sub": user_id,
"jti": jti,
"exp": expire,
"type": "refresh",
}
return jwt.encode(payload, _secret(), algorithm=ALGORITHM)
def decode_token(token: str) -> dict[str, Any]:
"""Decode and validate a JWT. Raises JWTError on failure."""
return jwt.decode(token, _secret(), algorithms=[ALGORITHM])
# --- Database operations ---
async def register_user(email: str, password: str) -> dict[str, Any]:
"""Insert a new user. Returns the created user row."""
conn = get_conn()
lock = get_write_lock()
sql_check = "SELECT id FROM users WHERE email = ?"
sql_insert = "INSERT INTO users (email, password_hash) VALUES (?, ?)"
sql_fetch = "SELECT id, email, role FROM users WHERE email = ?"
async with lock:
existing = conn.execute(sql_check, [email]).fetchone()
if existing:
raise ValueError("Email already registered.")
conn.execute(sql_insert, [email, hash_password(password)],)
row = conn.execute(sql_fetch, [email]).fetchone()
if row is None:
raise RuntimeError("Failed to fetch user after registration.")
return {"id": str(row[0]), "email": row[1], "role": row[2]}
async def authenticate_user(email: str, password: str) -> dict[str, Any] | None:
"""Return user dict if credentials are valid, else None."""
conn = get_conn()
sql_fetch = "SELECT id, email, password_hash, role FROM users WHERE email = ?"
row = conn.execute(sql_fetch, [email]).fetchone()
if row is None or not verify_password(password, row[2]):
return None
return {"id": str(row[0]), "email": row[1], "role": row[3]}
async def store_refresh_token(user_id: str, jti: str) -> None:
"""Persist a refresh token JTI in the database."""
conn = get_conn()
lock = get_write_lock()
sql_insert = "INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)"
from datetime import timedelta
expires_at = datetime.now(timezone.utc) + \
timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
async with lock:
conn.execute(sql_insert, [jti, user_id, expires_at])
async def revoke_refresh_token(jti: str) -> None:
"""Mark a refresh token as revoked."""
conn = get_conn()
lock = get_write_lock()
sql_update = "UPDATE refresh_tokens SET revoked = true WHERE jti = ?"
async with lock:
conn.execute(sql_update, [jti])
async def validate_refresh_token_jti(jti: str, user_id: str) -> bool:
"""Return True if the JTI exists, is not revoked, and belongs to user_id."""
conn = get_conn()
now = datetime.now(timezone.utc)
sql_select = """
SELECT 1 FROM refresh_tokens
WHERE jti = ? AND user_id = ? AND revoked = false AND expires_at > ?
"""
row = conn.execute(sql_select, [jti, user_id, now]).fetchone()
return row is not None
"""Authentication service: password hashing, JWT creation/verification, token management."""
import os
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from ..db import get_conn, get_write_lock
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ACCESS_TOKEN_EXPIRE_MINUTES = 15
REFRESH_TOKEN_EXPIRE_DAYS = 7
ALGORITHM = "HS256"
def _secret() -> str:
secret = os.getenv("JWT_SECRET")
if not secret:
raise RuntimeError("JWT_SECRET environment variable is not set.")
return secret
# --- Password ---
def hash_password(plain: str) -> str:
return _pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return _pwd_context.verify(plain, hashed)
# --- Tokens ---
def create_access_token(user_id: str, email: str, role: str) -> str:
expire = datetime.now(timezone.utc) + \
timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {
"sub": user_id,
"email": email,
"role": role,
"exp": expire,
"type": "access",
}
return jwt.encode(payload, _secret(), algorithm=ALGORITHM)
def create_refresh_token(user_id: str, jti: str) -> str:
expire = datetime.now(timezone.utc) + \
timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
payload = {
"sub": user_id,
"jti": jti,
"exp": expire,
"type": "refresh",
}
return jwt.encode(payload, _secret(), algorithm=ALGORITHM)
def decode_token(token: str) -> dict[str, Any]:
"""Decode and validate a JWT. Raises JWTError on failure."""
return jwt.decode(token, _secret(), algorithms=[ALGORITHM])
# --- Database operations ---
async def register_user(email: str, password: str) -> dict[str, Any]:
"""Insert a new user. Returns the created user row."""
conn = get_conn()
lock = get_write_lock()
sql_check = "SELECT id FROM users WHERE email = ?"
sql_insert = "INSERT INTO users (email, password_hash) VALUES (?, ?)"
sql_fetch = "SELECT id, email, role FROM users WHERE email = ?"
async with lock:
existing = conn.execute(sql_check, [email]).fetchone()
if existing:
raise ValueError("Email already registered.")
conn.execute(sql_insert, [email, hash_password(password)],)
row = conn.execute(sql_fetch, [email]).fetchone()
if row is None:
raise RuntimeError("Failed to fetch user after registration.")
return {"id": str(row[0]), "email": row[1], "role": row[2]}
async def authenticate_user(email: str, password: str) -> dict[str, Any] | None:
"""Return user dict if credentials are valid, else None."""
conn = get_conn()
sql_fetch = "SELECT id, email, password_hash, role FROM users WHERE email = ?"
row = conn.execute(sql_fetch, [email]).fetchone()
if row is None or not verify_password(password, row[2]):
return None
return {"id": str(row[0]), "email": row[1], "role": row[3]}
async def store_refresh_token(user_id: str, jti: str) -> None:
"""Persist a refresh token JTI in the database."""
conn = get_conn()
lock = get_write_lock()
sql_insert = "INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)"
from datetime import timedelta
expires_at = datetime.now(timezone.utc) + \
timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
async with lock:
conn.execute(sql_insert, [jti, user_id, expires_at])
async def revoke_refresh_token(jti: str) -> None:
"""Mark a refresh token as revoked."""
conn = get_conn()
lock = get_write_lock()
sql_update = "UPDATE refresh_tokens SET revoked = true WHERE jti = ?"
async with lock:
conn.execute(sql_update, [jti])
async def validate_refresh_token_jti(jti: str, user_id: str) -> bool:
"""Return True if the JTI exists, is not revoked, and belongs to user_id."""
conn = get_conn()
now = datetime.now(timezone.utc)
sql_select = """
SELECT 1 FROM refresh_tokens
WHERE jti = ? AND user_id = ? AND revoked = false AND expires_at > ?
"""
row = conn.execute(sql_select, [jti, user_id, now]).fetchone()
return row is not None
+246 -246
View File
@@ -1,246 +1,246 @@
"""Model cache service: fetch from OpenRouter, store in DuckDB."""
import json
from datetime import datetime, timedelta, timezone
from typing import Any
import duckdb
from . import openrouter
CACHE_TTL_HOURS = 24
def _normalize_modality(raw: str) -> str:
"""Normalize OpenRouter modality labels to canonical values."""
value = (raw or "").strip().lower()
if value in {"text", "image", "video", "audio", "embeddings", "embedding"}:
return "embeddings" if value == "embedding" else value
if "image" in value:
return "image"
if "video" in value:
return "video"
if "audio" in value:
return "audio"
if "embed" in value:
return "embeddings"
return "text"
def _parse_modality(raw_modality: str) -> str:
"""Extract output modality from OpenRouter architecture.modality string.
Examples: "text->text", "text+image->text", "text->image", "text->video"
"""
output = raw_modality.split(
"->", 1)[-1] if "->" in raw_modality else raw_modality
return _normalize_modality(output)
def _extract_output_modality(model: dict[str, Any]) -> str:
"""Extract output modality using OpenRouter schema, fallback to legacy field."""
architecture = model.get("architecture") or {}
output_modalities = architecture.get(
"output_modalities") or model.get("output_modalities")
if isinstance(output_modalities, list) and output_modalities:
return _normalize_modality(str(output_modalities[0]))
raw_modality = architecture.get(
"modality") or model.get("modality") or "text->text"
if isinstance(raw_modality, str):
return _parse_modality(raw_modality)
return "text"
async def _fetch_models_for_cache() -> list[dict[str, Any]]:
"""Fetch broad + modality-specific lists and merge unique models by id."""
by_id: dict[str, dict[str, Any]] = {}
# Primary fetch: all modalities (per OpenRouter docs).
primary = await openrouter.list_models(output_modalities="all")
for model in primary:
model_id = model.get("id")
if model_id:
by_id[model_id] = model
# Warmup fetches: some providers surface better results with explicit modality filter.
for modality in ("image", "video", "audio", "embeddings", "text"):
try:
subset = await openrouter.list_models(output_modalities=modality)
except Exception:
continue
for model in subset:
model_id = model.get("id")
if model_id and model_id not in by_id:
by_id[model_id] = model
return list(by_id.values())
async def refresh_models_cache(conn: duckdb.DuckDBPyConnection) -> int:
"""Fetch all models from OpenRouter and replace the cache. Returns count stored."""
raw = await _fetch_models_for_cache()
# Use naive UTC to avoid DuckDB TIMESTAMP tz-stripping inconsistencies
now = datetime.now(timezone.utc).replace(tzinfo=None)
conn.execute("DELETE FROM models_cache")
count = 0
for m in raw:
modality = _extract_output_modality(m)
pricing = m.get("pricing")
model_id = m.get("id", "")
if not model_id:
continue
# Full output_modalities array from architecture (for proper modalities param in image gen)
architecture = m.get("architecture") or {}
raw_output_modalities: list | None = (
architecture.get("output_modalities") or m.get("output_modalities")
)
output_modalities_json: str | None = (
json.dumps([_normalize_modality(str(v))
for v in raw_output_modalities])
if isinstance(raw_output_modalities, list)
else None
)
conn.execute(
"""
INSERT INTO models_cache (model_id, name, modality, context_length, pricing, fetched_at, output_modalities)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (model_id) DO UPDATE SET
name = excluded.name,
modality = excluded.modality,
context_length = excluded.context_length,
pricing = excluded.pricing,
fetched_at = excluded.fetched_at,
output_modalities = excluded.output_modalities
""",
[
model_id,
m.get("name", model_id),
modality,
m.get("context_length"),
json.dumps(pricing) if pricing else None,
now,
output_modalities_json,
],
)
count += 1
return count
def is_cache_stale(conn: duckdb.DuckDBPyConnection) -> bool:
"""Return True if cache is empty or last fetched more than CACHE_TTL_HOURS ago."""
row = conn.execute("SELECT MAX(fetched_at) FROM models_cache").fetchone()
if not row or row[0] is None:
return True
last_fetched = row[0]
# DuckDB TIMESTAMP is always naive; compare against naive UTC
if last_fetched.tzinfo is not None:
last_fetched = last_fetched.replace(tzinfo=None)
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
return now_naive - last_fetched > timedelta(hours=CACHE_TTL_HOURS)
def get_cached_models(
conn: duckdb.DuckDBPyConnection,
modality: str | None = None,
) -> list[dict[str, Any]]:
"""Return cached models, optionally filtered by modality, ordered by name."""
if modality:
rows = conn.execute(
"""
SELECT model_id, name, modality, context_length, pricing
FROM models_cache
WHERE modality = ?
ORDER BY name
""",
[modality],
).fetchall()
else:
rows = conn.execute(
"""
SELECT model_id, name, modality, context_length, pricing
FROM models_cache
ORDER BY name
"""
).fetchall()
result = []
for row in rows:
pricing = None
if row[4]:
try:
pricing = json.loads(row[4])
except (json.JSONDecodeError, TypeError):
pricing = None
result.append({
"id": row[0],
"name": row[1],
"modality": row[2],
"context_length": row[3],
"pricing": pricing,
})
return result
def get_model_output_modalities(
conn: duckdb.DuckDBPyConnection,
model_id: str,
) -> list[str]:
"""Return output_modalities list for a model; empty list if not found."""
row = conn.execute(
"SELECT output_modalities FROM models_cache WHERE model_id = ?",
[model_id],
).fetchone()
if not row or not row[0]:
return []
try:
return json.loads(row[0])
except (json.JSONDecodeError, TypeError):
return []
def get_cache_status(conn: duckdb.DuckDBPyConnection) -> dict[str, Any]:
"""Return cache last update time and model count."""
row = conn.execute(
"SELECT MAX(fetched_at), COUNT(*) FROM models_cache"
).fetchone()
last_updated, model_count = (row[0], row[1]) if row else (None, 0)
return {"last_updated": last_updated, "model_count": model_count}
def mark_timed_out_video_jobs(conn: duckdb.DuckDBPyConnection, timeout_minutes: int = 120) -> int:
"""Mark video jobs that have been in 'queued' or 'processing' status for too long as 'failed'.
Returns the number of jobs marked as timed out.
"""
timeout_threshold = datetime.now(
timezone.utc) - timedelta(minutes=timeout_minutes)
# Find timed out jobs
timed_out_rows = conn.execute(
"""
SELECT id FROM generated_videos
WHERE status IN ('queued', 'processing')
AND updated_at < ?
""",
[timeout_threshold]
).fetchall()
if not timed_out_rows:
return 0
job_ids = [row[0] for row in timed_out_rows]
placeholders = ",".join(["?"] * len(job_ids))
# Update them to failed
conn.execute(
f"""
UPDATE generated_videos
SET status = 'failed', updated_at = ?
WHERE id IN ({placeholders})
""",
[datetime.now(timezone.utc)] + job_ids
)
return len(job_ids)
"""Model cache service: fetch from OpenRouter, store in DuckDB."""
import json
from datetime import datetime, timedelta, timezone
from typing import Any
import duckdb
from . import openrouter
CACHE_TTL_HOURS = 24
def _normalize_modality(raw: str) -> str:
"""Normalize OpenRouter modality labels to canonical values."""
value = (raw or "").strip().lower()
if value in {"text", "image", "video", "audio", "embeddings", "embedding"}:
return "embeddings" if value == "embedding" else value
if "image" in value:
return "image"
if "video" in value:
return "video"
if "audio" in value:
return "audio"
if "embed" in value:
return "embeddings"
return "text"
def _parse_modality(raw_modality: str) -> str:
"""Extract output modality from OpenRouter architecture.modality string.
Examples: "text->text", "text+image->text", "text->image", "text->video"
"""
output = raw_modality.split(
"->", 1)[-1] if "->" in raw_modality else raw_modality
return _normalize_modality(output)
def _extract_output_modality(model: dict[str, Any]) -> str:
"""Extract output modality using OpenRouter schema, fallback to legacy field."""
architecture = model.get("architecture") or {}
output_modalities = architecture.get(
"output_modalities") or model.get("output_modalities")
if isinstance(output_modalities, list) and output_modalities:
return _normalize_modality(str(output_modalities[0]))
raw_modality = architecture.get(
"modality") or model.get("modality") or "text->text"
if isinstance(raw_modality, str):
return _parse_modality(raw_modality)
return "text"
async def _fetch_models_for_cache() -> list[dict[str, Any]]:
"""Fetch broad + modality-specific lists and merge unique models by id."""
by_id: dict[str, dict[str, Any]] = {}
# Primary fetch: all modalities (per OpenRouter docs).
primary = await openrouter.list_models(output_modalities="all")
for model in primary:
model_id = model.get("id")
if model_id:
by_id[model_id] = model
# Warmup fetches: some providers surface better results with explicit modality filter.
for modality in ("image", "video", "audio", "embeddings", "text"):
try:
subset = await openrouter.list_models(output_modalities=modality)
except Exception:
continue
for model in subset:
model_id = model.get("id")
if model_id and model_id not in by_id:
by_id[model_id] = model
return list(by_id.values())
async def refresh_models_cache(conn: duckdb.DuckDBPyConnection) -> int:
"""Fetch all models from OpenRouter and replace the cache. Returns count stored."""
raw = await _fetch_models_for_cache()
# Use naive UTC to avoid DuckDB TIMESTAMP tz-stripping inconsistencies
now = datetime.now(timezone.utc).replace(tzinfo=None)
conn.execute("DELETE FROM models_cache")
count = 0
for m in raw:
modality = _extract_output_modality(m)
pricing = m.get("pricing")
model_id = m.get("id", "")
if not model_id:
continue
# Full output_modalities array from architecture (for proper modalities param in image gen)
architecture = m.get("architecture") or {}
raw_output_modalities: list | None = (
architecture.get("output_modalities") or m.get("output_modalities")
)
output_modalities_json: str | None = (
json.dumps([_normalize_modality(str(v))
for v in raw_output_modalities])
if isinstance(raw_output_modalities, list)
else None
)
conn.execute(
"""
INSERT INTO models_cache (model_id, name, modality, context_length, pricing, fetched_at, output_modalities)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (model_id) DO UPDATE SET
name = excluded.name,
modality = excluded.modality,
context_length = excluded.context_length,
pricing = excluded.pricing,
fetched_at = excluded.fetched_at,
output_modalities = excluded.output_modalities
""",
[
model_id,
m.get("name", model_id),
modality,
m.get("context_length"),
json.dumps(pricing) if pricing else None,
now,
output_modalities_json,
],
)
count += 1
return count
def is_cache_stale(conn: duckdb.DuckDBPyConnection) -> bool:
"""Return True if cache is empty or last fetched more than CACHE_TTL_HOURS ago."""
row = conn.execute("SELECT MAX(fetched_at) FROM models_cache").fetchone()
if not row or row[0] is None:
return True
last_fetched = row[0]
# DuckDB TIMESTAMP is always naive; compare against naive UTC
if last_fetched.tzinfo is not None:
last_fetched = last_fetched.replace(tzinfo=None)
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
return now_naive - last_fetched > timedelta(hours=CACHE_TTL_HOURS)
def get_cached_models(
conn: duckdb.DuckDBPyConnection,
modality: str | None = None,
) -> list[dict[str, Any]]:
"""Return cached models, optionally filtered by modality, ordered by name."""
if modality:
rows = conn.execute(
"""
SELECT model_id, name, modality, context_length, pricing
FROM models_cache
WHERE modality = ?
ORDER BY name
""",
[modality],
).fetchall()
else:
rows = conn.execute(
"""
SELECT model_id, name, modality, context_length, pricing
FROM models_cache
ORDER BY name
"""
).fetchall()
result = []
for row in rows:
pricing = None
if row[4]:
try:
pricing = json.loads(row[4])
except (json.JSONDecodeError, TypeError):
pricing = None
result.append({
"id": row[0],
"name": row[1],
"modality": row[2],
"context_length": row[3],
"pricing": pricing,
})
return result
def get_model_output_modalities(
conn: duckdb.DuckDBPyConnection,
model_id: str,
) -> list[str]:
"""Return output_modalities list for a model; empty list if not found."""
row = conn.execute(
"SELECT output_modalities FROM models_cache WHERE model_id = ?",
[model_id],
).fetchone()
if not row or not row[0]:
return []
try:
return json.loads(row[0])
except (json.JSONDecodeError, TypeError):
return []
def get_cache_status(conn: duckdb.DuckDBPyConnection) -> dict[str, Any]:
"""Return cache last update time and model count."""
row = conn.execute(
"SELECT MAX(fetched_at), COUNT(*) FROM models_cache"
).fetchone()
last_updated, model_count = (row[0], row[1]) if row else (None, 0)
return {"last_updated": last_updated, "model_count": model_count}
def mark_timed_out_video_jobs(conn: duckdb.DuckDBPyConnection, timeout_minutes: int = 120) -> int:
"""Mark video jobs that have been in 'queued' or 'processing' status for too long as 'failed'.
Returns the number of jobs marked as timed out.
"""
timeout_threshold = datetime.now(
timezone.utc) - timedelta(minutes=timeout_minutes)
# Find timed out jobs
timed_out_rows = conn.execute(
"""
SELECT id FROM generated_videos
WHERE status IN ('queued', 'processing')
AND updated_at < ?
""",
[timeout_threshold]
).fetchall()
if not timed_out_rows:
return 0
job_ids = [row[0] for row in timed_out_rows]
placeholders = ",".join(["?"] * len(job_ids))
# Update them to failed
conn.execute(
f"""
UPDATE generated_videos
SET status = 'failed', updated_at = ?
WHERE id IN ({placeholders})
""",
[datetime.now(timezone.utc)] + job_ids
)
return len(job_ids)
+213 -213
View File
@@ -1,213 +1,213 @@
"""OpenRouter API client (OpenAI-compatible interface)."""
import os
from typing import Any
import httpx
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
def _api_key() -> str:
key = os.getenv("OPENROUTER_API_KEY")
if not key:
raise RuntimeError(
"OPENROUTER_API_KEY environment variable is not set.")
return key
def _headers() -> dict[str, str]:
return {
"Authorization": f"Bearer {_api_key()}",
"Content-Type": "application/json",
"HTTP-Referer": os.getenv("APP_URL", "https://ai.allucanget.biz"),
"X-Title": os.getenv("APP_NAME", "All You Can GET AI"),
}
async def list_models(
output_modalities: str = "all",
category: str | None = None,
supported_parameters: str | None = None,
) -> list[dict[str, Any]]:
"""Return available models from OpenRouter.
Docs: GET /models supports query filters like output_modalities.
"""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
params: dict[str, str] = {"output_modalities": output_modalities}
if category:
params["category"] = category
if supported_parameters:
params["supported_parameters"] = supported_parameters
async with httpx.AsyncClient(timeout=15) as client:
resp = client.build_request(
"GET", f"{base_url}/models", headers=_headers(), params=params)
response = await client.send(resp)
response.raise_for_status()
return response.json().get("data", [])
async def chat_completion(
model: str,
messages: list[dict[str, str]],
temperature: float = 0.7,
max_tokens: int = 1024,
) -> dict[str, Any]:
"""Send a chat completion request to OpenRouter."""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
async with httpx.AsyncClient(timeout=60) as client:
resp = client.build_request(
"POST", f"{base_url}/chat/completions", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def generate_image(
model: str,
prompt: str,
n: int = 1,
size: str = "1024x1024",
) -> dict[str, Any]:
"""Request image generation via OpenRouter /images/generations."""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
payload = {"model": model, "prompt": prompt, "n": n, "size": size}
async with httpx.AsyncClient(timeout=120) as client:
resp = client.build_request(
"POST", f"{base_url}/images/generations", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def generate_video(
model: str,
prompt: str,
duration_seconds: int | None = None,
aspect_ratio: str = "16:9",
resolution: str | None = None,
generate_audio: bool | None = None,
) -> dict[str, Any]:
"""Request text-to-video generation via OpenRouter POST /videos."""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
payload: dict[str, Any] = {
"model": model,
"prompt": prompt,
"aspect_ratio": aspect_ratio,
}
if duration_seconds is not None:
# API uses 'duration' not 'duration_seconds'
payload["duration"] = duration_seconds
if resolution is not None:
payload["resolution"] = resolution
if generate_audio is not None:
payload["generate_audio"] = generate_audio
async with httpx.AsyncClient(timeout=120) as client:
resp = client.build_request(
"POST", f"{base_url}/videos", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def generate_video_from_image(
model: str,
image_url: str,
prompt: str,
duration_seconds: int | None = None,
aspect_ratio: str = "16:9",
resolution: str | None = None,
generate_audio: bool | None = None,
) -> dict[str, Any]:
"""Request image-to-video generation via OpenRouter POST /videos.
Uses frame_images array with first_frame as per OpenRouter API spec.
"""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
payload: dict[str, Any] = {
"model": model,
"prompt": prompt,
"aspect_ratio": aspect_ratio,
"frame_images": [
{
"type": "image_url",
"image_url": {"url": image_url},
"frame_type": "first_frame",
}
],
}
if duration_seconds is not None:
payload["duration"] = duration_seconds
if resolution is not None:
payload["resolution"] = resolution
if generate_audio is not None:
payload["generate_audio"] = generate_audio
async with httpx.AsyncClient(timeout=120) as client:
resp = client.build_request(
"POST", f"{base_url}/videos", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def poll_video_status(polling_url: str) -> dict[str, Any]:
"""Check the status of a video generation job via its polling_url."""
async with httpx.AsyncClient(timeout=15) as client:
resp = client.build_request("GET", polling_url, headers=_headers())
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def list_video_models() -> list[dict[str, Any]]:
"""Return video generation models from the dedicated /videos/models endpoint."""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
async with httpx.AsyncClient(timeout=15) as client:
resp = client.build_request(
"GET", f"{base_url}/videos/models", headers=_headers()
)
response = await client.send(resp)
response.raise_for_status()
return response.json().get("data", [])
async def generate_image_chat(
model: str,
prompt: str,
modalities: list[str] | None = None,
image_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Request image generation via Chat Completions with modalities.
Used by models like FLUX.2 Klein 4B and GPT-5 Image Mini that output
images through the chat completions endpoint rather than /images/generations.
"""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
if modalities is None:
# Image-only models (FLUX) vs multimodal (GPT-5 Image Mini)
modalities = ["image"]
payload: dict[str, Any] = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"modalities": modalities,
}
if image_config:
payload["image_config"] = image_config
async with httpx.AsyncClient(timeout=120) as client:
resp = client.build_request(
"POST", f"{base_url}/chat/completions", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
"""OpenRouter API client (OpenAI-compatible interface)."""
import os
from typing import Any
import httpx
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
def _api_key() -> str:
key = os.getenv("OPENROUTER_API_KEY")
if not key:
raise RuntimeError(
"OPENROUTER_API_KEY environment variable is not set.")
return key
def _headers() -> dict[str, str]:
return {
"Authorization": f"Bearer {_api_key()}",
"Content-Type": "application/json",
"HTTP-Referer": os.getenv("APP_URL", "https://ai.allucanget.biz"),
"X-Title": os.getenv("APP_NAME", "All You Can GET AI"),
}
async def list_models(
output_modalities: str = "all",
category: str | None = None,
supported_parameters: str | None = None,
) -> list[dict[str, Any]]:
"""Return available models from OpenRouter.
Docs: GET /models supports query filters like output_modalities.
"""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
params: dict[str, str] = {"output_modalities": output_modalities}
if category:
params["category"] = category
if supported_parameters:
params["supported_parameters"] = supported_parameters
async with httpx.AsyncClient(timeout=15) as client:
resp = client.build_request(
"GET", f"{base_url}/models", headers=_headers(), params=params)
response = await client.send(resp)
response.raise_for_status()
return response.json().get("data", [])
async def chat_completion(
model: str,
messages: list[dict[str, str]],
temperature: float = 0.7,
max_tokens: int = 1024,
) -> dict[str, Any]:
"""Send a chat completion request to OpenRouter."""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
async with httpx.AsyncClient(timeout=60) as client:
resp = client.build_request(
"POST", f"{base_url}/chat/completions", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def generate_image(
model: str,
prompt: str,
n: int = 1,
size: str = "1024x1024",
) -> dict[str, Any]:
"""Request image generation via OpenRouter /images/generations."""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
payload = {"model": model, "prompt": prompt, "n": n, "size": size}
async with httpx.AsyncClient(timeout=120) as client:
resp = client.build_request(
"POST", f"{base_url}/images/generations", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def generate_video(
model: str,
prompt: str,
duration_seconds: int | None = None,
aspect_ratio: str = "16:9",
resolution: str | None = None,
generate_audio: bool | None = None,
) -> dict[str, Any]:
"""Request text-to-video generation via OpenRouter POST /videos."""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
payload: dict[str, Any] = {
"model": model,
"prompt": prompt,
"aspect_ratio": aspect_ratio,
}
if duration_seconds is not None:
# API uses 'duration' not 'duration_seconds'
payload["duration"] = duration_seconds
if resolution is not None:
payload["resolution"] = resolution
if generate_audio is not None:
payload["generate_audio"] = generate_audio
async with httpx.AsyncClient(timeout=120) as client:
resp = client.build_request(
"POST", f"{base_url}/videos", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def generate_video_from_image(
model: str,
image_url: str,
prompt: str,
duration_seconds: int | None = None,
aspect_ratio: str = "16:9",
resolution: str | None = None,
generate_audio: bool | None = None,
) -> dict[str, Any]:
"""Request image-to-video generation via OpenRouter POST /videos.
Uses frame_images array with first_frame as per OpenRouter API spec.
"""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
payload: dict[str, Any] = {
"model": model,
"prompt": prompt,
"aspect_ratio": aspect_ratio,
"frame_images": [
{
"type": "image_url",
"image_url": {"url": image_url},
"frame_type": "first_frame",
}
],
}
if duration_seconds is not None:
payload["duration"] = duration_seconds
if resolution is not None:
payload["resolution"] = resolution
if generate_audio is not None:
payload["generate_audio"] = generate_audio
async with httpx.AsyncClient(timeout=120) as client:
resp = client.build_request(
"POST", f"{base_url}/videos", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def poll_video_status(polling_url: str) -> dict[str, Any]:
"""Check the status of a video generation job via its polling_url."""
async with httpx.AsyncClient(timeout=15) as client:
resp = client.build_request("GET", polling_url, headers=_headers())
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def list_video_models() -> list[dict[str, Any]]:
"""Return video generation models from the dedicated /videos/models endpoint."""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
async with httpx.AsyncClient(timeout=15) as client:
resp = client.build_request(
"GET", f"{base_url}/videos/models", headers=_headers()
)
response = await client.send(resp)
response.raise_for_status()
return response.json().get("data", [])
async def generate_image_chat(
model: str,
prompt: str,
modalities: list[str] | None = None,
image_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Request image generation via Chat Completions with modalities.
Used by models like FLUX.2 Klein 4B and GPT-5 Image Mini that output
images through the chat completions endpoint rather than /images/generations.
"""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
if modalities is None:
# Image-only models (FLUX) vs multimodal (GPT-5 Image Mini)
modalities = ["image"]
payload: dict[str, Any] = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"modalities": modalities,
}
if image_config:
payload["image_config"] = image_config
async with httpx.AsyncClient(timeout=120) as client:
resp = client.build_request(
"POST", f"{base_url}/chat/completions", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
+86 -86
View File
@@ -1,86 +1,86 @@
"""User management service: CRUD helpers against DuckDB."""
from typing import Any
from ..db import get_conn, get_write_lock
from .auth import hash_password
async def get_user(user_id: str) -> dict[str, Any] | None:
conn = get_conn()
row = conn.execute(
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
).fetchone()
if row is None:
return None
return {"id": str(row[0]), "email": row[1], "role": row[2]}
async def list_users() -> list[dict[str, Any]]:
conn = get_conn()
rows = conn.execute(
"SELECT id, email, role FROM users ORDER BY email").fetchall()
return [{"id": str(r[0]), "email": r[1], "role": r[2]} for r in rows]
async def update_user(
user_id: str,
email: str | None = None,
password: str | None = None,
) -> dict[str, Any] | None:
"""Update email and/or password. Returns updated user or None if not found."""
conn = get_conn()
lock = get_write_lock()
if email is None and password is None:
return await get_user(user_id)
async with lock:
if email is not None:
existing = conn.execute(
"SELECT id FROM users WHERE email = ? AND id != ?", [
email, user_id]
).fetchone()
if existing:
raise ValueError("Email already in use.")
conn.execute(
"UPDATE users SET email = ?, updated_at = now() WHERE id = ?",
[email, user_id],
)
if password is not None:
conn.execute(
"UPDATE users SET password_hash = ?, updated_at = now() WHERE id = ?",
[hash_password(password), user_id],
)
row = conn.execute(
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
).fetchone()
if row is None:
return None
return {"id": str(row[0]), "email": row[1], "role": row[2]}
async def set_user_role(user_id: str, role: str) -> dict[str, Any] | None:
conn = get_conn()
lock = get_write_lock()
async with lock:
conn.execute(
"UPDATE users SET role = ?, updated_at = now() WHERE id = ?",
[role, user_id],
)
row = conn.execute(
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
).fetchone()
if row is None:
return None
return {"id": str(row[0]), "email": row[1], "role": row[2]}
async def delete_user(user_id: str) -> bool:
"""Delete user and their refresh tokens. Returns True if a row was removed."""
conn = get_conn()
lock = get_write_lock()
async with lock:
conn.execute("DELETE FROM refresh_tokens WHERE user_id = ?", [user_id])
conn.execute("DELETE FROM users WHERE id = ?", [user_id])
return True
"""User management service: CRUD helpers against DuckDB."""
from typing import Any
from ..db import get_conn, get_write_lock
from .auth import hash_password
async def get_user(user_id: str) -> dict[str, Any] | None:
conn = get_conn()
row = conn.execute(
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
).fetchone()
if row is None:
return None
return {"id": str(row[0]), "email": row[1], "role": row[2]}
async def list_users() -> list[dict[str, Any]]:
conn = get_conn()
rows = conn.execute(
"SELECT id, email, role FROM users ORDER BY email").fetchall()
return [{"id": str(r[0]), "email": r[1], "role": r[2]} for r in rows]
async def update_user(
user_id: str,
email: str | None = None,
password: str | None = None,
) -> dict[str, Any] | None:
"""Update email and/or password. Returns updated user or None if not found."""
conn = get_conn()
lock = get_write_lock()
if email is None and password is None:
return await get_user(user_id)
async with lock:
if email is not None:
existing = conn.execute(
"SELECT id FROM users WHERE email = ? AND id != ?", [
email, user_id]
).fetchone()
if existing:
raise ValueError("Email already in use.")
conn.execute(
"UPDATE users SET email = ?, updated_at = now() WHERE id = ?",
[email, user_id],
)
if password is not None:
conn.execute(
"UPDATE users SET password_hash = ?, updated_at = now() WHERE id = ?",
[hash_password(password), user_id],
)
row = conn.execute(
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
).fetchone()
if row is None:
return None
return {"id": str(row[0]), "email": row[1], "role": row[2]}
async def set_user_role(user_id: str, role: str) -> dict[str, Any] | None:
conn = get_conn()
lock = get_write_lock()
async with lock:
conn.execute(
"UPDATE users SET role = ?, updated_at = now() WHERE id = ?",
[role, user_id],
)
row = conn.execute(
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
).fetchone()
if row is None:
return None
return {"id": str(row[0]), "email": row[1], "role": row[2]}
async def delete_user(user_id: str) -> bool:
"""Delete user and their refresh tokens. Returns True if a row was removed."""
conn = get_conn()
lock = get_write_lock()
async with lock:
conn.execute("DELETE FROM refresh_tokens WHERE user_id = ?", [user_id])
conn.execute("DELETE FROM users WHERE id = ?", [user_id])
return True
+159 -159
View File
@@ -1,159 +1,159 @@
"""Background worker: processes queued/processing video generation jobs."""
import asyncio
import json
import logging
from datetime import datetime, timezone
import duckdb
from . import openrouter
from .models import mark_timed_out_video_jobs
logger = logging.getLogger(__name__)
# Interval between worker ticks (seconds)
WORKER_INTERVAL = 15
# Jobs to process per tick (prevents unbounded bursts)
BATCH_SIZE = 5
async def process_queued_jobs(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> int:
"""Submit queued jobs to OpenRouter and transition them to 'processing'."""
rows = conn.execute(
"""SELECT id, generation_type, request_params
FROM generated_videos
WHERE status = 'queued' AND request_params IS NOT NULL
ORDER BY created_at ASC
LIMIT ?""",
[BATCH_SIZE],
).fetchall()
processed = 0
for row in rows:
db_id, generation_type, raw_params = str(row[0]), row[1], row[2]
try:
params = json.loads(raw_params)
except (json.JSONDecodeError, TypeError):
logger.error("Bad request_params for video job %s", db_id)
continue
try:
if generation_type == "image_to_video":
result = await openrouter.generate_video_from_image(
model=params["model"],
image_url=params.get("image_url", ""),
prompt=params.get("prompt", ""),
duration_seconds=params.get("duration_seconds"),
aspect_ratio=params.get("aspect_ratio", "16:9"),
resolution=params.get("resolution"),
)
else:
result = await openrouter.generate_video(
model=params["model"],
prompt=params.get("prompt", ""),
duration_seconds=params.get("duration_seconds"),
aspect_ratio=params.get("aspect_ratio", "16:9"),
resolution=params.get("resolution"),
)
except Exception as exc:
logger.warning("OpenRouter call failed for job %s: %s", db_id, exc)
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with lock:
conn.execute(
"UPDATE generated_videos SET status = 'failed', error = ?, updated_at = ? WHERE id = ?",
[str(exc), now, db_id],
)
continue
job_id = result.get("id", "")
polling_url = result.get("polling_url")
new_status = result.get("status", "processing")
# Normalise terminal statuses returned immediately (rare but possible)
if new_status not in ("queued", "processing", "completed", "failed", "cancelled"):
new_status = "processing"
urls = result.get("unsigned_urls") or result.get("video_urls")
video_url = (urls or [None])[0]
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with lock:
conn.execute(
"""UPDATE generated_videos
SET job_id = ?, polling_url = ?, status = ?, video_url = ?, updated_at = ?
WHERE id = ?""",
[job_id, polling_url, new_status, video_url, now, db_id],
)
processed += 1
logger.info("Video job %s → %s (provider id: %s)",
db_id, new_status, job_id)
return processed
async def process_processing_jobs(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> int:
"""Poll in-progress jobs and update to 'completed' or 'failed'."""
rows = conn.execute(
"""SELECT id, polling_url
FROM generated_videos
WHERE status = 'processing' AND polling_url IS NOT NULL
ORDER BY updated_at ASC
LIMIT ?""",
[BATCH_SIZE],
).fetchall()
updated = 0
for row in rows:
db_id, polling_url = str(row[0]), row[1]
try:
result = await openrouter.poll_video_status(polling_url)
except Exception as exc:
logger.warning("Polling failed for job %s: %s", db_id, exc)
continue
job_status = result.get("status", "processing")
if job_status not in ("completed", "failed"):
continue # still in-progress — check again next tick
urls = result.get("unsigned_urls") or result.get("video_urls")
video_url = (urls or [None])[0]
error_msg = result.get("error")
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with lock:
conn.execute(
"""UPDATE generated_videos
SET status = ?, video_url = ?, error = ?, updated_at = ?
WHERE id = ?""",
[job_status, video_url, error_msg, now, db_id],
)
updated += 1
logger.info("Video job %s → %s", db_id, job_status)
return updated
async def worker_tick(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> None:
"""Single worker tick: submit queued, poll processing, expire timed-out."""
queued = await process_queued_jobs(conn, lock)
polled = await process_processing_jobs(conn, lock)
async with lock:
timed_out = mark_timed_out_video_jobs(conn, timeout_minutes=120)
if queued or polled or timed_out:
logger.info(
"Worker tick: submitted=%d polled=%d timed_out=%d",
queued, polled, timed_out,
)
async def run_worker(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> None:
"""Infinite loop: run a worker tick every WORKER_INTERVAL seconds."""
logger.info("Video worker started (interval=%ds)", WORKER_INTERVAL)
while True:
try:
await worker_tick(conn, lock)
except asyncio.CancelledError:
logger.info("Video worker stopped.")
return
except Exception as exc:
logger.exception("Unexpected error in video worker: %s", exc)
await asyncio.sleep(WORKER_INTERVAL)
"""Background worker: processes queued/processing video generation jobs."""
import asyncio
import json
import logging
from datetime import datetime, timezone
import duckdb
from . import openrouter
from .models import mark_timed_out_video_jobs
logger = logging.getLogger(__name__)
# Interval between worker ticks (seconds)
WORKER_INTERVAL = 15
# Jobs to process per tick (prevents unbounded bursts)
BATCH_SIZE = 5
async def process_queued_jobs(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> int:
"""Submit queued jobs to OpenRouter and transition them to 'processing'."""
rows = conn.execute(
"""SELECT id, generation_type, request_params
FROM generated_videos
WHERE status = 'queued' AND request_params IS NOT NULL
ORDER BY created_at ASC
LIMIT ?""",
[BATCH_SIZE],
).fetchall()
processed = 0
for row in rows:
db_id, generation_type, raw_params = str(row[0]), row[1], row[2]
try:
params = json.loads(raw_params)
except (json.JSONDecodeError, TypeError):
logger.error("Bad request_params for video job %s", db_id)
continue
try:
if generation_type == "image_to_video":
result = await openrouter.generate_video_from_image(
model=params["model"],
image_url=params.get("image_url", ""),
prompt=params.get("prompt", ""),
duration_seconds=params.get("duration_seconds"),
aspect_ratio=params.get("aspect_ratio", "16:9"),
resolution=params.get("resolution"),
)
else:
result = await openrouter.generate_video(
model=params["model"],
prompt=params.get("prompt", ""),
duration_seconds=params.get("duration_seconds"),
aspect_ratio=params.get("aspect_ratio", "16:9"),
resolution=params.get("resolution"),
)
except Exception as exc:
logger.warning("OpenRouter call failed for job %s: %s", db_id, exc)
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with lock:
conn.execute(
"UPDATE generated_videos SET status = 'failed', error = ?, updated_at = ? WHERE id = ?",
[str(exc), now, db_id],
)
continue
job_id = result.get("id", "")
polling_url = result.get("polling_url")
new_status = result.get("status", "processing")
# Normalise terminal statuses returned immediately (rare but possible)
if new_status not in ("queued", "processing", "completed", "failed", "cancelled"):
new_status = "processing"
urls = result.get("unsigned_urls") or result.get("video_urls")
video_url = (urls or [None])[0]
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with lock:
conn.execute(
"""UPDATE generated_videos
SET job_id = ?, polling_url = ?, status = ?, video_url = ?, updated_at = ?
WHERE id = ?""",
[job_id, polling_url, new_status, video_url, now, db_id],
)
processed += 1
logger.info("Video job %s → %s (provider id: %s)",
db_id, new_status, job_id)
return processed
async def process_processing_jobs(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> int:
"""Poll in-progress jobs and update to 'completed' or 'failed'."""
rows = conn.execute(
"""SELECT id, polling_url
FROM generated_videos
WHERE status = 'processing' AND polling_url IS NOT NULL
ORDER BY updated_at ASC
LIMIT ?""",
[BATCH_SIZE],
).fetchall()
updated = 0
for row in rows:
db_id, polling_url = str(row[0]), row[1]
try:
result = await openrouter.poll_video_status(polling_url)
except Exception as exc:
logger.warning("Polling failed for job %s: %s", db_id, exc)
continue
job_status = result.get("status", "processing")
if job_status not in ("completed", "failed"):
continue # still in-progress — check again next tick
urls = result.get("unsigned_urls") or result.get("video_urls")
video_url = (urls or [None])[0]
error_msg = result.get("error")
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with lock:
conn.execute(
"""UPDATE generated_videos
SET status = ?, video_url = ?, error = ?, updated_at = ?
WHERE id = ?""",
[job_status, video_url, error_msg, now, db_id],
)
updated += 1
logger.info("Video job %s → %s", db_id, job_status)
return updated
async def worker_tick(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> None:
"""Single worker tick: submit queued, poll processing, expire timed-out."""
queued = await process_queued_jobs(conn, lock)
polled = await process_processing_jobs(conn, lock)
async with lock:
timed_out = mark_timed_out_video_jobs(conn, timeout_minutes=120)
if queued or polled or timed_out:
logger.info(
"Worker tick: submitted=%d polled=%d timed_out=%d",
queued, polled, timed_out,
)
async def run_worker(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> None:
"""Infinite loop: run a worker tick every WORKER_INTERVAL seconds."""
logger.info("Video worker started (interval=%ds)", WORKER_INTERVAL)
while True:
try:
await worker_tick(conn, lock)
except asyncio.CancelledError:
logger.info("Video worker stopped.")
return
except Exception as exc:
logger.exception("Unexpected error in video worker: %s", exc)
await asyncio.sleep(WORKER_INTERVAL)