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

+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'},
);
}
}
}