Update 2025-05-21_08:58:06

This commit is contained in:
oib
2025-05-21 08:58:07 +02:00
parent 1011f58d00
commit 39934115a1
28 changed files with 2166 additions and 672 deletions

View File

@ -42,9 +42,9 @@ uvicorn main:app --reload
- `static/` — static HTML/JS/CSS assets
### Notes
- By default, audio uploads are stored in `/data` and streams in `/srv/streams` (change in code as needed).
- By default, audio uploads are stored in `/data`.
- Ollama music/singing detection requires a local Whisper API at `localhost:11434`.
- Abuse logs are written to `log.txt`.
- Abuse logs are written to `abuse.log`.
## License
MIT

0
__RELOAD__ Normal file
View File

37
concat_opus.py Normal file
View File

@ -0,0 +1,37 @@
# concat_opus.py — Concatenate all opus files in a user directory in random order into a single stream.opus
import os
import random
import subprocess
from pathlib import Path
def concat_opus_files(user_dir: Path, output_file: Path):
"""
Concatenate all .opus files in user_dir (except stream.opus) in random order into output_file.
Overwrites output_file if exists. Creates it if missing.
"""
files = [f for f in user_dir.glob('*.opus') if f.name != 'stream.opus']
if not files:
raise FileNotFoundError(f"No opus files to concatenate in {user_dir}")
random.shuffle(files)
# Create a filelist for ffmpeg concat
filelist_path = user_dir / 'filelist.txt'
with open(filelist_path, 'w') as f:
for opusfile in files:
f.write(f"file '{opusfile.resolve()}'\n")
# ffmpeg concat demuxer (no re-encoding)
cmd = [
'ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', str(filelist_path),
'-c', 'copy', str(output_file)
]
try:
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"FFmpeg concat failed: {e}")
finally:
if filelist_path.exists():
filelist_path.unlink()
if not output_file.exists():
raise RuntimeError("Concatenation did not produce output.")
return output_file

10
gunicorn.conf.py Normal file
View File

@ -0,0 +1,10 @@
bind = "0.0.0.0:8000"
workers = 2 # Tune based on available CPU cores
worker_class = "uvicorn.workers.UvicornWorker"
timeout = 60
keepalive = 30
loglevel = "info"
accesslog = "-"
errorlog = "-"
proxy_allow_ips = "*"

View File

@ -2,14 +2,63 @@
from fastapi import APIRouter
from pathlib import Path
from fastapi.responses import StreamingResponse
import asyncio
router = APIRouter()
DATA_ROOT = Path("./data")
@router.get("/streams")
@router.get("/streams-sse")
def streams_sse():
return list_streams_sse()
import json
import datetime
def list_streams_sse():
async def event_generator():
txt_path = Path("./public_streams.txt")
if not txt_path.exists():
print(f"[{datetime.datetime.now()}] [SSE] No public_streams.txt found")
yield f"data: {json.dumps({'end': True})}\n\n"
return
try:
with txt_path.open("r") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
stream = json.loads(line)
print(f"[{datetime.datetime.now()}] [SSE] Yielding stream: {stream}")
yield f"data: {json.dumps(stream)}\n\n"
await asyncio.sleep(0) # Yield control to event loop
except Exception as e:
print(f"[{datetime.datetime.now()}] [SSE] JSON decode error: {e}")
continue # skip malformed lines
print(f"[{datetime.datetime.now()}] [SSE] Yielding end event")
yield f"data: {json.dumps({'end': True})}\n\n"
except Exception as e:
print(f"[{datetime.datetime.now()}] [SSE] Exception: {e}")
yield f"data: {json.dumps({'end': True, 'error': True})}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
def list_streams():
streams = []
for user_dir in DATA_ROOT.iterdir():
if user_dir.is_dir() and (user_dir / "stream.opus").exists():
streams.append(user_dir.name)
return {"streams": streams}
txt_path = Path("./public_streams.txt")
if not txt_path.exists():
return {"streams": []}
try:
streams = []
with txt_path.open("r") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
streams.append(json.loads(line))
except Exception:
continue # skip malformed lines
return {"streams": streams}
except Exception:
return {"streams": []}

2
log.py
View File

@ -9,7 +9,7 @@ def log_violation(event: str, ip: str, uid: str, reason: str):
timestamp = datetime.utcnow().isoformat()
log_dir = os.path.join(os.path.dirname(__file__), "log")
os.makedirs(log_dir, exist_ok=True)
log_path = os.path.join(log_dir, "log.txt")
log_path = os.path.join(log_dir, "abuse.log")
log_entry = f"[{timestamp}] {event} IP={ip} UID={uid} REASON={reason}\n"
with open(log_path, "a") as f:
f.write(log_entry)

View File

@ -11,20 +11,24 @@ router = APIRouter()
@router.post("/magic-login")
def magic_login(request: Request, db: Session = Depends(get_db), token: str = Form(...)):
print(f"[magic-login] Received token: {token}")
user = db.exec(select(User).where(User.token == token)).first()
print(f"[magic-login] User lookup: {'found' if user else 'not found'}")
if not user:
print("[magic-login] Invalid or expired token")
return RedirectResponse(url="/?error=Invalid%20or%20expired%20token", status_code=302)
if user.confirmed:
return RedirectResponse(url="/?error=Token%20already%20used", status_code=302)
if datetime.utcnow() - user.token_created > timedelta(minutes=15):
if datetime.utcnow() - user.token_created > timedelta(minutes=30):
print(f"[magic-login] Token expired for user: {user.username}")
return RedirectResponse(url="/?error=Token%20expired", status_code=302)
user.confirmed = True
# record client IP on confirmation
user.ip = request.client.host
db.commit()
if not user.confirmed:
user.confirmed = True
user.ip = request.client.host
db.commit()
print(f"[magic-login] User {user.username} confirmed. Redirecting to /?login=success&confirmed_uid={user.username}")
else:
print(f"[magic-login] Token already used for user: {user.username}, but allowing multi-use login.")
return RedirectResponse(url=f"/?login=success&confirmed_uid={user.username}", status_code=302)

180
main.py
View File

