GitHub Actions commited on
Commit
5259aa6
·
1 Parent(s): a4128dd

Sync from GitHub repo

Browse files
admin.py CHANGED
@@ -1,7 +1,8 @@
1
  from flask import Blueprint, render_template, current_app, jsonify, request, redirect, url_for, flash
2
  from models import db, User, Model, Vote, EloHistory, ModelType
3
  from auth import admin_required
4
- from sqlalchemy import func, desc, extract
 
5
  from datetime import datetime, timedelta
6
  import json
7
  import os
@@ -115,7 +116,20 @@ def users():
115
  admin_users = os.getenv("ADMIN_USERS", "").split(",")
116
  admin_users = [username.strip() for username in admin_users]
117
 
118
- return render_template("admin/users.html", users=users, admin_users=admin_users)
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
  @admin.route("/user/<int:user_id>")
121
  @admin_required
@@ -123,6 +137,9 @@ def user_detail(user_id):
123
  """View user details"""
124
  user = User.query.get_or_404(user_id)
125
 
 
 
 
126
  # Get user votes
127
  recent_votes = Vote.query.filter_by(user_id=user_id).order_by(Vote.vote_date.desc()).limit(20).all()
128
 
@@ -130,28 +147,56 @@ def user_detail(user_id):
130
  tts_votes = Vote.query.filter_by(user_id=user_id, model_type=ModelType.TTS).count()
131
  conversational_votes = Vote.query.filter_by(user_id=user_id, model_type=ModelType.CONVERSATIONAL).count()
132
 
133
- # Get favorite models (most chosen)
134
- favorite_models = db.session.query(
135
- Vote.model_chosen,
136
- Model.name,
137
- func.count().label('count')
138
- ).join(
139
- Model, Vote.model_chosen == Model.id
140
- ).filter(
141
- Vote.user_id == user_id
142
- ).group_by(
143
- Vote.model_chosen, Model.name
144
- ).order_by(
145
- desc('count')
146
- ).limit(5).all()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
  return render_template(
149
  "admin/user_detail.html",
150
  user=user,
 
 
151
  recent_votes=recent_votes,
152
  tts_votes=tts_votes,
153
  conversational_votes=conversational_votes,
154
- favorite_models=favorite_models,
155
  total_votes=tts_votes + conversational_votes
156
  )
157
 
@@ -398,4 +443,240 @@ def activity():
398
  recent_tts_votes=recent_tts_votes,
399
  recent_conv_votes=recent_conv_votes,
400
  hourly_data=json.dumps(hourly_data)
401
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from flask import Blueprint, render_template, current_app, jsonify, request, redirect, url_for, flash
2
  from models import db, User, Model, Vote, EloHistory, ModelType
3
  from auth import admin_required
4
+ from security import check_user_security_score
5
+ from sqlalchemy import func, desc, extract, text
6
  from datetime import datetime, timedelta
7
  import json
8
  import os
 
116
  admin_users = os.getenv("ADMIN_USERS", "").split(",")
117
  admin_users = [username.strip() for username in admin_users]
118
 
119
+ # Calculate security scores for all users
120
+ users_with_scores = []
121
+ for user in users:
122
+ score, factors = check_user_security_score(user.id)
123
+ users_with_scores.append({
124
+ 'user': user,
125
+ 'security_score': score,
126
+ 'security_factors': factors
127
+ })
128
+
129
+ # Sort by security score (lowest first to highlight problematic users)
130
+ users_with_scores.sort(key=lambda x: x['security_score'])
131
+
132
+ return render_template("admin/users.html", users_with_scores=users_with_scores, admin_users=admin_users)
133
 
134
  @admin.route("/user/<int:user_id>")
135
  @admin_required
 
137
  """View user details"""
138
  user = User.query.get_or_404(user_id)
139
 
140
+ # Get security score and factors
141
+ security_score, security_factors = check_user_security_score(user_id)
142
+
143
  # Get user votes
144
  recent_votes = Vote.query.filter_by(user_id=user_id).order_by(Vote.vote_date.desc()).limit(20).all()
145
 
 
147
  tts_votes = Vote.query.filter_by(user_id=user_id, model_type=ModelType.TTS).count()
148
  conversational_votes = Vote.query.filter_by(user_id=user_id, model_type=ModelType.CONVERSATIONAL).count()
149
 
150
+ # Get comprehensive model bias analysis
151
+ # This counts how often each model was chosen vs how often it appeared
152
+ model_bias_analysis = []
153
+
154
+ # Get all votes by this user
155
+ user_votes = Vote.query.filter_by(user_id=user_id).all()
156
+
157
+ if user_votes:
158
+ model_stats = {}
159
+
160
+ for vote in user_votes:
161
+ # Track model_chosen
162
+ chosen_id = vote.model_chosen
163
+ rejected_id = vote.model_rejected
164
+
165
+ # Initialize model stats if not exists
166
+ if chosen_id not in model_stats:
167
+ model_stats[chosen_id] = {'chosen': 0, 'appeared': 0, 'name': None}
168
+ if rejected_id not in model_stats:
169
+ model_stats[rejected_id] = {'chosen': 0, 'appeared': 0, 'name': None}
170
+
171
+ # Count appearances and choices
172
+ model_stats[chosen_id]['chosen'] += 1
173
+ model_stats[chosen_id]['appeared'] += 1
174
+ model_stats[rejected_id]['appeared'] += 1
175
+
176
+ # Get model names and calculate bias ratios
177
+ for model_id, stats in model_stats.items():
178
+ model = Model.query.get(model_id)
179
+ if model:
180
+ stats['name'] = model.name
181
+ stats['bias_ratio'] = stats['chosen'] / stats['appeared'] if stats['appeared'] > 0 else 0
182
+ stats['model_id'] = model_id
183
+
184
+ # Sort by bias ratio (highest bias first) and take top 5
185
+ model_bias_analysis = sorted(
186
+ [stats for stats in model_stats.values() if stats['name'] is not None],
187
+ key=lambda x: x['bias_ratio'],
188
+ reverse=True
189
+ )[:5]
190
 
191
  return render_template(
192
  "admin/user_detail.html",
193
  user=user,
194
+ security_score=security_score,
195
+ security_factors=security_factors,
196
  recent_votes=recent_votes,
197
  tts_votes=tts_votes,
198
  conversational_votes=conversational_votes,
199
+ model_bias_analysis=model_bias_analysis,
200
  total_votes=tts_votes + conversational_votes
201
  )
202
 
 
443
  recent_tts_votes=recent_tts_votes,
444
  recent_conv_votes=recent_conv_votes,
445
  hourly_data=json.dumps(hourly_data)
446
+ )
447
+
448
+ @admin.route("/analytics")
449
+ @admin_required
450
+ def analytics():
451
+ """View analytics data including session duration, IP addresses, etc."""
452
+
453
+ # Get analytics statistics
454
+ analytics_stats = {}
455
+
456
+ try:
457
+ # Session duration statistics
458
+ duration_stats = db.session.execute(text("""
459
+ SELECT
460
+ AVG(session_duration_seconds) as avg_duration,
461
+ MIN(session_duration_seconds) as min_duration,
462
+ MAX(session_duration_seconds) as max_duration,
463
+ COUNT(session_duration_seconds) as total_with_duration
464
+ FROM vote
465
+ WHERE session_duration_seconds IS NOT NULL
466
+ """)).fetchone()
467
+
468
+ analytics_stats['duration'] = {
469
+ 'avg': round(duration_stats.avg_duration, 2) if duration_stats.avg_duration else 0,
470
+ 'min': round(duration_stats.min_duration, 2) if duration_stats.min_duration else 0,
471
+ 'max': round(duration_stats.max_duration, 2) if duration_stats.max_duration else 0,
472
+ 'total': duration_stats.total_with_duration or 0
473
+ }
474
+
475
+ # Cache hit statistics
476
+ cache_stats = db.session.execute(text("""
477
+ SELECT
478
+ cache_hit,
479
+ COUNT(*) as count
480
+ FROM vote
481
+ WHERE cache_hit IS NOT NULL
482
+ GROUP BY cache_hit
483
+ """)).fetchall()
484
+
485
+ analytics_stats['cache'] = {
486
+ 'hits': 0,
487
+ 'misses': 0,
488
+ 'total': 0
489
+ }
490
+
491
+ for stat in cache_stats:
492
+ if stat.cache_hit:
493
+ analytics_stats['cache']['hits'] = stat.count
494
+ else:
495
+ analytics_stats['cache']['misses'] = stat.count
496
+ analytics_stats['cache']['total'] += stat.count
497
+
498
+ # Top IP address regions (anonymized)
499
+ ip_stats = db.session.execute(text("""
500
+ SELECT
501
+ ip_address_partial,
502
+ COUNT(*) as count
503
+ FROM vote
504
+ WHERE ip_address_partial IS NOT NULL
505
+ GROUP BY ip_address_partial
506
+ ORDER BY count DESC
507
+ LIMIT 10
508
+ """)).fetchall()
509
+
510
+ analytics_stats['top_ips'] = [
511
+ {'ip': stat.ip_address_partial, 'count': stat.count}
512
+ for stat in ip_stats
513
+ ]
514
+
515
+ # User agent statistics (top browsers/devices)
516
+ ua_stats = db.session.execute(text("""
517
+ SELECT
518
+ CASE
519
+ WHEN user_agent LIKE '%Chrome%' THEN 'Chrome'
520
+ WHEN user_agent LIKE '%Firefox%' THEN 'Firefox'
521
+ WHEN user_agent LIKE '%Safari%' AND user_agent NOT LIKE '%Chrome%' THEN 'Safari'
522
+ WHEN user_agent LIKE '%Edge%' THEN 'Edge'
523
+ WHEN user_agent LIKE '%Mobile%' OR user_agent LIKE '%Android%' THEN 'Mobile'
524
+ ELSE 'Other'
525
+ END as browser,
526
+ COUNT(*) as count
527
+ FROM vote
528
+ WHERE user_agent IS NOT NULL
529
+ GROUP BY browser
530
+ ORDER BY count DESC
531
+ """)).fetchall()
532
+
533
+ analytics_stats['browsers'] = [
534
+ {'browser': stat.browser, 'count': stat.count}
535
+ for stat in ua_stats
536
+ ]
537
+
538
+ # Recent votes with analytics data
539
+ recent_analytics = db.session.execute(text("""
540
+ SELECT
541
+ v.id,
542
+ v.vote_date,
543
+ v.session_duration_seconds,
544
+ v.ip_address_partial,
545
+ v.cache_hit,
546
+ v.model_type,
547
+ u.username,
548
+ m1.name as chosen_model,
549
+ m2.name as rejected_model
550
+ FROM vote v
551
+ LEFT JOIN user u ON v.user_id = u.id
552
+ LEFT JOIN model m1 ON v.model_chosen = m1.id
553
+ LEFT JOIN model m2 ON v.model_rejected = m2.id
554
+ WHERE v.session_duration_seconds IS NOT NULL
555
+ ORDER BY v.vote_date DESC
556
+ LIMIT 20
557
+ """)).fetchall()
558
+
559
+ analytics_stats['recent_votes'] = [
560
+ {
561
+ 'id': vote.id,
562
+ 'vote_date': vote.vote_date,
563
+ 'duration': round(vote.session_duration_seconds, 2) if vote.session_duration_seconds else None,
564
+ 'ip': vote.ip_address_partial,
565
+ 'cache_hit': vote.cache_hit,
566
+ 'model_type': vote.model_type,
567
+ 'username': vote.username,
568
+ 'chosen_model': vote.chosen_model,
569
+ 'rejected_model': vote.rejected_model
570
+ }
571
+ for vote in recent_analytics
572
+ ]
573
+
574
+ except Exception as e:
575
+ flash(f"Error retrieving analytics data: {str(e)}", "error")
576
+ analytics_stats = {}
577
+
578
+ return render_template(
579
+ "admin/analytics.html",
580
+ analytics_stats=analytics_stats
581
+ )
582
+
583
+ @admin.route("/security")
584
+ @admin_required
585
+ def security():
586
+ """View security monitoring data and suspicious activity."""
587
+ try:
588
+ from security import (
589
+ detect_suspicious_voting_patterns,
590
+ detect_coordinated_voting,
591
+ check_user_security_score,
592
+ detect_model_bias
593
+ )
594
+
595
+ # Get recent suspicious users
596
+ recent_users = User.query.order_by(User.join_date.desc()).limit(50).all()
597
+ suspicious_users = []
598
+
599
+ for user in recent_users:
600
+ score, factors = check_user_security_score(user.id)
601
+ if score < 50: # Flag users with low security scores
602
+ suspicious_users.append({
603
+ 'user': user,
604
+ 'score': score,
605
+ 'factors': factors
606
+ })
607
+
608
+ # Sort by lowest score first
609
+ suspicious_users.sort(key=lambda x: x['score'])
610
+
611
+ # Check for coordinated voting on top models
612
+ top_models = Model.query.order_by(Model.current_elo.desc()).limit(10).all()
613
+ coordinated_campaigns = []
614
+
615
+ for model in top_models:
616
+ is_coordinated, user_count, vote_count, suspicious_users_list = detect_coordinated_voting(model.id)
617
+ if is_coordinated:
618
+ coordinated_campaigns.append({
619
+ 'model': model,
620
+ 'user_count': user_count,
621
+ 'vote_count': vote_count,
622
+ 'suspicious_users': suspicious_users_list
623
+ })
624
+
625
+ # Get users with high model bias
626
+ biased_users = []
627
+ for model in top_models:
628
+ # Check recent voters for this model
629
+ recent_voters = db.session.query(Vote.user_id).filter(
630
+ Vote.model_chosen == model.id
631
+ ).distinct().limit(20).all()
632
+
633
+ for voter in recent_voters:
634
+ if voter.user_id:
635
+ is_biased, bias_ratio, votes_for_model, total_votes = detect_model_bias(
636
+ voter.user_id, model.id
637
+ )
638
+ if is_biased and total_votes >= 5:
639
+ user = User.query.get(voter.user_id)
640
+ if user:
641
+ biased_users.append({
642
+ 'user': user,
643
+ 'model': model,
644
+ 'bias_ratio': bias_ratio,
645
+ 'votes_for_model': votes_for_model,
646
+ 'total_votes': total_votes
647
+ })
648
+
649
+ # Remove duplicates and sort by bias ratio
650
+ seen_users = set()
651
+ unique_biased_users = []
652
+ for item in biased_users:
653
+ user_model_key = (item['user'].id, item['model'].id)
654
+ if user_model_key not in seen_users:
655
+ seen_users.add(user_model_key)
656
+ unique_biased_users.append(item)
657
+
658
+ unique_biased_users.sort(key=lambda x: x['bias_ratio'], reverse=True)
659
+
660
+ # Get recent security blocks from logs (if available)
661
+ security_blocks = []
662
+ try:
663
+ # This would require parsing application logs
664
+ # For now, we'll show a placeholder
665
+ pass
666
+ except Exception:
667
+ pass
668
+
669
+ return render_template(
670
+ "admin/security.html",
671
+ suspicious_users=suspicious_users[:20], # Limit to top 20
672
+ coordinated_campaigns=coordinated_campaigns,
673
+ biased_users=unique_biased_users[:20], # Limit to top 20
674
+ security_blocks=security_blocks
675
+ )
676
+
677
+ except ImportError:
678
+ flash("Security module not available", "error")
679
+ return redirect(url_for("admin.index"))
680
+ except Exception as e:
681
+ flash(f"Error loading security data: {str(e)}", "error")
682
+ return redirect(url_for("admin.index"))
app.py CHANGED
@@ -1,33 +1,1528 @@
1
- from flask import Flask, render_template_string
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  app = Flask(__name__)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
- HTML = """
6
- <!DOCTYPE html>
7
- <html lang="en">
8
- <head>
9
- <meta charset="UTF-8">
10
- <title>Maintenance</title>
11
- <meta name="viewport" content="width=device-width, initial-scale=1">
12
- <script src="https://cdn.tailwindcss.com"></script>
13
- </head>
14
- <body class="bg-gray-100 flex items-center justify-center h-screen">
15
- <div class="bg-white p-8 rounded-2xl shadow-lg text-center max-w-md">
16
- <svg class="mx-auto mb-4 w-16 h-16 text-yellow-500" fill="none" stroke="currentColor" stroke-width="1.5"
17
- viewBox="0 0 24 24">
18
- <path stroke-linecap="round" stroke-linejoin="round"
19
- d="M12 9v2m0 4h.01M4.93 4.93a10 10 0 0114.14 0 10 10 0 010 14.14 10 10 0 01-14.14 0 10 10 0 010-14.14z"/>
20
- </svg>
21
- <h1 class="text-2xl font-bold text-gray-800 mb-2">We'll be back soon!</h1>
22
- <p class="text-gray-600">The TTS Arena is temporarily undergoing maintenance.<br>Thank you for your patience.</p>
23
- </div>
24
- </body>
25
- </html>
26
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  @app.route("/")
29
- def maintenance():
30
- return render_template_string(HTML)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  if __name__ == "__main__":
33
- app.run(host="0.0.0.0", port=7860)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from huggingface_hub import HfApi, hf_hub_download
3
+ from apscheduler.schedulers.background import BackgroundScheduler
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from datetime import datetime
6
+ import threading # Added for locking
7
+ from sqlalchemy import or_ # Added for vote counting query
8
+
9
+ year = datetime.now().year
10
+ month = datetime.now().month
11
+
12
+ # Check if running in a Huggin Face Space
13
+ IS_SPACES = False
14
+ if os.getenv("SPACE_REPO_NAME"):
15
+ print("Running in a Hugging Face Space 🤗")
16
+ IS_SPACES = True
17
+
18
+ # Setup database sync for HF Spaces
19
+ if not os.path.exists("instance/tts_arena.db"):
20
+ os.makedirs("instance", exist_ok=True)
21
+ try:
22
+ print("Database not found, downloading from HF dataset...")
23
+ hf_hub_download(
24
+ repo_id="TTS-AGI/database-arena-v2",
25
+ filename="tts_arena.db",
26
+ repo_type="dataset",
27
+ local_dir="instance",
28
+ token=os.getenv("HF_TOKEN"),
29
+ )
30
+ print("Database downloaded successfully ✅")
31
+ except Exception as e:
32
+ print(f"Error downloading database from HF dataset: {str(e)} ⚠️")
33
+
34
+ from flask import (
35
+ Flask,
36
+ render_template,
37
+ g,
38
+ request,
39
+ jsonify,
40
+ send_file,
41
+ redirect,
42
+ url_for,
43
+ session,
44
+ abort,
45
+ )
46
+ from flask_login import LoginManager, current_user
47
+ from models import *
48
+ from auth import auth, init_oauth, is_admin
49
+ from admin import admin
50
+ from security import is_vote_allowed, check_user_security_score
51
+ import os
52
+ from dotenv import load_dotenv
53
+ from flask_limiter import Limiter
54
+ from flask_limiter.util import get_remote_address
55
+ import uuid
56
+ import tempfile
57
+ import shutil
58
+ from tts import predict_tts
59
+ import random
60
+ import json
61
+ from datetime import datetime, timedelta
62
+ from flask_migrate import Migrate
63
+ import requests
64
+ import functools
65
+ import time # Added for potential retries
66
+
67
+
68
+ def get_client_ip():
69
+ """Get the client's IP address, handling proxies and load balancers."""
70
+ # Check for forwarded headers first (common with reverse proxies)
71
+ if request.headers.get('X-Forwarded-For'):
72
+ # X-Forwarded-For can contain multiple IPs, take the first one
73
+ return request.headers.get('X-Forwarded-For').split(',')[0].strip()
74
+ elif request.headers.get('X-Real-IP'):
75
+ return request.headers.get('X-Real-IP')
76
+ elif request.headers.get('CF-Connecting-IP'): # Cloudflare
77
+ return request.headers.get('CF-Connecting-IP')
78
+ else:
79
+ return request.remote_addr
80
+
81
+
82
+ # Load environment variables
83
+ if not IS_SPACES:
84
+ load_dotenv() # Only load .env if not running in a Hugging Face Space
85
 
86
  app = Flask(__name__)
