mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
feat: add EPG TV guide, season passes (auto-recording) and unified recordings/EPG tab with 3 sub-tabs
This commit is contained in:
1 parent
4762b4edf8
commit
50a6998c09
7 files changed
+1271
-248
No files matched your search
+1
-1
@@ -17,7 +17,7 @@ ENV DART_VM_OPTIONS="--old_gen_heap_size=4096"
|
||||
RUN flutter config --enable-web
|
||||
|
||||
# Force cache invalidation when source changes (update this value to force rebuild)
|
||||
ARG CACHEBUST=2026-03-08-v3
|
||||
ARG CACHEBUST=2026-03-09-epg-v1
|
||||
|
||||
# Copy dependency files first for better caching
|
||||
COPY pubspec.yaml ./
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../database/database.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
|
||||
/// API EPG — proxy vers Xtream avec cache 30 minutes
|
||||
/// GET /api/epg/<channel_id>?days=1
|
||||
class EpgApi {
|
||||
final AppDatabase _db;
|
||||
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
|
||||
|
||||
// Cache simple en mémoire : channelId → {data, expiresAt}
|
||||
final Map<String, _CacheEntry> _cache = {};
|
||||
|
||||
EpgApi(this._db, this._getPlaylist);
|
||||
|
||||
Future<Response> handleGetEpg(Request request, String channelId) async {
|
||||
// Vérifier le cache
|
||||
final cached = _cache[channelId];
|
||||
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
|
||||
return Response.ok(
|
||||
cached.data,
|
||||
headers: {'Content-Type': 'application/json', 'X-Cache': 'HIT'},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final playlist = await _getPlaylist(request);
|
||||
if (playlist == null) {
|
||||
return Response.forbidden(json.encode({'error': 'Playlist non trouvée'}),
|
||||
headers: {'Content-Type': 'application/json'});
|
||||
}
|
||||
|
||||
final dns = playlist.dns ?? '${playlist.serverUrl}';
|
||||
// Appel API Xtream pour l'EPG complet de la chaîne
|
||||
final url =
|
||||
'$dns/player_api.php?username=${playlist.username}&password=${playlist.password}'
|
||||
'&action=get_simple_data_table&stream_id=$channelId&type=epg&limit=48';
|
||||
|
||||
final response = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 15));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
return Response(response.statusCode,
|
||||
body: json.encode({'error': 'Erreur API Xtream: ${response.statusCode}'}),
|
||||
headers: {'Content-Type': 'application/json'});
|
||||
}
|
||||
|
||||
// Reformater les données pour la grille TV
|
||||
final raw = json.decode(response.body);
|
||||
final epgData = _transformEpgData(raw, channelId);
|
||||
final jsonStr = json.encode(epgData);
|
||||
|
||||
// Mettre en cache 30 minutes
|
||||
_cache[channelId] = _CacheEntry(
|
||||
data: jsonStr,
|
||||
expiresAt: DateTime.now().add(const Duration(minutes: 30)),
|
||||
);
|
||||
|
||||
return Response.ok(jsonStr, headers: {'Content-Type': 'application/json', 'X-Cache': 'MISS'});
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur lors de la récupération EPG: $e'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _transformEpgData(dynamic raw, String channelId) {
|
||||
try {
|
||||
List<dynamic> listings = [];
|
||||
|
||||
if (raw is Map && raw.containsKey('epg_listings')) {
|
||||
listings = raw['epg_listings'] as List<dynamic>? ?? [];
|
||||
} else if (raw is List) {
|
||||
listings = raw;
|
||||
}
|
||||
|
||||
final programmes = listings.map((item) {
|
||||
final startRaw = item['start'] as String? ?? '';
|
||||
final endRaw = item['stop'] as String? ?? item['end'] as String? ?? '';
|
||||
|
||||
// Décoder le titre (base64 si nécessaire)
|
||||
String title = item['title'] as String? ?? '';
|
||||
try {
|
||||
title = utf8.decode(base64Decode(title));
|
||||
} catch (_) {
|
||||
// n'est pas du base64, garder tel quel
|
||||
}
|
||||
|
||||
String description = item['description'] as String? ?? '';
|
||||
try {
|
||||
description = utf8.decode(base64Decode(description));
|
||||
} catch (_) {}
|
||||
|
||||
return {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'start': startRaw,
|
||||
'end': endRaw,
|
||||
'channel_id': channelId,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
return {'channel_id': channelId, 'programmes': programmes};
|
||||
} catch (e) {
|
||||
return {'channel_id': channelId, 'programmes': [], 'error': e.toString()};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheEntry {
|
||||
final String data;
|
||||
final DateTime expiresAt;
|
||||
_CacheEntry({required this.data, required this.expiresAt});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import '../database/database.dart';
|
||||
import '../models/user.dart';
|
||||
|
||||
/// API Season Passes — enregistrements répétés par titre d'émission
|
||||
class SeasonPassesApi {
|
||||
final AppDatabase _db;
|
||||
|
||||
SeasonPassesApi(this._db);
|
||||
|
||||
/// GET /api/season-passes — liste tous les season passes
|
||||
Response handleGetAll(Request request) {
|
||||
try {
|
||||
final passes = _db.getAllSeasonPasses();
|
||||
return Response.ok(
|
||||
json.encode(passes),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur: $e'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/season-passes — créer un season pass
|
||||
Future<Response> handlePost(Request request) async {
|
||||
try {
|
||||
final body = await request.readAsString();
|
||||
final data = json.decode(body) as Map<String, dynamic>;
|
||||
|
||||
final showTitle = data['show_title'] as String?;
|
||||
final channelId = data['channel_id'] as String?;
|
||||
final streamUrl = data['stream_url'] as String?;
|
||||
|
||||
if (showTitle == null || showTitle.isEmpty) {
|
||||
return Response(400,
|
||||
body: json.encode({'error': 'show_title est requis'}),
|
||||
headers: {'Content-Type': 'application/json'});
|
||||
}
|
||||
if (channelId == null || channelId.isEmpty) {
|
||||
return Response(400,
|
||||
body: json.encode({'error': 'channel_id est requis'}),
|
||||
headers: {'Content-Type': 'application/json'});
|
||||
}
|
||||
if (streamUrl == null || streamUrl.isEmpty) {
|
||||
return Response(400,
|
||||
body: json.encode({'error': 'stream_url est requis'}),
|
||||
headers: {'Content-Type': 'application/json'});
|
||||
}
|
||||
|
||||
// Récupérer l'utilisateur depuis le contexte
|
||||
final user = request.context['user'] as User?;
|
||||
final userId = user?.id ?? 'admin'; // fallback
|
||||
|
||||
// Vérifier si un season pass identique existe déjà
|
||||
final existing = _db.getAllSeasonPasses();
|
||||
final duplicate = existing.any((p) =>
|
||||
(p['show_title'] as String).toLowerCase() == showTitle.toLowerCase() &&
|
||||
p['channel_id'] == channelId);
|
||||
if (duplicate) {
|
||||
return Response(409,
|
||||
body: json.encode({'error': 'Un Season Pass identique existe déjà'}),
|
||||
headers: {'Content-Type': 'application/json'});
|
||||
}
|
||||
|
||||
final pass = _db.createSeasonPass(
|
||||
userId: userId,
|
||||
showTitle: showTitle,
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
);
|
||||
|
||||
print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId');
|
||||
return Response(201,
|
||||
body: json.encode(pass),
|
||||
headers: {'Content-Type': 'application/json'});
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur: $e'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/season-passes/<id> — supprimer un season pass
|
||||
Response handleDelete(Request request, String id) {
|
||||
try {
|
||||
_db.deleteSeasonPass(id);
|
||||
return Response.ok(
|
||||
json.encode({'message': 'Season Pass supprimé'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur: $e'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,20 @@ class AppDatabase {
|
||||
)
|
||||
''');
|
||||
|
||||
// Season Passes table (enregistrements répétés intelligents)
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS season_passes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
show_title TEXT NOT NULL,
|
||||
channel_id TEXT NOT NULL,
|
||||
stream_url TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_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)',
|
||||
@@ -108,6 +122,9 @@ class AppDatabase {
|
||||
_db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id)',
|
||||
);
|
||||
_db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_recordings_status ON tv_recordings(status)',
|
||||
);
|
||||
}
|
||||
|
||||
/// Seed default admin user if no users exist
|
||||
@@ -510,6 +527,49 @@ class AppDatabase {
|
||||
_db.execute('DELETE FROM tv_recordings WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
// ==================== Season Passes ====================
|
||||
|
||||
/// Créer un Season Pass (enregistrement répété par titre d'émission)
|
||||
Map<String, dynamic> createSeasonPass({
|
||||
required String userId,
|
||||
required String showTitle,
|
||||
required String channelId,
|
||||
required String streamUrl,
|
||||
}) {
|
||||
final id = _uuid.v4();
|
||||
final now = DateTime.now().toIso8601String();
|
||||
_db.execute(
|
||||
'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[id, userId, showTitle, channelId, streamUrl, now],
|
||||
);
|
||||
return {'id': id, 'user_id': userId, 'show_title': showTitle, 'channel_id': channelId, 'stream_url': streamUrl, 'enabled': 1, 'created_at': now};
|
||||
}
|
||||
|
||||
/// Lister tous les Season Passes
|
||||
List<Map<String, dynamic>> getAllSeasonPasses() {
|
||||
final result = _db.select('SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC');
|
||||
return result.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||
}
|
||||
|
||||
/// Supprimer un Season Pass
|
||||
void deleteSeasonPass(String id) {
|
||||
_db.execute('DELETE FROM season_passes WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/// Vérifier si un enregistrement existe déjà pour ce titre (déduplication)
|
||||
/// Retourne true si un enregistrement non-échoué avec ce titre existe pour cetteémission programméeà la même heure
|
||||
bool existsRecordingForEpisode(String title, DateTime startTime) {
|
||||
// Normaliser le titre pour la comparaison (insensible casse, sans espaces doubles)
|
||||
final result = _db.select(
|
||||
'''SELECT COUNT(*) as cnt FROM tv_recordings
|
||||
WHERE LOWER(title) = LOWER(?)
|
||||
AND start_time = ?
|
||||
AND status NOT IN ('failed')''',
|
||||
[title, startTime.toUtc().toIso8601String()],
|
||||
);
|
||||
return (result.first['cnt'] as int) > 0;
|
||||
}
|
||||
|
||||
/// Close database connection
|
||||
void close() {
|
||||
_db.dispose();
|
||||
|
||||
+30
-4
@@ -18,6 +18,8 @@ import 'api/settings_handler.dart';
|
||||
import 'api/streaming_handler.dart';
|
||||
import 'api/proxy_handler.dart';
|
||||
import 'api/recordings_api.dart';
|
||||
import 'api/epg_api.dart';
|
||||
import 'api/season_passes_api.dart';
|
||||
import 'middleware/auth_middleware.dart';
|
||||
import 'middleware/security_middleware.dart';
|
||||
import 'services/cleanup_service.dart';
|
||||
@@ -42,6 +44,24 @@ void main(List<String> args) async {
|
||||
final recordingScheduler = RecordingScheduler(db);
|
||||
recordingScheduler.start();
|
||||
|
||||
// Injecter la config playlist dans le scheduler pour les Season Passes
|
||||
// (on prend la playlist du premier utilisateur disponible)
|
||||
Future<void> _injectPlaylistToScheduler() async {
|
||||
final users = db.getAllUsers();
|
||||
if (users.isNotEmpty) {
|
||||
final playlists = db.getPlaylists(users[0].id);
|
||||
if (playlists.isNotEmpty) {
|
||||
final p = playlists.first;
|
||||
recordingScheduler.playlistDns = p.serverUrl;
|
||||
recordingScheduler.playlistUsername = p.username;
|
||||
recordingScheduler.playlistPassword = p.password;
|
||||
print('[Server] Playlist injectée dans le scheduler: ${p.name}');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Injecter après 5s pour attendre l'initialisation complète
|
||||
Future.delayed(const Duration(seconds: 5), _injectPlaylistToScheduler);
|
||||
|
||||
// Initialize Streaming Subsystem
|
||||
await initStreaming();
|
||||
|
||||
@@ -90,6 +110,8 @@ void main(List<String> args) async {
|
||||
final settingsHandler = SettingsHandler(db);
|
||||
final proxyHandler = ProxyHandler(getPlaylist, db);
|
||||
final recordingsApi = RecordingsApi(db, recordingScheduler);
|
||||
final epgApi = EpgApi(db, getPlaylist);
|
||||
final seasonPassesApi = SeasonPassesApi(db);
|
||||
|
||||
// Setup router
|
||||
final apiRouter = Router()
|
||||
@@ -116,14 +138,18 @@ void main(List<String> args) async {
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(settingsHandler.router.call),
|
||||
)
|
||||
// TV Recordings - Chaque route déclarée EXPLICITEMENT pour éviter les conflits shelf_router
|
||||
// NE PAS utiliser mount() ici car mount('/api/recordings', ...) intercepte TOUT
|
||||
// ce qui commence par /api/recordings (y compris /api/recordings/logs/<id>)
|
||||
// TV Recordings - routes explicites
|
||||
..get('/api/recordings', recordingsApi.handleGetAll)
|
||||
..post('/api/recordings', recordingsApi.handlePost)
|
||||
..delete('/api/recordings/<id>', recordingsApi.handleDelete)
|
||||
..post('/api/recordings/stop/<id>', recordingsApi.handleStop)
|
||||
..get('/api/recordings/logs/<id>', recordingsApi.getLogHandler);
|
||||
..get('/api/recordings/logs/<id>', recordingsApi.getLogHandler)
|
||||
// EPG - guide TV
|
||||
..get('/api/epg/<channelId>', epgApi.handleGetEpg)
|
||||
// Season Passes - enregistrements répétés
|
||||
..get('/api/season-passes', seasonPassesApi.handleGetAll)
|
||||
..post('/api/season-passes', seasonPassesApi.handlePost)
|
||||
..delete('/api/season-passes/<id>', seasonPassesApi.handleDelete);
|
||||
// NOTE: /api/xtream is handled by proxyHandler in the Cascade below
|
||||
// Do NOT mount here as it would intercept and block the actual proxy
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../database/database.dart';
|
||||
import '../models/recording.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -7,24 +9,36 @@ import 'package:path/path.dart' as p;
|
||||
class RecordingScheduler {
|
||||
final AppDatabase _db;
|
||||
Timer? _timer;
|
||||
Timer? _seasonPassTimer;
|
||||
bool _isRunning = false;
|
||||
|
||||
// Stocker l'enregistrement actuellement en cours d'exécution
|
||||
Recording? _currentRecording;
|
||||
Process? _ffmpegProcess;
|
||||
|
||||
// Playlist config pour les appels EPG des season passes
|
||||
// Rempli depuis server.dart après initialisation
|
||||
String? playlistDns;
|
||||
String? playlistUsername;
|
||||
String? playlistPassword;
|
||||
|
||||
RecordingScheduler(this._db);
|
||||
|
||||
void start() {
|
||||
print('[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV');
|
||||
// Vérifier toutes les 10 secondes (pour un démarrage quasi-immédiat)
|
||||
_timer = Timer.periodic(const Duration(seconds: 10), (_) => _checkAndRunRecordings());
|
||||
// Season Passes : vérifier toutes les 4 heures
|
||||
_seasonPassTimer = Timer.periodic(const Duration(hours: 4), (_) => _checkSeasonPasses());
|
||||
// Lancer une première vérification immédiatement
|
||||
_checkAndRunRecordings();
|
||||
// Vérification initiale des season passes après 30s (laisser le serveur démarrer)
|
||||
Timer(const Duration(seconds: 30), _checkSeasonPasses);
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_timer?.cancel();
|
||||
_seasonPassTimer?.cancel();
|
||||
_stopCurrentRecording(reason: 'Arrêt du service planificateur');
|
||||
print('[RecordingScheduler] Arrêté');
|
||||
}
|
||||
@@ -87,6 +101,83 @@ class RecordingScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifier les Season Passes et créer des enregistrements pour les nouvelles diffusions
|
||||
Future<void> _checkSeasonPasses() async {
|
||||
final passes = _db.getAllSeasonPasses();
|
||||
if (passes.isEmpty) return;
|
||||
|
||||
final dns = playlistDns;
|
||||
final username = playlistUsername;
|
||||
final password = playlistPassword;
|
||||
|
||||
if (dns == null || username == null || password == null) {
|
||||
print('[SeasonPass] Config playlist non disponible, vérification annulée');
|
||||
return;
|
||||
}
|
||||
|
||||
print('[SeasonPass] Vérification de ${passes.length} Season Pass(s)...');
|
||||
|
||||
for (final pass in passes) {
|
||||
try {
|
||||
final channelId = pass['channel_id'] as String;
|
||||
final streamUrl = pass['stream_url'] as String;
|
||||
final showTitle = pass['show_title'] as String;
|
||||
final userId = pass['user_id'] as String;
|
||||
|
||||
// Récupérer l'EPG de la chaîne (48 prochaines heures)
|
||||
final url = '$dns/player_api.php?username=$username&password=$password'
|
||||
'&action=get_simple_data_table&stream_id=$channelId&type=epg&limit=48';
|
||||
|
||||
final response = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 15));
|
||||
if (response.statusCode != 200) continue;
|
||||
|
||||
final raw = json.decode(response.body);
|
||||
final listings = (raw is Map ? raw['epg_listings'] : raw) as List<dynamic>? ?? [];
|
||||
|
||||
for (final item in listings) {
|
||||
String title = item['title'] as String? ?? '';
|
||||
try { title = utf8.decode(base64Decode(title)); } catch (_) {}
|
||||
|
||||
// Vérifier si le titre correspond au Season Pass (insensible casse, recherche partielle)
|
||||
if (!title.toLowerCase().contains(showTitle.toLowerCase())) continue;
|
||||
|
||||
// Parser les heures de début/fin
|
||||
final startStr = item['start'] as String? ?? '';
|
||||
final endStr = item['stop'] as String? ?? item['end'] as String? ?? '';
|
||||
if (startStr.isEmpty || endStr.isEmpty) continue;
|
||||
|
||||
DateTime startTime, endTime;
|
||||
try {
|
||||
startTime = DateTime.parse(startStr).toUtc();
|
||||
endTime = DateTime.parse(endStr).toUtc();
|
||||
} catch (_) { continue; }
|
||||
|
||||
// Ne pas créer pour les programmes déjà terminés
|
||||
if (endTime.isBefore(DateTime.now().toUtc())) continue;
|
||||
|
||||
// Déduplication : vérifier si cet épisode est déjà planifié/enregistré
|
||||
if (_db.existsRecordingForEpisode(title, startTime)) {
|
||||
print('[SeasonPass] "$title" déjà enregistré, skip.');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Créer l'enregistrement automatiquement
|
||||
_db.createRecording(
|
||||
userId: userId,
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
title: title,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
);
|
||||
print('[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}');
|
||||
}
|
||||
} catch (e) {
|
||||
print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startRecording(Recording recording) async {
|
||||
_currentRecording = recording;
|
||||
_db.updateRecordingStatus(recording.id, 'recording');
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user