@ -1,18 +1,24 @@
# main.py — FastAPI backend entrypoint for dicta2stream
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request
from fastapi.responses import JSONResponse, HTMLResponse
from fastapi import FastAPI, Request, Response, status, Form, UploadFile, File, Depends
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
import os
import subprocess
from log import log_violation
from models import get_user_by_uid
from sqlmodel import Session, SQLModel, select
import io
import traceback
import shutil
import mimetypes
from typing import Optional
from models import User, UploadLog
from sqlmodel import Session, select, SQLModel
from database import get_db, engine
from models import User, UserQuota
from fastapi import Depends
from log import log_violation
import secrets
import time
import json
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
@ -31,16 +37,72 @@ from fastapi.exception_handlers import RequestValidationError
from fastapi.exceptions import HTTPException as FastAPIHTTPException
app = FastAPI(debug=debug_mode)
# --- CORS Middleware for SSE and API access ---
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://dicta2stream.net", "http://localhost:8000", "http://127.0.0.1:8000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
from fastapi.staticfiles import StaticFiles
import os
if not os.path.exists("data"):
os.makedirs("data")
app.mount("/audio", StaticFiles(directory="data"), name="audio")
# Secure audio file serving endpoint (replaces static mount)
from fastapi.responses import FileResponse
from fastapi import Security
def get_current_user(request: Request, db: Session = Depends(get_db)):
# Use your existing session/cookie/token mechanism here
uid = request.headers.get("x-uid") or request.query_params.get("uid") or request.cookies.get("uid")
if not uid:
raise HTTPException(status_code=403, detail="Not authenticated")
user = get_user_by_uid(uid)
if not user or not user.confirmed:
raise HTTPException(status_code=403, detail="Invalid user")
return user
from range_response import range_response
@app.get("/audio/{uid}/{filename}")
def get_audio(uid: str, filename: str, request: Request, db: Session = Depends(get_db)):
# Allow public access ONLY to stream.opus
user_dir = os.path.join("data", uid)
file_path = os.path.join(user_dir, filename)
real_user_dir = os.path.realpath(user_dir)
real_file_path = os.path.realpath(file_path)
if not real_file_path.startswith(real_user_dir):
raise HTTPException(status_code=403, detail="Path traversal detected")
if not os.path.isfile(real_file_path):
raise HTTPException(status_code=404, detail="File not found")
if filename == "stream.opus":
# Use range_response for browser seeking support
return range_response(request, real_file_path, content_type="audio/ogg")
# Otherwise, require authentication and owner check
try:
from fastapi import Security
current_user = get_current_user(request, db)
except Exception:
raise HTTPException(status_code=403, detail="Not allowed")
if uid != current_user.username:
raise HTTPException(status_code=403, detail="Not allowed")
return FileResponse(real_file_path, media_type="audio/ogg")
if debug_mode:
print("[DEBUG] FastAPI running in debug mode.")
# Global error handler to always return JSON
from slowapi.errors import RateLimitExceeded
from models import get_user_by_uid, UserQuota
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
return JSONResponse(status_code=429, content={"detail": "Rate limit exceeded. Please try again later."})
@app.exception_handler(FastAPIHTTPException)
async def http_exception_handler(request: FastAPIRequest, exc: FastAPIHTTPException):
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
@ -57,20 +119,64 @@ async def generic_exception_handler(request: FastAPIRequest, exc: Exception):
from register import router as register_router
from magic import router as magic_router
from upload import router as upload_router
from redirect import router as redirect_router
from streams import router as streams_router
from list_user_files import router as list_user_files_router
app.include_router(streams_router)
from list_streams import router as list_streams_router
app.include_router(register_router)
app.include_router(magic_router)
app.include_router(upload_router)
app.include_router(redirect_router)
app.include_router(list_user_files_router)
app.include_router(list_streams_router)
# Serve static files
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.post("/log-client")
async def log_client(request: Request):
try:
data = await request.json()
msg = data.get("msg", "")
ip = request.client.host
timestamp = datetime.utcnow().isoformat()
log_dir = os.path.join(os.path.dirname(__file__), "log")
os.makedirs(log_dir, exist_ok=True)
log_path = os.path.join(log_dir, "debug.log")
log_entry = f"[{timestamp}] IP={ip} MSG={msg}\n"
with open(log_path, "a") as f:
f.write(log_entry)
if os.getenv("DEBUG", "0") in ("1", "true", "True"):
print(f"[CLIENT-DEBUG] {log_entry.strip()}")
return {"status": "ok"}
except Exception as e:
# Enhanced error logging
import sys
import traceback
error_log_dir = os.path.join(os.path.dirname(__file__), "log")
os.makedirs(error_log_dir, exist_ok=True)
error_log_path = os.path.join(error_log_dir, "debug-errors.log")
tb = traceback.format_exc()
try:
req_body = await request.body()
except Exception:
req_body = b"<failed to read body>"
error_entry = (
f"[{datetime.utcnow().isoformat()}] /log-client ERROR: {type(e).__name__}: {e}\n"
f"Request IP: {getattr(request.client, 'host', None)}\n"
f"Request body: {req_body}\n"
f"Traceback:\n{tb}\n"
)
try:
with open(error_log_path, "a") as ef:
ef.write(error_entry)
except Exception as ef_exc:
print(f"[CLIENT-DEBUG-ERROR] Failed to write error log: {ef_exc}", file=sys.stderr)
print(error_entry, file=sys.stderr)
return {"status": "error", "detail": str(e)}
@app.get("/", response_class=HTMLResponse)
def serve_index():
with open("static/index.html") as f:
@ -116,9 +222,6 @@ def debug(request: Request):
"headers": dict(request.headers),
}
STREAM_DIR = "/srv/streams"
ICECAST_BASE_URL = "https://dicta2stream.net/stream/"
ICECAST_MOUNT_PREFIX = "user-"
MAX_QUOTA_BYTES = 100 * 1024 * 1024
@app.post("/delete-account")
@ -142,47 +245,15 @@ async def delete_account(data: dict, request: Request, db: Session = Depends(get
db.commit()
import shutil
user_dir = os.path.join(STREAM_DIR, user.username)
# Only allow deletion within STREAM_DIR
user_dir = os.path.join('data', user.username)
real_user_dir = os.path.realpath(user_dir)
if not real_user_dir.startswith(os.path.realpath(STREAM_DIR)):
if not real_user_dir.startswith(os.path.realpath('data')):
raise HTTPException(status_code=400, detail="Invalid user directory")
if os.path.exists(real_user_dir):
shutil.rmtree(real_user_dir, ignore_errors=True)
return {"message": "User deleted"}
@app.post("/upload")
async def upload_audio(
request: Request,
uid: str = Form(...),
file: UploadFile = File(...)
):
ip = request.client.host
user = get_user_by_uid(uid)
if not user:
log_violation("UPLOAD", ip, uid, "UID not found")
raise HTTPException(status_code=403, detail="Invalid user ID")
if user.ip != ip:
log_violation("UPLOAD", ip, uid, "UID/IP mismatch")
raise HTTPException(status_code=403, detail="Device/IP mismatch")
user_dir = os.path.join(STREAM_DIR, user.username)
os.makedirs(user_dir, exist_ok=True)
raw_path = os.path.join(user_dir, "upload.wav")
final_path = os.path.join(user_dir, "stream.opus")
with open(raw_path, "wb") as out:
content = await file.read()
out.write(content)
usage = subprocess.check_output(["du", "-sb", user_dir]).split()[0]
if int(usage) > MAX_QUOTA_BYTES:
os.remove(raw_path)
log_violation("UPLOAD", ip, uid, "Quota exceeded")
raise HTTPException(status_code=403, detail="Quota exceeded")
from fastapi.concurrency import run_in_threadpool
# from detect_content_type_whisper_ollama import detect_content_type_whisper_ollama # Broken import: module not found
content_type = None
@ -214,8 +285,7 @@ async def upload_audio(
except Exception as e:
log_violation("QUOTA", ip, uid, f"Quota update failed: {e}")
stream_url = f"{ICECAST_BASE_URL}{ICECAST_MOUNT_PREFIX}{user.username}.opus"
return {"stream_url": stream_url}
return {}
@app.delete("/uploads/{uid}/{filename}")
def delete_file(uid: str, filename: str, request: Request, db: Session = Depends(get_db)):
@ -227,7 +297,7 @@ def delete_file(uid: str, filename: str, request: Request, db: Session = Depends
if user.ip != ip:
raise HTTPException(status_code=403, detail="Device/IP mismatch")
user_dir = os.path.join(STREAM_DIR, user.username)
user_dir = os.path.join('data', user.username)
target_path = os.path.join(user_dir, filename)
# Prevent path traversal attacks
real_target_path = os.path.realpath(target_path)
@ -268,7 +338,7 @@ def get_me(uid: str, request: Request, db: Session = Depends(get_db)):
if not user or user.ip != ip:
raise HTTPException(status_code=403, detail="Unauthorized access")
user_dir = os.path.join(STREAM_DIR, user.username)
user_dir = os.path.join('data', user.username)
files = []
if os.path.exists(user_dir):
for f in os.listdir(user_dir):
@ -280,7 +350,7 @@ def get_me(uid: str, request: Request, db: Session = Depends(get_db)):
quota_mb = round(q.storage_bytes / (1024 * 1024), 2) if q else 0
return {
"stream_url": f"{ICECAST_BASE_URL}{ICECAST_MOUNT_PREFIX}{user.username}.opus",
"files": files,
"quota": quota_mb
}

1
public_streams.txt Normal file
View File

@ -0,0 +1 @@
{"uid":"devuser","size":22455090,"mtime":1747563720}

60
range_response.py Normal file
View File

@ -0,0 +1,60 @@
# range_response.py — Range request support for audio files in FastAPI
import os
from fastapi import Request, HTTPException
from fastapi.responses import StreamingResponse
from typing import Generator
def parse_range_header(range_header, file_size):
if not range_header or not range_header.startswith('bytes='):
return None
ranges = range_header[6:].split(',')[0].strip().split('-')
if len(ranges) != 2:
return None
try:
start = int(ranges[0]) if ranges[0] else 0
end = int(ranges[1]) if ranges[1] else file_size - 1
if start > end or end >= file_size:
return None
return start, end
except ValueError:
return None
def file_stream_generator(path, start, end, chunk_size=8192) -> Generator[bytes, None, None]:
with open(path, 'rb') as f:
f.seek(start)
remaining = end - start + 1
while remaining > 0:
chunk = f.read(min(chunk_size, remaining))
if not chunk:
break
yield chunk
remaining -= len(chunk)
def range_response(request: Request, file_path: str, content_type: str = 'audio/ogg'):
file_size = os.path.getsize(file_path)
range_header = request.headers.get('range')
range_tuple = parse_range_header(range_header, file_size)
if range_tuple:
start, end = range_tuple
headers = {
'Content-Range': f'bytes {start}-{end}/{file_size}',
'Accept-Ranges': 'bytes',
'Content-Length': str(end - start + 1),
}
return StreamingResponse(
file_stream_generator(file_path, start, end),
status_code=206,
media_type=content_type,
headers=headers
)
else:
headers = {
'Accept-Ranges': 'bytes',
'Content-Length': str(file_size),
}
return StreamingResponse(
file_stream_generator(file_path, 0, file_size - 1),
status_code=200,
media_type=content_type,
headers=headers
)

View File

@ -1,16 +1,2 @@
# redirect.py — Short stream link: /stream/{uid} → /stream/{uid}/stream.opus
from fastapi import APIRouter, HTTPException
from fastapi.responses import RedirectResponse
from pathlib import Path
router = APIRouter()
DATA_ROOT = Path("/data")
@router.get("/stream/{uid}")
def redirect_to_stream(uid: str):
stream_path = DATA_ROOT / uid / "stream.opus"
if not stream_path.exists():
raise HTTPException(status_code=404, detail="Stream not found")
return RedirectResponse(f"/stream/{uid}/stream.opus")

View File

@ -15,14 +15,51 @@ MAGIC_DOMAIN = "https://dicta2stream.net"
@router.post("/register")
def register(request: Request, email: str = Form(...), user: str = Form(...), db: Session = Depends(get_db)):
if db.get(User, email):
raise HTTPException(status_code=400, detail="Email already registered")
from sqlalchemy.exc import IntegrityError
# Try to find user by email or username
existing_user = db.get(User, email)
if not existing_user:
# Try by username (since username is not primary key, need to query)
stmt = select(User).where(User.username == user)
existing_user = db.exec(stmt).first()
token = str(uuid.uuid4())
db.add(User(email=email, username=user, token=token, confirmed=False, ip=request.client.host))
db.add(UserQuota(uid=user))
db.commit()
if existing_user:
# Update token, timestamp, and ip, set confirmed False
from datetime import datetime
existing_user.token = token
existing_user.token_created = datetime.utcnow()
existing_user.confirmed = False
existing_user.ip = request.client.host
db.add(existing_user)
try:
db.commit()
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Database error: {e}")
else:
# Register new user
db.add(User(email=email, username=user, token=token, confirmed=False, ip=request.client.host))
db.add(UserQuota(uid=user))
try:
db.commit()
except Exception as e:
db.rollback()
if isinstance(e, IntegrityError):
# Race condition: user created after our check
# Try again as login
stmt = select(User).where((User.email == email) | (User.username == user))
existing_user = db.exec(stmt).first()
if existing_user:
existing_user.token = token
existing_user.confirmed = False
existing_user.ip = request.client.host
db.add(existing_user)
db.commit()
else:
raise HTTPException(status_code=409, detail="Username or email already exists.")
else:
raise HTTPException(status_code=500, detail=f"Database error: {e}")
# Send magic link
msg = EmailMessage()
msg["From"] = MAGIC_FROM
msg["To"] = email
@ -30,11 +67,9 @@ def register(request: Request, email: str = Form(...), user: str = Form(...), db
msg.set_content(
f"Hello {user},\n\nClick to confirm your account:\n{MAGIC_DOMAIN}/?token={token}\n\nThis link is valid for one-time login."
)
try:
with smtplib.SMTP("localhost") as smtp:
smtp.send_message(msg)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Email failed: {e}")
return { "message": "Confirmation sent" }

14
requirements.in Normal file
View File

@ -0,0 +1,14 @@
fastapi==0.115.12
httptools==0.6.4
pip==25.0.1
psycopg2-binary==2.9.10
python-dotenv==1.1.0
python-multipart==0.0.20
PyYAML==6.0.2
setuptools==66.1.1
slowapi==0.1.9
sqlmodel==0.0.24
uvicorn==0.34.2
uvloop==0.21.0
watchfiles==1.0.5
websockets==15.0.1

View File

@ -1,9 +1,75 @@
fastapi
uvicorn
sqlmodel
python-dotenv
slowapi
requests
smtplib
email
psycopg2-binary
#
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile requirements.in
#
annotated-types==0.6.0
# via pydantic
anyio==4.2.0
# via
# starlette
# watchfiles
click==8.1.3
# via uvicorn
deprecated==1.2.13
# via limits
fastapi==0.115.12
# via -r requirements.in
greenlet==3.2.1
# via sqlalchemy
h11==0.14.0
# via uvicorn
httptools==0.6.4
# via -r requirements.in
idna==3.4
# via anyio
limits==3.2.0
# via slowapi
packaging==23.0
# via limits
psycopg2-binary==2.9.10
# via -r requirements.in
pydantic==2.6.0
# via
# fastapi
# sqlmodel
pydantic-core==2.16.1
# via pydantic
python-dotenv==1.1.0
# via -r requirements.in
python-multipart==0.0.20
# via -r requirements.in
pyyaml==6.0.2
# via -r requirements.in
slowapi==0.1.9
# via -r requirements.in
sniffio==1.3.0
# via anyio
sqlalchemy==2.0.40
# via sqlmodel
sqlmodel==0.0.24
# via -r requirements.in
starlette==0.46.1
# via fastapi
typing-extensions==4.13.2
# via
# fastapi
# limits
# pydantic
# pydantic-core
# sqlalchemy
uvicorn==0.34.2
# via -r requirements.in
uvloop==0.21.0
# via -r requirements.in
watchfiles==1.0.5
# via -r requirements.in
websockets==15.0.1
# via -r requirements.in
wrapt==1.15.0
# via deprecated
# The following packages are considered to be unsafe in a requirements file:
# pip
# setuptools

View File

@ -1,27 +1,52 @@
// app.js — Frontend upload + minimal native player logic with slide-in and pulse effect
import { playBeep } from "./sound.js";
// 🔔 Minimal toast helper so calls to showToast() dont fail
function showToast(msg) {
const toast = document.createElement("div");
toast.className = "toast";
toast.textContent = msg;
toast.style.position = "fixed";
toast.style.bottom = "1.5rem";
toast.style.left = "50%";
toast.style.transform = "translateX(-50%)";
toast.style.background = "#333";
toast.style.color = "#fff";
toast.style.padding = "0.6em 1.2em";
toast.style.borderRadius = "6px";
toast.style.boxShadow = "0 2px 6px rgba(0,0,0,.2)";
toast.style.zIndex = 9999;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
import { playBeep } from "./sound.js";
import { showToast } from "./toast.js";
// Log debug messages to server
export function logToServer(msg) {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/log", true);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({ msg }));
}
// Expose for debugging
window.logToServer = logToServer;
// Handle magic link login redirect
(function handleMagicLoginRedirect() {
const params = new URLSearchParams(window.location.search);
if (params.get('login') === 'success' && params.get('confirmed_uid')) {
const username = params.get('confirmed_uid');
localStorage.setItem('uid', username);
logToServer(`[DEBUG] localStorage.setItem('uid', '${username}')`);
localStorage.setItem('confirmed_uid', username);
logToServer(`[DEBUG] localStorage.setItem('confirmed_uid', '${username}')`);
const uidTime = Date.now().toString();
localStorage.setItem('uid_time', uidTime);
logToServer(`[DEBUG] localStorage.setItem('uid_time', '${uidTime}')`);
// Set uid as cookie for backend authentication
document.cookie = "uid=" + encodeURIComponent(username) + "; path=/";
// Remove query params from URL
window.history.replaceState({}, document.title, window.location.pathname);
// Reload to show dashboard as logged in
location.reload();
return;
}
})();
document.addEventListener("DOMContentLoaded", () => {
// (Removed duplicate logToServer definition)
// Guest vs. logged-in toggling is now handled by dashboard.js
// --- Public profile view logic ---
function showProfilePlayerFromUrl() {
@ -43,373 +68,303 @@ document.addEventListener("DOMContentLoaded", () => {
if (meHeading) meHeading.textContent = `${profileUid}'s Stream 🎙️`;
const meDesc = document.querySelector("#me-page p");
if (meDesc) meDesc.textContent = `This is ${profileUid}'s public stream.`;
// Load playlist for the given profileUid
loadProfilePlaylist(profileUid);
// Show a Play Stream button for explicit user action
const streamInfo = document.getElementById("stream-info");
if (streamInfo) {
streamInfo.innerHTML = "";
const playBtn = document.createElement('button');
playBtn.textContent = "▶ Play Stream";
playBtn.onclick = () => {
loadProfileStream(profileUid);
playBtn.disabled = true;
};
streamInfo.appendChild(playBtn);
streamInfo.hidden = false;
}
// Do NOT call loadProfileStream(profileUid) automatically!
}
}
}
// Run on popstate (SPA navigation and browser back/forward)
window.addEventListener('popstate', showProfilePlayerFromUrl);
async function loadProfilePlaylist(uid) {
const meAudio = document.getElementById("me-audio");
if (!meAudio) return;
const resp = await fetch(`/user-files/${encodeURIComponent(uid)}`);
const data = await resp.json();
if (!data.files || !Array.isArray(data.files) || data.files.length === 0) {
meAudio.src = "";
return;
}
// Shuffle playlist
function shuffle(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
window.mePlaylist = shuffle(data.files.map(f => `/audio/${uid}/${f}`));
window.mePlaylistIdx = 0;
const newSrc = window.mePlaylist[window.mePlaylistIdx];
meAudio.src = newSrc;
meAudio.load();
meAudio.play().catch(() => {/* autoplay may be blocked, ignore */});
// --- Only run showProfilePlayerFromUrl after session/profile checks are complete ---
function runProfilePlayerIfSessionValid() {
if (typeof checkSessionValidity === "function" && !checkSessionValidity()) return;
showProfilePlayerFromUrl();
}
window.loadProfilePlaylist = loadProfilePlaylist;
document.addEventListener("DOMContentLoaded", () => {
setTimeout(runProfilePlayerIfSessionValid, 200);
});
window.addEventListener('popstate', () => {
setTimeout(runProfilePlayerIfSessionValid, 200);
});
window.showProfilePlayerFromUrl = showProfilePlayerFromUrl;
// --- Playlist for #me-page ---
const mePageLink = document.getElementById("show-me");
const meAudio = document.getElementById("me-audio");
const copyUrlBtn = document.getElementById("copy-url");
if (copyUrlBtn) copyUrlBtn.onclick = () => {
const uid = localStorage.getItem("uid");
if (uid) {
const streamUrl = `${window.location.origin}/stream/${encodeURIComponent(uid)}`;
navigator.clipboard.writeText(streamUrl);
showToast(`Copied your stream URL: ${streamUrl}`);
} else {
showToast("No user stream URL available");
// Global audio state
let globalAudio = null;
let currentStreamUid = null;
let audioPlaying = false;
let lastPosition = 0;
// Expose main audio element for other scripts
window.getMainAudio = () => globalAudio;
window.stopMainAudio = () => {
if (globalAudio) {
globalAudio.pause();
audioPlaying = false;
updatePlayPauseButton();
}
};
let mePlaylist = [];
let mePlaylistIdx = 0;
// Playlist UI is hidden, so do not render
function getOrCreateAudioElement() {
if (!globalAudio) {
globalAudio = document.getElementById('me-audio');
if (!globalAudio) {
console.error('Audio element not found');
return null;
}
// Set up audio element properties
globalAudio.preload = 'metadata'; // Preload metadata for better performance
globalAudio.crossOrigin = 'use-credentials'; // Use credentials for authenticated requests
globalAudio.setAttribute('crossorigin', 'use-credentials'); // Explicitly set the attribute
// Set up event listeners
globalAudio.addEventListener('play', () => {
audioPlaying = true;
updatePlayPauseButton();
});
globalAudio.addEventListener('pause', () => {
audioPlaying = false;
updatePlayPauseButton();
});
globalAudio.addEventListener('timeupdate', () => lastPosition = globalAudio.currentTime);
// Add error handling
globalAudio.addEventListener('error', (e) => {
console.error('Audio error:', e);
showToast('❌ Audio playback error');
});
}
return globalAudio;
}
// Function to update play/pause button state
function updatePlayPauseButton() {
const audio = getOrCreateAudioElement();
if (playPauseButton && audio) {
playPauseButton.textContent = audio.paused ? '▶' : '⏸️';
}
}
// Initialize play/pause button
const playPauseButton = document.getElementById('play-pause');
if (playPauseButton) {
// Set initial state
updatePlayPauseButton();
// Add click handler
playPauseButton.addEventListener('click', () => {
const audio = getOrCreateAudioElement();
if (audio) {
if (audio.paused) {
// Stop any playing public streams first
const publicPlayers = document.querySelectorAll('.stream-player audio');
publicPlayers.forEach(player => {
if (!player.paused) {
player.pause();
const button = player.closest('.stream-player').querySelector('.play-pause');
if (button) {
button.textContent = '▶';
}
}
});
audio.play().catch(e => {
console.error('Play failed:', e);
audioPlaying = false;
});
} else {
audio.pause();
}
updatePlayPauseButton();
}
});
}
// Preload audio without playing it
function preloadAudio(src) {
return new Promise((resolve) => {
const audio = new Audio();
audio.preload = 'auto';
audio.crossOrigin = 'anonymous';
audio.src = src;
audio.load();
audio.oncanplaythrough = () => resolve(audio);
});
}
// Load and play a stream
async function loadProfileStream(uid) {
const audio = getOrCreateAudioElement();
if (!audio) return null;
// Always reset current stream and update audio source
currentStreamUid = uid;
audio.pause();
audio.src = '';
// Wait a moment to ensure the previous source is cleared
await new Promise(resolve => setTimeout(resolve, 50));
// Set new source with cache-busting timestamp
audio.src = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${Date.now()}`;
// Try to play immediately
try {
await audio.play();
audioPlaying = true;
} catch (e) {
console.error('Play failed:', e);
audioPlaying = false;
}
// Show stream info
const streamInfo = document.getElementById("stream-info");
if (streamInfo) streamInfo.hidden = false;
// Update button state
updatePlayPauseButton();
return audio;
}
// Load and play a stream
async function loadProfileStream(uid) {
const audio = getOrCreateAudioElement();
if (!audio) return null;
// Hide playlist controls
const mePrevBtn = document.getElementById("me-prev");
if (mePrevBtn) mePrevBtn.style.display = "none";
const meNextBtn = document.getElementById("me-next");
if (meNextBtn) meNextBtn.style.display = "none";
async function loadUserPlaylist() {
const uid = localStorage.getItem("uid");
if (!uid) return;
const resp = await fetch(`/user-files/${encodeURIComponent(uid)}`);
const data = await resp.json();
if (!data.files || !Array.isArray(data.files) || data.files.length === 0) {
meAudio.src = "";
return;
}
// Shuffle playlist
function shuffle(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
mePlaylist = shuffle(data.files.map(f => `/audio/${uid}/${f}`));
mePlaylistIdx = 0;
const newSrc = mePlaylist[mePlaylistIdx];
const prevSrc = meAudio.src;
const wasPlaying = !meAudio.paused && !meAudio.ended && meAudio.currentTime > 0;
const fullNewSrc = window.location.origin + newSrc;
if (prevSrc !== fullNewSrc) {
meAudio.src = newSrc;
meAudio.load();
} // else: do nothing, already loaded
// Don't call load() if already playing the correct file
// Don't call load() redundantly
// Don't set src redundantly
// This prevents DOMException from fetch aborts
}
// Handle navigation to "Your Stream"
const mePageLink = document.getElementById("show-me");
if (mePageLink) {
mePageLink.addEventListener("click", async (e) => {
e.preventDefault();
const uid = localStorage.getItem("uid");
if (!uid) return;
if (mePageLink && meAudio) {
mePageLink.addEventListener("click", async () => {
await loadUserPlaylist();
// Start playback from current index
if (mePlaylist.length > 0) {
const newSrc = mePlaylist[mePlaylistIdx];
const prevSrc = meAudio.src;
const fullNewSrc = window.location.origin + newSrc;
if (prevSrc !== fullNewSrc) {
meAudio.src = newSrc;
meAudio.load();
// Show loading state
const streamInfo = document.getElementById("stream-info");
if (streamInfo) {
streamInfo.hidden = false;
streamInfo.innerHTML = '<p>Loading stream...</p>';
}
try {
// Load the stream but don't autoplay
await loadProfileStream(uid);
// Update URL without triggering a full page reload
if (window.location.pathname !== '/') {
window.history.pushState({}, '', '/');
}
meAudio.play();
}
});
meAudio.addEventListener("ended", () => {
if (mePlaylist.length > 1) {
mePlaylistIdx = (mePlaylistIdx + 1) % mePlaylist.length;
meAudio.src = mePlaylist[mePlaylistIdx];
meAudio.load();
meAudio.play();
} else if (mePlaylist.length === 1) {
// Only one file: restart
meAudio.currentTime = 0;
meAudio.load();
meAudio.play();
}
});
// Detect player stop and random play a new track
meAudio.addEventListener("pause", () => {
// Only trigger if playback reached the end and playlist has more than 1 track
if (meAudio.ended && mePlaylist.length > 1) {
let nextIdx;
do {
nextIdx = Math.floor(Math.random() * mePlaylist.length);
} while (nextIdx === mePlaylistIdx && mePlaylist.length > 1);
mePlaylistIdx = nextIdx;
meAudio.currentTime = 0;
meAudio.src = mePlaylist[mePlaylistIdx];
meAudio.load();
meAudio.play();
// Show the me-page section
const mePage = document.getElementById('me-page');
if (mePage) {
document.querySelectorAll('main > section').forEach(s => s.hidden = s.id !== 'me-page');
}
// Clear loading state
const streamInfo = document.getElementById('stream-info');
if (streamInfo) {
streamInfo.innerHTML = '';
}
} catch (error) {
console.error('Error loading stream:', error);
const streamInfo = document.getElementById('stream-info');
if (streamInfo) {
streamInfo.innerHTML = '<p>Error loading stream. Please try again.</p>';
}
}
});
}
const deleteBtn = document.getElementById("delete-account");
if (deleteBtn) deleteBtn.onclick = async () => {
if (!confirm("Are you sure you want to delete your account and all uploaded audio?")) return;
const res = await fetch("/delete-account", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uid })
});
if (res.ok) {
showToast("✅ Account deleted");
localStorage.removeItem("uid");
setTimeout(() => window.location.reload(), 2000);
} else {
const msg = (await res.json()).detail || res.status;
showToast("❌ Delete failed: " + msg);
}
};
// Always reset current stream and update audio source
currentStreamUid = uid;
audio.pause();
audio.src = '';
const fadeAllSections = () => {
const uid = localStorage.getItem('uid');
document.querySelectorAll("main > section").forEach(section => {
// Always keep upload-area visible for logged-in users
if (uid && section.id === 'upload-area') return;
if (!section.hidden) {
section.classList.add("fade-out");
setTimeout(() => {
section.classList.remove("fade-out");
section.hidden = true;
}, 300);
}
});
};
// Wait a moment to ensure the previous source is cleared
await new Promise(resolve => setTimeout(resolve, 50));
const dropzone = document.getElementById("upload-area");
dropzone.setAttribute("aria-label", "Upload area. Click or drop an audio file to upload.");
const fileInput = document.getElementById("fileInput");
const fileInfo = document.createElement("div");
fileInfo.id = "file-info";
fileInfo.style.textAlign = "center";
fileInput.parentNode.insertBefore(fileInfo, fileInput.nextSibling);
// Set new source with cache-busting timestamp
audio.src = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${Date.now()}`;
// Try to play immediately
try {
await audio.play();
audioPlaying = true;
} catch (e) {
console.error('Play failed:', e);
audioPlaying = false;
}
// Show stream info
const streamInfo = document.getElementById("stream-info");
const streamUrlEl = document.getElementById("streamUrl");
if (streamInfo) streamInfo.hidden = false;
const status = document.getElementById("status");
const spinner = document.getElementById("spinner");
// Update button state
updatePlayPauseButton();
const uid = localStorage.getItem("uid");
const uidTime = parseInt(localStorage.getItem("uid_time"), 10);
const now = Date.now();
// Hide register button if logged in
const registerBtn = document.getElementById("show-register");
if (uid && localStorage.getItem("confirmed_uid") === uid && uidTime && (now - uidTime) < 3600000) {
if (registerBtn) registerBtn.style.display = "none";
} else {
if (registerBtn) registerBtn.style.display = "";
}
if (!uid || !uidTime || (now - uidTime) > 3600000) {
localStorage.removeItem("uid");
localStorage.removeItem("confirmed_uid");
localStorage.removeItem("uid_time");
status.className = "error-toast";
status.innerText = "❌ Session expired. Please log in again.";
// Add Login or Register button only for this error
let loginBtn = document.createElement('button');
loginBtn.textContent = 'Login or Register';
loginBtn.className = 'login-register-btn';
loginBtn.onclick = () => {
document.querySelectorAll('main > section').forEach(sec => sec.hidden = sec.id !== 'register-page');
};
status.appendChild(document.createElement('br'));
status.appendChild(loginBtn);
// Remove the status div after a short delay so only toast remains
setTimeout(() => {
if (status.parentNode) status.parentNode.removeChild(status);
}, 100);
return;
}
const confirmed = localStorage.getItem("confirmed_uid");
if (!confirmed || uid !== confirmed) {
status.className = "error-toast";
status.innerText = "❌ Please confirm your account via email first.";
showToast(status.innerText);
return;
}
return audio;
}
let abortController;
// Export the function for use in other modules
window.loadProfileStream = loadProfileStream;
const upload = async (file) => {
if (abortController) abortController.abort();
abortController = new AbortController();
fileInfo.innerText = `📁 ${file.name}${(file.size / 1024 / 1024).toFixed(2)} MB`;
if (file.size > 100 * 1024 * 1024) {
status.className = "error-toast";
status.innerText = "❌ Session expired. Please log in again.";
// Add Login or Register button only for this error
let loginBtn = document.createElement('button');
loginBtn.textContent = 'Login or Register';
loginBtn.className = 'login-register-btn';
loginBtn.onclick = () => {
document.querySelectorAll('main > section').forEach(sec => sec.hidden = sec.id !== 'register-page');
};
status.appendChild(document.createElement('br'));
status.appendChild(loginBtn);
showToast(status.innerText);
return;
}
spinner.style.display = "block";
status.innerHTML = '📡 Uploading…';
status.className = "uploading-toast";
fileInput.disabled = true;
dropzone.classList.add("uploading");
const formData = new FormData();
formData.append("uid", uid);
formData.append("file", file);
const res = await fetch("/upload", {
signal: abortController.signal,
method: "POST",
body: formData,
document.addEventListener("DOMContentLoaded", () => {
// Initialize play/pause button
const playPauseButton = document.getElementById('play-pause');
if (playPauseButton) {
// Set initial state
audioPlaying = false;
updatePlayPauseButton();
// Add event listener
playPauseButton.addEventListener('click', () => {
const audio = getMainAudio();
if (audio) {
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
updatePlayPauseButton();
}
});
}
let data, parseError;
try {
data = await res.json();
} catch (e) {
parseError = e;
}
if (!data) {
status.className = "error-toast";
status.innerText = "❌ Upload failed: " + (parseError && parseError.message ? parseError.message : "Unknown error");
showToast(status.innerText);
spinner.style.display = "none";
fileInput.disabled = false;
dropzone.classList.remove("uploading");
return;
}
if (res.ok) {
status.className = "success-toast";
streamInfo.hidden = false;
streamInfo.innerHTML = `
<p>Your stream is now live:</p>
<audio controls id="me-audio" aria-label="Stream audio player. Your uploaded voice loop plays here.">
<p style='font-size: 0.9em; text-align: center;'>🔁 This stream loops forever</p>
<source src="${data.stream_url}" type="audio/ogg">
</audio>
<p><a href="${data.stream_url}" target="_blank" class="button" aria-label="Open your stream in a new tab">Open in external player</a></p>
`;
const meAudio = document.getElementById("me-audio");
meAudio.addEventListener("ended", () => {
if (mePlaylist.length > 1) {
mePlaylistIdx = (mePlaylistIdx + 1) % mePlaylist.length;
meAudio.src = mePlaylist[mePlaylistIdx];
meAudio.load();
meAudio.play();
meUrl.value = mePlaylist[mePlaylistIdx];
} else if (mePlaylist.length === 1) {
// Only one file: restart
meAudio.currentTime = 0;
meAudio.load();
meAudio.play();
}
});
if (data.quota && data.quota.used_mb !== undefined) {
const bar = document.getElementById("quota-bar");
const text = document.getElementById("quota-text");
const quotaSec = document.getElementById("quota-meter");
if (bar && text && quotaSec) {
quotaSec.hidden = false;
const used = parseFloat(data.quota.used_mb);
bar.value = used;
bar.max = 100;
text.textContent = `${used.toFixed(1)} MB used`;
}
// Add bot protection for registration form
const registerForm = document.getElementById('register-form');
if (registerForm) {
registerForm.addEventListener('submit', (e) => {
const botTrap = e.target.elements.bot_trap;
if (botTrap && botTrap.value) {
e.preventDefault();
showToast('❌ Bot detected! Please try again.');
return false;
}
spinner.style.display = "none";
fileInput.disabled = false;
dropzone.classList.remove("uploading");
showToast(status.innerText);
status.innerText = "✅ Upload successful.";
playBeep(432, 0.25, "sine");
setTimeout(() => status.innerText = "", 5000);
streamInfo.classList.add("visible", "slide-in");
} else {
streamInfo.hidden = true;
status.className = "error-toast";
spinner.style.display = "none";
if ((data.detail || data.error || "").includes("music")) {
status.innerText = "🎵 Upload rejected: singing or music detected.";
} else {
status.innerText = `❌ Upload failed: ${data.detail || data.error}`;
}
showToast(status.innerText);
fileInput.value = null;
dropzone.classList.remove("uploading");
fileInput.disabled = false;
streamInfo.classList.remove("visible", "slide-in");
}
};
dropzone.addEventListener("click", () => {
console.log("[DEBUG] Dropzone clicked");
fileInput.click();
console.log("[DEBUG] fileInput.click() called");
});
dropzone.addEventListener("dragover", (e) => {
e.preventDefault();
dropzone.classList.add("dragover");
dropzone.style.transition = "background-color 0.3s ease";
});
dropzone.addEventListener("dragleave", () => {
dropzone.classList.remove("dragover");
});
dropzone.addEventListener("drop", (e) => {
dropzone.classList.add("pulse");
setTimeout(() => dropzone.classList.remove("pulse"), 400);
e.preventDefault();
dropzone.classList.remove("dragover");
const file = e.dataTransfer.files[0];
if (file) upload(file);
});
fileInput.addEventListener("change", (e) => {
status.innerText = "";
status.className = "";
const file = e.target.files[0];
if (file) upload(file);
});
return true;
});
}
// Initialize navigation
document.querySelectorAll('#links a[data-target]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
@ -426,4 +381,34 @@ document.addEventListener("DOMContentLoaded", () => {
if (burger && burger.checked) burger.checked = false;
});
});
// Initialize profile player if valid session
setTimeout(runProfilePlayerIfSessionValid, 200);
window.addEventListener('popstate', () => {
setTimeout(runProfilePlayerIfSessionValid, 200);
});
});
// Initialize navigation
document.querySelectorAll('#links a[data-target]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = link.getAttribute('data-target');
// Only hide other sections when not opening #me-page
if (target !== 'me-page') fadeAllSections();
const section = document.getElementById(target);
if (section) {
section.hidden = false;
section.classList.add("slide-in");
section.scrollIntoView({ behavior: "smooth" });
}
const burger = document.getElementById('burger-toggle');
if (burger && burger.checked) burger.checked = false;
});
});
// Initialize profile player if valid session
setTimeout(runProfilePlayerIfSessionValid, 200);
window.addEventListener('popstate', () => {
setTimeout(runProfilePlayerIfSessionValid, 200);
});
});

View File

@ -1,28 +1,34 @@
import { showToast } from "./toast.js";
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
// dashboard.js — toggle guest vs. user dashboard and reposition streams link
async function initDashboard() {
const uploadArea = document.querySelector('#upload-area');
const userDashboard = document.querySelector('#me-page');
const meAudio = document.querySelector('#me-audio');
const quotaBar = document.querySelector('#quota-bar');
const quotaText = document.querySelector('#quota-text');
const streamsLink = document.querySelector('#show-streams');
const registerLink = document.querySelector('#show-register');
// New dashboard toggling logic
const guestDashboard = document.getElementById('guest-dashboard');
const userDashboard = document.getElementById('user-dashboard');
const userUpload = document.getElementById('user-upload-area');
// Default state: hide both
uploadArea.hidden = true;
userDashboard.hidden = true;
// Hide all by default
if (guestDashboard) guestDashboard.style.display = 'none';
if (userDashboard) userDashboard.style.display = 'none';
if (userUpload) userUpload.style.display = 'none';
const uid = localStorage.getItem('uid');
const uid = getCookie('uid');
if (!uid) {
// Guest: only upload area and move Streams next to Register
uploadArea.hidden = false;
userDashboard.hidden = true;
if (registerLink && streamsLink) {
registerLink.parentElement.insertAdjacentElement('afterend', streamsLink.parentElement);
}
return;
}
// Guest view: only nav
if (guestDashboard) guestDashboard.style.display = '';
if (userDashboard) userDashboard.style.display = 'none';
if (userUpload) userUpload.style.display = 'none';
const mePage = document.getElementById('me-page');
if (mePage) mePage.style.display = 'none';
return;
}
try {
const res = await fetch(`/me/${uid}`);
@ -30,22 +36,46 @@ async function initDashboard() {
const data = await res.json();
// Logged-in view
uploadArea.hidden = false;
userDashboard.hidden = false;
// Restore links section and show-me link
const linksSection = document.getElementById('links');
if (linksSection) linksSection.style.display = '';
const showMeLink = document.getElementById('show-me');
if (showMeLink && showMeLink.parentElement) showMeLink.parentElement.style.display = '';
// Show me-page for logged-in users
const mePage = document.getElementById('me-page');
if (mePage) mePage.style.display = '';
// Ensure upload area is visible if last_page was me-page
const userUpload = document.getElementById('user-upload-area');
if (userUpload && localStorage.getItem('last_page') === 'me-page') {
// userUpload visibility is now only controlled by nav.js SPA logic
}
// Remove guest warning if present
const guestMsg = document.getElementById('guest-warning-msg');
if (guestMsg && guestMsg.parentNode) guestMsg.parentNode.removeChild(guestMsg);
userDashboard.style.display = '';
// Set audio source
meAudio.src = data.stream_url;
const meAudio = document.getElementById('me-audio');
if (meAudio && uid) {
meAudio.src = `/audio/${encodeURIComponent(uid)}/stream.opus`;
}
// Update quota
quotaBar.value = data.quota;
quotaText.textContent = `${data.quota} MB used`;
const quotaBar = document.getElementById('quota-bar');
const quotaText = document.getElementById('quota-text');
if (quotaBar) quotaBar.value = data.quota;
if (quotaText) quotaText.textContent = `${data.quota} MB used`;
// Ensure Streams link remains in nav, not moved
// (No action needed if static)
} catch (e) {
console.warn('Dashboard init error, treating as guest:', e);
localStorage.removeItem('uid');
uploadArea.hidden = false;
userDashboard.hidden = true;
userUpload.style.display = '';
userDashboard.style.display = 'none';
const registerLink = document.getElementById('guest-login');
const streamsLink = document.getElementById('guest-streams');
if (registerLink && streamsLink) {
registerLink.parentElement.insertAdjacentElement('afterend', streamsLink.parentElement);
}
@ -53,3 +83,111 @@ async function initDashboard() {
}
document.addEventListener('DOMContentLoaded', initDashboard);
// Registration form handler for guests
// Handles the submit event on #register-form, sends data to /register, and alerts the user with the result
document.addEventListener('DOMContentLoaded', () => {
const regForm = document.getElementById('register-form');
if (regForm) {
regForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(regForm);
try {
const res = await fetch('/register', {
method: 'POST',
body: formData
});
let data;
const contentType = res.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
data = await res.json();
} else {
data = { detail: await res.text() };
}
if (res.ok) {
showToast('Confirmation sent! Check your email.');
} else {
showToast('Registration failed: ' + (data.detail || res.status));
}
} catch (err) {
showToast('Network error: ' + err);
}
});
}
});
// Connect Login or Register link to register form
document.addEventListener('DOMContentLoaded', () => {
// Login/Register (guest)
const loginLink = document.getElementById('guest-login');
if (loginLink) {
loginLink.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll('main > section').forEach(sec => {
sec.hidden = sec.id !== 'register-page';
});
const reg = document.getElementById('register-page');
if (reg) reg.hidden = false;
reg.scrollIntoView({behavior:'smooth'});
});
}
// Terms of Service (all dashboards)
const termsLinks = [
document.getElementById('guest-terms'),
document.getElementById('user-terms')
];
termsLinks.forEach(link => {
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll('main > section').forEach(sec => {
sec.hidden = sec.id !== 'terms-page';
});
const terms = document.getElementById('terms-page');
if (terms) terms.hidden = false;
terms.scrollIntoView({behavior:'smooth'});
});
}
});
// Imprint (all dashboards)
const imprintLinks = [
document.getElementById('guest-imprint'),
document.getElementById('user-imprint')
];
imprintLinks.forEach(link => {
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll('main > section').forEach(sec => {
sec.hidden = sec.id !== 'imprint-page';
});
const imprint = document.getElementById('imprint-page');
if (imprint) imprint.hidden = false;
imprint.scrollIntoView({behavior:'smooth'});
});
}
});
// Privacy Policy (all dashboards)
const privacyLinks = [
document.getElementById('guest-privacy'),
document.getElementById('user-privacy')
];
privacyLinks.forEach(link => {
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll('main > section').forEach(sec => {
sec.hidden = sec.id !== 'privacy-page';
});
const privacy = document.getElementById('privacy-page');
if (privacy) privacy.hidden = false;
privacy.scrollIntoView({behavior:'smooth'});
});
}
});
});

