mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-18 04:39:41 +02:00
feat(recording): implement simplified recording system with API endpoints and UI integration
This commit is contained in:
1 parent
ee97e29f72
commit
2c4572191d
9 files changed
+2636
No files matched your search
@@ -0,0 +1,129 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
import '../database/database.dart';
|
||||
import '../services/simple_recorder.dart';
|
||||
|
||||
/// 🎯 Simple Recording API - 3 endpoints only!
|
||||
class SimpleRecordingApi {
|
||||
final AppDatabase db;
|
||||
final SimpleRecorder recorder;
|
||||
|
||||
SimpleRecordingApi(this.db, this.recorder);
|
||||
|
||||
/// Setup routes
|
||||
Router get router {
|
||||
final router = Router();
|
||||
|
||||
router.post('/record/now', _recordNow);
|
||||
router.post('/record/schedule', _scheduleRecord);
|
||||
router.post('/record/stop/<channelId>', _stopRecord);
|
||||
router.get('/record/list', _listRecordings);
|
||||
router.get('/record/active', _getActive);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/// 🟢 POST /record/now
|
||||
/// Start recording RIGHT NOW
|
||||
/// Body: { channel_id, stream_url, title, duration_minutes }
|
||||
Future<Response> _recordNow(Request request) async {
|
||||
try {
|
||||
final data = jsonDecode(await request.readAsString());
|
||||
|
||||
final id = await recorder.startRecording(
|
||||
channelId: data['channel_id'],
|
||||
streamUrl: data['stream_url'],
|
||||
title: data['title'] ?? 'Recording',
|
||||
duration: Duration(minutes: data['duration_minutes'] ?? 60),
|
||||
);
|
||||
|
||||
return Response.ok(
|
||||
jsonEncode({
|
||||
'status': 'recording',
|
||||
'id': id,
|
||||
'message': 'Recording started!',
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.badRequest(
|
||||
body: jsonEncode({'error': '$e'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔵 POST /record/schedule
|
||||
/// Schedule recording for later
|
||||
/// Body: { channel_id, stream_url, title, start_time (ISO), end_time (ISO) }
|
||||
Future<Response> _scheduleRecord(Request request) async {
|
||||
try {
|
||||
final data = jsonDecode(await request.readAsString());
|
||||
|
||||
final id = await recorder.scheduleRecording(
|
||||
channelId: data['channel_id'],
|
||||
streamUrl: data['stream_url'],
|
||||
title: data['title'] ?? 'Recording',
|
||||
startTime: DateTime.parse(data['start_time']),
|
||||
endTime: DateTime.parse(data['end_time']),
|
||||
);
|
||||
|
||||
return Response.ok(
|
||||
jsonEncode({
|
||||
'status': 'scheduled',
|
||||
'id': id,
|
||||
'message': 'Recording scheduled!',
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.badRequest(
|
||||
body: jsonEncode({'error': '$e'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔴 POST /record/stop/<channelId>
|
||||
/// Stop recording immediately
|
||||
Future<Response> _stopRecord(Request request, String channelId) async {
|
||||
try {
|
||||
await recorder.stopRecording(channelId);
|
||||
return Response.ok(
|
||||
jsonEncode({'status': 'stopped', 'message': 'Recording stopped!'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.badRequest(
|
||||
body: jsonEncode({'error': '$e'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 📋 GET /record/list
|
||||
/// List all recordings
|
||||
Response _listRecordings(Request request) {
|
||||
final recordings = db.getAllRecordings();
|
||||
return Response.ok(
|
||||
jsonEncode({
|
||||
'total': recordings.length,
|
||||
'recordings': recordings.map((r) => r.toMap()).toList(),
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
/// 🟢 GET /record/active
|
||||
/// List currently recording
|
||||
Response _getActive(Request request) {
|
||||
return Response.ok(
|
||||
jsonEncode({
|
||||
'active': recorder.getActive(),
|
||||
'count': recorder.getActive().length,
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../database/database.dart';
|
||||
import '../models/recording.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',
|
||||
'-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((_) {
|
||||
db.updateRecordingStatus(recordingId, 'completed');
|
||||
_active.remove(channelId);
|
||||
print('✅ Recording done: $title');
|
||||
}).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';
|
||||
}
|
||||
|
||||
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