feat: security hardening, streaming overhaul, design polish, tests

Security:
- Replace unsalted SHA-256 password hashing with bcrypt (lazy rehash on login)
- Add authenticated /api/xtream-api gateway: Xtream credentials are injected
  server-side and never sent to the frontend; /api/playlists no longer
  returns passwords
- Redact credentials from all logs (login body, proxy/FFmpeg/scheduler URLs)
- Add auth to recordings, EPG, season-passes and streaming routes
  (HttpOnly session cookie for hls.js; loopback bypass for local FFmpeg)
- Lock player postMessage to same-origin in both directions
- Vendor and pin hls.js 1.6.7 / mpegts.js 1.7.3 (drop CDN @latest)
- Fix rate limiter (client IP was never resolved), add login rate limit,
  restrict CORS, add CSP Report-Only, block private-IP SSRF targets,
  fix path traversal in recording log retrieval, chmod 777 -> 770
- Remove dead HiveService (seeded admin/admin into IndexedDB with SHA-256)
- Fix authMiddleware not populating 'user' context (getPlaylist ignored the
  logged-in user; admin purge always returned 403)

Streaming:
- New FfmpegSessionManager: process registry, idle reaper (4 min live /
  15 min VOD), orphan cleanup at startup, clean SIGTERM shutdown,
  fast-fail with stderr instead of 30 s timeout
- Quality selection (source/high/medium/low) for live and VOD; source mode
  streams with -c:v copy (zero transcoding); selector wired into the player
- Concurrent recordings (MAX_CONCURRENT_RECORDINGS, default 2); conflicts
  retry on the next tick instead of silently failing
- Lower live latency (HLS window 20 -> 10 segments, liveSync 10 -> 3)
- Fix recording log lookup (.mp4 vs .mkv mismatch)

Design:
- Replace hardcoded colors with AppColors tokens (12 files)
- web/theme.css syncs HTML players with the Flutter palette
- DPAD/keyboard navigation (arrow-key focus, player shortcuts)
- Tooltips on player icon buttons, Semantics on content cards
- Remove 7 dead widgets broken since the Stitch merge

Quality:
- bin/test/: 21 unit tests (bcrypt, redaction, traversal, SSRF, recording
  conflicts) plus a quality-selector widget test
- GitHub Actions CI (analyze + test + build web)
- Archive stale status docs into docs/archive/

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Fable 5 committed 2026-06-10 10:07:18 +02:00
1 parent cb5eca7547
commit 60d3f42901
107 files changed
+4525 -3815

No files matched your search

