Compare commits
5 Commits
deploy-202
...
ab9d93d913
Author | SHA1 | Date | |
---|---|---|---|
ab9d93d913 | |||
da28b205e5 | |||
c5412b07ac | |||
402e920bc6 | |||
17616ac5b8 |
93
DATABASE.md
Normal file
93
DATABASE.md
Normal 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
98
account_router.py
Normal 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
140
alembic.ini
Normal 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
1
alembic/README
Normal file
@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
61
alembic/env.py
Normal file
61
alembic/env.py
Normal 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
28
alembic/script.py.mako
Normal 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"}
|
71
alembic/versions/0df481ee920b_add_publicstream_model.py
Normal file
71
alembic/versions/0df481ee920b_add_publicstream_model.py
Normal 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 ###
|
86
alembic/versions/1ab2db0e4b5e_make_username_unique.py
Normal file
86
alembic/versions/1ab2db0e4b5e_make_username_unique.py
Normal 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 ###
|
49
alembic/versions/8be4811023d8_add_display_name_to_user.py
Normal file
49
alembic/versions/8be4811023d8_add_display_name_to_user.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"""add_display_name_to_user
|
||||||
|
|
||||||
|
Revision ID: 8be4811023d8
|
||||||
|
Revises: 0df481ee920b
|
||||||
|
Create Date: 2025-07-19 19:46:01.129412
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '8be4811023d8'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '0df481ee920b'
|
||||||
|
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! ###
|
||||||
|
op.drop_constraint(op.f('dbsession_user_id_fkey'), 'dbsession', type_='foreignkey')
|
||||||
|
op.create_foreign_key(None, 'dbsession', 'user', ['user_id'], ['username'])
|
||||||
|
op.alter_column('publicstream', 'storage_bytes',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
nullable=False,
|
||||||
|
existing_server_default=sa.text('0'))
|
||||||
|
op.create_index(op.f('ix_publicstream_username'), 'publicstream', ['username'], unique=False)
|
||||||
|
op.drop_column('publicstream', 'size')
|
||||||
|
op.add_column('user', sa.Column('display_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('user', 'display_name')
|
||||||
|
op.add_column('publicstream', sa.Column('size', sa.INTEGER(), autoincrement=False, nullable=False))
|
||||||
|
op.drop_index(op.f('ix_publicstream_username'), table_name='publicstream')
|
||||||
|
op.alter_column('publicstream', 'storage_bytes',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text('0'))
|
||||||
|
op.drop_constraint(None, 'dbsession', type_='foreignkey')
|
||||||
|
op.create_foreign_key(op.f('dbsession_user_id_fkey'), 'dbsession', 'user', ['user_id'], ['username'], ondelete='CASCADE')
|
||||||
|
# ### end Alembic commands ###
|
@ -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
73
auth.py
Normal 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
|
138
auth_router.py
Normal file
138
auth_router.py
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
"""Authentication routes for dicta2stream"""
|
||||||
|
from fastapi import APIRouter, Depends, Request, Response, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from models import Session as DBSession, User
|
||||||
|
from database import get_db
|
||||||
|
from auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["auth"])
|
||||||
|
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"""
|
||||||
|
try:
|
||||||
|
# Get the token from the Authorization header
|
||||||
|
token = credentials.credentials if credentials else None
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return {"message": "No session to invalidate"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find and invalidate the session
|
||||||
|
session = db.exec(
|
||||||
|
select(DBSession)
|
||||||
|
.where(DBSession.token == token)
|
||||||
|
.where(DBSession.is_active == True) # noqa: E712
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if session:
|
||||||
|
try:
|
||||||
|
session.is_active = False
|
||||||
|
db.add(session)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# Continue with logout even if session lookup fails
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Clear the session cookie
|
||||||
|
response.delete_cookie(
|
||||||
|
key="sessionid",
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="lax",
|
||||||
|
path="/"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear any other auth-related cookies
|
||||||
|
for cookie_name in ["uid", "authToken", "isAuthenticated", "token"]:
|
||||||
|
response.delete_cookie(
|
||||||
|
key=cookie_name,
|
||||||
|
path="/",
|
||||||
|
domain=request.url.hostname,
|
||||||
|
secure=True,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "Successfully logged out"}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
# Re-raise HTTP exceptions
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
# Don't expose internal errors to the client
|
||||||
|
return {"message": "Logout processed"}
|
||||||
|
|
||||||
|
|
||||||
|
@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"}
|
@ -9,9 +9,50 @@ def concat_opus_files(user_dir: Path, output_file: Path):
|
|||||||
Concatenate all .opus files in user_dir (except stream.opus) in random order into output_file.
|
Concatenate all .opus files in user_dir (except stream.opus) in random order into output_file.
|
||||||
Overwrites output_file if exists. Creates it if missing.
|
Overwrites output_file if exists. Creates it if missing.
|
||||||
"""
|
"""
|
||||||
files = [f for f in user_dir.glob('*.opus') if f.name != 'stream.opus']
|
# Clean up any existing filelist.txt to prevent issues
|
||||||
|
filelist_path = user_dir / 'filelist.txt'
|
||||||
|
if filelist_path.exists():
|
||||||
|
try:
|
||||||
|
filelist_path.unlink()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not clean up old filelist.txt: {e}")
|
||||||
|
|
||||||
|
# Get all opus files except stream.opus and remove any duplicates
|
||||||
|
import hashlib
|
||||||
|
file_hashes = set()
|
||||||
|
files = []
|
||||||
|
|
||||||
|
for f in user_dir.glob('*.opus'):
|
||||||
|
if f.name == 'stream.opus':
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Calculate file hash for duplicate detection
|
||||||
|
hasher = hashlib.md5()
|
||||||
|
with open(f, 'rb') as file:
|
||||||
|
buf = file.read(65536) # Read in 64kb chunks
|
||||||
|
while len(buf) > 0:
|
||||||
|
hasher.update(buf)
|
||||||
|
buf = file.read(65536)
|
||||||
|
file_hash = hasher.hexdigest()
|
||||||
|
|
||||||
|
# Skip if we've seen this exact file before
|
||||||
|
if file_hash in file_hashes:
|
||||||
|
print(f"Removing duplicate file: {f.name}")
|
||||||
|
f.unlink()
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_hashes.add(file_hash)
|
||||||
|
files.append(f)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing {f}: {e}")
|
||||||
|
|
||||||
if not files:
|
if not files:
|
||||||
raise FileNotFoundError(f"No opus files to concatenate in {user_dir}")
|
# If no files, create an empty stream.opus
|
||||||
|
output_file.write_bytes(b'')
|
||||||
|
return output_file
|
||||||
|
|
||||||
random.shuffle(files)
|
random.shuffle(files)
|
||||||
|
|
||||||
# Create a filelist for ffmpeg concat
|
# Create a filelist for ffmpeg concat
|
||||||
|
70
create_silent_opus.py
Normal file
70
create_silent_opus.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Create a silent OPUS audio file with 1 second of silence.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import opuslib
|
||||||
|
import numpy as np
|
||||||
|
import struct
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SAMPLE_RATE = 48000
|
||||||
|
CHANNELS = 1
|
||||||
|
FRAME_SIZE = 960 # 20ms at 48kHz
|
||||||
|
SILENCE_DURATION = 1.0 # seconds
|
||||||
|
OUTPUT_FILE = "silent.opus"
|
||||||
|
|
||||||
|
# Calculate number of frames needed
|
||||||
|
num_frames = int((SAMPLE_RATE * SILENCE_DURATION) / (FRAME_SIZE * CHANNELS))
|
||||||
|
|
||||||
|
# Initialize Opus encoder
|
||||||
|
enc = opuslib.Encoder(SAMPLE_RATE, CHANNELS, 'voip')
|
||||||
|
|
||||||
|
# Create silent audio data (all zeros)
|
||||||
|
silent_frame = struct.pack('h' * FRAME_SIZE * CHANNELS, *([0] * FRAME_SIZE * CHANNELS))
|
||||||
|
|
||||||
|
# Create Ogg Opus file
|
||||||
|
with open(OUTPUT_FILE, 'wb') as f:
|
||||||
|
# Write Ogg header
|
||||||
|
f.write(b'OggS') # Magic number
|
||||||
|
f.write(b'\x00') # Version
|
||||||
|
f.write(b'\x00') # Header type (0 = normal)
|
||||||
|
f.write(b'\x00\x00\x00\x00\x00\x00\x00\x00') # Granule position
|
||||||
|
f.write(b'\x00\x00\x00\x00') # Bitstream serial number
|
||||||
|
f.write(b'\x00\x00\x00\x00') # Page sequence number
|
||||||
|
f.write(b'\x00\x00\x00\x00') # Checksum
|
||||||
|
f.write(b'\x01') # Number of segments
|
||||||
|
f.write(b'\x00') # Segment table (0 = 1 byte segment)
|
||||||
|
|
||||||
|
# Write Opus header
|
||||||
|
f.write(b'OpusHead') # Magic signature
|
||||||
|
f.write(b'\x01') # Version
|
||||||
|
f.write(chr(CHANNELS).encode('latin1')) # Channel count
|
||||||
|
f.write(struct.pack('<H', 80)) # Preskip (80 samples)
|
||||||
|
f.write(struct.pack('<I', SAMPLE_RATE)) # Input sample rate
|
||||||
|
f.write(b'\x00\x00') # Output gain
|
||||||
|
f.write(b'\x00') # Channel mapping family (0 = mono/stereo)
|
||||||
|
|
||||||
|
# Write comment header
|
||||||
|
f.write(b'OpusTags') # Magic signature
|
||||||
|
f.write(struct.pack('<I', 0)) # Vendor string length (0 for none)
|
||||||
|
f.write(struct.pack('<I', 0)) # Number of comments (0)
|
||||||
|
|
||||||
|
# Encode and write silent frames
|
||||||
|
for _ in range(num_frames):
|
||||||
|
# Encode the silent frame
|
||||||
|
encoded = enc.encode(silent_frame, FRAME_SIZE)
|
||||||
|
|
||||||
|
# Write Ogg page
|
||||||
|
f.write(b'OggS') # Magic number
|
||||||
|
f.write(b'\x00') # Version
|
||||||
|
f.write(b'\x00') # Header type (0 = normal)
|
||||||
|
f.write(struct.pack('<Q', (FRAME_SIZE * _) % (1 << 64))) # Granule position
|
||||||
|
f.write(b'\x00\x00\x00\x00') # Bitstream serial number
|
||||||
|
f.write(struct.pack('<I', _ + 2)) # Page sequence number
|
||||||
|
f.write(b'\x00\x00\x00\x00') # Checksum (0 for now)
|
||||||
|
f.write(b'\x01') # Number of segments
|
||||||
|
f.write(chr(len(encoded)).encode('latin1')) # Segment length
|
||||||
|
f.write(encoded) # The encoded data
|
||||||
|
|
||||||
|
print(f"Created silent OPUS file: {OUTPUT_FILE}")
|
212
deletefile.py
Normal file
212
deletefile.py
Normal 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
22
dicta2stream.service
Normal 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
35
gunicorn_config.py
Normal 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
94
import_streams.py
Normal 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
36
init_db.py
Normal 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()
|
164
list_streams.py
164
list_streams.py
@ -1,64 +1,138 @@
|
|||||||
# list_streams.py — FastAPI route to list all public streams (users with stream.opus)
|
# 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 pathlib import Path
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
DATA_ROOT = Path("./data")
|
DATA_ROOT = Path("./data")
|
||||||
|
|
||||||
@router.get("/streams-sse")
|
@router.get("/streams-sse")
|
||||||
def streams_sse():
|
async def streams_sse(request: Request, db: Session = Depends(get_db)):
|
||||||
return list_streams_sse()
|
# 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():
|
# Handle preflight requests
|
||||||
async def event_generator():
|
if request.method == "OPTIONS":
|
||||||
txt_path = Path("./public_streams.txt")
|
headers.update({
|
||||||
if not txt_path.exists():
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
print(f"[{datetime.datetime.now()}] [SSE] No public_streams.txt found")
|
"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 with required fields
|
||||||
|
stmt = select(PublicStream).order_by(PublicStream.mtime.desc())
|
||||||
|
result = db.execute(stmt)
|
||||||
|
streams = result.scalars().all()
|
||||||
|
|
||||||
|
if not streams:
|
||||||
|
print("No public streams found in the database")
|
||||||
yield f"data: {json.dumps({'end': True})}\n\n"
|
yield f"data: {json.dumps({'end': True})}\n\n"
|
||||||
return
|
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():
|
print(f"Found {len(streams)} public streams in the database")
|
||||||
txt_path = Path("./public_streams.txt")
|
|
||||||
if not txt_path.exists():
|
# Send each stream as an SSE event
|
||||||
return {"streams": []}
|
for stream in streams:
|
||||||
try:
|
try:
|
||||||
streams = []
|
# Ensure we have all required fields with fallbacks
|
||||||
with txt_path.open("r") as f:
|
stream_data = {
|
||||||
for line in f:
|
'uid': stream.uid or '',
|
||||||
line = line.strip()
|
'size': stream.storage_bytes or 0,
|
||||||
if not line:
|
'mtime': int(stream.mtime) if stream.mtime is not None else 0,
|
||||||
|
'username': stream.username or stream.uid or '',
|
||||||
|
'display_name': stream.display_name or stream.username or stream.uid or '',
|
||||||
|
'created_at': stream.created_at.isoformat() if stream.created_at else None,
|
||||||
|
'updated_at': stream.updated_at.isoformat() if stream.updated_at else None
|
||||||
|
}
|
||||||
|
print(f"Sending stream data: {stream_data}")
|
||||||
|
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:
|
||||||
|
print(f"Error processing stream {stream.uid}: {str(e)}")
|
||||||
|
if os.getenv("DEBUG") == "1":
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Send end of stream marker
|
||||||
|
print("Finished sending all streams")
|
||||||
|
yield f"data: {json.dumps({'end': True})}\n\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in list_streams_sse: {str(e)}")
|
||||||
|
if os.getenv("DEBUG") == "1":
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
yield f"data: {json.dumps({'error': True, 'message': str(e)})}\n\n"
|
||||||
|
|
||||||
|
def list_streams(db: Session = Depends(get_db)):
|
||||||
|
"""List all public streams from the database"""
|
||||||
try:
|
try:
|
||||||
streams.append(json.loads(line))
|
stmt = select(PublicStream).order_by(PublicStream.mtime.desc())
|
||||||
except Exception:
|
result = db.execute(stmt)
|
||||||
continue # skip malformed lines
|
streams = result.scalars().all()
|
||||||
return {"streams": streams}
|
|
||||||
except Exception:
|
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": []}
|
return {"streams": []}
|
||||||
|
53
magic.py
53
magic.py
@ -1,16 +1,18 @@
|
|||||||
# magic.py — handle magic token login confirmation
|
# magic.py — handle magic token login confirmation
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, HTTPException, Depends, Request
|
from fastapi import APIRouter, Form, HTTPException, Depends, Request, Response
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse, JSONResponse
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import User
|
from models import User, DBSession
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
import secrets
|
||||||
|
import json
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@router.post("/magic-login")
|
@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}")
|
print(f"[magic-login] Received token: {token}")
|
||||||
user = db.exec(select(User).where(User.token == token)).first()
|
user = db.exec(select(User).where(User.token == token)).first()
|
||||||
print(f"[magic-login] User lookup: {'found' if user else 'not found'}")
|
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}")
|
print(f"[magic-login] Token expired for user: {user.username}")
|
||||||
return RedirectResponse(url="/?error=Token%20expired", status_code=302)
|
return RedirectResponse(url="/?error=Token%20expired", status_code=302)
|
||||||
|
|
||||||
|
# Mark user as confirmed if not already
|
||||||
if not user.confirmed:
|
if not user.confirmed:
|
||||||
user.confirmed = True
|
user.confirmed = True
|
||||||
user.ip = request.client.host
|
user.ip = request.client.host
|
||||||
db.commit()
|
db.add(user)
|
||||||
print(f"[magic-login] User {user.username} confirmed. Redirecting to /?login=success&confirmed_uid={user.username}")
|
print(f"[magic-login] User {user.username} confirmed.")
|
||||||
else:
|
|
||||||
print(f"[magic-login] Token already used for user: {user.username}, but allowing multi-use login.")
|
|
||||||
|
|
||||||
return RedirectResponse(url=f"/?login=success&confirmed_uid={user.username}", status_code=302)
|
# 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)
|
||||||
|
)
|
||||||
|
401
main.py
401
main.py
@ -1,6 +1,6 @@
|
|||||||
# main.py — FastAPI backend entrypoint for dicta2stream
|
# 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.responses import HTMLResponse, RedirectResponse, StreamingResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@ -11,13 +11,14 @@ import traceback
|
|||||||
import shutil
|
import shutil
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from models import User, UploadLog
|
from models import User, UploadLog, UserQuota, get_user_by_uid
|
||||||
from sqlmodel import Session, select, SQLModel
|
from sqlmodel import Session, select, SQLModel
|
||||||
from database import get_db, engine
|
from database import get_db, engine
|
||||||
from log import log_violation
|
from log import log_violation
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
|
import subprocess
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@ -36,16 +37,36 @@ from fastapi.requests import Request as FastAPIRequest
|
|||||||
from fastapi.exception_handlers import RequestValidationError
|
from fastapi.exception_handlers import RequestValidationError
|
||||||
from fastapi.exceptions import HTTPException as FastAPIHTTPException
|
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 ---
|
# --- CORS Middleware for SSE and API access ---
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["https://dicta2stream.net", "http://localhost:8000", "http://127.0.0.1:8000"],
|
allow_origins=["https://dicta2stream.net", "http://localhost:8000", "http://127.0.0.1:8000"],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
expose_headers=["Content-Type", "Content-Length", "Cache-Control", "ETag", "Last-Modified"],
|
||||||
|
max_age=3600, # 1 hour
|
||||||
)
|
)
|
||||||
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@ -115,17 +136,47 @@ async def validation_exception_handler(request: FastAPIRequest, exc: RequestVali
|
|||||||
async def generic_exception_handler(request: FastAPIRequest, exc: Exception):
|
async def generic_exception_handler(request: FastAPIRequest, exc: Exception):
|
||||||
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
||||||
|
|
||||||
|
# Debug endpoint to list all routes
|
||||||
|
@app.get("/debug/routes")
|
||||||
|
async def list_routes():
|
||||||
|
routes = []
|
||||||
|
for route in app.routes:
|
||||||
|
if hasattr(route, "methods") and hasattr(route, "path"):
|
||||||
|
routes.append({
|
||||||
|
"path": route.path,
|
||||||
|
"methods": list(route.methods) if hasattr(route, "methods") else [],
|
||||||
|
"name": route.name if hasattr(route, "name") else "",
|
||||||
|
"endpoint": str(route.endpoint) if hasattr(route, "endpoint") else "",
|
||||||
|
"router": str(route) # Add router info for debugging
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort routes by path for easier reading
|
||||||
|
routes.sort(key=lambda x: x["path"])
|
||||||
|
|
||||||
|
# Also print to console for server logs
|
||||||
|
print("\n=== Registered Routes ===")
|
||||||
|
for route in routes:
|
||||||
|
print(f"{', '.join(route['methods']).ljust(20)} {route['path']}")
|
||||||
|
print("======================\n")
|
||||||
|
|
||||||
|
return {"routes": routes}
|
||||||
|
|
||||||
# include routers from submodules
|
# include routers from submodules
|
||||||
from register import router as register_router
|
from register import router as register_router
|
||||||
from magic import router as magic_router
|
from magic import router as magic_router
|
||||||
from upload import router as upload_router
|
from upload import router as upload_router
|
||||||
from streams import router as streams_router
|
from streams import router as streams_router
|
||||||
from list_user_files import router as list_user_files_router
|
from list_user_files import router as list_user_files_router
|
||||||
|
from auth_router import router as auth_router
|
||||||
|
|
||||||
app.include_router(streams_router)
|
app.include_router(streams_router)
|
||||||
|
|
||||||
from list_streams import router as list_streams_router
|
from list_streams import router as list_streams_router
|
||||||
|
from account_router import router as account_router
|
||||||
|
|
||||||
|
# Include all routers
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(account_router)
|
||||||
app.include_router(register_router)
|
app.include_router(register_router)
|
||||||
app.include_router(magic_router)
|
app.include_router(magic_router)
|
||||||
app.include_router(upload_router)
|
app.include_router(upload_router)
|
||||||
@ -135,6 +186,10 @@ app.include_router(list_streams_router)
|
|||||||
# Serve static files
|
# Serve static files
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
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")
|
@app.post("/log-client")
|
||||||
async def log_client(request: Request):
|
async def log_client(request: Request):
|
||||||
try:
|
try:
|
||||||
@ -224,105 +279,138 @@ def debug(request: Request):
|
|||||||
|
|
||||||
MAX_QUOTA_BYTES = 100 * 1024 * 1024
|
MAX_QUOTA_BYTES = 100 * 1024 * 1024
|
||||||
|
|
||||||
@app.post("/delete-account")
|
# Delete account endpoint has been moved to account_router.py
|
||||||
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 {}
|
|
||||||
|
|
||||||
@app.delete("/uploads/{uid}/{filename}")
|
@app.delete("/uploads/{uid}/{filename}")
|
||||||
def delete_file(uid: str, filename: str, request: Request, db: Session = Depends(get_db)):
|
async def delete_file(uid: str, filename: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Delete a file for a specific user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
uid: The username of the user (used as UID in routes)
|
||||||
|
filename: The name of the file to delete
|
||||||
|
request: The incoming request object
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status message
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get the user by username (which is used as UID in routes)
|
||||||
user = get_user_by_uid(uid)
|
user = get_user_by_uid(uid)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=403, detail="Invalid user ID")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Get client IP and verify it matches the user's IP
|
||||||
ip = request.client.host
|
ip = request.client.host
|
||||||
if user.ip != ip:
|
if user.ip != ip:
|
||||||
raise HTTPException(status_code=403, detail="Device/IP mismatch")
|
raise HTTPException(status_code=403, detail="Device/IP mismatch. Please log in again.")
|
||||||
|
|
||||||
|
# Set up user directory and validate paths
|
||||||
user_dir = os.path.join('data', user.username)
|
user_dir = os.path.join('data', user.username)
|
||||||
|
os.makedirs(user_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Decode URL-encoded filename
|
||||||
|
from urllib.parse import unquote
|
||||||
|
filename = unquote(filename)
|
||||||
|
|
||||||
|
# Construct and validate target path
|
||||||
target_path = os.path.join(user_dir, filename)
|
target_path = os.path.join(user_dir, filename)
|
||||||
# Prevent path traversal attacks
|
|
||||||
real_target_path = os.path.realpath(target_path)
|
real_target_path = os.path.realpath(target_path)
|
||||||
real_user_dir = os.path.realpath(user_dir)
|
real_user_dir = os.path.realpath(user_dir)
|
||||||
|
|
||||||
|
# Security check: Ensure the target path is inside the user's directory
|
||||||
if not real_target_path.startswith(real_user_dir + os.sep):
|
if not real_target_path.startswith(real_user_dir + os.sep):
|
||||||
raise HTTPException(status_code=403, detail="Invalid path")
|
raise HTTPException(status_code=403, detail="Invalid file path")
|
||||||
|
|
||||||
|
# Check if file exists
|
||||||
if not os.path.isfile(real_target_path):
|
if not os.path.isfile(real_target_path):
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
|
||||||
|
|
||||||
os.remove(real_target_path)
|
|
||||||
log_violation("DELETE", ip, uid, f"Deleted {filename}")
|
|
||||||
subprocess.run(["/root/scripts/refresh_user_playlist.sh", user.username])
|
|
||||||
|
|
||||||
|
# Delete both the target file and its UUID-only variant
|
||||||
|
deleted_files = []
|
||||||
try:
|
try:
|
||||||
actual_bytes = int(subprocess.check_output(["du", "-sb", user_dir]).split()[0])
|
# First delete the requested file (with log ID prefix)
|
||||||
q = db.get(UserQuota, uid)
|
if os.path.exists(real_target_path):
|
||||||
if q:
|
os.remove(real_target_path)
|
||||||
q.storage_bytes = actual_bytes
|
deleted_files.append(filename)
|
||||||
db.add(q)
|
log_violation("DELETE", ip, uid, f"Deleted {filename}")
|
||||||
db.commit()
|
|
||||||
|
# Then try to find and delete the UUID-only variant (without log ID prefix)
|
||||||
|
if '_' in filename: # If filename has a log ID prefix (e.g., "123_uuid.opus")
|
||||||
|
uuid_part = filename.split('_', 1)[1] # Get the part after the first underscore
|
||||||
|
uuid_path = os.path.join(user_dir, uuid_part)
|
||||||
|
if os.path.exists(uuid_path):
|
||||||
|
os.remove(uuid_path)
|
||||||
|
deleted_files.append(uuid_part)
|
||||||
|
log_violation("DELETE", ip, uid, f"Deleted UUID variant: {uuid_part}")
|
||||||
|
|
||||||
|
file_deleted = len(deleted_files) > 0
|
||||||
|
|
||||||
|
if not file_deleted:
|
||||||
|
log_violation("DELETE_WARNING", ip, uid, f"No files found to delete for: {filename}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_violation("QUOTA", ip, uid, f"Quota update after delete failed: {e}")
|
log_violation("DELETE_ERROR", ip, uid, f"Error deleting file {filename}: {str(e)}")
|
||||||
|
file_deleted = False
|
||||||
|
|
||||||
|
# Try to refresh the user's playlist, but don't fail if we can't
|
||||||
|
try:
|
||||||
|
subprocess.run(["/root/scripts/refresh_user_playlist.sh", user.username],
|
||||||
|
check=False, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
|
||||||
|
except Exception as e:
|
||||||
|
log_violation("PLAYLIST_REFRESH_WARNING", ip, uid,
|
||||||
|
f"Failed to refresh playlist: {str(e)}")
|
||||||
|
|
||||||
|
# Clean up the database record for this file
|
||||||
|
try:
|
||||||
|
# Find and delete the upload log entry
|
||||||
|
log_entry = db.exec(
|
||||||
|
select(UploadLog)
|
||||||
|
.where(UploadLog.uid == uid)
|
||||||
|
.where(UploadLog.processed_filename == filename)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if log_entry:
|
||||||
|
db.delete(log_entry)
|
||||||
|
db.commit()
|
||||||
|
log_violation("DB_CLEANUP", ip, uid, f"Removed DB record for {filename}")
|
||||||
|
except Exception as e:
|
||||||
|
log_violation("DB_CLEANUP_ERROR", ip, uid, f"Failed to clean up DB record: {str(e)}")
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
# Regenerate stream.opus after file deletion
|
||||||
|
try:
|
||||||
|
from concat_opus import concat_opus_files
|
||||||
|
from pathlib import Path
|
||||||
|
user_dir_path = Path(user_dir)
|
||||||
|
stream_path = user_dir_path / "stream.opus"
|
||||||
|
concat_opus_files(user_dir_path, stream_path)
|
||||||
|
log_violation("STREAM_UPDATE", ip, uid, "Regenerated stream.opus after file deletion")
|
||||||
|
except Exception as e:
|
||||||
|
log_violation("STREAM_UPDATE_ERROR", ip, uid, f"Failed to regenerate stream.opus: {str(e)}")
|
||||||
|
|
||||||
|
# Update user quota in a separate try-except to not fail the entire operation
|
||||||
|
try:
|
||||||
|
# Use verify_and_fix_quota to ensure consistency between disk and DB
|
||||||
|
total_size = verify_and_fix_quota(db, user.username, user_dir)
|
||||||
|
log_violation("QUOTA_UPDATE", ip, uid,
|
||||||
|
f"Updated quota: {total_size} bytes")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_violation("QUOTA_ERROR", ip, uid, f"Quota update failed: {str(e)}")
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Log the error and re-raise with a user-friendly message
|
||||||
|
error_detail = str(e)
|
||||||
|
log_violation("DELETE_ERROR", request.client.host, uid, f"Failed to delete {filename}: {error_detail}")
|
||||||
|
if not isinstance(e, HTTPException):
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to delete file: {error_detail}")
|
||||||
|
raise
|
||||||
|
|
||||||
@app.get("/confirm/{uid}")
|
@app.get("/confirm/{uid}")
|
||||||
def confirm_user(uid: str, request: Request):
|
def confirm_user(uid: str, request: Request):
|
||||||
ip = request.client.host
|
ip = request.client.host
|
||||||
@ -331,26 +419,147 @@ def confirm_user(uid: str, request: Request):
|
|||||||
raise HTTPException(status_code=403, detail="Unauthorized")
|
raise HTTPException(status_code=403, detail="Unauthorized")
|
||||||
return {"username": user.username, "email": user.email}
|
return {"username": user.username, "email": user.email}
|
||||||
|
|
||||||
|
def verify_and_fix_quota(db: Session, uid: str, user_dir: str) -> int:
|
||||||
|
"""
|
||||||
|
Verify and fix the user's quota based on the size of stream.opus file.
|
||||||
|
Returns the size of stream.opus in bytes.
|
||||||
|
"""
|
||||||
|
stream_opus_path = os.path.join(user_dir, 'stream.opus')
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
# Only consider stream.opus for quota
|
||||||
|
if os.path.isfile(stream_opus_path):
|
||||||
|
try:
|
||||||
|
total_size = os.path.getsize(stream_opus_path)
|
||||||
|
print(f"[QUOTA] Stream.opus size for {uid}: {total_size} bytes")
|
||||||
|
except (OSError, FileNotFoundError) as e:
|
||||||
|
print(f"[QUOTA] Error getting size for stream.opus: {e}")
|
||||||
|
else:
|
||||||
|
print(f"[QUOTA] stream.opus not found in {user_dir}")
|
||||||
|
|
||||||
|
# Update quota in database
|
||||||
|
q = db.get(UserQuota, uid) or UserQuota(uid=uid, storage_bytes=0)
|
||||||
|
q.storage_bytes = total_size
|
||||||
|
db.add(q)
|
||||||
|
|
||||||
|
# Clean up any database records for files that don't exist
|
||||||
|
uploads = db.exec(select(UploadLog).where(UploadLog.uid == uid)).all()
|
||||||
|
for upload in uploads:
|
||||||
|
if upload.processed_filename: # Only check if processed_filename exists
|
||||||
|
stored_filename = f"{upload.id}_{upload.processed_filename}"
|
||||||
|
file_path = os.path.join(user_dir, stored_filename)
|
||||||
|
if not os.path.isfile(file_path):
|
||||||
|
print(f"[QUOTA] Removing orphaned DB record: {stored_filename}")
|
||||||
|
db.delete(upload)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
print(f"[QUOTA] Updated quota for {uid}: {total_size} bytes")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[QUOTA] Error committing quota update: {e}")
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
return total_size
|
||||||
|
|
||||||
@app.get("/me/{uid}")
|
@app.get("/me/{uid}")
|
||||||
def get_me(uid: str, request: Request, db: Session = Depends(get_db)):
|
def get_me(uid: str, request: Request, response: Response, db: Session = Depends(get_db)):
|
||||||
ip = request.client.host
|
# Add headers to prevent caching
|
||||||
|
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
response.headers["Expires"] = "0"
|
||||||
|
print(f"[DEBUG] GET /me/{uid} - Client IP: {request.client.host}")
|
||||||
|
try:
|
||||||
|
# Get user info
|
||||||
user = get_user_by_uid(uid)
|
user = get_user_by_uid(uid)
|
||||||
if not user or user.ip != ip:
|
if not user:
|
||||||
raise HTTPException(status_code=403, detail="Unauthorized access")
|
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 user directory
|
||||||
|
user_dir = os.path.join('data', uid)
|
||||||
|
os.makedirs(user_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# 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, checking if files exist on disk
|
||||||
files = []
|
files = []
|
||||||
if os.path.exists(user_dir):
|
seen_files = set() # Track seen files to avoid duplicates
|
||||||
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)})
|
|
||||||
|
|
||||||
q = db.get(UserQuota, uid)
|
print(f"[DEBUG] Processing {len(upload_logs)} upload logs for UID {uid}")
|
||||||
quota_mb = round(q.storage_bytes / (1024 * 1024), 2) if q else 0
|
|
||||||
|
|
||||||
return {
|
for i, log in enumerate(upload_logs):
|
||||||
|
if not log.filename or not log.processed_filename:
|
||||||
|
print(f"[DEBUG] Skipping log entry {i}: missing filename or processed_filename")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# The actual filename on disk has the log ID prepended
|
||||||
|
stored_filename = f"{log.id}_{log.processed_filename}"
|
||||||
|
file_path = os.path.join(user_dir, stored_filename)
|
||||||
|
|
||||||
|
# Skip if we've already seen this file
|
||||||
|
if stored_filename in seen_files:
|
||||||
|
print(f"[DEBUG] Skipping duplicate file: {stored_filename}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen_files.add(stored_filename)
|
||||||
|
|
||||||
|
# Only include the file if it exists on disk and is not stream.opus
|
||||||
|
if os.path.isfile(file_path) and stored_filename != 'stream.opus':
|
||||||
|
try:
|
||||||
|
# Get the actual file size in case it changed
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
file_info = {
|
||||||
|
"name": stored_filename,
|
||||||
|
"original_name": log.filename,
|
||||||
|
"size": file_size
|
||||||
|
}
|
||||||
|
files.append(file_info)
|
||||||
|
print(f"[DEBUG] Added file {len(files)}: {log.filename} (stored as {stored_filename}, {file_size} bytes)")
|
||||||
|
except OSError as e:
|
||||||
|
print(f"[WARNING] Could not access file {stored_filename}: {e}")
|
||||||
|
else:
|
||||||
|
print(f"[DEBUG] File not found on disk or is stream.opus: {stored_filename}")
|
||||||
|
|
||||||
|
# Log all files being returned
|
||||||
|
print("[DEBUG] All files being returned:")
|
||||||
|
for i, file_info in enumerate(files, 1):
|
||||||
|
print(f" {i}. {file_info['name']} (original: {file_info['original_name']}, size: {file_info['size']} bytes)")
|
||||||
|
|
||||||
|
# Verify and fix quota based on actual files on disk
|
||||||
|
total_size = verify_and_fix_quota(db, uid, user_dir)
|
||||||
|
quota_mb = round(total_size / (1024 * 1024), 2)
|
||||||
|
print(f"[DEBUG] Verified quota for UID {uid}: {quota_mb} MB")
|
||||||
|
|
||||||
|
response_data = {
|
||||||
"files": files,
|
"files": files,
|
||||||
"quota": quota_mb
|
"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
73
middleware.py
Normal 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
|
67
migrations/0002_add_session_tables.py
Normal file
67
migrations/0002_add_session_tables.py
Normal 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')
|
24
migrations/add_processed_filename_to_uploadlog.py
Normal file
24
migrations/add_processed_filename_to_uploadlog.py
Normal 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')
|
81
models.py
81
models.py
@ -8,7 +8,8 @@ from database import engine
|
|||||||
class User(SQLModel, table=True):
|
class User(SQLModel, table=True):
|
||||||
token_created: datetime = Field(default_factory=datetime.utcnow)
|
token_created: datetime = Field(default_factory=datetime.utcnow)
|
||||||
email: str = Field(primary_key=True)
|
email: str = Field(primary_key=True)
|
||||||
username: str
|
username: str = Field(unique=True, index=True)
|
||||||
|
display_name: str = Field(default="", nullable=True)
|
||||||
token: str
|
token: str
|
||||||
confirmed: bool = False
|
confirmed: bool = False
|
||||||
ip: str = Field(default="")
|
ip: str = Field(default="")
|
||||||
@ -23,13 +24,85 @@ class UploadLog(SQLModel, table=True):
|
|||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
uid: str
|
uid: str
|
||||||
ip: str
|
ip: str
|
||||||
filename: Optional[str]
|
filename: Optional[str] # Original filename
|
||||||
|
processed_filename: Optional[str] # Processed filename (UUID.opus)
|
||||||
size_bytes: int
|
size_bytes: int
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
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)
|
||||||
|
username: Optional[str] = Field(default=None, index=True)
|
||||||
|
display_name: Optional[str] = Field(default=None)
|
||||||
|
storage_bytes: int = 0
|
||||||
|
mtime: int = Field(default_factory=lambda: int(datetime.utcnow().timestamp()))
|
||||||
|
last_updated: Optional[datetime] = Field(default_factory=datetime.utcnow)
|
||||||
|
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]:
|
def get_user_by_uid(uid: str) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Retrieve a user by their UID (username).
|
||||||
|
|
||||||
|
Note: In this application, the User model uses email as primary key,
|
||||||
|
but we're using username as UID for API routes. This function looks up
|
||||||
|
users by username.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
uid: The username to look up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User object if found, None otherwise
|
||||||
|
"""
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
|
# First try to find by username (which is what we're using as UID)
|
||||||
statement = select(User).where(User.username == uid)
|
statement = select(User).where(User.username == uid)
|
||||||
result = session.exec(statement).first()
|
user = session.exec(statement).first()
|
||||||
return result
|
|
||||||
|
# If not found by username, try by email (for backward compatibility)
|
||||||
|
if not user and '@' in uid:
|
||||||
|
statement = select(User).where(User.email == uid)
|
||||||
|
user = session.exec(statement).first()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
4
nohup.out
Normal file
4
nohup.out
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
INFO: Will watch for changes in these directories: ['/home/oib/games/dicta2stream']
|
||||||
|
ERROR: [Errno 98] Address already in use
|
||||||
|
INFO: Will watch for changes in these directories: ['/home/oib/games/dicta2stream']
|
||||||
|
ERROR: [Errno 98] Address already in use
|
@ -1 +1,2 @@
|
|||||||
{"uid":"devuser","size":22455090,"mtime":1747563720}
|
{"uid":"oibchello","size":3371119,"mtime":1752994076}
|
||||||
|
{"uid":"orangeicebear","size":1734396,"mtime":1748767975}
|
||||||
|
3
public_streams.txt.backup
Normal file
3
public_streams.txt.backup
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{"uid":"devuser","size":90059327,"mtime":1752911461}
|
||||||
|
{"uid":"oibchello","size":16262818,"mtime":1752911899}
|
||||||
|
{"uid":"orangeicebear","size":1734396,"mtime":1748767975}
|
40
register.py
40
register.py
@ -7,11 +7,46 @@ from database import get_db
|
|||||||
import uuid
|
import uuid
|
||||||
import smtplib
|
import smtplib
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
MAGIC_FROM = "noreply@dicta2stream.net"
|
MAGIC_FROM = "noreply@dicta2stream.net"
|
||||||
MAGIC_DOMAIN = "https://dicta2stream.net"
|
MAGIC_DOMAIN = "https://dicta2stream.net"
|
||||||
|
DATA_ROOT = Path("./data")
|
||||||
|
|
||||||
|
def initialize_user_directory(username: str):
|
||||||
|
"""Initialize user directory with a silent stream.opus file"""
|
||||||
|
try:
|
||||||
|
user_dir = DATA_ROOT / username
|
||||||
|
default_stream_path = DATA_ROOT / "stream.opus"
|
||||||
|
|
||||||
|
print(f"[DEBUG] Initializing user directory: {user_dir.absolute()}")
|
||||||
|
|
||||||
|
# Create the directory if it doesn't exist
|
||||||
|
user_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"[DEBUG] Directory created or already exists: {user_dir.exists()}")
|
||||||
|
|
||||||
|
# Create stream.opus by copying the default stream.opus file
|
||||||
|
user_stream_path = user_dir / "stream.opus"
|
||||||
|
print(f"[DEBUG] Creating stream.opus at: {user_stream_path.absolute()}")
|
||||||
|
|
||||||
|
if not user_stream_path.exists():
|
||||||
|
if default_stream_path.exists():
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(default_stream_path, user_stream_path)
|
||||||
|
print(f"[DEBUG] Copied default stream.opus to {user_stream_path}")
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] Default stream.opus not found at {default_stream_path}")
|
||||||
|
# Fallback: create an empty file to prevent errors
|
||||||
|
with open(user_stream_path, 'wb') as f:
|
||||||
|
f.write(b'')
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error initializing user directory for {username}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
@router.post("/register")
|
@router.post("/register")
|
||||||
def register(request: Request, email: str = Form(...), user: str = Form(...), db: Session = Depends(get_db)):
|
def register(request: Request, email: str = Form(...), user: str = Form(...), db: Session = Depends(get_db)):
|
||||||
@ -40,8 +75,13 @@ def register(request: Request, email: str = Form(...), user: str = Form(...), db
|
|||||||
# Register new user
|
# Register new user
|
||||||
db.add(User(email=email, username=user, token=token, confirmed=False, ip=request.client.host))
|
db.add(User(email=email, username=user, token=token, confirmed=False, ip=request.client.host))
|
||||||
db.add(UserQuota(uid=user))
|
db.add(UserQuota(uid=user))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# First commit the user to the database
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Only after successful commit, initialize the user directory
|
||||||
|
initialize_user_directory(user)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
if isinstance(e, IntegrityError):
|
if isinstance(e, IntegrityError):
|
||||||
|
29
run_migrations.py
Normal file
29
run_migrations.py
Normal 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()
|
BIN
silent.opus
Normal file
BIN
silent.opus
Normal file
Binary file not shown.
1468
static/app.js
1468
static/app.js
File diff suppressed because it is too large
Load Diff
208
static/css/base.css
Normal file
208
static/css/base.css
Normal 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
69
static/css/colors.css
Normal 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
|
||||||
|
*/
|
0
static/css/components/buttons.css
Normal file
0
static/css/components/buttons.css
Normal file
268
static/css/components/file-upload.css
Normal file
268
static/css/components/file-upload.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
0
static/css/components/forms.css
Normal file
0
static/css/components/forms.css
Normal file
80
static/css/layout/footer.css
Normal file
80
static/css/layout/footer.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
149
static/css/layout/header.css
Normal file
149
static/css/layout/header.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
0
static/css/pages/auth.css
Normal file
0
static/css/pages/auth.css
Normal file
0
static/css/pages/home.css
Normal file
0
static/css/pages/home.css
Normal file
0
static/css/pages/stream.css
Normal file
0
static/css/pages/stream.css
Normal file
116
static/css/section.css
Normal file
116
static/css/section.css
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
/* 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 {
|
||||||
|
display: block !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show auth-only elements when authenticated */
|
||||||
|
body.authenticated .auth-only {
|
||||||
|
display: block !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Account deletion section - improved width and formatting */
|
||||||
|
#account-deletion {
|
||||||
|
margin: 2.5rem auto;
|
||||||
|
padding: 2.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
|
||||||
|
max-width: 600px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#account-deletion h3 {
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#account-deletion p {
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#account-deletion ul {
|
||||||
|
margin: 1rem 0 1.5rem 1.5rem;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#account-deletion .centered-container {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-account-from-privacy {
|
||||||
|
background-color: #ff4d4f;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-account-from-privacy:hover {
|
||||||
|
background-color: #ff6b6b;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide guest-only elements when authenticated */
|
||||||
|
body.authenticated .guest-only {
|
||||||
|
display: none !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.always-visible {
|
||||||
|
display: block !important;
|
||||||
|
}
|
0
static/css/utilities/spacing.css
Normal file
0
static/css/utilities/spacing.css
Normal file
0
static/css/utilities/typography.css
Normal file
0
static/css/utilities/typography.css
Normal file
1181
static/dashboard.js
1181
static/dashboard.js
File diff suppressed because it is too large
Load Diff
231
static/desktop.css
Normal file
231
static/desktop.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
134
static/fix-nav.js
Normal file
134
static/fix-nav.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
// Force hide guest navigation for authenticated users
|
||||||
|
function fixMobileNavigation() {
|
||||||
|
console.log('[FIX-NAV] Running navigation fix...');
|
||||||
|
|
||||||
|
// Check if user is authenticated
|
||||||
|
const hasAuthCookie = document.cookie.includes('isAuthenticated=true');
|
||||||
|
const hasUidCookie = document.cookie.includes('uid=');
|
||||||
|
const hasLocalStorageAuth = localStorage.getItem('isAuthenticated') === 'true';
|
||||||
|
const hasAuthToken = localStorage.getItem('authToken') !== null;
|
||||||
|
const isAuthenticated = hasAuthCookie || hasUidCookie || hasLocalStorageAuth || hasAuthToken;
|
||||||
|
|
||||||
|
console.log('[FIX-NAV] Authentication state:', {
|
||||||
|
isAuthenticated,
|
||||||
|
hasAuthCookie,
|
||||||
|
hasUidCookie,
|
||||||
|
hasLocalStorageAuth,
|
||||||
|
hasAuthToken
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
// Force hide guest navigation with !important styles
|
||||||
|
const guestNav = document.getElementById('guest-dashboard');
|
||||||
|
if (guestNav) {
|
||||||
|
console.log('[FIX-NAV] Hiding guest navigation');
|
||||||
|
guestNav.style.cssText = `
|
||||||
|
display: none !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
width: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
position: absolute !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
clip: rect(0, 0, 0, 0) !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
`;
|
||||||
|
guestNav.classList.add('force-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure user navigation is visible with !important styles
|
||||||
|
const userNav = document.getElementById('user-dashboard');
|
||||||
|
if (userNav) {
|
||||||
|
console.log('[FIX-NAV] Showing user navigation');
|
||||||
|
userNav.style.cssText = `
|
||||||
|
display: flex !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
height: auto !important;
|
||||||
|
position: relative !important;
|
||||||
|
clip: auto !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
`;
|
||||||
|
userNav.classList.add('force-visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add authenticated class to body
|
||||||
|
document.body.classList.add('authenticated');
|
||||||
|
document.body.classList.remove('guest-mode');
|
||||||
|
|
||||||
|
// Prevent default behavior of nav links that might cause page reloads
|
||||||
|
document.querySelectorAll('a[href^="#"]').forEach(link => {
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const targetId = link.getAttribute('href');
|
||||||
|
if (targetId && targetId !== '#') {
|
||||||
|
// Use history API to update URL without full page reload
|
||||||
|
history.pushState(null, '', targetId);
|
||||||
|
// Dispatch a custom event that other scripts can listen for
|
||||||
|
window.dispatchEvent(new CustomEvent('hashchange'));
|
||||||
|
// Force re-apply our navigation fix
|
||||||
|
setTimeout(fixMobileNavigation, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// User is not authenticated - ensure guest nav is visible
|
||||||
|
const guestNav = document.getElementById('guest-dashboard');
|
||||||
|
if (guestNav) {
|
||||||
|
guestNav.style.cssText = ''; // Reset any inline styles
|
||||||
|
}
|
||||||
|
document.body.classList.remove('authenticated');
|
||||||
|
document.body.classList.add('guest-mode');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', fixMobileNavigation);
|
||||||
|
|
||||||
|
// Also run after a short delay to catch any dynamic content
|
||||||
|
setTimeout(fixMobileNavigation, 100);
|
||||||
|
setTimeout(fixMobileNavigation, 300);
|
||||||
|
setTimeout(fixMobileNavigation, 1000);
|
||||||
|
|
||||||
|
// Listen for hash changes (navigation)
|
||||||
|
window.addEventListener('hashchange', fixMobileNavigation);
|
||||||
|
|
||||||
|
// Listen for pushState/replaceState (SPA navigation)
|
||||||
|
const originalPushState = history.pushState;
|
||||||
|
const originalReplaceState = history.replaceState;
|
||||||
|
|
||||||
|
history.pushState = function() {
|
||||||
|
originalPushState.apply(this, arguments);
|
||||||
|
setTimeout(fixMobileNavigation, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
history.replaceState = function() {
|
||||||
|
originalReplaceState.apply(this, arguments);
|
||||||
|
setTimeout(fixMobileNavigation, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run on any DOM mutations (for dynamically loaded content)
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
let shouldFix = false;
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
if (mutation.addedNodes.length || mutation.removedNodes.length) {
|
||||||
|
shouldFix = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (shouldFix) {
|
||||||
|
setTimeout(fixMobileNavigation, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class', 'style', 'id']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for debugging
|
||||||
|
window.fixMobileNavigation = fixMobileNavigation;
|
12
static/footer.html
Normal file
12
static/footer.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!-- Footer content -->
|
||||||
|
<footer>
|
||||||
|
<p>Built for public voice streaming • Opus | Mono | 48 kHz | 60 kbps</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
13
static/generate-test-audio.sh
Executable 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
|
@ -3,24 +3,26 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<link rel="stylesheet" href="/static/style.css" media="all" />
|
<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>">
|
<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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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." />
|
<meta name="description" content="dicta2stream is a minimalist voice streaming platform for looping your spoken audio anonymously." />
|
||||||
<title>dicta2stream</title>
|
<title>dicta2stream</title>
|
||||||
<!-- Responsive burger menu display -->
|
<!-- Section visibility and navigation styles -->
|
||||||
|
<link rel="stylesheet" href="/static/css/section.css" media="all" />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#burger-label, #burger-toggle { display: none; }
|
/* Hide mobile menu by default on larger screens */
|
||||||
@media (max-width: 959px) {
|
|
||||||
#burger-label { display: block; }
|
|
||||||
section#links { display: none; }
|
|
||||||
#burger-toggle:checked + #burger-label + section#links { display: block; }
|
|
||||||
}
|
|
||||||
@media (min-width: 960px) {
|
@media (min-width: 960px) {
|
||||||
section#links { display: block; }
|
#mobile-menu { display: none !important; }
|
||||||
|
#burger-label { display: none !important; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<link rel="modulepreload" href="/static/sound.js" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
@ -31,43 +33,76 @@
|
|||||||
<main>
|
<main>
|
||||||
|
|
||||||
<!-- Guest Dashboard -->
|
<!-- Guest Dashboard -->
|
||||||
<nav id="guest-dashboard" class="dashboard-nav">
|
<nav id="guest-dashboard" class="dashboard-nav guest-only">
|
||||||
<a href="#" id="guest-welcome" data-target="welcome-page">Welcome</a> |
|
<a href="#welcome-page" id="guest-welcome">Welcome</a>
|
||||||
<a href="#" id="guest-streams" data-target="stream-page">Streams</a> |
|
<a href="#stream-page" id="guest-streams">Streams</a>
|
||||||
<a href="#" id="guest-login" data-target="register-page">Login or Register</a>
|
<a href="#account" id="guest-login">Account</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- User Dashboard -->
|
<!-- User Dashboard -->
|
||||||
<nav id="user-dashboard" class="dashboard-nav" style="display:none;">
|
<nav id="user-dashboard" class="dashboard-nav auth-only">
|
||||||
<a href="#" id="user-welcome" data-target="welcome-page">Welcome</a> |
|
<a href="#welcome-page" id="user-welcome">Welcome</a>
|
||||||
<a href="#" id="user-streams" data-target="stream-page">Streams</a> |
|
<a href="#stream-page" id="user-streams">Streams</a>
|
||||||
<a href="#" id="show-me" data-target="me-page">Your Stream</a>
|
<a href="#me-page" id="show-me">Your Stream</a>
|
||||||
</nav>
|
</nav>
|
||||||
<section id="me-page">
|
<section id="me-page" class="auth-only">
|
||||||
|
<div>
|
||||||
|
<h2>Your Stream</h2>
|
||||||
|
</div>
|
||||||
<article>
|
<article>
|
||||||
<h2>Your Stream 🎙️</h2>
|
|
||||||
<p>This is your personal stream. Only you can upload to it.</p>
|
<p>This is your personal stream. Only you can upload to it.</p>
|
||||||
<audio id="me-audio"></audio>
|
<audio id="me-audio"></audio>
|
||||||
<div class="audio-controls">
|
<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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<section id="user-upload-area" class="dropzone">
|
<section id="user-upload-area" class="auth-only">
|
||||||
<p>🎙 Drag & drop your audio file here<br>or click to browse</p>
|
<p>Drag & drop your audio file here<br>or click to browse</p>
|
||||||
<input type="file" id="fileInputUser" accept="audio/*" hidden />
|
<input type="file" id="fileInputUser" accept="audio/*" hidden />
|
||||||
</section>
|
</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>
|
||||||
|
|
||||||
|
<!-- Account Deletion Section -->
|
||||||
|
<section id="account-deletion" class="article--bordered auth-only">
|
||||||
|
<h3>Account Deletion</h3>
|
||||||
|
<p>This action is irreversible and will permanently remove:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Your account information</li>
|
||||||
|
<li>All uploaded audio files</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="centered-container">
|
||||||
|
<button id="delete-account-from-privacy" class="button">
|
||||||
|
🗑️ Delete My Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div id="spinner" class="spinner"></div>
|
<div id="spinner" class="spinner"></div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Burger menu and legacy links section removed for clarity -->
|
<!-- Burger menu and legacy links section removed for clarity -->
|
||||||
|
|
||||||
<section id="terms-page" hidden>
|
<section id="terms-page" class="always-visible">
|
||||||
<article>
|
|
||||||
<h2>Terms of Service</h2>
|
<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">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<strong>Beta Testing Notice:</strong> This service is currently in public beta. As such, you may encounter bugs or unexpected behavior.
|
||||||
|
Updates to the service may cause data loss. Please report any issues or suggestions to help us improve.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
<ul>
|
||||||
<li>You must be at least 18 years old to register.</li>
|
<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>
|
<li>Each account must be unique and used by only one person.</li>
|
||||||
@ -76,38 +111,40 @@
|
|||||||
<li>The associated email address will be banned from recreating an account.</li>
|
<li>The associated email address will be banned from recreating an account.</li>
|
||||||
<li>Uploads are limited to 100 MB and must be voice only.</li>
|
<li>Uploads are limited to 100 MB and must be voice only.</li>
|
||||||
<li>Music/singing will be rejected.</li>
|
<li>Music/singing will be rejected.</li>
|
||||||
|
<li>This is a beta service; data may be lost during updates or maintenance.</li>
|
||||||
|
<li>Please report any bugs or suggestions to help improve the service.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="privacy-page" hidden>
|
<section id="privacy-page" class="always-visible">
|
||||||
<article>
|
<div>
|
||||||
<h2>Privacy Policy</h2>
|
<h2>Privacy Policy</h2>
|
||||||
|
</div>
|
||||||
|
<article class="article--bordered">
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Users</strong>: Session uses both cookies and localStorage to store UID and authentication state.</li>
|
<li><strong>Users</strong>: Session uses both cookies and localStorage to store UID and authentication state.</li>
|
||||||
<li><strong>Guests</strong>: No cookies are set. No persistent identifiers are stored.</li>
|
<li><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>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.</li>
|
||||||
<li>Data is never sold. Contact us for account deletion.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<!-- Guest login message removed as per user request -->
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="imprint-page" hidden>
|
<section id="imprint-page" class="always-visible">
|
||||||
<article>
|
|
||||||
<h2>Imprint</h2>
|
<h2>Imprint</h2>
|
||||||
|
<article class="article--bordered">
|
||||||
<p><strong>Andreas Michael Fleckl</strong></p>
|
<p><strong>Andreas Michael Fleckl</strong></p>
|
||||||
<p>Johnstrassse 7/6<br>1140 Vienna<br>Austria / Europe</p>
|
<p>Johnstrassse 7/6<br>1140 Vienna<br>Austria / Europe</p>
|
||||||
|
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="welcome-page">
|
<section id="welcome-page" class="always-visible">
|
||||||
<article>
|
|
||||||
<h2>Welcome</h2>
|
<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 | 48 kHz | 60 kbps)</span><br><br>
|
||||||
<strong>What you can do here:</strong></p>
|
<strong>What you can do here:</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>🎧 Listen to public voice streams from others, instantly</li>
|
<li>🎧 Listen to public voice streams from others, instantly</li>
|
||||||
@ -115,50 +152,43 @@
|
|||||||
<li>🕵️ No sign-up required for listening</li>
|
<li>🕵️ No sign-up required for listening</li>
|
||||||
<li>🔒 Optional registration for uploading and managing your own stream</li>
|
<li>🔒 Optional registration for uploading and managing your own stream</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<div class="email-section">
|
||||||
|
<a href="mailto:Andreas.Fleckl@dicta2stream.net" class="button">
|
||||||
|
Andreas.Fleckl@dicta2stream.net
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
<section id="stream-page" hidden>
|
<section id="stream-page" class="always-visible">
|
||||||
<article>
|
<h2>Public Streams</h2>
|
||||||
<h2>🎧 Public Streams</h2>
|
|
||||||
<!-- The list below is dynamically populated by streams-ui.js; shows 'Loading...' while fetching -->
|
<!-- The list below is dynamically populated by streams-ui.js; shows 'Loading...' while fetching -->
|
||||||
<ul id="stream-list"><li>Loading...</li></ul>
|
<ul id="stream-list"><li>Loading...</li></ul>
|
||||||
</article>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="register-page" hidden>
|
<section id="register-page" class="guest-only">
|
||||||
<article>
|
<h2>Account</h2>
|
||||||
<h2>Login or Register</h2>
|
<article class="article--wide">
|
||||||
<form id="register-form">
|
<form id="register-form">
|
||||||
<p><label>Email<br><input type="email" name="email" required /></label></p>
|
<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><label>Username<br><input type="text" name="user" required /></label></p>
|
||||||
<p style="display: none;">
|
<p class="bot-trap">
|
||||||
<label>Leave this empty:<br>
|
<label>Leave this empty:<br>
|
||||||
<input type="text" name="bot_trap" autocomplete="off" />
|
<input type="text" name="bot_trap" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
</p>
|
</p>
|
||||||
<p><button type="submit">Create Account</button></p>
|
<p><button type="submit">Login / Create Account</button></p>
|
||||||
</form>
|
</form>
|
||||||
<p><small>You’ll receive a magic login link via email. No password required.</small></p>
|
<p class="form-note">You'll receive a magic login link via email. No password required.</p>
|
||||||
<p style="font-size: 0.85em; opacity: 0.65; margin-top: 1em;">Your session expires after 1 hour. Shareable links redirect to homepage.</p>
|
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p>Built for public voice streaming • Opus | Mono | 48 kHz | 60 kbps</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">
|
<p class="footer-links">
|
||||||
<a href="#" id="footer-terms" data-target="terms-page">Terms of Service</a> |
|
<a href="#" id="footer-terms" data-target="terms-page">Terms</a> |
|
||||||
<a href="#" id="footer-privacy" data-target="privacy-page">Privacy Policy</a> |
|
<a href="#" id="footer-privacy" data-target="privacy-page">Privacy</a> |
|
||||||
<a href="#" id="footer-imprint" data-target="imprint-page">Imprint</a>
|
<a href="#" id="footer-imprint" data-target="imprint-page">Imprint</a>
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
@ -189,5 +219,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script type="module" src="/static/init-personal-stream.js"></script>
|
||||||
|
<!-- Temporary fix for mobile navigation -->
|
||||||
|
<script src="/static/fix-nav.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
38
static/init-personal-stream.js
Normal file
38
static/init-personal-stream.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
// 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');
|
||||||
|
const streamPlayer = document.querySelector('#me-page .stream-player');
|
||||||
|
|
||||||
|
if (!playButton || !streamPlayer) return;
|
||||||
|
|
||||||
|
// Get UID from localStorage or cookie
|
||||||
|
const uid = localStorage.getItem('uid') || getCookie('uid');
|
||||||
|
|
||||||
|
if (uid) {
|
||||||
|
// Show the player and set the UID if not already set
|
||||||
|
streamPlayer.style.display = 'block';
|
||||||
|
if (!playButton.dataset.uid) {
|
||||||
|
playButton.dataset.uid = uid;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Hide the player for guests
|
||||||
|
streamPlayer.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
184
static/inject-nav.js
Normal 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;
|
@ -27,10 +27,17 @@ export async function initMagicLogin() {
|
|||||||
const url = new URL(res.url);
|
const url = new URL(res.url);
|
||||||
const confirmedUid = url.searchParams.get('confirmed_uid');
|
const confirmedUid = url.searchParams.get('confirmed_uid');
|
||||||
if (confirmedUid) {
|
if (confirmedUid) {
|
||||||
document.cookie = "uid=" + encodeURIComponent(confirmedUid) + "; path=/";
|
// Generate a simple auth token (in a real app, this would come from the server)
|
||||||
// Set localStorage for SPA session logic instantly
|
const authToken = 'token-' + Math.random().toString(36).substring(2, 15);
|
||||||
|
|
||||||
|
// Set cookies and localStorage for SPA session logic
|
||||||
|
document.cookie = `uid=${encodeURIComponent(confirmedUid)}; path=/; SameSite=Lax`;
|
||||||
|
document.cookie = `authToken=${authToken}; path=/; SameSite=Lax; Secure`;
|
||||||
|
|
||||||
|
// Store in localStorage for client-side access
|
||||||
localStorage.setItem('uid', confirmedUid);
|
localStorage.setItem('uid', confirmedUid);
|
||||||
localStorage.setItem('confirmed_uid', confirmedUid);
|
localStorage.setItem('confirmed_uid', confirmedUid);
|
||||||
|
localStorage.setItem('authToken', authToken);
|
||||||
localStorage.setItem('uid_time', Date.now().toString());
|
localStorage.setItem('uid_time', Date.now().toString());
|
||||||
}
|
}
|
||||||
window.location.href = res.url;
|
window.location.href = res.url;
|
||||||
@ -42,14 +49,34 @@ export async function initMagicLogin() {
|
|||||||
if (contentType && contentType.includes('application/json')) {
|
if (contentType && contentType.includes('application/json')) {
|
||||||
data = await res.json();
|
data = await res.json();
|
||||||
if (data && data.confirmed_uid) {
|
if (data && data.confirmed_uid) {
|
||||||
document.cookie = "uid=" + encodeURIComponent(data.confirmed_uid) + "; path=/";
|
// Generate a simple auth token (in a real app, this would come from the server)
|
||||||
// Set localStorage for SPA session logic
|
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=/; SameSite=Lax`;
|
||||||
|
document.cookie = `authToken=${authToken}; path=/; SameSite=Lax; Secure`;
|
||||||
|
|
||||||
|
// Store in localStorage for client-side access
|
||||||
localStorage.setItem('uid', data.confirmed_uid);
|
localStorage.setItem('uid', data.confirmed_uid);
|
||||||
localStorage.setItem('confirmed_uid', data.confirmed_uid);
|
localStorage.setItem('confirmed_uid', data.confirmed_uid);
|
||||||
|
localStorage.setItem('authToken', authToken);
|
||||||
localStorage.setItem('uid_time', Date.now().toString());
|
localStorage.setItem('uid_time', Date.now().toString());
|
||||||
import('./toast.js').then(({ showToast }) => showToast('✅ Login successful!'));
|
import('./toast.js').then(({ showToast }) => {
|
||||||
// Optionally reload or navigate
|
showToast('✅ Login successful!');
|
||||||
setTimeout(() => location.reload(), 700);
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
alert(data.detail || 'Login failed.');
|
alert(data.detail || 'Login failed.');
|
||||||
|
522
static/mobile.css
Normal file
522
static/mobile.css
Normal file
@ -0,0 +1,522 @@
|
|||||||
|
/* 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 - Enhanced with more specific selectors */
|
||||||
|
/* Show user dashboard only when authenticated */
|
||||||
|
body.authenticated #user-dashboard.dashboard-nav,
|
||||||
|
html body.authenticated #user-dashboard.dashboard-nav,
|
||||||
|
body.authenticated #user-dashboard.dashboard-nav:not(.hidden) {
|
||||||
|
display: flex !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
height: auto !important;
|
||||||
|
position: relative !important;
|
||||||
|
clip: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide guest dashboard when authenticated - with more specific selectors */
|
||||||
|
body.authenticated #guest-dashboard.dashboard-nav,
|
||||||
|
html body.authenticated #guest-dashboard.dashboard-nav,
|
||||||
|
body.authenticated #guest-dashboard.dashboard-nav:not(.visible) {
|
||||||
|
display: none !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
width: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
position: absolute !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
clip: rect(0, 0, 0, 0) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show guest dashboard when not authenticated - with more specific selectors */
|
||||||
|
body:not(.authenticated) #guest-dashboard.dashboard-nav,
|
||||||
|
html body:not(.authenticated) #guest-dashboard.dashboard-nav,
|
||||||
|
body:not(.authenticated) #guest-dashboard.dashboard-nav:not(.hidden) {
|
||||||
|
display: flex !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
height: auto !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure user dashboard is hidden when not authenticated */
|
||||||
|
body:not(.authenticated) #user-dashboard.dashboard-nav {
|
||||||
|
display: none !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
height: 0 !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;
|
||||||
|
}
|
||||||
|
}
|
275
static/nav.js
275
static/nav.js
@ -8,31 +8,267 @@ function getCookie(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
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 = {
|
const Router = {
|
||||||
sections: Array.from(document.querySelectorAll("main > section")),
|
sections: Array.from(document.querySelectorAll("main > section")),
|
||||||
|
|
||||||
showOnly(id) {
|
showOnly(id) {
|
||||||
this.sections.forEach(sec => {
|
// Validate the section ID
|
||||||
sec.hidden = sec.id !== id;
|
const validId = getValidSection(id);
|
||||||
sec.tabIndex = -1;
|
|
||||||
|
// 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
|
// Show user-upload-area only when me-page is shown and user is logged in
|
||||||
const userUpload = document.getElementById("user-upload-area");
|
const userUpload = document.getElementById("user-upload-area");
|
||||||
if (userUpload) {
|
if (userUpload) {
|
||||||
const uid = getCookie("uid");
|
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);
|
// Store the current page
|
||||||
if (target) target.focus();
|
localStorage.setItem("last_page", window.location.hash.substring(1));
|
||||||
},
|
|
||||||
init() {
|
// Initialize navigation
|
||||||
initNavLinks();
|
initNavLinks();
|
||||||
initBackButtons();
|
initBackButtons();
|
||||||
|
|
||||||
initStreamLinks();
|
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
|
// Highlight active profile link on browser back/forward navigation
|
||||||
function highlightActiveProfileLink() {
|
function highlightActiveProfileLink() {
|
||||||
@ -49,6 +285,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
window.addEventListener('popstate', () => {
|
window.addEventListener('popstate', () => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const profileUid = params.get('profile');
|
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) {
|
if (profileUid) {
|
||||||
showOnly('me-page');
|
showOnly('me-page');
|
||||||
if (typeof window.showProfilePlayerFromUrl === 'function') {
|
if (typeof window.showProfilePlayerFromUrl === 'function') {
|
||||||
@ -196,5 +441,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Router
|
// 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();
|
Router.init();
|
||||||
});
|
});
|
||||||
|
165
static/router.js
165
static/router.js
@ -1,15 +1,168 @@
|
|||||||
// static/router.js — core routing for SPA navigation
|
// static/router.js — core routing for SPA navigation
|
||||||
export const Router = {
|
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) {
|
showOnly(id) {
|
||||||
this.sections.forEach(sec => {
|
if (!id) return;
|
||||||
sec.hidden = sec.id !== id;
|
|
||||||
sec.tabIndex = -1;
|
// 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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
} 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);
|
localStorage.setItem("last_page", id);
|
||||||
const target = document.getElementById(id);
|
|
||||||
if (target) target.focus();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initialize router when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
Router.init();
|
||||||
|
});
|
||||||
|
|
||||||
export const showOnly = Router.showOnly.bind(Router);
|
export const showOnly = Router.showOnly.bind(Router);
|
||||||
|
@ -1,8 +1,20 @@
|
|||||||
// static/streams-ui.js — public streams loader and profile-link handling
|
// static/streams-ui.js — public streams loader and profile-link handling
|
||||||
import { showOnly } from './router.js';
|
import { showOnly } from './router.js';
|
||||||
|
|
||||||
// Removed loadingStreams and lastStreamsPageVisible guards for instant fetch
|
// Global variable to track if we should force refresh the stream list
|
||||||
|
let shouldForceRefresh = false;
|
||||||
|
|
||||||
|
// Function to refresh the stream list
|
||||||
|
window.refreshStreamList = function(force = true) {
|
||||||
|
shouldForceRefresh = force;
|
||||||
|
loadAndRenderStreams();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// Resolve after a short delay to allow the stream list to update
|
||||||
|
setTimeout(resolve, 500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Removed loadingStreams and lastStreamsPageVisible guards for instant fetch
|
||||||
|
|
||||||
export function initStreamsUI() {
|
export function initStreamsUI() {
|
||||||
initStreamLinks();
|
initStreamLinks();
|
||||||
@ -24,154 +36,353 @@ function maybeLoadStreamsOnShow() {
|
|||||||
}
|
}
|
||||||
window.maybeLoadStreamsOnShow = maybeLoadStreamsOnShow;
|
window.maybeLoadStreamsOnShow = maybeLoadStreamsOnShow;
|
||||||
|
|
||||||
|
// Global variables for audio control
|
||||||
let currentlyPlayingAudio = null;
|
let currentlyPlayingAudio = null;
|
||||||
let currentlyPlayingButton = null;
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initStreamsUI);
|
// Global variable to track the active SSE connection
|
||||||
|
let activeSSEConnection = null;
|
||||||
|
|
||||||
|
// Global cleanup function for SSE connections
|
||||||
|
const cleanupConnections = () => {
|
||||||
|
if (window._streamsSSE) {
|
||||||
|
if (window._streamsSSE.abort) {
|
||||||
|
window._streamsSSE.abort();
|
||||||
|
}
|
||||||
|
window._streamsSSE = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.connectionTimeout) {
|
||||||
|
clearTimeout(window.connectionTimeout);
|
||||||
|
window.connectionTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeSSEConnection = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initStreamsUI();
|
||||||
|
|
||||||
|
// Also try to load streams immediately in case the page is already loaded
|
||||||
|
setTimeout(() => {
|
||||||
|
loadAndRenderStreams();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
function loadAndRenderStreams() {
|
function loadAndRenderStreams() {
|
||||||
const ul = document.getElementById('stream-list');
|
const ul = document.getElementById('stream-list');
|
||||||
if (!ul) {
|
if (!ul) {
|
||||||
console.warn('[streams-ui] #stream-list not found in DOM');
|
console.error('[STREAMS-UI] Stream list element not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.debug('[streams-ui] loadAndRenderStreams (SSE mode) called');
|
console.log('[STREAMS-UI] loadAndRenderStreams called, shouldForceRefresh:', shouldForceRefresh);
|
||||||
|
|
||||||
|
// Don't start a new connection if one is already active and we're not forcing a refresh
|
||||||
|
if (activeSSEConnection && !shouldForceRefresh) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're forcing a refresh, clean up the existing connection
|
||||||
|
if (shouldForceRefresh && activeSSEConnection) {
|
||||||
|
// Clean up any existing connections
|
||||||
|
cleanupConnections();
|
||||||
|
shouldForceRefresh = false; // Reset the flag after handling
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}`;
|
||||||
|
|
||||||
ul.innerHTML = '<li>Loading...</li>';
|
|
||||||
let gotAny = false;
|
let gotAny = false;
|
||||||
let streams = [];
|
let streams = [];
|
||||||
// Close previous EventSource if any
|
window.connectionTimeout = null;
|
||||||
if (window._streamsSSE) {
|
|
||||||
window._streamsSSE.close();
|
|
||||||
}
|
|
||||||
const evtSource = new window.EventSource('/streams-sse');
|
|
||||||
window._streamsSSE = evtSource;
|
|
||||||
|
|
||||||
evtSource.onmessage = function(event) {
|
// Clean up any existing connections
|
||||||
console.debug('[streams-ui] SSE event received:', event.data);
|
cleanupConnections();
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
// Reset the retry count if we have a successful connection
|
||||||
if (data.end) {
|
window.streamRetryCount = 0;
|
||||||
if (!gotAny) {
|
|
||||||
ul.innerHTML = '<li>No active streams.</li>';
|
if (window.connectionTimeout) {
|
||||||
|
clearTimeout(window.connectionTimeout);
|
||||||
|
window.connectionTimeout = null;
|
||||||
}
|
}
|
||||||
evtSource.close();
|
|
||||||
|
// Use fetch with ReadableStream for better CORS handling
|
||||||
|
const controller = new AbortController();
|
||||||
|
const signal = controller.signal;
|
||||||
|
|
||||||
|
// Store the controller for cleanup
|
||||||
|
window._streamsSSE = controller;
|
||||||
|
|
||||||
|
// Track the active connection
|
||||||
|
activeSSEConnection = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 15000); // 15 second timeout (increased from 10s)
|
||||||
|
|
||||||
|
// Store the timeout ID for cleanup
|
||||||
|
window.connectionTimeout = connectionTimeoutId;
|
||||||
|
|
||||||
|
// 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 => {
|
||||||
|
if (!response.ok) {
|
||||||
|
// Try to get the response text for error details
|
||||||
|
return response.text().then(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(() => {
|
||||||
|
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) {
|
||||||
|
throw new Error('Response body is null or undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the readable stream
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
// Process the stream
|
||||||
|
function processStream({ done, value }) {
|
||||||
|
console.log('[STREAMS-UI] processStream called with done:', done);
|
||||||
|
if (done) {
|
||||||
|
console.log('[STREAMS-UI] Stream processing complete');
|
||||||
|
// Process any remaining data in the buffer
|
||||||
|
if (buffer.trim()) {
|
||||||
|
console.log('[STREAMS-UI] Processing remaining buffer data');
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(buffer);
|
||||||
|
console.log('[STREAMS-UI] Parsed data from buffer:', data);
|
||||||
|
processSSEEvent(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[STREAMS-UI] Error parsing buffer 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 abort error
|
||||||
|
if (error.name !== 'AbortError') {
|
||||||
|
// Clean up the controller reference
|
||||||
|
window._streamsSSE = null;
|
||||||
|
activeSSEConnection = null;
|
||||||
|
|
||||||
|
// Clear the connection timeout
|
||||||
|
if (connectionTimeout) {
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
|
connectionTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a user-friendly error message
|
||||||
|
const ul = document.getElementById('stream-list');
|
||||||
|
if (ul) {
|
||||||
|
let errorMessage = 'Error loading streams. ';
|
||||||
|
|
||||||
|
if (error.message && error.message.includes('Failed to fetch')) {
|
||||||
|
errorMessage += 'Unable to connect to the server. Please check your internet connection.';
|
||||||
|
} else if (error.message && 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] Processing SSE event:', data);
|
||||||
|
if (data.end) {
|
||||||
|
if (streams.length === 0) {
|
||||||
|
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));
|
||||||
|
|
||||||
|
// 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, '/') : '';
|
||||||
|
|
||||||
|
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);
|
||||||
|
} catch (error) {
|
||||||
|
const errorLi = document.createElement('li');
|
||||||
|
errorLi.textContent = `Error loading stream: ${uid}`;
|
||||||
|
errorLi.style.color = 'var(--error)';
|
||||||
|
ul.appendChild(errorLi);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
highlightActiveProfileLink();
|
highlightActiveProfileLink();
|
||||||
return;
|
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) {
|
if (!gotAny) {
|
||||||
ul.innerHTML = '';
|
ul.innerHTML = '';
|
||||||
gotAny = true;
|
gotAny = true;
|
||||||
}
|
}
|
||||||
streams.push(data);
|
}
|
||||||
const uid = data.uid || '';
|
|
||||||
const sizeMb = data.size ? (data.size / (1024 * 1024)).toFixed(1) : '?';
|
// Function to handle SSE errors
|
||||||
const mtime = data.mtime ? new Date(data.mtime * 1000).toISOString().split('T')[0].replace(/-/g, '/') : '';
|
function handleSSEError(error) {
|
||||||
const li = document.createElement('li');
|
console.error('[streams-ui] SSE error:', error);
|
||||||
li.innerHTML = `
|
|
||||||
<article class="stream-player">
|
// Only show error if we haven't already loaded any streams
|
||||||
<h3>${uid}</h3>
|
if (streams.length === 0) {
|
||||||
<audio id="audio-${uid}" class="stream-audio" preload="auto" crossOrigin="anonymous" src="/audio/${encodeURIComponent(uid)}/stream.opus"></audio>
|
const errorMsg = 'Error connecting to stream server. Please try again.';
|
||||||
<div class="audio-controls">
|
|
||||||
<button id="play-pause-${uid}">▶</button>
|
ul.innerHTML = `
|
||||||
</div>
|
<li>${errorMsg}</li>
|
||||||
<p class="stream-info" style='color:gray;font-size:90%'>[${sizeMb} MB, ${mtime}]</p>
|
<li><button id="reload-streams" onclick="loadAndRenderStreams()" class="retry-button">🔄 Retry</button></li>
|
||||||
</article>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Add play/pause handler after appending to DOM
|
|
||||||
ul.appendChild(li);
|
|
||||||
|
|
||||||
// Wait for DOM update
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const playPauseButton = document.getElementById(`play-pause-${uid}`);
|
|
||||||
const audio = document.getElementById(`audio-${uid}`);
|
|
||||||
|
|
||||||
if (playPauseButton && audio) {
|
|
||||||
playPauseButton.addEventListener('click', () => {
|
|
||||||
try {
|
|
||||||
if (audio.paused) {
|
|
||||||
// Stop any currently playing audio first
|
|
||||||
if (currentlyPlayingAudio && currentlyPlayingAudio !== audio) {
|
|
||||||
currentlyPlayingAudio.pause();
|
|
||||||
if (currentlyPlayingButton) {
|
|
||||||
currentlyPlayingButton.textContent = '▶';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop the main player if it's playing
|
|
||||||
if (typeof window.stopMainAudio === 'function') {
|
|
||||||
window.stopMainAudio();
|
|
||||||
}
|
|
||||||
|
|
||||||
audio.play().then(() => {
|
|
||||||
playPauseButton.textContent = '⏸️';
|
|
||||||
currentlyPlayingAudio = audio;
|
|
||||||
currentlyPlayingButton = playPauseButton;
|
|
||||||
}).catch(e => {
|
|
||||||
console.error('Play failed:', e);
|
|
||||||
// Reset button if play fails
|
|
||||||
playPauseButton.textContent = '▶';
|
|
||||||
currentlyPlayingAudio = null;
|
|
||||||
currentlyPlayingButton = null;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
audio.pause();
|
|
||||||
playPauseButton.textContent = '▶';
|
|
||||||
if (currentlyPlayingAudio === audio) {
|
|
||||||
currentlyPlayingAudio = null;
|
|
||||||
currentlyPlayingButton = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Audio error:', e);
|
|
||||||
playPauseButton.textContent = '▶';
|
|
||||||
if (currentlyPlayingAudio === audio) {
|
|
||||||
currentlyPlayingAudio = null;
|
|
||||||
currentlyPlayingButton = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
highlightActiveProfileLink();
|
|
||||||
ul.appendChild(li);
|
|
||||||
highlightActiveProfileLink();
|
|
||||||
} catch (e) {
|
|
||||||
// Remove Loading... even if JSON parse fails, to avoid stuck UI
|
|
||||||
if (!gotAny) {
|
|
||||||
ul.innerHTML = '';
|
|
||||||
gotAny = true;
|
|
||||||
}
|
|
||||||
console.error('[streams-ui] SSE parse error', e, event.data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
evtSource.onerror = function(err) {
|
|
||||||
console.error('[streams-ui] SSE error', err);
|
|
||||||
ul.innerHTML = '<li>Error loading stream list</li>';
|
|
||||||
if (typeof showToast === 'function') {
|
if (typeof showToast === 'function') {
|
||||||
showToast('❌ Error loading public streams.');
|
showToast('❌ ' + errorMsg);
|
||||||
}
|
}
|
||||||
evtSource.close();
|
|
||||||
// Add reload button if not present
|
// Auto-retry after 5 seconds
|
||||||
const reloadButton = document.getElementById('reload-streams');
|
setTimeout(() => {
|
||||||
if (!reloadButton) {
|
loadAndRenderStreams();
|
||||||
const reloadHtml = '<button id="reload-streams" onclick="loadAndRenderStreams()">Reload</button>';
|
}, 5000);
|
||||||
ul.insertAdjacentHTML('beforeend', reloadHtml);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
// 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) {
|
export function renderStreamList(streams) {
|
||||||
const ul = document.getElementById('stream-list');
|
const ul = document.getElementById('stream-list');
|
||||||
if (!ul) {
|
if (!ul) {
|
||||||
console.warn('[streams-ui] renderStreamList: #stream-list not found');
|
console.warn('[STREAMS-UI] renderStreamList: #stream-list not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.debug('[streams-ui] Rendering stream list:', streams);
|
console.log('[STREAMS-UI] Rendering stream list with', streams.length, 'streams');
|
||||||
|
console.debug('[STREAMS-UI] Streams data:', streams);
|
||||||
if (Array.isArray(streams)) {
|
if (Array.isArray(streams)) {
|
||||||
if (streams.length) {
|
if (streams.length) {
|
||||||
// Sort by mtime descending (most recent first)
|
// Sort by mtime descending (most recent first)
|
||||||
@ -181,7 +392,7 @@ export function renderStreamList(streams) {
|
|||||||
const uid = stream.uid || '';
|
const uid = stream.uid || '';
|
||||||
const sizeKb = stream.size ? (stream.size / 1024).toFixed(1) : '?';
|
const sizeKb = stream.size ? (stream.size / 1024).toFixed(1) : '?';
|
||||||
const mtime = stream.mtime ? new Date(stream.mtime * 1000).toLocaleString() : '';
|
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('');
|
.join('');
|
||||||
} else {
|
} else {
|
||||||
@ -208,7 +419,6 @@ export function highlightActiveProfileLink() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function initStreamLinks() {
|
export function initStreamLinks() {
|
||||||
const ul = document.getElementById('stream-list');
|
const ul = document.getElementById('stream-list');
|
||||||
if (!ul) return;
|
if (!ul) return;
|
||||||
@ -232,3 +442,368 @@ export function initStreamLinks() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to safely escape HTML
|
||||||
|
function escapeHtml(unsafe) {
|
||||||
|
if (typeof unsafe !== 'string') return '';
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
// If we already have an audio element for this UID and it's paused, just resume it
|
||||||
|
if (audioElement && currentUid === uid && audioElement.paused) {
|
||||||
|
try {
|
||||||
|
await audioElement.play();
|
||||||
|
isPlaying = true;
|
||||||
|
updatePlayPauseButton(playPauseBtn, true);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
// Fall through to reload if resume fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop any current playback
|
||||||
|
stopPlayback();
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
updatePlayPauseButton(playPauseBtn, true);
|
||||||
|
currentlyPlayingButton = playPauseBtn;
|
||||||
|
currentUid = uid;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a new audio element with the correct MIME type
|
||||||
|
const audioUrl = `/audio/${encodeURIComponent(uid)}/stream.opus`;
|
||||||
|
|
||||||
|
// 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 = () => {
|
||||||
|
isPlaying = true;
|
||||||
|
updatePlayPauseButton(playPauseBtn, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPause = () => {
|
||||||
|
isPlaying = false;
|
||||||
|
updatePlayPauseButton(playPauseBtn, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEnded = () => {
|
||||||
|
isPlaying = false;
|
||||||
|
cleanupAudio();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (e) => {
|
||||||
|
// Ignore errors from previous audio elements that were cleaned up
|
||||||
|
if (!audioElement || audioElement.readyState === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaying = false;
|
||||||
|
updatePlayPauseButton(playPauseBtn, false);
|
||||||
|
|
||||||
|
// Don't show error to user for aborted requests
|
||||||
|
if (audioElement.error && audioElement.error.code === MediaError.MEDIA_ERR_ABORTED) {
|
||||||
|
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
|
||||||
|
try {
|
||||||
|
const playPromise = audioElement.play();
|
||||||
|
|
||||||
|
if (playPromise !== undefined) {
|
||||||
|
await playPromise.catch(error => {
|
||||||
|
// Ignore abort errors when switching between streams
|
||||||
|
if (error.name !== 'AbortError') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If clicking the currently playing button, toggle pause/play
|
||||||
|
if (currentUid === uid) {
|
||||||
|
if (isPlaying) {
|
||||||
|
await audioElement.pause();
|
||||||
|
isPlaying = false;
|
||||||
|
updatePlayPauseButton(playPauseBtn, false);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await audioElement.play();
|
||||||
|
isPlaying = true;
|
||||||
|
updatePlayPauseButton(playPauseBtn, true);
|
||||||
|
} catch (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
|
||||||
|
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);
|
||||||
|
1485
static/style.css
1485
static/style.css
File diff suppressed because it is too large
Load Diff
240
static/test-audio-player.html
Normal file
240
static/test-audio-player.html
Normal 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
210
static/test-audio.html
Normal 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
BIN
static/test-audio.opus
Normal file
Binary file not shown.
182
static/upload.js
182
static/upload.js
@ -19,7 +19,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
const streamInfo = document.getElementById("stream-info");
|
const streamInfo = document.getElementById("stream-info");
|
||||||
const streamUrlEl = document.getElementById("streamUrl");
|
const streamUrlEl = document.getElementById("streamUrl");
|
||||||
const spinner = document.getElementById("spinner");
|
const spinner = document.getElementById("spinner") || { style: { display: 'none' } };
|
||||||
let abortController;
|
let abortController;
|
||||||
|
|
||||||
// Upload function
|
// Upload function
|
||||||
@ -78,10 +78,31 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
dropzone.classList.remove("uploading");
|
dropzone.classList.remove("uploading");
|
||||||
showToast("✅ Upload successful.");
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the stream list to update the last update time
|
||||||
|
if (window.refreshStreamList) {
|
||||||
|
await window.refreshStreamList();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to refresh:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
playBeep(432, 0.25, "sine");
|
playBeep(432, 0.25, "sine");
|
||||||
} else {
|
} else {
|
||||||
streamInfo.hidden = true;
|
if (streamInfo) streamInfo.hidden = true;
|
||||||
spinner.style.display = "none";
|
if (spinner) spinner.style.display = "none";
|
||||||
if ((data.detail || data.error || "").includes("music")) {
|
if ((data.detail || data.error || "").includes("music")) {
|
||||||
showToast("🎵 Upload rejected: singing or music detected.");
|
showToast("🎵 Upload rejected: singing or music detected.");
|
||||||
} else {
|
} else {
|
||||||
@ -95,8 +116,161 @@ 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="${isRenamed ? `Stored as: ${file.name}` : displayName}">
|
||||||
|
${displayName}
|
||||||
|
${isRenamed ? `<div class="stored-as"><button class="delete-file" data-filename="${file.name}" data-original-name="${file.original_name}" title="Delete file">🗑️</button></div>` :
|
||||||
|
`<button class="delete-file" data-filename="${file.name}" data-original-name="${file.original_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>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete button handling is now managed by dashboard.js
|
||||||
|
|
||||||
|
// 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.upload = upload;
|
||||||
|
window.fetchAndDisplayFiles = fetchAndDisplayFiles;
|
||||||
|
|
||||||
if (dropzone && fileInput) {
|
if (dropzone && fileInput) {
|
||||||
dropzone.addEventListener("click", () => {
|
dropzone.addEventListener("click", () => {
|
||||||
|
215
upload.py
215
upload.py
@ -5,69 +5,83 @@ from slowapi import Limiter
|
|||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
from convert_to_opus import convert_to_opus
|
from convert_to_opus import convert_to_opus
|
||||||
|
from models import UploadLog, UserQuota, User, PublicStream
|
||||||
|
from sqlalchemy import select, or_
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import UploadLog, UserQuota, User
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
limiter = Limiter(key_func=get_remote_address)
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
# # Not needed for SlowAPI ≥0.1.5
|
# # Not needed for SlowAPI ≥0.1.5
|
||||||
DATA_ROOT = Path("./data")
|
DATA_ROOT = Path("./data")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("5/minute")
|
||||||
@router.post("/upload")
|
@router.post("/upload")
|
||||||
async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), file: UploadFile = Form(...)):
|
async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), file: UploadFile = Form(...)):
|
||||||
from log import log_violation
|
from log import log_violation
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Generate a unique request ID for this upload
|
||||||
|
request_id = str(int(time.time()))
|
||||||
|
log_violation("UPLOAD", request.client.host, uid, f"[{request_id}] Starting upload of {file.filename}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# First, verify the user exists and is confirmed
|
||||||
|
user = db.exec(select(User).where((User.username == uid) | (User.email == uid))).first()
|
||||||
|
if user is not None and not isinstance(user, User) and hasattr(user, "__getitem__"):
|
||||||
|
user = user[0]
|
||||||
|
|
||||||
|
log_violation("UPLOAD", request.client.host, uid, f"[{request_id}] User check - found: {user is not None}, confirmed: {getattr(user, 'confirmed', False) if user else 'N/A'}")
|
||||||
|
|
||||||
|
if not user or not hasattr(user, "confirmed") or not user.confirmed:
|
||||||
|
raise HTTPException(status_code=403, detail="Account not confirmed")
|
||||||
|
|
||||||
|
# Check quota before doing any file operations
|
||||||
|
quota = db.get(UserQuota, uid) or UserQuota(uid=uid, storage_bytes=0)
|
||||||
|
if quota.storage_bytes >= 100 * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=400, detail="Quota exceeded")
|
||||||
|
|
||||||
|
# Create user directory if it doesn't exist
|
||||||
user_dir = DATA_ROOT / uid
|
user_dir = DATA_ROOT / uid
|
||||||
user_dir.mkdir(parents=True, exist_ok=True)
|
user_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
raw_path = user_dir / ("raw." + file.filename.split(".")[-1])
|
# Generate a unique filename for the processed file first
|
||||||
import uuid
|
import uuid
|
||||||
|
unique_name = f"{uuid.uuid4()}.opus"
|
||||||
unique_name = str(uuid.uuid4()) + ".opus"
|
raw_ext = file.filename.split(".")[-1].lower()
|
||||||
|
raw_path = user_dir / ("raw." + raw_ext)
|
||||||
# Save temp upload FIRST
|
|
||||||
with open(raw_path, "wb") as f:
|
|
||||||
f.write(await file.read())
|
|
||||||
|
|
||||||
# Block music/singing via Ollama prompt
|
|
||||||
import requests
|
|
||||||
try:
|
|
||||||
with open(raw_path, "rb") as f:
|
|
||||||
audio = f.read()
|
|
||||||
res = requests.post("http://localhost:11434/api/generate", json={
|
|
||||||
"model": "whisper",
|
|
||||||
"prompt": "Does this audio contain music or singing? Answer yes or no only.",
|
|
||||||
"audio": audio
|
|
||||||
}, timeout=10)
|
|
||||||
resp = res.json().get("response", "").lower()
|
|
||||||
if "yes" in resp:
|
|
||||||
raw_path.unlink(missing_ok=True)
|
|
||||||
raise HTTPException(status_code=403, detail="Upload rejected: music or singing detected")
|
|
||||||
except Exception as ollama_err:
|
|
||||||
# fallback: allow, log if needed
|
|
||||||
pass
|
|
||||||
processed_path = user_dir / unique_name
|
processed_path = user_dir / unique_name
|
||||||
|
|
||||||
# Block unconfirmed users (use ORM)
|
# Clean up any existing raw files first (except the one we're about to create)
|
||||||
user = db.exec(select(User).where((User.username == uid) | (User.email == uid))).first()
|
for old_file in user_dir.glob('raw.*'):
|
||||||
# If result is a Row or tuple, extract the User object
|
try:
|
||||||
if user is not None and not isinstance(user, User) and hasattr(user, "__getitem__"):
|
if old_file != raw_path: # Don't delete the file we're about to create
|
||||||
user = user[0]
|
old_file.unlink(missing_ok=True)
|
||||||
from log import log_violation
|
log_violation("UPLOAD", request.client.host, uid, f"[{request_id}] Cleaned up old file: {old_file}")
|
||||||
log_violation("UPLOAD", request.client.host, uid, f"DEBUG: Incoming uid={uid}, user found={user}, confirmed={getattr(user, 'confirmed', None)}")
|
except Exception as e:
|
||||||
log_violation("UPLOAD", request.client.host, uid, f"DEBUG: After unpack, user={user}, type={type(user)}, confirmed={getattr(user, 'confirmed', None)}")
|
log_violation("UPLOAD_ERROR", request.client.host, uid, f"[{request_id}] Failed to clean up {old_file}: {e}")
|
||||||
if not user or not hasattr(user, "confirmed") or not user.confirmed:
|
|
||||||
raw_path.unlink(missing_ok=True)
|
|
||||||
raise HTTPException(status_code=403, detail="Account not confirmed")
|
|
||||||
|
|
||||||
# DB-based quota check
|
# Save the uploaded file temporarily
|
||||||
quota = db.get(UserQuota, uid)
|
log_violation("UPLOAD", request.client.host, uid, f"[{request_id}] Saving temporary file to {raw_path}")
|
||||||
if quota and quota.storage_bytes >= 100 * 1024 * 1024:
|
try:
|
||||||
raw_path.unlink(missing_ok=True)
|
with open(raw_path, "wb") as f:
|
||||||
raise HTTPException(status_code=400, detail="Quota exceeded")
|
content = await file.read()
|
||||||
|
if not content:
|
||||||
|
raise ValueError("Uploaded file is empty")
|
||||||
|
f.write(content)
|
||||||
|
log_violation("UPLOAD", request.client.host, uid, f"[{request_id}] Successfully wrote {len(content)} bytes to {raw_path}")
|
||||||
|
except Exception as e:
|
||||||
|
log_violation("UPLOAD_ERROR", request.client.host, uid, f"[{request_id}] Failed to save {raw_path}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to save uploaded file: {e}")
|
||||||
|
|
||||||
|
# Ollama music/singing check is disabled for this release
|
||||||
|
log_violation("UPLOAD", request.client.host, uid, f"[{request_id}] Ollama music/singing check is disabled")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
convert_to_opus(str(raw_path), str(processed_path))
|
convert_to_opus(str(raw_path), str(processed_path))
|
||||||
@ -78,8 +92,14 @@ async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), f
|
|||||||
original_size = raw_path.stat().st_size
|
original_size = raw_path.stat().st_size
|
||||||
raw_path.unlink(missing_ok=True) # cleanup
|
raw_path.unlink(missing_ok=True) # cleanup
|
||||||
|
|
||||||
|
# First, verify the file was created and has content
|
||||||
|
if not processed_path.exists() or processed_path.stat().st_size == 0:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to process audio file")
|
||||||
|
|
||||||
# Concatenate all .opus files in random order to stream.opus for public playback
|
# Concatenate all .opus files in random order to stream.opus for public playback
|
||||||
|
# This is now done after the file is in its final location with log ID
|
||||||
from concat_opus import concat_opus_files
|
from concat_opus import concat_opus_files
|
||||||
|
def update_stream_opus():
|
||||||
try:
|
try:
|
||||||
concat_opus_files(user_dir, user_dir / "stream.opus")
|
concat_opus_files(user_dir, user_dir / "stream.opus")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -87,23 +107,82 @@ async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), f
|
|||||||
import shutil
|
import shutil
|
||||||
stream_path = user_dir / "stream.opus"
|
stream_path = user_dir / "stream.opus"
|
||||||
shutil.copy2(processed_path, stream_path)
|
shutil.copy2(processed_path, stream_path)
|
||||||
|
log_violation("STREAM_UPDATE", request.client.host, uid,
|
||||||
|
f"[fallback] Updated stream.opus with {processed_path}")
|
||||||
|
|
||||||
db.add(UploadLog(
|
# We'll call this after the file is in its final location
|
||||||
|
|
||||||
|
# Get the final file size
|
||||||
|
size = processed_path.stat().st_size
|
||||||
|
|
||||||
|
# Start a transaction
|
||||||
|
try:
|
||||||
|
# Create a log entry with the original filename
|
||||||
|
log = UploadLog(
|
||||||
uid=uid,
|
uid=uid,
|
||||||
ip=request.client.host,
|
ip=request.client.host,
|
||||||
filename=file.filename,
|
filename=file.filename, # Store original filename
|
||||||
size_bytes=original_size
|
processed_filename=unique_name, # Store the processed filename
|
||||||
))
|
size_bytes=size
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
db.flush() # Get the log ID without committing
|
||||||
|
|
||||||
# Store updated quota
|
# Rename the processed file to include the log ID for better tracking
|
||||||
size = processed_path.stat().st_size
|
processed_with_id = user_dir / f"{log.id}_{unique_name}"
|
||||||
quota = db.get(UserQuota, uid)
|
if processed_path.exists():
|
||||||
|
# First check if there's already a file with the same UUID but different prefix
|
||||||
|
for existing_file in user_dir.glob(f"*_{unique_name}"):
|
||||||
|
if existing_file != processed_path:
|
||||||
|
log_violation("CLEANUP", request.client.host, uid,
|
||||||
|
f"[UPLOAD] Removing duplicate file: {existing_file}")
|
||||||
|
existing_file.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
# Now do the rename
|
||||||
|
if processed_path != processed_with_id:
|
||||||
|
if processed_with_id.exists():
|
||||||
|
processed_with_id.unlink(missing_ok=True)
|
||||||
|
processed_path.rename(processed_with_id)
|
||||||
|
processed_path = processed_with_id
|
||||||
|
|
||||||
|
# Only clean up raw.* files, not previously uploaded opus files
|
||||||
|
for old_temp_file in user_dir.glob('raw.*'):
|
||||||
|
try:
|
||||||
|
old_temp_file.unlink(missing_ok=True)
|
||||||
|
log_violation("CLEANUP", request.client.host, uid, f"[{request_id}] Cleaned up temp file: {old_temp_file}")
|
||||||
|
except Exception as e:
|
||||||
|
log_violation("CLEANUP_ERROR", request.client.host, uid, f"[{request_id}] Failed to clean up {old_temp_file}: {e}")
|
||||||
|
|
||||||
|
# Get or create quota
|
||||||
|
quota = db.query(UserQuota).filter(UserQuota.uid == uid).first()
|
||||||
if not quota:
|
if not quota:
|
||||||
quota = UserQuota(uid=uid)
|
quota = UserQuota(uid=uid, storage_bytes=0)
|
||||||
db.add(quota)
|
db.add(quota)
|
||||||
quota.storage_bytes += size
|
|
||||||
|
# Update quota with the new file size
|
||||||
|
quota.storage_bytes = sum(
|
||||||
|
f.stat().st_size
|
||||||
|
for f in user_dir.glob('*.opus')
|
||||||
|
if f.name != 'stream.opus' and f != processed_path
|
||||||
|
) + size
|
||||||
|
|
||||||
|
# Update public streams
|
||||||
|
update_public_streams(uid, quota.storage_bytes, db)
|
||||||
|
|
||||||
|
# Commit the transaction
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Now that the transaction is committed and files are in their final location,
|
||||||
|
# update the stream.opus file to include all files
|
||||||
|
update_stream_opus()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
# Clean up the processed file if something went wrong
|
||||||
|
if processed_path.exists():
|
||||||
|
processed_path.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"filename": file.filename,
|
"filename": file.filename,
|
||||||
"original_size": round(original_size / 1024, 1),
|
"original_size": round(original_size / 1024, 1),
|
||||||
@ -123,3 +202,35 @@ async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), f
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {"detail": f"Server error: {type(e).__name__}: {str(e)}"}
|
return {"detail": f"Server error: {type(e).__name__}: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
def update_public_streams(uid: str, storage_bytes: int, db: Session):
|
||||||
|
"""Update the public streams list in the database with the latest user upload info"""
|
||||||
|
try:
|
||||||
|
# Get the user's info
|
||||||
|
user = db.query(User).filter(User.username == uid).first()
|
||||||
|
if not user:
|
||||||
|
print(f"[WARNING] User {uid} not found when updating public streams")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Try to get existing public stream or create new one
|
||||||
|
public_stream = db.query(PublicStream).filter(PublicStream.uid == uid).first()
|
||||||
|
if not public_stream:
|
||||||
|
public_stream = PublicStream(uid=uid)
|
||||||
|
db.add(public_stream)
|
||||||
|
|
||||||
|
# Update the public stream info
|
||||||
|
public_stream.username = user.username
|
||||||
|
public_stream.display_name = user.display_name or user.username
|
||||||
|
public_stream.storage_bytes = storage_bytes
|
||||||
|
public_stream.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
# Don't commit here - let the caller handle the transaction
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Just log the error and let the caller handle the rollback
|
||||||
|
print(f"[ERROR] Error updating public streams: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
raise # Re-raise to let the caller handle the error
|
||||||
|
Reference in New Issue
Block a user