87
+ app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", os.urandom(24))
88
+ app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv(
89
+ "DATABASE_URI", "sqlite:///tts_arena.db"
90
+ )
91
+ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
92
+ app.config["SESSION_COOKIE_SECURE"] = True
93
+ app.config["SESSION_COOKIE_SAMESITE"] = (
94
+ "None" if IS_SPACES else "Lax"
95
+ ) # HF Spaces uses iframes to load the app, so we need to set SAMESITE to None
96
+ app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30) # Set to desired duration
97
+
98
+ # Force HTTPS when running in HuggingFace Spaces
99
+ if IS_SPACES:
100
+ app.config["PREFERRED_URL_SCHEME"] = "https"
101
+
102
+ # Cloudflare Turnstile settings
103
+ app.config["TURNSTILE_ENABLED"] = (
104
+ os.getenv("TURNSTILE_ENABLED", "False").lower() == "true"
105
+ )
106
+ app.config["TURNSTILE_SITE_KEY"] = os.getenv("TURNSTILE_SITE_KEY", "")
107
+ app.config["TURNSTILE_SECRET_KEY"] = os.getenv("TURNSTILE_SECRET_KEY", "")
108
+ app.config["TURNSTILE_VERIFY_URL"] = (
109
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify"
110
+ )
111
+
112
+ migrate = Migrate(app, db)
113
+
114
+ # Initialize extensions
115
+ db.init_app(app)
116
+ login_manager = LoginManager()
117
+ login_manager.init_app(app)
118
+ login_manager.login_view = "auth.login"
119
+
120
+ # Initialize OAuth
121
+ init_oauth(app)
122
+
123
+ # Configure rate limits
124
+ limiter = Limiter(
125
+ app=app,
126
+ key_func=get_remote_address,
127
+ default_limits=["2000 per day", "50 per minute"],
128
+ storage_uri="memory://",
129
+ )
130
+
131
+ # TTS Cache Configuration - Read from environment
132
+ TTS_CACHE_SIZE = int(os.getenv("TTS_CACHE_SIZE", "10"))
133
+ CACHE_AUDIO_SUBDIR = "cache"
134
+ tts_cache = {} # sentence -> {model_a, model_b, audio_a, audio_b, created_at}
135
+ tts_cache_lock = threading.Lock()
136
+ SMOOTHING_FACTOR_MODEL_SELECTION = 500 # For weighted random model selection
137
+ # Increased max_workers to 8 for concurrent generation/refill
138
+ cache_executor = ThreadPoolExecutor(max_workers=8, thread_name_prefix='CacheReplacer')
139
+ all_harvard_sentences = [] # Keep the full list available
140
+
141
+ # Create temp directories
142
+ TEMP_AUDIO_DIR = os.path.join(tempfile.gettempdir(), "tts_arena_audio")
143
+ CACHE_AUDIO_DIR = os.path.join(TEMP_AUDIO_DIR, CACHE_AUDIO_SUBDIR)
144
+ os.makedirs(TEMP_AUDIO_DIR, exist_ok=True)
145
+ os.makedirs(CACHE_AUDIO_DIR, exist_ok=True) # Ensure cache subdir exists
146
+
147
+
148
+ # Store active TTS sessions
149
+ app.tts_sessions = {}
150
+ tts_sessions = app.tts_sessions
151
+
152
+ # Store active conversational sessions
153
+ app.conversational_sessions = {}
154
+ conversational_sessions = app.conversational_sessions
155
+
156
+ # Register blueprints
157
+ app.register_blueprint(auth, url_prefix="/auth")
158
+ app.register_blueprint(admin)
159
+
160
+
161
+ @login_manager.user_loader
162
+ def load_user(user_id):
163
+ return User.query.get(int(user_id))
164
+
165
+
166
+ @app.before_request
167
+ def before_request():
168
+ g.user = current_user
169
+ g.is_admin = is_admin(current_user)
170
+
171
+ # Ensure HTTPS for HuggingFace Spaces environment
172
+ if IS_SPACES and request.headers.get("X-Forwarded-Proto") == "http":
173
+ url = request.url.replace("http://", "https://", 1)
174
+ return redirect(url, code=301)
175
+
176
+ # Check if Turnstile verification is required
177
+ if app.config["TURNSTILE_ENABLED"]:
178
+ # Exclude verification routes
179
+ excluded_routes = ["verify_turnstile", "turnstile_page", "static"]
180
+ if request.endpoint not in excluded_routes:
181
+ # Check if user is verified
182
+ if not session.get("turnstile_verified"):
183
+ # Save original URL for redirect after verification
184
+ redirect_url = request.url
185
+ # Force HTTPS in HuggingFace Spaces
186
+ if IS_SPACES and redirect_url.startswith("http://"):
187
+ redirect_url = redirect_url.replace("http://", "https://", 1)
188
+
189
+ # If it's an API request, return a JSON response
190
+ if request.path.startswith("/api/"):
191
+ return jsonify({"error": "Turnstile verification required"}), 403
192
+ # For regular requests, redirect to verification page
193
+ return redirect(url_for("turnstile_page", redirect_url=redirect_url))
194
+ else:
195
+ # Check if verification has expired (default: 24 hours)
196
+ verification_timeout = (
197
+ int(os.getenv("TURNSTILE_TIMEOUT_HOURS", "24")) * 3600
198
+ ) # Convert hours to seconds
199
+ verified_at = session.get("turnstile_verified_at", 0)
200
+ current_time = datetime.utcnow().timestamp()
201
+
202
+ if current_time - verified_at > verification_timeout:
203
+ # Verification expired, clear status and redirect to verification page
204
+ session.pop("turnstile_verified", None)
205
+ session.pop("turnstile_verified_at", None)
206
+
207
+ redirect_url = request.url
208
+ # Force HTTPS in HuggingFace Spaces
209
+ if IS_SPACES and redirect_url.startswith("http://"):
210
+ redirect_url = redirect_url.replace("http://", "https://", 1)
211
+
212
+ if request.path.startswith("/api/"):
213
+ return jsonify({"error": "Turnstile verification expired"}), 403
214
+ return redirect(
215
+ url_for("turnstile_page", redirect_url=redirect_url)
216
+ )
217
+
218
+
219
+ @app.route("/turnstile", methods=["GET"])
220
+ def turnstile_page():
221
+ """Display Cloudflare Turnstile verification page"""
222
+ redirect_url = request.args.get("redirect_url", url_for("arena", _external=True))
223
 
224
+ # Force HTTPS in HuggingFace Spaces
225
+ if IS_SPACES and redirect_url.startswith("http://"):
226
+ redirect_url = redirect_url.replace("http://", "https://", 1)
227
+
228
+ return render_template(
229
+ "turnstile.html",
230
+ turnstile_site_key=app.config["TURNSTILE_SITE_KEY"],
231
+ redirect_url=redirect_url,
232
+ )
233
+
234
+
235
+ @app.route("/verify-turnstile", methods=["POST"])
236
+ def verify_turnstile():
237
+ """Verify Cloudflare Turnstile token"""
238
+ token = request.form.get("cf-turnstile-response")
239
+ redirect_url = request.form.get("redirect_url", url_for("arena", _external=True))
240
+
241
+ # Force HTTPS in HuggingFace Spaces
242
+ if IS_SPACES and redirect_url.startswith("http://"):
243
+ redirect_url = redirect_url.replace("http://", "https://", 1)
244
+
245
+ if not token:
246
+ # If AJAX request, return JSON error
247
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
248
+ return (
249
+ jsonify({"success": False, "error": "Missing verification token"}),
250
+ 400,
251
+ )
252
+ # Otherwise redirect back to turnstile page
253
+ return redirect(url_for("turnstile_page", redirect_url=redirect_url))
254
+
255
+ # Verify token with Cloudflare
256
+ data = {
257
+ "secret": app.config["TURNSTILE_SECRET_KEY"],
258
+ "response": token,
259
+ "remoteip": request.remote_addr,
260
+ }
261
+
262
+ try:
263
+ response = requests.post(app.config["TURNSTILE_VERIFY_URL"], data=data)
264
+ result = response.json()
265
+
266
+ if result.get("success"):
267
+ # Set verification status in session
268
+ session["turnstile_verified"] = True
269
+ session["turnstile_verified_at"] = datetime.utcnow().timestamp()
270
+
271
+ # Determine response type based on request
272
+ is_xhr = request.headers.get("X-Requested-With") == "XMLHttpRequest"
273
+ accepts_json = "application/json" in request.headers.get("Accept", "")
274
+
275
+ # If AJAX or JSON request, return success JSON
276
+ if is_xhr or accepts_json:
277
+ return jsonify({"success": True, "redirect": redirect_url})
278
+
279
+ # For regular form submissions, redirect to the target URL
280
+ return redirect(redirect_url)
281
+ else:
282
+ # Verification failed
283
+ app.logger.warning(f"Turnstile verification failed: {result}")
284
+
285
+ # If AJAX request, return JSON error
286
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
287
+ return jsonify({"success": False, "error": "Verification failed"}), 403
288
+
289
+ # Otherwise redirect back to turnstile page
290
+ return redirect(url_for("turnstile_page", redirect_url=redirect_url))
291
+
292
+ except Exception as e:
293
+ app.logger.error(f"Turnstile verification error: {str(e)}")
294
+
295
+ # If AJAX request, return JSON error
296
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
297
+ return (
298
+ jsonify(
299
+ {"success": False, "error": "Server error during verification"}
300
+ ),
301
+ 500,
302
+ )
303
+
304
+ # Otherwise redirect back to turnstile page
305
+ return redirect(url_for("turnstile_page", redirect_url=redirect_url))
306
+
307
+ with open("sentences.txt", "r") as f, open("emotional_sentences.txt", "r") as f_emotional:
308
+ # Store all sentences and clean them up
309
+ all_harvard_sentences = [line.strip() for line in f.readlines() if line.strip()] + [line.strip() for line in f_emotional.readlines() if line.strip()]
310
+ # Shuffle for initial random selection if needed, but main list remains ordered
311
+ initial_sentences = random.sample(all_harvard_sentences, min(len(all_harvard_sentences), 500)) # Limit initial pass for template
312
 
313
  @app.route("/")
