Initial commit — BashyOverlay v1

Twitch stream event platform with OBS browser source overlay.

Features:
- Twitch OAuth login (streamer + mod access)
- EventSub WebSocket for subs, gift subs, bits, channel points, follows, raids
- Chat commands via tmi.js in the overlay
- Gap audio — tone plays on new chat message after silence, volume/pitch scale with gap length
- Self-hosted media library (upload sounds and videos)
- Action configuration — map any event to sound, video, or alert
- Per-action cooldowns, test button, enable/disable toggle
- Multiple actions per event (all matching actions fire)
- Activity log dashboard with EventSub connection status
- Layout editor — iframe-based WYSIWYG with drag handles, live style preview
- Custom CSS and custom JS injection into overlay
- Custom DOM events (bashyoverlay:sub, bashyoverlay:raid, etc.) for custom JS hooks
- deploy.sh — one-shot setup and launch script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bashy 2026-04-22 23:48:35 +03:00
commit 6ad661c220
37 changed files with 2180 additions and 0 deletions

7
.env.example Normal file
View file

@ -0,0 +1,7 @@
TWITCH_CLIENT_ID=your_client_id
TWITCH_CLIENT_SECRET=your_client_secret
TWITCH_REDIRECT_URI=http://localhost:8000/auth/callback
SECRET_KEY=change_this_to_a_random_string
APP_BASE_URL=http://localhost:8000
DATABASE_URL=sqlite:///./bashyoverlay.db

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
*.db
*.pyc
__pycache__/
.env
.venv/
venv/
static/media/*
!static/media/.gitkeep

95
SPEC.md Normal file
View file

@ -0,0 +1,95 @@
# BashyOverlay — spec
Self-hosted Twitch stream event platform. Subs, bits, channel points, and chat commands trigger configurable sound and video reactions. Think StreamElements/Streamlabs alerts, but owned and customisable.
No smart lights.
## Event sources
| Source | How |
|---|---|
| Chat commands (`!name`) | tmi.js IRC WebSocket — no auth needed for public chat |
| Subs, bits, channel points | Twitch EventSub WebSocket — requires OAuth token |
## Action types
| Action | Notes |
|---|---|
| Play a sound | From Blerp library — see open questions |
| Play a video/clip overlay | From HudFX library — see open questions |
| On-screen alert | Text, image, or animation shown in the OBS browser source |
## Media libraries
- **Sounds — Blerp.** Meme/clip sound platform. Has Twitch integration history. API details need confirming before building — check developer docs for programmatic library access and licensing.
- **Videos — HudFX.** Overlay video/animation library. API access and terms need confirming before building.
Both are external dependencies. If their APIs aren't open enough, fallback is a self-hosted media library (upload your own files via the management UI).
## The overlay
OBS browser source pointed at the local management server. Receives events from the backend via WebSocket and plays the configured reaction.
### Chat gap audio
Passive audio notification for slow chat — not tied to a command, just fires when a new message arrives after a long silence:
- **Under 30 seconds** — no sound
- **30s to ~2 minutes** — volume scales up with gap
- **2+ minutes** — full volume + pitch scales up (caps ~5 min)
Thresholds configurable in management UI.
## Architecture
```
Twitch ──EventSub WebSocket──► Backend ──WebSocket──► OBS Browser Source
Twitch ──IRC (tmi.js)────────────────────────────────► OBS Browser Source
```
The overlay holds the tmi.js connection directly. The backend holds the EventSub connection and relays events to the overlay.
## Management platform
Local web app running on the streamer's machine.
**Stack:** FastAPI + SQLite + Jinja2 + HTMX (same as IllBring)
**Features:**
- Configure actions per event type (sub, bits tier, channel point reward, chat command)
- Search and preview Blerp sounds
- Browse and preview HudFX videos
- Upload custom sounds/videos as fallback
- Configure gap-audio thresholds
- Activity log / dashboard
- User access via Twitch OAuth — mods verified against channel mod list, no separate user table
## Authentication
Twitch OAuth throughout:
- Streamer logs in → full admin access
- Mods log in → access if verified as mod of the channel via Twitch API
- Token used for EventSub subscriptions
## Deferred
- **Crypto payment notifications** — watch a wallet address for incoming transactions via a blockchain API (Alchemy, Moralis, etc.), fire an overlay event on receipt. Which chains to support and whether to use raw wallet watching vs a payment processor (BTCPay Server, etc.) TBD.
## Estimated effort
| Piece | Effort |
|---|---|
| OBS overlay + gap audio | 12 hours |
| Twitch OAuth | half day |
| EventSub WebSocket + subscriptions | half1 day |
| Event → action mapping + management UI | 1 day |
| Blerp integration | half day (pending API review) |
| HudFX integration | half day (pending API review) |
| Dashboard / activity log | half day |
| **Total v1** | **~34 days** |
## Open questions before building
- Does Blerp have a public API with library search + programmatic playback?
- Does HudFX expose a video library via API?
- If either doesn't — self-hosted media library, or a different provider?
- Crypto: which chains, wallet watching vs payment processor?

0
app/__init__.py Normal file
View file

64
app/auth.py Normal file
View file

@ -0,0 +1,64 @@
import os
from typing import Optional
import httpx
from fastapi import Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from sqlmodel import Session, select
from app.database import get_session
from app.models import User
TWITCH_CLIENT_ID = os.getenv("TWITCH_CLIENT_ID", "")
TWITCH_CLIENT_SECRET = os.getenv("TWITCH_CLIENT_SECRET", "")
TWITCH_REDIRECT_URI = os.getenv("TWITCH_REDIRECT_URI", "http://localhost:8000/auth/callback")
SCOPES = "channel:read:subscriptions bits:read channel:read:redemptions moderator:read:followers"
TWITCH_AUTH_URL = (
"https://id.twitch.tv/oauth2/authorize"
f"?client_id={TWITCH_CLIENT_ID}"
f"&redirect_uri={TWITCH_REDIRECT_URI}"
"&response_type=code"
f"&scope={SCOPES.replace(' ', '+')}"
)
async def exchange_code(code: str) -> dict:
async with httpx.AsyncClient() as client:
r = await client.post("https://id.twitch.tv/oauth2/token", data={
"client_id": TWITCH_CLIENT_ID,
"client_secret": TWITCH_CLIENT_SECRET,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": TWITCH_REDIRECT_URI,
})
r.raise_for_status()
return r.json()
async def get_twitch_user(access_token: str) -> dict:
async with httpx.AsyncClient() as client:
r = await client.get(
"https://api.twitch.tv/helix/users",
headers={"Authorization": f"Bearer {access_token}", "Client-Id": TWITCH_CLIENT_ID},
)
r.raise_for_status()
return r.json()["data"][0]
def get_current_user(request: Request, session: Session = Depends(get_session)) -> Optional[User]:
user_id = request.session.get("user_id")
if not user_id:
return None
return session.get(User, user_id)
def require_user(request: Request, session: Session = Depends(get_session)) -> User:
user_id = request.session.get("user_id")
if not user_id:
raise HTTPException(status_code=307, headers={"Location": "/auth/login"})
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=307, headers={"Location": "/auth/login"})
return user

15
app/cooldowns.py Normal file
View file

@ -0,0 +1,15 @@
from datetime import datetime
_store: dict[int, tuple[datetime, int]] = {}
def is_on_cooldown(action_id: int) -> bool:
if action_id not in _store:
return False
triggered_at, duration = _store[action_id]
return (datetime.utcnow() - triggered_at).total_seconds() < duration
def set_cooldown(action_id: int, duration_seconds: int) -> None:
if duration_seconds > 0:
_store[action_id] = (datetime.utcnow(), duration_seconds)

48
app/database.py Normal file
View file

@ -0,0 +1,48 @@
import os
from dotenv import load_dotenv
from sqlmodel import Session, SQLModel, create_engine, select
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./bashyoverlay.db")
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
def create_db() -> None:
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session
def get_settings():
from app.models import Settings
with Session(engine) as session:
s = session.exec(select(Settings)).first()
if not s:
s = Settings()
session.add(s)
session.commit()
session.refresh(s)
return s
def get_layout():
from app.models import OverlayLayout
with Session(engine) as session:
layout = session.exec(select(OverlayLayout)).first()
if not layout:
layout = OverlayLayout()
session.add(layout)
session.commit()
session.refresh(layout)
return layout
def get_admin_user():
from app.models import User
with Session(engine) as session:
return session.exec(select(User).where(User.is_admin == True)).first()

109
app/dispatch.py Normal file
View file

@ -0,0 +1,109 @@
from typing import Optional
from sqlmodel import Session, select
from app.cooldowns import is_on_cooldown, set_cooldown
from app.database import engine
from app.models import ActivityLog, EventAction, MediaFile
from app.ws import manager
async def dispatch_event(
event_type: str,
username: Optional[str] = None,
event_detail: Optional[str] = None,
bits_amount: Optional[int] = None,
) -> None:
with Session(engine) as session:
actions = session.exec(
select(EventAction)
.where(EventAction.event_type == event_type)
.where(EventAction.enabled == True)
).all()
fired = []
for action in actions:
if not _matches(action, event_detail, bits_amount):
continue
if is_on_cooldown(action.id):
continue
payload = _resolve_payload(action, session)
alert_text = action.alert_text or _default_alert(event_type, username, event_detail, bits_amount)
set_cooldown(action.id, action.cooldown_seconds)
await manager.broadcast({
"type": "execute",
"event_type": event_type,
"action_type": action.action_type,
"media_url": payload.get("media_url"),
"alert_text": alert_text,
})
fired.append(f"{action.action_type}: {payload.get('media_url') or alert_text or ''}")
action_taken = ", ".join(fired) if fired else None
_log(session, event_type, username, event_detail, bits_amount, action_taken)
session.commit()
def _matches(action: EventAction, event_detail: Optional[str], bits_amount: Optional[int]) -> bool:
if not action.event_detail:
return True
if action.event_type == "bits" and bits_amount is not None:
try:
return bits_amount >= int(action.event_detail)
except ValueError:
return False
if action.event_type == "command":
return action.event_detail.lower() == (event_detail or "").lower()
return action.event_detail == event_detail
def _resolve_payload(action: EventAction, session: Session) -> dict:
if action.media_file_id:
media = session.get(MediaFile, action.media_file_id)
if media:
return {"media_url": f"/static/media/{media.filename}"}
return {}
def _log(session, event_type, username, event_detail, bits_amount, action_taken):
session.add(ActivityLog(
event_type=event_type,
username=username,
detail=_detail_str(event_type, event_detail, bits_amount),
action_taken=action_taken,
))
def _detail_str(event_type, event_detail, bits_amount) -> Optional[str]:
if event_type == "sub":
return f"tier {event_detail}" if event_detail else None
if event_type == "bits":
return f"{bits_amount} bits" if bits_amount else None
if event_type == "channel_points":
return event_detail
if event_type == "command":
return f"!{event_detail}" if event_detail else None
if event_type == "raid":
return f"{event_detail} viewers" if event_detail else None
return None
def _default_alert(event_type, username, event_detail, bits_amount) -> Optional[str]:
u = username or "Someone"
if event_type == "sub":
return f"{u} just subscribed!"
if event_type == "gift_sub":
return f"{u} gifted a sub!"
if event_type == "bits":
return f"{u} cheered {bits_amount} bits!"
if event_type == "channel_points":
return f"{u} redeemed channel points!"
if event_type == "follow":
return f"{u} just followed!"
if event_type == "raid":
viewers = f" with {event_detail} viewers" if event_detail else ""
return f"{u} is raiding{viewers}!"
return None

141
app/eventsub.py Normal file
View file

@ -0,0 +1,141 @@
import asyncio
import json
import logging
import os
import httpx
import websockets
from app.dispatch import dispatch_event
logger = logging.getLogger(__name__)
EVENTSUB_WS_URL = "wss://eventsub.wss.twitch.tv/ws"
TWITCH_CLIENT_ID = os.getenv("TWITCH_CLIENT_ID", "")
TWITCH_CLIENT_SECRET = os.getenv("TWITCH_CLIENT_SECRET", "")
SUBSCRIPTIONS = [
("channel.subscribe", "1", lambda bid: {"broadcaster_user_id": bid}),
("channel.subscription.message", "1", lambda bid: {"broadcaster_user_id": bid}),
("channel.subscription.gift", "1", lambda bid: {"broadcaster_user_id": bid}),
("channel.cheer", "1", lambda bid: {"broadcaster_user_id": bid}),
("channel.channel_points_custom_reward_redemption.add", "1", lambda bid: {"broadcaster_user_id": bid}),
("channel.follow", "2", lambda bid: {"broadcaster_user_id": bid, "moderator_user_id": bid}),
("channel.raid", "1", lambda bid: {"to_broadcaster_user_id": bid}),
]
async def run_eventsub(broadcaster_id: str, access_token: str) -> None:
while True:
try:
async with websockets.connect(EVENTSUB_WS_URL) as ws:
logger.info("EventSub connected")
async for raw in ws:
msg = json.loads(raw)
msg_type = msg.get("metadata", {}).get("message_type")
if msg_type == "session_welcome":
session_id = msg["payload"]["session"]["id"]
token = await _subscribe_all(session_id, broadcaster_id, access_token)
if token:
access_token = token
elif msg_type == "notification":
await _handle_notification(msg["payload"])
elif msg_type == "session_reconnect":
reconnect_url = msg["payload"]["session"]["reconnect_url"]
logger.info("EventSub reconnect requested")
break
except asyncio.CancelledError:
logger.info("EventSub task cancelled")
return
except Exception as e:
logger.warning(f"EventSub error: {e}, reconnecting in 10s")
await asyncio.sleep(10)
async def _subscribe_all(session_id: str, broadcaster_id: str, access_token: str) -> str | None:
transport = {"method": "websocket", "session_id": session_id}
condition = {"broadcaster_user_id": broadcaster_id}
headers = {
"Authorization": f"Bearer {access_token}",
"Client-Id": TWITCH_CLIENT_ID,
"Content-Type": "application/json",
}
async with httpx.AsyncClient() as client:
for sub_type, version, condition_fn in SUBSCRIPTIONS:
condition = condition_fn(broadcaster_id)
r = await client.post(
"https://api.twitch.tv/helix/eventsub/subscriptions",
headers=headers,
json={"type": sub_type, "version": version, "condition": condition, "transport": transport},
)
if r.status_code == 401:
new_token = await _refresh_token(access_token)
if new_token:
headers["Authorization"] = f"Bearer {new_token}"
await client.post(
"https://api.twitch.tv/helix/eventsub/subscriptions",
headers=headers,
json={"type": sub_type, "version": version, "condition": condition, "transport": transport},
)
return new_token
elif r.status_code not in (200, 202, 409):
logger.warning(f"Failed to subscribe to {sub_type}: {r.status_code} {r.text}")
return None
async def _refresh_token(old_token: str) -> str | None:
from app.database import engine
from app.models import User
from sqlmodel import Session, select
with Session(engine) as session:
user = session.exec(select(User).where(User.access_token == old_token)).first()
if not user:
return None
async with httpx.AsyncClient() as client:
r = await client.post("https://id.twitch.tv/oauth2/token", data={
"client_id": TWITCH_CLIENT_ID,
"client_secret": TWITCH_CLIENT_SECRET,
"grant_type": "refresh_token",
"refresh_token": user.refresh_token,
})
if r.status_code != 200:
return None
data = r.json()
user.access_token = data["access_token"]
user.refresh_token = data.get("refresh_token", user.refresh_token)
session.add(user)
session.commit()
return user.access_token
async def _handle_notification(payload: dict) -> None:
sub_type = payload["subscription"]["type"]
event = payload["event"]
username = event.get("user_name") or event.get("user_login")
if sub_type in ("channel.subscribe", "channel.subscription.message"):
await dispatch_event("sub", username=username, event_detail=event.get("tier", "1000"))
elif sub_type == "channel.subscription.gift":
await dispatch_event("gift_sub", username=username, event_detail=event.get("tier", "1000"))
elif sub_type == "channel.cheer":
await dispatch_event("bits", username=username, bits_amount=event.get("bits", 0))
elif sub_type == "channel.channel_points_custom_reward_redemption.add":
reward_id = event.get("reward", {}).get("id")
await dispatch_event("channel_points", username=username, event_detail=reward_id)
elif sub_type == "channel.follow":
await dispatch_event("follow", username=username)
elif sub_type == "channel.raid":
raider = event.get("from_broadcaster_user_name") or event.get("from_broadcaster_user_login")
viewers = str(event.get("viewers", ""))
await dispatch_event("raid", username=raider, event_detail=viewers)

60
app/main.py Normal file
View file

@ -0,0 +1,60 @@
import asyncio
import os
from contextlib import asynccontextmanager
from typing import Optional
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from app.database import create_db, get_admin_user
from app.routers import actions, auth, dashboard, layout, media, overlay, settings
load_dotenv()
_eventsub_task: Optional[asyncio.Task] = None
def get_eventsub_task() -> Optional[asyncio.Task]:
return _eventsub_task
async def start_eventsub() -> None:
global _eventsub_task
from app.eventsub import run_eventsub
admin = get_admin_user()
if not admin:
return
if _eventsub_task and not _eventsub_task.done():
_eventsub_task.cancel()
try:
await _eventsub_task
except asyncio.CancelledError:
pass
_eventsub_task = asyncio.create_task(run_eventsub(admin.broadcaster_id, admin.access_token))
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db()
await start_eventsub()
yield
if _eventsub_task and not _eventsub_task.done():
_eventsub_task.cancel()
app = FastAPI(title="BashyOverlay", lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=os.getenv("SECRET_KEY", "dev-secret-change-me"))
app.mount("/static", StaticFiles(directory="static"), name="static")
app.include_router(auth.router)
app.include_router(dashboard.router)
app.include_router(overlay.router)
app.include_router(actions.router)
app.include_router(media.router)
app.include_router(layout.router)
app.include_router(settings.router)

79
app/models.py Normal file
View file

@ -0,0 +1,79 @@
from datetime import datetime
from typing import List, Optional
from sqlmodel import Field, Relationship, SQLModel
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
twitch_id: str = Field(unique=True, index=True)
twitch_login: str
twitch_display_name: str
broadcaster_id: str
access_token: str
refresh_token: str
is_admin: bool = False
created_at: datetime = Field(default_factory=datetime.utcnow)
class MediaFile(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
filename: str
original_name: str
file_type: str # "audio" | "video"
created_at: datetime = Field(default_factory=datetime.utcnow)
actions: List["EventAction"] = Relationship(back_populates="media_file")
class EventAction(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
event_type: str # "sub" | "gift_sub" | "bits" | "channel_points" | "command"
event_detail: Optional[str] = None # command name, reward ID, sub tier, bits minimum
action_type: str # "sound" | "video" | "alert"
media_file_id: Optional[int] = Field(default=None, foreign_key="mediafile.id")
alert_text: Optional[str] = None
cooldown_seconds: int = 0
enabled: bool = True
created_at: datetime = Field(default_factory=datetime.utcnow)
media_file: Optional["MediaFile"] = Relationship(back_populates="actions")
class ActivityLog(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
event_type: str
username: Optional[str] = None
detail: Optional[str] = None
action_taken: Optional[str] = None
triggered_at: datetime = Field(default_factory=datetime.utcnow)
class Settings(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
twitch_channel: str = ""
gap_silence_threshold: int = 30
gap_scale_end: int = 120
gap_pitch_scale_end: int = 300
custom_css: str = ""
custom_js: str = ""
class OverlayLayout(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
# Chat widget — position/size as % of canvas
chat_left: float = 72.0
chat_top: float = 5.0
chat_width: float = 27.0
chat_height: float = 88.0
chat_font_size: int = 14
chat_bg_opacity: float = 0.55
chat_text_color: str = "#ffffff"
chat_max_messages: int = 20
# Alert widget — position as % of canvas (centered via transform)
alert_left: float = 50.0
alert_top: float = 40.0
alert_font_size: int = 24
alert_bg_opacity: float = 0.8
alert_text_color: str = "#ffffff"
alert_duration: int = 4

0
app/routers/__init__.py Normal file
View file

140
app/routers/actions.py Normal file
View file

@ -0,0 +1,140 @@
from typing import Optional
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse
from sqlmodel import Session, select
from app.auth import require_user
from app.database import get_session
from app.models import ActivityLog, EventAction, MediaFile
from app.templates import templates
from app.ws import manager
router = APIRouter(prefix="/actions")
EVENT_TYPES = ["sub", "gift_sub", "bits", "channel_points", "command", "follow", "raid"]
ACTION_TYPES = ["sound", "video", "alert"]
@router.get("", response_class=HTMLResponse)
async def actions_page(request: Request, session: Session = Depends(get_session), user=Depends(require_user)):
actions = session.exec(select(EventAction)).all()
media_files = session.exec(select(MediaFile)).all()
return templates.TemplateResponse("actions.html", {
"request": request,
"user": user,
"actions": actions,
"media_files": media_files,
"event_types": EVENT_TYPES,
"action_types": ACTION_TYPES,
})
@router.post("", response_class=HTMLResponse)
async def create_action(
request: Request,
event_type: str = Form(...),
event_detail: Optional[str] = Form(default=None),
action_type: str = Form(...),
media_file_id: Optional[int] = Form(default=None),
alert_text: Optional[str] = Form(default=None),
cooldown_seconds: int = Form(default=0),
session: Session = Depends(get_session),
user=Depends(require_user),
):
action = EventAction(
event_type=event_type,
event_detail=event_detail.strip() if event_detail and event_detail.strip() else None,
action_type=action_type,
media_file_id=media_file_id or None,
alert_text=alert_text.strip() if alert_text and alert_text.strip() else None,
cooldown_seconds=max(0, cooldown_seconds),
)
session.add(action)
session.commit()
session.refresh(action)
media_files = session.exec(select(MediaFile)).all()
return templates.TemplateResponse("partials/action_row.html", {
"request": request,
"action": action,
"media_files": media_files,
})
@router.post("/{action_id}/test", response_class=HTMLResponse)
async def test_action(
request: Request,
action_id: int,
session: Session = Depends(get_session),
user=Depends(require_user),
):
action = session.get(EventAction, action_id)
if not action:
raise HTTPException(status_code=404)
media_url = None
if action.media_file_id:
media = session.get(MediaFile, action.media_file_id)
if media:
media_url = f"/static/media/{media.filename}"
await manager.broadcast({
"type": "execute",
"action_type": action.action_type,
"media_url": media_url,
"alert_text": action.alert_text or "Test!",
})
session.add(ActivityLog(
event_type="test",
username=user.twitch_display_name,
detail=f"{action.event_type}:{action.event_detail or 'any'}",
action_taken=f"{action.action_type}: {media_url or action.alert_text or ''}",
))
session.commit()
media_files = session.exec(select(MediaFile)).all()
return templates.TemplateResponse("partials/action_row.html", {
"request": request,
"action": action,
"media_files": media_files,
"just_tested": True,
})
@router.delete("/{action_id}", response_class=HTMLResponse)
async def delete_action(
action_id: int,
session: Session = Depends(get_session),
user=Depends(require_user),
):
action = session.get(EventAction, action_id)
if not action:
raise HTTPException(status_code=404)
session.delete(action)
session.commit()
return HTMLResponse("")
@router.post("/{action_id}/toggle", response_class=HTMLResponse)
async def toggle_action(
request: Request,
action_id: int,
session: Session = Depends(get_session),
user=Depends(require_user),
):
action = session.get(EventAction, action_id)
if not action:
raise HTTPException(status_code=404)
action.enabled = not action.enabled
session.add(action)
session.commit()
session.refresh(action)
media_files = session.exec(select(MediaFile)).all()
return templates.TemplateResponse("partials/action_row.html", {
"request": request,
"action": action,
"media_files": media_files,
})

75
app/routers/auth.py Normal file
View file

@ -0,0 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlmodel import Session, select
from app.auth import TWITCH_AUTH_URL, exchange_code, get_twitch_user, get_current_user
from app.database import get_session
from app.models import Settings, User
from app.templates import templates
router = APIRouter(prefix="/auth")
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, user=Depends(get_current_user)):
if user:
return RedirectResponse(url="/")
return templates.TemplateResponse("login.html", {"request": request})
@router.get("/twitch")
async def twitch_redirect():
return RedirectResponse(url=TWITCH_AUTH_URL)
@router.get("/callback")
async def twitch_callback(request: Request, code: str, session: Session = Depends(get_session)):
try:
tokens = await exchange_code(code)
twitch_user = await get_twitch_user(tokens["access_token"])
except Exception:
raise HTTPException(status_code=400, detail="OAuth failed")
user = session.exec(select(User).where(User.twitch_id == twitch_user["id"])).first()
is_first = user is None
if not user:
user = User(
twitch_id=twitch_user["id"],
twitch_login=twitch_user["login"],
twitch_display_name=twitch_user["display_name"],
broadcaster_id=twitch_user["id"],
access_token=tokens["access_token"],
refresh_token=tokens.get("refresh_token", ""),
is_admin=True,
)
else:
user.access_token = tokens["access_token"]
user.refresh_token = tokens.get("refresh_token", user.refresh_token)
user.twitch_display_name = twitch_user["display_name"]
session.add(user)
if is_first:
settings = session.exec(select(Settings)).first()
if not settings:
settings = Settings()
settings.twitch_channel = twitch_user["login"]
session.add(settings)
session.commit()
session.refresh(user)
request.session["user_id"] = user.id
if is_first:
from app.main import start_eventsub
import asyncio
asyncio.create_task(start_eventsub())
return RedirectResponse(url="/", status_code=303)
@router.get("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse(url="/auth/login")

28
app/routers/dashboard.py Normal file
View file

@ -0,0 +1,28 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlmodel import Session, select, desc
from app.auth import require_user
from app.database import get_session
from app.models import ActivityLog
from app.templates import templates
router = APIRouter()
@router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request, session: Session = Depends(get_session), user=Depends(require_user)):
from app.main import get_eventsub_task
task = get_eventsub_task()
eventsub_connected = task is not None and not task.done()
logs = session.exec(
select(ActivityLog).order_by(desc(ActivityLog.triggered_at)).limit(50)
).all()
return templates.TemplateResponse("dashboard.html", {
"request": request,
"user": user,
"eventsub_connected": eventsub_connected,
"logs": logs,
})

91
app/routers/layout.py Normal file
View file

@ -0,0 +1,91 @@
from typing import Optional
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import require_user
from app.database import get_session
from app.models import OverlayLayout
from app.templates import templates
router = APIRouter(prefix="/layout")
def _get_or_create(session: Session) -> OverlayLayout:
layout = session.exec(select(OverlayLayout)).first()
if not layout:
layout = OverlayLayout()
session.add(layout)
session.commit()
session.refresh(layout)
return layout
@router.get("", response_class=HTMLResponse)
async def layout_page(request: Request, session: Session = Depends(get_session), user=Depends(require_user)):
layout = _get_or_create(session)
return templates.TemplateResponse("layout.html", {"request": request, "user": user, "layout": layout})
@router.post("", response_class=HTMLResponse)
async def save_styles(
request: Request,
chat_font_size: int = Form(default=14),
chat_bg_opacity: float = Form(default=0.55),
chat_text_color: str = Form(default="#ffffff"),
chat_max_messages: int = Form(default=20),
alert_font_size: int = Form(default=24),
alert_bg_opacity: float = Form(default=0.8),
alert_text_color: str = Form(default="#ffffff"),
alert_duration: int = Form(default=4),
session: Session = Depends(get_session),
user=Depends(require_user),
):
layout = _get_or_create(session)
layout.chat_font_size = max(8, min(48, chat_font_size))
layout.chat_bg_opacity = max(0.0, min(1.0, chat_bg_opacity))
layout.chat_text_color = chat_text_color
layout.chat_max_messages = max(1, min(50, chat_max_messages))
layout.alert_font_size = max(8, min(72, alert_font_size))
layout.alert_bg_opacity = max(0.0, min(1.0, alert_bg_opacity))
layout.alert_text_color = alert_text_color
layout.alert_duration = max(1, min(30, alert_duration))
session.add(layout)
session.commit()
session.refresh(layout)
return templates.TemplateResponse("layout.html", {
"request": request,
"user": user,
"layout": layout,
"saved": True,
})
class PositionUpdate(BaseModel):
widget: str
left: float
top: float
width: Optional[float] = None
height: Optional[float] = None
@router.post("/positions")
async def save_positions(
data: PositionUpdate,
session: Session = Depends(get_session),
user=Depends(require_user),
):
layout = _get_or_create(session)
if data.widget == "chat":
layout.chat_left = round(max(0.0, min(95.0, data.left)), 2)
layout.chat_top = round(max(0.0, min(95.0, data.top)), 2)
if data.width is not None: layout.chat_width = round(max(5.0, min(100.0, data.width)), 2)
if data.height is not None: layout.chat_height = round(max(5.0, min(100.0, data.height)), 2)
elif data.widget == "alert":
layout.alert_left = round(max(0.0, min(100.0, data.left)), 2)
layout.alert_top = round(max(0.0, min(100.0, data.top)), 2)
session.add(layout)
session.commit()
return {"ok": True}

79
app/routers/media.py Normal file
View file

@ -0,0 +1,79 @@
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse
from sqlmodel import Session, select
from app.auth import require_user
from app.database import get_session
from app.models import MediaFile
from app.templates import templates
router = APIRouter(prefix="/media")
MEDIA_DIR = "static/media"
AUDIO_EXTENSIONS = {".mp3", ".ogg", ".wav", ".m4a"}
VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov"}
@router.get("", response_class=HTMLResponse)
async def media_page(request: Request, session: Session = Depends(get_session), user=Depends(require_user)):
files = session.exec(select(MediaFile)).all()
return templates.TemplateResponse("media.html", {
"request": request,
"user": user,
"files": files,
})
@router.post("", response_class=HTMLResponse)
async def upload_file(
request: Request,
file: UploadFile,
session: Session = Depends(get_session),
user=Depends(require_user),
):
ext = os.path.splitext(file.filename)[1].lower()
if ext in AUDIO_EXTENSIONS:
file_type = "audio"
elif ext in VIDEO_EXTENSIONS:
file_type = "video"
else:
raise HTTPException(status_code=400, detail="Unsupported file type")
filename = f"{uuid.uuid4().hex}{ext}"
path = os.path.join(MEDIA_DIR, filename)
content = await file.read()
with open(path, "wb") as f:
f.write(content)
media = MediaFile(filename=filename, original_name=file.filename, file_type=file_type)
session.add(media)
session.commit()
session.refresh(media)
return templates.TemplateResponse("partials/media_item.html", {
"request": request,
"file": media,
})
@router.delete("/{file_id}", response_class=HTMLResponse)
async def delete_file(
file_id: int,
session: Session = Depends(get_session),
user=Depends(require_user),
):
media = session.get(MediaFile, file_id)
if not media:
raise HTTPException(status_code=404)
path = os.path.join(MEDIA_DIR, media.filename)
if os.path.exists(path):
os.remove(path)
session.delete(media)
session.commit()
return HTMLResponse("")

57
app/routers/overlay.py Normal file
View file

@ -0,0 +1,57 @@
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from fastapi.requests import Request
from fastapi.responses import HTMLResponse
from app.database import get_layout, get_settings
from app.dispatch import dispatch_event
from app.templates import templates
from app.ws import manager
router = APIRouter()
@router.get("/overlay", response_class=HTMLResponse)
async def overlay_page(request: Request):
settings = get_settings()
layout = get_layout()
return templates.TemplateResponse("overlay.html", {
"request": request,
"channel": settings.twitch_channel,
"gap_silence": settings.gap_silence_threshold,
"gap_scale_end": settings.gap_scale_end,
"gap_pitch_end": settings.gap_pitch_scale_end,
"layout": layout,
"custom_css": settings.custom_css,
"custom_js": settings.custom_js,
})
@router.get("/overlay/preview", response_class=HTMLResponse)
async def overlay_preview(request: Request):
settings = get_settings()
layout = get_layout()
return templates.TemplateResponse("overlay_preview.html", {
"request": request,
"layout": layout,
"custom_css": settings.custom_css,
"custom_js": settings.custom_js,
"alert_duration_ms": layout.alert_duration * 1000,
})
@router.websocket("/ws/overlay")
async def overlay_ws(websocket: WebSocket):
await manager.connect(websocket)
try:
while True:
data = await websocket.receive_json()
if data.get("type") == "command":
await dispatch_event(
"command",
username=data.get("user"),
event_detail=data.get("name"),
)
except WebSocketDisconnect:
manager.disconnect(websocket)
except Exception:
manager.disconnect(websocket)

64
app/routers/settings.py Normal file
View file

@ -0,0 +1,64 @@
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlmodel import Session, select
from app.auth import require_user
from app.database import get_session
from app.models import Settings
from app.templates import templates
router = APIRouter(prefix="/settings")
@router.get("", response_class=HTMLResponse)
async def settings_page(request: Request, session: Session = Depends(get_session), user=Depends(require_user)):
settings = session.exec(select(Settings)).first() or Settings()
return templates.TemplateResponse("settings.html", {
"request": request,
"user": user,
"settings": settings,
"overlay_url": str(request.base_url) + "overlay",
})
@router.post("", response_class=HTMLResponse)
async def save_settings(
request: Request,
twitch_channel: str = Form(...),
gap_silence_threshold: int = Form(default=30),
gap_scale_end: int = Form(default=120),
gap_pitch_scale_end: int = Form(default=300),
custom_css: str = Form(default=""),
custom_js: str = Form(default=""),
session: Session = Depends(get_session),
user=Depends(require_user),
):
settings = session.exec(select(Settings)).first()
if not settings:
settings = Settings()
settings.twitch_channel = twitch_channel.strip().lstrip("#")
settings.gap_silence_threshold = max(0, gap_silence_threshold)
settings.gap_scale_end = max(settings.gap_silence_threshold + 1, gap_scale_end)
settings.gap_pitch_scale_end = max(settings.gap_scale_end + 1, gap_pitch_scale_end)
settings.custom_css = custom_css
settings.custom_js = custom_js
session.add(settings)
session.commit()
return templates.TemplateResponse("settings.html", {
"request": request,
"user": user,
"settings": settings,
"overlay_url": str(request.base_url) + "overlay",
"saved": True,
})
@router.post("/reconnect")
async def reconnect_eventsub(user=Depends(require_user)):
from app.main import start_eventsub
import asyncio
asyncio.create_task(start_eventsub())
return RedirectResponse(url="/settings", status_code=303)

3
app/templates.py Normal file
View file

@ -0,0 +1,3 @@
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")

28
app/ws.py Normal file
View file

@ -0,0 +1,28 @@
from fastapi import WebSocket
class ConnectionManager:
def __init__(self):
self.active: list[WebSocket] = []
async def connect(self, ws: WebSocket) -> None:
await ws.accept()
self.active.append(ws)
def disconnect(self, ws: WebSocket) -> None:
self.active.discard(ws) if hasattr(self.active, "discard") else None
if ws in self.active:
self.active.remove(ws)
async def broadcast(self, message: dict) -> None:
dead = []
for ws in self.active:
try:
await ws.send_json(message)
except Exception:
dead.append(ws)
for ws in dead:
self.disconnect(ws)
manager = ConnectionManager()

72
deploy.sh Executable file
View file

@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[BashyOverlay]${NC} $1"; }
warn() { echo -e "${YELLOW}[BashyOverlay]${NC} $1"; }
error() { echo -e "${RED}[BashyOverlay]${NC} $1"; exit 1; }
# ── Python version check ──────────────────────────────────────────────────────
PYTHON=$(command -v python3 || command -v python || error "Python not found")
PY_VER=$($PYTHON -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
PY_MAJOR=$($PYTHON -c 'import sys; print(sys.version_info.major)')
PY_MINOR=$($PYTHON -c 'import sys; print(sys.version_info.minor)')
if [ "$PY_MAJOR" -lt 3 ] || { [ "$PY_MAJOR" -eq 3 ] && [ "$PY_MINOR" -lt 10 ]; }; then
error "Python 3.10+ required (found $PY_VER)"
fi
info "Python $PY_VER OK"
# ── Virtual environment ───────────────────────────────────────────────────────
if [ ! -d ".venv" ]; then
info "Creating virtual environment..."
$PYTHON -m venv .venv
fi
source .venv/bin/activate
info "Virtual environment active"
# ── Dependencies ──────────────────────────────────────────────────────────────
info "Installing dependencies..."
pip install -q --upgrade pip
pip install -q -r requirements.txt
info "Dependencies installed"
# ── .env ──────────────────────────────────────────────────────────────────────
if [ ! -f ".env" ]; then
cp .env.example .env
warn ".env created from .env.example"
warn "Edit .env with your Twitch credentials before continuing."
warn ""
warn " TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET:"
warn " Register an app at https://dev.twitch.tv/console/apps"
warn " Set OAuth Redirect URL to: http://localhost:8000/auth/callback"
warn ""
echo -n "Have you filled in .env? [y/N] "
read -r answer
if [[ ! "$answer" =~ ^[Yy]$ ]]; then
warn "Edit .env and run deploy.sh again."
exit 0
fi
fi
# ── Media directory ───────────────────────────────────────────────────────────
mkdir -p static/media
info "Media directory ready"
# ── Launch ────────────────────────────────────────────────────────────────────
HOST="${HOST:-0.0.0.0}"
PORT="${PORT:-8000}"
info "Starting BashyOverlay at http://localhost:${PORT}"
info "Open that URL in your browser to log in with Twitch."
info "OBS browser source URL: http://localhost:${PORT}/overlay"
info ""
info "Press Ctrl+C to stop."
echo ""
exec uvicorn app.main:app --host "$HOST" --port "$PORT" --reload

9
requirements.txt Normal file
View file

@ -0,0 +1,9 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
sqlmodel>=0.0.21
jinja2>=3.1.4
python-dotenv>=1.0.1
httpx>=0.27.0
python-multipart>=0.0.9
websockets>=12.0
itsdangerous>=2.1.0

0
static/media/.gitkeep Normal file
View file

74
static/overlay.css Normal file
View file

@ -0,0 +1,74 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: transparent;
overflow: hidden;
font-family: 'Segoe UI', Arial, sans-serif;
}
#chat {
position: fixed;
left: var(--chat-left, 72%);
top: var(--chat-top, 5%);
width: var(--chat-width, 27%);
height: var(--chat-height, 88%);
font-size: var(--chat-font-size, 14px);
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 8px;
gap: 4px;
}
.chat-msg {
background: rgba(0, 0, 0, var(--chat-bg-opacity, 0.55));
border-radius: 6px;
padding: 5px 8px;
color: var(--chat-text-color, #fff);
line-height: 1.4;
word-break: break-word;
animation: fadein 0.2s ease;
}
.uname { font-weight: bold; }
#alert {
position: fixed;
left: var(--alert-left, 50%);
top: var(--alert-top, 40%);
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, var(--alert-bg-opacity, 0.8));
color: var(--alert-text-color, #fff);
padding: 1rem 2rem;
border-radius: 12px;
font-size: var(--alert-font-size, 24px);
font-weight: bold;
text-align: center;
max-width: 80vw;
animation: popin 0.3s ease;
}
#alert-video-wrap {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 100;
}
#alert-video {
max-width: 80vw;
max-height: 80vh;
border-radius: 8px;
}
@keyframes fadein {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes popin {
from { opacity: 0; transform: translate(-50%, -50%) scale(0.8); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}

87
static/style.css Normal file
View file

@ -0,0 +1,87 @@
/* Management UI */
.login-page { display: flex; align-items: center; justify-content: center; min-height: 80vh; }
.login-card { text-align: center; max-width: 360px; }
.twitch-btn { background: #9146ff; border-color: #9146ff; color: #fff; display: inline-block; }
.twitch-btn:hover { background: #772ce8; border-color: #772ce8; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 768px) { .form-grid { grid-template-columns: 1fr; } }
.status-bar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1.5rem; padding: 0.5rem 1rem; border-radius: var(--pico-border-radius); background: var(--pico-card-background-color); }
.status-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.status-dot.connected { background: #2ecc71; }
.status-dot.disconnected { background: #e74c3c; }
.action-row { display: flex; align-items: center; justify-content: space-between; padding: 0.6rem 0; border-bottom: 1px solid var(--pico-table-border-color); gap: 1rem; }
.action-info { flex: 1; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.action-controls { display: flex; gap: 0.5rem; flex-shrink: 0; }
.media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
.media-item { background: var(--pico-card-background-color); border-radius: var(--pico-border-radius); padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; }
.media-info { display: flex; align-items: center; gap: 0.5rem; }
.media-type-badge { font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 999px; font-weight: bold; text-transform: uppercase; }
.media-type-badge.audio { background: #2980b9; color: #fff; }
.media-type-badge.video { background: #8e44ad; color: #fff; }
.media-item audio, .media-item video { width: 100%; }
.small-btn { padding: 0.25rem 0.6rem; font-size: 0.8rem; margin-bottom: 0; }
.tested-badge { color: #2ecc71; font-size: 0.85rem; animation: fadeout 2s ease 1s forwards; }
@keyframes fadeout { from { opacity: 1; } to { opacity: 0; } }
.muted { color: var(--pico-muted-color); }
section { margin-bottom: 2rem; }
.url-input { font-family: monospace; font-size: 0.85em; }
.code-textarea { font-family: 'Fira Code', 'Consolas', 'Monaco', monospace; font-size: 0.82rem; resize: vertical; }
.notice-success { background: #1a3a1a; border: 1px solid #2ecc71; border-radius: var(--pico-border-radius); padding: 0.75rem 1rem; margin-bottom: 1rem; }
/* Layout editor */
.editor-wrap {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--pico-table-border-color);
background: #000;
}
.drag-handle {
position: absolute;
border: 2px dashed rgba(145, 70, 255, 0.7);
border-radius: 4px;
cursor: move;
user-select: none;
z-index: 10;
box-sizing: border-box;
transition: border-color 0.1s;
}
.drag-handle:hover { border-color: rgba(145, 70, 255, 1); background: rgba(145,70,255,0.08); }
.handle-label {
font-size: 0.6rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(145, 70, 255, 0.9);
padding: 2px 5px;
background: rgba(0,0,0,0.6);
border-radius: 2px;
width: fit-content;
}
.resize-corner {
position: absolute;
bottom: 0;
right: 0;
width: 14px;
height: 14px;
background: rgba(145, 70, 255, 0.8);
border-top-left-radius: 4px;
cursor: se-resize;
}
.canvas-footer { display: flex; justify-content: space-between; align-items: center; margin: 0.4rem 0 1rem; }
.style-form { margin-top: 0.5rem; }
.props-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
@media (max-width: 768px) { .props-grid { grid-template-columns: 1fr; } }
.props-row { display: flex; flex-wrap: wrap; gap: 0.75rem; }
.props-row label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; }
.props-row input[type="number"] { width: 70px; margin-bottom: 0; padding: 0.3rem; }
.props-row input[type="range"] { width: 100px; margin-bottom: 0; }
.props-row input[type="color"] { width: 48px; height: 36px; padding: 2px; border-radius: 4px; cursor: pointer; margin-bottom: 0; }
.props-row span { font-size: 0.75rem; color: var(--pico-muted-color); }

71
templates/actions.html Normal file
View file

@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Actions — BashyOverlay{% endblock %}
{% block content %}
<hgroup>
<h1>Actions</h1>
<p>Configure what happens when each event fires.</p>
</hgroup>
<section>
<h2>Add action</h2>
<form hx-post="/actions"
hx-target="#actions-list"
hx-swap="beforeend"
hx-on::after-request="this.reset()">
<div class="form-grid">
<label>
Event type
<select name="event_type" required>
{% for et in event_types %}
<option value="{{ et }}">{{ et }}</option>
{% endfor %}
</select>
</label>
<label>
Event detail
<input type="text" name="event_detail" placeholder="command name / reward ID / bits min / sub tier">
<small>Leave blank to match any. For commands: the name without !. For bits: minimum amount.</small>
</label>
<label>
Action type
<select name="action_type" required>
{% for at in action_types %}
<option value="{{ at }}">{{ at }}</option>
{% endfor %}
</select>
</label>
<label>
Media file <small>(for sound/video)</small>
<select name="media_file_id">
<option value="">— none —</option>
{% for f in media_files %}
<option value="{{ f.id }}">[{{ f.file_type }}] {{ f.original_name }}</option>
{% endfor %}
</select>
</label>
<label>
Alert text <small>(shown on screen)</small>
<input type="text" name="alert_text" placeholder="e.g. {{ '{{username}} just subscribed!' }}">
</label>
<label>
Cooldown (seconds)
<input type="number" name="cooldown_seconds" value="0" min="0">
</label>
</div>
<button type="submit">Add action</button>
</form>
</section>
<section>
<h2>Configured actions</h2>
<div id="actions-list">
{% for action in actions %}
{% set _ = namespace(media_files=media_files) %}
{% include "partials/action_row.html" %}
{% endfor %}
{% if not actions %}
<p><em>No actions configured yet.</em></p>
{% endif %}
</div>
</section>
{% endblock %}

31
templates/base.html Normal file
View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}BashyOverlay{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.0.6/css/pico.min.css">
<script src="https://unpkg.com/htmx.org@2.0.3/dist/htmx.min.js"></script>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="container-fluid">
<nav>
<ul>
<li><strong><a href="/" class="contrast">BashyOverlay</a></strong></li>
</ul>
<ul>
<li><a href="/">Dashboard</a></li>
<li><a href="/actions">Actions</a></li>
<li><a href="/media">Media</a></li>
<li><a href="/layout">Layout</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/auth/logout" class="secondary">Logout</a></li>
</ul>
</nav>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
</body>
</html>

50
templates/dashboard.html Normal file
View file

@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Dashboard — BashyOverlay{% endblock %}
{% block content %}
<hgroup>
<h1>Dashboard</h1>
<p>Welcome, {{ user.twitch_display_name }}</p>
</hgroup>
<div class="status-bar">
<span class="status-dot {% if eventsub_connected %}connected{% else %}disconnected{% endif %}"></span>
EventSub: <strong>{% if eventsub_connected %}connected{% else %}disconnected{% endif %}</strong>
{% if not eventsub_connected %}
<form method="post" action="/settings/reconnect" style="display:inline">
<button type="submit" class="outline small-btn">Reconnect</button>
</form>
{% endif %}
</div>
<section>
<h2>Recent activity</h2>
{% if logs %}
<figure>
<table>
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>User</th>
<th>Detail</th>
<th>Action taken</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td><small>{{ log.triggered_at.strftime('%H:%M:%S') }}</small></td>
<td><code>{{ log.event_type }}</code></td>
<td>{{ log.username or '—' }}</td>
<td>{{ log.detail or '—' }}</td>
<td>{{ log.action_taken or '<span class="muted">none configured</span>' | safe }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
{% else %}
<p><em>No activity yet. Events will appear here as they come in.</em></p>
{% endif %}
</section>
{% endblock %}

200
templates/layout.html Normal file
View file

@ -0,0 +1,200 @@
{% extends "base.html" %}
{% block title %}Layout Editor — BashyOverlay{% endblock %}
{% block content %}
<hgroup>
<h1>Layout Editor</h1>
<p>Drag handles to reposition. Resize chat from the bottom-right corner. Changes to styles preview live.</p>
</hgroup>
{% if saved %}
<div class="notice-success">Styles saved. Refresh your OBS browser source to apply.</div>
{% endif %}
<div class="editor-wrap" id="editor-wrap">
<iframe id="preview-frame"
src="/overlay/preview"
scrolling="no"
style="position:absolute;top:0;left:0;width:100%;height:100%;border:none;pointer-events:none;">
</iframe>
<div class="drag-handle handle-chat"
id="handle-chat"
data-target="chat"
data-left="{{ layout.chat_left }}"
data-top="{{ layout.chat_top }}"
data-width="{{ layout.chat_width }}"
data-height="{{ layout.chat_height }}"
style="left:{{ layout.chat_left }}%;top:{{ layout.chat_top }}%;width:{{ layout.chat_width }}%;height:{{ layout.chat_height }}%">
<div class="handle-label">Chat</div>
<div class="resize-corner"></div>
</div>
<div class="drag-handle handle-alert"
id="handle-alert"
data-target="alert"
data-left="{{ layout.alert_left }}"
data-top="{{ layout.alert_top }}"
style="left:{{ layout.alert_left }}%;top:{{ layout.alert_top }}%;transform:translate(-50%,-50%)">
<div class="handle-label">Alert</div>
</div>
</div>
<div class="canvas-footer">
<small class="muted">Positions save on drag end. Refresh OBS browser source after repositioning.</small>
<button type="button" id="preview-alert-btn" class="outline small-btn">▶ Preview alert</button>
</div>
<form method="post" action="/layout" class="style-form">
<div class="props-grid">
<fieldset>
<legend><strong>Chat</strong></legend>
<div class="props-row">
<label>
Font size (px)
<input type="number" name="chat_font_size" value="{{ layout.chat_font_size }}" min="8" max="48">
</label>
<label>
BG opacity
<input type="range" name="chat_bg_opacity" value="{{ layout.chat_bg_opacity }}"
min="0" max="1" step="0.05"
oninput="this.nextElementSibling.textContent = (+this.value).toFixed(2)">
<span>{{ layout.chat_bg_opacity }}</span>
</label>
<label>
Text color
<input type="color" name="chat_text_color" value="{{ layout.chat_text_color }}">
</label>
<label>
Max messages
<input type="number" name="chat_max_messages" value="{{ layout.chat_max_messages }}" min="1" max="50">
</label>
</div>
</fieldset>
<fieldset>
<legend><strong>Alert</strong></legend>
<div class="props-row">
<label>
Font size (px)
<input type="number" name="alert_font_size" value="{{ layout.alert_font_size }}" min="8" max="72">
</label>
<label>
BG opacity
<input type="range" name="alert_bg_opacity" value="{{ layout.alert_bg_opacity }}"
min="0" max="1" step="0.05"
oninput="this.nextElementSibling.textContent = (+this.value).toFixed(2)">
<span>{{ layout.alert_bg_opacity }}</span>
</label>
<label>
Text color
<input type="color" name="alert_text_color" value="{{ layout.alert_text_color }}">
</label>
<label>
Duration (sec)
<input type="number" name="alert_duration" value="{{ layout.alert_duration }}" min="1" max="30">
</label>
</div>
</fieldset>
</div>
<button type="submit">Save styles</button>
</form>
<script src="https://cdn.jsdelivr.net/npm/interactjs@1.10.27/dist/interact.min.js"></script>
<script>
const wrap = document.getElementById('editor-wrap');
const iframe = document.getElementById('preview-frame');
function iframeDoc() { return iframe.contentDocument || iframe.contentWindow?.document; }
function iframeVar(cssVar, value) {
const doc = iframeDoc();
if (doc) doc.documentElement.style.setProperty(cssVar, value);
}
// ── Live style preview ────────────────────────────────────────────────────
function bind(name, cssVar, transform = v => v) {
const el = document.querySelector(`[name="${name}"]`);
if (!el) return;
el.addEventListener('input', () => iframeVar(cssVar, transform(el.value)));
}
bind('chat_font_size', '--chat-font-size', v => v + 'px');
bind('chat_bg_opacity', '--chat-bg-opacity');
bind('chat_text_color', '--chat-text-color');
bind('alert_font_size', '--alert-font-size', v => v + 'px');
bind('alert_bg_opacity', '--alert-bg-opacity');
bind('alert_text_color', '--alert-text-color');
// ── Preview alert button ──────────────────────────────────────────────────
document.getElementById('preview-alert-btn').addEventListener('click', () => {
iframe.contentWindow?.postMessage({ type: 'preview-alert' }, '*');
});
// ── Drag ──────────────────────────────────────────────────────────────────
interact('.drag-handle').draggable({
listeners: {
move(event) {
const el = event.target;
const ww = wrap.offsetWidth;
const wh = wrap.offsetHeight;
let left = parseFloat(el.dataset.left || 0);
let top = parseFloat(el.dataset.top || 0);
left = Math.max(0, Math.min(95, left + event.dx / ww * 100));
top = Math.max(0, Math.min(95, top + event.dy / wh * 100));
el.style.left = left + '%';
el.style.top = top + '%';
el.dataset.left = left;
el.dataset.top = top;
const t = el.dataset.target;
iframeVar(`--${t}-left`, left + '%');
iframeVar(`--${t}-top`, top + '%');
},
end(event) {
const el = event.target;
savePos(el.dataset.target,
parseFloat(el.dataset.left),
parseFloat(el.dataset.top),
parseFloat(el.dataset.width || 0),
parseFloat(el.dataset.height || 0));
}
}
});
// ── Resize (chat only) ────────────────────────────────────────────────────
interact('#handle-chat').resizable({
edges: { right: true, bottom: true },
modifiers: [interact.modifiers.restrictSize({ min: { width: 30, height: 30 } })],
listeners: {
move(event) {
const el = event.target;
const ww = wrap.offsetWidth;
const wh = wrap.offsetHeight;
const w = Math.max(5, Math.min(100, event.rect.width / ww * 100));
const h = Math.max(5, Math.min(100, event.rect.height / wh * 100));
el.style.width = w + '%';
el.style.height = h + '%';
el.dataset.width = w;
el.dataset.height = h;
iframeVar('--chat-width', w + '%');
iframeVar('--chat-height', h + '%');
},
end(event) {
const el = event.target;
savePos('chat',
parseFloat(el.dataset.left),
parseFloat(el.dataset.top),
parseFloat(el.dataset.width),
parseFloat(el.dataset.height));
}
}
});
async function savePos(target, left, top, width, height) {
await fetch('/layout/positions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ widget: target, left, top, width, height })
});
}
</script>
{% endblock %}

19
templates/login.html Normal file
View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login — BashyOverlay</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.0.6/css/pico.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main class="container login-page">
<article class="login-card">
<h1>BashyOverlay</h1>
<p>Twitch stream event platform.</p>
<a href="/auth/twitch" role="button" class="twitch-btn">Login with Twitch</a>
</article>
</main>
</body>
</html>

35
templates/media.html Normal file
View file

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Media — BashyOverlay{% endblock %}
{% block content %}
<hgroup>
<h1>Media</h1>
<p>Upload sounds and videos for your actions.</p>
</hgroup>
<section>
<h2>Upload</h2>
<form hx-post="/media"
hx-target="#media-list"
hx-swap="beforeend"
hx-encoding="multipart/form-data"
hx-on::after-request="this.reset()">
<label>
File <small>(mp3, ogg, wav, m4a, mp4, webm)</small>
<input type="file" name="file" accept="audio/*,video/*" required>
</label>
<button type="submit">Upload</button>
</form>
</section>
<section>
<h2>Library</h2>
<div id="media-list" class="media-grid">
{% for file in files %}
{% include "partials/media_item.html" %}
{% endfor %}
{% if not files %}
<p><em>No files uploaded yet.</em></p>
{% endif %}
</div>
</section>
{% endblock %}

162
templates/overlay.html Normal file
View file

@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Overlay</title>
<link rel="stylesheet" href="/static/overlay.css">
{% if custom_css %}<style>{{ custom_css | safe }}</style>{% endif %}
<style>
:root {
--chat-left: {{ layout.chat_left }}%;
--chat-top: {{ layout.chat_top }}%;
--chat-width: {{ layout.chat_width }}%;
--chat-height: {{ layout.chat_height }}%;
--chat-font-size: {{ layout.chat_font_size }}px;
--chat-bg-opacity: {{ layout.chat_bg_opacity }};
--chat-text-color: {{ layout.chat_text_color }};
--alert-left: {{ layout.alert_left }}%;
--alert-top: {{ layout.alert_top }}%;
--alert-font-size: {{ layout.alert_font_size }}px;
--alert-bg-opacity: {{ layout.alert_bg_opacity }};
--alert-text-color: {{ layout.alert_text_color }};
}
</style>
</head>
<body>
<div id="alert-video-wrap" style="display:none">
<video id="alert-video" playsinline></video>
</div>
<div id="alert" style="display:none"></div>
<div id="chat"></div>
<script src="https://cdn.jsdelivr.net/npm/tmi.js@1.8.5/build/tmi.min.js"></script>
<script>
const CHANNEL = {{ channel | tojson }};
const GAP_SIL = {{ gap_silence }};
const GAP_VOL = {{ gap_scale_end }};
const GAP_PITCH = {{ gap_pitch_end }};
const MAX_MSG = {{ layout.chat_max_messages }};
const ALERT_DURATION = {{ layout.alert_duration * 1000 }};
let lastMsgTime = Date.now();
let alertQueue = [];
let alertBusy = false;
// ── WebSocket to backend ──────────────────────────────────────────────
let ws;
function connectWS() {
ws = new WebSocket(`ws://${location.host}/ws/overlay`);
ws.onmessage = e => {
const msg = JSON.parse(e.data);
if (msg.type === 'execute') {
document.dispatchEvent(new CustomEvent(
`bashyoverlay:${msg.event_type || 'event'}`,
{ detail: msg }
));
enqueueAlert(msg);
}
};
ws.onclose = () => setTimeout(connectWS, 3000);
}
connectWS();
// ── Gap audio (Web Audio API — no file needed) ────────────────────────
function playGapBeep(gapSec) {
if (gapSec < GAP_SIL) return;
const vol = Math.min(1, (gapSec - GAP_SIL) / Math.max(1, GAP_VOL - GAP_SIL));
const pitch = 1 + Math.min(1, Math.max(0, (gapSec - GAP_VOL) / Math.max(1, GAP_PITCH - GAP_VOL)));
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 440 * pitch;
gain.gain.value = vol * 0.35;
osc.start();
osc.stop(ctx.currentTime + 0.25);
setTimeout(() => ctx.close(), 500);
}
// ── tmi.js chat ───────────────────────────────────────────────────────
const client = new tmi.Client({ channels: [CHANNEL] });
client.connect().catch(console.error);
client.on('message', (channel, tags, message, self) => {
const now = Date.now();
playGapBeep((now - lastMsgTime) / 1000);
lastMsgTime = now;
addChatMsg(tags['display-name'] || tags.username, message, tags.color);
if (message.startsWith('!') && ws.readyState === WebSocket.OPEN) {
const name = message.split(' ')[0].slice(1).toLowerCase();
if (name) ws.send(JSON.stringify({ type: 'command', name, user: tags['display-name'] || tags.username }));
}
});
// ── Chat display ──────────────────────────────────────────────────────
const chatEl = document.getElementById('chat');
function addChatMsg(username, message, color) {
const el = document.createElement('div');
el.className = 'chat-msg';
el.innerHTML = `<span class="uname" style="color:${color || '#9b59b6'}">${esc(username)}</span>: ${esc(message)}`;
chatEl.appendChild(el);
while (chatEl.children.length > MAX_MSG) chatEl.firstChild.remove();
}
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── Alert queue ───────────────────────────────────────────────────────
function enqueueAlert(msg) {
alertQueue.push(msg);
if (!alertBusy) nextAlert();
}
function nextAlert() {
if (!alertQueue.length) { alertBusy = false; return; }
alertBusy = true;
playAlert(alertQueue.shift());
}
function playAlert(msg) {
const alertEl = document.getElementById('alert');
const videoWrap = document.getElementById('alert-video-wrap');
const videoEl = document.getElementById('alert-video');
if (msg.alert_text) {
alertEl.textContent = msg.alert_text;
alertEl.style.display = 'block';
}
if (msg.action_type === 'sound' && msg.media_url) {
new Audio(msg.media_url).play().catch(() => {});
}
if (msg.action_type === 'video' && msg.media_url) {
videoEl.src = msg.media_url;
videoWrap.style.display = 'block';
videoEl.play().catch(() => {});
videoEl.onended = () => {
videoWrap.style.display = 'none';
videoEl.src = '';
alertEl.style.display = 'none';
alertEl.textContent = '';
nextAlert();
};
return;
}
setTimeout(() => {
alertEl.style.display = 'none';
alertEl.textContent = '';
nextAlert();
}, ALERT_DURATION);
}
</script>
{% if custom_js %}<script>{{ custom_js | safe }}</script>{% endif %}
</body>
</html>

View file

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="/static/overlay.css">
{% if custom_css %}<style>{{ custom_css | safe }}</style>{% endif %}
<style>
:root {
--chat-left: {{ layout.chat_left }}%;
--chat-top: {{ layout.chat_top }}%;
--chat-width: {{ layout.chat_width }}%;
--chat-height: {{ layout.chat_height }}%;
--chat-font-size: {{ layout.chat_font_size }}px;
--chat-bg-opacity: {{ layout.chat_bg_opacity }};
--chat-text-color: {{ layout.chat_text_color }};
--alert-left: {{ layout.alert_left }}%;
--alert-top: {{ layout.alert_top }}%;
--alert-font-size: {{ layout.alert_font_size }}px;
--alert-bg-opacity: {{ layout.alert_bg_opacity }};
--alert-text-color: {{ layout.alert_text_color }};
}
</style>
</head>
<body>
<div id="alert" style="display:none"></div>
<div id="alert-video-wrap" style="display:none"><video id="alert-video" playsinline></video></div>
<div id="chat">
<div class="chat-msg"><span class="uname" style="color:#9b59b6">StreamFriend</span>: Let's goooo! 🎉</div>
<div class="chat-msg"><span class="uname" style="color:#e74c3c">Viewer42</span>: PogChamp PogChamp</div>
<div class="chat-msg"><span class="uname" style="color:#2ecc71">ChatUser</span>: great stream today!</div>
<div class="chat-msg"><span class="uname" style="color:#f39c12">AnotherFan</span>: !airhorn</div>
<div class="chat-msg"><span class="uname" style="color:#3498db">NewViewer</span>: first time here!</div>
</div>
<script>
const ALERT_DURATION = {{ alert_duration_ms }};
let alertTimer = null;
function showMockAlert(text) {
const el = document.getElementById('alert');
el.style.display = 'none';
void el.offsetWidth;
el.textContent = text || 'StreamFan just subscribed! 🎉';
el.style.display = 'block';
if (alertTimer) clearTimeout(alertTimer);
alertTimer = setTimeout(() => { el.style.display = 'none'; }, ALERT_DURATION);
}
window.addEventListener('message', (e) => {
if (e.data?.type === 'preview-alert') showMockAlert(e.data.text);
});
</script>
{% if custom_js %}<script>{{ custom_js | safe }}</script>{% endif %}
</body>
</html>

View file

@ -0,0 +1,42 @@
<div class="action-row" id="action-{{ action.id }}">
<div class="action-info">
<code>{{ action.event_type }}{% if action.event_detail %}:{{ action.event_detail }}{% endif %}</code>
<strong>{{ action.action_type }}</strong>
{% if action.media_file %}
<span class="muted">{{ action.media_file.original_name }}</span>
{% endif %}
{% if action.alert_text %}
<span class="muted">"{{ action.alert_text }}"</span>
{% endif %}
{% if action.cooldown_seconds %}
<small class="muted">{{ action.cooldown_seconds }}s cooldown</small>
{% endif %}
{% if just_tested %}
<span class="tested-badge">✓ fired</span>
{% endif %}
</div>
<div class="action-controls">
<form hx-post="/actions/{{ action.id }}/test"
hx-target="#action-{{ action.id }}"
hx-swap="outerHTML"
style="display:inline">
<button type="submit" class="outline small-btn">Test</button>
</form>
<form hx-post="/actions/{{ action.id }}/toggle"
hx-target="#action-{{ action.id }}"
hx-swap="outerHTML"
style="display:inline">
<button type="submit" class="outline small-btn {% if not action.enabled %}secondary{% endif %}">
{{ 'on' if action.enabled else 'off' }}
</button>
</form>
<button hx-delete="/actions/{{ action.id }}"
hx-target="#action-{{ action.id }}"
hx-swap="outerHTML"
hx-confirm="Delete this action?"
class="outline secondary small-btn">
Delete
</button>
</div>
</div>

View file

@ -0,0 +1,18 @@
<div class="media-item" id="media-{{ file.id }}">
<div class="media-info">
<span class="media-type-badge {{ file.file_type }}">{{ file.file_type }}</span>
<strong>{{ file.original_name }}</strong>
</div>
{% if file.file_type == "audio" %}
<audio controls src="/static/media/{{ file.filename }}" preload="none"></audio>
{% else %}
<video controls src="/static/media/{{ file.filename }}" preload="none" style="max-width:200px;max-height:120px"></video>
{% endif %}
<button hx-delete="/media/{{ file.id }}"
hx-target="#media-{{ file.id }}"
hx-swap="outerHTML"
hx-confirm="Delete {{ file.original_name }}?"
class="outline secondary small-btn">
Delete
</button>
</div>

65
templates/settings.html Normal file
View file

@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}Settings — BashyOverlay{% endblock %}
{% block content %}
<hgroup>
<h1>Settings</h1>
</hgroup>
{% if saved %}
<div class="notice-success">Settings saved.</div>
{% endif %}
<section>
<h2>OBS browser source URL</h2>
<p>Add this as a browser source in OBS:</p>
<input type="text" value="{{ overlay_url }}" readonly onclick="this.select()" class="url-input">
<small>Refresh the browser source in OBS after changing settings.</small>
</section>
<section>
<form method="post" action="/settings">
<h2>Channel</h2>
<label>
Twitch channel name
<input type="text" name="twitch_channel" value="{{ settings.twitch_channel }}" required placeholder="your_channel">
</label>
<h2>Gap audio thresholds</h2>
<p><small>A tone plays when a new message arrives after a silence. These control how the volume and pitch scale with the gap length.</small></p>
<div class="form-grid">
<label>
Silence threshold (seconds)
<input type="number" name="gap_silence_threshold" value="{{ settings.gap_silence_threshold }}" min="0">
<small>No sound if gap is shorter than this.</small>
</label>
<label>
Full volume at (seconds)
<input type="number" name="gap_scale_end" value="{{ settings.gap_scale_end }}" min="1">
<small>Volume reaches 100% at this gap length.</small>
</label>
<label>
Max pitch at (seconds)
<input type="number" name="gap_pitch_scale_end" value="{{ settings.gap_pitch_scale_end }}" min="1">
<small>Pitch stops scaling up after this gap length.</small>
</label>
</div>
<h2>Custom CSS</h2>
<p><small>Injected into the overlay after the default styles. Overrides anything. Use <code>#chat</code>, <code>.chat-msg</code>, <code>#alert</code>.</small></p>
<textarea name="custom_css" class="code-textarea" rows="10" placeholder="/* example */&#10;#alert { border: 2px solid #9146ff; box-shadow: 0 0 20px #9146ff; }&#10;.chat-msg { border-left: 3px solid; }">{{ settings.custom_css }}</textarea>
<h2>Custom JS</h2>
<p><small>Injected after the overlay scripts. Hook into events with <code>document.addEventListener('bashyoverlay:sub', e => ...)</code>. Available events: <code>sub</code>, <code>gift_sub</code>, <code>bits</code>, <code>channel_points</code>, <code>command</code>, <code>follow</code>, <code>raid</code>.</small></p>
<textarea name="custom_js" class="code-textarea" rows="10" placeholder="// example&#10;document.addEventListener('bashyoverlay:raid', e => {&#10; console.log('Raid from', e.detail.alert_text);&#10;});">{{ settings.custom_js }}</textarea>
<button type="submit">Save settings</button>
</form>
</section>
<section>
<h2>EventSub</h2>
<form method="post" action="/settings/reconnect">
<button type="submit" class="outline">Reconnect EventSub</button>
</form>
</section>
{% endblock %}