View File

@ -2,12 +2,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="/static/style.css" media="all" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎙️</text></svg>">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="dicta2stream is a minimalist voice streaming platform for looping your spoken audio anonymously." />
<title>dicta2stream</title>
<link rel="stylesheet" href="/static/style.css" />
<!-- Responsive burger menu display -->
<style>
#burger-label, #burger-toggle { display: none; }
@ -30,67 +30,68 @@
<main>
<section id="upload-area" class="dropzone">
<p>🎙 Drag & drop your audio file here<br>or click to browse</p>
<input type="file" id="fileInput" accept="audio/*" hidden />
<!-- Guest Dashboard -->
<nav id="guest-dashboard" class="dashboard-nav">
<a href="#" id="guest-welcome" data-target="welcome-page">Welcome</a> |
<a href="#" id="guest-streams" data-target="stream-page">Streams</a> |
<a href="#" id="guest-login" data-target="register-page">Login or Register</a>
</nav>
<!-- User Dashboard -->
<nav id="user-dashboard" class="dashboard-nav" style="display:none;">
<a href="#" id="user-welcome" data-target="welcome-page">Welcome</a> |
<a href="#" id="user-streams" data-target="stream-page">Streams</a> |
<a href="#" id="show-me" data-target="me-page">Your Stream</a>
</nav>
<section id="me-page">
<article>
<h2>Your Stream 🎙️</h2>
<p>This is your personal stream. Only you can upload to it.</p>
<audio id="me-audio"></audio>
<div class="audio-controls">
<button id="play-pause" type="button">▶️</button>
</div>
</article>
<section id="user-upload-area" class="dropzone">
<p>🎙 Drag & drop your audio file here<br>or click to browse</p>
<input type="file" id="fileInputUser" accept="audio/*" hidden />
</section>
</section>
<div id="spinner" class="spinner">
<div id="spinner" class="spinner"></div>
</div>
<div id="status"></div>
<section id="stream-info" hidden>
<p>Your loop stream:</p>
<code id="streamUrl">...</code>
<audio controls id="player" loop></audio>
<p><button id="delete-account" class="delete-account">🗑️ Delete My Account</button></p>
</section>
<input type="checkbox" id="burger-toggle" hidden>
<label for="burger-toggle" id="burger-label" aria-label="Menu">
<span></span>
<span></span>
<span></span>
</label>
<section id="links">
<p><a href="#" id="show-me" data-target="me-page">Your Stream</a></p>
<p><a href="#" id="show-register" data-target="register-page">Login or Register</a></p>
<p>
<a href="#" id="show-terms" data-target="terms-page">Terms of Service</a> |
<a href="#" id="show-imprint" data-target="imprint-page">Imprint</a> |
<a href="#" id="show-privacy" data-target="privacy-page">Privacy Policy</a> |
<a href="#" id="show-streams" data-target="stream-page">Streams</a>
</p>
</section>
<!-- Burger menu and legacy links section removed for clarity -->
<section id="terms-page" hidden>
<article>
<h2>Terms of Service</h2>
<p><em>Last updated: April 18, 2025</em></p>
<p>By accessing or using dicta2stream.net (the “Service”), you agree to be bound by these Terms of Service (“Terms”). If you do not agree, do not use the Service.</p>
<ul>
<li>You must be at least 18 years old to register.</li>
<li>UID in localStorage must be uniquely yours.</li>
<li>Each account must be unique and used by only one person.</li>
<li>One account per device/IP per 24 hours.</li>
<li>If hate speech, illegal, or harmful content is detected, the account and all associated data will be deleted.</li>
<li>The associated email address will be banned from recreating an account.</li>
<li>Uploads are limited to 100 MB and must be voice only.</li>
<li>Music/singing will be rejected.</li>
</ul>
<p>Uploads are limited to <strong>100 MB</strong> and must be <strong>voice only</strong>. Music/singing will be rejected. Your stream will loop publicly and anonymously via Icecast.</p>
<p>See full legal terms in the Git repository or request via support@dicta2stream.net.</p>
<p><a href="#" data-back="upload-area">← Back</a></p>
</article>
</section>
<section id="privacy-page" hidden>
<article>
<h2>Privacy Policy</h2>
<p><em>Last updated: April 18, 2025</em></p>
<ul>
<li>No cookies. UID is stored locally only.</li>
<li><strong>Users</strong>: Session uses both cookies and localStorage to store UID and authentication state.</li>
<li><strong>Guests</strong>: No cookies are set. No persistent identifiers are stored.</li>
<li>We log IP + UID only for abuse protection and quota enforcement.</li>
<li>Uploads are scanned via Whisper+Ollama but not stored as transcripts.</li>
<li>Data is never sold. Contact us for account deletion.</li>
</ul>
<p><a href="#" data-back="upload-area">← Back</a></p>
</article>
</section>
@ -99,90 +100,94 @@
<h2>Imprint</h2>
<p><strong>Andreas Michael Fleckl</strong></p>
<p>Johnstrassse 7/6<br>1140 Vienna<br>Austria / Europe</p>
<p><strong>Contact:</strong><br>
<a href="mailto:Andreas.Fleckl@dicta2stream.net">Andreas.Fleckl@dicta2stream.net</a></p>
<p><a href="#" data-back="upload-area">← Back</a></p>
</article>
</section>
<section id="welcome-page">
<article>
<h2>Welcome</h2>
<p>dicta2stream is a minimalist voice streaming platform for your spoken audio anonymously under a nickname in a loop. <br><br>
<strong>What you can do here:</strong></p>
<ul>
<li>🎧 Listen to public voice streams from others, instantly</li>
<li>🎙️ Upload your own audio and share your voice with the world</li>
<li>🕵️ No sign-up required for listening</li>
<li>🔒 Optional registration for uploading and managing your own stream</li>
</ul>
</article>
</section>
<section id="stream-page" hidden>
<article>
<h2>🎧 Public Streams</h2>
<!-- The list below is dynamically populated by streams-ui.js; shows 'Loading...' while fetching -->
<ul id="stream-list"><li>Loading...</li></ul>
<p><a href="#" data-back="upload-area">← Back</a></p>
<p style="margin-top:1.5em;font-size:0.98em;">
<a href="#" id="show-terms" data-target="terms-page">Terms of Service</a> |
<a href="#" id="show-imprint" data-target="imprint-page">Imprint</a> |
<a href="#" id="show-privacy" data-target="privacy-page">Privacy Policy</a>
</p>
</article>
</section>
<section id="register-page" hidden>
<article>
<h2>Register</h2>
<h2>Login or Register</h2>
<form id="register-form">
<p><label>Email<br><input type="email" name="email" required /></label></p>
<p><label>Username<br><input type="text" name="user" required /></label></p>
<p style="display: none;">
<label>Leave this empty:<br>
<input type="text" name="bot_trap" autocomplete="off" />
</label>
</p>
<p><button type="submit">Create Account</button></p>
</form>
<p><small>Youll receive a magic login link via email. No password required.</small></p>
<p><a href="#" data-back="upload-area">← Back</a></p>
<p style="font-size: 0.85em; opacity: 0.65; margin-top: 1em;">Your session expires after 1 hour. Shareable links redirect to homepage.</p>
</article>
</section>
<section id="magic-login-page" hidden>
<article>
<h2>Magic Login</h2>
<p>If you received a magic login link, you're almost in. Click below to confirm your account and activate streaming.</p>
<form id="magic-login-form">
<div id="magic-error" style="color: #b22222; font-size: 0.9em; display: none; margin-bottom: 1em;"></div>
<input type="hidden" name="token" id="magic-token" />
<button type="submit">Confirm &amp; Activate</button>
</form>
<p><a href="#" data-back="upload-area">← Back</a></p>
</article>
</section>
<section id="quota-meter" hidden>
<p class="quota-meter">Quota: <progress id="quota-bar" value="0" max="100"></progress> <span id="quota-text">0 MB used</span></p>
</section>
<section id="me-page" hidden>
<article>
<h2>Your Stream 🎙️</h2>
<p>This is your personal stream. Only you can upload to it.</p>
<audio controls id="me-audio"></audio>
<!-- Playlist and URL input hidden as per user request -->
<div class="playlist-controls">
<button id="me-prev" aria-label="Previous track">⏮️</button>
<button id="me-next" aria-label="Next track">⏭️</button>
</div>
<!-- <ul id="me-playlist" class="playlist"></ul> -->
<!-- <p><input id="me-url" readonly class="me-url" /></p> -->
<p><button id="copy-url">📋 Copy URL to clipboard</button></p>
<p><a href="#" data-back="upload-area">← Back</a></p>
</article>
</section>
</main>
<footer>
<p>Built for public voice streaming • Opus | Mono | 48kHz | 60kbps</p>
<p class="footer-hint">Need more space? Contact <a href="mailto:Andreas.Fleckl@dicta2stream.net">Andreas.Fleckl@dicta2stream.net</a></p>
<p style="font-size: 0.85em; opacity: 0.65;">Your session expires after 1 hour. Shareable links redirect to homepage.</p>
<p class="footer-hint">Need more space? Contact<a href="mailto:Andreas.Fleckl@dicta2stream.net">Andreas.Fleckl@dicta2stream.net</a></p>
<p class="footer-links">
<a href="#" id="footer-terms" data-target="terms-page">Terms of Service</a> |
<a href="#" id="footer-privacy" data-target="privacy-page">Privacy Policy</a> |
<a href="#" id="footer-imprint" data-target="imprint-page">Imprint</a>
</p>
</footer>
<script type="module" src="/static/dashboard.js"></script>
<script type="module" src="/static/app.js"></script>
<!-- Load public streams UI logic -->
<script type="module" src="/static/streams-ui.js"></script>
<!-- Load upload functionality -->
<script type="module" src="/static/upload.js"></script>
<script type="module">
import "/static/nav.js";
window.addEventListener("pageshow", () => {
const dz = document.querySelector("#upload-area");
dz.classList.remove("uploading");
const dz = document.querySelector("#user-upload-area");
if (dz) dz.classList.remove("uploading");
const spinner = document.querySelector("#spinner");
if (spinner) spinner.style.display = "none";
});
</script>
<script type="module">
import { initMagicLogin } from '/static/magic-login.js';
const params = new URLSearchParams(window.location.search);
if (params.has('token')) {
if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', initMagicLogin);
} else {
initMagicLogin();
}
}
</script>
</body>
</html>