314
+ def arena():
315
+ # Pass a subset of sentences for the random button fallback
316
+ return render_template("arena.html", harvard_sentences=json.dumps(initial_sentences))
317
+
318
+
319
+ @app.route("/leaderboard")
320
+ def leaderboard():
321
+ tts_leaderboard = get_leaderboard_data(ModelType.TTS)
322
+ conversational_leaderboard = get_leaderboard_data(ModelType.CONVERSATIONAL)
323
+ top_voters = get_top_voters(10) # Get top 10 voters
324
+
325
+ # Initialize personal leaderboard data
326
+ tts_personal_leaderboard = None
327
+ conversational_personal_leaderboard = None
328
+ user_leaderboard_visibility = None
329
+
330
+ # If user is logged in, get their personal leaderboard and visibility setting
331
+ if current_user.is_authenticated:
332
+ tts_personal_leaderboard = get_user_leaderboard(current_user.id, ModelType.TTS)
333
+ conversational_personal_leaderboard = get_user_leaderboard(
334
+ current_user.id, ModelType.CONVERSATIONAL
335
+ )
336
+ user_leaderboard_visibility = current_user.show_in_leaderboard
337
+
338
+ # Get key dates for the timeline
339
+ tts_key_dates = get_key_historical_dates(ModelType.TTS)
340
+ conversational_key_dates = get_key_historical_dates(ModelType.CONVERSATIONAL)
341
+
342
+ # Format dates for display in the dropdown
343
+ formatted_tts_dates = [date.strftime("%B %Y") for date in tts_key_dates]
344
+ formatted_conversational_dates = [
345
+ date.strftime("%B %Y") for date in conversational_key_dates
346
+ ]
347
+
348
+ return render_template(
349
+ "leaderboard.html",
350
+ tts_leaderboard=tts_leaderboard,
351
+ conversational_leaderboard=conversational_leaderboard,
352
+ tts_personal_leaderboard=tts_personal_leaderboard,
353
+ conversational_personal_leaderboard=conversational_personal_leaderboard,
354
+ tts_key_dates=tts_key_dates,
355
+ conversational_key_dates=conversational_key_dates,
356
+ formatted_tts_dates=formatted_tts_dates,
357
+ formatted_conversational_dates=formatted_conversational_dates,
358
+ top_voters=top_voters,
359
+ user_leaderboard_visibility=user_leaderboard_visibility
360
+ )
361
+
362
+
363
+ @app.route("/api/historical-leaderboard/<model_type>")
364
+ def historical_leaderboard(model_type):
365
+ """Get historical leaderboard data for a specific date"""
366
+ if model_type not in [ModelType.TTS, ModelType.CONVERSATIONAL]:
367
+ return jsonify({"error": "Invalid model type"}), 400
368
+
369
+ # Get date from query parameter
370
+ date_str = request.args.get("date")
371
+ if not date_str:
372
+ return jsonify({"error": "Date parameter is required"}), 400
373
+
374
+ try:
375
+ # Parse date from URL parameter (format: YYYY-MM-DD)
376
+ target_date = datetime.strptime(date_str, "%Y-%m-%d")
377
+
378
+ # Get historical leaderboard data
379
+ leaderboard_data = get_historical_leaderboard_data(model_type, target_date)
380
+
381
+ return jsonify(
382
+ {"date": target_date.strftime("%B %d, %Y"), "leaderboard": leaderboard_data}
383
+ )
384
+ except ValueError:
385
+ return jsonify({"error": "Invalid date format. Use YYYY-MM-DD"}), 400
386
+
387
+
388
+ @app.route("/about")
389
+ def about():
390
+ return render_template("about.html")
391
+
392
+
393
+ # --- TTS Caching Functions ---
394
+
395
+ def generate_and_save_tts(text, model_id, output_dir):
396
+ """Generates TTS and saves it to a specific directory, returning the full path."""
397
+ temp_audio_path = None # Initialize to None
398
+ try:
399
+ app.logger.debug(f"[TTS Gen {model_id}] Starting generation for: '{text[:30]}...'")
400
+ # If predict_tts saves file itself and returns path:
401
+ temp_audio_path = predict_tts(text, model_id)
402
+ app.logger.debug(f"[TTS Gen {model_id}] predict_tts returned: {temp_audio_path}")
403
+
404
+ if not temp_audio_path or not os.path.exists(temp_audio_path):
405
+ app.logger.warning(f"[TTS Gen {model_id}] predict_tts failed or returned invalid path: {temp_audio_path}")
406
+ raise ValueError("predict_tts did not return a valid path or file does not exist")
407
+
408
+ file_uuid = str(uuid.uuid4())
409
+ dest_path = os.path.join(output_dir, f"{file_uuid}.wav")
410
+ app.logger.debug(f"[TTS Gen {model_id}] Moving {temp_audio_path} to {dest_path}")
411
+ # Move the file generated by predict_tts to the target cache directory
412
+ shutil.move(temp_audio_path, dest_path)
413
+ app.logger.debug(f"[TTS Gen {model_id}] Move successful. Returning {dest_path}")
414
+ return dest_path
415
+
416
+ except Exception as e:
417
+ app.logger.error(f"Error generating/saving TTS for model {model_id} and text '{text[:30]}...': {str(e)}")
418
+ # Ensure temporary file from predict_tts (if any) is cleaned up
419
+ if temp_audio_path and os.path.exists(temp_audio_path):
420
+ try:
421
+ app.logger.debug(f"[TTS Gen {model_id}] Cleaning up temporary file {temp_audio_path} after error.")
422
+ os.remove(temp_audio_path)
423
+ except OSError:
424
+ pass # Ignore error if file couldn't be removed
425
+ return None
426
+
427
+
428
+ def _generate_cache_entry_task(sentence):
429
+ """Task function to generate audio for a sentence and add to cache."""
430
+ # Wrap the entire task in an application context
431
+ with app.app_context():
432
+ if not sentence:
433
+ # Select a new sentence if not provided (for replacement)
434
+ with tts_cache_lock:
435
+ cached_keys = set(tts_cache.keys())
436
+ available_sentences = [s for s in all_harvard_sentences if s not in cached_keys]
437
+ if not available_sentences:
438
+ app.logger.warning("No more unique Harvard sentences available for caching.")
439
+ return
440
+ sentence = random.choice(available_sentences)
441
+
442
+ # app.logger.info removed duplicate log
443
+ print(f"[Cache Task] Querying models for: '{sentence[:50]}...'")
444
+ available_models = Model.query.filter_by(
445
+ model_type=ModelType.TTS, is_active=True
446
+ ).all()
447
+
448
+ if len(available_models) < 2:
449
+ app.logger.error("Not enough active TTS models to generate cache entry.")
450
+ return
451
+
452
+ try:
453
+ models = get_weighted_random_models(available_models, 2, ModelType.TTS)
454
+ model_a_id = models[0].id
455
+ model_b_id = models[1].id
456
+
457
+ # Generate audio concurrently using a local executor for clarity within the task
458
+ with ThreadPoolExecutor(max_workers=2, thread_name_prefix='AudioGen') as audio_executor:
459
+ future_a = audio_executor.submit(generate_and_save_tts, sentence, model_a_id, CACHE_AUDIO_DIR)
460
+ future_b = audio_executor.submit(generate_and_save_tts, sentence, model_b_id, CACHE_AUDIO_DIR)
461
+
462
+ timeout_seconds = 120
463
+ audio_a_path = future_a.result(timeout=timeout_seconds)
464
+ audio_b_path = future_b.result(timeout=timeout_seconds)
465
+
466
+ if audio_a_path and audio_b_path:
467
+ with tts_cache_lock:
468
+ # Only add if the sentence isn't already back in the cache
469
+ # And ensure cache size doesn't exceed limit
470
+ if sentence not in tts_cache and len(tts_cache) < TTS_CACHE_SIZE:
471
+ tts_cache[sentence] = {
472
+ "model_a": model_a_id,
473
+ "model_b": model_b_id,
474
+ "audio_a": audio_a_path,
475
+ "audio_b": audio_b_path,
476
+ "created_at": datetime.utcnow(),
477
+ }
478
+ app.logger.info(f"Successfully cached entry for: '{sentence[:50]}...'")
479
+ elif sentence in tts_cache:
480
+ app.logger.warning(f"Sentence '{sentence[:50]}...' already re-cached. Discarding new generation.")
481
+ # Clean up the newly generated files if not added
482
+ if os.path.exists(audio_a_path): os.remove(audio_a_path)
483
+ if os.path.exists(audio_b_path): os.remove(audio_b_path)
484
+ else: # Cache is full
485
+ app.logger.warning(f"Cache is full ({len(tts_cache)} entries). Discarding new generation for '{sentence[:50]}...'.")
486
+ # Clean up the newly generated files if not added
487
+ if os.path.exists(audio_a_path): os.remove(audio_a_path)
488
+ if os.path.exists(audio_b_path): os.remove(audio_b_path)
489
+
490
+ else:
491
+ app.logger.error(f"Failed to generate one or both audio files for cache: '{sentence[:50]}...'")
492
+ # Clean up whichever file might have been created
493
+ if audio_a_path and os.path.exists(audio_a_path): os.remove(audio_a_path)
494
+ if audio_b_path and os.path.exists(audio_b_path): os.remove(audio_b_path)
495
+
496
+ except Exception as e:
497
+ # Log the exception within the app context
498
+ app.logger.error(f"Exception in _generate_cache_entry_task for '{sentence[:50]}...': {str(e)}", exc_info=True)
499
+
500
+
501
+ def initialize_tts_cache():
502
+ print("Initializing TTS cache")
503
+ """Selects initial sentences and starts generation tasks."""
504
+ with app.app_context(): # Ensure access to models
505
+ if not all_harvard_sentences:
506
+ app.logger.error("Harvard sentences not loaded. Cannot initialize cache.")
507
+ return
508
+
509
+ initial_selection = random.sample(all_harvard_sentences, min(len(all_harvard_sentences), TTS_CACHE_SIZE))
510
+ app.logger.info(f"Initializing TTS cache with {len(initial_selection)} sentences...")
511
+
512
+ for sentence in initial_selection:
513
+ # Use the main cache_executor for initial population too
514
+ cache_executor.submit(_generate_cache_entry_task, sentence)
515
+ app.logger.info("Submitted initial cache generation tasks.")
516
+
517
+ # --- End TTS Caching Functions ---
518
+
519
+
520
+ @app.route("/api/tts/generate", methods=["POST"])
521
+ @limiter.limit("10 per minute") # Keep limit, cached responses are still requests
522
+ def generate_tts():
523
+ # If verification not setup, handle it first
524
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
525
+ return jsonify({"error": "Turnstile verification required"}), 403
526
+
527
+ # Require user to be logged in to generate audio
528
+ if not current_user.is_authenticated:
529
+ return jsonify({"error": "You must be logged in to generate audio"}), 401
530
+
531
+ data = request.json
532
+ text = data.get("text", "").strip() # Ensure text is stripped
533
+
534
+ if not text or len(text) > 1000:
535
+ return jsonify({"error": "Invalid or too long text"}), 400
536
+
537
+ # --- Cache Check ---
538
+ cache_hit = False
539
+ session_data_from_cache = None
540
+ with tts_cache_lock:
541
+ if text in tts_cache:
542
+ cache_hit = True
543
+ cached_entry = tts_cache.pop(text) # Remove from cache immediately
544
+ app.logger.info(f"TTS Cache HIT for: '{text[:50]}...'")
545
+
546
+ # Prepare session data using cached info
547
+ session_id = str(uuid.uuid4())
548
+ session_data_from_cache = {
549
+ "model_a": cached_entry["model_a"],
550
+ "model_b": cached_entry["model_b"],
551
+ "audio_a": cached_entry["audio_a"], # Paths are now from cache_dir
552
+ "audio_b": cached_entry["audio_b"],
553
+ "text": text,
554
+ "created_at": datetime.utcnow(),
555
+ "expires_at": datetime.utcnow() + timedelta(minutes=30),
556
+ "voted": False,
557
+ "cache_hit": True,
558
+ }
559
+ app.tts_sessions[session_id] = session_data_from_cache
560
+
561
+ # --- Trigger background tasks to refill the cache ---
562
+ # Calculate how many slots need refilling
563
+ current_cache_size = len(tts_cache) # Size *before* adding potentially new items
564
+ needed_refills = TTS_CACHE_SIZE - current_cache_size
565
+ # Limit concurrent refills to 8 or the actual need
566
+ refills_to_submit = min(needed_refills, 8)
567
+
568
+ if refills_to_submit > 0:
569
+ app.logger.info(f"Cache hit: Submitting {refills_to_submit} background task(s) to refill cache (current size: {current_cache_size}, target: {TTS_CACHE_SIZE}).")
570
+ for _ in range(refills_to_submit):
571
+ # Pass None to signal replacement selection within the task
572
+ cache_executor.submit(_generate_cache_entry_task, None)
573
+ else:
574
+ app.logger.info(f"Cache hit: Cache is already full or at target size ({current_cache_size}/{TTS_CACHE_SIZE}). No refill tasks submitted.")
575
+ # --- End Refill Trigger ---
576
+
577
+ if cache_hit and session_data_from_cache:
578
+ # Return response using cached data
579
+ # Note: The files are now managed by the session lifecycle (cleanup_session)
580
+ return jsonify(
581
+ {
582
+ "session_id": session_id,
583
+ "audio_a": f"/api/tts/audio/{session_id}/a",
584
+ "audio_b": f"/api/tts/audio/{session_id}/b",
585
+ "expires_in": 1800, # 30 minutes in seconds
586
+ "cache_hit": True,
587
+ }
588
+ )
589
+ # --- End Cache Check ---
590
+
591
+ # --- Cache Miss: Generate on the fly ---
592
+ app.logger.info(f"TTS Cache MISS for: '{text[:50]}...'. Generating on the fly.")
593
+ available_models = Model.query.filter_by(
594
+ model_type=ModelType.TTS, is_active=True
595
+ ).all()
596
+ if len(available_models) < 2:
597
+ return jsonify({"error": "Not enough TTS models available"}), 500
598
+
599
+ selected_models = get_weighted_random_models(available_models, 2, ModelType.TTS)
600
+
601
+ try:
602
+ audio_files = []
603
+ model_ids = []
604
+
605
+ # Function to process a single model (generate directly to TEMP_AUDIO_DIR, not cache subdir)
606
+ def process_model_on_the_fly(model):
607
+ # Generate and save directly to the main temp dir
608
+ # Assume predict_tts handles saving temporary files
609
+ temp_audio_path = predict_tts(text, model.id)
610
+ if not temp_audio_path or not os.path.exists(temp_audio_path):
611
+ raise ValueError(f"predict_tts failed for model {model.id}")
612
+
613
+ # Create a unique name in the main TEMP_AUDIO_DIR for the session
614
+ file_uuid = str(uuid.uuid4())
615
+ dest_path = os.path.join(TEMP_AUDIO_DIR, f"{file_uuid}.wav")
616
+ shutil.move(temp_audio_path, dest_path) # Move from predict_tts's temp location
617
+
618
+ return {"model_id": model.id, "audio_path": dest_path}
619
+
620
+
621
+ # Use ThreadPoolExecutor to process models concurrently
622
+ with ThreadPoolExecutor(max_workers=2) as executor:
623
+ results = list(executor.map(process_model_on_the_fly, selected_models))
624
+
625
+ # Extract results
626
+ for result in results:
627
+ model_ids.append(result["model_id"])
628
+ audio_files.append(result["audio_path"])
629
+
630
+ # Create session
631
+ session_id = str(uuid.uuid4())
632
+ app.tts_sessions[session_id] = {
633
+ "model_a": model_ids[0],
634
+ "model_b": model_ids[1],
635
+ "audio_a": audio_files[0], # Paths are now from TEMP_AUDIO_DIR directly
636
+ "audio_b": audio_files[1],
637
+ "text": text,
638
+ "created_at": datetime.utcnow(),
639
+ "expires_at": datetime.utcnow() + timedelta(minutes=30),
640
+ "voted": False,
641
+ "cache_hit": False,
642
+ }
643
+
644
+ # Return audio file paths and session
645
+ return jsonify(
646
+ {
647
+ "session_id": session_id,
648
+ "audio_a": f"/api/tts/audio/{session_id}/a",
649
+ "audio_b": f"/api/tts/audio/{session_id}/b",
650
+ "expires_in": 1800,
651
+ "cache_hit": False,
652
+ }
653
+ )
654
+
655
+ except Exception as e:
656
+ app.logger.error(f"TTS on-the-fly generation error: {str(e)}", exc_info=True)
657
+ # Cleanup any files potentially created during the failed attempt
658
+ if 'results' in locals():
659
+ for res in results:
660
+ if 'audio_path' in res and os.path.exists(res['audio_path']):
661
+ try:
662
+ os.remove(res['audio_path'])
663
+ except OSError:
664
+ pass
665
+ return jsonify({"error": "Failed to generate TTS"}), 500
666
+ # --- End Cache Miss ---
667
+
668
+
669
+ @app.route("/api/tts/audio/<session_id>/<model_key>")
670
+ def get_audio(session_id, model_key):
671
+ # If verification not setup, handle it first
672
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
673
+ return jsonify({"error": "Turnstile verification required"}), 403
674
+
675
+ if session_id not in app.tts_sessions:
676
+ return jsonify({"error": "Invalid or expired session"}), 404
677
+
678
+ session_data = app.tts_sessions[session_id]
679
+
680
+ # Check if session expired
681
+ if datetime.utcnow() > session_data["expires_at"]:
682
+ cleanup_session(session_id)
683
+ return jsonify({"error": "Session expired"}), 410
684
+
685
+ if model_key == "a":
686
+ audio_path = session_data["audio_a"]
687
+ elif model_key == "b":
688
+ audio_path = session_data["audio_b"]
689
+ else:
690
+ return jsonify({"error": "Invalid model key"}), 400
691
+
692
+ # Check if file exists
693
+ if not os.path.exists(audio_path):
694
+ return jsonify({"error": "Audio file not found"}), 404
695
+
696
+ return send_file(audio_path, mimetype="audio/wav")
697
+
698
+
699
+ @app.route("/api/tts/vote", methods=["POST"])
700
+ @limiter.limit("30 per minute")
701
+ def submit_vote():
702
+ # If verification not setup, handle it first
703
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
704
+ return jsonify({"error": "Turnstile verification required"}), 403
705
+
706
+ # Require user to be logged in to vote
707
+ if not current_user.is_authenticated:
708
+ return jsonify({"error": "You must be logged in to vote"}), 401
709
+
710
+ # Security checks for vote manipulation prevention
711
+ client_ip = get_client_ip()
712
+ vote_allowed, security_reason, security_score = is_vote_allowed(current_user.id, client_ip)
713
+
714
+ if not vote_allowed:
715
+ app.logger.warning(f"Vote blocked for user {current_user.username} (ID: {current_user.id}): {security_reason} (Score: {security_score})")
716
+ return jsonify({"error": f"Vote not allowed: {security_reason}"}), 403
717
+
718
+ data = request.json
719
+ session_id = data.get("session_id")
720
+ chosen_model_key = data.get("chosen_model") # "a" or "b"
721
+
722
+ if not session_id or session_id not in app.tts_sessions:
723
+ return jsonify({"error": "Invalid or expired session"}), 404
724
+
725
+ if not chosen_model_key or chosen_model_key not in ["a", "b"]:
726
+ return jsonify({"error": "Invalid chosen model"}), 400
727
+
728
+ session_data = app.tts_sessions[session_id]
729
+
730
+ # Check if session expired
731
+ if datetime.utcnow() > session_data["expires_at"]:
732
+ cleanup_session(session_id)
733
+ return jsonify({"error": "Session expired"}), 410
734
+
735
+ # Check if already voted
736
+ if session_data["voted"]:
737
+ return jsonify({"error": "Vote already submitted for this session"}), 400
738
+
739
+ # Get model IDs and audio paths
740
+ chosen_id = (
741
+ session_data["model_a"] if chosen_model_key == "a" else session_data["model_b"]
742
+ )
743
+ rejected_id = (
744
+ session_data["model_b"] if chosen_model_key == "a" else session_data["model_a"]
745
+ )
746
+ chosen_audio_path = (
747
+ session_data["audio_a"] if chosen_model_key == "a" else session_data["audio_b"]
748
+ )
749
+ rejected_audio_path = (
750
+ session_data["audio_b"] if chosen_model_key == "a" else session_data["audio_a"]
751
+ )
752
+
753
+ # Calculate session duration and gather analytics data
754
+ vote_time = datetime.utcnow()
755
+ session_duration = (vote_time - session_data["created_at"]).total_seconds()
756
+ client_ip = get_client_ip()
757
+ user_agent = request.headers.get('User-Agent')
758
+ cache_hit = session_data.get("cache_hit", False)
759
+
760
+ # Record vote in database with analytics data
761
+ vote, error = record_vote(
762
+ current_user.id,
763
+ session_data["text"],
764
+ chosen_id,
765
+ rejected_id,
766
+ ModelType.TTS,
767
+ session_duration=session_duration,
768
+ ip_address=client_ip,
769
+ user_agent=user_agent,
770
+ generation_date=session_data["created_at"],
771
+ cache_hit=cache_hit
772
+ )
773
+
774
+ if error:
775
+ return jsonify({"error": error}), 500
776
+
777
+ # --- Save preference data ---
778
+ try:
779
+ vote_uuid = str(uuid.uuid4())
780
+ vote_dir = os.path.join("./votes", vote_uuid)
781
+ os.makedirs(vote_dir, exist_ok=True)
782
+
783
+ # Copy audio files
784
+ shutil.copy(chosen_audio_path, os.path.join(vote_dir, "chosen.wav"))
785
+ shutil.copy(rejected_audio_path, os.path.join(vote_dir, "rejected.wav"))
786
+
787
+ # Create metadata
788
+ chosen_model_obj = Model.query.get(chosen_id)
789
+ rejected_model_obj = Model.query.get(rejected_id)
790
+ metadata = {
791
+ "text": session_data["text"],
792
+ "chosen_model": chosen_model_obj.name if chosen_model_obj else "Unknown",
793
+ "chosen_model_id": chosen_model_obj.id if chosen_model_obj else "Unknown",
794
+ "rejected_model": rejected_model_obj.name if rejected_model_obj else "Unknown",
795
+ "rejected_model_id": rejected_model_obj.id if rejected_model_obj else "Unknown",
796
+ "session_id": session_id,
797
+ "timestamp": datetime.utcnow().isoformat(),
798
+ "username": current_user.username,
799
+ "model_type": "TTS"
800
+ }
801
+ with open(os.path.join(vote_dir, "metadata.json"), "w") as f:
802
+ json.dump(metadata, f, indent=2)
803
+
804
+ except Exception as e:
805
+ app.logger.error(f"Error saving preference data for vote {session_id}: {str(e)}")
806
+ # Continue even if saving preference data fails, vote is already recorded
807
+
808
+ # Mark session as voted
809
+ session_data["voted"] = True
810
+
811
+ # Return updated models (use previously fetched objects)
812
+ return jsonify(
813
+ {
814
+ "success": True,
815
+ "chosen_model": {"id": chosen_id, "name": chosen_model_obj.name if chosen_model_obj else "Unknown"},
816
+ "rejected_model": {
817
+ "id": rejected_id,
818
+ "name": rejected_model_obj.name if rejected_model_obj else "Unknown",
819
+ },
820
+ "names": {
821
+ "a": (
822
+ chosen_model_obj.name if chosen_model_key == "a" else rejected_model_obj.name
823
+ if chosen_model_obj and rejected_model_obj else "Unknown"
824
+ ),
825
+ "b": (
826
+ rejected_model_obj.name if chosen_model_key == "a" else chosen_model_obj.name
827
+ if chosen_model_obj and rejected_model_obj else "Unknown"
828
+ ),
829
+ },
830
+ }
831
+ )
832
+
833
+
834
+ def cleanup_session(session_id):
835
+ """Remove session and its audio files"""
836
+ if session_id in app.tts_sessions:
837
+ session = app.tts_sessions[session_id]
838
+
839
+ # Remove audio files
840
+ for audio_file in [session["audio_a"], session["audio_b"]]:
841
+ if os.path.exists(audio_file):
842
+ try:
843
+ os.remove(audio_file)
844
+ except Exception as e:
845
+ app.logger.error(f"Error removing audio file: {str(e)}")
846
+
847
+ # Remove session
848
+ del app.tts_sessions[session_id]
849
+
850
+
851
+ @app.route("/api/conversational/generate", methods=["POST"])
852
+ @limiter.limit("5 per minute")
853
+ def generate_podcast():
854
+ # If verification not setup, handle it first
855
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
856
+ return jsonify({"error": "Turnstile verification required"}), 403
857
+
858
+ # Require user to be logged in to generate audio
859
+ if not current_user.is_authenticated:
860
+ return jsonify({"error": "You must be logged in to generate audio"}), 401
861
+
862
+ data = request.json
863
+ script = data.get("script")
864
+
865
+ if not script or not isinstance(script, list) or len(script) < 2:
866
+ return jsonify({"error": "Invalid script format or too short"}), 400
867
+
868
+ # Validate script format
869
+ for line in script:
870
+ if not isinstance(line, dict) or "text" not in line or "speaker_id" not in line:
871
+ return (
872
+ jsonify(
873
+ {
874
+ "error": "Invalid script line format. Each line must have text and speaker_id"
875
+ }
876
+ ),
877
+ 400,
878
+ )
879
+ if (
880
+ not line["text"]
881
+ or not isinstance(line["speaker_id"], int)
882
+ or line["speaker_id"] not in [0, 1]
883
+ ):
884
+ return (
885
+ jsonify({"error": "Invalid script content. Speaker ID must be 0 or 1"}),
886
+ 400,
887
+ )
888
+
889
+ # Get two conversational models (currently only CSM and PlayDialog)
890
+ available_models = Model.query.filter_by(
891
+ model_type=ModelType.CONVERSATIONAL, is_active=True
892
+ ).all()
893
+
894
+ if len(available_models) < 2:
895
+ return jsonify({"error": "Not enough conversational models available"}), 500
896
+
897
+ selected_models = get_weighted_random_models(available_models, 2, ModelType.CONVERSATIONAL)
898
+
899
+ try:
900
+ # Generate audio for both models concurrently
901
+ audio_files = []
902
+ model_ids = []
903
+
904
+ # Function to process a single model
905
+ def process_model(model):
906
+ # Call conversational TTS service
907
+ audio_content = predict_tts(script, model.id)
908
+
909
+ # Save to temp file with unique name
910
+ file_uuid = str(uuid.uuid4())
911
+ dest_path = os.path.join(TEMP_AUDIO_DIR, f"{file_uuid}.wav")
912
+
913
+ with open(dest_path, "wb") as f:
914
+ f.write(audio_content)
915
+
916
+ return {"model_id": model.id, "audio_path": dest_path}
917
+
918
+ # Use ThreadPoolExecutor to process models concurrently
919
+ with ThreadPoolExecutor(max_workers=2) as executor:
920
+ results = list(executor.map(process_model, selected_models))
921
+
922
+ # Extract results
923
+ for result in results:
924
+ model_ids.append(result["model_id"])
925
+ audio_files.append(result["audio_path"])
926
+
927
+ # Create session
928
+ session_id = str(uuid.uuid4())
929
+ script_text = " ".join([line["text"] for line in script])
930
+ app.conversational_sessions[session_id] = {
931
+ "model_a": model_ids[0],
932
+ "model_b": model_ids[1],
933
+ "audio_a": audio_files[0],
934
+ "audio_b": audio_files[1],
935
+ "text": script_text[:1000], # Limit text length
936
+ "created_at": datetime.utcnow(),
937
+ "expires_at": datetime.utcnow() + timedelta(minutes=30),
938
+ "voted": False,
939
+ "script": script,
940
+ "cache_hit": False, # Conversational is always generated on-demand
941
+ }
942
+
943
+ # Return audio file paths and session
944
+ return jsonify(
945
+ {
946
+ "session_id": session_id,
947
+ "audio_a": f"/api/conversational/audio/{session_id}/a",
948
+ "audio_b": f"/api/conversational/audio/{session_id}/b",
949
+ "expires_in": 1800, # 30 minutes in seconds
950
+ }
951
+ )
952
+
953
+ except Exception as e:
954
+ app.logger.error(f"Conversational generation error: {str(e)}")
955
+ return jsonify({"error": f"Failed to generate podcast: {str(e)}"}), 500
956
+
957
+
958
+ @app.route("/api/conversational/audio/<session_id>/<model_key>")
959
+ def get_podcast_audio(session_id, model_key):
960
+ # If verification not setup, handle it first
961
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
962
+ return jsonify({"error": "Turnstile verification required"}), 403
963
+
964
+ if session_id not in app.conversational_sessions:
965
+ return jsonify({"error": "Invalid or expired session"}), 404
966
+
967
+ session_data = app.conversational_sessions[session_id]
968
+
969
+ # Check if session expired
970
+ if datetime.utcnow() > session_data["expires_at"]:
971
+ cleanup_conversational_session(session_id)
972
+ return jsonify({"error": "Session expired"}), 410
973
+
974
+ if model_key == "a":
975
+ audio_path = session_data["audio_a"]
976
+ elif model_key == "b":
977
+ audio_path = session_data["audio_b"]
978
+ else:
979
+ return jsonify({"error": "Invalid model key"}), 400
980
+
981
+ # Check if file exists
982
+ if not os.path.exists(audio_path):
983
+ return jsonify({"error": "Audio file not found"}), 404
984
+
985
+ return send_file(audio_path, mimetype="audio/wav")
986
+
987
+
988
+ @app.route("/api/conversational/vote", methods=["POST"])
989
+ @limiter.limit("30 per minute")
990
+ def submit_podcast_vote():
991
+ # If verification not setup, handle it first
992
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
993
+ return jsonify({"error": "Turnstile verification required"}), 403
994
+
995
+ # Require user to be logged in to vote
996
+ if not current_user.is_authenticated:
997
+ return jsonify({"error": "You must be logged in to vote"}), 401
998
+
999
+ # Security checks for vote manipulation prevention
1000
+ client_ip = get_client_ip()
1001
+ vote_allowed, security_reason, security_score = is_vote_allowed(current_user.id, client_ip)
1002
+
1003
+ if not vote_allowed:
1004
+ app.logger.warning(f"Conversational vote blocked for user {current_user.username} (ID: {current_user.id}): {security_reason} (Score: {security_score})")
1005
+ return jsonify({"error": f"Vote not allowed: {security_reason}"}), 403
1006
+
1007
+ data = request.json
1008
+ session_id = data.get("session_id")
1009
+ chosen_model_key = data.get("chosen_model") # "a" or "b"
1010
+
1011
+ if not session_id or session_id not in app.conversational_sessions:
1012
+ return jsonify({"error": "Invalid or expired session"}), 404
1013
+
1014
+ if not chosen_model_key or chosen_model_key not in ["a", "b"]:
1015
+ return jsonify({"error": "Invalid chosen model"}), 400
1016
+
1017
+ session_data = app.conversational_sessions[session_id]
1018
+
1019
+ # Check if session expired
1020
+ if datetime.utcnow() > session_data["expires_at"]:
1021
+ cleanup_conversational_session(session_id)
1022
+ return jsonify({"error": "Session expired"}), 410
1023
+
1024
+ # Check if already voted
1025
+ if session_data["voted"]:
1026
+ return jsonify({"error": "Vote already submitted for this session"}), 400
1027
+
1028
+ # Get model IDs and audio paths
1029
+ chosen_id = (
1030
+ session_data["model_a"] if chosen_model_key == "a" else session_data["model_b"]
1031
+ )
1032
+ rejected_id = (
1033
+ session_data["model_b"] if chosen_model_key == "a" else session_data["model_a"]
1034
+ )
1035
+ chosen_audio_path = (
1036
+ session_data["audio_a"] if chosen_model_key == "a" else session_data["audio_b"]
1037
+ )
1038
+ rejected_audio_path = (
1039
+ session_data["audio_b"] if chosen_model_key == "a" else session_data["audio_a"]
1040
+ )
1041
+
1042
+ # Calculate session duration and gather analytics data
1043
+ vote_time = datetime.utcnow()
1044
+ session_duration = (vote_time - session_data["created_at"]).total_seconds()
1045
+ client_ip = get_client_ip()
1046
+ user_agent = request.headers.get('User-Agent')
1047
+ cache_hit = session_data.get("cache_hit", False)
1048
+
1049
+ # Record vote in database with analytics data
1050
+ vote, error = record_vote(
1051
+ current_user.id,
1052
+ session_data["text"],
1053
+ chosen_id,
1054
+ rejected_id,
1055
+ ModelType.CONVERSATIONAL,
1056
+ session_duration=session_duration,
1057
+ ip_address=client_ip,
1058
+ user_agent=user_agent,
1059
+ generation_date=session_data["created_at"],
1060
+ cache_hit=cache_hit
1061
+ )
1062
+
1063
+ if error:
1064
+ return jsonify({"error": error}), 500
1065
+
1066
+ # --- Save preference data ---\
1067
+ try:
1068
+ vote_uuid = str(uuid.uuid4())
1069
+ vote_dir = os.path.join("./votes", vote_uuid)
1070
+ os.makedirs(vote_dir, exist_ok=True)
1071
+
1072
+ # Copy audio files
1073
+ shutil.copy(chosen_audio_path, os.path.join(vote_dir, "chosen.wav"))
1074
+ shutil.copy(rejected_audio_path, os.path.join(vote_dir, "rejected.wav"))
1075
+
1076
+ # Create metadata
1077
+ chosen_model_obj = Model.query.get(chosen_id)
1078
+ rejected_model_obj = Model.query.get(rejected_id)
1079
+ metadata = {
1080
+ "script": session_data["script"], # Save the full script
1081
+ "chosen_model": chosen_model_obj.name if chosen_model_obj else "Unknown",
1082
+ "chosen_model_id": chosen_model_obj.id if chosen_model_obj else "Unknown",
1083
+ "rejected_model": rejected_model_obj.name if rejected_model_obj else "Unknown",
1084
+ "rejected_model_id": rejected_model_obj.id if rejected_model_obj else "Unknown",
1085
+ "session_id": session_id,
1086
+ "timestamp": datetime.utcnow().isoformat(),
1087
+ "username": current_user.username,
1088
+ "model_type": "CONVERSATIONAL"
1089
+ }
1090
+ with open(os.path.join(vote_dir, "metadata.json"), "w") as f:
1091
+ json.dump(metadata, f, indent=2)
1092
+
1093
+ except Exception as e:
1094
+ app.logger.error(f"Error saving preference data for conversational vote {session_id}: {str(e)}")
1095
+ # Continue even if saving preference data fails, vote is already recorded
1096
+
1097
+ # Mark session as voted
1098
+ session_data["voted"] = True
1099
+
1100
+ # Return updated models (use previously fetched objects)
1101
+ return jsonify(
1102
+ {
1103
+ "success": True,
1104
+ "chosen_model": {"id": chosen_id, "name": chosen_model_obj.name if chosen_model_obj else "Unknown"},
1105
+ "rejected_model": {
1106
+ "id": rejected_id,
1107
+ "name": rejected_model_obj.name if rejected_model_obj else "Unknown",
1108
+ },
1109
+ "names": {
1110
+ "a": Model.query.get(session_data["model_a"]).name,
1111
+ "b": Model.query.get(session_data["model_b"]).name,
1112
+ },
1113
+ }
1114
+ )
1115
+
1116
+
1117
+ def cleanup_conversational_session(session_id):
1118
+ """Remove conversational session and its audio files"""
1119
+ if session_id in app.conversational_sessions:
1120
+ session = app.conversational_sessions[session_id]
1121
+
1122
+ # Remove audio files
1123
+ for audio_file in [session["audio_a"], session["audio_b"]]:
1124
+ if os.path.exists(audio_file):
1125
+ try:
1126
+ os.remove(audio_file)
1127
+ except Exception as e:
1128
+ app.logger.error(
1129
+ f"Error removing conversational audio file: {str(e)}"
1130
+ )
1131
+
1132
+ # Remove session
1133
+ del app.conversational_sessions[session_id]
1134
+
1135
+
1136
+ # Schedule periodic cleanup
1137
+ def setup_cleanup():
1138
+ def cleanup_expired_sessions():
1139
+ with app.app_context(): # Ensure app context for logging
1140
+ current_time = datetime.utcnow()
1141
+ # Cleanup TTS sessions
1142
+ expired_tts_sessions = [
1143
+ sid
1144
+ for sid, session_data in app.tts_sessions.items()
1145
+ if current_time > session_data["expires_at"]
1146
+ ]
1147
+ for sid in expired_tts_sessions:
1148
+ cleanup_session(sid)
1149
+
1150
+ # Cleanup conversational sessions
1151
+ expired_conv_sessions = [
1152
+ sid
1153
+ for sid, session_data in app.conversational_sessions.items()
1154
+ if current_time > session_data["expires_at"]
1155
+ ]
1156
+ for sid in expired_conv_sessions:
1157
+ cleanup_conversational_session(sid)
1158
+ app.logger.info(f"Cleaned up {len(expired_tts_sessions)} TTS and {len(expired_conv_sessions)} conversational sessions.")
1159
+
1160
+ # Also cleanup potentially expired cache entries (e.g., > 1 hour old)
1161
+ # This prevents stale cache entries if generation is slow or failing
1162
+ # cleanup_stale_cache_entries()
1163
+
1164
+ # Run cleanup every 15 minutes
1165
+ scheduler = BackgroundScheduler(daemon=True) # Run scheduler as daemon thread
1166
+ scheduler.add_job(cleanup_expired_sessions, "interval", minutes=15)
1167
+ scheduler.start()
1168
+ print("Cleanup scheduler started") # Use print for startup messages
1169
+
1170
+
1171
+ # Schedule periodic tasks (database sync and preference upload)
1172
+ def setup_periodic_tasks():
1173
+ """Setup periodic database synchronization and preference data upload for Spaces"""
1174
+ if not IS_SPACES:
1175
+ return
1176
+
1177
+ db_path = app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "instance/") # Get relative path
1178
+ preferences_repo_id = "TTS-AGI/arena-v2-preferences"
1179
+ database_repo_id = "TTS-AGI/database-arena-v2"
1180
+ votes_dir = "./votes"
1181
+
1182
+ def sync_database():
1183
+ """Uploads the database to HF dataset"""
1184
+ with app.app_context(): # Ensure app context for logging
1185
+ try:
1186
+ if not os.path.exists(db_path):
1187
+ app.logger.warning(f"Database file not found at {db_path}, skipping sync.")
1188
+ return
1189
+
1190
+ api = HfApi(token=os.getenv("HF_TOKEN"))
1191
+ api.upload_file(
1192
+ path_or_fileobj=db_path,
1193
+ path_in_repo="tts_arena.db",
1194
+ repo_id=database_repo_id,
1195
+ repo_type="dataset",
1196
+ )
1197
+ app.logger.info(f"Database uploaded to {database_repo_id} at {datetime.utcnow()}")
1198
+ except Exception as e:
1199
+ app.logger.error(f"Error uploading database to {database_repo_id}: {str(e)}")
1200
+
1201
+ def sync_preferences_data():
1202
+ """Zips and uploads preference data folders in batches to HF dataset"""
1203
+ with app.app_context(): # Ensure app context for logging
1204
+ if not os.path.isdir(votes_dir):
1205
+ return # Don't log every 5 mins if dir doesn't exist yet
1206
+
1207
+ temp_batch_dir = None # Initialize to manage cleanup
1208
+ temp_individual_zip_dir = None # Initialize for individual zips
1209
+ local_batch_zip_path = None # Initialize for batch zip path
1210
+
1211
+ try:
1212
+ api = HfApi(token=os.getenv("HF_TOKEN"))
1213
+ vote_uuids = [d for d in os.listdir(votes_dir) if os.path.isdir(os.path.join(votes_dir, d))]
1214
+
1215
+ if not vote_uuids:
1216
+ return # No data to process
1217
+
1218
+ app.logger.info(f"Found {len(vote_uuids)} vote directories to process.")
1219
+
1220
+ # Create temporary directories
1221
+ temp_batch_dir = tempfile.mkdtemp(prefix="hf_batch_")
1222
+ temp_individual_zip_dir = tempfile.mkdtemp(prefix="hf_indiv_zips_")
1223
+ app.logger.debug(f"Created temp directories: {temp_batch_dir}, {temp_individual_zip_dir}")
1224
+
1225
+ processed_vote_dirs = []
1226
+ individual_zips_in_batch = []
1227
+
1228
+ # 1. Create individual zips and move them to the batch directory
1229
+ for vote_uuid in vote_uuids:
1230
+ dir_path = os.path.join(votes_dir, vote_uuid)
1231
+ individual_zip_base_path = os.path.join(temp_individual_zip_dir, vote_uuid)
1232
+ individual_zip_path = f"{individual_zip_base_path}.zip"
1233
+
1234
+ try:
1235
+ shutil.make_archive(individual_zip_base_path, 'zip', dir_path)
1236
+ app.logger.debug(f"Created individual zip: {individual_zip_path}")
1237
+
1238
+ # Move the created zip into the batch directory
1239
+ final_individual_zip_path = os.path.join(temp_batch_dir, f"{vote_uuid}.zip")
1240
+ shutil.move(individual_zip_path, final_individual_zip_path)
1241
+ app.logger.debug(f"Moved individual zip to batch dir: {final_individual_zip_path}")
1242
+
1243
+ processed_vote_dirs.append(dir_path) # Mark original dir for later cleanup
1244
+ individual_zips_in_batch.append(final_individual_zip_path)
1245
+
1246
+ except Exception as zip_err:
1247
+ app.logger.error(f"Error creating or moving zip for {vote_uuid}: {str(zip_err)}")
1248
+ # Clean up partial zip if it exists
1249
+ if os.path.exists(individual_zip_path):
1250
+ try:
1251
+ os.remove(individual_zip_path)
1252
+ except OSError:
1253
+ pass
1254
+ # Continue processing other votes
1255
+
1256
+ # Clean up the temporary dir used for creating individual zips
1257
+ shutil.rmtree(temp_individual_zip_dir)
1258
+ temp_individual_zip_dir = None # Mark as cleaned
1259
+ app.logger.debug("Cleaned up temporary individual zip directory.")
1260
+
1261
+ if not individual_zips_in_batch:
1262
+ app.logger.warning("No individual zips were successfully created for batching.")
1263
+ # Clean up batch dir if it's empty or only contains failed attempts
1264
+ if temp_batch_dir and os.path.exists(temp_batch_dir):
1265
+ shutil.rmtree(temp_batch_dir)
1266
+ temp_batch_dir = None
1267
+ return
1268
+
1269
+ # 2. Create the batch zip file
1270
+ batch_timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
1271
+ batch_uuid_short = str(uuid.uuid4())[:8]
1272
+ batch_zip_filename = f"{batch_timestamp}_batch_{batch_uuid_short}.zip"
1273
+ # Create batch zip in a standard temp location first
1274
+ local_batch_zip_base = os.path.join(tempfile.gettempdir(), batch_zip_filename.replace('.zip', ''))
1275
+ local_batch_zip_path = f"{local_batch_zip_base}.zip"
1276
+
1277
+ app.logger.info(f"Creating batch zip: {local_batch_zip_path} with {len(individual_zips_in_batch)} individual zips.")
1278
+ shutil.make_archive(local_batch_zip_base, 'zip', temp_batch_dir)
1279
+ app.logger.info(f"Batch zip created successfully: {local_batch_zip_path}")
1280
+
1281
+ # 3. Upload the batch zip file
1282
+ hf_repo_path = f"votes/{year}/{month}/{batch_zip_filename}"
1283
+ app.logger.info(f"Uploading batch zip to HF Hub: {preferences_repo_id}/{hf_repo_path}")
1284
+
1285
+ api.upload_file(
1286
+ path_or_fileobj=local_batch_zip_path,
1287
+ path_in_repo=hf_repo_path,
1288
+ repo_id=preferences_repo_id,
1289
+ repo_type="dataset",
1290
+ commit_message=f"Add batch preference data {batch_zip_filename} ({len(individual_zips_in_batch)} votes)"
1291
+ )
1292
+ app.logger.info(f"Successfully uploaded batch {batch_zip_filename} to {preferences_repo_id}")
1293
+
1294
+ # 4. Cleanup after successful upload
1295
+ app.logger.info("Cleaning up local files after successful upload.")
1296
+ # Remove original vote directories that were successfully zipped and uploaded
1297
+ for dir_path in processed_vote_dirs:
1298
+ try:
1299
+ shutil.rmtree(dir_path)
1300
+ app.logger.debug(f"Removed original vote directory: {dir_path}")
1301
+ except OSError as e:
1302
+ app.logger.error(f"Error removing processed vote directory {dir_path}: {str(e)}")
1303
+
1304
+ # Remove the temporary batch directory (containing the individual zips)
1305
+ shutil.rmtree(temp_batch_dir)
1306
+ temp_batch_dir = None
1307
+ app.logger.debug("Removed temporary batch directory.")
1308
+
1309
+ # Remove the local batch zip file
1310
+ os.remove(local_batch_zip_path)
1311
+ local_batch_zip_path = None
1312
+ app.logger.debug("Removed local batch zip file.")
1313
+
1314
+ app.logger.info(f"Finished preference data sync. Uploaded batch {batch_zip_filename}.")
1315
+
1316
+ except Exception as e:
1317
+ app.logger.error(f"Error during preference data batch sync: {str(e)}", exc_info=True)
1318
+ # If upload failed, the local batch zip might exist, clean it up.
1319
+ if local_batch_zip_path and os.path.exists(local_batch_zip_path):
1320
+ try:
1321
+ os.remove(local_batch_zip_path)
1322
+ app.logger.debug("Cleaned up local batch zip after failed upload.")
1323
+ except OSError as clean_err:
1324
+ app.logger.error(f"Error cleaning up batch zip after failed upload: {clean_err}")
1325
+ # Do NOT remove temp_batch_dir if it exists; its contents will be retried next time.
1326
+ # Do NOT remove original vote directories if upload failed.
1327
+
1328
+ finally:
1329
+ # Final cleanup for temporary directories in case of unexpected exits
1330
+ if temp_individual_zip_dir and os.path.exists(temp_individual_zip_dir):
1331
+ try:
1332
+ shutil.rmtree(temp_individual_zip_dir)
1333
+ except Exception as final_clean_err:
1334
+ app.logger.error(f"Error in final cleanup (indiv zips): {final_clean_err}")
1335
+ # Only clean up batch dir in finally block if it *wasn't* kept intentionally after upload failure
1336
+ if temp_batch_dir and os.path.exists(temp_batch_dir):
1337
+ # Check if an upload attempt happened and failed
1338
+ upload_failed = 'e' in locals() and isinstance(e, Exception) # Crude check if exception occurred
1339
+ if not upload_failed: # If no upload error or upload succeeded, clean up
1340
+ try:
1341
+ shutil.rmtree(temp_batch_dir)
1342
+ except Exception as final_clean_err:
1343
+ app.logger.error(f"Error in final cleanup (batch dir): {final_clean_err}")
1344
+ else:
1345
+ app.logger.warning("Keeping temporary batch directory due to upload failure for next attempt.")
1346
+
1347
+
1348
+ # Schedule periodic tasks
1349
+ scheduler = BackgroundScheduler()
1350
+ # Sync database less frequently if needed, e.g., every 15 minutes
1351
+ scheduler.add_job(sync_database, "interval", minutes=15, id="sync_db_job")
1352
+ # Sync preferences more frequently
1353
+ scheduler.add_job(sync_preferences_data, "interval", minutes=5, id="sync_pref_job")
1354
+ scheduler.start()
1355
+ print("Periodic tasks scheduler started (DB sync and Preferences upload)") # Use print for startup
1356
+
1357
+
1358
+ @app.cli.command("init-db")
1359
+ def init_db():
1360
+ """Initialize the database."""
1361
+ with app.app_context():
1362
+ db.create_all()
1363
+ print("Database initialized!")
1364
+
1365
+
1366
+ @app.route("/api/toggle-leaderboard-visibility", methods=["POST"])
1367
+ def toggle_leaderboard_visibility():
1368
+ """Toggle whether the current user appears in the top voters leaderboard"""
1369
+ if not current_user.is_authenticated:
1370
+ return jsonify({"error": "You must be logged in to change this setting"}), 401
1371
+
1372
+ new_status = toggle_user_leaderboard_visibility(current_user.id)
1373
+ if new_status is None:
1374
+ return jsonify({"error": "User not found"}), 404
1375
+
1376
+ return jsonify({
1377
+ "success": True,
1378
+ "visible": new_status,
1379
+ "message": "You are now visible in the voters leaderboard" if new_status else "You are now hidden from the voters leaderboard"
1380
+ })
1381
+
1382
+
1383
+ @app.route("/api/tts/cached-sentences")
1384
+ def get_cached_sentences():
1385
+ """Returns a list of sentences currently available in the TTS cache."""
1386
+ with tts_cache_lock:
1387
+ cached_keys = list(tts_cache.keys())
1388
+ return jsonify(cached_keys)
1389
+
1390
+
1391
+ def get_weighted_random_models(
1392
+ applicable_models: list[Model], num_to_select: int, model_type: ModelType
1393
+ ) -> list[Model]:
1394
+ """
1395
+ Selects a specified number of models randomly from a list of applicable_models,
1396
+ weighting models with fewer votes higher. A smoothing factor is used to ensure
1397
+ the preference is slight and to prevent models with zero votes from being
1398
+ overwhelmingly favored. Models are selected without replacement.
1399
+
1400
+ Assumes len(applicable_models) >= num_to_select, which should be checked by the caller.
1401
+ """
1402
+ model_votes_counts = {}
1403
+ for model in applicable_models:
1404
+ votes = (
1405
+ Vote.query.filter(Vote.model_type == model_type)
1406
+ .filter(or_(Vote.model_chosen == model.id, Vote.model_rejected == model.id))
1407
+ .count()
1408
+ )
1409
+ model_votes_counts[model.id] = votes
1410
+
1411
+ weights = [
1412
+ 1.0 / (model_votes_counts[model.id] + SMOOTHING_FACTOR_MODEL_SELECTION)
1413
+ for model in applicable_models
1414
+ ]
1415
+
1416
+ selected_models_list = []
1417
+ # Create copies to modify during selection process
1418
+ current_candidates = list(applicable_models)
1419
+ current_weights = list(weights)
1420
+
1421
+ # Assumes num_to_select is positive and less than or equal to len(current_candidates)
1422
+ # Callers should ensure this (e.g., len(available_models) >= 2).
1423
+ for _ in range(num_to_select):
1424
+ if not current_candidates: # Safety break
1425
+ app.logger.warning("Not enough candidates left for weighted selection.")
1426
+ break
1427
+
1428
+ chosen_model = random.choices(current_candidates, weights=current_weights, k=1)[0]
1429
+ selected_models_list.append(chosen_model)
1430
+
1431
+ try:
1432
+ idx_to_remove = current_candidates.index(chosen_model)
1433
+ current_candidates.pop(idx_to_remove)
1434
+ current_weights.pop(idx_to_remove)
1435
+ except ValueError:
1436
+ # This should ideally not happen if chosen_model came from current_candidates.
1437
+ app.logger.error(f"Error removing model {chosen_model.id} from weighted selection candidates.")
1438
+ break # Avoid potential issues
1439
+
1440
+ return selected_models_list
1441
+
1442
 
