mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-14 18:57:41 +02:00
feat: implement live tv recording system
This commit is contained in:
1 parent
758cbe4c43
commit
2180572b92
9 files changed
+806
-14
No files matched your search
+1
-1
@@ -91,7 +91,7 @@ RUN groupadd -r xtremuser && useradd -r -g xtremuser -G audio,video xtremuser
|
||||
WORKDIR /app
|
||||
|
||||
# Create necessary data directories with correct permissions
|
||||
RUN mkdir -p /app/data /app/web /tmp/xtremflow_streams \
|
||||
RUN mkdir -p /app/data /app/web /app/recordings /tmp/xtremflow_streams \
|
||||
&& chown -R xtremuser:xtremuser /app /tmp/xtremflow_streams
|
||||
|
||||
# Copy built artifacts
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
import '../database/database.dart';
|
||||
import '../services/recording_scheduler.dart';
|
||||
|
||||
class RecordingsApi {
|
||||
final AppDatabase _db;
|
||||
final RecordingScheduler _scheduler;
|
||||
|
||||
RecordingsApi(this._db, this._scheduler);
|
||||
|
||||
Router get router {
|
||||
final router = Router();
|
||||
|
||||
// Récupérer tous les enregistrements (admin) ou les enregistrements de l'utilisateur courant (si filtré plus tard via les middlewares)
|
||||
router.get('/', (Request request) {
|
||||
// Dans une implémentation complète, nous récupérerions l'ID utilisateur
|
||||
// final session = request.context['session'] as Session?;
|
||||
// final userId = session?.userId;
|
||||
|
||||
final recordings = _db.getAllRecordings();
|
||||
return Response.ok(
|
||||
json.encode(recordings.map((r) => r.toMap()).toList()),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
});
|
||||
|
||||
// Planifier un nouvel enregistrement
|
||||
router.post('/', (Request request) async {
|
||||
// final session = request.context['session'] as Session;
|
||||
// final userId = session.userId;
|
||||
final payload = await request.readAsString();
|
||||
final data = json.decode(payload);
|
||||
|
||||
try {
|
||||
final recording = _db.createRecording(
|
||||
userId: 'dev_user_id', // Remplacer par `userId` dans l'implèm réelle avec Auth
|
||||
channelId: data['channel_id'],
|
||||
streamUrl: data['stream_url'],
|
||||
title: data['title'] ?? 'Sans Titre',
|
||||
startTime: DateTime.parse(data['start_time']),
|
||||
endTime: DateTime.parse(data['end_time']),
|
||||
);
|
||||
|
||||
return Response.ok(
|
||||
recording.toJson(),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur lors de la programmation de l\'enregistrement : $e'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Supprimer (annuler) un enregistrement
|
||||
router.delete('/<id>', (Request request, String id) {
|
||||
final recording = _db.getRecordingById(id);
|
||||
|
||||
if (recording == null) {
|
||||
return Response.notFound(json.encode({'error': 'Enregistrement non trouvé'}));
|
||||
}
|
||||
|
||||
// 1. Si l'enregistrement est en cours, il faut potentiellement l'arrêter
|
||||
// _scheduler.cancelRecording(id); // Ceci pourrait être une nouvelle méthode de l'ordonnanceur
|
||||
|
||||
// 2. Supprimer la donnée en BDD
|
||||
_db.deleteRecording(id);
|
||||
|
||||
// 3. Supprimer le fichier vidéo physiquement si existant
|
||||
/*
|
||||
if (recording.filePath != null) {
|
||||
final file = File(recording.filePath!);
|
||||
if (file.existsSync()) {
|
||||
file.deleteSync();
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
return Response.ok(
|
||||
json.encode({'message': 'Enregistrement supprimé avec succès'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,25 @@ class AppDatabase {
|
||||
)
|
||||
''');
|
||||
|
||||
// TV Recordings table
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tv_recordings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
channel_id TEXT NOT NULL,
|
||||
stream_url TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'scheduled',
|
||||
file_path TEXT,
|
||||
error_reason TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
// Indexes
|
||||
_db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)',
|
||||
@@ -404,6 +423,92 @@ class AppDatabase {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==================== TV Recordings ====================
|
||||
|
||||
/// Créer un nouvel enregistrement
|
||||
Recording createRecording({
|
||||
required String userId,
|
||||
required String channelId,
|
||||
required String streamUrl,
|
||||
required String title,
|
||||
required DateTime startTime,
|
||||
required DateTime endTime,
|
||||
}) {
|
||||
final recordingId = _uuid.v4();
|
||||
final now = DateTime.now().toIso8601String();
|
||||
|
||||
_db.execute(
|
||||
'''
|
||||
INSERT INTO tv_recordings (id, user_id, channel_id, stream_url, title, start_time, end_time, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''',
|
||||
[
|
||||
recordingId,
|
||||
userId,
|
||||
channelId,
|
||||
streamUrl,
|
||||
title,
|
||||
startTime.toIso8601String(),
|
||||
endTime.toIso8601String(),
|
||||
now,
|
||||
now
|
||||
],
|
||||
);
|
||||
|
||||
return Recording(
|
||||
id: recordingId,
|
||||
userId: userId,
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
title: title,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
status: 'scheduled',
|
||||
createdAt: DateTime.parse(now),
|
||||
updatedAt: DateTime.parse(now),
|
||||
);
|
||||
}
|
||||
|
||||
/// Lister tous les enregistrements (pour le Scheduler et l'admin)
|
||||
List<Recording> getAllRecordings() {
|
||||
final result = _db.select('SELECT * FROM tv_recordings ORDER BY start_time ASC');
|
||||
return result.map((row) => Recording.fromMap(row)).toList();
|
||||
}
|
||||
|
||||
/// Lister les enregistrements d'un utilisateur spécifique
|
||||
List<Recording> getUserRecordings(String userId) {
|
||||
final result = _db.select(
|
||||
'SELECT * FROM tv_recordings WHERE user_id = ? ORDER BY start_time ASC',
|
||||
[userId],
|
||||
);
|
||||
return result.map((row) => Recording.fromMap(row)).toList();
|
||||
}
|
||||
|
||||
/// Récupérer un enregistrement par ID
|
||||
Recording? getRecordingById(String id) {
|
||||
final result = _db.select('SELECT * FROM tv_recordings WHERE id = ?', [id]);
|
||||
if (result.isEmpty) return null;
|
||||
return Recording.fromMap(result.first);
|
||||
}
|
||||
|
||||
/// Mettre à jour le statut et éventuellement le chemin d'un enregistrement
|
||||
void updateRecordingStatus(String id, String status, {String? filePath, String? errorReason}) {
|
||||
final now = DateTime.now().toIso8601String();
|
||||
_db.execute(
|
||||
'''
|
||||
UPDATE tv_recordings
|
||||
SET status = ?, file_path = COALESCE(?, file_path), error_reason = COALESCE(?, error_reason), updated_at = ?
|
||||
WHERE id = ?
|
||||
''',
|
||||
[status, filePath, errorReason, now, id],
|
||||
);
|
||||
}
|
||||
|
||||
/// Supprimer un enregistrement depuis la BDD (ne supprime pas le fichier)
|
||||
void deleteRecording(String id) {
|
||||
_db.execute('DELETE FROM tv_recordings WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/// Close database connection
|
||||
void close() {
|
||||
_db.dispose();
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class Recording {
|
||||
final String id;
|
||||
final String userId;
|
||||
final String channelId;
|
||||
final String streamUrl;
|
||||
final String title;
|
||||
final DateTime startTime;
|
||||
final DateTime endTime;
|
||||
final String status; // 'scheduled', 'recording', 'completed', 'failed', 'cancelled'
|
||||
final String? filePath;
|
||||
final String? errorReason;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
Recording({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.channelId,
|
||||
required this.streamUrl,
|
||||
required this.title,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.status,
|
||||
this.filePath,
|
||||
this.errorReason,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'user_id': userId,
|
||||
'channel_id': channelId,
|
||||
'stream_url': streamUrl,
|
||||
'title': title,
|
||||
'start_time': startTime.toIso8601String(),
|
||||
'end_time': endTime.toIso8601String(),
|
||||
'status': status,
|
||||
'file_path': filePath,
|
||||
'error_reason': errorReason,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
factory Recording.fromMap(Map<String, dynamic> map) {
|
||||
return Recording(
|
||||
id: map['id'] ?? '',
|
||||
userId: map['user_id'] ?? '',
|
||||
channelId: map['channel_id'] ?? '',
|
||||
streamUrl: map['stream_url'] ?? '',
|
||||
title: map['title'] ?? '',
|
||||
startTime: DateTime.parse(map['start_time']),
|
||||
endTime: DateTime.parse(map['end_time']),
|
||||
status: map['status'] ?? 'scheduled',
|
||||
filePath: map['file_path'],
|
||||
errorReason: map['error_reason'],
|
||||
createdAt: DateTime.parse(map['created_at']),
|
||||
updatedAt: DateTime.parse(map['updated_at']),
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory Recording.fromJson(String source) =>
|
||||
Recording.fromMap(json.decode(source));
|
||||
|
||||
Recording copyWith({
|
||||
String? id,
|
||||
String? userId,
|
||||
String? channelId,
|
||||
String? streamUrl,
|
||||
String? title,
|
||||
DateTime? startTime,
|
||||
DateTime? endTime,
|
||||
String? status,
|
||||
String? filePath,
|
||||
String? errorReason,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return Recording(
|
||||
id: id ?? this.id,
|
||||
userId: userId ?? this.userId,
|
||||
channelId: channelId ?? this.channelId,
|
||||
streamUrl: streamUrl ?? this.streamUrl,
|
||||
title: title ?? this.title,
|
||||
startTime: startTime ?? this.startTime,
|
||||
endTime: endTime ?? this.endTime,
|
||||
status: status ?? this.status,
|
||||
filePath: filePath ?? this.filePath,
|
||||
errorReason: errorReason ?? this.errorReason,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,11 @@ import 'api/playlists_handler.dart';
|
||||
import 'api/settings_handler.dart';
|
||||
import 'api/streaming_handler.dart';
|
||||
import 'api/proxy_handler.dart';
|
||||
import 'api/recordings_api.dart';
|
||||
import 'middleware/auth_middleware.dart';
|
||||
import 'middleware/security_middleware.dart';
|
||||
import 'services/cleanup_service.dart';
|
||||
import 'services/recording_scheduler.dart';
|
||||
|
||||
void main(List<String> args) async {
|
||||
// Parse command line arguments
|
||||
@@ -36,6 +38,10 @@ void main(List<String> args) async {
|
||||
await db.init();
|
||||
await db.seedAdmin();
|
||||
|
||||
// Initialize and start Recording Scheduler
|
||||
final recordingScheduler = RecordingScheduler(db);
|
||||
recordingScheduler.start();
|
||||
|
||||
// Initialize Streaming Subsystem
|
||||
await initStreaming();
|
||||
|
||||
@@ -83,6 +89,7 @@ void main(List<String> args) async {
|
||||
final usersHandler = UsersHandler(db);
|
||||
final settingsHandler = SettingsHandler(db);
|
||||
final proxyHandler = ProxyHandler(getPlaylist, db);
|
||||
final recordingsApi = RecordingsApi(db, recordingScheduler);
|
||||
|
||||
// Setup router
|
||||
final apiRouter = Router()
|
||||
@@ -108,6 +115,13 @@ void main(List<String> args) async {
|
||||
const Pipeline()
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(settingsHandler.router.call),
|
||||
)
|
||||
// TV Recordings endpoints
|
||||
..mount(
|
||||
'/api/recordings',
|
||||
const Pipeline()
|
||||
//.addMiddleware(authMiddleware(db)) // TODO: Activer quand UI aura l'Auth header
|
||||
.addHandler(recordingsApi.router.call),
|
||||
);
|
||||
// NOTE: /api/xtream is handled by proxyHandler in the Cascade below
|
||||
// Do NOT mount here as it would intercept and block the actual proxy
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import '../database/database.dart';
|
||||
import '../models/recording.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class RecordingScheduler {
|
||||
final AppDatabase _db;
|
||||
Timer? _timer;
|
||||
bool _isRunning = false;
|
||||
|
||||
// Stocker l'enregistrement actuellement en cours d'exécution
|
||||
Recording? _currentRecording;
|
||||
Process? _ffmpegProcess;
|
||||
|
||||
RecordingScheduler(this._db);
|
||||
|
||||
void start() {
|
||||
print('[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV');
|
||||
// Vérifier toutes les minutes
|
||||
_timer = Timer.periodic(const Duration(minutes: 1), (_) => _checkAndRunRecordings());
|
||||
// Lancer une première vérification immédiatement
|
||||
_checkAndRunRecordings();
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_timer?.cancel();
|
||||
_stopCurrentRecording(reason: 'Arrêt du service planificateur');
|
||||
print('[RecordingScheduler] Arrêté');
|
||||
}
|
||||
|
||||
Future<void> _checkAndRunRecordings() async {
|
||||
if (_isRunning) return;
|
||||
_isRunning = true;
|
||||
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
|
||||
// Si un enregistrement est en cours, vérifier s'il doit s'arrêter
|
||||
if (_currentRecording != null) {
|
||||
if (now.isAfter(_currentRecording!.endTime)) {
|
||||
print('[RecordingScheduler] Fin de l\'enregistrement : ${_currentRecording!.title}');
|
||||
await _stopCurrentRecording();
|
||||
}
|
||||
}
|
||||
|
||||
// Rechercher les enregistrements planifiés
|
||||
final recordings = _db.getAllRecordings();
|
||||
|
||||
for (final recording in recordings) {
|
||||
// Nettoyer les enregistrements bloqués "recording" suite à un crash serveur
|
||||
if (recording.status == 'recording' && _currentRecording?.id != recording.id) {
|
||||
print('[RecordingScheduler] Correction d\'un enregistrement orphelin (état "recording" sans processus)');
|
||||
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Interruption inattendue du serveur');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Si l'enregistrement est planifié, qu'il est temps de démarrer et qu'il n'est pas déjà fini
|
||||
if (recording.status == 'scheduled' && now.isAfter(recording.startTime) && now.isBefore(recording.endTime)) {
|
||||
|
||||
if (_currentRecording != null) {
|
||||
print('[RecordingScheduler] Impossible de démarrer "${recording.title}", un autre enregistrement est en cours (${_currentRecording!.title}).');
|
||||
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Un autre enregistrement était déjà en cours (limite de 1 flux simultané).');
|
||||
continue;
|
||||
}
|
||||
|
||||
print('[RecordingScheduler] Démarrage de l\'enregistrement : ${recording.title}');
|
||||
await _startRecording(recording);
|
||||
}
|
||||
|
||||
// Si l'enregistrement est planifié mais que la date de fin est dépassée (loupé)
|
||||
if (recording.status == 'scheduled' && now.isAfter(recording.endTime)) {
|
||||
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Heure de fin dépassée avant le démarrage');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('[RecordingScheduler] Erreur lors de la vérification : $e');
|
||||
} finally {
|
||||
_isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startRecording(Recording recording) async {
|
||||
_currentRecording = recording;
|
||||
_db.updateRecordingStatus(recording.id, 'recording');
|
||||
|
||||
try {
|
||||
// Préparation du dossier d'enregistrement
|
||||
final recordingsDir = Directory('/app/recordings');
|
||||
if (!await recordingsDir.exists()) {
|
||||
await recordingsDir.create(recursive: true);
|
||||
}
|
||||
|
||||
// Nettoyer l'espace disque si nécessaire (TODO : Implémenter logique de roulement ici)
|
||||
await _checkDiskSpaceAndRotate(recordingsDir);
|
||||
|
||||
// Génération d'un nom de fichier unique et sûr
|
||||
final safeTitle = recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_');
|
||||
final dateStr = recording.startTime.toIso8601String().replaceAll(':', '').split('.')[0];
|
||||
final fileName = '${safeTitle}_$dateStr.mp4';
|
||||
final filePath = p.join(recordingsDir.path, fileName);
|
||||
|
||||
// FFmpeg arguments pour transcodage H265
|
||||
// Utilisation d'un Timeout ou d'une capture directe
|
||||
final args = [
|
||||
'-y', // Force overwrite
|
||||
'-i', recording.streamUrl, // Input stream
|
||||
'-c:v', 'libx265', // Transcodage Video H.265 (HEVC)
|
||||
'-preset', 'fast', // Preset d'encodage
|
||||
'-c:a', 'aac', // Transcodage Audio AAC
|
||||
'-b:a', '128k', // Bitrate audio
|
||||
// Arrêter automatiquement après la durée (sécurité supplémentaire en plus du Timer)
|
||||
'-t', '${recording.endTime.difference(DateTime.now()).inSeconds}',
|
||||
filePath
|
||||
];
|
||||
|
||||
print('[RecordingScheduler] Exécution: ffmpeg ${args.join(' ')}');
|
||||
|
||||
_ffmpegProcess = await Process.start('ffmpeg', args);
|
||||
|
||||
// Enregistrer le chemin du fichier dans la BDD
|
||||
_db.updateRecordingStatus(recording.id, 'recording', filePath: filePath);
|
||||
|
||||
// Écouter de manière asynchrone la fin du processus FFmpeg
|
||||
_ffmpegProcess!.exitCode.then((exitCode) {
|
||||
if (_currentRecording?.id == recording.id) {
|
||||
if (exitCode == 0 || exitCode == 255) { // 255 est souvent renvoyé lors d'un arrêt forcé (SIGKILL/SIGTERM) qui est attendu
|
||||
print('[RecordingScheduler] Enregistrement terminé avec succès (${recording.title})');
|
||||
_db.updateRecordingStatus(recording.id, 'completed');
|
||||
} else {
|
||||
print('[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour l\'enregistrement ${recording.title}');
|
||||
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Erreur FFmpeg code $exitCode');
|
||||
}
|
||||
_currentRecording = null;
|
||||
_ffmpegProcess = null;
|
||||
}
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
print('[RecordingScheduler] Impossible de démarrer FFmpeg: $e');
|
||||
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Impossible de lancer le processus (FFmpeg manquant?)');
|
||||
_currentRecording = null;
|
||||
_ffmpegProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopCurrentRecording({String? reason}) async {
|
||||
if (_ffmpegProcess != null && _currentRecording != null) {
|
||||
print('[RecordingScheduler] Arrêt forcé du processus FFmpeg. Raison: ${reason ?? "Fin programmée"}');
|
||||
_ffmpegProcess!.kill(ProcessSignal.sigterm); // Envoyer un signal d'arrêt propre
|
||||
_db.updateRecordingStatus(_currentRecording!.id, 'completed');
|
||||
_ffmpegProcess = null;
|
||||
_currentRecording = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkDiskSpaceAndRotate(Directory dir) async {
|
||||
// Cette fonction pourrait invoquer une commande système `df` ou simplement lister les fichiers
|
||||
// et supprimer les plus anciens si un quota (ex: max 20 Go) est atteint.
|
||||
// Pour l'implémentation initiale, nous pouvons lister et supprimer si plus de X fichiers
|
||||
try {
|
||||
final maxFiles = 50; // Nombre max d'enregistrements (exemple simpliste)
|
||||
final files = dir.listSync().whereType<File>().toList();
|
||||
|
||||
if (files.length > maxFiles) {
|
||||
print('[RecordingScheduler] Rotation de l\'espace disque : suppression des anciens enregistrements');
|
||||
files.sort((a, b) => a.statSync().modified.compareTo(b.statSync().modified)); // Du plus vieux au plus récent
|
||||
|
||||
// Supprimer les plus anciens pour revenir sous la limite
|
||||
final filesToDelete = files.take(files.length - maxFiles);
|
||||
for (var file in filesToDelete) {
|
||||
file.deleteSync();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-8
@@ -1,17 +1,20 @@
|
||||
services:
|
||||
iptv-web:
|
||||
build: .
|
||||
xtremflow:
|
||||
build:
|
||||
context: https://github.com/R0m1k3/xtremflow.git
|
||||
dockerfile: Dockerfile
|
||||
container_name: xtremflow
|
||||
ports:
|
||||
- "8089:8089"
|
||||
networks:
|
||||
- nginx_default
|
||||
volumes:
|
||||
- xtremflow-data:/app/data
|
||||
# Configuration du répertoire d'enregistrement TV
|
||||
# Par défaut: sauve dans ./data/recordings (dossier local)
|
||||
# Pour changer: modifiez le chemin avant les deux points ":"
|
||||
# Exemple Windows : - D:\MesVideos\TV:/app/recordings
|
||||
# Exemple Linux : - /mnt/nas/videos:/app/recordings
|
||||
- ${RECORDINGS_PATH:-./data/recordings}:/app/recordings
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
xtremflow-data:
|
||||
|
||||
networks:
|
||||
nginx_default:
|
||||
external: true
|
||||
@@ -12,6 +12,7 @@ import '../../../core/utils/responsive_layout.dart';
|
||||
import '../../../core/widgets/themed_loading_screen.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../core/widgets/tv_focusable_card.dart';
|
||||
import 'recording_modal.dart';
|
||||
|
||||
class LiveTVTab extends ConsumerStatefulWidget {
|
||||
final PlaylistConfig playlist;
|
||||
@@ -377,12 +378,15 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Icon Area
|
||||
Expanded(
|
||||
child: Container(
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Icon Area
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white
|
||||
.withOpacity(0.05), // Subtle transparent background
|
||||
@@ -498,6 +502,30 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
],
|
||||
),
|
||||
),
|
||||
// Record Button Icon Overlay (Positioned top right)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: TvFocusableCard(
|
||||
onTap: () {
|
||||
RecordingModal.show(context, channel);
|
||||
},
|
||||
borderRadius: 20,
|
||||
scaleFactor: 1.2,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.fiber_manual_record,
|
||||
color: Colors.redAccent,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../../../core/models/iptv_models.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../core/widgets/tv_focusable_card.dart';
|
||||
|
||||
class RecordingModal extends StatefulWidget {
|
||||
final Channel channel;
|
||||
|
||||
const RecordingModal({
|
||||
super.key,
|
||||
required this.channel,
|
||||
});
|
||||
|
||||
static Future<void> show(BuildContext context, Channel channel) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => RecordingModal(channel: channel),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<RecordingModal> createState() => _RecordingModalState();
|
||||
}
|
||||
|
||||
class _RecordingModalState extends State<RecordingModal> {
|
||||
DateTime _startTime = DateTime.now();
|
||||
int _durationMinutes = 60;
|
||||
bool _isLoading = false;
|
||||
|
||||
Future<void> _scheduleRecording() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final endTime = _startTime.add(Duration(minutes: _durationMinutes));
|
||||
|
||||
try {
|
||||
// Dans une implémentation réelle le port/URL serait géré par une configuration
|
||||
final response = await http.post(
|
||||
Uri.parse('http://localhost:8089/api/recordings'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({
|
||||
'channel_id': widget.channel.streamId,
|
||||
'stream_url': widget.channel.streamIcon, // Hack temp: Utiliser streamId pour générer l'url via player logic plus tard
|
||||
'title': widget.channel.name,
|
||||
'start_time': _startTime.toIso8601String(),
|
||||
'end_time': endTime.toIso8601String(),
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Enregistrement programmé !')),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} else {
|
||||
throw Exception('Erreur API');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
width: 450,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.background.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: AppColors.primary.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.fiber_manual_record, color: Colors.red),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Programmer un enregistrement',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Informations Chaine
|
||||
Text(
|
||||
'Chaîne ciblée :',
|
||||
style: GoogleFonts.inter(color: Colors.white54, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.channel.name,
|
||||
style: GoogleFonts.inter(color: Colors.white, fontSize: 18),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Heure de début
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Heure de début :',
|
||||
style: GoogleFonts.inter(color: Colors.white54, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TvFocusableCard(
|
||||
onTap: () async {
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(_startTime),
|
||||
);
|
||||
if (time != null) {
|
||||
setState(() {
|
||||
final now = DateTime.now();
|
||||
_startTime = DateTime(
|
||||
now.year, now.month, now.day,
|
||||
time.hour, time.minute,
|
||||
);
|
||||
// Empêcher l'heure de passé de demain pour simplifier
|
||||
if (_startTime.isBefore(now)) {
|
||||
_startTime = _startTime.add(const Duration(days: 1));
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
borderRadius: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.access_time, color: Colors.white, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${_startTime.hour.toString().padLeft(2, '0')}:${_startTime.minute.toString().padLeft(2, '0')}',
|
||||
style: GoogleFonts.inter(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Durée
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Durée (minutes) :',
|
||||
style: GoogleFonts.inter(color: Colors.white54, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
GlassContainer(
|
||||
borderRadius: 8,
|
||||
opacity: 0.1,
|
||||
padding: EdgeInsets.zero,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove, color: Colors.white),
|
||||
onPressed: () {
|
||||
if (_durationMinutes > 15) {
|
||||
setState(() => _durationMinutes -= 15);
|
||||
}
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$_durationMinutes m',
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.inter(color: Colors.white),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add, color: Colors.white),
|
||||
onPressed: () {
|
||||
if (_durationMinutes < 300) {
|
||||
setState(() => _durationMinutes += 15);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Actions
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
'Annuler',
|
||||
style: GoogleFonts.inter(color: Colors.white54),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
TvFocusableCard(
|
||||
onTap: _isLoading ? () {} : _scheduleRecording,
|
||||
borderRadius: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20, height: 20,
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
|
||||
)
|
||||
: Text(
|
||||
'Enregistrer',
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user