@@ -1,253 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/playlist_config.dart';
import '../models/playlist.dart';
/// Represents a content recommendation
class Recommendation {
final Playlist item;
final RecommendationType type;
final double score;
final String reason;
Recommendation({
required this.item,
required this.type,
this.score = 0.0,
required this.reason,
});
}
enum RecommendationType {
continueWatching,
trending,
recentlyAdded,
similarContent,
topRated,
forYou,
}
class RecommendationService {
/// Get continue watching items based on watch history
static List<Recommendation> getContinueWatching(
Map<String, dynamic> watchHistory,
List<Playlist> allContent,
) {
final recommendations = <Recommendation>[];
for (final item in allContent) {
final historyKey = item.streamId.toString();
if (watchHistory.containsKey(historyKey)) {
final lastPosition = watchHistory[historyKey] as double? ?? 0.0;
// Only show if watched more than 5 seconds ago and less than 95% complete
if (lastPosition > 5 && lastPosition < 95) {
recommendations.add(
Recommendation(
item: item,
type: RecommendationType.continueWatching,
score: 100.0 - lastPosition, // Score higher if recently started
reason:
'Continue watching from ${lastPosition.toStringAsFixed(0)}%',
),
);
}
}
}
// Sort by last watched (highest incomplete progress first)
recommendations.sort((a, b) => b.score.compareTo(a.score));
return recommendations.take(10).toList();
}
/// Get trending content based on view counts
static List<Recommendation> getTrending(
Map<String, int> viewCounts,
List<Playlist> allContent,
) {
final recommendations = <Recommendation>[];
for (final item in allContent) {
final views = viewCounts[item.streamId.toString()] ?? 0;
if (views > 0) {
recommendations.add(
Recommendation(
item: item,
type: RecommendationType.trending,
score: views.toDouble(),
reason: '$views views - Trending now',
),
);
}
}
recommendations.sort((a, b) => b.score.compareTo(a.score));
return recommendations.take(10).toList();
}
/// Get recently added content
static List<Recommendation> getRecentlyAdded(List<Playlist> allContent) {
final recommendations = <Recommendation>[];
// Sort by added date (most recent first)
final sorted = [...allContent];
sorted.sort((a, b) {
final aDate = DateTime.tryParse(a.added ?? '') ?? DateTime(2000);
final bDate = DateTime.tryParse(b.added ?? '') ?? DateTime(2000);
return bDate.compareTo(aDate);
});
for (final item in sorted.take(20)) {
final daysAgo = DateTime.now()
.difference(DateTime.tryParse(item.added ?? '') ?? DateTime(2000))
.inDays;
recommendations.add(
Recommendation(
item: item,
type: RecommendationType.recentlyAdded,
score: (20 - daysAgo).toDouble().clamp(0, 100),
reason: 'Added $daysAgo days ago',
),
);
}
return recommendations.take(10).toList();
}
/// Get personalized recommendations based on watch history
static List<Recommendation> getForYou(
Map<String, dynamic> watchHistory,
List<Playlist> allContent,
) {
final recommendations = <Recommendation>[];
// Collect watched categories
final watchedCategories = <String>{};
watchHistory.forEach((key, value) {
if (value is Map && value.containsKey('category')) {
watchedCategories.add(value['category'] as String);
}
});
// Recommend similar content
for (final item in allContent) {
if (watchedCategories.contains(item.categoryId)) {
recommendations.add(
Recommendation(
item: item,
type: RecommendationType.forYou,
score: 75.0,
reason: 'Based on your interests',
),
);
}
}
// Add highly rated content
var ratedContent = [...allContent];
ratedContent.sort((a, b) {
final aRating = double.tryParse(a.rating ?? '0') ?? 0.0;
final bRating = double.tryParse(b.rating ?? '0') ?? 0.0;
return bRating.compareTo(aRating);
});
for (final item in ratedContent.where((i) {
final rating = double.tryParse(i.rating ?? '0') ?? 0.0;
return rating >= 7.5;
}).take(5)) {
recommendations.add(
Recommendation(
item: item,
type: RecommendationType.topRated,
score: double.tryParse(item.rating ?? '0') ?? 0.0,
reason: 'Highly rated - ${item.rating}/10',
),
);
}
// Remove duplicates and limit
final seen = <int>{};
final unique = <Recommendation>[];
for (final rec in recommendations) {
if (!seen.contains(rec.item.streamId)) {
unique.add(rec);
seen.add(rec.item.streamId);
}
}
return unique.take(15).toList();
}
}
/// Provider for watch history
final watchHistoryProvider =
StateNotifierProvider<WatchHistoryNotifier, Map<String, dynamic>>((ref) {
return WatchHistoryNotifier();
});
class WatchHistoryNotifier extends StateNotifier<Map<String, dynamic>> {
WatchHistoryNotifier() : super({});
void updateWatchTime(int streamId, double percentage) {
state = {
...state,
streamId.toString(): {
'percentage': percentage,
'timestamp': DateTime.now().toIso8601String(),
},
};
}
void clearHistory() {
state = {};
}
}
/// Provider for view counts/trending
final trendingProvider =
StateNotifierProvider<TrendingNotifier, Map<String, int>>((ref) {
return TrendingNotifier();
});
class TrendingNotifier extends StateNotifier<Map<String, int>> {
TrendingNotifier() : super({});
void incrementViewCount(int streamId) {
final key = streamId.toString();
state = {
...state,
key: (state[key] ?? 0) + 1,
};
}
}
/// Provider for recommendations
final recommendationsProvider = FutureProvider.family<List<Recommendation>,
(Playlist, Map<String, dynamic>, Map<String, int>, List<Playlist>)>(
(ref, params) async {
final (playlist, watchHistory, trending, allContent) = params;
// Combine all recommendation types
final allRecommendations = <Recommendation>[
...RecommendationService.getContinueWatching(watchHistory, allContent),
...RecommendationService.getTrending(trending, allContent),
...RecommendationService.getRecentlyAdded(allContent),
...RecommendationService.getForYou(watchHistory, allContent),
];
// Deduplicate and score
final seen = <int>{};
final scored = <Recommendation>[];
for (final rec in allRecommendations) {
if (!seen.contains(rec.item.streamId)) {
scored.add(rec);
seen.add(rec.item.streamId);
}
}
// Sort by score
scored.sort((a, b) => b.score.compareTo(a.score));
return scored;
},
);