1443
  if __name__ == "__main__":
1444
+ with app.app_context():
1445
+ # Ensure ./instance and ./votes directories exist
1446
+ os.makedirs("instance", exist_ok=True)
1447
+ os.makedirs("./votes", exist_ok=True) # Create votes directory if it doesn't exist
1448
+ os.makedirs(CACHE_AUDIO_DIR, exist_ok=True) # Ensure cache audio dir exists
1449
+
1450
+ # Clean up old cache audio files on startup
1451
+ try:
1452
+ app.logger.info(f"Clearing old cache audio files from {CACHE_AUDIO_DIR}")
1453
+ for filename in os.listdir(CACHE_AUDIO_DIR):
1454
+ file_path = os.path.join(CACHE_AUDIO_DIR, filename)
1455
+ try:
1456
+ if os.path.isfile(file_path) or os.path.islink(file_path):
1457
+ os.unlink(file_path)
1458
+ elif os.path.isdir(file_path):
1459
+ shutil.rmtree(file_path)
1460
+ except Exception as e:
1461
+ app.logger.error(f'Failed to delete {file_path}. Reason: {e}')
1462
+ except Exception as e:
1463
+ app.logger.error(f"Error clearing cache directory {CACHE_AUDIO_DIR}: {e}")
1464
+
1465
+
1466
+ # Download database if it doesn't exist (only on initial space start)
1467
+ if IS_SPACES and not os.path.exists(app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "")):
1468
+ try:
1469
+ print("Database not found, downloading from HF dataset...")
1470
+ hf_hub_download(
1471
+ repo_id="TTS-AGI/database-arena-v2",
1472
+ filename="tts_arena.db",
1473
+ repo_type="dataset",
1474
+ local_dir="instance", # download to instance/
1475
+ token=os.getenv("HF_TOKEN"),
1476
+ )
1477
+ print("Database downloaded successfully ✅")
1478
+ except Exception as e:
1479
+ print(f"Error downloading database from HF dataset: {str(e)} ⚠️")
1480
+
1481
+
1482
+ db.create_all() # Create tables if they don't exist
1483
+ insert_initial_models()
1484
+ # Setup background tasks
1485
+ initialize_tts_cache() # Start populating the cache
1486
+ setup_cleanup()
1487
+ setup_periodic_tasks() # Renamed function call
1488
+
1489
+ # Configure Flask to recognize HTTPS when behind a reverse proxy
1490
+ from werkzeug.middleware.proxy_fix import ProxyFix
1491
+
1492
+ # Apply ProxyFix middleware to handle reverse proxy headers
1493
+ # This ensures Flask generates correct URLs with https scheme
1494
+ # X-Forwarded-Proto header will be used to detect the original protocol
1495
+ app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
1496
+
1497
+ # Force Flask to prefer HTTPS for generated URLs
1498
+ app.config["PREFERRED_URL_SCHEME"] = "https"
1499
+
1500
+ from waitress import serve
1501
+
1502
+ # Configuration for 2 vCPUs:
1503
+ # - threads: typically 4-8 threads per CPU core is a good balance
1504
+ # - connection_limit: maximum concurrent connections
1505
+ # - channel_timeout: prevent hanging connections
1506
+ threads = 12 # 6 threads per vCPU is a good balance for mixed IO/CPU workloads
1507
+
1508
+ if IS_SPACES:
1509
+ serve(
1510
+ app,
1511
+ host="0.0.0.0",
1512
+ port=int(os.environ.get("PORT", 7860)),
1513
+ threads=threads,
1514
+ connection_limit=100,
1515
+ channel_timeout=30,
1516
+ url_scheme='https'
1517
+ )
1518
+ else:
1519
+ print(f"Starting Waitress server with {threads} threads")
1520
+ serve(
1521
+ app,
1522
+ host="0.0.0.0",
1523
+ port=5000,
1524
+ threads=threads,
1525
+ connection_limit=100,
1526
+ channel_timeout=30,
1527
+ url_scheme='https' # Keep https for local dev if using proxy/tunnel
1528
+ )
auth.py CHANGED
@@ -5,6 +5,7 @@ import os
5
  from models import db, User
