Compare commits

...

3 Commits

Author SHA1 Message Date
oib
c5412b07ac Migrate from file-based to database-backed stream metadata storage
- Add PublicStream model and migration
- Update list_streams.py and upload.py to use database
- Add import script for data migration
- Remove public_streams.txt (replaced by database)
- Fix quota sync between userquota and publicstream tables
2025-07-19 10:49:16 +02:00
oib
402e920bc6 Fix double audio playback and add UID handling for personal stream
- Fixed double playback issue on stream page by properly scoping event delegation in streams-ui.js
- Added init-personal-stream.js to handle UID for personal stream playback
- Improved error handling and logging for audio playback
- Added proper event propagation control to prevent duplicate event handling
2025-07-18 16:51:39 +02:00
oib
17616ac5b8 feat: Add database migrations and auth system
- Add Alembic for database migrations
- Implement user authentication system
- Update frontend styles and components
- Add new test audio functionality
- Update stream management and UI
2025-07-02 09:37:03 +02:00
57 changed files with 8537 additions and 1058 deletions

93
DATABASE.md Normal file
View File

@ -0,0 +1,93 @@
# Database Setup and Migrations
This document explains how to set up and manage the database for the dicta2stream application.
## Prerequisites
- PostgreSQL database server
- Python 3.8+
- Required Python packages (install with `pip install -r requirements.txt`)
## Initial Setup
1. Create a PostgreSQL database:
```bash
createdb dicta2stream
```
2. Set up the database URL in your environment:
```bash
echo "DATABASE_URL=postgresql://username:password@localhost/dicta2stream" > .env
```
Replace `username` and `password` with your PostgreSQL credentials.
3. Initialize the database:
```bash
python init_db.py
```
## Running Migrations
After making changes to the database models, you can create and apply migrations:
1. Install the required dependencies:
```bash
pip install -r requirements.txt
```
2. Run the migrations:
```bash
python run_migrations.py
```
## Database Models
The application uses the following database models:
### User
- Stores user account information
- Fields: username, email, hashed_password, is_active, created_at, updated_at
### Session
- Manages user sessions
- Fields: id, user_id, token, ip_address, user_agent, created_at, expires_at, last_used_at, is_active
### PublicStream
- Tracks publicly available audio streams
- Fields: uid, filename, size, mtime, created_at, updated_at
### UserQuota
- Tracks user storage quotas
- Fields: uid, storage_bytes, updated_at
### UploadLog
- Logs file uploads
- Fields: id, uid, filename, size, ip_address, user_agent, created_at
## Backing Up the Database
To create a backup of your database:
```bash
pg_dump -U username -d dicta2stream -f backup.sql
```
To restore from a backup:
```bash
psql -U username -d dicta2stream -f backup.sql
```
## Troubleshooting
- If you encounter connection issues, verify that:
- The PostgreSQL server is running
- The database URL in your .env file is correct
- The database user has the necessary permissions
- If you need to reset the database:
```bash
dropdb dicta2stream
createdb dicta2stream
python init_db.py
```

98
account_router.py Normal file
View File

@ -0,0 +1,98 @@
# account_router.py — Account management endpoints
from fastapi import APIRouter, Request, HTTPException, Depends
from fastapi.responses import JSONResponse
from sqlmodel import Session, select
from models import User, UserQuota, UploadLog, DBSession
from database import get_db
import os
from typing import Dict, Any
router = APIRouter(prefix="/api", tags=["account"])
@router.post("/delete-account")
async def delete_account(data: Dict[str, Any], request: Request, db: Session = Depends(get_db)):
try:
# Get UID from request data
uid = data.get("uid")
if not uid:
print(f"[DELETE_ACCOUNT] Error: Missing UID in request data")
raise HTTPException(status_code=400, detail="Missing UID")
ip = request.client.host
print(f"[DELETE_ACCOUNT] Processing delete request for UID: {uid} from IP: {ip}")
# Verify user exists and IP matches
user = db.exec(select(User).where(User.username == uid)).first()
if not user:
print(f"[DELETE_ACCOUNT] Error: User {uid} not found")
raise HTTPException(status_code=404, detail="User not found")
if user.ip != ip:
print(f"[DELETE_ACCOUNT] Error: IP mismatch. User IP: {user.ip}, Request IP: {ip}")
raise HTTPException(status_code=403, detail="Unauthorized: IP address does not match")
# Start transaction
try:
# Delete user's upload logs
uploads = db.exec(select(UploadLog).where(UploadLog.uid == uid)).all()
for upload in uploads:
db.delete(upload)
print(f"[DELETE_ACCOUNT] Deleted {len(uploads)} upload logs for user {uid}")
# Delete user's quota
quota = db.get(UserQuota, uid)
if quota:
db.delete(quota)
print(f"[DELETE_ACCOUNT] Deleted quota for user {uid}")
# Delete user's active sessions
sessions = db.exec(select(DBSession).where(DBSession.user_id == uid)).all()
for session in sessions:
db.delete(session)
print(f"[DELETE_ACCOUNT] Deleted {len(sessions)} active sessions for user {uid}")
# Delete user account
user_obj = db.get(User, user.email)
if user_obj:
db.delete(user_obj)
print(f"[DELETE_ACCOUNT] Deleted user account {uid} ({user.email})")
db.commit()
print(f"[DELETE_ACCOUNT] Database changes committed for user {uid}")
except Exception as e:
db.rollback()
print(f"[DELETE_ACCOUNT] Database error during account deletion: {str(e)}")
raise HTTPException(status_code=500, detail="Database error during account deletion")
# Delete user's files
try:
user_dir = os.path.join('data', user.username)
real_user_dir = os.path.realpath(user_dir)
# Security check to prevent directory traversal
if not real_user_dir.startswith(os.path.realpath('data')):
print(f"[DELETE_ACCOUNT] Security alert: Invalid user directory path: {user_dir}")
raise HTTPException(status_code=400, detail="Invalid user directory")
if os.path.exists(real_user_dir):
import shutil
shutil.rmtree(real_user_dir, ignore_errors=True)
print(f"[DELETE_ACCOUNT] Deleted user directory: {real_user_dir}")
else:
print(f"[DELETE_ACCOUNT] User directory not found: {real_user_dir}")
except Exception as e:
print(f"[DELETE_ACCOUNT] Error deleting user files: {str(e)}")
# Continue even if file deletion fails, as the account is already deleted from the DB
print(f"[DELETE_ACCOUNT] Successfully deleted account for user {uid}")
return {"status": "success", "message": "Account and all associated data have been deleted"}
except HTTPException as he:
print(f"[DELETE_ACCOUNT] HTTP Error {he.status_code}: {he.detail}")
raise
except Exception as e:
print(f"[DELETE_ACCOUNT] Unexpected error: {str(e)}")
raise HTTPException(status_code=500, detail="An unexpected error occurred")

140
alembic.ini Normal file
View File

@ -0,0 +1,140 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = postgresql://postgres:postgres@localhost/dicta2stream
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
alembic/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

61
alembic/env.py Normal file
View File

@ -0,0 +1,61 @@
from logging.config import fileConfig
import os
import sys
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Add the project root to the Python path
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# Import your SQLAlchemy models and engine
from models import SQLModel
from database import engine
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Import all your SQLModel models here so that Alembic can detect them
from models import User, DBSession
# Set the target metadata to SQLModel.metadata
target_metadata = SQLModel.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = engine
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

28
alembic/script.py.mako Normal file
View File

