Initial commit — ComfyUI Discord bot + web UI

Full source for the-third-rev: Discord bot (discord.py), FastAPI web UI
(React/TS/Vite/Tailwind), ComfyUI integration, generation history DB,
preset manager, workflow inspector, and all supporting modules.

Excluded from tracking: .env, invite_tokens.json, *.db (SQLite),
current-workflow-changes.json, user_settings/, presets/, logs/,
web-static/ (build output), frontend/node_modules/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Khoa (Revenovich) Tran Gia
2026-03-02 09:55:48 +07:00
commit 1ed3c9ec4b
82 changed files with 20693 additions and 0 deletions

286
media_uploader.py Normal file
View File

@@ -0,0 +1,286 @@
"""
media_uploader.py
=================
Auto-uploads generated media files to the external storage server.
On success the local file is deleted. Any files that fail to upload
are left in place and will be retried automatically on the next call
(i.e. the next time a generation completes).
If no credentials are configured (MEDIA_UPLOAD_USER / MEDIA_UPLOAD_PASS
env vars not set), flush_pending() is a no-op and files are left for the
manual ttr!collect-videos command.
Upload behaviour:
- Files are categorised into image / gif / video / audio folders.
- A ``folder`` form field is sent with each upload so the server can
route the file into the correct subdirectory.
- The current datetime is appended to each filename before uploading
(e.g. ``output_20260225_143022.png``); the local filename is unchanged.
Usage::
from media_uploader import flush_pending, get_stats
await flush_pending(Path(config.comfy_output_path),
config.media_upload_user,
config.media_upload_pass)
stats = get_stats()
"""
from __future__ import annotations
import asyncio
import logging
import mimetypes
import ssl
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional
import aiohttp
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
UPLOAD_URL = "https://mediaup.revoluxiant.ddns.net/upload"
# Media categories and their recognised extensions.
CATEGORY_EXTENSIONS: dict[str, frozenset[str]] = {
"image": frozenset({
".png", ".jpg", ".jpeg", ".webp", ".bmp",
".tiff", ".tif", ".avif", ".heic", ".heif", ".svg", ".ico",
}),
"gif": frozenset({
".gif",
}),
"video": frozenset({
".mp4", ".webm", ".avi", ".mkv", ".mov",
".flv", ".ts", ".m2ts", ".m4v", ".wmv",
}),
"audio": frozenset({
".mp3", ".wav", ".ogg", ".flac", ".aac",
".m4a", ".opus", ".wma", ".aiff", ".aif",
}),
}
# Flat set of all recognised extensions (used for directory scanning).
MEDIA_EXTENSIONS: frozenset[str] = frozenset().union(*CATEGORY_EXTENSIONS.values())
# Shared SSL context — server uses a self-signed cert.
_ssl_ctx = ssl.create_default_context()
_ssl_ctx.check_hostname = False
_ssl_ctx.verify_mode = ssl.CERT_NONE
# Prevents concurrent flush runs from uploading the same file twice.
_flush_lock = asyncio.Lock()
# ---------------------------------------------------------------------------
# Stats
# ---------------------------------------------------------------------------
@dataclass
class UploadStats:
"""Cumulative upload counters for the current bot session."""
total_attempted: int = 0
total_ok: int = 0
last_attempted: int = 0
last_ok: int = 0
@property
def total_fail(self) -> int:
return self.total_attempted - self.total_ok
@property
def last_fail(self) -> int:
return self.last_attempted - self.last_ok
@property
def fail_rate_pct(self) -> float:
if self.total_attempted == 0:
return 0.0
return (self.total_fail / self.total_attempted) * 100.0
_stats = UploadStats()
def get_stats() -> UploadStats:
"""Return the module-level upload stats (live reference)."""
return _stats
def is_running() -> bool:
"""Return True if a flush is currently in progress."""
return _flush_lock.locked()
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _content_type(filepath: Path) -> str:
mime, _ = mimetypes.guess_type(filepath.name)
return mime or "application/octet-stream"
def _get_category(suffix: str) -> str:
"""Return the upload folder category for a file extension."""
s = suffix.lower()
for category, extensions in CATEGORY_EXTENSIONS.items():
if s in extensions:
logger.info(f"[_get_category] File category: {category}")
return category
logger.info(f"[_get_category] File category: other")
return "other"
def _build_upload_name(filepath: Path) -> str:
"""Return a filename with the current datetime appended before the extension.
Example: ``ComfyUI_00042.png`` → ``20260225_143022_ComfyUI_00042.png``
"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"{timestamp}_{filepath.stem}{filepath.suffix}"
def _build_url(url, category: str) -> str:
"""Build url to upload to each specific folder"""
url += "/comfyui"
if category == "image":
url += "/image"
elif category == "video":
url += "/video"
elif category == "gif":
url += "/gif"
elif category == "audio":
url += "/audio"
else:
url
return url
async def _upload_one(session: aiohttp.ClientSession, filepath: Path) -> bool:
"""Upload a single file. Returns True on HTTP 2xx."""
try:
file_bytes = filepath.read_bytes()
except OSError:
logger.warning("Cannot read file for upload: %s", filepath)
return False
category = _get_category(filepath.suffix)
upload_name = _build_upload_name(filepath)
form = aiohttp.FormData()
form.add_field(
"file",
file_bytes,
filename=upload_name,
content_type=_content_type(filepath),
)
url = _build_url(UPLOAD_URL, category)
logger.info(f"Uploading file to url: {url}")
try:
async with session.post(
url,
data=form,
timeout=aiohttp.ClientTimeout(total=120),
) as resp:
if resp.status // 100 == 2:
return True
body = await resp.text()
logger.warning(
"Upload rejected %s: HTTP %s%s",
upload_name,
resp.status,
body[:200],
)
return False
except Exception:
logger.warning("Upload error for %s", upload_name, exc_info=True)
return False
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def flush_pending(
output_path: Path,
user: Optional[str],
password: Optional[str],
) -> int:
"""
Scan *output_path* for media files, upload each to the storage server,
and delete the local file on success.
If *user* or *password* is falsy (not configured), returns 0 immediately
and leaves all files in place for the manual ttr!collect-videos command.
Files that fail to upload are left in place and will be retried on the
next call. If a previous flush is still running the new call returns 0
immediately to avoid double-uploading.
Returns the number of files successfully uploaded and deleted.
"""
if not user or not password:
return 0
if _flush_lock.locked():
logger.debug("flush_pending already in progress, skipping")
return 0
async with _flush_lock:
try:
entries = [
e for e in output_path.iterdir()
if e.is_file() and e.suffix.lower() in MEDIA_EXTENSIONS
]
except OSError:
logger.warning("Cannot scan output directory: %s", output_path)
return 0
if not entries:
_stats.last_attempted = 0
_stats.last_ok = 0
return 0
logger.info("Auto-uploading %d pending media file(s)…", len(entries))
auth = aiohttp.BasicAuth(user, password)
connector = aiohttp.TCPConnector(ssl=_ssl_ctx)
uploaded = 0
async with aiohttp.ClientSession(connector=connector, auth=auth) as session:
for filepath in entries:
if await _upload_one(session, filepath):
try:
filepath.unlink()
logger.info("Uploaded and deleted: %s", filepath.name)
uploaded += 1
except OSError:
logger.warning(
"Uploaded but could not delete local file: %s", filepath
)
# Update cumulative stats
_stats.last_attempted = len(entries)
_stats.last_ok = uploaded
_stats.total_attempted += len(entries)
_stats.total_ok += uploaded
failed = len(entries) - uploaded
if failed:
logger.warning(
"Auto-upload: %d ok, %d failed — will retry next generation.",
uploaded,
failed,
)
elif uploaded:
logger.info("Auto-upload complete: %d file(s) uploaded and deleted.", uploaded)
return uploaded