6
  import requests
7
  from functools import wraps
 
8
 
9
  auth = Blueprint("auth", __name__)
10
  oauth = OAuth()
@@ -50,6 +51,46 @@ def admin_required(f):
50
  return decorated_function
51
 
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  @auth.route("/login")
54
  def login():
55
  # Store the next URL to redirect after login
@@ -75,13 +116,40 @@ def authorize():
75
  return redirect(url_for("arena"))
76
 
77
  user_info = resp.json()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  # Check if user exists, otherwise create
80
  user = User.query.filter_by(hf_id=user_info["id"]).first()
81
  if not user:
82
- user = User(username=user_info["name"], hf_id=user_info["id"])
 
 
 
 
83
  db.session.add(user)
84
  db.session.commit()
 
 
 
 
 
 
85
 
86
  # Log in the user
87
  login_user(user, remember=True)
 
5
  from models import db, User
6
  import requests
7
  from functools import wraps
8
+ from datetime import datetime, timedelta
9
 
10
  auth = Blueprint("auth", __name__)
11
  oauth = OAuth()
 
51
  return decorated_function
52
 
53
 
54
+ def check_account_age(username, min_days=30):
55
+ """
56
+ Check if a Hugging Face account is at least min_days old.
57
+ Returns (is_old_enough, created_date, error_message)
58
+ """
59
+ try:
60
+ # Fetch user overview from HF API
61
+ resp = requests.get(f"https://huggingface.co/api/users/{username}/overview", timeout=10)
62
+
63
+ if not resp.ok:
64
+ return False, None, f"Failed to fetch account information (HTTP {resp.status_code})"
65
+
66
+ user_data = resp.json()
67
+
68
+ if "createdAt" not in user_data:
69
+ return False, None, "Account creation date not available"
70
+
71
+ # Parse the creation date
72
+ created_at_str = user_data["createdAt"]
73
+ # Handle both formats: with and without milliseconds
74
+ try:
75
+ created_at = datetime.fromisoformat(created_at_str.replace('Z', '+00:00'))
76
+ except ValueError:
77
+ # Try without milliseconds
78
+ created_at = datetime.strptime(created_at_str, "%Y-%m-%dT%H:%M:%S.%fZ")
79
+
80
+ # Calculate account age
81
+ account_age = datetime.utcnow() - created_at.replace(tzinfo=None)
82
+ required_age = timedelta(days=min_days)
83
+
84
+ is_old_enough = account_age >= required_age
85
+
86
+ return is_old_enough, created_at, None
87
+
88
+ except requests.RequestException as e:
89
+ return False, None, f"Network error checking account age: {str(e)}"
90
+ except Exception as e:
91
+ return False, None, f"Error parsing account data: {str(e)}"
92
+
93
+
94
  @auth.route("/login")
95
  def login():
96
  # Store the next URL to redirect after login
 
116
  return redirect(url_for("arena"))
117
 
118
  user_info = resp.json()
119
+ username = user_info["name"]
120
+
121
+ # Check account age requirement (30 days minimum)
122
+ is_old_enough, created_date, error_msg = check_account_age(username, min_days=30)
123
+
124
+ if error_msg:
125
+ current_app.logger.warning(f"Account age check failed for {username}: {error_msg}")
126
+ flash("Unable to verify account age. Please try again later.", "error")
127
+ return redirect(url_for("arena"))
128
+
129
+ if not is_old_enough:
130
+ if created_date:
131
+ account_age_days = (datetime.utcnow() - created_date.replace(tzinfo=None)).days
132
+ flash(f"Your Hugging Face account must be at least 30 days old to use TTS Arena. Your account is {account_age_days} days old. Please try again later.", "error")
133
+ else:
134
+ flash("Your Hugging Face account must be at least 30 days old to use TTS Arena.", "error")
135
+ return redirect(url_for("arena"))
136
 
137
  # Check if user exists, otherwise create
138
  user = User.query.filter_by(hf_id=user_info["id"]).first()
139
  if not user:
140
+ user = User(
141
+ username=username,
142
+ hf_id=user_info["id"],
143
+ hf_account_created=created_date.replace(tzinfo=None) if created_date else None
144
+ )
145
  db.session.add(user)
146
  db.session.commit()
147
+ current_app.logger.info(f"Created new user account: {username} (HF account created: {created_date})")
148
+ elif not user.hf_account_created and created_date:
149
+ # Update existing users with missing creation date
150
+ user.hf_account_created = created_date.replace(tzinfo=None)
151
+ db.session.commit()
152
+ current_app.logger.info(f"Updated HF account creation date for {username}: {created_date}")
153
 
154
  # Log in the user
155
  login_user(user, remember=True)
