mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-23 15:08:07 +02:00
Serveur réécrit en Rust
Le serveur Dart (bin/) est remplacé par un serveur Rust (server/) : tokio + axum, reqwest/rustls, SQLite embarqué via rusqlite. Même API, mêmes routes et mêmes réponses : le front Flutter ne change pas. La base de production est reprise telle quelle : schéma, migrations et formats de dates (DateTime.toIso8601String de Dart) sont reproduits à l'identique, les sessions et hachages existants restent valides. Comportement conservé : cascade de routage, anti-SSRF, logs masqués, rate limits, transcodage HLS/turbo.ts avec repli GPU, quotas fournisseur, enregistrements (reprise, relances, fusion), season passes, EPG, logos de repli, arrêt gracieux sur SIGTERM. Tests Dart portés (132 tests Rust) ; la CI exécute fmt, test et clippy -D warnings, l'image Docker compile le serveur dans un étage rust:1.98.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f82c70ae05
commit
3ea6fa0ae3
109 files changed
+14008
-11047
No files matched your search
@@ -1,238 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../database/database.dart';
|
||||
|
||||
/// 🎯 SIMPLIFIED Recording System - One job: Record streams
|
||||
class SimpleRecorder {
|
||||
final AppDatabase db;
|
||||
final String recordingsDir;
|
||||
|
||||
// Active recordings (streamId -> process)
|
||||
final Map<String, _ActiveRecording> _active = {};
|
||||
|
||||
SimpleRecorder(this.db, {this.recordingsDir = '/app/recordings'});
|
||||
|
||||
/// Initialize recordings directory
|
||||
Future<void> init() async {
|
||||
final dir = Directory(recordingsDir);
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
print('🎬 Recorder ready -> $recordingsDir');
|
||||
}
|
||||
|
||||
/// Start recording immediately
|
||||
Future<String> startRecording({
|
||||
required String channelId,
|
||||
required String streamUrl,
|
||||
required String title,
|
||||
required Duration duration,
|
||||
}) async {
|
||||
if (_active.containsKey(channelId)) {
|
||||
throw Exception('Already recording: $title');
|
||||
}
|
||||
|
||||
final recordingId = const Uuid().v4();
|
||||
final now = DateTime.now();
|
||||
final endTime = now.add(duration);
|
||||
|
||||
// Create recording in DB
|
||||
db.createRecording(
|
||||
userId: 'system',
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
title: title,
|
||||
startTime: now,
|
||||
endTime: endTime,
|
||||
);
|
||||
|
||||
// Generate safe filename
|
||||
final filename = _safeName(title, recordingId);
|
||||
final filepath = '$recordingsDir/$filename';
|
||||
|
||||
// Start FFmpeg process
|
||||
try {
|
||||
final ffmpeg = await Process.start('ffmpeg', [
|
||||
'-y',
|
||||
'-fflags', '+genpts+igndts', // FORCE generation of missing timestamps for MKV/MP4 compatibility
|
||||
'-i',
|
||||
streamUrl,
|
||||
'-c',
|
||||
'copy',
|
||||
'-t',
|
||||
'${duration.inSeconds}',
|
||||
filepath,
|
||||
]);
|
||||
|
||||
_active[channelId] = _ActiveRecording(
|
||||
id: recordingId,
|
||||
process: ffmpeg,
|
||||
filepath: filepath,
|
||||
endTime: endTime,
|
||||
);
|
||||
|
||||
// Update DB
|
||||
db.updateRecordingStatus(recordingId, 'recording', filePath: filepath);
|
||||
|
||||
// Auto-stop when time is up or process ends
|
||||
ffmpeg.exitCode.then((code) {
|
||||
if (code == 0 || code == 255) { // 255 is usually returned when gracefully stopped via q/SIGTERM
|
||||
db.updateRecordingStatus(recordingId, 'completed');
|
||||
} else {
|
||||
db.updateRecordingStatus(
|
||||
recordingId,
|
||||
'failed',
|
||||
errorReason: 'FFmpeg crashed with exit code $code',
|
||||
);
|
||||
}
|
||||
_active.remove(channelId);
|
||||
print('✅ Recording finished: $title (Code: $code)');
|
||||
}).catchError((e) {
|
||||
db.updateRecordingStatus(
|
||||
recordingId,
|
||||
'failed',
|
||||
errorReason: 'FFmpeg error',
|
||||
);
|
||||
_active.remove(channelId);
|
||||
});
|
||||
|
||||
print('🔴 Recording: $title ($duration)');
|
||||
return recordingId;
|
||||
} catch (e) {
|
||||
db.updateRecordingStatus(recordingId, 'failed', errorReason: '$e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule recording for later
|
||||
Future<String> scheduleRecording({
|
||||
required String channelId,
|
||||
required String streamUrl,
|
||||
required String title,
|
||||
required DateTime startTime,
|
||||
required DateTime endTime,
|
||||
}) async {
|
||||
final recordingId = const Uuid().v4();
|
||||
|
||||
// Create in DB with 'scheduled' status
|
||||
db.createRecording(
|
||||
userId: 'system',
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
title: title,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
);
|
||||
|
||||
// Schedule auto-start
|
||||
final delay = startTime.difference(DateTime.now());
|
||||
if (delay.isNegative) {
|
||||
throw Exception('Start time is in the past');
|
||||
}
|
||||
|
||||
Timer(delay, () async {
|
||||
try {
|
||||
await startRecording(
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
title: title,
|
||||
duration: endTime.difference(startTime),
|
||||
);
|
||||
} catch (e) {
|
||||
print('❌ Auto-start failed: $e');
|
||||
}
|
||||
});
|
||||
|
||||
print('⏰ Scheduled: $title at ${startTime.toLocal()}');
|
||||
return recordingId;
|
||||
}
|
||||
|
||||
/// Stop recording immediately
|
||||
Future<void> stopRecording(String channelId) async {
|
||||
final active = _active[channelId];
|
||||
if (active == null) return;
|
||||
|
||||
active.process.kill();
|
||||
db.updateRecordingStatus(active.id, 'completed');
|
||||
_active.remove(channelId);
|
||||
print('⏹️ Stopped: $channelId');
|
||||
}
|
||||
|
||||
/// Check scheduled recordings (call every minute)
|
||||
Future<void> checkScheduled() async {
|
||||
final now = DateTime.now();
|
||||
for (final rec in db.getAllRecordings()) {
|
||||
if (rec.status != 'scheduled') continue;
|
||||
if (now.isBefore(rec.startTime)) continue;
|
||||
|
||||
try {
|
||||
await startRecording(
|
||||
channelId: rec.channelId,
|
||||
streamUrl: rec.streamUrl,
|
||||
title: rec.title,
|
||||
duration: rec.endTime.difference(rec.startTime),
|
||||
);
|
||||
} catch (e) {
|
||||
print('❌ Auto-start error: $e');
|
||||
db.updateRecordingStatus(rec.id, 'failed', errorReason: '$e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get active recordings
|
||||
List<Map<String, dynamic>> getActive() {
|
||||
return _active.entries
|
||||
.map(
|
||||
(e) => {
|
||||
'id': e.value.id,
|
||||
'channel': e.key,
|
||||
'filepath': e.value.filepath,
|
||||
'endsAt': e.value.endTime.toIso8601String(),
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Cleanup old recordings (keep last 20)
|
||||
Future<void> cleanupOld({int keepCount = 20}) async {
|
||||
final dir = Directory(recordingsDir);
|
||||
final files = dir.listSync().whereType<File>().toList()
|
||||
..sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified));
|
||||
|
||||
if (files.length > keepCount) {
|
||||
for (final file in files.skip(keepCount)) {
|
||||
await file.delete();
|
||||
print('🗑️ Deleted: ${file.path}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _safeName(String title, String id) {
|
||||
final safe = title.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_');
|
||||
final timestamp =
|
||||
DateTime.now().toIso8601String().replaceAll(':', '').split('.')[0];
|
||||
return '${safe}_$timestamp.mkv'; // The user explicitly mandated MKV or MP4.
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final rec in _active.values) {
|
||||
rec.process.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal: Active recording tracking
|
||||
class _ActiveRecording {
|
||||
final String id;
|
||||
final Process process;
|
||||
final String filepath;
|
||||
final DateTime endTime;
|
||||
|
||||
_ActiveRecording({
|
||||
required this.id,
|
||||
required this.process,
|
||||
required this.filepath,
|
||||
required this.endTime,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user