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
@@ -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'},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user