migrate.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Database migration script for TTS Arena analytics columns.
4
+
5
+ Usage:
6
+ python migrate.py database.db
7
+ python migrate.py instance/tts_arena.db
8
+ """
9
+
10
+ import click
11
+ import sqlite3
12
+ import sys
13
+ import os
14
+ from pathlib import Path
15
+
16
+
17
+ def check_column_exists(cursor, table_name, column_name):
18
+ """Check if a column exists in a table."""
19
+ cursor.execute(f"PRAGMA table_info({table_name})")
20
+ columns = [row[1] for row in cursor.fetchall()]
21
+ return column_name in columns
22
+
23
+
24
+ def add_analytics_columns(db_path):
25
+ """Add analytics columns to the vote table."""
26
+ if not os.path.exists(db_path):
27
+ click.echo(f"❌ Database file not found: {db_path}", err=True)
28
+ return False
29
+
30
+ try:
31
+ # Connect to the database
32
+ conn = sqlite3.connect(db_path)
33
+ cursor = conn.cursor()
34
+
35
+ # Check if vote table exists
36
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='vote'")
37
+ if not cursor.fetchone():
38
+ click.echo("❌ Vote table not found in database", err=True)
39
+ return False
40
+
41
+ # Define the columns to add to vote table
42
+ vote_columns_to_add = [
43
+ ("session_duration_seconds", "REAL"),
44
+ ("ip_address_partial", "VARCHAR(20)"),
45
+ ("user_agent", "VARCHAR(500)"),
46
+ ("generation_date", "DATETIME"),
47
+ ("cache_hit", "BOOLEAN")
48
+ ]
49
+
50
+ # Define the columns to add to user table
51
+ user_columns_to_add = [
52
+ ("hf_account_created", "DATETIME")
53
+ ]
54
+
55
+ added_columns = []
56
+ skipped_columns = []
57
+
58
+ # Add vote table columns
59
+ click.echo("📊 Processing vote table columns...")
60
+ for column_name, column_type in vote_columns_to_add:
61
+ if check_column_exists(cursor, "vote", column_name):
62
+ skipped_columns.append(f"vote.{column_name}")
63
+ click.echo(f"⏭️ Column 'vote.{column_name}' already exists, skipping")
64
+ else:
65
+ try:
66
+ cursor.execute(f"ALTER TABLE vote ADD COLUMN {column_name} {column_type}")
67
+ added_columns.append(f"vote.{column_name}")
68
+ click.echo(f"✅ Added column 'vote.{column_name}' ({column_type})")
69
+ except sqlite3.Error as e:
70
+ click.echo(f"❌ Failed to add column 'vote.{column_name}': {e}", err=True)
71
+ conn.rollback()
72
+ return False
73
+
74
+ # Add user table columns
75
+ click.echo("👤 Processing user table columns...")
76
+ for column_name, column_type in user_columns_to_add:
77
+ if check_column_exists(cursor, "user", column_name):
78
+ skipped_columns.append(f"user.{column_name}")
79
+ click.echo(f"⏭️ Column 'user.{column_name}' already exists, skipping")
80
+ else:
81
+ try:
82
+ cursor.execute(f"ALTER TABLE user ADD COLUMN {column_name} {column_type}")
83
+ added_columns.append(f"user.{column_name}")
84
+ click.echo(f"✅ Added column 'user.{column_name}' ({column_type})")
85
+ except sqlite3.Error as e:
86
+ click.echo(f"❌ Failed to add column 'user.{column_name}': {e}", err=True)
87
+ conn.rollback()
88
+ return False
89
+
90
+ # Commit the changes
91
+ conn.commit()
92
+ conn.close()
93
+
94
+ # Summary
95
+ if added_columns:
96
+ click.echo(f"\n🎉 Successfully added {len(added_columns)} analytics columns:")
97
+ for col in added_columns:
98
+ click.echo(f" • {col}")
99
+
100
+ if skipped_columns:
101
+ click.echo(f"\n⏭️ Skipped {len(skipped_columns)} existing columns:")
102
+ for col in skipped_columns:
103
+ click.echo(f" • {col}")
104
+
105
+ if not added_columns and not skipped_columns:
106
+ click.echo("❌ No columns were processed")
107
+ return False
108
+
109
+ click.echo(f"\n✨ Migration completed successfully!")
110
+ return True
111
+
112
+ except sqlite3.Error as e:
113
+ click.echo(f"❌ Database error: {e}", err=True)
114
+ return False
115
+ except Exception as e:
116
+ click.echo(f"❌ Unexpected error: {e}", err=True)
117
+ return False
118
+
119
+
120
+ @click.command()
121
+ @click.argument('database_path', type=click.Path())
122
+ @click.option('--dry-run', is_flag=True, help='Show what would be done without making changes')
123
+ @click.option('--backup', is_flag=True, help='Create a backup before migration')
124
+ def migrate(database_path, dry_run, backup):
125
+ """
126
+ Add analytics columns to the TTS Arena database.
127
+
128
+ DATABASE_PATH: Path to the SQLite database file (e.g., instance/tts_arena.db)
129
+ """
130
+ click.echo("🚀 TTS Arena Analytics Migration Tool")
131
+ click.echo("=" * 40)
132
+
133
+ # Resolve the database path
134
+ db_path = Path(database_path).resolve()
135
+ click.echo(f"📁 Database: {db_path}")
136
+
137
+ if not db_path.exists():
138
+ click.echo(f"❌ Database file not found: {db_path}", err=True)
139
+ sys.exit(1)
140
+
141
+ # Create backup if requested
142
+ if backup:
143
+ backup_path = db_path.with_suffix(f"{db_path.suffix}.backup")
144
+ try:
145
+ import shutil
146
+ shutil.copy2(db_path, backup_path)
147
+ click.echo(f"💾 Backup created: {backup_path}")
148
+ except Exception as e:
149
+ click.echo(f"❌ Failed to create backup: {e}", err=True)
150
+ sys.exit(1)
151
+
152
+ if dry_run:
153
+ click.echo("\n🔍 DRY RUN MODE - No changes will be made")
154
+ click.echo("The following columns would be added to the 'vote' table:")
155
+ click.echo(" • session_duration_seconds (REAL)")
156
+ click.echo(" • ip_address_partial (VARCHAR(20))")
157
+ click.echo(" • user_agent (VARCHAR(500))")
158
+ click.echo(" • generation_date (DATETIME)")
159
+ click.echo(" • cache_hit (BOOLEAN)")
160
+ click.echo("\nThe following columns would be added to the 'user' table:")
161
+ click.echo(" • hf_account_created (DATETIME)")
162
+ click.echo("\nRun without --dry-run to apply changes.")
163
+ return
164
+
165
+ # Confirm before proceeding
166
+ if not click.confirm(f"\n⚠️ This will modify the database at {db_path}. Continue?"):
167
+ click.echo("❌ Migration cancelled")
168
+ sys.exit(0)
169
+
170
+ # Perform the migration
171
+ click.echo("\n🔧 Starting migration...")
172
+ success = add_analytics_columns(str(db_path))
173
+
174
+ if success:
175
+ click.echo("\n🎊 Migration completed successfully!")
176
+ click.echo("You can now restart your TTS Arena application to use analytics features.")
177
+ else:
178
+ click.echo("\n💥 Migration failed!")
179
+ sys.exit(1)
180
+
181
+
182
+ if __name__ == "__main__":
183
+ migrate()
models.py CHANGED
@@ -2,7 +2,8 @@ from flask_sqlalchemy import SQLAlchemy
2
  from flask_login import UserMixin
3
  from datetime import datetime
4
  import math
5
- from sqlalchemy import func
 
6
 
7
  db = SQLAlchemy()
8
 
@@ -12,6 +13,7 @@ class User(db.Model, UserMixin):
12
  username = db.Column(db.String(100), unique=True, nullable=False)
13
  hf_id = db.Column(db.String(100), unique=True, nullable=False)
14
  join_date = db.Column(db.DateTime, default=datetime.utcnow)
 
15
  votes = db.relationship("Vote", backref="user", lazy=True)
16
  show_in_leaderboard = db.Column(db.Boolean, default=True)
17
 
@@ -63,6 +65,13 @@ class Vote(db.Model):
63
  db.String(100), db.ForeignKey("model.id"), nullable=False
64
  )
65
  model_type = db.Column(db.String(20), nullable=False) # 'tts' or 'conversational'
 
 
 
 
 
 
 
66
 
67
  chosen = db.relationship(
68
  "Model",
@@ -105,15 +114,49 @@ def calculate_elo_change(winner_elo, loser_elo, k_factor=32):
105
  return winner_new_elo, loser_new_elo
106
 
107
 
108
- def record_vote(user_id, text, chosen_model_id, rejected_model_id, model_type):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  """Record a vote and update Elo ratings."""
110
  # Create the vote
111
  vote = Vote(
112
- user_id=user_id, # Can be None for anonymous votes
113
  text=text,
114
  model_chosen=chosen_model_id,
115
  model_rejected=rejected_model_id,
116
  model_type=model_type,
 
 
 
 
 
117
  )
118
  db.session.add(vote)
119
  db.session.flush() # Get the vote ID without committing
@@ -503,6 +546,7 @@ def insert_initial_models():
503
  name="OpenAudio S1",
504
  model_type=ModelType.TTS,
505
  is_open=False,
 
506
  model_url="https://fish.audio/",
507
  ),
508
  ]
 
2
  from flask_login import UserMixin
3
  from datetime import datetime
4
  import math
5
+ from sqlalchemy import func, text
6
+ import logging
7
 
8
  db = SQLAlchemy()
9
 
 
13
  username = db.Column(db.String(100), unique=True, nullable=False)
14
  hf_id = db.Column(db.String(100), unique=True, nullable=False)
15
  join_date = db.Column(db.DateTime, default=datetime.utcnow)
16
+ hf_account_created = db.Column(db.DateTime, nullable=True) # HF account creation date
17
  votes = db.relationship("Vote", backref="user", lazy=True)
18
  show_in_leaderboard = db.Column(db.Boolean, default=True)
19
 
 
65
  db.String(100), db.ForeignKey("model.id"), nullable=False
66
  )
67
  model_type = db.Column(db.String(20), nullable=False) # 'tts' or 'conversational'
68
+
69
+ # New analytics columns - added with temporary checks for migration
70
+ session_duration_seconds = db.Column(db.Float, nullable=True) # Time from generation to vote
71
+ ip_address_partial = db.Column(db.String(20), nullable=True) # IP with last digits removed
72
+ user_agent = db.Column(db.String(500), nullable=True) # Browser/device info
73
+ generation_date = db.Column(db.DateTime, nullable=True) # When audio was generated
74
+ cache_hit = db.Column(db.Boolean, nullable=True) # Whether generation was from cache
75
 
76
  chosen = db.relationship(
77
  "Model",
 
114
  return winner_new_elo, loser_new_elo
115
 
116
 
117
+ def anonymize_ip_address(ip_address):
118
+ """
119
+ Remove the last 1-2 octets from an IP address for privacy compliance.
120
+ Examples:
121
+ - 192.168.1.100 -> 192.168.0.0
122
+ - 2001:db8::1 -> 2001:db8::
123
+ """
124
+ if not ip_address:
125
+ return None
126
+
127
+ try:
128
+ if ':' in ip_address: # IPv6
129
+ # Keep first 4 groups, zero out the rest
130
+ parts = ip_address.split(':')
131
+ if len(parts) >= 4:
132
+ return ':'.join(parts[:4]) + '::'
133
+ return ip_address
134
+ else: # IPv4
135
+ # Keep first 2 octets, zero out last 2
136
+ parts = ip_address.split('.')
137
+ if len(parts) == 4:
138
+ return f"{parts[0]}.{parts[1]}.0.0"
139
+ return ip_address
140
+ except Exception:
141
+ return None
142
+
143
+
144
+ def record_vote(user_id, text, chosen_model_id, rejected_model_id, model_type,
145
+ session_duration=None, ip_address=None, user_agent=None,
146
+ generation_date=None, cache_hit=None):
147
  """Record a vote and update Elo ratings."""
148
  # Create the vote
149
  vote = Vote(
150
+ user_id=user_id, # Required - user must be logged in to vote
151
  text=text,
152
  model_chosen=chosen_model_id,
153
  model_rejected=rejected_model_id,
154
  model_type=model_type,
155
+ session_duration_seconds=session_duration,
156
+ ip_address_partial=anonymize_ip_address(ip_address),
157
+ user_agent=user_agent[:500] if user_agent else None, # Truncate if too long
158
+ generation_date=generation_date,
159
+ cache_hit=cache_hit,
160
  )
161
  db.session.add(vote)
162
  db.session.flush() # Get the vote ID without committing
 
546
  name="OpenAudio S1",
547
  model_type=ModelType.TTS,
548
  is_open=False,
549
+ is_active=False, # NOTE: Waiting to receive a pool of voices
550
  model_url="https://fish.audio/",
551
  ),
552
  ]
security.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Security utilities for TTS Arena to prevent vote manipulation and botting.
3
+ """
4
+
5
+ from datetime import datetime, timedelta
6
+ from models import db, Vote, User
7
+ from sqlalchemy import func, and_, or_
8
+ import logging
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def detect_suspicious_voting_patterns(user_id, hours_back=24, max_votes_per_hour=30):
14
+ """
15
+ Detect if a user has suspicious voting patterns.
16
+ Updated to allow rapid voting for reasonable periods (30 votes/hour = 1 vote every 2 minutes)
17
+ Returns (is_suspicious, reason, vote_count)
18
+ """
19
+ if not user_id:
20
+ return False, None, 0
21
+
22
+ # Check voting frequency over 24 hours
23
+ time_threshold = datetime.utcnow() - timedelta(hours=hours_back)
24
+ recent_votes = Vote.query.filter(
25
+ and_(
26
+ Vote.user_id == user_id,
27
+ Vote.vote_date >= time_threshold
28
+ )
29
+ ).count()
30
+
31
+ # Allow up to 30 votes per hour (720 votes in 24 hours)
32
+ # This allows rapid voting for several hours but catches extended botting
33
+ max_votes_24h = max_votes_per_hour * hours_back
34
+
35
+ if recent_votes > max_votes_24h:
36
+ return True, f"Too many votes: {recent_votes} in {hours_back} hours (max: {max_votes_24h})", recent_votes
37
+
38
+ # Additional check: if someone votes more than 100 times in 3 hours, that's suspicious
39
+ # (100 votes in 3 hours = 1 vote every 1.8 minutes, which is very sustained)
40
+ if hours_back >= 3:
41
+ three_hour_threshold = datetime.utcnow() - timedelta(hours=3)
42
+ votes_3h = Vote.query.filter(
43
+ and_(
44
+ Vote.user_id == user_id,
45
+ Vote.vote_date >= three_hour_threshold
46
+ )
47
+ ).count()
48
+
49
+ if votes_3h > 100:
50
+ return True, f"Excessive voting in short period: {votes_3h} votes in 3 hours", recent_votes
51
+
52
+ return False, None, recent_votes
53
+
54
+
55
+ def detect_model_bias(user_id, model_id, min_votes=5, bias_threshold=0.8):
56
+ """
57
+ Detect if a user consistently votes for a specific model.
58
+ Returns (is_biased, bias_ratio, total_votes_for_model, total_votes)
59
+ """
60
+ if not user_id:
61
+ return False, 0, 0, 0
62
+
63
+ # Get all votes by this user
64
+ total_votes = Vote.query.filter_by(user_id=user_id).count()
65
+
66
+ if total_votes < min_votes:
67
+ return False, 0, 0, total_votes
68
+
69
+ # Get votes where this user chose the specific model
70
+ votes_for_model = Vote.query.filter(
71
+ and_(
72
+ Vote.user_id == user_id,
73
+ Vote.model_chosen == model_id
74
+ )
75
+ ).count()
76
+
77
+ bias_ratio = votes_for_model / total_votes if total_votes > 0 else 0
78
+
79
+ is_biased = bias_ratio >= bias_threshold and total_votes >= min_votes
80
+
81
+ return is_biased, bias_ratio, votes_for_model, total_votes
82
+
83
+
84
+ def detect_coordinated_voting(model_id, hours_back=6, min_users=3, vote_threshold=10):
85
+ """
86
+ Detect coordinated voting campaigns for a specific model.
87
+ Returns (is_coordinated, user_count, vote_count, suspicious_users)
88
+ """
89
+ time_threshold = datetime.utcnow() - timedelta(hours=hours_back)
90
+
91
+ # Get recent votes for this model
92
+ recent_votes = db.session.query(Vote.user_id).filter(
93
+ and_(
94
+ Vote.model_chosen == model_id,
95
+ Vote.vote_date >= time_threshold
96
+ )
97
+ ).all()
98
+
99
+ if len(recent_votes) < vote_threshold:
100
+ return False, 0, len(recent_votes), []
101
+
102
+ # Count unique users
103
+ unique_users = set(vote.user_id for vote in recent_votes if vote.user_id)
104
+ user_count = len(unique_users)
105
+
106
+ # Check if multiple users are voting for the same model in a short time
107
+ if user_count >= min_users and len(recent_votes) >= vote_threshold:
108
+ # Get user details for suspicious users
109
+ suspicious_users = []
110
+ for user_id in unique_users:
111
+ user_votes_for_model = Vote.query.filter(
112
+ and_(
113
+ Vote.user_id == user_id,
114
+ Vote.model_chosen == model_id,
115
+ Vote.vote_date >= time_threshold
116
+ )
117
+ ).count()
118
+
119
+ if user_votes_for_model > 1: # Multiple votes for same model in short time
120
+ user = User.query.get(user_id)
121
+ if user:
122
+ suspicious_users.append({
123
+ 'user_id': user_id,
124
+ 'username': user.username,
125
+ 'votes_for_model': user_votes_for_model,
126
+ 'account_age_days': (datetime.utcnow() - user.join_date).days if user.join_date else None
127
+ })
128
+
129
+ return True, user_count, len(recent_votes), suspicious_users
130
+
131
+ return False, user_count, len(recent_votes), []
132
+
133
+
134
+ def detect_rapid_voting(user_id, min_interval_seconds=3):
135
+ """
136
+ Detect if a user is voting too rapidly (potential bot behavior).
137
+ This allows rapid voting (3+ seconds) for reasonable periods, but flags
138
+ extended periods of very rapid voting that indicate bot behavior.
139
+ Returns (is_rapid, intervals, avg_interval)
140
+ """
141
+ if not user_id:
142
+ return False, [], 0
143
+
144
+ # Get more recent votes to better analyze patterns (last 50 instead of 10)
145
+ recent_votes = Vote.query.filter_by(user_id=user_id).order_by(
146
+ Vote.vote_date.desc()
147
+ ).limit(50).all()
148
+
149
+ if len(recent_votes) < 50: # Need at least 50 votes to detect patterns
150
+ return False, [], 0
151
+
152
+ # Calculate intervals between votes
153
+ intervals = []
154
+ for i in range(len(recent_votes) - 1):
155
+ interval = (recent_votes[i].vote_date - recent_votes[i + 1].vote_date).total_seconds()
156
+ intervals.append(interval)
157
+
158
+ avg_interval = sum(intervals) / len(intervals) if intervals else 0
159
+
160
+ # More sophisticated bot detection:
161
+ # 1. Count votes with intervals < 3 seconds (very rapid)
162
+ very_rapid_votes = sum(1 for interval in intervals if interval < 3)
163
+
164
+ # 2. Count votes with intervals < 1 second (extremely rapid - likely bot)
165
+ extremely_rapid_votes = sum(1 for interval in intervals if interval < 1)
166
+
167
+ # 3. Check for sustained rapid voting patterns
168
+ # Look for sequences of 10+ votes all under 5 seconds
169
+ sustained_rapid_sequences = 0
170
+ current_sequence = 0
171
+ for interval in intervals:
172
+ if interval < 5:
173
+ current_sequence += 1
174
+ else:
175
+ if current_sequence >= 10: # 10+ votes in a row under 5 seconds
176
+ sustained_rapid_sequences += 1
177
+ current_sequence = 0
178
+
179
+ # Final check for remaining sequence
180
+ if current_sequence >= 10:
181
+ sustained_rapid_sequences += 1
182
+
183
+ # Flag as rapid/bot if:
184
+ # - More than 20% of votes are extremely rapid (< 1 second) OR
185
+ # - More than 60% of votes are very rapid (< 3 seconds) AND there are sustained sequences OR
186
+ # - There are multiple sustained rapid sequences (10+ votes under 5 seconds each)
187
+ total_intervals = len(intervals)
188
+ extremely_rapid_ratio = extremely_rapid_votes / total_intervals if total_intervals > 0 else 0
189
+ very_rapid_ratio = very_rapid_votes / total_intervals if total_intervals > 0 else 0
190
+
191
+ is_rapid = (
192
+ extremely_rapid_ratio > 0.2 or # > 20% extremely rapid
193
+ (very_rapid_ratio > 0.6 and sustained_rapid_sequences > 0) or # > 60% very rapid + sustained
194
+ sustained_rapid_sequences >= 2 # Multiple sustained rapid sequences
195
+ )
196
+
197
+ return is_rapid, intervals, avg_interval
198
+
199
+
200
+ def check_user_security_score(user_id):
201
+ """
202
+ Calculate a security score for a user based on various factors.
203
+ Returns (score, factors) where score is 0-100 (higher = more trustworthy)
204
+ """
205
+ if not user_id:
206
+ return 0, {"error": "No user ID provided"}
207
+
208
+ user = User.query.get(user_id)
209
+ if not user:
210
+ return 0, {"error": "User not found"}
211
+
212
+ factors = {}
213
+ score = 100 # Start with perfect score and deduct points
214
+
215
+ # Account age factor
216
+ if user.join_date:
217
+ account_age_days = (datetime.utcnow() - user.join_date).days
218
+ factors['account_age_days'] = account_age_days
219
+ if account_age_days < 45:
220
+ score -= 30
221
+ elif account_age_days < 90:
222
+ score -= 15
223
+ elif account_age_days < 180:
224
+ score -= 5
225
+ else:
226
+ score -= 20
227
+ factors['account_age_days'] = None
228
+
229
+ # HF account age factor
230
+ if user.hf_account_created:
231
+ hf_age_days = (datetime.utcnow() - user.hf_account_created).days
232
+ factors['hf_account_age_days'] = hf_age_days
233
+ if hf_age_days < 30:
234
+ score -= 25 # This should be caught by auth, but double-check
235
+ elif hf_age_days < 90:
236
+ score -= 10
237
+ else:
238
+ score -= 15
239
+ factors['hf_account_age_days'] = None
240
+
241
+ # Voting pattern analysis
242
+ is_suspicious, reason, vote_count = detect_suspicious_voting_patterns(user_id)
243
+ factors['suspicious_voting'] = is_suspicious
244
+ factors['recent_vote_count'] = vote_count
245
+ if is_suspicious:
246
+ score -= 25
247
+ factors['suspicious_reason'] = reason
248
+
249
+ # Rapid voting check
250
+ is_rapid, intervals, avg_interval = detect_rapid_voting(user_id)
251
+ factors['rapid_voting'] = is_rapid
252
+ factors['avg_vote_interval'] = avg_interval
253
+ if is_rapid:
254
+ score -= 20
255
+
256
+ # Total vote count (very new users with many votes are suspicious)
257
+ total_votes = Vote.query.filter_by(user_id=user_id).count()
258
+ factors['total_votes'] = total_votes
259
+
260
+ if account_age_days and account_age_days < 7 and total_votes > 20:
261
+ score -= 15 # New account with many votes
262
+
263
+ # Model bias detection - check for extreme bias toward any single model
264
+ if total_votes >= 5: # Only check if user has enough votes
265
+ max_bias_ratio = 0
266
+ most_biased_model = None
267
+
268
+ # Get all models this user has voted for
269
+ user_votes = Vote.query.filter_by(user_id=user_id).all()
270
+ model_stats = {}
271
+
272
+ for vote in user_votes:
273
+ chosen_id = vote.model_chosen
274
+ rejected_id = vote.model_rejected
275
+
276
+ # Track appearances and choices
277
+ if chosen_id not in model_stats:
278
+ model_stats[chosen_id] = {'chosen': 0, 'appeared': 0}
279
+ if rejected_id not in model_stats:
280
+ model_stats[rejected_id] = {'chosen': 0, 'appeared': 0}
281
+
282
+ model_stats[chosen_id]['chosen'] += 1
283
+ model_stats[chosen_id]['appeared'] += 1
284
+ model_stats[rejected_id]['appeared'] += 1
285
+
286
+ # Find the highest bias ratio
287
+ for model_id, stats in model_stats.items():
288
+ if stats['appeared'] >= 5: # Only consider models with enough appearances
289
+ bias_ratio = stats['chosen'] / stats['appeared']
290
+ if bias_ratio > max_bias_ratio:
291
+ max_bias_ratio = bias_ratio
292
+ most_biased_model = model_id
293
+
294
+ factors['max_bias_ratio'] = max_bias_ratio
295
+ factors['most_biased_model_id'] = most_biased_model
296
+
297
+ # Deduct points based on bias level
298
+ if max_bias_ratio >= 0.95: # 95%+ bias
299
+ score -= 30
300
+ factors['bias_penalty'] = 'Extreme bias (95%+)'
301
+ elif max_bias_ratio >= 0.9: # 90%+ bias
302
+ score -= 20
303
+ factors['bias_penalty'] = 'Very high bias (90%+)'
304
+ elif max_bias_ratio >= 0.8: # 80%+ bias
305
+ score -= 10
306
+ factors['bias_penalty'] = 'High bias (80%+)'
307
+ else:
308
+ factors['bias_penalty'] = None
309
+ else:
310
+ factors['max_bias_ratio'] = 0
311
+ factors['bias_penalty'] = None
312
+
313
+ # Ensure score doesn't go below 0
314
+ score = max(0, score)
315
+ factors['final_score'] = score
316
+
317
+ return score, factors
318
+
319
+
320
+ def is_vote_allowed(user_id, ip_address=None):
321
+ """
322
+ Check if a vote should be allowed based on security factors.
323
+ Returns (allowed, reason, security_score)
324
+ """
325
+ if not user_id:
326
+ return False, "User not authenticated", 0
327
+
328
+ # Check security score
329
+ score, factors = check_user_security_score(user_id)
330
+
331
+ # Very low scores are blocked
332
+ if score < 20:
333
+ return False, f"Security score too low: {score}/100", score
334
+
335
+ # Check for recent suspicious activity
336
+ if factors.get('suspicious_voting'):
337
+ return False, f"Suspicious voting pattern detected: {factors.get('suspicious_reason')}", score
338
+
339
+ if factors.get('rapid_voting'):
340
+ return False, f"Voting too rapidly (avg interval: {factors.get('avg_vote_interval', 0):.1f}s)", score
341
+
342
+ # Additional IP-based checks could go here
343
+
344
+ return True, "Vote allowed", score
templates/admin/analytics.html ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block extra_head %}
4
+ {{ super() }}
5
+ <style>
6
+ .admin-grid {
7
+ display: grid;
8
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
9
+ gap: 24px;
10
+ margin-bottom: 24px;
11
+ }
12
+
13
+ .stats-grid {
14
+ display: grid;
15
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
16
+ gap: 16px;
17
+ }
18
+
19
+ .stat-item {
20
+ text-align: center;
21
+ padding: 12px;
22
+ background-color: var(--secondary-color);
23
+ border-radius: var(--radius);
24
+ }
25
+
26
+ .stat-value {
27
+ font-size: 24px;
28
+ font-weight: 600;
29
+ color: var(--primary-color);
30
+ margin-bottom: 4px;
31
+ }
32
+
33
+ .stat-label {
34
+ font-size: 12px;
35
+ color: #666;
36
+ text-transform: uppercase;
37
+ letter-spacing: 0.5px;
38
+ }
39
+
40
+ .badge-success {
41
+ background-color: #10b981;
42
+ color: white;
43
+ }
44
+
45
+ .badge-warning {
46
+ background-color: #f59e0b;
47
+ color: white;
48
+ }
49
+
50
+ @media (prefers-color-scheme: dark) {
51
+ .stat-item {
52
+ background-color: rgba(255, 255, 255, 0.05);
53
+ }
54
+
55
+ .stat-label {
56
+ color: #999;
57
+ }
58
+ }
59
+ </style>
60
+ {% endblock %}
61
+
62
+ {% block admin_content %}
63
+ <div class="admin-header">
64
+ <div class="admin-title">Analytics</div>
65
+ </div>
66
+
67
+ <div class="admin-grid">
68
+ <!-- Session Duration Statistics -->
69
+ <div class="admin-card">
70
+ <div class="admin-card-header">
71
+ <div class="admin-card-title">Session Duration</div>
72
+ </div>
73
+ <div class="admin-card-content">
74
+ <div class="stats-grid">
75
+ <div class="stat-item">
76
+ <div class="stat-value">{{ analytics_stats.duration.avg }}s</div>
77
+ <div class="stat-label">Average Duration</div>
78
+ </div>
79
+ <div class="stat-item">
80
+ <div class="stat-value">{{ analytics_stats.duration.min }}s</div>
81
+ <div class="stat-label">Minimum Duration</div>
82
+ </div>
83
+ <div class="stat-item">
84
+ <div class="stat-value">{{ analytics_stats.duration.max }}s</div>
85
+ <div class="stat-label">Maximum Duration</div>
86
+ </div>
87
+ <div class="stat-item">
88
+ <div class="stat-value">{{ analytics_stats.duration.total }}</div>
89
+ <div class="stat-label">Total Sessions</div>
90
+ </div>
91
+ </div>
92
+ </div>
93
+ </div>
94
+
95
+ <!-- Cache Hit Statistics -->
96
+ <div class="admin-card">
97
+ <div class="admin-card-header">
98
+ <div class="admin-card-title">Cache Performance</div>
99
+ </div>
100
+ <div class="admin-card-content">
101
+ <div class="stats-grid">
102
+ <div class="stat-item">
103
+ <div class="stat-value">{{ analytics_stats.cache.hits }}</div>
104
+ <div class="stat-label">Cache Hits</div>
105
+ </div>
106
+ <div class="stat-item">
107
+ <div class="stat-value">{{ analytics_stats.cache.misses }}</div>
108
+ <div class="stat-label">Cache Misses</div>
109
+ </div>
110
+ <div class="stat-item">
111
+ <div class="stat-value">
112
+ {% if analytics_stats.cache.total > 0 %}
113
+ {{ "%.1f"|format((analytics_stats.cache.hits / analytics_stats.cache.total) * 100) }}%
114
+ {% else %}
115
+ 0%
116
+ {% endif %}
117
+ </div>
118
+ <div class="stat-label">Hit Rate</div>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </div>
124
+
125
+ <!-- Top IP Addresses -->
126
+ <div class="admin-card">
127
+ <div class="admin-card-header">
128
+ <div class="admin-card-title">Top IP Address Regions (Anonymized)</div>
129
+ </div>
130
+ <div class="table-responsive">
131
+ <table class="admin-table">
132
+ <thead>
133
+ <tr>
134
+ <th>IP Range</th>
135
+ <th>Vote Count</th>
136
+ </tr>
137
+ </thead>
138
+ <tbody>
139
+ {% for ip_stat in analytics_stats.top_ips %}
140
+ <tr>
141
+ <td>{{ ip_stat.ip }}</td>
142
+ <td>{{ ip_stat.count }}</td>
143
+ </tr>
144
+ {% endfor %}
145
+ </tbody>
146
+ </table>
147
+ </div>
148
+ </div>
149
+
150
+ <!-- Browser Statistics -->
151
+ <div class="admin-card">
152
+ <div class="admin-card-header">
153
+ <div class="admin-card-title">Browser/Device Statistics</div>
154
+ </div>
155
+ <div class="table-responsive">
156
+ <table class="admin-table">
157
+ <thead>
158
+ <tr>
159
+ <th>Browser/Device</th>
160
+ <th>Vote Count</th>
161
+ </tr>
162
+ </thead>
163
+ <tbody>
164
+ {% for browser_stat in analytics_stats.browsers %}
165
+ <tr>
166
+ <td>{{ browser_stat.browser }}</td>
167
+ <td>{{ browser_stat.count }}</td>
168
+ </tr>
169
+ {% endfor %}
170
+ </tbody>
171
+ </table>
172
+ </div>
173
+ </div>
174
+
175
+ <!-- Recent Votes with Analytics -->
176
+ <div class="admin-card">
177
+ <div class="admin-card-header">
178
+ <div class="admin-card-title">Recent Votes with Analytics Data</div>
179
+ </div>
180
+ <div class="table-responsive">
181
+ <table class="admin-table">
182
+ <thead>
183
+ <tr>
184
+ <th>ID</th>
185
+ <th>Date</th>
186
+ <th>User</th>
187
+ <th>Type</th>
188
+ <th>Duration (s)</th>
189
+ <th>IP Range</th>
190
+ <th>Cache Hit</th>
191
+ <th>Chosen Model</th>
192
+ <th>Rejected Model</th>
193
+ </tr>
194
+ </thead>
195
+ <tbody>
196
+ {% for vote in analytics_stats.recent_votes %}
197
+ <tr>
198
+ <td>{{ vote.id }}</td>
199
+ <td>{{ vote.vote_date.strftime('%Y-%m-%d %H:%M') }}</td>
200
+ <td>{{ vote.username or 'Anonymous' }}</td>
201
+ <td>{{ vote.model_type }}</td>
202
+ <td>{{ vote.duration }}</td>
203
+ <td>{{ vote.ip }}</td>
204
+ <td>
205
+ {% if vote.cache_hit %}
206
+ <span class="badge badge-success">Yes</span>
207
+ {% else %}
208
+ <span class="badge badge-warning">No</span>
209
+ {% endif %}
210
+ </td>
211
+ <td>{{ vote.chosen_model }}</td>
212
+ <td>{{ vote.rejected_model }}</td>
213
+ </tr>
214
+ {% endfor %}
215
+ </tbody>
216
+ </table>
217
+ </div>
218
+ </div>
219
+
220
+ {% endblock %}
templates/admin/base.html CHANGED
@@ -526,6 +526,14 @@
526
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M18 12V8"/><path d="M13 12v-2"/><path d="M8 12v-5"/></svg>
527
  Statistics
