feat: add EPG TV guide, season passes (auto-recording) and unified recordings/EPG tab with 3 sub-tabs

This commit is contained in:
Michael committed 2026-03-09 08:21:48 +01:00
1 parent 4762b4edf8
commit 50a6998c09
7 files changed
+1271 -248

No files matched your search

+1 -1
View File
@@ -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 ./
+116
View File
@@ -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});
}
+103
View File
@@ -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'},
);
}
}
}
+60
View File
@@ -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
View File
@@ -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
+91
View File
@@ -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