View File

@ -1,18 +1,63 @@
// static/magic-login.js — handles magiclink token UI
import { showOnly } from './router.js';
export function initMagicLogin() {
let magicLoginSubmitted = false;
export async function initMagicLogin() {
console.debug('[magic-login] initMagicLogin called');
const params = new URLSearchParams(location.search);
const token = params.get('token');
if (token) {
const tokenInput = document.getElementById('magic-token');
if (tokenInput) tokenInput.value = token;
showOnly('magic-login-page');
const err = params.get('error');
if (err) {
const box = document.getElementById('magic-error');
box.textContent = decodeURIComponent(err);
box.style.display = 'block';
if (!token) {
console.debug('[magic-login] No token in URL');
return;
}
// Remove token from URL immediately to prevent loops
const url = new URL(window.location.href);
url.searchParams.delete('token');
window.history.replaceState({}, document.title, url.pathname + url.search);
try {
const formData = new FormData();
formData.append('token', token);
const res = await fetch('/magic-login', {
method: 'POST',
body: formData,
});
if (res.redirected) {
// If redirected, backend should set cookie; but set localStorage for SPA
const url = new URL(res.url);
const confirmedUid = url.searchParams.get('confirmed_uid');
if (confirmedUid) {
document.cookie = "uid=" + encodeURIComponent(confirmedUid) + "; path=/";
// Set localStorage for SPA session logic instantly
localStorage.setItem('uid', confirmedUid);
localStorage.setItem('confirmed_uid', confirmedUid);
localStorage.setItem('uid_time', Date.now().toString());
}
window.location.href = res.url;
return;
}
// If not redirected, show error (shouldn't happen in normal flow)
let data;
const contentType = res.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
data = await res.json();
if (data && data.confirmed_uid) {
document.cookie = "uid=" + encodeURIComponent(data.confirmed_uid) + "; path=/";
// Set localStorage for SPA session logic
localStorage.setItem('uid', data.confirmed_uid);
localStorage.setItem('confirmed_uid', data.confirmed_uid);
localStorage.setItem('uid_time', Date.now().toString());
import('./toast.js').then(({ showToast }) => showToast('✅ Login successful!'));
// Optionally reload or navigate
setTimeout(() => location.reload(), 700);
return;
}
alert(data.detail || 'Login failed.');
} else {
const text = await res.text();
alert(text || 'Login failed.');
}
} catch (err) {
alert('Network error: ' + err);
}
}

View File

@ -1,8 +1,10 @@
// nav.js — lightweight navigation & magiclink handling
import { showToast } from "./toast.js";
// fallback toast if app.js not yet loaded
if (typeof window.showToast !== "function") {
window.showToast = (msg) => alert(msg);
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
document.addEventListener("DOMContentLoaded", () => {
@ -13,6 +15,12 @@ document.addEventListener("DOMContentLoaded", () => {
sec.hidden = sec.id !== id;
sec.tabIndex = -1;
});
// Show user-upload-area only when me-page is shown and user is logged in
const userUpload = document.getElementById("user-upload-area");
if (userUpload) {
const uid = getCookie("uid");
userUpload.style.display = (id === "me-page" && uid) ? '' : 'none';
}
localStorage.setItem("last_page", id);
const target = document.getElementById(id);
if (target) target.focus();
@ -20,7 +28,7 @@ document.addEventListener("DOMContentLoaded", () => {
init() {
initNavLinks();
initBackButtons();
initStreamsLoader();
initStreamLinks();
}
};
@ -38,14 +46,30 @@ document.addEventListener("DOMContentLoaded", () => {
link.classList.toggle('active', uidParam === profileUid);
});
}
window.addEventListener('popstate', highlightActiveProfileLink);
window.addEventListener('popstate', () => {
const params = new URLSearchParams(window.location.search);
const profileUid = params.get('profile');
if (profileUid) {
showOnly('me-page');
if (typeof window.showProfilePlayerFromUrl === 'function') {
window.showProfilePlayerFromUrl();
}
} else {
highlightActiveProfileLink();
}
});
/* restore last page (unless magiclink token present) */
const params = new URLSearchParams(location.search);
const token = params.get("token");
if (!token) {
const last = localStorage.getItem("last_page");
if (last && document.getElementById(last)) showOnly(last);
if (last && document.getElementById(last)) {
showOnly(last);
} else if (document.getElementById("welcome-page")) {
// Show Welcome page by default for all new/guest users
showOnly("welcome-page");
}
// Highlight active link on initial load
highlightActiveProfileLink();
}
@ -62,8 +86,7 @@ document.addEventListener("DOMContentLoaded", () => {
}
}
// Debounce loading and helper for streams list
let loadingStreams = false;
function renderStreamList(streams) {
const ul = document.getElementById("stream-list");
if (!ul) return;
@ -81,49 +104,79 @@ document.addEventListener("DOMContentLoaded", () => {
// Initialize navigation listeners
function initNavLinks() {
const linksContainer = document.getElementById("links");
if (!linksContainer) return;
linksContainer.addEventListener("click", e => {
const a = e.target.closest("a[data-target]");
if (!a || !linksContainer.contains(a)) return;
e.preventDefault();
const target = a.dataset.target;
if (target) showOnly(target);
const burger = document.getElementById("burger-toggle");
if (burger && burger.checked) burger.checked = false;
const navIds = ["links", "user-dashboard", "guest-dashboard"];
navIds.forEach(id => {
const nav = document.getElementById(id);
if (!nav) return;
nav.addEventListener("click", e => {
const a = e.target.closest("a[data-target]");
if (!a || !nav.contains(a)) return;
e.preventDefault();
// Save audio state before navigation
const audio = document.getElementById('me-audio');
const wasPlaying = audio && !audio.paused;
const currentTime = audio ? audio.currentTime : 0;
const target = a.dataset.target;
if (target) showOnly(target);
// Handle stream page specifically
if (target === "stream-page" && typeof window.maybeLoadStreamsOnShow === "function") {
window.maybeLoadStreamsOnShow();
}
// Handle me-page specifically
else if (target === "me-page" && audio) {
// Restore audio state if it was playing
if (wasPlaying) {
audio.currentTime = currentTime;
audio.play().catch(e => console.error('Play failed:', e));
}
}
});
});
// Add click handlers for footer links with audio state saving
document.querySelectorAll(".footer-links a").forEach(link => {
link.addEventListener("click", (e) => {
e.preventDefault();
const target = link.dataset.target;
if (!target) return;
// Save audio state before navigation
const audio = document.getElementById('me-audio');
const wasPlaying = audio && !audio.paused;
const currentTime = audio ? audio.currentTime : 0;
showOnly(target);
// Handle me-page specifically
if (target === "me-page" && audio) {
// Restore audio state if it was playing
if (wasPlaying) {
audio.currentTime = currentTime;
audio.play().catch(e => console.error('Play failed:', e));
}
}
});
});
}
function initBackButtons() {
document.querySelectorAll('a[data-back]').forEach(btn => {
btn.addEventListener("click", e => {
e.preventDefault();
const target = btn.dataset.back;
if (target) showOnly(target);
// Ensure streams load instantly when stream-page is shown
if (target === "stream-page" && typeof window.maybeLoadStreamsOnShow === "function") {
window.maybeLoadStreamsOnShow();
}
});
});
}
function initStreamsLoader() {
const streamsLink = document.getElementById("show-streams");
streamsLink?.addEventListener("click", async e => {
e.preventDefault();
if (loadingStreams) return;
loadingStreams = true;
showOnly("stream-page");
try {
const res = await fetch("/streams");
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
const data = await res.json();
renderStreamList(data.streams || []);
} catch {
const ul = document.getElementById("stream-list");
if (ul) ul.innerHTML = "<li>Error loading stream list</li>";
} finally {
loadingStreams = false;
}
});
}
function initStreamLinks() {
const ul = document.getElementById("stream-list");

0
static/reload.txt Normal file
View File

View File

@ -1,22 +1,198 @@
// static/streams-ui.js — public streams loader and profile-link handling
import { showOnly } from './router.js';
let loadingStreams = false;
// Removed loadingStreams and lastStreamsPageVisible guards for instant fetch
export function initStreamsUI() {
initStreamLinks();
window.addEventListener('popstate', () => {
highlightActiveProfileLink();
maybeLoadStreamsOnShow();
});
document.addEventListener('visibilitychange', maybeLoadStreamsOnShow);
maybeLoadStreamsOnShow();
}
function maybeLoadStreamsOnShow() {
// Expose globally for nav.js
const streamPage = document.getElementById('stream-page');
const isVisible = streamPage && !streamPage.hidden;
if (isVisible) {
loadAndRenderStreams();
}
}
window.maybeLoadStreamsOnShow = maybeLoadStreamsOnShow;
let currentlyPlayingAudio = null;
let currentlyPlayingButton = null;
document.addEventListener('DOMContentLoaded', initStreamsUI);
function loadAndRenderStreams() {
const ul = document.getElementById('stream-list');
if (!ul) {
console.warn('[streams-ui] #stream-list not found in DOM');
return;
}
console.debug('[streams-ui] loadAndRenderStreams (SSE mode) called');
ul.innerHTML = '<li>Loading...</li>';
let gotAny = false;
let streams = [];
// Close previous EventSource if any
if (window._streamsSSE) {
window._streamsSSE.close();
}
const evtSource = new window.EventSource('/streams-sse');
window._streamsSSE = evtSource;
evtSource.onmessage = function(event) {
console.debug('[streams-ui] SSE event received:', event.data);
try {
const data = JSON.parse(event.data);
if (data.end) {
if (!gotAny) {
ul.innerHTML = '<li>No active streams.</li>';
}
evtSource.close();
highlightActiveProfileLink();
return;
}
// Remove Loading... on any valid event
if (!gotAny) {
ul.innerHTML = '';
gotAny = true;
}
streams.push(data);
const uid = data.uid || '';
const sizeMb = data.size ? (data.size / (1024 * 1024)).toFixed(1) : '?';
const mtime = data.mtime ? new Date(data.mtime * 1000).toISOString().split('T')[0].replace(/-/g, '/') : '';
const li = document.createElement('li');
li.innerHTML = `
<article class="stream-player">
<h3>${uid}</h3>
<audio id="audio-${uid}" class="stream-audio" preload="auto" crossOrigin="anonymous" src="/audio/${encodeURIComponent(uid)}/stream.opus"></audio>
<div class="audio-controls">
<button id="play-pause-${uid}">▶</button>
</div>
<p class="stream-info" style='color:gray;font-size:90%'>[${sizeMb} MB, ${mtime}]</p>
</article>
`;
// Add play/pause handler after appending to DOM
ul.appendChild(li);
// Wait for DOM update
requestAnimationFrame(() => {
const playPauseButton = document.getElementById(`play-pause-${uid}`);
const audio = document.getElementById(`audio-${uid}`);
if (playPauseButton && audio) {
playPauseButton.addEventListener('click', () => {
try {
if (audio.paused) {
// Stop any currently playing audio first
if (currentlyPlayingAudio && currentlyPlayingAudio !== audio) {
currentlyPlayingAudio.pause();
if (currentlyPlayingButton) {
currentlyPlayingButton.textContent = '▶';
}
}
// Stop the main player if it's playing
if (typeof window.stopMainAudio === 'function') {
window.stopMainAudio();
}
audio.play().then(() => {
playPauseButton.textContent = '⏸️';
currentlyPlayingAudio = audio;
currentlyPlayingButton = playPauseButton;
}).catch(e => {
console.error('Play failed:', e);
// Reset button if play fails
playPauseButton.textContent = '▶';
currentlyPlayingAudio = null;
currentlyPlayingButton = null;
});
} else {
audio.pause();
playPauseButton.textContent = '▶';
if (currentlyPlayingAudio === audio) {
currentlyPlayingAudio = null;
currentlyPlayingButton = null;
}
}
} catch (e) {
console.error('Audio error:', e);
playPauseButton.textContent = '▶';
if (currentlyPlayingAudio === audio) {
currentlyPlayingAudio = null;
currentlyPlayingButton = null;
}
}
});
}
});
highlightActiveProfileLink();
ul.appendChild(li);
highlightActiveProfileLink();
} catch (e) {
// Remove Loading... even if JSON parse fails, to avoid stuck UI
if (!gotAny) {
ul.innerHTML = '';
gotAny = true;
}
console.error('[streams-ui] SSE parse error', e, event.data);
}
};
evtSource.onerror = function(err) {
console.error('[streams-ui] SSE error', err);
ul.innerHTML = '<li>Error loading stream list</li>';
if (typeof showToast === 'function') {
showToast('❌ Error loading public streams.');
}
evtSource.close();
// Add reload button if not present
const reloadButton = document.getElementById('reload-streams');
if (!reloadButton) {
const reloadHtml = '<button id="reload-streams" onclick="loadAndRenderStreams()">Reload</button>';
ul.insertAdjacentHTML('beforeend', reloadHtml);
}
};
}
export function renderStreamList(streams) {
const ul = document.getElementById('stream-list');
if (!ul) return;
if (streams.length) {
streams.sort();
ul.innerHTML = streams
.map(
uid => `<li><a href="/?profile=${encodeURIComponent(uid)}" class="profile-link">▶ ${uid}</a></li>`
)
.join('');
if (!ul) {
console.warn('[streams-ui] renderStreamList: #stream-list not found');
return;
}
console.debug('[streams-ui] Rendering stream list:', streams);
if (Array.isArray(streams)) {
if (streams.length) {
// Sort by mtime descending (most recent first)
streams.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
ul.innerHTML = streams
.map(stream => {
const uid = stream.uid || '';
const sizeKb = stream.size ? (stream.size / 1024).toFixed(1) : '?';
const mtime = stream.mtime ? new Date(stream.mtime * 1000).toLocaleString() : '';
return `<li><a href="/?profile=${encodeURIComponent(uid)}" class="profile-link">▶ ${uid}</a> <span style='color:gray;font-size:90%'>[${sizeKb} KB, ${mtime}]</span></li>`;
})
.join('');
} else {
ul.innerHTML = '<li>No active streams.</li>';
}
} else {
ul.innerHTML = '<li>No active streams.</li>';
ul.innerHTML = '<li>Error: Invalid stream data.</li>';
console.error('[streams-ui] renderStreamList: streams is not an array', streams);
}
highlightActiveProfileLink();
console.debug('[streams-ui] renderStreamList complete');
}
export function highlightActiveProfileLink() {
@ -31,26 +207,7 @@ export function highlightActiveProfileLink() {
});
}
export function initStreamsLoader() {
const streamsLink = document.getElementById('show-streams');
streamsLink?.addEventListener('click', async e => {
e.preventDefault();
if (loadingStreams) return;
loadingStreams = true;
showOnly('stream-page');
try {
const res = await fetch('/streams');
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
const data = await res.json();
renderStreamList(data.streams || []);
} catch {
const ul = document.getElementById('stream-list');
if (ul) ul.innerHTML = '<li>Error loading stream list</li>';
} finally {
loadingStreams = false;
}
});
}
export function initStreamLinks() {
const ul = document.getElementById('stream-list');
@ -61,16 +218,17 @@ export function initStreamLinks() {
e.preventDefault();
const url = new URL(a.href, window.location.origin);
const profileUid = url.searchParams.get('profile');
if (profileUid && window.location.search !== `?profile=${encodeURIComponent(profileUid)}`) {
window.profileNavigationTriggered = true;
window.history.pushState({}, '', `/?profile=${encodeURIComponent(profileUid)}`);
window.dispatchEvent(new Event('popstate'));
if (profileUid) {
if (window.location.search !== `?profile=${encodeURIComponent(profileUid)}`) {
window.profileNavigationTriggered = true;
window.history.pushState({}, '', `/?profile=${encodeURIComponent(profileUid)}#`);
window.dispatchEvent(new Event('popstate'));
} else {
// If already on this profile, still highlight
if (typeof window.highlightActiveProfileLink === "function") {
window.highlightActiveProfileLink();
}
}
}
});
}
export function initStreamsUI() {
initStreamsLoader();
initStreamLinks();
window.addEventListener('popstate', highlightActiveProfileLink);
}

View File

@ -1,4 +1,48 @@
/* style.css — minimal UI styling for dicta2stream */
main {
display: flex;
flex-direction: column;
align-items: center; /* centers children horizontally */
}
nav#guest-dashboard.dashboard-nav {
width: fit-content; /* optional: shrink to fit content */
margin: 0 auto; /* fallback for block centering */
}
/* Toast notification styles */
.toast-container {
position: fixed;
left: 50%;
bottom: 40px;
transform: translateX(-50%);
z-index: 10000;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
}
.toast {
background: var(--crt-screen);
color: var(--crt-text);
padding: 1em 2em;
border-radius: 8px;
box-shadow: 0 4px 20px var(--crt-shadow);
margin-top: 0.5em;
opacity: 0;
animation: fadeInOut 3.5s both;
font-size: 1.1em;
pointer-events: auto;
border: 1px solid var(--crt-border);
text-shadow: 0 0 2px rgba(0, 255, 0, 0.1);
}
@keyframes fadeInOut {
0% { opacity: 0; transform: translateY(40px) scale(0.96); }
10% { opacity: 1; transform: translateY(0) scale(1); }
90% { opacity: 1; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(40px) scale(0.96); }
}
.spinner {
position: absolute;
@ -27,6 +71,8 @@
opacity: 0.7;
}
.cancel-upload {
display: none;
margin-top: 0.4em;
@ -83,7 +129,7 @@ button.logout:hover {
audio {
display: block;
margin: 1em auto;
margin: 0;
max-width: 100%;
outline: none;
border-radius: 6px;
@ -91,6 +137,366 @@ audio {
background: #fff;
}
.audio-controls {
display: flex;
justify-content: center;
margin: 1.5em 0;
padding: 1em;
}
.audio-controls button {
background: none;
border: none;
cursor: pointer;
padding: 1.2em;
border-radius: 12px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 96px;
min-height: 96px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
font-size: 48px;
font-weight: bold;
line-height: 1;
color: #333;
}
.audio-controls button:hover {
background-color: #f0f0f0;
transform: scale(1.1);
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
}
.audio-controls button:active {
color: #000;
transform: scale(0.9);
}
.audio-controls svg {
fill: #333;
transition: all 0.2s ease;
width: 56px;
height: 56px;
}
.audio-controls button:hover svg {
fill: #000;
transform: scale(1.1);
}
/* Hide the native controls */
audio::-webkit-media-controls-panel {
display: none !important;
}
audio::-webkit-media-controls-play-button {
display: none !important;
}
audio::-webkit-media-controls-volume-slider {
display: none !important;
}
main > section {
width: 100%;
max-width: 900px;
padding: 2em;
background: #1a1a1a;
border: 1px solid var(--audio-metal);
border-radius: 8px;
margin-bottom: 1.5em;
color: var(--audio-text);
text-shadow: 0 0 2px rgba(255, 102, 0, 0.2);
position: relative;
}
main > section article {
background: #222;
border: 1px solid #444;
padding: 1.5em;
border-radius: 6px;
margin-bottom: 1em;
position: relative;
color: #d3d3d3; /* Light gray for better contrast */
}
main > section article::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(45deg, rgba(255, 102, 0, 0.03), rgba(255, 102, 0, 0.01));
pointer-events: none;
z-index: -1;
}
main > section::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(45deg, rgba(255, 102, 0, 0.05), rgba(255, 102, 0, 0.02));
pointer-events: none;
z-index: -1;
}
/* Audio controls styling */
button.audio-control {
background: #333;
border: 2px solid #555;
color: #fff;
padding: 0.5em 1em;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-family: 'Roboto', sans-serif;
font-weight: 500;
}
#register-form {
max-width: 400px;
margin: 0 auto;
padding: 20px;
text-align: center;
}
#register-form p {
margin-bottom: 1em;
text-align: left;
}
#register-form label {
display: inline;
margin-right: 10px;
color: #d3d3d3;
font-family: 'Courier New', monospace;
}
#register-form input[type="text"],
#register-form input[type="email"],
#register-form input[type="password"] {
display: inline;
background-color: rgb(26, 26, 26);
border: 1px solid #444;
color: #d3d3d3;
padding: 0.8em;
border-radius: 4px;
width: 250px;
transition: all 0.2s ease;
}
#register-form input[type="text"]:focus,
#register-form input[type="email"]:focus,
#register-form input[type="password"]:focus {
outline: none;
border-color: #ff6600;
box-shadow: 0 0 0 2px rgba(255, 102, 0, 0.1);
}
/* Submit button styling */
#register-form button[type="submit"] {
display: inline;
width: calc(250px + 1.6em);
padding: 0.8em;
background: transparent;
color: #d3d3d3;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
margin-top: 1.25em;
transition: color 0.3s;
font-family: 'Courier New', monospace;
text-align: center;
border: 1px solid #444;
background: #1a1a1a;
}
#register-form button[type="submit"]:hover {
color: #ff6600;
}
#register-form button[type="submit"]:active {
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
transform: translateY(0);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
button.audio-control:hover {
background: #444;
border-color: #666;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}
/* Audio meter styling */
.audio-meter {
background: #333;
border: 1px solid #555;
height: 10px;
border-radius: 5px;
overflow: hidden;
}
.audio-meter-fill {
background: linear-gradient(90deg, #ff6600, #ff8800);
height: 100%;
transition: width 0.3s ease-in-out;
}
/* Audio list styling */
.audio-list {
list-style: none;
padding: 0;
margin: 0;
background: #1a1a1a;
border: 1px solid var(--audio-metal);
border-radius: 8px;
overflow: hidden;
}
#stream-list > li {
background: #1a1a1a;
}
/* Stream player styling */
.stream-player {
background: #1a1a1a;
border: 1px solid #444;
padding: 1.5em;
border-radius: 8px;
margin-bottom: 1em;
position: relative;
color: #d3d3d3;
}
.stream-player::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(45deg, rgba(255, 102, 0, 0.03), rgba(255, 102, 0, 0.01));
pointer-events: none;
z-index: -1;
}
.stream-player h3 {
color: var(--audio-accent);
margin: 0 0 1em 0;
font-size: 1.1em;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.stream-player audio {
width: 100%;
max-width: 500px;
margin: 1em 0;
background: #1a1a1a;
border: 1px solid #444;
border-radius: 6px;
}
.stream-player audio {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background: #1a1a1a;
border: none;
border-radius: 6px;
width: 100%;
max-width: 500px;
margin: 1em 0;
padding: 0.5em;
}
/* Custom play button */
.stream-player audio::before {
content: '▶️';
position: absolute;
left: 0.5em;
top: 50%;
transform: translateY(-50%);
color: #2e8b57;
font-size: 1.5em;
cursor: pointer;
z-index: 1;
}
/* Custom progress bar */
.stream-player audio::-webkit-progress-bar {
background: #444;
border-radius: 3px;
}
.stream-player audio::-webkit-progress-value {
background: linear-gradient(90deg, #ff6600, #ff8800);
border-radius: 3px;
}
/* Custom volume slider */
.stream-player audio::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: #ff6600;
border-radius: 50%;
cursor: pointer;
}
.stream-player audio::-webkit-slider-runnable-track {
height: 4px;
background: #444;
border-radius: 2px;
}
/* Guest login heading styling */
#guest-login h2 {
color: var(--audio-accent);
font-size: 1.1em;
margin: 0.5em 0;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
text-align: center;
display: inline-block;
background: #2e8b57;
color: white;
padding: 0.4em 1em;
border-radius: 5px;
text-decoration: none;
transition: background 0.2s ease;
}
#guest-login h2:hover {
background: #256b45;
}
.audio-list li {
background: #333;
border: 1px solid #555;
padding: 1em;
margin-bottom: 0.5em;
border-radius: 6px;
transition: all 0.2s;
}
.audio-list li:hover {
background: #444;
border-color: #666;
transform: translateY(-1px);
}
#me-wrap {
background: #fdfdfd;
padding: 1.5em;
@ -149,6 +555,13 @@ input[disabled], button[disabled] {
display: inline-block;
}
#stream-info, #stream-info * {
display: initial !important;
visibility: visible !important;
color: #222 !important;
background: #fff !important;
}
body {
font-family: sans-serif;
background: #fafafa;
@ -357,6 +770,68 @@ section article {
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
}
section article.stream-page {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.stream-player {
background: #1a1a1a;
border: 1px solid #444;
padding: 1.5em;
border-radius: 8px;
margin-bottom: 1em;
position: relative;
color: #d3d3d3;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.stream-player h3 {
margin: 0 0 15px 0;
color: #d3d3d3;
}
.stream-audio {
width: 100%;
margin-bottom: 15px;
}
.audio-controls {
display: flex;
justify-content: center;
margin-bottom: 10px;
}
.audio-controls button {
background: none;
border: none;
cursor: pointer;
padding: 12px;
border-radius: 8px;
transition: all 0.2s ease;
font-size: 24px;
font-weight: bold;
color: #666;
}
.audio-controls button:hover {
background-color: #f0f0f0;
color: #333;
transform: scale(1.05);
}
.audio-controls button:active {
color: #000;
transform: scale(0.95);
}
.stream-info {
margin: 0;
color: #666;
font-size: 0.9em;
}
ul#stream-list,
ul#me-files {
padding-left: 0;
@ -413,10 +888,130 @@ section article a[href^="mailto"]:hover {
}
code {
background: #eee;
background: #1a1a1a;
padding: 0.2em 0.4em;
border-radius: 4px;
font-family: monospace;
font-family: 'Courier New', monospace;
color: #fff;
text-shadow: 0 0 4px rgba(0, 255, 0, 0.1);
}
:root {
--audio-bg: #222;
--audio-metal: #333;
--audio-text: #fff;
--audio-accent: #ff6600;
--audio-shadow: rgba(0, 0, 0, 0.5);
}
body {
margin: 0;
font-family: 'Roboto', sans-serif;
background-color: var(--audio-bg);
color: var(--audio-text);
line-height: 1.6;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle at center, rgba(255, 102, 0, 0.05) 0%, rgba(255, 102, 0, 0) 20%);
pointer-events: none;
z-index: -1;
}
main {
display: flex;
flex-direction: column;
align-items: center;
max-width: 960px;
width: 95%;
margin: 0 auto;
padding: 20px;
background: var(--audio-bg);
border: 2px solid var(--audio-metal);
border-radius: 12px;
box-shadow: 0 4px 15px var(--audio-shadow);
}
main::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: repeating-linear-gradient(
45deg,
rgba(0, 255, 0, 0.05),
rgba(0, 255, 0, 0.05) 2px,
transparent 2px,
transparent 4px
);
pointer-events: none;
z-index: -1;
}
nav#guest-dashboard.dashboard-nav,
nav#user-dashboard.dashboard-nav {
width: fit-content;
margin: 20px auto;
padding: 10px;
background: var(--crt-screen);
border: 1px solid var(--crt-border);
border-radius: 5px;
font-family: 'Courier New', monospace;
}
nav.dashboard-nav a {
color: #d3d3d3;
text-decoration: none;
font-family: 'Courier New', monospace;
margin: 0 0.5em;
padding: 5px;
border-radius: 3px;
transition: all 0.2s ease;
}
nav.dashboard-nav a:hover {
color: #ff6600;
}
/* Toast notification styles */
/* Footer links styling */
.footer-links {
margin-top: 1em;
font-size: 0.85em;
}
.footer-links a {
color: #d3d3d3;
text-decoration: none;
font-family: 'Courier New', monospace;
margin: 0 0.5em;
}
.footer-links a:hover {
color: #ff6600;
}
footer p.footer-hint a {
color: #d3d3d3;
text-decoration: none;
font-family: 'Courier New', monospace;
margin: 0 0.5em;
transition: color 0.3s;
}
footer p.footer-hint a:hover {
color: #ff6600;
}
@media (min-width: 960px) {
@ -424,7 +1019,7 @@ code {
display: flex;
flex-direction: column;
align-items: center;
background: #e0f7ff;
background: var(--crt-screen);
padding: 1em;
margin: 2em auto;
border-radius: 6px;

19
static/toast.js Normal file
View File

@ -0,0 +1,19 @@
// toast.js — centralized toast notification logic for dicta2stream
export function showToast(message) {
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.remove();
// Do not remove the container; let it persist for stacking
}, 3500);
}

128
static/upload.js Normal file
View File

@ -0,0 +1,128 @@
// upload.js — Frontend file upload handler
import { showToast } from "./toast.js";
import { playBeep } from "./sound.js";
import { logToServer } from "./app.js";
// Initialize upload system when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
const dropzone = document.getElementById("user-upload-area");
if (dropzone) {
dropzone.setAttribute("aria-label", "Upload area. Click or drop an audio file to upload.");
}
const fileInput = document.getElementById("fileInputUser");
const fileInfo = document.createElement("div");
fileInfo.id = "file-info";
fileInfo.style.textAlign = "center";
if (fileInput) {
fileInput.parentNode.insertBefore(fileInfo, fileInput.nextSibling);
}
const streamInfo = document.getElementById("stream-info");
const streamUrlEl = document.getElementById("streamUrl");
const spinner = document.getElementById("spinner");
let abortController;
// Upload function
const upload = async (file) => {
if (abortController) abortController.abort();
abortController = new AbortController();
fileInfo.innerText = `📁 ${file.name}${(file.size / 1024 / 1024).toFixed(2)} MB`;
if (file.size > 100 * 1024 * 1024) {
showToast("❌ File too large. Please upload a file smaller than 100MB.");
return;
}
spinner.style.display = "block";
showToast('📡 Uploading…');
fileInput.disabled = true;
dropzone.classList.add("uploading");
const formData = new FormData();
const sessionUid = localStorage.getItem("uid");
formData.append("uid", sessionUid);
formData.append("file", file);
const res = await fetch("/upload", {
signal: abortController.signal,
method: "POST",
body: formData,
});
let data, parseError;
try {
data = await res.json();
} catch (e) {
parseError = e;
}
if (!data) {
showToast("❌ Upload failed: " + (parseError && parseError.message ? parseError.message : "Unknown error"));
spinner.style.display = "none";
fileInput.disabled = false;
dropzone.classList.remove("uploading");
return;
}
if (res.ok) {
if (data.quota && data.quota.used_mb !== undefined) {
const bar = document.getElementById("quota-bar");
const text = document.getElementById("quota-text");
const quotaSec = document.getElementById("quota-meter");
if (bar && text && quotaSec) {
quotaSec.hidden = false;
const used = parseFloat(data.quota.used_mb);
bar.value = used;
bar.max = 100;
text.textContent = `${used.toFixed(1)} MB used`;
}
}
spinner.style.display = "none";
fileInput.disabled = false;
dropzone.classList.remove("uploading");
showToast("✅ Upload successful.");
playBeep(432, 0.25, "sine");
} else {
streamInfo.hidden = true;
spinner.style.display = "none";
if ((data.detail || data.error || "").includes("music")) {
showToast("🎵 Upload rejected: singing or music detected.");
} else {
showToast(`❌ Upload failed: ${data.detail || data.error}`);
}
if (fileInput) fileInput.value = null;
if (dropzone) dropzone.classList.remove("uploading");
if (fileInput) fileInput.disabled = false;
if (streamInfo) streamInfo.classList.remove("visible", "slide-in");
}
};
// Export the upload function for use in other modules
window.upload = upload;
if (dropzone && fileInput) {
dropzone.addEventListener("click", () => {
console.log("[DEBUG] Dropzone clicked");
fileInput.click();
console.log("[DEBUG] fileInput.click() called");
});
dropzone.addEventListener("dragover", (e) => {
e.preventDefault();
dropzone.classList.add("dragover");
dropzone.style.transition = "background-color 0.3s ease";
});
dropzone.addEventListener("dragleave", () => {
dropzone.classList.remove("dragover");
});
dropzone.addEventListener("drop", (e) => {
dropzone.classList.add("pulse");
setTimeout(() => dropzone.classList.remove("pulse"), 400);
e.preventDefault();
dropzone.classList.remove("dragover");
const file = e.dataTransfer.files[0];
if (file) upload(file);
});
fileInput.addEventListener("change", (e) => {
const file = e.target.files[0];
if (file) upload(file);
});
}
});