@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,71 @@
"""Add PublicStream model
Revision ID: 0df481ee920b
Revises: f86c93c7a872
Create Date: 2025-07-19 10:02:22.902696
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0df481ee920b'
down_revision: Union[str, Sequence[str], None] = 'f86c93c7a872'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
# First create the new publicstream table
op.create_table('publicstream',
sa.Column('uid', sa.String(), nullable=False),
sa.Column('size', sa.Integer(), nullable=False),
sa.Column('mtime', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('uid')
)
# Drop the foreign key constraint first
op.drop_constraint('dbsession_user_id_fkey', 'dbsession', type_='foreignkey')
# Then drop the unique constraint
op.drop_constraint(op.f('uq_user_username'), 'user', type_='unique')
# Create the new index
op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True)
# Recreate the foreign key constraint
op.create_foreign_key(
'dbsession_user_id_fkey', 'dbsession', 'user',
['user_id'], ['username'], ondelete='CASCADE'
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
# Drop the foreign key constraint first
op.drop_constraint('dbsession_user_id_fkey', 'dbsession', type_='foreignkey')
# Drop the index
op.drop_index(op.f('ix_user_username'), table_name='user')
# Recreate the unique constraint
op.create_unique_constraint(op.f('uq_user_username'), 'user', ['username'])
# Recreate the foreign key constraint
op.create_foreign_key(
'dbsession_user_id_fkey', 'dbsession', 'user',
['user_id'], ['username'], ondelete='CASCADE'
)
# Drop the publicstream table
op.drop_table('publicstream')
# ### end Alembic commands ###

View File

@ -0,0 +1,86 @@
"""make username unique
Revision ID: 1ab2db0e4b5e
Revises:
Create Date: 2025-06-27 13:04:10.085253
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '1ab2db0e4b5e'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# 1. First, add the unique constraint to the username column
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.create_unique_constraint('uq_user_username', ['username'])
# 2. Now create the dbsession table with the foreign key
op.create_table('dbsession',
sa.Column('token', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('user_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('ip_address', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('user_agent', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('last_activity', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.username'], ),
sa.PrimaryKeyConstraint('token')
)
# 3. Drop old tables if they exist
if op.get_bind().engine.dialect.has_table(op.get_bind(), 'session'):
op.drop_index(op.f('ix_session_token'), table_name='session')
op.drop_index(op.f('ix_session_user_id'), table_name='session')
op.drop_table('session')
if op.get_bind().engine.dialect.has_table(op.get_bind(), 'publicstream'):
op.drop_table('publicstream')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# 1. First drop the dbsession table
op.drop_table('dbsession')
# 2. Recreate the old tables
op.create_table('publicstream',
sa.Column('uid', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('size', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('mtime', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('uid', name=op.f('publicstream_pkey'))
)
op.create_table('session',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('token', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('ip_address', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('user_agent', sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.Column('expires_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.Column('last_used_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('session_pkey'))
)
op.create_index(op.f('ix_session_user_id'), 'session', ['user_id'], unique=False)
op.create_index(op.f('ix_session_token'), 'session', ['token'], unique=True)
# 3. Finally, remove the unique constraint from username
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_constraint('uq_user_username', type_='unique')
# ### end Alembic commands ###

View File

@ -0,0 +1,30 @@
"""add_processed_filename_to_uploadlog
Revision ID: f86c93c7a872
Revises: 1ab2db0e4b5e
Create Date: 2025-06-28 15:56:29.169668
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f86c93c7a872'
down_revision: Union[str, Sequence[str], None] = '1ab2db0e4b5e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column('uploadlog',
sa.Column('processed_filename', sa.String(), nullable=True),
schema=None)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column('uploadlog', 'processed_filename', schema=None)

73
auth.py Normal file
View File

@ -0,0 +1,73 @@
"""Authentication middleware and utilities for dicta2stream"""
from fastapi import Request, HTTPException, Depends, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlmodel import Session
from typing import Optional
from models import User, Session as DBSession, verify_session
from database import get_db
security = HTTPBearer()
def get_current_user(
request: Request,
db: Session = Depends(get_db),
credentials: HTTPAuthorizationCredentials = Depends(security)
) -> User:
"""Dependency to get the current authenticated user"""
token = credentials.credentials
db_session = verify_session(db, token)
if not db_session:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired session",
headers={"WWW-Authenticate": "Bearer"},
)
# Get the user from the session
user = db.exec(
select(User).where(User.username == db_session.user_id)
).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
# Attach the session to the request state for later use
request.state.session = db_session
return user
def get_optional_user(
request: Request,
db: Session = Depends(get_db),
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security, use_cache=False)
) -> Optional[User]:
"""Dependency that returns the current user if authenticated, None otherwise"""
if not credentials:
return None
try:
return get_current_user(request, db, credentials)
except HTTPException:
return None
def create_session(db: Session, user: User, request: Request) -> DBSession:
"""Create a new session for the user"""
user_agent = request.headers.get("user-agent")
ip_address = request.client.host if request.client else "0.0.0.0"
session = DBSession.create_for_user(
user_id=user.username,
ip_address=ip_address,
user_agent=user_agent
)
db.add(session)
db.commit()
return session

106
auth_router.py Normal file
View File

@ -0,0 +1,106 @@
"""Authentication routes for dicta2stream"""
from fastapi import APIRouter, Depends, Request, Response, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlmodel import Session
from models import Session as DBSession, User
from database import get_db
from auth import get_current_user
router = APIRouter()
security = HTTPBearer()
@router.post("/logout")
async def logout(
request: Request,
response: Response,
db: Session = Depends(get_db),
credentials: HTTPAuthorizationCredentials = Depends(security)
):
"""Log out by invalidating the current session"""
token = credentials.credentials
# Find and invalidate the session
session = db.exec(
select(DBSession)
.where(DBSession.token == token)
.where(DBSession.is_active == True) # noqa: E712
).first()
if session:
session.is_active = False
db.add(session)
db.commit()
# Clear the session cookie
response.delete_cookie(
key="sessionid", # Must match the cookie name in main.py
httponly=True,
secure=True, # Must match the cookie settings from login
samesite="lax",
path="/"
)
return {"message": "Successfully logged out"}
@router.get("/me")
async def get_current_user_info(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get current user information"""
return {
"username": current_user.username,
"email": current_user.email,
"created_at": current_user.token_created.isoformat(),
"is_confirmed": current_user.confirmed
}
@router.get("/sessions")
async def list_sessions(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""List all active sessions for the current user"""
sessions = DBSession.get_active_sessions(db, current_user.username)
return [
{
"id": s.id,
"ip_address": s.ip_address,
"user_agent": s.user_agent,
"created_at": s.created_at.isoformat(),
"last_used_at": s.last_used_at.isoformat(),
"expires_at": s.expires_at.isoformat()
}
for s in sessions
]
@router.post("/sessions/{session_id}/revoke")
async def revoke_session(
session_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Revoke a specific session"""
session = db.get(DBSession, session_id)
if not session or session.user_id != current_user.username:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if not session.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Session is already inactive"
)
session.is_active = False
db.add(session)
db.commit()
return {"message": "Session revoked"}

212
deletefile.py Normal file
View File

@ -0,0 +1,212 @@
# deletefile.py — FastAPI route for file deletion
import os
import shutil
from typing import Optional, Dict, Any
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request, Depends, status, Header
from sqlalchemy import select, delete, and_
from sqlalchemy.orm import Session
from database import get_db
from models import UploadLog, UserQuota, User, DBSession
router = APIRouter()
# Use absolute path for security
DATA_ROOT = Path(os.path.abspath("./data"))
def get_current_user(
authorization: str = Header(None, description="Bearer token for authentication"),
db: Session = Depends(get_db)
) -> User:
"""
Get current user from authorization token with enhanced security.
Args:
authorization: The Authorization header containing the Bearer token
db: Database session dependency
Returns:
User: The authenticated user
Raises:
HTTPException: If authentication fails or user not found
"""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required"
)
token = authorization.split(" ")[1]
try:
with Session(db) as session:
# Check if session is valid
session_stmt = select(DBSession).where(
and_(
DBSession.token == token,
DBSession.is_active == True,
DBSession.expires_at > datetime.utcnow()
)
)
db_session = session.exec(session_stmt).first()
if not db_session:
print(f"[DELETE_FILE] Invalid or expired session token")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired session"
)
# Get the user
user = session.get(User, db_session.user_id)
if not user:
print(f"[DELETE_FILE] User not found for session token")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return user
except Exception as e:
print(f"[DELETE_FILE] Error during user authentication: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Error during authentication"
)
@router.delete("/delete/{filename}")
async def delete_file(
request: Request,
filename: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> Dict[str, Any]:
"""
Delete a file for the authenticated user with enhanced security and error handling.
Args:
request: The HTTP request object
filename: The name of the file to delete
db: Database session
current_user: The authenticated user
Returns:
Dict: Status and message of the operation
Raises:
HTTPException: If file not found, permission denied, or other errors
"""
print(f"[DELETE_FILE] Processing delete request for file '{filename}' from user {current_user.username}")
try:
# Security: Validate filename to prevent directory traversal
if not filename or any(c in filename for c in ['..', '/', '\\']):
print(f"[DELETE_FILE] Security alert: Invalid filename '{filename}'")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid filename"
)
# Construct full path with security checks
user_dir = DATA_ROOT / current_user.username
file_path = (user_dir / filename).resolve()
# Security: Ensure the file is within the user's directory
if not file_path.is_relative_to(user_dir.resolve()):
print(f"[DELETE_FILE] Security alert: Attempted path traversal: {file_path}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# Verify file exists and is a file
if not file_path.exists() or not file_path.is_file():
print(f"[DELETE_FILE] File not found: {file_path}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Get file size before deletion for quota update
file_size = file_path.stat().st_size
print(f"[DELETE_FILE] Deleting file: {file_path} (size: {file_size} bytes)")
# Start database transaction
with Session(db) as session:
try:
# Delete the file
try:
os.unlink(file_path)
print(f"[DELETE_FILE] Successfully deleted file: {file_path}")
except OSError as e:
print(f"[DELETE_FILE] Error deleting file: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete file"
)
# Clean up any associated raw files
raw_pattern = f"raw.*{filename}"
raw_files = list(file_path.parent.glob(raw_pattern))
for raw_file in raw_files:
try:
os.unlink(raw_file)
print(f"[DELETE_FILE] Deleted raw file: {raw_file}")
except OSError as e:
print(f"[DELETE_FILE] Warning: Could not delete raw file {raw_file}: {str(e)}")
# Delete the upload log entry
result = session.execute(
delete(UploadLog).where(
and_(
UploadLog.uid == current_user.username,
UploadLog.processed_filename == filename
)
)
)
if result.rowcount == 0:
print(f"[DELETE_FILE] Warning: No upload log entry found for {filename}")
else:
print(f"[DELETE_FILE] Deleted upload log entry for {filename}")
# Update user quota
quota = session.exec(
select(UserQuota)
.where(UserQuota.uid == current_user.username)
.with_for_update()
).first()
if quota:
new_quota = max(0, quota.storage_bytes - file_size)
print(f"[DELETE_FILE] Updating quota: {quota.storage_bytes} -> {new_quota}")
quota.storage_bytes = new_quota
session.add(quota)
session.commit()
print(f"[DELETE_FILE] Successfully updated database")
return {
"status": "success",
"message": "File deleted successfully",
"bytes_freed": file_size
}
except Exception as e:
session.rollback()
print(f"[DELETE_FILE] Database error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Database error during file deletion"
)
except HTTPException as he:
print(f"[DELETE_FILE] HTTP Error {he.status_code}: {he.detail}")
raise
except Exception as e:
print(f"[DELETE_FILE] Unexpected error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred"
)

22
dicta2stream.service Normal file
View File

@ -0,0 +1,22 @@
[Unit]
Description=Dicta2Stream FastAPI application (Gunicorn)
After=network.target
[Service]
User=oib
Group=www-data
WorkingDirectory=/home/oib/games/dicta2stream
Environment="PATH=/home/oib/games/dicta2stream/venv/bin"
Environment="PYTHONPATH=/home/oib/games/dicta2stream"
ExecStart=/home/oib/games/dicta2stream/venv/bin/gunicorn -c gunicorn_config.py main:app
Restart=always
RestartSec=5
# Security
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=read-only
[Install]
WantedBy=multi-user.target

35
gunicorn_config.py Normal file
View File

@ -0,0 +1,35 @@
# Gunicorn configuration file
import multiprocessing
import os
# Server socket
bind = "0.0.0.0:8000"
# Worker processes
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "uvicorn.workers.UvicornWorker"
worker_connections = 1000
max_requests = 1000
max_requests_jitter = 50
timeout = 120
keepalive = 5
# Security
limit_request_line = 4094
limit_request_fields = 50
limit_request_field_size = 8190
# Debugging
debug = os.getenv("DEBUG", "false").lower() == "true"
reload = debug
# Logging
loglevel = "debug" if debug else "info"
accesslog = "-" # Log to stdout
errorlog = "-" # Log to stderr
# Server mechanics
preload_app = True
# Process naming
proc_name = "dicta2stream"

94
import_streams.py Normal file
View File

@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
Script to import stream data from backup file into the publicstream table.
"""
import json
from datetime import datetime
from pathlib import Path
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from sqlmodel import Session
from models import PublicStream, User, UserQuota, DBSession, UploadLog
from database import engine
# Database connection URL - using the same as in database.py
DATABASE_URL = "postgresql://d2s:kuTy4ZKs2VcjgDh6@localhost:5432/dictastream"
def import_streams_from_backup(backup_file: str):
"""Import stream data from backup file into the database."""
# Set up database connection
SessionLocal = sessionmaker(bind=engine)
with Session(engine) as session:
try:
# Read the backup file
with open(backup_file, 'r') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
# Parse the JSON data
stream_data = json.loads(line)
uid = stream_data.get('uid')
size = stream_data.get('size', 0)
mtime = stream_data.get('mtime', int(datetime.now().timestamp()))
if not uid:
print(f"Skipping invalid entry (missing uid): {line}")
continue
# Check if the stream already exists
existing = session.exec(
select(PublicStream).where(PublicStream.uid == uid)
).first()
now = datetime.utcnow()
if existing:
# Update existing record
existing.size = size
existing.mtime = mtime
existing.updated_at = now
session.add(existing)
print(f"Updated stream: {uid}")
else:
# Create new record
stream = PublicStream(
uid=uid,
size=size,
mtime=mtime,
created_at=now,
updated_at=now
)
session.add(stream)
print(f"Added stream: {uid}")
# Commit after each record to ensure data integrity
session.commit()
except json.JSONDecodeError as e:
print(f"Error parsing line: {line}")
print(f"Error: {e}")
session.rollback()
except Exception as e:
print(f"Error processing line: {line}")
print(f"Error: {e}")
session.rollback()
print("Import completed successfully!")
except Exception as e:
session.rollback()
print(f"Error during import: {e}")
raise
if __name__ == "__main__":
backup_file = "public_streams.txt.backup"
if not Path(backup_file).exists():
print(f"Error: Backup file '{backup_file}' not found.")
exit(1)
print(f"Starting import from {backup_file}...")
import_streams_from_backup(backup_file)

36
init_db.py Normal file
View File

@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Initialize the database with required tables"""
import os
import sys
from sqlmodel import SQLModel, create_engine
from dotenv import load_dotenv
# Add the parent directory to the path so we can import our models
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from models import User, UserQuota, UploadLog, PublicStream, Session
def init_db():
"""Initialize the database with required tables"""
# Load environment variables
load_dotenv()
# Get database URL from environment or use default
database_url = os.getenv(
"DATABASE_URL",
"postgresql://postgres:postgres@localhost/dicta2stream"
)
print(f"Connecting to database: {database_url}")
# Create database engine
engine = create_engine(database_url)
# Create all tables
print("Creating database tables...")
SQLModel.metadata.create_all(engine)
print("Database initialized successfully!")
if __name__ == "__main__":
init_db()

View File

@ -1,64 +1,129 @@
# list_streams.py — FastAPI route to list all public streams (users with stream.opus)
from fastapi import APIRouter
from fastapi import APIRouter, Request, Depends
from fastapi.responses import StreamingResponse, Response
from sqlalchemy.orm import Session
from sqlalchemy import select
from models import PublicStream
from database import get_db
from pathlib import Path
from fastapi.responses import StreamingResponse
import asyncio
import os
import json
router = APIRouter()
DATA_ROOT = Path("./data")
@router.get("/streams-sse")
def streams_sse():
return list_streams_sse()
async def streams_sse(request: Request, db: Session = Depends(get_db)):
# Add CORS headers for SSE
origin = request.headers.get('origin', '')
allowed_origins = ["https://dicta2stream.net", "http://localhost:8000", "http://127.0.0.1:8000"]
import json
# Use the request origin if it's in the allowed list, otherwise use the first allowed origin
cors_origin = origin if origin in allowed_origins else allowed_origins[0]
import datetime
headers = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": cors_origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Expose-Headers": "Content-Type",
"X-Accel-Buffering": "no" # Disable buffering for nginx
}
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")
# Handle preflight requests
if request.method == "OPTIONS":
headers.update({
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": request.headers.get("access-control-request-headers", "*"),
"Access-Control-Max-Age": "86400" # 24 hours
})
return Response(status_code=204, headers=headers)
async def event_wrapper():
try:
async for event in list_streams_sse(db):
yield event
except Exception as e:
# Only log errors if DEBUG is enabled
if os.getenv("DEBUG") == "1":
import traceback
traceback.print_exc()
yield f"data: {json.dumps({'error': True, 'message': 'An error occurred'})}\n\n"
return StreamingResponse(
event_wrapper(),
media_type="text/event-stream",
headers=headers
)
async def list_streams_sse(db):
"""Stream public streams from the database as Server-Sent Events"""
try:
# Send initial ping
yield ":ping\n\n"
# Query all public streams from the database
stmt = select(PublicStream).order_by(PublicStream.mtime.desc())
result = db.execute(stmt)
streams = result.scalars().all()
if not streams:
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():
txt_path = Path("./public_streams.txt")
if not txt_path.exists():
return {"streams": []}
# Send each stream as an SSE event
for stream in streams:
try:
streams = []
with txt_path.open("r") as f:
for line in f:
line = line.strip()
if not line:
stream_data = {
'uid': stream.uid,
'size': stream.size,
'mtime': stream.mtime,
'created_at': stream.created_at.isoformat() if stream.created_at else None,
'updated_at': stream.updated_at.isoformat() if stream.updated_at else None
}
yield f"data: {json.dumps(stream_data)}\n\n"
# Small delay to prevent overwhelming the client
await asyncio.sleep(0.1)
except Exception as e:
if os.getenv("DEBUG") == "1":
import traceback
traceback.print_exc()
continue
# Send end of stream marker
yield f"data: {json.dumps({'end': True})}\n\n"
except Exception as e:
if os.getenv("DEBUG") == "1":
import traceback
traceback.print_exc()
yield f"data: {json.dumps({'error': True, 'message': str(e)})}\n\n"
yield f"data: {json.dumps({'error': True, 'message': 'Stream generation failed'})}\n\n"
def list_streams(db: Session = Depends(get_db)):
"""List all public streams from the database"""
try:
streams.append(json.loads(line))
except Exception:
continue # skip malformed lines
return {"streams": streams}
except Exception:
stmt = select(PublicStream).order_by(PublicStream.mtime.desc())
result = db.execute(stmt)
streams = result.scalars().all()
return {
"streams": [
{
'uid': stream.uid,
'size': stream.size,
'mtime': stream.mtime,
'created_at': stream.created_at.isoformat() if stream.created_at else None,
'updated_at': stream.updated_at.isoformat() if stream.updated_at else None
}
for stream in streams
]
}
except Exception as e:
if os.getenv("DEBUG") == "1":
import traceback
traceback.print_exc()
return {"streams": []}

View File

@ -1,16 +1,18 @@
# magic.py — handle magic token login confirmation
from fastapi import APIRouter, Form, HTTPException, Depends, Request
from fastapi.responses import RedirectResponse
from fastapi import APIRouter, Form, HTTPException, Depends, Request, Response
from fastapi.responses import RedirectResponse, JSONResponse
from sqlmodel import Session, select
from database import get_db
from models import User
from models import User, DBSession
from datetime import datetime, timedelta
import secrets
import json
router = APIRouter()
@router.post("/magic-login")
def magic_login(request: Request, db: Session = Depends(get_db), token: str = Form(...)):
async def magic_login(request: Request, response: Response, 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'}")
@ -23,12 +25,45 @@ def magic_login(request: Request, db: Session = Depends(get_db), token: str = Fo
print(f"[magic-login] Token expired for user: {user.username}")
return RedirectResponse(url="/?error=Token%20expired", status_code=302)
# Mark user as confirmed if not already
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.")
db.add(user)
print(f"[magic-login] User {user.username} confirmed.")
return RedirectResponse(url=f"/?login=success&confirmed_uid={user.username}", status_code=302)
# Create a new session for the user (valid for 1 hour)
session_token = secrets.token_urlsafe(32)
expires_at = datetime.utcnow() + timedelta(hours=1)
# Create new session
session = DBSession(
token=session_token,
user_id=user.username,
ip_address=request.client.host or "",
user_agent=request.headers.get("user-agent", ""),
expires_at=expires_at,
is_active=True
)
db.add(session)
db.commit()
# Set cookie with the session token (valid for 1 hour)
response.set_cookie(
key="sessionid",
value=session_token,
httponly=True,
secure=not request.url.hostname == "localhost",
samesite="lax",
max_age=3600, # 1 hour
path="/"
)
print(f"[magic-login] Session created for user: {user.username}")
# Redirect to success page
return RedirectResponse(
url=f"/?login=success&confirmed_uid={user.username}",
status_code=302,
headers=dict(response.headers)
)

156
main.py
View File

@ -1,6 +1,6 @@
# main.py — FastAPI backend entrypoint for dicta2stream
from fastapi import FastAPI, Request, Response, status, Form, UploadFile, File, Depends
from fastapi import FastAPI, Request, Response, status, Form, UploadFile, File, Depends, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
@ -36,16 +36,36 @@ from fastapi.requests import Request as FastAPIRequest
from fastapi.exception_handlers import RequestValidationError
from fastapi.exceptions import HTTPException as FastAPIHTTPException
app = FastAPI(debug=debug_mode)
app = FastAPI(debug=debug_mode, docs_url=None, redoc_url=None, openapi_url=None)
# Override default HTML error handlers to return JSON
from fastapi.exceptions import RequestValidationError, HTTPException as FastAPIHTTPException
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request, exc):
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail}
)
# --- CORS Middleware for SSE and API access ---
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
# Add GZip middleware for compression
app.add_middleware(GZipMiddleware, minimum_size=1000)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["https://dicta2stream.net", "http://localhost:8000", "http://127.0.0.1:8000"],
allow_credentials=True,
allow_methods=["*"],
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
expose_headers=["Content-Type", "Content-Length", "Cache-Control", "ETag", "Last-Modified"],
max_age=3600, # 1 hour
)
from fastapi.staticfiles import StaticFiles
@ -125,7 +145,9 @@ 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
from account_router import router as account_router
app.include_router(account_router)
app.include_router(register_router)
app.include_router(magic_router)
app.include_router(upload_router)
@ -135,6 +157,10 @@ app.include_router(list_streams_router)
# Serve static files
app.mount("/static", StaticFiles(directory="static"), name="static")
# Serve audio files
os.makedirs("data", exist_ok=True) # Ensure the data directory exists
app.mount("/audio", StaticFiles(directory="data"), name="audio")
@app.post("/log-client")
async def log_client(request: Request):
try:
@ -224,68 +250,7 @@ def debug(request: Request):
MAX_QUOTA_BYTES = 100 * 1024 * 1024
@app.post("/delete-account")
async def delete_account(data: dict, request: Request, db: Session = Depends(get_db)):
uid = data.get("uid")
if not uid:
raise HTTPException(status_code=400, detail="Missing UID")
ip = request.client.host
user = get_user_by_uid(uid)
if not user or user.ip != ip:
raise HTTPException(status_code=403, detail="Unauthorized")
# Delete user quota and user using ORM
quota = db.get(UserQuota, uid)
if quota:
db.delete(quota)
user_obj = db.get(User, user.email)
if user_obj:
db.delete(user_obj)
db.commit()
import shutil
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('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"}
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
if content_type in ["music", "singing"]:
os.remove(raw_path)
log_violation("UPLOAD", ip, uid, f"Rejected content: {content_type}")
return JSONResponse(status_code=403, content={"error": f"{content_type.capitalize()} uploads are not allowed."})
try:
subprocess.run([
"ffmpeg", "-y", "-i", raw_path,
"-ac", "1", "-ar", "48000",
"-c:a", "libopus", "-b:a", "60k",
final_path
], check=True)
except subprocess.CalledProcessError as e:
os.remove(raw_path)
log_violation("FFMPEG", ip, uid, f"ffmpeg failed: {e}")
raise HTTPException(status_code=500, detail="Encoding failed")
os.remove(raw_path)
try:
actual_bytes = int(subprocess.check_output(["du", "-sb", user_dir]).split()[0])
q = db.get(UserQuota, uid)
if q:
q.storage_bytes = actual_bytes
db.add(q)
db.commit()
except Exception as e:
log_violation("QUOTA", ip, uid, f"Quota update failed: {e}")
return {}
# Delete account endpoint has been moved to account_router.py
@app.delete("/uploads/{uid}/{filename}")
def delete_file(uid: str, filename: str, request: Request, db: Session = Depends(get_db)):
@ -333,24 +298,63 @@ def confirm_user(uid: str, request: Request):
@app.get("/me/{uid}")
def get_me(uid: str, request: Request, db: Session = Depends(get_db)):
ip = request.client.host
print(f"[DEBUG] GET /me/{uid} - Client IP: {request.client.host}")
try:
# Get user info
user = get_user_by_uid(uid)
if not user or user.ip != ip:
raise HTTPException(status_code=403, detail="Unauthorized access")
if not user:
print(f"[ERROR] User with UID {uid} not found")
raise HTTPException(status_code=404, detail="User not found")
user_dir = os.path.join('data', user.username)
# Only enforce IP check in production
if not debug_mode:
if user.ip != request.client.host:
print(f"[WARNING] IP mismatch for UID {uid}: {request.client.host} != {user.ip}")
# In production, we might want to be more strict
# But for now, we'll just log a warning in development
if not debug_mode:
raise HTTPException(status_code=403, detail="IP address mismatch")
# Get all upload logs for this user
upload_logs = db.exec(
select(UploadLog)
.where(UploadLog.uid == uid)
.order_by(UploadLog.created_at.desc())
).all()
print(f"[DEBUG] Found {len(upload_logs)} upload logs for UID {uid}")
# Build file list from database records
files = []
if os.path.exists(user_dir):
for f in os.listdir(user_dir):
path = os.path.join(user_dir, f)
if os.path.isfile(path):
files.append({"name": f, "size": os.path.getsize(path)})
for log in upload_logs:
if log.filename and log.processed_filename:
# The actual filename on disk might have the log ID prepended
stored_filename = f"{log.id}_{log.processed_filename}"
files.append({
"name": stored_filename,
"original_name": log.filename,
"size": log.size_bytes
})
print(f"[DEBUG] Added file from DB: {log.filename} (stored as {stored_filename}, {log.size_bytes} bytes)")
# Get quota info
q = db.get(UserQuota, uid)
quota_mb = round(q.storage_bytes / (1024 * 1024), 2) if q else 0
print(f"[DEBUG] Quota for UID {uid}: {quota_mb} MB")
return {
response_data = {
"files": files,
"quota": quota_mb
}
print(f"[DEBUG] Returning {len(files)} files and quota info")
return response_data
except HTTPException:
# Re-raise HTTP exceptions as they are
raise
except Exception as e:
# Log the full traceback for debugging
import traceback
error_trace = traceback.format_exc()
print(f"[ERROR] Error in /me/{uid} endpoint: {str(e)}\n{error_trace}")
# Return a 500 error with a generic message
raise HTTPException(status_code=500, detail="Internal server error")

73
middleware.py Normal file
View File

@ -0,0 +1,73 @@
"""Custom middleware for the dicta2stream application"""
import time
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import Response
from starlette.types import ASGIApp
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Middleware to implement rate limiting"""
def __init__(self, app: ASGIApp, limit: int = 100, window: int = 60):
super().__init__(app)
self.limit = limit
self.window = window
self.requests = {}
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
# Get client IP
if "x-forwarded-for" in request.headers:
ip = request.headers["x-forwarded-for"].split(",")[0]
else:
ip = request.client.host or "unknown"
# Get current timestamp
current_time = int(time.time())
# Clean up old entries
self.requests = {
k: v
for k, v in self.requests.items()
if current_time - v["timestamp"] < self.window
}
# Check rate limit
if ip in self.requests:
self.requests[ip]["count"] += 1
if self.requests[ip]["count"] > self.limit:
raise HTTPException(
status_code=429,
detail="Too many requests. Please try again later."
)
else:
self.requests[ip] = {"count": 1, "timestamp": current_time}
# Process the request
response = await call_next(request)
return response
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Middleware to add security headers to responses"""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# Add security headers
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# Content Security Policy
csp_parts = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"media-src 'self' blob: data:",
"connect-src 'self' https: wss:",
"frame-ancestors 'none'"
]
response.headers["Content-Security-Policy"] = "; ".join(csp_parts)
return response

View File

@ -0,0 +1,67 @@
"""Add session and public_stream tables
Revision ID: 0002
Revises: 0001
Create Date: 2023-04-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '0002'
down_revision = '0001'
branch_labels = None
depends_on = None
def upgrade():
# Create public_stream table
op.create_table(
'public_stream',
sa.Column('uid', sa.String(), nullable=False, comment='User ID of the stream owner'),
sa.Column('filename', sa.String(), nullable=False, comment='Name of the audio file'),
sa.Column('size', sa.BigInteger(), nullable=False, comment='File size in bytes'),
sa.Column('mtime', sa.Float(), nullable=False, comment='Last modified time as Unix timestamp'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False, onupdate=sa.text('now()')),
sa.PrimaryKeyConstraint('uid', 'filename')
)
# Create session table
op.create_table(
'session',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.String(), nullable=False, index=True, comment='Reference to user.username'),
sa.Column('token', sa.Text(), nullable=False, index=True, comment='Random session token'),
sa.Column('ip_address', sa.String(45), nullable=False, comment='IP address of the client'),
sa.Column('user_agent', sa.Text(), nullable=True, comment='User-Agent header from the client'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False, comment='When the session expires'),
sa.Column('last_used_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False, onupdate=sa.text('now()')),
sa.Column('is_active', sa.Boolean(), server_default=sa.text('true'), nullable=False, comment='Whether the session is active'),
sa.ForeignKeyConstraint(['user_id'], ['user.username'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# Create indexes
op.create_index('ix_session_user_id', 'session', ['user_id'], unique=False)
op.create_index('ix_session_token', 'session', ['token'], unique=True)
op.create_index('ix_session_expires_at', 'session', ['expires_at'], unique=False)
op.create_index('ix_session_last_used_at', 'session', ['last_used_at'], unique=False)
op.create_index('ix_public_stream_uid', 'public_stream', ['uid'], unique=False)
op.create_index('ix_public_stream_updated_at', 'public_stream', ['updated_at'], unique=False)
def downgrade():
# Drop indexes first
op.drop_index('ix_session_user_id', table_name='session')
op.drop_index('ix_session_token', table_name='session')
op.drop_index('ix_session_expires_at', table_name='session')
op.drop_index('ix_session_last_used_at', table_name='session')
op.drop_index('ix_public_stream_uid', table_name='public_stream')
op.drop_index('ix_public_stream_updated_at', table_name='public_stream')
# Drop tables
op.drop_table('session')
op.drop_table('public_stream')

View File

@ -0,0 +1,24 @@
"""Add processed_filename to UploadLog
Revision ID: add_processed_filename_to_uploadlog
Revises:
Create Date: 2025-06-28 13:20:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_processed_filename_to_uploadlog'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Add the processed_filename column to the uploadlog table
op.add_column('uploadlog',
sa.Column('processed_filename', sa.String(), nullable=True))
def downgrade():
# Remove the processed_filename column if rolling back
op.drop_column('uploadlog', 'processed_filename')

View File

@ -8,7 +8,7 @@ from database import engine
class User(SQLModel, table=True):
token_created: datetime = Field(default_factory=datetime.utcnow)
email: str = Field(primary_key=True)
username: str
username: str = Field(unique=True, index=True)
token: str
confirmed: bool = False
ip: str = Field(default="")
@ -23,13 +23,62 @@ class UploadLog(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
uid: str
ip: str
filename: Optional[str]
filename: Optional[str] # Original filename
processed_filename: Optional[str] # Processed filename (UUID.opus)
size_bytes: int
created_at: datetime = Field(default_factory=datetime.utcnow)
class DBSession(SQLModel, table=True):
token: str = Field(primary_key=True)
user_id: str = Field(foreign_key="user.username")
ip_address: str
user_agent: str
created_at: datetime = Field(default_factory=datetime.utcnow)
expires_at: datetime
is_active: bool = True
last_activity: datetime = Field(default_factory=datetime.utcnow)
class PublicStream(SQLModel, table=True):
"""Stores public stream metadata for all users"""
uid: str = Field(primary_key=True)
size: int = 0
mtime: int = Field(default_factory=lambda: int(datetime.utcnow().timestamp()))
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
def get_user_by_uid(uid: str) -> Optional[User]:
with Session(engine) as session:
statement = select(User).where(User.username == uid)
result = session.exec(statement).first()
return result
def verify_session(db: Session, token: str) -> DBSession:
"""Verify a session token and return the session if valid"""
from datetime import datetime
# Find the session
session = db.exec(
select(DBSession)
.where(DBSession.token == token)
.where(DBSession.is_active == True) # noqa: E712
.where(DBSession.expires_at > datetime.utcnow())
).first()
if not session:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired session",
headers={"WWW-Authenticate": "Bearer"},
)
# Update last activity
session.last_activity = datetime.utcnow()
db.add(session)
db.commit()
db.refresh(session)
return session

View File

@ -1 +1,4 @@
{"uid":"devuser","size":22455090,"mtime":1747563720}
{"uid":"devuser","size":65551721,"mtime":1752752391}
{"uid":"oib9","size":12735117,"mtime":1752843762}
{"uid":"oibchello","size":1549246,"mtime":1752840918}
{"uid":"orangeicebear","size":1734396,"mtime":1748767975}

29
run_migrations.py Normal file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Run database migrations"""
import os
import sys
from alembic.config import Config
from alembic import command
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
def run_migrations():
# Get database URL from environment or use default
database_url = os.getenv(
"DATABASE_URL",
"postgresql://postgres:postgres@localhost/dicta2stream"
)
# Set up Alembic config
alembic_cfg = Config()
alembic_cfg.set_main_option("script_location", "migrations")
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
# Run migrations
command.upgrade(alembic_cfg, "head")
print("Database migrations completed successfully.")
if __name__ == "__main__":
run_migrations()

File diff suppressed because it is too large Load Diff

208
static/css/base.css Normal file
View File

@ -0,0 +1,208 @@
/* Base styles and resets */
:root {
/* Colors */
--color-primary: #4a90e2;
--color-primary-dark: #2a6fc9;
--color-text: #333;
--color-text-light: #666;
--color-bg: #f8f9fa;
--color-border: #e9ecef;
--color-white: #fff;
--color-black: #000;
/* Typography */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
--font-size-base: 1rem;
--line-height-base: 1.5;
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
/* Border radius */
--border-radius-sm: 4px;
--border-radius-md: 8px;
--border-radius-lg: 12px;
/* Transitions */
--transition-base: all 0.2s ease;
--transition-slow: all 0.3s ease;
}
/* Reset and base styles */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
height: 100%;
font-size: 16px;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
body {
margin: 0;
min-height: 100%;
font-family: var(--font-family);
line-height: var(--line-height-base);
color: var(--color-text);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Main content */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 6rem 1.5rem 2rem; /* Add top padding to account for fixed header */
min-height: calc(100vh - 200px); /* Ensure footer stays at bottom */
}
/* Sections */
section {
margin: 2rem 0;
padding: 2rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
section h2 {
color: var(--color-primary);
margin-top: 0;
margin-bottom: 1.5rem;
font-size: 2rem;
}
section p {
color: var(--color-text);
line-height: 1.6;
margin-bottom: 1.5rem;
}
.main-heading {
font-size: 2.5rem;
margin: 0 0 2rem 0;
color: var(--color-text);
font-weight: 700;
line-height: 1.2;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
text-align: center;
}
.main-heading .mic-icon {
display: inline-flex;
animation: pulse 2s infinite;
transform-origin: center;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
margin-top: 0;
margin-bottom: var(--spacing-md);
font-weight: 600;
line-height: 1.2;
}
p {
margin-top: 0;
margin-bottom: var(--spacing-md);
}
a {
color: var(--color-primary);
text-decoration: none;
transition: var(--transition-base);
}
a:hover {
color: var(--color-primary-dark);
text-decoration: underline;
}
/* Images */
img {
max-width: 100%;
height: auto;
vertical-align: middle;
border-style: none;
}
/* Lists */
ul, ol {
padding-left: var(--spacing-lg);
margin-bottom: var(--spacing-md);
}
/* Loading animation */
.app-loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
background: var(--color-white);
z-index: 9999;
transition: opacity var(--transition-slow);
text-align: center;
padding: 2rem;
color: var(--color-text);
}
.app-loading > div:first-child {
margin-bottom: 1rem;
font-size: 2rem;
}
.app-loading.hidden {
opacity: 0;
pointer-events: none;
}
.app-content {
opacity: 1;
transition: opacity var(--transition-slow);
}
/* This class can be used for initial fade-in if needed */
.app-content.initial-load {
opacity: 0;
}
.app-content.loaded {
opacity: 1;
}
/* Utility classes */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

69
static/css/colors.css Normal file
View File

@ -0,0 +1,69 @@
/*
* Color System Documentation
*
* This file documents the color variables used throughout the application.
* All colors should be defined as CSS variables in :root, and these variables
* should be used consistently across all CSS and JavaScript files.
*/
:root {
/* Primary Colors */
--primary-color: #4a6fa5; /* Main brand color */
--primary-hover: #3a5a8c; /* Darker shade for hover states */
/* Text Colors */
--text-color: #f0f0f0; /* Main text color */
--text-muted: #888; /* Secondary text, less important info */
--text-light: #999; /* Lighter text for disabled states */
--text-lighter: #bbb; /* Very light text, e.g., placeholders */
/* Background Colors */
--background: #1a1a1a; /* Main background color */
--surface: #2a2a2a; /* Surface color for cards, panels, etc. */
--code-bg: #222; /* Background for code blocks */
/* Border Colors */
--border: #444; /* Default border color */
--border-light: #555; /* Lighter border */
--border-lighter: #666; /* Even lighter border */
/* Status Colors */
--success: #2e8b57; /* Success messages, confirmations */
--warning: #ff6600; /* Warnings, important notices */
--error: #ff4444; /* Error messages, destructive actions */
--error-hover: #ff6666; /* Hover state for error buttons */
--info: #1e90ff; /* Informational messages, links */
--link-hover: #74c0fc; /* Hover state for links */
/* Transitions */
--transition: all 0.2s ease; /* Default transition */
}
/*
* Usage Examples:
*
* .button {
* background-color: var(--primary-color);
* color: var(--text-color);
* border: 1px solid var(--border);
* transition: var(--transition);
* }
*
* .button:hover {
* background-color: var(--primary-hover);
* }
*
* .error-message {
* color: var(--error);
* background-color: color-mix(in srgb, var(--error) 10%, transparent);
* border-left: 3px solid var(--error);
* }
*/
/*
* Accessibility Notes:
* - Ensure text has sufficient contrast with its background
* - Use semantic color names that describe the purpose, not the color
* - Test with color blindness simulators for accessibility
* - Maintain consistent color usage throughout the application
*/

View File

View File

@ -0,0 +1,268 @@
/* File upload and list styles */
#user-upload-area {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 2rem;
text-align: center;
margin: 1rem 0;
cursor: pointer;
transition: all 0.2s ease-in-out;
background-color: var(--surface);
}
#user-upload-area:hover,
#user-upload-area.highlight {
border-color: var(--primary);
background-color: rgba(var(--primary-rgb), 0.05);
}
#user-upload-area p {
margin: 0;
color: var(--text-secondary);
}
#file-list {
list-style: none;
padding: 0;
margin: 1rem 0 0;
}
#file-list {
margin: 1.5rem 0;
padding: 0;
}
#file-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
margin: 0.5rem 0;
background-color: var(--surface);
border-radius: 6px;
border: 1px solid var(--border);
transition: all 0.2s ease-in-out;
}
#file-list li:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
#file-list li.no-files,
#file-list li.loading-message,
#file-list li.error-message {
display: block;
text-align: center;
color: var(--text-muted);
padding: 2rem 1.5rem;
background-color: transparent;
border: 2px dashed var(--border);
margin: 1rem 0;
border-radius: 8px;
font-size: 1.1em;
}
#file-list li.loading-message {
color: var(--primary);
font-style: italic;
}
#file-list li.error-message {
color: var(--error);
border-color: var(--error);
}
#file-list li.error-message .login-link {
color: var(--primary);
text-decoration: none;
font-weight: bold;
margin-left: 0.3em;
}
#file-list li.error-message .login-link:hover {
text-decoration: underline;
}
#file-list li.no-files:hover {
background-color: rgba(var(--primary-rgb), 0.05);
border-color: var(--primary);
transform: none;
box-shadow: none;
}
.file-item {
width: 100%;
}
.file-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0; /* Allows text truncation */
}
.file-icon {
margin-right: 0.75rem;
font-size: 1.2em;
flex-shrink: 0;
}
.file-name {
color: var(--primary);
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 0.5rem;
}
.file-name:hover {
text-decoration: underline;
}
.file-size {
color: var(--text-muted);
font-size: 0.85em;
margin-left: 0.5rem;
white-space: nowrap;
flex-shrink: 0;
}
.file-actions {
display: flex;
gap: 0.5rem;
margin-left: 1rem;
flex-shrink: 0;
}
.download-button,
.delete-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.8rem;
border-radius: 4px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
border: 1px solid transparent;
}
.download-button {
background-color: var(--primary);
color: white;
}
.download-button:hover {
background-color: var(--primary-hover);
transform: translateY(-1px);
}
.delete-button {
background-color: transparent;
color: var(--error);
border-color: var(--error);
}
.delete-button:hover {
background-color: rgba(var(--error-rgb), 0.1);
}
.button-icon {
font-size: 1em;
}
.button-text {
display: none;
}
/* Show text on larger screens */
@media (min-width: 640px) {
.button-text {
display: inline;
}
.download-button,
.delete-button {
padding: 0.4rem 1rem;
}
}
/* Responsive adjustments */
@media (max-width: 480px) {
#file-list li {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.file-actions {
width: 100%;
margin-left: 0;
justify-content: flex-end;
}
.file-name {
max-width: 100%;
}
}
#file-list li a {
color: var(--primary);
text-decoration: none;
flex-grow: 1;
margin-right: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#file-list li a:hover {
text-decoration: underline;
}
.file-size {
color: var(--text-secondary);
font-size: 0.9em;
margin-left: 0.5rem;
}
.delete-file {
background: none;
border: none;
color: var(--error);
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.delete-file:hover {
background-color: rgba(var(--error-rgb), 0.1);
}
/* Loading state */
#file-list.loading {
opacity: 0.7;
pointer-events: none;
}
/* Mobile optimizations */
@media (max-width: 768px) {
#user-upload-area {
padding: 1.5rem 1rem;
}
#file-list li {
padding: 0.5rem;
font-size: 0.9rem;
}
.file-size {
display: block;
margin-left: 0;
margin-top: 0.25rem;
}
}

View File

View File

@ -0,0 +1,80 @@
/* Footer styles */
footer {
background: #2c3e50;
color: var(--text-color);
padding: 2rem 0;
margin-top: 3rem;
width: 100%;
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.footer-links {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1rem;
margin-top: 1rem;
}
.footer-links a {
color: var(--text-color);
text-decoration: none;
transition: color 0.2s;
}
.footer-links a:hover,
.footer-links a:focus {
color: var(--info);
text-decoration: underline;
}
.separator {
color: var(--text-muted);
margin: 0 0.25rem;
}
.footer-hint {
margin-top: 1rem;
font-size: 0.9rem;
color: var(--text-light);
}
.footer-hint a {
color: var(--info);
text-decoration: none;
}
.footer-hint a:hover,
.footer-hint a:focus {
text-decoration: underline;
}
/* Responsive adjustments */
@media (max-width: 767px) {
footer {
padding: 1.5rem 1rem;
}
.footer-links {
flex-direction: column;
gap: 0.5rem;
}
.separator {
display: none;
}
.footer-hint {
font-size: 0.85rem;
line-height: 1.5;
}
}

View File

@ -0,0 +1,149 @@
/* Header and navigation styles */
header {
width: 100%;
background: rgba(33, 37, 41, 0.95);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
position: fixed;
top: 0;
left: 0;
z-index: 1000;
padding: 0.5rem 0;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
}
/* Logo */
.logo {
color: white;
font-size: 1.5rem;
font-weight: bold;
text-decoration: none;
padding: 0.5rem 0;
}
.logo:hover {
text-decoration: none;
opacity: 0.9;
}
/* Navigation */
.nav-wrapper {
display: flex;
align-items: center;
height: 100%;
}
/* Menu toggle button */
.menu-toggle {
background: none;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
padding: 0.5rem;
display: none; /* Hidden by default, shown on mobile */
}
/* Navigation list */
.nav-list {
display: flex;
list-style: none;
margin: 0;
padding: 0;
gap: 1rem;
align-items: center;
}
.nav-item {
margin: 0;
}
.nav-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.2s, color 0.2s;
display: block;
}
.nav-link:hover,
.nav-link:focus {
background: rgba(255, 255, 255, 0.1);
text-decoration: none;
color: var(--text-color);
}
/* Active navigation item */
.nav-link.active {
background: rgba(255, 255, 255, 0.2);
font-weight: 500;
}
/* Mobile menu */
@media (max-width: 767px) {
.menu-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
background: transparent;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
z-index: 1001;
}
.nav-wrapper {
position: fixed;
top: 0;
right: -100%;
width: 80%;
max-width: 300px;
height: 100vh;
background: rgba(33, 37, 41, 0.98);
padding: 5rem 1.5rem 2rem;
transition: right 0.3s ease-in-out;
z-index: 1000;
overflow-y: auto;
display: block;
}
.nav-wrapper.active {
right: 0;
}
.nav-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0;
}
.nav-item {
width: 100%;
}
.nav-link {
display: block;
padding: 0.75rem 1rem;
border-radius: 4px;
}
.nav-link:hover,
.nav-link:focus {
background: rgba(255, 255, 255, 0.15);
}
}

View File

View File

View File

54
static/css/section.css Normal file
View File

@ -0,0 +1,54 @@
/* section.css - Centralized visibility control with class-based states */
/* Base section visibility - all sections hidden by default */
main > section {
display: none;
position: absolute;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
opacity: 0;
}
/* Active section styling - only visibility properties */
main > section.active {
display: block;
position: relative;
overflow: visible;
clip: auto;
white-space: normal;
opacity: 1;
}
/* Authentication-based visibility classes */
.guest-only { display: block; }
.auth-only {
display: none;
}
/* Show auth-only elements when authenticated */
body.authenticated .auth-only {
display: block;
}
/* Ensure me-page and its direct children are visible when me-page is active */
#me-page:not([hidden]) > .auth-only,
#me-page:not([hidden]) > section,
#me-page:not([hidden]) > article,
#me-page:not([hidden]) > div,
/* Ensure account deletion section is visible when privacy page is active and user is authenticated */
#privacy-page:not([hidden]) .auth-only,
#privacy-page:not([hidden]) #account-deletion {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
body.authenticated .guest-only {
display: none;
}
.always-visible {
display: block !important;
}

View File

View File

File diff suppressed because it is too large Load Diff

231
static/desktop.css Normal file
View File

@ -0,0 +1,231 @@
/* Desktop-specific styles for screens 960px and wider */
@media (min-width: 960px) {
:root {
--content-max-width: 800px;
--content-padding: 1.25rem;
--section-spacing: 1.5rem;
}
html {
background-color: #111 !important;
background-image:
repeating-linear-gradient(
45deg,
rgba(188, 183, 107, 0.1) 0,
rgba(188, 183, 107, 0.1) 1px,
transparent 1px,
transparent 20px
),
repeating-linear-gradient(
-45deg,
rgba(188, 183, 107, 0.1) 0,
rgba(188, 183, 107, 0.1) 1px,
transparent 1px,
transparent 20px
) !important;
background-size: 40px 40px !important;
background-repeat: repeat !important;
background-attachment: fixed !important;
min-height: 100% !important;
}
body {
background: transparent !important;
min-height: 100vh !important;
display: flex;
flex-direction: column;
}
/* Main content container */
main {
flex: 1;
width: 100%;
max-width: var(--content-max-width);
margin: 0 auto;
padding: 0 var(--content-padding);
box-sizing: border-box;
}
/* Ensure h2 in legal pages matches other pages */
#privacy-page > article > h2:first-child,
#imprint-page > article > h2:first-child {
margin-top: 0;
padding-top: 0;
}
/* Streams Page Specific Styles */
#streams-page section {
width: 100%;
max-width: var(--content-max-width);
margin: 0 auto;
padding: 2rem;
box-sizing: border-box;
}
.stream-card {
margin-bottom: 1rem;
background: var(--surface);
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stream-card:last-child {
margin-bottom: 0;
}
.stream-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stream-card .card-content {
padding: 1.25rem 1.5rem;
}
/* Section styles */
section {
width: 100%;
max-width: var(--content-max-width);
margin: 0 auto var(--section-spacing);
background: rgba(26, 26, 26, 0.9);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 10px;
padding: 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-sizing: border-box;
}
section:hover {
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25);
}
/* Navigation */
nav.dashboard-nav {
padding: 1rem 0;
margin-bottom: 2rem;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(5px);
display: block;
}
/* Desktop navigation visibility */
nav.dashboard-nav {
display: block;
}
/* Show desktop navigation */
section#links {
display: block;
}
/* Hide mobile navigation elements */
#burger-label,
#burger-toggle {
display: none !important;
}
/* Dashboard navigation */
#guest-dashboard,
#user-dashboard {
display: flex;
gap: 1rem;
}
nav.dashboard-nav a {
padding: 0.5rem 1rem;
margin: 0 0.5em;
border-radius: 4px;
transition: background-color 0.2s ease;
}
nav.dashboard-nav a:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* Form elements */
input[type="email"],
input[type="text"],
input[type="password"] {
width: 100%;
max-width: 400px;
padding: 0.75rem;
margin: 0.5rem 0;
border: 1px solid #444;
border-radius: 4px;
background: #2a2a2a;
color: #f0f0f0;
}
/* Buttons */
button,
.button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
background: #4a6fa5;
color: white;
cursor: pointer;
transition: background-color 0.2s ease;
}
button:hover,
.button:hover {
background: #5a8ad4;
}
/* Global article styles */
main > section > article,
#stream-page > article,
#stream-page #stream-list > li .stream-player {
max-width: 600px;
margin: 2em auto 2em auto;
padding: 2em;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 8px;
transition: all 0.2s ease;
box-sizing: border-box;
}
/* Add top margin to all stream players except the first one */
#stream-page #stream-list > li:not(:first-child) .stream-player {
margin-top: 2px;
}
/* Stream player styles */
#stream-page #stream-list > li {
list-style: none;
margin: 0;
padding: 0;
border: none;
background: transparent;
}
#stream-page #stream-list {
padding: 0;
margin: 0 auto;
max-width: 600px;
width: 100%;
}
/* Stream player specific overrides can be added here if needed in the future */
/* Hover states moved to style.css for consistency */
/* Stream list desktop styles */
#stream-list {
max-width: 600px;
margin: 0 auto;
}
/* User upload area - matches article styling */
#user-upload-area {
max-width: 600px;
width: 100%;
margin: 2rem auto;
box-sizing: border-box;
}
}

12
static/footer.html Normal file
View File

@ -0,0 +1,12 @@
<!-- Footer content -->
<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>
<div class="footer-links">
<a href="#" data-target="terms-page">Terms</a>
<span class="separator"></span>
<a href="#" data-target="privacy-page">Privacy</a>
<span class="separator"></span>
<a href="#" data-target="imprint-page">Imprint</a>
</div>
</footer>

13
static/generate-test-audio.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/bash
# Create a 1-second silent audio file in Opus format
ffmpeg -f lavfi -i anullsrc=r=48000:cl=mono -t 1 -c:a libopus -b:a 60k /home/oib/games/dicta2stream/static/test-audio.opus
# Verify the file was created
if [ -f "/home/oib/games/dicta2stream/static/test-audio.opus" ]; then
echo "Test audio file created successfully at /home/oib/games/dicta2stream/static/test-audio.opus"
echo "File size: $(du -h /home/oib/games/dicta2stream/static/test-audio.opus | cut -f1)"
else
echo "Failed to create test audio file"
exit 1
fi

View File

@ -3,24 +3,26 @@
<html lang="en">
<head>
<link rel="stylesheet" href="/static/style.css" media="all" />
<link rel="stylesheet" href="/static/desktop.css" media="(min-width: 960px)">
<link rel="stylesheet" href="/static/mobile.css" media="(max-width: 959px)">
<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>
<!-- Responsive burger menu display -->
<!-- Section visibility and navigation styles -->
<link rel="stylesheet" href="/static/css/section.css" media="all" />
<style>
#burger-label, #burger-toggle { display: none; }
@media (max-width: 959px) {
#burger-label { display: block; }
section#links { display: none; }
#burger-toggle:checked + #burger-label + section#links { display: block; }
}
/* Hide mobile menu by default on larger screens */
@media (min-width: 960px) {
section#links { display: block; }
#mobile-menu { display: none !important; }
#burger-label { display: none !important; }
}
</style>
<link rel="modulepreload" href="/static/sound.js" />
<script src="/static/streams-ui.js" type="module"></script>
<script src="/static/app.js" type="module"></script>
</head>
<body>
<header>
@ -31,43 +33,55 @@
<main>
<!-- 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 id="guest-dashboard" class="dashboard-nav guest-only">
<a href="#welcome-page" id="guest-welcome">Welcome</a>
<a href="#stream-page" id="guest-streams">Streams</a>
<a href="#account" id="guest-login">Account</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 id="user-dashboard" class="dashboard-nav auth-only">
<a href="#welcome-page" id="user-welcome">Welcome</a>
<a href="#stream-page" id="user-streams">Streams</a>
<a href="#me-page" id="show-me">Your Stream</a>
</nav>
<section id="me-page">
<section id="me-page" class="auth-only">
<div>
<h2>Your Stream</h2>
</div>
<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>
<button class="play-pause-btn" type="button" aria-label="Play" data-uid="">▶️</button>
</div>
</article>
<section id="user-upload-area" class="dropzone">
<p>🎙 Drag & drop your audio file here<br>or click to browse</p>
<section id="user-upload-area" class="auth-only">
<p>Drag & drop your audio file here<br>or click to browse</p>
<input type="file" id="fileInputUser" accept="audio/*" hidden />
</section>
<article id="log-out" class="auth-only article--bordered logout-section">
<button id="logout-button" class="button">🚪 Log Out</button>
</article>
<section id="quota-meter" class="auth-only">
<p class="quota-meter">Quota: <progress id="quota-bar" value="0" max="100"></progress> <span id="quota-text">0 MB</span></p>
<h4>Uploaded Files</h4>
<ul id="file-list" class="file-list">
<li>Loading files...</li>
</ul>
</section>
</section>
<div id="spinner" class="spinner"></div>
<!-- Burger menu and legacy links section removed for clarity -->
<section id="terms-page" hidden>
<article>
<section id="terms-page" class="always-visible">
<h2>Terms of Service</h2>
<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>
<article class="article--bordered">
<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>Each account must be unique and used by only one person.</li>
@ -77,37 +91,56 @@
<li>Uploads are limited to 100 MB and must be voice only.</li>
<li>Music/singing will be rejected.</li>
</ul>
</article>
</section>
<section id="privacy-page" hidden>
<article>
<section id="privacy-page" class="always-visible">
<div>
<h2>Privacy Policy</h2>
</div>
<article class="article--bordered">
<ul>
<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>
<li>Data is never sold.</li>
</ul>
</article>
<!-- This section will be shown only to authenticated users -->
<div class="auth-only">
<section id="account-deletion" class="article--bordered">
<h3>Account Deletion</h3>
<p>You can delete your account and all associated data at any time. This action is irreversible and will permanently remove:</p>
<ul>
<li>Your account information</li>
<li>All uploaded audio files</li>
</ul>
</article>
<div class="centered-container">
<button id="delete-account-from-privacy" class="button">
🗑️ Delete My Account
</button>
</div>
</section>
</div>
<!-- Guest login message removed as per user request -->
</section>
<section id="imprint-page" hidden>
<article>
<section id="imprint-page" class="always-visible">
<h2>Imprint</h2>
<article class="article--bordered">
<p><strong>Andreas Michael Fleckl</strong></p>
<p>Johnstrassse 7/6<br>1140 Vienna<br>Austria / Europe</p>
</article>
</section>
<section id="welcome-page">
<article>
<section id="welcome-page" class="always-visible">
<h2>Welcome</h2>
<p>dicta2stream is a minimalist voice streaming platform for your spoken audio anonymously under a nickname in a loop. <br><br>
<article class="article--bordered">
<p>dicta2stream is a minimalist voice streaming platform for your spoken audio anonymously under a nickname in a loop. <span class="text-muted">(Opus | Mono | 48kHz | 60kbps)</span><br><br>
<strong>What you can do here:</strong></p>
<ul>
<li>🎧 Listen to public voice streams from others, instantly</li>
@ -115,50 +148,44 @@
<li>🕵️ No sign-up required for listening</li>
<li>🔒 Optional registration for uploading and managing your own stream</li>
</ul>
<div class="email-section">
<a href="mailto:Andreas.Fleckl@dicta2stream.net" class="button">
Andreas.Fleckl@dicta2stream.net
</a>
</div>
</article>
</section>
<section id="stream-page" hidden>
<article>
<h2>🎧 Public Streams</h2>
<section id="stream-page" class="always-visible">
<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>
</article>
</section>
<section id="register-page" hidden>
<article>
<h2>Login or Register</h2>
<section id="register-page" class="guest-only">
<h2>Account</h2>
<article class="article--wide">
<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;">
<p class="bot-trap">
<label>Leave this empty:<br>
<input type="text" name="bot_trap" autocomplete="off" />
</label>
</p>
<p><button type="submit">Create Account</button></p>
<p><button type="submit">Login / Create Account</button></p>
</form>
<p><small>Youll receive a magic login link via email. No password required.</small></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>
<p class="form-note">You'll receive a magic login link via email. No password required.</p>
<p class="form-note session-note">Your session expires after 1 hour. Shareable links redirect to homepage.</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>
</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 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-terms" data-target="terms-page">Terms</a> |
<a href="#" id="footer-privacy" data-target="privacy-page">Privacy</a> |
<a href="#" id="footer-imprint" data-target="imprint-page">Imprint</a>
</p>
</footer>
@ -189,5 +216,6 @@
}
}
</script>
<script type="module" src="/static/init-personal-stream.js"></script>
</body>
</html>

View File

@ -0,0 +1,35 @@
// Initialize the personal stream play button with the user's UID
document.addEventListener('DOMContentLoaded', () => {
// Function to update the play button with UID
function updatePersonalStreamPlayButton() {
const playButton = document.querySelector('#me-page .play-pause-btn');
if (!playButton) return;
// Get UID from localStorage or cookie
const uid = localStorage.getItem('uid') || getCookie('uid');
if (uid) {
// Set the data-uid attribute if not already set
if (!playButton.dataset.uid) {
playButton.dataset.uid = uid;
console.log('[personal-stream] Set UID for personal stream play button:', uid);
}
} else {
console.warn('[personal-stream] No UID found for personal stream play button');
}
}
// Helper function to get cookie value by name
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
// Initial update
updatePersonalStreamPlayButton();
// Also update when auth state changes (e.g., after login)
document.addEventListener('authStateChanged', updatePersonalStreamPlayButton);
});

184
static/inject-nav.js Normal file
View File

@ -0,0 +1,184 @@
// inject-nav.js - Handles dynamic injection and management of navigation elements
import { showOnly } from './router.js';
// Function to set up guest navigation links
function setupGuestNav() {
const guestDashboard = document.getElementById('guest-dashboard');
if (!guestDashboard) return;
const links = guestDashboard.querySelectorAll('a');
links.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = link.getAttribute('href')?.substring(1); // Remove '#'
if (target) {
window.location.hash = target;
if (window.router && typeof window.router.showOnly === 'function') {
window.router.showOnly(target);
}
}
});
});
}
// Function to set up user navigation links
function setupUserNav() {
const userDashboard = document.getElementById('user-dashboard');
if (!userDashboard) return;
const links = userDashboard.querySelectorAll('a');
links.forEach(link => {
// Handle logout specially
if (link.getAttribute('href') === '#logout') {
link.addEventListener('click', (e) => {
e.preventDefault();
if (window.handleLogout) {
window.handleLogout();
}
});
} else {
// Handle regular navigation
link.addEventListener('click', (e) => {
e.preventDefault();
const target = link.getAttribute('href')?.substring(1); // Remove '#'
if (target) {
window.location.hash = target;
if (window.router && typeof window.router.showOnly === 'function') {
window.router.showOnly(target);
}
}
});
}
});
}
function createUserNav() {
const nav = document.createElement('div');
nav.className = 'dashboard-nav';
nav.setAttribute('role', 'navigation');
nav.setAttribute('aria-label', 'User navigation');
const navList = document.createElement('ul');
navList.className = 'nav-list';
const links = [
{ id: 'user-stream', target: 'your-stream', text: 'Your Stream' },
{ id: 'nav-streams', target: 'streams', text: 'Streams' },
{ id: 'nav-welcome', target: 'welcome', text: 'Welcome' },
{ id: 'user-logout', target: 'logout', text: 'Logout' }
];
// Create and append links
links.forEach((link) => {
const li = document.createElement('li');
li.className = 'nav-item';
const a = document.createElement('a');
a.id = link.id;
a.href = '#';
a.className = 'nav-link';
a.setAttribute('data-target', link.target);
a.textContent = link.text;
a.addEventListener('click', (e) => {
e.preventDefault();
const target = e.currentTarget.getAttribute('data-target');
if (target === 'logout') {
if (window.handleLogout) {
window.handleLogout();
}
} else if (target) {
window.location.hash = target;
if (window.router && typeof window.router.showOnly === 'function') {
window.router.showOnly(target);
}
}
});
li.appendChild(a);
navList.appendChild(li);
});
nav.appendChild(navList);
return nav;
}
// Navigation injection function
export function injectNavigation(isAuthenticated = false) {
// Get the appropriate dashboard element based on auth state
const guestDashboard = document.getElementById('guest-dashboard');
const userDashboard = document.getElementById('user-dashboard');
if (isAuthenticated) {
// Show user dashboard, hide guest dashboard
if (guestDashboard) guestDashboard.style.display = 'none';
if (userDashboard) userDashboard.style.display = 'block';
document.body.classList.add('authenticated');
document.body.classList.remove('guest-mode');
} else {
// Show guest dashboard, hide user dashboard
if (guestDashboard) guestDashboard.style.display = 'block';
if (userDashboard) userDashboard.style.display = 'none';
document.body.classList.add('guest-mode');
document.body.classList.remove('authenticated');
}
// Set up menu links and active state
setupMenuLinks();
updateActiveNav();
return isAuthenticated ? userDashboard : guestDashboard;
}
// Set up menu links with click handlers
function setupMenuLinks() {
// Set up guest and user navigation links
setupGuestNav();
setupUserNav();
// Handle hash changes for SPA navigation
window.addEventListener('hashchange', updateActiveNav);
}
// Update active navigation link
function updateActiveNav() {
const currentHash = window.location.hash.substring(1) || 'welcome';
// Remove active class from all links in both dashboards
document.querySelectorAll('#guest-dashboard a, #user-dashboard a').forEach(link => {
link.classList.remove('active');
// Check if this link's href matches the current hash
const linkTarget = link.getAttribute('href')?.substring(1); // Remove '#'
if (linkTarget === currentHash) {
link.classList.add('active');
}
});
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
// Check authentication state and initialize navigation
const isAuthenticated = document.cookie.includes('sessionid=') ||
localStorage.getItem('isAuthenticated') === 'true';
// Initialize navigation based on authentication state
injectNavigation(isAuthenticated);
// Set up menu links and active navigation
setupMenuLinks();
updateActiveNav();
// Update body classes based on authentication state
if (isAuthenticated) {
document.body.classList.add('authenticated');
document.body.classList.remove('guest-mode');
} else {
document.body.classList.add('guest-mode');
document.body.classList.remove('authenticated');
}
console.log('[NAV] Navigation initialized', { isAuthenticated });
});
// Make the function available globally for debugging
window.injectNavigation = injectNavigation;

View File

@ -27,10 +27,17 @@ export async function initMagicLogin() {
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
// Generate a simple auth token (in a real app, this would come from the server)
const authToken = 'token-' + Math.random().toString(36).substring(2, 15);
// Set cookies and localStorage for SPA session logic
document.cookie = `uid=${encodeURIComponent(confirmedUid)}; path=/`;
document.cookie = `authToken=${authToken}; path=/`;
// Store in localStorage for client-side access
localStorage.setItem('uid', confirmedUid);
localStorage.setItem('confirmed_uid', confirmedUid);
localStorage.setItem('authToken', authToken);
localStorage.setItem('uid_time', Date.now().toString());
}
window.location.href = res.url;
@ -42,14 +49,34 @@ export async function initMagicLogin() {
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
// Generate a simple auth token (in a real app, this would come from the server)
const authToken = 'token-' + Math.random().toString(36).substring(2, 15);
// Set cookies and localStorage for SPA session logic
document.cookie = `uid=${encodeURIComponent(data.confirmed_uid)}; path=/`;
document.cookie = `authToken=${authToken}; path=/`;
// Store in localStorage for client-side access
localStorage.setItem('uid', data.confirmed_uid);
localStorage.setItem('confirmed_uid', data.confirmed_uid);
localStorage.setItem('authToken', authToken);
localStorage.setItem('uid_time', Date.now().toString());
import('./toast.js').then(({ showToast }) => showToast('✅ Login successful!'));
// Optionally reload or navigate
setTimeout(() => location.reload(), 700);
import('./toast.js').then(({ showToast }) => {
showToast('✅ Login successful!');
// Update UI state after login
const guestDashboard = document.getElementById('guest-dashboard');
const userDashboard = document.getElementById('user-dashboard');
const registerPage = document.getElementById('register-page');
if (guestDashboard) guestDashboard.style.display = 'none';
if (userDashboard) userDashboard.style.display = 'block';
if (registerPage) registerPage.style.display = 'none';
// Show the user's stream page
if (window.showOnly) {
window.showOnly('me-page');
}
});
return;
}
alert(data.detail || 'Login failed.');

480
static/mobile.css Normal file
View File

@ -0,0 +1,480 @@
/* Mobile-specific styles for screens up to 959px */
@media (max-width: 959px) {
/* Base layout adjustments */
html {
height: 100%;
min-height: 100%;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
font-size: 16px;
overflow-x: hidden;
width: 100%;
max-width: 100%;
background: transparent !important;
}
main {
padding: 0.5rem 1rem;
margin: 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
box-shadow: none;
border: none;
background: none;
}
* {
box-sizing: border-box;
}
/* Mobile navigation */
#user-dashboard.dashboard-nav {
display: flex !important;
visibility: visible !important;
opacity: 1 !important;
}
.dashboard-nav {
display: flex;
justify-content: space-around;
padding: 0.5rem 0;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
margin-bottom: 1rem;
}
.dashboard-nav a {
padding: 0.5rem 0.25rem;
text-align: center;
font-size: 0.9rem;
color: var(--text-color);
text-decoration: none;
flex: 1;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.dashboard-nav a:hover,
.dashboard-nav a:focus {
background-color: var(--hover-bg);
outline: none;
}
/* Account Deletion Section */
#privacy-page.active #account-deletion,
#privacy-page:not(.active) #account-deletion {
display: block !important;
opacity: 1 !important;
position: relative !important;
clip: auto !important;
width: auto !important;
height: auto !important;
margin: 0 !important;
padding: 0 !important;
overflow: visible !important;
}
.account-deletion-section {
margin: 2rem 0;
padding: 1.75rem;
background: rgba(26, 26, 26, 0.8);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
}
.account-deletion-section h3 {
color: #fff;
font-size: 1.5rem;
margin-bottom: 1.25rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.account-deletion-section h3 {
color: #fff;
margin-bottom: 1rem;
font-size: 1.4rem;
}
.account-deletion-section ul {
margin: 1.5rem 0 2rem 1.5rem;
padding-left: 0.5rem;
}
.account-deletion-section li {
margin-bottom: 0.75rem;
color: #f0f0f0;
line-height: 1.5;
position: relative;
padding-left: 1.5rem;
}
.account-deletion-section li:before {
content: '•';
color: #ff5e57;
font-weight: bold;
font-size: 1.5rem;
position: absolute;
left: 0;
top: -0.25rem;
}
.danger-button {
background: linear-gradient(135deg, #ff3b30, #ff5e57);
color: white;
border: none;
padding: 1rem 1.5rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
width: 100%;
max-width: 300px;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(255, 59, 48, 0.3);
text-align: center;
}
.danger-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 59, 48, 0.4);
}
.danger-button:active {
transform: translateY(0);
}
.text-link {
color: #4dabf7;
text-decoration: none;
transition: color 0.2s ease;
}
.text-link:hover {
color: #74c0fc;
text-decoration: underline;
}
/* Hide desktop navigation in mobile */
nav.dashboard-nav {
display: none;
}
header {
padding: 0.5rem 1rem;
}
header h1 {
font-size: 1.8rem;
margin: 0.5rem 0;
}
header p {
font-size: 1rem;
margin: 0.25rem 0 1rem;
}
.dashboard-nav {
width: 100%;
padding: 0.5rem;
box-sizing: border-box;
text-align: center;
font-size: 0.9rem;
}
.dashboard-nav a {
padding: 0.5rem;
margin: 0 0.25rem;
display: inline-block;
}
main > section {
width: 100%;
max-width: 100%;
padding: 1rem;
box-sizing: border-box;
}
.btn {
width: 100%;
height: 48px;
padding: 0.75rem 1rem;
margin: 0.5rem 0;
font-size: 1rem;
box-sizing: border-box;
}
.audio-player {
width: 100%;
margin: 1rem 0;
}
.audio-controls {
flex-direction: column;
}
.audio-controls button {
margin: 0.25rem 0;
}
.dropzone {
padding: 2rem;
}
#quota-meter {
max-width: 600px;
width: 100%;
margin: 1rem auto;
padding: 0 1rem;
box-sizing: border-box;
}
.quota-meter {
height: 20px;
}
/* Stream item styles moved to .stream-player */
.stream-item {
padding: 0;
margin: 0;
border: none;
}
.modal-content {
width: 90%;
max-width: 90%;
}
footer {
padding: 1rem;
}
.footer-hint {
font-size: 0.9rem;
}
.desktop-only {
display: none !important;
}
#burger-label {
display: block;
}
section#links {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #1e1e1e;
z-index: 1000;
}
#burger-toggle:checked + #burger-label + section#links {
display: block;
}
/* Make sure all interactive elements are touch-friendly */
a, [role="button"], label, select, textarea {
min-height: 44px;
min-width: 44px;
}
.dropzone {
padding: 1.5rem;
margin: 1rem 0;
}
.dropzone p {
font-size: 1rem;
margin: 0.5rem 0;
}
/* Adjust header text for better mobile display */
header h1 {
font-size: 1.5rem;
}
header p {
font-size: 0.9rem;
}
.dashboard-nav {
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
padding: 0.5rem 0;
}
.dashboard-nav::-webkit-scrollbar {
display: none;
}
nav.dashboard-nav a {
all: unset;
display: inline-block;
background-color: var(--surface);
color: var(--text-color);
padding: 0.5rem 1rem;
margin: 0 0.25rem;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
text-align: center;
min-width: 100px;
box-sizing: border-box;
transition: background-color 0.2s;
}
.dashboard-nav a:active {
background-color: var(--border);
}
/* Stream page specific styles */
#stream-page {
padding: 0.5rem;
}
#stream-page h2 {
font-size: 1.5rem;
}
#stream-page article {
padding: 1rem;
margin: 0.5rem 0;
}
#stream-list {
padding: 0 1rem;
margin: 0 auto;
max-width: 600px;
width: 100%;
box-sizing: border-box;
}
#stream-list li {
margin: 0;
padding: 0;
border: none;
background: transparent;
list-style: none;
}
.stream-player {
padding: 0.75rem;
}
.stream-player h3 {
font-size: 1.1rem;
}
.stream-info {
font-size: 0.9rem;
}
/* Stream list items are now handled by the rules above */
/* User upload area - matches article styling */
#user-upload-area {
margin: 2rem auto;
padding: 1.6875rem;
background: var(--surface);
border: 1px solid var(--border-color, #2a2a2a);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
text-align: center;
cursor: pointer;
max-width: 600px;
width: 100%;
box-sizing: border-box;
color: var(--text-color);
}
#user-upload-area p {
margin: 0.5rem 0;
}
/* Stream player adjustments */
.stream-player {
padding: 1rem;
margin: 0.5rem 0;
border: 1px solid #444;
border-radius: 8px;
background-color: #1e1e1e;
}
.stream-player h3 {
margin: 0 0 0.5rem 0;
font-size: 1.2rem;
}
.stream-info {
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.stream-audio {
width: 100%;
}
/* Form elements */
input[type="text"],
input[type="email"],
input[type="password"],
textarea {
width: 100%;
max-width: 100%;
box-sizing: border-box;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
padding: 0.75rem;
margin: 0.5rem 0;
font-size: 1rem;
border-radius: 4px;
border: 1px solid #444;
background-color: #2a2a2a;
color: #f0f0f0;
}
/* Firefox mobile specific fixes */
@-moz-document url-prefix() {
input[type="email"] {
min-height: 2.5rem;
appearance: none;
}
}
/* Adjust audio element for mobile */
audio {
width: 100% !important;
max-width: 100% !important;
}
/* Toast notifications */
.toast {
width: 90%;
max-width: 100%;
left: 5%;
right: 5%;
transform: none;
margin: 0 auto;
}
}

View File

@ -8,31 +8,267 @@ function getCookie(name) {
}
document.addEventListener("DOMContentLoaded", () => {
// Check authentication status
const isLoggedIn = !!getCookie('uid');
// Update body class for CSS-based visibility
document.body.classList.toggle('logged-in', isLoggedIn);
// Get all main content sections
const mainSections = Array.from(document.querySelectorAll('main > section'));
// Show/hide sections with smooth transitions
const showSection = (sectionId) => {
// Update body class to indicate current page
document.body.className = '';
if (sectionId) {
document.body.classList.add(`page-${sectionId}`);
} else {
document.body.classList.add('page-welcome');
}
// Update active state of navigation links
document.querySelectorAll('.dashboard-nav a').forEach(link => {
link.classList.remove('active');
if ((!sectionId && link.getAttribute('href') === '#welcome-page') ||
(sectionId && link.getAttribute('href') === `#${sectionId}`)) {
link.classList.add('active');
}
});
mainSections.forEach(section => {
// Skip navigation sections
if (section.id === 'guest-dashboard' || section.id === 'user-dashboard') {
return;
}
const isTarget = section.id === sectionId;
const isLegalPage = ['terms-page', 'privacy-page', 'imprint-page'].includes(sectionId);
const isWelcomePage = !sectionId || sectionId === 'welcome-page';
if (isTarget || (isLegalPage && section.id === sectionId)) {
// Show the target section or legal page
section.classList.add('active');
section.hidden = false;
// Focus the section for accessibility with a small delay
// Only focus if the section is focusable and in the viewport
const focusSection = () => {
try {
if (section && typeof section.focus === 'function' &&
section.offsetParent !== null && // Check if element is visible
section.getBoundingClientRect().top < window.innerHeight &&
section.getBoundingClientRect().bottom > 0) {
section.focus({ preventScroll: true });
}
} catch (e) {
// Silently fail if focusing isn't possible
if (window.DEBUG_NAV || (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {
console.debug('Could not focus section:', e);
}
}
};
// Use requestAnimationFrame for better performance
requestAnimationFrame(() => {
// Only set the timeout in debug mode or local development
if (window.DEBUG_NAV || (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {
setTimeout(focusSection, 50);
} else {
focusSection();
}
});
} else if (isWelcomePage && section.id === 'welcome-page') {
// Special handling for welcome page
section.classList.add('active');
section.hidden = false;
} else {
// Hide other sections
section.classList.remove('active');
section.hidden = true;
}
});
// Update URL hash without page scroll
if (sectionId && !['terms-page', 'privacy-page', 'imprint-page'].includes(sectionId)) {
if (sectionId === 'welcome-page') {
history.replaceState(null, '', window.location.pathname);
} else {
history.replaceState(null, '', `#${sectionId}`);
}
}
};
// Handle initial page load
const getValidSection = (sectionId) => {
const protectedSections = ['me-page', 'register-page'];
// If not logged in and trying to access protected section
if (!isLoggedIn && protectedSections.includes(sectionId)) {
return 'welcome-page';
}
// If section doesn't exist, default to welcome page
if (!document.getElementById(sectionId)) {
return 'welcome-page';
}
return sectionId;
};
// Process initial page load
const initialPage = window.location.hash.substring(1) || 'welcome-page';
const validSection = getValidSection(initialPage);
// Update URL if needed
if (validSection !== initialPage) {
window.location.hash = validSection;
}
// Show the appropriate section
showSection(validSection);
const Router = {
sections: Array.from(document.querySelectorAll("main > section")),
showOnly(id) {
this.sections.forEach(sec => {
sec.hidden = sec.id !== id;
sec.tabIndex = -1;
// Validate the section ID
const validId = getValidSection(id);
// Update URL if needed
if (validId !== id) {
window.location.hash = validId;
return;
}
// Show the requested section
showSection(validId);
// Handle the quota meter visibility - only show with 'me-page'
const quotaMeter = document.getElementById('quota-meter');
if (quotaMeter) {
quotaMeter.hidden = validId !== 'me-page';
quotaMeter.tabIndex = validId === 'me-page' ? 0 : -1;
}
// Update navigation active states
this.updateActiveNav(validId);
},
updateActiveNav(activeId) {
// Update active states for navigation links
document.querySelectorAll('.dashboard-nav a').forEach(link => {
const target = link.getAttribute('href').substring(1);
if (target === activeId) {
link.setAttribute('aria-current', 'page');
link.classList.add('active');
} else {
link.removeAttribute('aria-current');
link.classList.remove('active');
}
});
}
};
// Initialize the router
const router = Router;
// Handle section visibility based on authentication
const updateSectionVisibility = (sectionId) => {
const section = document.getElementById(sectionId);
if (!section) return;
// Skip navigation sections and quota meter
if (['guest-dashboard', 'user-dashboard', 'quota-meter'].includes(sectionId)) {
return;
}
const currentHash = window.location.hash.substring(1);
const isLegalPage = ['terms-page', 'privacy-page', 'imprint-page'].includes(sectionId);
// Special handling for legal pages - always show when in hash
if (isLegalPage) {
const isActive = sectionId === currentHash;
section.hidden = !isActive;
section.tabIndex = isActive ? 0 : -1;
if (isActive) section.focus();
return;
}
// Special handling for me-page - only show to authenticated users
if (sectionId === 'me-page') {
section.hidden = !isLoggedIn || currentHash !== 'me-page';
section.tabIndex = (isLoggedIn && currentHash === 'me-page') ? 0 : -1;
return;
}
// Special handling for register page - only show to guests
if (sectionId === 'register-page') {
section.hidden = isLoggedIn || currentHash !== 'register-page';
section.tabIndex = (!isLoggedIn && currentHash === 'register-page') ? 0 : -1;
return;
}
// For other sections, show if they match the current section ID
const isActive = sectionId === currentHash;
section.hidden = !isActive;
section.tabIndex = isActive ? 0 : -1;
if (isActive) {
section.focus();
}
};
// Initialize the router
router.init = function() {
// Update visibility for all sections
this.sections.forEach(section => {
updateSectionVisibility(section.id);
});
// 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';
userUpload.style.display = (window.location.hash === '#me-page' && uid) ? '' : 'none';
}
localStorage.setItem("last_page", id);
const target = document.getElementById(id);
if (target) target.focus();
},
init() {
// Store the current page
localStorage.setItem("last_page", window.location.hash.substring(1));
// Initialize navigation
initNavLinks();
initBackButtons();
initStreamLinks();
// Ensure proper focus management for accessibility
const currentSection = document.querySelector('main > section:not([hidden])');
if (currentSection) {
currentSection.setAttribute('tabindex', '0');
currentSection.focus();
}
};
const showOnly = Router.showOnly.bind(Router);
// Initialize the router
router.init();
// Handle footer links
document.querySelectorAll('.footer-links a').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = link.dataset.target;
if (target) {
// Show the target section without updating URL hash
showSection(target);
}
});
});
// Export the showOnly function for global access
window.showOnly = router.showOnly.bind(router);
// Make router available globally for debugging
window.appRouter = router;
// Highlight active profile link on browser back/forward navigation
function highlightActiveProfileLink() {
@ -49,6 +285,15 @@ document.addEventListener("DOMContentLoaded", () => {
window.addEventListener('popstate', () => {
const params = new URLSearchParams(window.location.search);
const profileUid = params.get('profile');
const currentPage = window.location.hash.substring(1) || 'welcome-page';
// Prevent unauthorized access to me-page
if ((currentPage === 'me-page' || profileUid) && !getCookie('uid')) {
history.replaceState(null, '', '#welcome-page');
showOnly('welcome-page');
return;
}
if (profileUid) {
showOnly('me-page');
if (typeof window.showProfilePlayerFromUrl === 'function') {
@ -196,5 +441,13 @@ document.addEventListener("DOMContentLoaded", () => {
}
// Initialize Router
document.addEventListener('visibilitychange', () => {
// Re-check authentication when tab becomes visible again
if (!document.hidden && window.location.hash === '#me-page' && !getCookie('uid')) {
window.location.hash = 'welcome-page';
showOnly('welcome-page');
}
});
Router.init();
});

View File

@ -1,15 +1,168 @@
// static/router.js — core routing for SPA navigation
export const Router = {
sections: Array.from(document.querySelectorAll("main > section")),
sections: [],
// Map URL hashes to section IDs
sectionMap: {
'welcome': 'welcome-page',
'streams': 'stream-page',
'account': 'register-page',
'login': 'login-page',
'me': 'me-page',
'your-stream': 'me-page' // Map 'your-stream' to 'me-page'
},
init() {
this.sections = Array.from(document.querySelectorAll("main > section"));
// Set up hash change handler
window.addEventListener('hashchange', this.handleHashChange.bind(this));
// Initial route
this.handleHashChange();
},
handleHashChange() {
let hash = window.location.hash.substring(1) || 'welcome';
// First check if the hash matches any direct section ID
const directSection = this.sections.find(sec => sec.id === hash);
if (directSection) {
// If it's a direct section ID match, show it directly
this.showOnly(hash);
} else {
// Otherwise, use the section map
const sectionId = this.sectionMap[hash] || hash;
this.showOnly(sectionId);
}
},
showOnly(id) {
this.sections.forEach(sec => {
sec.hidden = sec.id !== id;
sec.tabIndex = -1;
if (!id) return;
// Update URL hash without triggering hashchange
if (window.location.hash !== `#${id}`) {
window.history.pushState(null, '', `#${id}`);
}
const isAuthenticated = document.body.classList.contains('authenticated');
const isMePage = id === 'me-page' || id === 'your-stream';
// Helper function to update section visibility
const updateSection = (sec) => {
const isTarget = sec.id === id;
const isGuestOnly = sec.classList.contains('guest-only');
const isAuthOnly = sec.classList.contains('auth-only');
const isAlwaysVisible = sec.classList.contains('always-visible');
const isQuotaMeter = sec.id === 'quota-meter';
const isUserUploadArea = sec.id === 'user-upload-area';
const isLogOut = sec.id === 'log-out';
// Determine if section should be visible
let shouldShow = isTarget;
// Always show sections with always-visible class
if (isAlwaysVisible) {
shouldShow = true;
}
// Handle guest-only sections
if (isGuestOnly && isAuthenticated) {
shouldShow = false;
}
// Handle auth-only sections
if (isAuthOnly && !isAuthenticated) {
shouldShow = false;
}
// Special case for me-page and its children
const isChildOfMePage = sec.closest('#me-page') !== null;
const shouldBeActive = isTarget ||
(isQuotaMeter && isMePage) ||
(isUserUploadArea && isMePage) ||
(isLogOut && isMePage) ||
(isChildOfMePage && isMePage);
// Update visibility and tab index
sec.hidden = !shouldShow;
sec.tabIndex = shouldShow ? 0 : -1;
// Update active state and ARIA attributes
if (shouldBeActive) {
sec.setAttribute('aria-current', 'page');
sec.classList.add('active');
// Ensure target section is visible
if (sec.hidden) {
sec.style.display = 'block';
sec.hidden = false;
}
// Show all children of the active section
if (isTarget) {
sec.focus();
// Make sure all auth-only children are visible
const authChildren = sec.querySelectorAll('.auth-only');
authChildren.forEach(child => {
if (isAuthenticated) {
child.style.display = '';
child.hidden = false;
}
});
localStorage.setItem("last_page", id);
const target = document.getElementById(id);
if (target) target.focus();
}
} else {
sec.removeAttribute('aria-current');
sec.classList.remove('active');
// Reset display property for sections when not active
if (shouldShow && !isAlwaysVisible) {
sec.style.display = ''; // Reset to default from CSS
}
}
};
// Update all sections
this.sections.forEach(updateSection);
// Update active nav links
document.querySelectorAll('[data-target], [href^="#"]').forEach(link => {
let target = link.getAttribute('data-target');
const href = link.getAttribute('href');
// If no data-target, try to get from href
if (!target && href) {
// Remove any query parameters and # from the href
const hash = href.split('?')[0].substring(1);
// Use mapped section ID or the hash as is
target = this.sectionMap[hash] || hash;
}
// Check if this link points to the current section or its mapped equivalent
const linkId = this.sectionMap[target] || target;
const currentId = this.sectionMap[id] || id;
if (linkId === currentId) {
link.setAttribute('aria-current', 'page');
link.classList.add('active');
} else {
link.removeAttribute('aria-current');
link.classList.remove('active');
}
});
// Close mobile menu if open
const menuToggle = document.querySelector('.menu-toggle');
if (menuToggle && menuToggle.getAttribute('aria-expanded') === 'true') {
menuToggle.setAttribute('aria-expanded', 'false');
document.body.classList.remove('menu-open');
}
localStorage.setItem("last_page", id);
}
};
// Initialize router when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
Router.init();
});
export const showOnly = Router.showOnly.bind(Router);

View File

@ -1,10 +1,12 @@
// static/streams-ui.js — public streams loader and profile-link handling
import { showOnly } from './router.js';
console.log('[streams-ui] Module loaded');
// Removed loadingStreams and lastStreamsPageVisible guards for instant fetch
export function initStreamsUI() {
console.log('[streams-ui] Initializing streams UI');
initStreamLinks();
window.addEventListener('popstate', () => {
highlightActiveProfileLink();
@ -24,145 +26,354 @@ function maybeLoadStreamsOnShow() {
}
window.maybeLoadStreamsOnShow = maybeLoadStreamsOnShow;
// Global variables for audio control
let currentlyPlayingAudio = null;
let currentlyPlayingButton = null;
document.addEventListener('DOMContentLoaded', initStreamsUI);
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
console.log('[streams-ui] DOM content loaded, initializing streams UI');
initStreamsUI();
// Also try to load streams immediately in case the page is already loaded
setTimeout(() => {
console.log('[streams-ui] Attempting initial stream load');
loadAndRenderStreams();
}, 100);
});
function loadAndRenderStreams() {
console.log('[streams-ui] loadAndRenderStreams called');
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>';
// Clear any existing error messages or retry buttons
ul.innerHTML = '<li>Loading public streams...</li>';
// Add a timestamp to prevent caching issues
const timestamp = new Date().getTime();
// Use the same protocol as the current page to avoid mixed content issues
const baseUrl = window.location.origin;
const sseUrl = `${baseUrl}/streams-sse?t=${timestamp}`;
console.log(`[streams-ui] Connecting to ${sseUrl}`);
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;
let connectionTimeout = null;
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>';
// Clean up previous connection and timeouts
if (window._streamsSSE) {
console.group('[streams-ui] Cleaning up previous connection');
console.log('Previous connection exists, aborting...');
if (window._streamsSSE.abort) {
window._streamsSSE.abort();
console.log('Previous connection aborted');
} else {
console.log('No abort method on previous connection');
}
evtSource.close();
window._streamsSSE = null;
console.groupEnd();
}
if (connectionTimeout) {
console.log('[streams-ui] Clearing previous connection timeout');
clearTimeout(connectionTimeout);
connectionTimeout = null;
} else {
console.log('[streams-ui] No previous connection timeout to clear');
}
console.log(`[streams-ui] Creating fetch-based SSE connection to ${sseUrl}`);
// Use fetch with ReadableStream for better CORS handling
const controller = new AbortController();
const signal = controller.signal;
// Store the controller for cleanup
window._streamsSSE = controller;
// Set a connection timeout with debug info
const connectionStartTime = Date.now();
const connectionTimeoutId = setTimeout(() => {
if (!gotAny) {
// Only log in development (localhost) or if explicitly enabled
const isLocalDevelopment = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1';
if (isLocalDevelopment || window.DEBUG_STREAMS) {
const duration = Date.now() - connectionStartTime;
console.group('[streams-ui] Connection timeout reached');
console.log(`Duration: ${duration}ms`);
console.log('Current time:', new Date().toISOString());
console.log('Streams received:', streams.length);
console.log('Active intervals:', window.activeIntervals ? window.activeIntervals.size : 'N/A');
console.log('Active timeouts:', window.activeTimeouts ? window.activeTimeouts.size : 'N/A');
console.groupEnd();
}
// Clean up and retry with backoff
controller.abort();
// Only retry if we haven't exceeded max retries
const retryCount = window.streamRetryCount || 0;
if (retryCount < 3) { // Max 3 retries
window.streamRetryCount = retryCount + 1;
const backoffTime = Math.min(1000 * Math.pow(2, retryCount), 10000); // Exponential backoff, max 10s
setTimeout(loadAndRenderStreams, backoffTime);
} else if (process.env.NODE_ENV === 'development' || window.DEBUG_STREAMS) {
console.warn('Max retries reached for stream loading');
}
}
}, 15000); // 15 second timeout (increased from 10s)
// Store the timeout ID for cleanup
connectionTimeout = connectionTimeoutId;
console.log('[streams-ui] Making fetch request to:', sseUrl);
console.log('[streams-ui] Making fetch request to:', sseUrl);
console.log('[streams-ui] Creating fetch request with URL:', sseUrl);
// Make the fetch request with proper error handling
fetch(sseUrl, {
method: 'GET',
headers: {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
credentials: 'same-origin',
signal: signal,
mode: 'cors',
redirect: 'follow'
})
.then(response => {
console.log('[streams-ui] Fetch response received, status:', response.status, response.statusText);
console.log('[streams-ui] Response URL:', response.url);
console.log('[streams-ui] Response type:', response.type);
console.log('[streams-ui] Response redirected:', response.redirected);
console.log('[streams-ui] Response headers:');
response.headers.forEach((value, key) => {
console.log(` ${key}: ${value}`);
});
if (!response.ok) {
// Try to get the response text for error details
return response.text().then(text => {
console.error('[streams-ui] Error response body:', text);
const error = new Error(`HTTP error! status: ${response.status}, statusText: ${response.statusText}`);
error.response = { status: response.status, statusText: response.statusText, body: text };
throw error;
}).catch(textError => {
console.error('[streams-ui] Could not read error response body:', textError);
const error = new Error(`HTTP error! status: ${response.status}, statusText: ${response.statusText}`);
error.response = { status: response.status, statusText: response.statusText };
throw error;
});
}
if (!response.body) {
const error = new Error('Response body is null or undefined');
console.error('[streams-ui] No response body:', error);
throw error;
}
console.log('[streams-ui] Response body is available, content-type:', response.headers.get('content-type'));
// Get the readable stream
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// Process the stream
function processStream({ done, value }) {
if (done) {
console.log('[streams-ui] Stream completed');
// Process any remaining data in the buffer
if (buffer.trim()) {
try {
const data = JSON.parse(buffer);
processSSEEvent(data);
} catch (e) {
console.error('[streams-ui] Error parsing final data:', e);
}
}
return;
}
// Decode the chunk and add to buffer
buffer += decoder.decode(value, { stream: true });
// Process complete events in the buffer
const events = buffer.split('\n\n');
buffer = events.pop() || ''; // Keep incomplete event in buffer
for (const event of events) {
if (!event.trim()) continue;
// Extract data field from SSE format
const dataMatch = event.match(/^data: (\{.*\})$/m);
if (dataMatch && dataMatch[1]) {
try {
const data = JSON.parse(dataMatch[1]);
processSSEEvent(data);
} catch (e) {
console.error('[streams-ui] Error parsing event data:', e, 'Event:', event);
}
}
}
// Read the next chunk
return reader.read().then(processStream);
}
// Start reading the stream
return reader.read().then(processStream);
})
.catch(error => {
// Only handle the error if it's not an AbortError (from our own abort)
if (error.name === 'AbortError') {
console.log('[streams-ui] Request was aborted as expected');
return;
}
console.error('[streams-ui] Stream loading failed:', error);
// Log additional error details
if (error.name === 'TypeError') {
console.error('[streams-ui] This is likely a network error or CORS issue');
}
// Show a user-friendly error message
const ul = document.getElementById('stream-list');
if (ul) {
let errorMessage = 'Error loading streams. ';
if (error.message.includes('Failed to fetch')) {
errorMessage += 'Unable to connect to the server. Please check your internet connection.';
} else if (error.message.includes('CORS')) {
errorMessage += 'A server configuration issue occurred. Please try again later.';
} else {
errorMessage += 'Please try again later.';
}
ul.innerHTML = `
<li class="error">
<p>${errorMessage}</p>
<button id="retry-loading" class="retry-button">
<span class="retry-icon">↻</span> Try Again
</button>
</li>
`;
// Add retry handler
const retryButton = document.getElementById('retry-loading');
if (retryButton) {
retryButton.addEventListener('click', () => {
ul.innerHTML = '<li>Loading streams...</li>';
loadAndRenderStreams();
});
}
}
});
// Function to process SSE events
function processSSEEvent(data) {
console.log('[streams-ui] Received SSE event:', data);
if (data.end) {
console.log('[streams-ui] Received end event, total streams:', streams.length);
if (streams.length === 0) {
console.log('[streams-ui] No streams found, showing empty state');
ul.innerHTML = '<li>No active streams.</li>';
return;
}
// Sort streams by mtime in descending order (newest first)
streams.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
console.log('[streams-ui] Sorted streams:', streams);
// Clear the list
ul.innerHTML = '';
// Render each stream in sorted order
streams.forEach((stream, index) => {
const uid = stream.uid || `stream-${index}`;
const sizeMb = stream.size ? (stream.size / (1024 * 1024)).toFixed(1) : '?';
const mtime = stream.mtime ? new Date(stream.mtime * 1000).toISOString().split('T')[0].replace(/-/g, '/') : '';
console.log(`[streams-ui] Rendering stream ${index + 1}/${streams.length}:`, { uid, sizeMb, mtime });
const li = document.createElement('li');
li.className = 'stream-item';
try {
li.innerHTML = `
<article class="stream-player" data-uid="${escapeHtml(uid)}">
<h3>${escapeHtml(uid)}</h3>
<div class="audio-controls">
<button class="play-pause-btn" data-uid="${escapeHtml(uid)}" aria-label="Play">▶️</button>
</div>
<p class="stream-info" style='color:var(--text-muted);font-size:90%'>[${sizeMb} MB, ${mtime}]</p>
</article>
`;
ul.appendChild(li);
console.log(`[streams-ui] Successfully rendered stream: ${uid}`);
} catch (error) {
console.error(`[streams-ui] Error rendering stream ${uid}:`, error);
const errorLi = document.createElement('li');
errorLi.textContent = `Error loading stream: ${uid}`;
errorLi.style.color = 'var(--error)';
ul.appendChild(errorLi);
}
});
highlightActiveProfileLink();
return;
}
// Remove Loading... on any valid event
// Add stream to our collection
streams.push(data);
// If this is the first stream, clear the loading message
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>
}
// Function to handle SSE errors
function handleSSEError(error) {
console.error('[streams-ui] SSE error:', error);
// Only show error if we haven't already loaded any streams
if (streams.length === 0) {
const errorMsg = 'Error connecting to stream server. Please try again.';
ul.innerHTML = `
<li>${errorMsg}</li>
<li><button id="reload-streams" onclick="loadAndRenderStreams()" class="retry-button">🔄 Retry</button></li>
`;
// 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.');
showToast('❌ ' + errorMsg);
}
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);
// Auto-retry after 5 seconds
setTimeout(() => {
loadAndRenderStreams();
}, 5000);
}
};
}
// Error and open handlers are now part of the fetch implementation
// Message handling is now part of the fetch implementation
// Error handling is now part of the fetch implementation
}
export function renderStreamList(streams) {
@ -181,7 +392,7 @@ export function renderStreamList(streams) {
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>`;
return `<li><a href="/?profile=${encodeURIComponent(uid)}" class="profile-link">▶ ${uid}</a> <span style='color:var(--text-muted);font-size:90%'>[${sizeKb} KB, ${mtime}]</span></li>`;
})
.join('');
} else {
@ -208,7 +419,6 @@ export function highlightActiveProfileLink() {
}
export function initStreamLinks() {
const ul = document.getElementById('stream-list');
if (!ul) return;
@ -232,3 +442,392 @@ export function initStreamLinks() {
}
});
}
// Helper function to safely escape HTML
function escapeHtml(unsafe) {
if (typeof unsafe !== 'string') return '';
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Function to update play/pause button state
function updatePlayPauseButton(button, isPlaying) {
if (!button) return;
button.textContent = isPlaying ? '⏸️' : '▶️';
button.setAttribute('aria-label', isPlaying ? 'Pause' : 'Play');
}
// Audio context for Web Audio API
let audioContext = null;
let audioSource = null;
let audioBuffer = null;
let isPlaying = false;
let currentUid = null;
let currentlyPlayingButton = null; // Controls the currently active play/pause button
let startTime = 0;
let pauseTime = 0;
let audioStartTime = 0;
let audioElement = null; // HTML5 Audio element for Opus playback
// Initialize audio context
function getAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return audioContext;
}
// Stop current playback completely
function stopPlayback() {
console.log('[streams-ui] Stopping playback');
// Stop Web Audio API if active
if (audioSource) {
try {
// Don't try to stop if already stopped
if (audioSource.context && audioSource.context.state !== 'closed') {
audioSource.stop();
audioSource.disconnect();
}
} catch (e) {
// Ignore errors when stopping already stopped sources
if (!e.message.includes('has already been stopped') &&
!e.message.includes('has already finished playing')) {
console.warn('Error stopping audio source:', e);
}
}
audioSource = null;
}
// Stop HTML5 Audio element if active
if (audioElement) {
try {
// Remove all event listeners first
if (audioElement._eventHandlers) {
const { onPlay, onPause, onEnded, onError } = audioElement._eventHandlers;
if (onPlay) audioElement.removeEventListener('play', onPlay);
if (onPause) audioElement.removeEventListener('pause', onPause);
if (onEnded) audioElement.removeEventListener('ended', onEnded);
if (onError) audioElement.removeEventListener('error', onError);
}
// Pause and reset the audio element
audioElement.pause();
audioElement.removeAttribute('src');
audioElement.load();
// Clear references
if (audioElement._eventHandlers) {
delete audioElement._eventHandlers;
}
// Nullify the element to allow garbage collection
audioElement = null;
} catch (e) {
console.warn('Error cleaning up audio element:', e);
}
}
// Reset state
audioBuffer = null;
isPlaying = false;
startTime = 0;
pauseTime = 0;
audioStartTime = 0;
// Update UI
if (currentlyPlayingButton) {
updatePlayPauseButton(currentlyPlayingButton, false);
currentlyPlayingButton = null;
}
// Clear current playing reference
currentlyPlayingAudio = null;
}
// Load and play audio using HTML5 Audio element for Opus
async function loadAndPlayAudio(uid, playPauseBtn) {
console.log(`[streams-ui] loadAndPlayAudio called for UID: ${uid}`);
// If trying to play the currently paused audio, just resume it
if (audioElement && currentUid === uid) {
console.log('[streams-ui] Resuming existing audio');
try {
await audioElement.play();
isPlaying = true;
updatePlayPauseButton(playPauseBtn, true);
return;
} catch (error) {
console.error('Error resuming audio:', error);
// Fall through to reload if resume fails
}
}
// Stop any current playback
stopPlayback();
// Update UI
updatePlayPauseButton(playPauseBtn, true);
currentlyPlayingButton = playPauseBtn;
currentUid = uid;
try {
console.log(`[streams-ui] Creating new audio element for ${uid}`);
// Create a new audio element with the correct MIME type
const audioUrl = `/audio/${encodeURIComponent(uid)}/stream.opus`;
console.log(`[streams-ui] Loading audio from: ${audioUrl}`);
// Create a new audio element with a small delay to prevent race conditions
await new Promise(resolve => setTimeout(resolve, 50));
audioElement = new Audio(audioUrl);
audioElement.preload = 'auto';
audioElement.crossOrigin = 'anonymous'; // Important for CORS
// Set up event handlers with proper binding
const onPlay = () => {
console.log('[streams-ui] Audio play event');
isPlaying = true;
updatePlayPauseButton(playPauseBtn, true);
};
const onPause = () => {
console.log('[streams-ui] Audio pause event');
isPlaying = false;
updatePlayPauseButton(playPauseBtn, false);
};
const onEnded = () => {
console.log('[streams-ui] Audio ended event');
isPlaying = false;
cleanupAudio();
};
const onError = (e) => {
// Ignore errors from previous audio elements that were cleaned up
if (!audioElement || audioElement.readyState === 0) {
console.log('[streams-ui] Ignoring error from cleaned up audio element');
return;
}
console.error('[streams-ui] Audio error:', e);
console.error('Error details:', audioElement.error);
isPlaying = false;
updatePlayPauseButton(playPauseBtn, false);
// Don't show error to user for aborted requests
if (audioElement.error && audioElement.error.code === MediaError.MEDIA_ERR_ABORTED) {
console.log('[streams-ui] Playback was aborted as expected');
return;
}
// Show error to user for other errors
if (typeof showToast === 'function') {
showToast('Error playing audio. The format may not be supported.', 'error');
}
};
// Add event listeners
audioElement.addEventListener('play', onPlay, { once: true });
audioElement.addEventListener('pause', onPause);
audioElement.addEventListener('ended', onEnded, { once: true });
audioElement.addEventListener('error', onError);
// Store references for cleanup
audioElement._eventHandlers = { onPlay, onPause, onEnded, onError };
// Start playback with error handling
console.log('[streams-ui] Starting audio playback');
try {
const playPromise = audioElement.play();
if (playPromise !== undefined) {
await playPromise.catch(error => {
// Ignore abort errors when switching between streams
if (error.name !== 'AbortError') {
console.error('[streams-ui] Play failed:', error);
throw error;
}
console.log('[streams-ui] Play was aborted as expected');
});
}
isPlaying = true;
} catch (error) {
// Only log unexpected errors
if (error.name !== 'AbortError') {
console.error('[streams-ui] Error during playback:', error);
throw error;
}
}
} catch (error) {
console.error('[streams-ui] Error loading/playing audio:', error);
if (playPauseBtn) {
updatePlayPauseButton(playPauseBtn, false);
}
// Only show error if it's not an abort error
if (error.name !== 'AbortError' && typeof showToast === 'function') {
showToast('Error playing audio. Please try again.', 'error');
}
}
}
// Handle audio ended event
function handleAudioEnded() {
isPlaying = false;
if (currentlyPlayingButton) {
updatePlayPauseButton(currentlyPlayingButton, false);
}
cleanupAudio();
}
// Clean up audio resources
function cleanupAudio() {
console.log('[streams-ui] Cleaning up audio resources');
// Clean up Web Audio API resources if they exist
if (audioSource) {
try {
if (isPlaying) {
audioSource.stop();
}
audioSource.disconnect();
} catch (e) {
console.warn('Error cleaning up audio source:', e);
}
audioSource = null;
}
// Clean up HTML5 Audio element if it exists
if (audioElement) {
try {
// Remove event listeners first
if (audioElement._eventHandlers) {
const { onPlay, onPause, onEnded, onError } = audioElement._eventHandlers;
if (onPlay) audioElement.removeEventListener('play', onPlay);
if (onPause) audioElement.removeEventListener('pause', onPause);
if (onEnded) audioElement.removeEventListener('ended', onEnded);
if (onError) audioElement.removeEventListener('error', onError);
}
// Pause and clean up the audio element
audioElement.pause();
audioElement.removeAttribute('src');
audioElement.load();
// Force garbage collection by removing references
if (audioElement._eventHandlers) {
delete audioElement._eventHandlers;
}
audioElement = null;
} catch (e) {
console.warn('Error cleaning up audio element:', e);
}
}
// Reset state
isPlaying = false;
currentUid = null;
// Update UI
if (currentlyPlayingButton) {
updatePlayPauseButton(currentlyPlayingButton, false);
currentlyPlayingButton = null;
}
}
// Event delegation for play/pause buttons - only handle buttons within the stream list
const streamList = document.getElementById('stream-list');
if (streamList) {
streamList.addEventListener('click', async (e) => {
const playPauseBtn = e.target.closest('.play-pause-btn');
// Skip if not a play button or if it's the personal stream's play button
if (!playPauseBtn || playPauseBtn.closest('#me-page')) return;
// Prevent event from bubbling up to document-level handlers
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
const uid = playPauseBtn.dataset.uid;
if (!uid) {
console.error('No UID found for play button');
return;
}
console.log(`[streams-ui] Play/pause clicked for UID: ${uid}, currentUid: ${currentUid}, isPlaying: ${isPlaying}`);
// If clicking the currently playing button, toggle pause/play
if (currentUid === uid) {
if (isPlaying) {
console.log('[streams-ui] Pausing current audio');
await audioElement.pause();
isPlaying = false;
updatePlayPauseButton(playPauseBtn, false);
} else {
console.log('[streams-ui] Resuming current audio');
try {
await audioElement.play();
isPlaying = true;
updatePlayPauseButton(playPauseBtn, true);
} catch (error) {
console.error('[streams-ui] Error resuming audio:', error);
// If resume fails, try reloading the audio
await loadAndPlayAudio(uid, playPauseBtn);
}
}
return;
}
// If a different stream is playing, stop it and start the new one
console.log(`[streams-ui] Switching to new audio stream: ${uid}`);
stopPlayback();
await loadAndPlayAudio(uid, playPauseBtn);
});
}
// Handle audio end event to update button state
document.addEventListener('play', (e) => {
if (e.target.tagName === 'AUDIO' && e.target !== currentlyPlayingAudio) {
if (currentlyPlayingAudio) {
currentlyPlayingAudio.pause();
}
currentlyPlayingAudio = e.target;
// Update the play/pause button state
const playerArticle = e.target.closest('.stream-player');
if (playerArticle) {
const playBtn = playerArticle.querySelector('.play-pause-btn');
if (playBtn) {
if (currentlyPlayingButton && currentlyPlayingButton !== playBtn) {
updatePlayPauseButton(currentlyPlayingButton, false);
}
updatePlayPauseButton(playBtn, true);
currentlyPlayingButton = playBtn;
}
}
}
}, true);
// Handle audio pause event
document.addEventListener('pause', (e) => {
if (e.target.tagName === 'AUDIO' && e.target === currentlyPlayingAudio) {
const playerArticle = e.target.closest('.stream-player');
if (playerArticle) {
const playBtn = playerArticle.querySelector('.play-pause-btn');
if (playBtn) {
updatePlayPauseButton(playBtn, false);
}
}
currentlyPlayingAudio = null;
currentlyPlayingButton = null;
}
}, true);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,240 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audio Player Test</title>
<style>
:root {
--success: #2e8b57;
--error: #ff4444;
--border: #444;
--text-color: #f0f0f0;
--surface: #2a2a2a;
}
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
background: #1a1a1a;
color: var(--text-color);
}
.test-case {
margin-bottom: 20px;
padding: 15px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--surface);
}
.success { color: var(--success); }
.error { color: var(--error); }
button {
padding: 8px 16px;
margin: 5px;
cursor: pointer;
background: #4a6fa5;
color: white;
border: none;
border-radius: 4px;
}
button:hover {
background: #3a5a8c;
}
#log {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
background: #f5f5f5;
}
.audio-container {
margin: 20px 0;
}
audio {
width: 100%;
margin: 10px 0;
}
</style>
</head>
<body>
<h1>Audio Player Test</h1>
<div class="test-case">
<h2>Test 1: Direct Audio Element</h2>
<div class="audio-container">
<audio id="direct-audio" controls>
<source src="/audio/devuser/stream.opus" type="audio/ogg; codecs=opus">
Your browser does not support the audio element.
</audio>
</div>
<div>
<button onclick="document.getElementById('direct-audio').play()">Play</button>
<button onclick="document.getElementById('direct-audio').pause()">Pause</button>
</div>
</div>
<div class="test-case">
<h2>Test 2: Dynamic Audio Element</h2>
<div id="dynamic-audio-container">
<button onclick="setupDynamicAudio()">Initialize Dynamic Audio</button>
</div>
</div>
<div class="test-case">
<h2>Test 3: Using loadProfileStream</h2>
<div id="load-profile-container">
<button onclick="testLoadProfileStream()">Test loadProfileStream</button>
<div id="test3-status">Not started</div>
<div class="audio-container">
<audio id="profile-audio" controls></audio>
</div>
</div>
</div>
<div class="test-case">
<h2>Browser Audio Support</h2>
<div id="codec-support">Testing codec support...</div>
</div>
<div class="test-case">
<h2>Console Log</h2>
<div id="log"></div>
<button onclick="document.getElementById('log').innerHTML = ''">Clear Log</button>
</div>
<script>
// Logging function
function log(message, type = 'info') {
const logDiv = document.getElementById('log');
const entry = document.createElement('div');
entry.className = type;
entry.textContent = `[${new Date().toISOString()}] ${message}`;
logDiv.appendChild(entry);
logDiv.scrollTop = logDiv.scrollHeight;
console.log(`[${type.toUpperCase()}] ${message}`);
}
// Test 2: Dynamic Audio Element
function setupDynamicAudio() {
log('Setting up dynamic audio element...');
const container = document.getElementById('dynamic-audio-container');
container.innerHTML = '';
try {
const audio = document.createElement('audio');
audio.controls = true;
audio.preload = 'auto';
audio.crossOrigin = 'anonymous';
const source = document.createElement('source');
source.src = '/audio/devuser/stream.opus';
source.type = 'audio/ogg; codecs=opus';
audio.appendChild(source);
container.appendChild(audio);
container.appendChild(document.createElement('br'));
const playBtn = document.createElement('button');
playBtn.textContent = 'Play';
playBtn.onclick = () => {
audio.play().catch(e => log(`Play error: ${e}`, 'error'));
};
container.appendChild(playBtn);
const pauseBtn = document.createElement('button');
pauseBtn.textContent = 'Pause';
pauseBtn.onclick = () => audio.pause();
container.appendChild(pauseBtn);
log('Dynamic audio element created successfully');
} catch (e) {
log(`Error creating dynamic audio: ${e}`, 'error');
}
}
// Test 3: loadProfileStream
async function testLoadProfileStream() {
const status = document.getElementById('test3-status');
status.textContent = 'Loading...';
status.className = '';
try {
// Import the loadProfileStream function from app.js
const { loadProfileStream } = await import('./app.js');
if (typeof loadProfileStream !== 'function') {
throw new Error('loadProfileStream function not found');
}
// Call loadProfileStream with test user
const audio = await loadProfileStream('devuser');
if (audio) {
status.textContent = 'Audio loaded successfully!';
status.className = 'success';
log('Audio loaded successfully', 'success');
// Add the audio element to the page
const audioContainer = document.querySelector('#load-profile-container .audio-container');
audioContainer.innerHTML = '';
audio.controls = true;
audioContainer.appendChild(audio);
} else {
status.textContent = 'No audio available for test user';
status.className = '';
log('No audio available for test user', 'info');
}
} catch (e) {
status.textContent = `Error: ${e.message}`;
status.className = 'error';
log(`Error in loadProfileStream: ${e}`, 'error');
console.error(e);
}
}
// Check browser audio support
function checkAudioSupport() {
const supportDiv = document.getElementById('codec-support');
const audio = document.createElement('audio');
const codecs = {
'audio/ogg; codecs=opus': 'Opus (OGG)',
'audio/webm; codecs=opus': 'Opus (WebM)',
'audio/mp4; codecs=mp4a.40.2': 'AAC (MP4)',
'audio/mpeg': 'MP3'
};
let results = [];
for (const [type, name] of Object.entries(codecs)) {
const canPlay = audio.canPlayType(type);
results.push(`${name}: ${canPlay || 'Not supported'}`);
}
supportDiv.innerHTML = results.join('<br>');
}
// Initialize tests
document.addEventListener('DOMContentLoaded', () => {
log('Test page loaded');
checkAudioSupport();
// Log audio element events for debugging
const audioElements = document.getElementsByTagName('audio');
Array.from(audioElements).forEach((audio, index) => {
['play', 'pause', 'error', 'stalled', 'suspend', 'abort', 'emptied', 'ended'].forEach(event => {
audio.addEventListener(event, (e) => {
log(`Audio ${index + 1} ${event} event: ${e.type}`);
});
});
});
});
</script>
</body>
</html>

210
static/test-audio.html Normal file
View File

@ -0,0 +1,210 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audio Player Test</title>
<style>
:root {
--success: #2e8b57;
--error: #ff4444;
--border: #444;
--text-color: #f0f0f0;
--surface: #2a2a2a;
}
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
background: #1a1a1a;
color: var(--text-color);
}
.test-case {
margin-bottom: 20px;
padding: 15px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--surface);
}
.success { color: var(--success); }
.error { color: var(--error); }
button {
padding: 8px 16px;
margin: 5px;
cursor: pointer;
background: #4a6fa5;
color: white;
border: none;
border-radius: 4px;
}
button:hover {
background: #3a5a8c;
}
#log {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
background: #f5f5f5;
}
</style>
</head>
<body>
<h1>Audio Player Test</h1>
<div class="test-case">
<h2>Test 1: Basic Audio Element</h2>
<audio id="test1" controls>
<source src="/static/test-audio.opus" type="audio/ogg; codecs=opus">
Your browser does not support the audio element.
</audio>
<div>
<button onclick="document.getElementById('test1').play()">Play</button>
<button onclick="document.getElementById('test1').pause()">Pause</button>
</div>
</div>
<div class="test-case">
<h2>Test 2: Dynamic Audio Element</h2>
<div id="test2-container">
<button onclick="setupTest2()">Initialize Audio</button>
</div>
</div>
<div class="test-case">
<h2>Test 3: Using loadProfileStream</h2>
<div id="test3-container">
<button onclick="testLoadProfileStream()">Test loadProfileStream</button>
<div id="test3-status">Not started</div>
</div>
</div>
<div class="test-case">
<h2>Browser Audio Support</h2>
<div id="codec-support">Testing codec support...</div>
</div>
<div class="test-case">
<h2>Console Log</h2>
<div id="log"></div>
<button onclick="document.getElementById('log').innerHTML = ''">Clear Log</button>
</div>
<script>
// Logging function
function log(message, type = 'info') {
const logDiv = document.getElementById('log');
const entry = document.createElement('div');
entry.className = type;
entry.textContent = `[${new Date().toISOString()}] ${message}`;
logDiv.appendChild(entry);
logDiv.scrollTop = logDiv.scrollHeight;
console.log(`[${type.toUpperCase()}] ${message}`);
}
// Test 2: Dynamic Audio Element
function setupTest2() {
log('Setting up dynamic audio element...');
const container = document.getElementById('test2-container');
container.innerHTML = '';
try {
const audio = document.createElement('audio');
audio.controls = true;
audio.preload = 'auto';
const source = document.createElement('source');
source.src = '/static/test-audio.opus';
source.type = 'audio/ogg; codecs=opus';
audio.appendChild(source);
container.appendChild(audio);
container.appendChild(document.createElement('br'));
const playBtn = document.createElement('button');
playBtn.textContent = 'Play';
playBtn.onclick = () => audio.play().catch(e => log(`Play error: ${e}`, 'error'));
container.appendChild(playBtn);
const pauseBtn = document.createElement('button');
pauseBtn.textContent = 'Pause';
pauseBtn.onclick = () => audio.pause();
container.appendChild(pauseBtn);
log('Dynamic audio element created successfully');
} catch (e) {
log(`Error creating dynamic audio: ${e}`, 'error');
}
}
// Test 3: loadProfileStream
async function testLoadProfileStream() {
const status = document.getElementById('test3-status');
status.textContent = 'Loading...';
status.className = '';
try {
// Create a test user ID
const testUid = 'test-user-' + Math.random().toString(36).substr(2, 8);
log(`Testing with user: ${testUid}`);
// Call loadProfileStream
const audio = await window.loadProfileStream(testUid);
if (audio) {
status.textContent = 'Audio loaded successfully!';
status.className = 'success';
log('Audio loaded successfully', 'success');
} else {
status.textContent = 'No audio available for test user';
status.className = '';
log('No audio available for test user', 'info');
}
} catch (e) {
status.textContent = `Error: ${e.message}`;
status.className = 'error';
log(`Error in loadProfileStream: ${e}`, 'error');
}
}
// Check browser audio support
function checkAudioSupport() {
const supportDiv = document.getElementById('codec-support');
const audio = document.createElement('audio');
const codecs = {
'audio/ogg; codecs=opus': 'Opus (OGG)',
'audio/webm; codecs=opus': 'Opus (WebM)',
'audio/mp4; codecs=mp4a.40.2': 'AAC (MP4)',
'audio/mpeg': 'MP3'
};
let results = [];
for (const [type, name] of Object.entries(codecs)) {
const canPlay = audio.canPlayType(type);
results.push(`${name}: ${canPlay || 'Not supported'}`);
}
supportDiv.innerHTML = results.join('<br>');
}
// Initialize tests
document.addEventListener('DOMContentLoaded', () => {
log('Test page loaded');
checkAudioSupport();
// Expose loadProfileStream for testing
if (!window.loadProfileStream) {
log('Warning: loadProfileStream not found in global scope', 'warning');
}
});
</script>
</body>
</html>

BIN
static/test-audio.opus Normal file

Binary file not shown.

View File

@ -78,6 +78,22 @@ document.addEventListener('DOMContentLoaded', () => {
dropzone.classList.remove("uploading");
showToast("✅ Upload successful.");
// Refresh the audio player and file list
const uid = localStorage.getItem("uid");
if (uid) {
try {
if (window.loadProfileStream) {
await window.loadProfileStream(uid);
}
// Refresh the file list
if (window.fetchAndDisplayFiles) {
await window.fetchAndDisplayFiles(uid);
}
} catch (e) {
console.error('Failed to refresh:', e);
}
}
playBeep(432, 0.25, "sine");
} else {
streamInfo.hidden = true;
@ -95,8 +111,202 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
// Export the upload function for use in other modules
// Function to fetch and display uploaded files
async function fetchAndDisplayFiles(uidFromParam) {
console.log('[UPLOAD] fetchAndDisplayFiles called with uid:', uidFromParam);
// Get the file list element
const fileList = document.getElementById('file-list');
if (!fileList) {
const errorMsg = 'File list element not found in DOM';
console.error(errorMsg);
return showErrorInUI(errorMsg);
}
// Get UID from parameter, localStorage, or cookie
const uid = uidFromParam || localStorage.getItem('uid') || getCookie('uid');
const authToken = localStorage.getItem('authToken');
const headers = {
'Accept': 'application/json',
};
// Include auth token in headers if available, but don't fail if it's not
// The server should handle both token-based and UID-based auth
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
} else {
console.debug('[UPLOAD] No auth token available, using UID-only authentication');
}
console.log('[UPLOAD] Auth state - UID:', uid, 'Token exists:', !!authToken);
if (!uid) {
console.error('[UPLOAD] No UID found in any source');
fileList.innerHTML = '<li class="error-message">User session expired. Please refresh the page.</li>';
return;
}
// Log the authentication method being used
if (!authToken) {
console.debug('[UPLOAD] No auth token found, using UID-only authentication');
} else {
console.debug('[UPLOAD] Using token-based authentication');
}
// Show loading state
fileList.innerHTML = '<li class="loading-message">Loading files...</li>';
try {
console.log(`[DEBUG] Fetching files for user: ${uid}`);
const response = await fetch(`/me/${uid}`, {
headers: {
'Authorization': authToken ? `Bearer ${authToken}` : '',
'Content-Type': 'application/json',
},
});
console.log('[DEBUG] Response status:', response.status, response.statusText);
if (!response.ok) {
const errorText = await response.text();
const errorMsg = `Failed to fetch files: ${response.status} ${response.statusText} - ${errorText}`;
console.error(`[ERROR] ${errorMsg}`);
throw new Error(errorMsg);
}
const data = await response.json();
console.log('[DEBUG] Received files data:', data);
if (!data.files) {
throw new Error('Invalid response format: missing files array');
}
if (data.files.length > 0) {
// Sort files by name
const sortedFiles = [...data.files].sort((a, b) => a.name.localeCompare(b.name));
fileList.innerHTML = sortedFiles.map(file => {
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
const displayName = file.original_name || file.name;
const isRenamed = file.original_name && file.original_name !== file.name;
return `
<li class="file-item" data-filename="${file.name}">
<div class="file-name" title="${displayName}">
${displayName}
${isRenamed ? `<div class="stored-as" title="Stored as: ${file.name}">${file.name} <button class="delete-file" data-filename="${file.name}" title="Delete file">🗑️</button></div>` :
`<button class="delete-file" data-filename="${file.name}" title="Delete file">🗑️</button>`}
</div>
<span class="file-size">${sizeMB} MB</span>
</li>
`;
}).join('');
} else {
fileList.innerHTML = '<li class="empty-message">No files uploaded yet</li>';
}
// Add event listeners to delete buttons
document.querySelectorAll('.delete-file').forEach(button => {
button.addEventListener('click', async (e) => {
e.stopPropagation();
const filename = button.dataset.filename;
if (confirm(`Are you sure you want to delete ${filename}?`)) {
try {
// Get the auth token from the cookie
const token = document.cookie
.split('; ')
.find(row => row.startsWith('sessionid='))
?.split('=')[1];
if (!token) {
throw new Error('Not authenticated');
}
const response = await fetch(`/delete/${filename}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to delete file: ${response.statusText}`);
}
// Refresh the file list
const uid = document.body.dataset.userUid;
if (uid) {
fetchAndDisplayFiles(uid);
}
} catch (error) {
console.error('Error deleting file:', error);
alert('Failed to delete file. Please try again.');
}
}
});
});
// Update quota display if available
if (data.quota !== 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;
bar.value = data.quota;
bar.max = 100;
text.textContent = `${data.quota.toFixed(1)} MB`;
}
}
} catch (error) {
const errorMessage = `Error loading file list: ${error.message || 'Unknown error'}`;
console.error('[ERROR]', errorMessage, error);
showErrorInUI(errorMessage, fileList);
}
// Helper function to show error messages in the UI
function showErrorInUI(message, targetElement = null) {
const errorHtml = `
<div style="
padding: 10px;
margin: 5px 0;
background: #2a0f0f;
border-left: 3px solid #f55;
color: var(--error-hover);
font-family: monospace;
font-size: 0.9em;
white-space: pre-wrap;
word-break: break-word;
">
<div style="font-weight: bold; color: var(--error);">Error loading files</div>
<div style="margin-top: 5px;">${message}</div>
<div style="margin-top: 10px; font-size: 0.8em; color: var(--text-muted);">
Check browser console for details
</div>
</div>
`;
if (targetElement) {
targetElement.innerHTML = errorHtml;
} else {
// If no target element, try to find it
const fileList = document.getElementById('file-list');
if (fileList) fileList.innerHTML = errorHtml;
}
}
}
// Helper function to get cookie value by name
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
// Export functions for use in other modules
window.upload = upload;
window.fetchAndDisplayFiles = fetchAndDisplayFiles;
if (dropzone && fileInput) {
dropzone.addEventListener("click", () => {

View File

@ -5,16 +5,20 @@ from slowapi import Limiter
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from pathlib import Path
import json
from datetime import datetime
from convert_to_opus import convert_to_opus
from database import get_db
from models import UploadLog, UserQuota, User
from sqlalchemy import select
from database import get_db
limiter = Limiter(key_func=get_remote_address)
router = APIRouter()
# # Not needed for SlowAPI ≥0.1.5
DATA_ROOT = Path("./data")
@limiter.limit("5/minute")
@router.post("/upload")
async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), file: UploadFile = Form(...)):
@ -88,12 +92,22 @@ async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), f
stream_path = user_dir / "stream.opus"
shutil.copy2(processed_path, stream_path)
db.add(UploadLog(
# Create a log entry with the original filename
log = UploadLog(
uid=uid,
ip=request.client.host,
filename=file.filename,
filename=file.filename, # Store original filename
processed_filename=unique_name, # Store the processed filename
size_bytes=original_size
))
)
db.add(log)
db.commit()
db.refresh(log)
# Rename the processed file to include the log ID for better tracking
processed_with_id = user_dir / f"{log.id}_{unique_name}"
processed_path.rename(processed_with_id)
processed_path = processed_with_id
# Store updated quota
size = processed_path.stat().st_size
@ -104,6 +118,9 @@ async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), f
quota.storage_bytes += size
db.commit()
# Update public streams list
update_public_streams(uid, quota.storage_bytes)
return {
"filename": file.filename,
"original_size": round(original_size / 1024, 1),
@ -123,3 +140,39 @@ async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), f
except Exception:
pass
return {"detail": f"Server error: {type(e).__name__}: {str(e)}"}
def update_public_streams(uid: str, storage_bytes: int, db = Depends(get_db)):
"""Update the public streams list in the database with the latest user upload info"""
try:
from models import PublicStream
# Get or create the public stream record
public_stream = db.get(PublicStream, uid)
current_time = datetime.utcnow()
if public_stream is None:
# Create a new record if it doesn't exist
public_stream = PublicStream(
uid=uid,
size=storage_bytes,
mtime=int(current_time.timestamp()),
created_at=current_time,
updated_at=current_time
)
db.add(public_stream)
else:
# Update existing record
public_stream.size = storage_bytes
public_stream.mtime = int(current_time.timestamp())
public_stream.updated_at = current_time
db.commit()
db.refresh(public_stream)
except Exception as e:
db.rollback()
import traceback
print(f"Error updating public streams in database: {e}")
print(traceback.format_exc())
raise