528
  </a>
 
 
 
 
 
 
 
 
529
  <a href="{{ url_for('admin.activity') }}" class="admin-nav-item {% if request.endpoint == 'admin.activity' %}active{% endif %}">
530
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12h-8v8h8v-8z"/><path d="M3 21V3h18v9"/><path d="M12 3v6H3"/></svg>
531
  Activity
 
526
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M18 12V8"/><path d="M13 12v-2"/><path d="M8 12v-5"/></svg>
527
  Statistics
528
  </a>
529
+ <a href="{{ url_for('admin.analytics') }}" class="admin-nav-item {% if request.endpoint == 'admin.analytics' %}active{% endif %}">
530
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
531
+ Analytics
532
+ </a>
533
+ <a href="{{ url_for('admin.security') }}" class="admin-nav-item {% if request.endpoint == 'admin.security' %}active{% endif %}">
534
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
535
+ Security
536
+ </a>
537
  <a href="{{ url_for('admin.activity') }}" class="admin-nav-item {% if request.endpoint == 'admin.activity' %}active{% endif %}">
538
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12h-8v8h8v-8z"/><path d="M3 21V3h18v9"/><path d="M12 3v6H3"/></svg>
539
  Activity
templates/admin/security.html ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block extra_head %}
4
+ {{ super() }}
5
+ <style>
6
+ .security-alert {
7
+ padding: 12px 16px;
8
+ border-radius: var(--radius);
9
+ margin-bottom: 16px;
10
+ border-left: 4px solid;
11
+ }
12
+
13
+ .security-alert.high {
14
+ background-color: #fef2f2;
15
+ border-color: #dc2626;
16
+ color: #991b1b;
17
+ }
18
+
19
+ .security-alert.medium {
20
+ background-color: #fffbeb;
21
+ border-color: #d97706;
22
+ color: #92400e;
23
+ }
24
+
25
+ .security-alert.low {
26
+ background-color: #f0f9ff;
27
+ border-color: #0284c7;
28
+ color: #0c4a6e;
29
+ }
30
+
31
+ .score-badge {
32
+ display: inline-block;
33
+ padding: 4px 8px;
34
+ border-radius: 4px;
35
+ font-size: 12px;
36
+ font-weight: 600;
37
+ color: white;
38
+ }
39
+
40
+ .score-high { background-color: #dc2626; }
41
+ .score-medium { background-color: #d97706; }
42
+ .score-low { background-color: #059669; }
43
+
44
+ .factor-list {
45
+ font-size: 12px;
46
+ color: #666;
47
+ margin-top: 4px;
48
+ }
49
+
50
+ .factor-item {
51
+ margin-right: 12px;
52
+ display: inline-block;
53
+ }
54
+
55
+ @media (prefers-color-scheme: dark) {
56
+ .security-alert.high {
57
+ background-color: rgba(220, 38, 38, 0.1);
58
+ color: #fca5a5;
59
+ }
60
+
61
+ .security-alert.medium {
62
+ background-color: rgba(217, 119, 6, 0.1);
63
+ color: #fbbf24;
64
+ }
65
+
66
+ .security-alert.low {
67
+ background-color: rgba(2, 132, 199, 0.1);
68
+ color: #7dd3fc;
69
+ }
70
+
71
+ .factor-list {
72
+ color: #999;
73
+ }
74
+ }
75
+ </style>
76
+ {% endblock %}
77
+
78
+ {% block admin_content %}
79
+ <div class="admin-header">
80
+ <div class="admin-title">Security Monitoring</div>
81
+ </div>
82
+
83
+ <!-- Security Alerts -->
84
+ {% if coordinated_campaigns %}
85
+ <div class="security-alert high">
86
+ <strong>⚠️ Coordinated Voting Detected!</strong>
87
+ {{ coordinated_campaigns|length }} potential voting campaign(s) detected in the last 6 hours.
88
+ </div>
89
+ {% endif %}
90
+
91
+ {% if suspicious_users %}
92
+ <div class="security-alert medium">
93
+ <strong>🔍 Suspicious Users Detected</strong>
94
+ {{ suspicious_users|length }} users with low security scores (< 50/100) found.
95
+ </div>
96
+ {% endif %}
97
+
98
+ {% if biased_users %}
99
+ <div class="security-alert low">
100
+ <strong>📊 Model Bias Detected</strong>
101
+ {{ biased_users|length }} users showing strong bias toward specific models.
102
+ </div>
103
+ {% endif %}
104
+
105
+ <!-- Suspicious Users -->
106
+ <div class="admin-card">
107
+ <div class="admin-card-header">
108
+ <div class="admin-card-title">Suspicious Users (Low Security Scores)</div>
109
+ </div>
110
+ {% if suspicious_users %}
111
+ <div class="table-responsive">
112
+ <table class="admin-table">
113
+ <thead>
114
+ <tr>
115
+ <th>Username</th>
116
+ <th>Security Score</th>
117
+ <th>Account Age</th>
118
+ <th>HF Account Age</th>
119
+ <th>Total Votes</th>
120
+ <th>Issues</th>
121
+ </tr>
122
+ </thead>
123
+ <tbody>
124
+ {% for item in suspicious_users %}
125
+ <tr>
126
+ <td>
127
+ <a href="{{ url_for('admin.user_detail', user_id=item.user.id) }}">
128
+ {{ item.user.username }}
129
+ </a>
130
+ </td>
131
+ <td>
132
+ <span class="score-badge {% if item.score < 20 %}score-high{% elif item.score < 40 %}score-medium{% else %}score-low{% endif %}">
133
+ {{ item.score }}/100
134
+ </span>
135
+ </td>
136
+ <td>{{ item.factors.account_age_days or 'Unknown' }} days</td>
137
+ <td>{{ item.factors.hf_account_age_days or 'Unknown' }} days</td>
138
+ <td>{{ item.factors.total_votes or 0 }}</td>
139
+ <td>
140
+ <div class="factor-list">
141
+ {% if item.factors.suspicious_voting %}
142
+ <span class="factor-item">🚨 Suspicious voting</span>
143
+ {% endif %}
144
+ {% if item.factors.rapid_voting %}
145
+ <span class="factor-item">⚡ Rapid voting</span>
146
+ {% endif %}
147
+ {% if item.factors.account_age_days and item.factors.account_age_days < 7 %}
148
+ <span class="factor-item">🆕 New account</span>
149
+ {% endif %}
150
+ {% if item.factors.hf_account_age_days and item.factors.hf_account_age_days < 90 %}
151
+ <span class="factor-item">🔰 New HF account</span>
152
+ {% endif %}
153
+ </div>
154
+ </td>
155
+ </tr>
156
+ {% endfor %}
157
+ </tbody>
158
+ </table>
159
+ </div>
160
+ {% else %}
161
+ <p>No suspicious users detected.</p>
162
+ {% endif %}
163
+ </div>
164
+
165
+ <!-- Coordinated Voting Campaigns -->
166
+ <div class="admin-card">
167
+ <div class="admin-card-header">
168
+ <div class="admin-card-title">Coordinated Voting Campaigns</div>
169
+ </div>
170
+ {% if coordinated_campaigns %}
171
+ {% for campaign in coordinated_campaigns %}
172
+ <div class="security-alert high" style="margin-bottom: 16px;">
173
+ <h4>{{ campaign.model.name }}</h4>
174
+ <p><strong>{{ campaign.vote_count }}</strong> votes from <strong>{{ campaign.user_count }}</strong> users in the last 6 hours</p>
175
+
176
+ {% if campaign.suspicious_users %}
177
+ <div class="table-responsive" style="margin-top: 12px;">
178
+ <table class="admin-table">
179
+ <thead>
180
+ <tr>
181
+ <th>Username</th>
182
+ <th>Votes for Model</th>
183
+ <th>Account Age</th>
184
+ </tr>
185
+ </thead>
186
+ <tbody>
187
+ {% for user in campaign.suspicious_users %}
188
+ <tr>
189
+ <td>{{ user.username }}</td>
190
+ <td>{{ user.votes_for_model }}</td>
191
+ <td>{{ user.account_age_days or 'Unknown' }} days</td>
192
+ </tr>
193
+ {% endfor %}
194
+ </tbody>
195
+ </table>
196
+ </div>
197
+ {% endif %}
198
+ </div>
199
+ {% endfor %}
200
+ {% else %}
201
+ <p>No coordinated voting campaigns detected in the last 6 hours.</p>
202
+ {% endif %}
203
+ </div>
204
+
205
+ <!-- Model Bias Detection -->
206
+ <div class="admin-card">
207
+ <div class="admin-card-header">
208
+ <div class="admin-card-title">Users with Strong Model Bias</div>
209
+ </div>
210
+ {% if biased_users %}
211
+ <div class="table-responsive">
212
+ <table class="admin-table">
213
+ <thead>
214
+ <tr>
215
+ <th>Username</th>
216
+ <th>Favored Model</th>
217
+ <th>Bias Ratio</th>
218
+ <th>Votes for Model</th>
219
+ <th>Total Votes</th>
220
+ </tr>
221
+ </thead>
222
+ <tbody>
223
+ {% for item in biased_users %}
224
+ <tr>
225
+ <td>
226
+ <a href="{{ url_for('admin.user_detail', user_id=item.user.id) }}">
227
+ {{ item.user.username }}
228
+ </a>
229
+ </td>
230
+ <td>{{ item.model.name }}</td>
231
+ <td>
232
+ <span class="score-badge {% if item.bias_ratio > 0.9 %}score-high{% elif item.bias_ratio > 0.8 %}score-medium{% else %}score-low{% endif %}">
233
+ {{ "%.1f"|format(item.bias_ratio * 100) }}%
234
+ </span>
235
+ </td>
236
+ <td>{{ item.votes_for_model }}</td>
237
+ <td>{{ item.total_votes }}</td>
238
+ </tr>
239
+ {% endfor %}
240
+ </tbody>
241
+ </table>
242
+ </div>
243
+ {% else %}
244
+ <p>No users with strong model bias detected.</p>
245
+ {% endif %}
246
+ </div>
247
+
248
+ {% endblock %}
templates/admin/user_detail.html CHANGED
@@ -21,7 +21,11 @@
21
  </div>
22
  <div class="user-detail-row">
23
  <div class="user-detail-label">Join Date:</div>
24
- <div class="user-detail-value">{{ user.join_date.strftime('%Y-%m-%d %H:%M:%S') }}</div>
 
 
 
 
25
  </div>
26
  </div>
27
 
@@ -38,34 +42,99 @@
38
  <div class="stat-title">Conversational Votes</div>
39
  <div class="stat-value">{{ conversational_votes }}</div>
40
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  </div>
42
  </div>
43
 
44
- {% if favorite_models %}
45
  <div class="admin-card">
46
  <div class="admin-card-header">
47
- <div class="admin-card-title">Favorite Models</div>
48
  </div>
49
- <div class="table-responsive">
50
- <table class="admin-table">
51
- <thead>
52
- <tr>
53
- <th>Model</th>
54
- <th>Votes</th>
55
- </tr>
56
- </thead>
57
- <tbody>
58
- {% for model in favorite_models %}
59
- <tr>
60
- <td>{{ model.name }}</td>
61
- <td>{{ model.count }}</td>
62
- </tr>
63
- {% endfor %}
64
- </tbody>
65
- </table>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  </div>
67
  </div>
68
- {% endif %}
 
69
 
70
  {% if recent_votes %}
71
  <div class="admin-card">
@@ -103,6 +172,53 @@
103
  </div>
104
  {% endif %}
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  <style>
107
  .user-detail-row {
108
  display: flex;
@@ -125,6 +241,112 @@
125
  margin-top: 24px;
126
  }
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  .text-truncate {
129
  max-width: 300px;
130
  white-space: nowrap;
@@ -140,6 +362,10 @@
140
  .user-detail-label {
141
  margin-bottom: 4px;
142
  }
 
 
 
 
143
  }
144
  </style>
145
  {% endblock %}
 
21
  </div>
22
  <div class="user-detail-row">
23
  <div class="user-detail-label">Join Date:</div>
24
+ <div class="user-detail-value">{{ user.join_date.strftime('%Y-%m-%d %H:%M:%S') if user.join_date else 'N/A' }}</div>
25
+ </div>
26
+ <div class="user-detail-row">
27
+ <div class="user-detail-label">HF Account Created:</div>
28
+ <div class="user-detail-value">{{ user.hf_account_created.strftime('%Y-%m-%d %H:%M:%S') if user.hf_account_created else 'N/A' }}</div>
29
  </div>
30
  </div>
31
 
 
42
  <div class="stat-title">Conversational Votes</div>
43
  <div class="stat-value">{{ conversational_votes }}</div>
44
  </div>
45
+ <div class="stat-card security-score-card">
46
+ <div class="stat-title">Security Score</div>
47
+ <div class="stat-value">
48
+ {% if security_score < 20 %}
49
+ <span class="security-score high-risk">{{ security_score }}/100</span>
50
+ {% elif security_score < 40 %}
51
+ <span class="security-score medium-risk">{{ security_score }}/100</span>
52
+ {% elif security_score < 70 %}
53
+ <span class="security-score low-risk">{{ security_score }}/100</span>
54
+ {% else %}
55
+ <span class="security-score trusted">{{ security_score }}/100</span>
56
+ {% endif %}
57
+ </div>
58
+ </div>
59
  </div>
60
  </div>
61
 
 
62
  <div class="admin-card">
63
  <div class="admin-card-header">
64
+ <div class="admin-card-title">Security Analysis</div>
65
  </div>
66
+ <div class="security-factors">
67
+ {% if security_factors.account_age_days is not none %}
68
+ <div class="security-factor">
69
+ <div class="factor-label">Account Age:</div>
70
+ <div class="factor-value">{{ security_factors.account_age_days }} days</div>
71
+ </div>
72
+ {% endif %}
73
+
74
+ {% if security_factors.hf_account_age_days is not none %}
75
+ <div class="security-factor">
76
+ <div class="factor-label">HF Account Age:</div>
77
+ <div class="factor-value">{{ security_factors.hf_account_age_days }} days</div>
78
+ </div>
79
+ {% endif %}
80
+
81
+ <div class="security-factor">
82
+ <div class="factor-label">Recent Vote Count (24h):</div>
83
+ <div class="factor-value">{{ security_factors.recent_vote_count or 0 }}</div>
84
+ </div>
85
+
86
+ <div class="security-factor">
87
+ <div class="factor-label">Total Votes:</div>
88
+ <div class="factor-value">{{ security_factors.total_votes or 0 }}</div>
89
+ </div>
90
+
91
+ {% if security_factors.avg_vote_interval %}
92
+ <div class="security-factor">
93
+ <div class="factor-label">Avg Vote Interval:</div>
94
+ <div class="factor-value">{{ "%.1f"|format(security_factors.avg_vote_interval) }}s</div>
95
+ </div>
96
+ {% endif %}
97
+
98
+ <div class="security-factor">
99
+ <div class="factor-label">Suspicious Voting:</div>
100
+ <div class="factor-value">
101
+ {% if security_factors.suspicious_voting %}
102
+ <span class="status-bad">Yes</span>
103
+ {% if security_factors.suspicious_reason %}
104
+ <div class="factor-detail">{{ security_factors.suspicious_reason }}</div>
105
+ {% endif %}
106
+ {% else %}
107
+ <span class="status-good">No</span>
108
+ {% endif %}
109
+ </div>
110
+ </div>
111
+
112
+ <div class="security-factor">
113
+ <div class="factor-label">Rapid Voting:</div>
114
+ <div class="factor-value">
115
+ {% if security_factors.rapid_voting %}
116
+ <span class="status-bad">Yes</span>
117
+ {% else %}
118
+ <span class="status-good">No</span>
119
+ {% endif %}
120
+ </div>
121
+ </div>
122
+
123
+ {% if security_factors.max_bias_ratio is defined %}
124
+ <div class="security-factor">
125
+ <div class="factor-label">Max Model Bias:</div>
126
+ <div class="factor-value">
127
+ {{ "%.1f"|format(security_factors.max_bias_ratio * 100) }}%
128
+ {% if security_factors.bias_penalty %}
129
+ <div class="factor-detail">{{ security_factors.bias_penalty }}</div>
130
+ {% endif %}
131
+ </div>
132
+ </div>
133
+ {% endif %}
134
  </div>
135
  </div>
136
+
137
+
138
 
139
  {% if recent_votes %}
140
  <div class="admin-card">
 
172
  </div>
173
  {% endif %}
174
 
175
+ {% if model_bias_analysis %}
176
+ <div class="admin-card">
177
+ <div class="admin-card-header">
178
+ <div class="admin-card-title">Model Bias Analysis</div>
179
+ <div class="admin-card-subtitle">Shows how often each model was chosen vs how often it appeared in comparisons</div>
180
+ </div>
181
+ <div class="table-responsive">
182
+ <table class="admin-table">
183
+ <thead>
184
+ <tr>
185
+ <th>Model</th>
186
+ <th>Chosen</th>
187
+ <th>Appeared</th>
188
+ <th>Bias Ratio</th>
189
+ <th>Bias Level</th>
190
+ </tr>
191
+ </thead>
192
+ <tbody>
193
+ {% for model_stats in model_bias_analysis %}
194
+ <tr>
195
+ <td>{{ model_stats.name }}</td>
196
+ <td>{{ model_stats.chosen }}</td>
197
+ <td>{{ model_stats.appeared }}</td>
198
+ <td>{{ "%.1f"|format(model_stats.bias_ratio * 100) }}%</td>
199
+ <td>
200
+ {% if model_stats.appeared < 5 %}
201
+ <span class="bias-badge insufficient-data">Too Few Votes</span>
202
+ {% elif model_stats.bias_ratio >= 0.9 and model_stats.appeared >= 10 %}
203
+ <span class="bias-badge extreme-bias">Extreme Bias</span>
204
+ {% elif model_stats.bias_ratio >= 0.8 and model_stats.appeared >= 8 %}
205
+ <span class="bias-badge high-bias">High Bias</span>
206
+ {% elif model_stats.bias_ratio >= 0.7 and model_stats.appeared >= 5 %}
207
+ <span class="bias-badge moderate-bias">Moderate Bias</span>
208
+ {% elif model_stats.bias_ratio >= 0.6 and model_stats.appeared >= 5 %}
209
+ <span class="bias-badge low-bias">Low Bias</span>
210
+ {% else %}
211
+ <span class="bias-badge no-bias">Normal Pattern</span>
212
+ {% endif %}
213
+ </td>
214
+ </tr>
215
+ {% endfor %}
216
+ </tbody>
217
+ </table>
218
+ </div>
219
+ </div>
220
+ {% endif %}
221
+
222
  <style>
223
  .user-detail-row {
224
  display: flex;
 
241
  margin-top: 24px;
242
  }
243
 
244
+ .security-score-card {
245
+ border: 2px solid #e9ecef;
246
+ }
247
+
248
+ .security-score {
249
+ font-weight: bold;
250
+ font-size: 1.1em;
251
+ }
252
+
253
+ .security-score.high-risk {
254
+ color: #dc3545;
255
+ }
256
+
257
+ .security-score.medium-risk {
258
+ color: #fd7e14;
259
+ }
260
+
261
+ .security-score.low-risk {
262
+ color: #ffc107;
263
+ }
264
+
265
+ .security-score.trusted {
266
+ color: #28a745;
267
+ }
268
+
269
+ .security-factors {
270
+ display: grid;
271
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
272
+ gap: 16px;
273
+ margin-top: 16px;
274
+ }
275
+
276
+ .security-factor {
277
+ display: flex;
278
+ flex-direction: column;
279
+ padding: 12px;
280
+ background-color: #f8f9fa;
281
+ border-radius: 6px;
282
+ border-left: 4px solid #dee2e6;
283
+ }
284
+
285
+ .factor-label {
286
+ font-weight: 600;
287
+ margin-bottom: 4px;
288
+ color: #495057;
289
+ }
290
+
291
+ .factor-value {
292
+ font-size: 1.1em;
293
+ }
294
+
295
+ .factor-detail {
296
+ font-size: 0.9em;
297
+ color: #6c757d;
298
+ margin-top: 4px;
299
+ font-style: italic;
300
+ }
301
+
302
+ .status-good {
303
+ color: #28a745;
304
+ font-weight: 600;
305
+ }
306
+
307
+ .status-bad {
308
+ color: #dc3545;
309
+ font-weight: 600;
310
+ }
311
+
312
+ .bias-badge {
313
+ padding: 4px 8px;
314
+ border-radius: 4px;
315
+ font-size: 0.85em;
316
+ font-weight: 600;
317
+ text-transform: uppercase;
318
+ }
319
+
320
+ .bias-badge.extreme-bias {
321
+ background-color: #dc3545;
322
+ color: white;
323
+ }
324
+
325
+ .bias-badge.high-bias {
326
+ background-color: #fd7e14;
327
+ color: white;
328
+ }
329
+
330
+ .bias-badge.moderate-bias {
331
+ background-color: #ffc107;
332
+ color: black;
333
+ }
334
+
335
+ .bias-badge.low-bias {
336
+ background-color: #17a2b8;
337
+ color: white;
338
+ }
339
+
340
+ .bias-badge.no-bias {
341
+ background-color: #28a745;
342
+ color: white;
343
+ }
344
+
345
+ .bias-badge.insufficient-data {
346
+ background-color: #6c757d;
347
+ color: white;
348
+ }
349
+
350
  .text-truncate {
351
  max-width: 300px;
352
  white-space: nowrap;
 
362
  .user-detail-label {
363
  margin-bottom: 4px;
364
  }
365
+
366
+ .security-factors {
367
+ grid-template-columns: 1fr;
368
+ }
369
  }
370
  </style>
371
  {% endblock %}
templates/admin/users.html CHANGED
@@ -7,7 +7,13 @@
7
 
8
  <div class="admin-card">
9
  <div class="admin-card-header">
10
- <div class="admin-card-title">All Users</div>
 
 
 
 
 
 
11
  </div>
12
  <div class="table-responsive">
13
  <table class="admin-table">
@@ -17,17 +23,31 @@
17
  <th>Username</th>
18
  <th>HF ID</th>
19
  <th>Join Date</th>
 
20
  <th>Admin Status</th>
21
  <th>Actions</th>
22
  </tr>
23
  </thead>
24
  <tbody>
25
- {% for user in users %}
 
 
26
  <tr>
27
  <td>{{ user.id }}</td>
28
  <td>{{ user.username }}</td>
29
  <td>{{ user.hf_id }}</td>
30
- <td>{{ user.join_date.strftime('%Y-%m-%d %H:%M') }}</td>
 
 
 
 
 
 
 
 
 
 
 
31
  <td>
32
  {% if g.is_admin and user.username in admin_users %}
33
  <span class="badge badge-primary">Admin</span>
 
7
 
8
  <div class="admin-card">
9
  <div class="admin-card-header">
10
+ <div class="admin-card-title">All Users (Sorted by Security Score)</div>
11
+ <div class="admin-card-subtitle">
12
+ <span class="badge" style="background-color: #dc3545; color: white;">0-19: High Risk</span>
13
+ <span class="badge" style="background-color: #fd7e14; color: white;">20-39: Medium Risk</span>
14
+ <span class="badge" style="background-color: #ffc107; color: black;">40-69: Low Risk</span>
15
+ <span class="badge" style="background-color: #28a745; color: white;">70-100: Trusted</span>
16
+ </div>
17
  </div>
18
  <div class="table-responsive">
19
  <table class="admin-table">
 
23
  <th>Username</th>
24
  <th>HF ID</th>
25
  <th>Join Date</th>
26
+ <th>Security Score</th>
27
  <th>Admin Status</th>
28
  <th>Actions</th>
29
  </tr>
30
  </thead>
31
  <tbody>
32
+ {% for user_data in users_with_scores %}
33
+ {% set user = user_data.user %}
34
+ {% set score = user_data.security_score %}
35
  <tr>
36
  <td>{{ user.id }}</td>
37
  <td>{{ user.username }}</td>
38
  <td>{{ user.hf_id }}</td>
39
+ <td>{{ user.join_date.strftime('%Y-%m-%d %H:%M') if user.join_date else 'N/A' }}</td>
40
+ <td>
41
+ {% if score < 20 %}
42
+ <span class="badge" style="background-color: #dc3545; color: white;" title="High Risk - Votes may be blocked">{{ score }}/100</span>
43
+ {% elif score < 40 %}
44
+ <span class="badge" style="background-color: #fd7e14; color: white;" title="Medium Risk - Monitor closely">{{ score }}/100</span>
45
+ {% elif score < 70 %}
46
+ <span class="badge" style="background-color: #ffc107; color: black;" title="Low Risk - Normal user">{{ score }}/100</span>
47
+ {% else %}
48
+ <span class="badge" style="background-color: #28a745; color: white;" title="Trusted - High security score">{{ score }}/100</span>
49
+ {% endif %}
50
+ </td>
51
  <td>
52
  {% if g.is_admin and user.username in admin_users %}
53
  <span class="badge badge-primary">Admin</span>
templates/arena.html CHANGED
@@ -5,6 +5,23 @@
5
  {% block current_page %}Arena{% endblock %}
6
 
7
  {% block content %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  <div class="tabs">
9
  <div class="tab active" data-tab="tts">TTS</div>
10
  <div class="tab" data-tab="conversational">Conversational</div>
@@ -983,6 +1000,89 @@
983
  border-color: var(--border-color);
984
  }
985
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
986
  </style>
987
  {% endblock %}
988
 
@@ -990,6 +1090,40 @@
990
  <script src="{{ url_for('static', filename='js/waveplayer.js') }}"></script>
991
  <script>
992
  document.addEventListener('DOMContentLoaded', function() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
993
  const synthForm = document.querySelector('.input-container');
994
  const synthBtn = document.querySelector('.synth-btn');
995
  const mobileSynthBtn = document.querySelector('.mobile-synth-btn');
@@ -1102,6 +1236,12 @@
1102
  e.preventDefault();
1103
  }
1104
 
 
 
 
 
 
 
1105
  const text = textInput.value.trim();
1106
  if (!text) {
1107
  openToast("Please enter some text to synthesize", "warning");
@@ -1193,7 +1333,13 @@
1193
  })
1194
  .catch(error => {
1195
  loadingContainer.style.display = 'none';
1196
- openToast(error.message, "error");
 
 
 
 
 
 
1197
  console.error('Error:', error);
1198
  });
1199
  }
@@ -1266,7 +1412,12 @@
1266
  btn.querySelector('.vote-loader').style.display = 'none';
1267
  });