27
streams.py Normal file
View File

@ -0,0 +1,27 @@
# streams.py — Public streams endpoint for dicta2stream
from fastapi import APIRouter
from fastapi import APIRouter
from pathlib import Path
import json
router = APIRouter()
@router.get("/streams")
def list_streams():
txt_path = Path("./public_streams.txt")
if not txt_path.exists():
return {"streams": []}
try:
streams = []
with txt_path.open("r") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
streams.append(json.loads(line))
except Exception:
continue # skip malformed lines
return {"streams": streams}
except Exception:
return {"streams": []}

0
streams_cache.py Normal file
View File

11
testmail.py Normal file
View File

@ -0,0 +1,11 @@
import smtplib
from email.message import EmailMessage
msg = EmailMessage()
msg["From"] = "test@keisanki.net"
msg["To"] = "oib@bubuit.net"
msg["Subject"] = "Test"
msg.set_content("Hello world")
with smtplib.SMTP("localhost") as smtp:
smtp.send_message(msg)

View File

@ -78,16 +78,15 @@ async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), f
original_size = raw_path.stat().st_size
raw_path.unlink(missing_ok=True) # cleanup
# Always copy latest upload as stream.opus for redirect compatibility
import shutil
stream_path = user_dir / "stream.opus"
shutil.copy2(processed_path, stream_path)
# Also update ./data/{uid}/stream.opus for public stream listing
streams_dir = Path("data") / uid
streams_dir.mkdir(parents=True, exist_ok=True)
streams_stream_path = streams_dir / "stream.opus"
shutil.copy2(processed_path, streams_stream_path)
# Concatenate all .opus files in random order to stream.opus for public playback
from concat_opus import concat_opus_files
try:
concat_opus_files(user_dir, user_dir / "stream.opus")
except Exception as e:
# fallback: just use the latest processed file if concat fails
import shutil
stream_path = user_dir / "stream.opus"
shutil.copy2(processed_path, stream_path)
db.add(UploadLog(
uid=uid,
@ -106,7 +105,6 @@ async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), f
db.commit()
return {
"stream_url": f"http://localhost:8000/streams/{uid}/stream.opus",
"filename": file.filename,
"original_size": round(original_size / 1024, 1),
"quota": {