1268
 
1269
- openToast(error.message, "error");
 
 
 
 
 
1270
  console.error('Error:', error);
1271
  });
1272
  }
@@ -1803,7 +1954,13 @@
1803
  })
1804
  .catch(error => {
1805
  podcastLoadingContainer.style.display = 'none';
1806
- openToast(error.message, "error");
 
 
 
 
 
 
1807
  console.error('Error:', error);
1808
  });
1809
  }
@@ -1875,7 +2032,12 @@
1875
  btn.querySelector('.vote-loader').style.display = 'none';
1876
  });
1877
 
1878
- openToast(error.message, "error");
 
 
 
 
 
1879
  console.error('Error:', error);
1880
  });
1881
  }
 
5
  {% block current_page %}Arena{% endblock %}
6
 
7
  {% block content %}
8
+ <!-- Authentication status for JavaScript -->
9
+ <div id="auth-status" data-authenticated="{% if current_user.is_authenticated %}true{% else %}false{% endif %}" style="display: none;"></div>
10
+
11
+ {% if not current_user.is_authenticated %}
12
+ <!-- Login prompt overlay -->
13
+ <div id="login-prompt-overlay" class="login-prompt-overlay" style="display: none;">
14
+ <div class="login-prompt-content">
15
+ <h3>Login Required</h3>
16
+ <p>You need to be logged in to use TTS Arena. Login to generate audio and vote on models!</p>
17
+ <div class="login-prompt-actions">
18
+ <button class="login-prompt-close">Maybe later</button>
19
+ <a href="{{ url_for('auth.login', next=request.path) }}" class="login-prompt-btn">Login with Hugging Face</a>
20
+ </div>
21
+ </div>
22
+ </div>
23
+ {% endif %}
24
+
25
  <div class="tabs">
26
  <div class="tab active" data-tab="tts">TTS</div>
27
  <div class="tab" data-tab="conversational">Conversational</div>
 
1000
  border-color: var(--border-color);
1001
  }
1002
  }
1003
+
1004
+ /* Login prompt overlay styles */
1005
+ .login-prompt-overlay {
1006
+ position: fixed;
1007
+ top: 0;
1008
+ left: 0;
1009
+ width: 100%;
1010
+ height: 100%;
1011
+ background-color: rgba(0, 0, 0, 0.7);
1012
+ z-index: 10000;
1013
+ display: flex;
1014
+ align-items: center;
1015
+ justify-content: center;
1016
+ }
1017
+
1018
+ .login-prompt-content {
1019
+ background: white;
1020
+ border-radius: 12px;
1021
+ padding: 32px;
1022
+ max-width: 400px;
1023
+ width: 90%;
1024
+ text-align: center;
1025
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
1026
+ }
1027
+
1028
+ .login-prompt-content h3 {
1029
+ margin: 0 0 16px 0;
1030
+ color: var(--text-color);
1031
+ font-size: 24px;
1032
+ }
1033
+
1034
+ .login-prompt-content p {
1035
+ margin: 0 0 24px 0;
1036
+ color: var(--text-secondary);
1037
+ line-height: 1.5;
1038
+ }
1039
+
1040
+ .login-prompt-actions {
1041
+ display: flex;
1042
+ gap: 12px;
1043
+ justify-content: center;
1044
+ }
1045
+
1046
+ .login-prompt-close {
1047
+ padding: 12px 24px;
1048
+ background: transparent;
1049
+ border: 1px solid var(--border-color);
1050
+ border-radius: 8px;
1051
+ color: var(--text-secondary);
1052
+ cursor: pointer;
1053
+ font-size: 14px;
1054
+ transition: all 0.2s;
1055
+ }
1056
+
1057
+ .login-prompt-close:hover {
1058
+ background: var(--light-gray);
1059
+ }
1060
+
1061
+ .login-prompt-btn {
1062
+ padding: 12px 24px;
1063
+ background: var(--primary-color);
1064
+ border: none;
1065
+ border-radius: 8px;
1066
+ color: white;
1067
+ text-decoration: none;
1068
+ font-size: 14px;
1069
+ font-weight: 500;
1070
+ transition: all 0.2s;
1071
+ display: inline-block;
1072
+ }
1073
+
1074
+ .login-prompt-btn:hover {
1075
+ background: var(--primary-hover);
1076
+ transform: translateY(-1px);
1077
+ }
1078
+
1079
+ /* Dark mode for login prompt */
1080
+ @media (prefers-color-scheme: dark) {
1081
+ .login-prompt-content {
1082
+ background: var(--bg-color);
1083
+ border: 1px solid var(--border-color);
1084
+ }
1085
+ }
1086
  </style>
1087
  {% endblock %}
1088
 
 
1090
  <script src="{{ url_for('static', filename='js/waveplayer.js') }}"></script>
1091
  <script>
1092
  document.addEventListener('DOMContentLoaded', function() {
1093
+ // Check authentication status
1094
+ const authStatus = document.getElementById('auth-status');
1095
+ const isAuthenticated = authStatus ? authStatus.dataset.authenticated === 'true' : false;
1096
+ const loginPromptOverlay = document.getElementById('login-prompt-overlay');
1097
+ const loginPromptClose = document.querySelector('.login-prompt-close');
1098
+
1099
+ // Function to show login prompt
1100
+ function showLoginPrompt() {
1101
+ if (loginPromptOverlay) {
1102
+ loginPromptOverlay.style.display = 'flex';
1103
+ }
1104
+ }
1105
+
1106
+ // Function to hide login prompt
1107
+ function hideLoginPrompt() {
1108
+ if (loginPromptOverlay) {
1109
+ loginPromptOverlay.style.display = 'none';
1110
+ }
1111
+ }
1112
+
1113
+ // Add event listener to close button
1114
+ if (loginPromptClose) {
1115
+ loginPromptClose.addEventListener('click', hideLoginPrompt);
1116
+ }
1117
+
1118
+ // Close prompt when clicking outside
1119
+ if (loginPromptOverlay) {
1120
+ loginPromptOverlay.addEventListener('click', function(e) {
1121
+ if (e.target === loginPromptOverlay) {
1122
+ hideLoginPrompt();
1123
+ }
1124
+ });
1125
+ }
1126
+
1127
  const synthForm = document.querySelector('.input-container');
1128
  const synthBtn = document.querySelector('.synth-btn');
1129
  const mobileSynthBtn = document.querySelector('.mobile-synth-btn');
 
1236
  e.preventDefault();
1237
  }
1238
 
1239
+ // Check authentication first
1240
+ if (!isAuthenticated) {
1241
+ showLoginPrompt();
1242
+ return;
1243
+ }
1244
+
1245
  const text = textInput.value.trim();
1246
  if (!text) {
1247
  openToast("Please enter some text to synthesize", "warning");
 
1333
  })
1334
  .catch(error => {
1335
  loadingContainer.style.display = 'none';
1336
+
1337
+ // Handle authentication errors specially
1338
+ if (error.message.includes('logged in to generate') || error.message.includes('logged in to vote')) {
1339
+ openToast("Please log in to use TTS Arena. <a href='{{ url_for('auth.login', next=request.path) }}' style='color: white; text-decoration: underline;'>Login now</a>", "error");
1340
+ } else {
1341
+ openToast(error.message, "error");
1342
+ }
1343
  console.error('Error:', error);
1344
  });
1345
  }
 
1412
  btn.querySelector('.vote-loader').style.display = 'none';
1413
  });
1414
 
1415
+ // Handle authentication errors specially
1416
+ if (error.message.includes('logged in to vote')) {
1417
+ openToast("Please log in to vote. <a href='{{ url_for('auth.login', next=request.path) }}' style='color: white; text-decoration: underline;'>Login now</a>", "error");
1418
+ } else {
1419
+ openToast(error.message, "error");
1420
+ }
1421
  console.error('Error:', error);
1422
  });
1423
  }
 
1954
  })
1955
  .catch(error => {
1956
  podcastLoadingContainer.style.display = 'none';
1957
+
1958
+ // Handle authentication errors specially
1959
+ if (error.message.includes('logged in to generate') || error.message.includes('logged in to vote')) {
1960
+ openToast("Please log in to use TTS Arena. <a href='{{ url_for('auth.login', next=request.path) }}' style='color: white; text-decoration: underline;'>Login now</a>", "error");
1961
+ } else {
1962
+ openToast(error.message, "error");
1963
+ }
1964
  console.error('Error:', error);
1965
  });
1966
  }
 
2032
  btn.querySelector('.vote-loader').style.display = 'none';
2033
  });
2034
 
2035
+ // Handle authentication errors specially
2036
+ if (error.message.includes('logged in to vote')) {
2037
+ openToast("Please log in to vote. <a href='{{ url_for('auth.login', next=request.path) }}' style='color: white; text-decoration: underline;'>Login now</a>", "error");
2038
+ } else {
2039
+ openToast(error.message, "error");
2040
+ }
2041
  console.error('Error:', error);
2042
  });
2043
  }
templates/turnstile.html CHANGED
@@ -159,7 +159,7 @@
159
  <div class="verification-container">
160
  <div class="logo">TTS Arena</div>
161
  <h1>Verification Required</h1>
162
- <p>Please complete the verification below to access TTS Arena.</p>
163
 
164
  <div id="turnstile-form">
165
  <div class="turnstile-container">
 
159
  <div class="verification-container">
160
  <div class="logo">TTS Arena</div>
161
  <h1>Verification Required</h1>
162
+ <p>Please complete the verification below to access TTS Arena. <b>If you are having issues on Safari, please try again using Chrome.</b> (Apologies for the temporary inconvenience - this is a bug with the captcha on Safari and should be fixed soon.)</p>
163
 
164
  <div id="turnstile-form">
165
  <div class="turnstile-container">