mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
feat: add mobile-optimized IPTV player with HLS and MpegTS support
This commit is contained in:
1 parent
3c9a9c7be7
commit
fa6ae90d8d
38 files changed
+1280
-895
No files matched your search
@@ -29,8 +29,9 @@ class EpgApi {
|
||||
final playlist = await _getPlaylist(request);
|
||||
if (playlist == null) {
|
||||
return Response.forbidden(
|
||||
json.encode({'error': 'Playlist non trouvée'}),
|
||||
headers: {'Content-Type': 'application/json'});
|
||||
json.encode({'error': 'Playlist non trouvée'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
final dns = playlist.dns;
|
||||
@@ -43,7 +44,7 @@ class EpgApi {
|
||||
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
|
||||
Map<String, dynamic> epgData = {
|
||||
'channel_id': channelId,
|
||||
'programmes': []
|
||||
'programmes': [],
|
||||
};
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
@@ -72,8 +73,10 @@ class EpgApi {
|
||||
expiresAt: DateTime.now().add(const Duration(minutes: 30)),
|
||||
);
|
||||
|
||||
return Response.ok(jsonStr,
|
||||
headers: {'Content-Type': 'application/json', 'X-Cache': 'MISS'});
|
||||
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'}),
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/playlist.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
import '../database/database.dart';
|
||||
|
||||
@@ -34,8 +33,9 @@ class ProxyHandler {
|
||||
Future<PlaylistConfig?> _getCachedPlaylist(Request request) async {
|
||||
// Basic caching to avoid DB overhead on every video segment
|
||||
final now = DateTime.now();
|
||||
final cacheKey = 'global_playlist'; // Currently app has one primary playlist per user/global
|
||||
|
||||
const cacheKey =
|
||||
'global_playlist'; // Currently app has one primary playlist per user/global
|
||||
|
||||
if (_playlistCache.containsKey(cacheKey)) {
|
||||
final (cached, expiry) = _playlistCache[cacheKey]!;
|
||||
if (now.isBefore(expiry)) return cached;
|
||||
@@ -134,7 +134,8 @@ class ProxyHandler {
|
||||
final playlist = await _getCachedPlaylist(request);
|
||||
if (playlist == null) {
|
||||
return Response.forbidden(
|
||||
'No active playlist configuration found to validate request');
|
||||
'No active playlist configuration found to validate request',
|
||||
);
|
||||
}
|
||||
|
||||
final targetHost = targetUrl.host.toLowerCase();
|
||||
@@ -142,9 +143,11 @@ class ProxyHandler {
|
||||
|
||||
if (targetHost != allowedHost) {
|
||||
print(
|
||||
'[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)');
|
||||
'[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)',
|
||||
);
|
||||
return Response.forbidden(
|
||||
'Access to this domain is forbidden by policy');
|
||||
'Access to this domain is forbidden by policy',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,14 +166,14 @@ class ProxyHandler {
|
||||
try {
|
||||
print('[Proxy] Forwarding to: $targetUrl');
|
||||
final proxyRequest = http.Request(request.method, targetUrl);
|
||||
|
||||
|
||||
// Forward safe request headers
|
||||
for (final header in _allowedRequestHeaders) {
|
||||
if (request.headers.containsKey(header)) {
|
||||
proxyHeaders[header] = request.headers[header]!;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
proxyRequest.headers.addAll(proxyHeaders);
|
||||
proxyRequest.followRedirects = true;
|
||||
|
||||
@@ -215,7 +218,8 @@ class ProxyHandler {
|
||||
targetUrl?.path.endsWith('.jpg') == true) {
|
||||
return Response.ok(
|
||||
base64Decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='),
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
|
||||
),
|
||||
headers: {'content-type': 'image/png'},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,19 +36,25 @@ class SeasonPassesApi {
|
||||
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'});
|
||||
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'});
|
||||
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'});
|
||||
return Response(
|
||||
400,
|
||||
body: json.encode({'error': 'stream_url est requis'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
// Récupérer l'utilisateur depuis le contexte
|
||||
@@ -57,13 +63,18 @@ class SeasonPassesApi {
|
||||
|
||||
// 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);
|
||||
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'});
|
||||
return Response(
|
||||
409,
|
||||
body: json.encode({'error': 'Un Season Pass identique existe déjà'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
final pass = _db.createSeasonPass(
|
||||
@@ -74,9 +85,11 @@ class SeasonPassesApi {
|
||||
);
|
||||
|
||||
print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId');
|
||||
return Response(201,
|
||||
body: json.encode(pass),
|
||||
headers: {'Content-Type': 'application/json'});
|
||||
return Response(
|
||||
201,
|
||||
body: json.encode(pass),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur: $e'}),
|
||||
|
||||
@@ -153,17 +153,20 @@ Handler createLiveStreamHandler(
|
||||
'-i', targetUrl,
|
||||
// Video
|
||||
if (useNvidiaGpu) ...[
|
||||
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq', // Better quality preset for NVENC
|
||||
'-b:v', '8000k', '-maxrate', '12000k', '-bufsize', '16000k', // Quality native / high bitrate
|
||||
'-g', '50',
|
||||
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune',
|
||||
'hq', // Better quality preset for NVENC
|
||||
'-b:v', '8000k', '-maxrate', '12000k', '-bufsize',
|
||||
'16000k', // Quality native / high bitrate
|
||||
'-g', '50',
|
||||
] else ...[
|
||||
'-c:v', 'libx264', '-preset', 'medium', '-tune', 'zerolatency', // Better detail than ultrafast
|
||||
'-c:v', 'libx264', '-preset', 'medium', '-tune',
|
||||
'zerolatency', // Better detail than ultrafast
|
||||
'-b:v', '6000k', '-maxrate', '8000k', '-bufsize', '12000k',
|
||||
'-g', '50',
|
||||
'-g', '50',
|
||||
],
|
||||
// Audio: High quality + Sync filter
|
||||
'-c:a', 'aac', '-b:a', '192k', '-ac', '2', '-ar', '48000',
|
||||
'-af', 'aresample=async=1',
|
||||
'-af', 'aresample=async=1',
|
||||
// HLS Sliding Window
|
||||
'-f', 'hls',
|
||||
'-hls_time', '2',
|
||||
@@ -201,15 +204,19 @@ Handler createLiveStreamHandler(
|
||||
retries++;
|
||||
}
|
||||
|
||||
if (!file.existsSync())
|
||||
if (!file.existsSync()) {
|
||||
return Response.internalServerError(body: 'Timeout starting live HLS');
|
||||
}
|
||||
|
||||
return Response.ok(file.openRead(), headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
return Response.ok(
|
||||
file.openRead(),
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Route: /api/live/{streamId}.ts (Direct Proxy for internal recordings or raw playback)
|
||||
@@ -245,11 +252,14 @@ Handler createLiveStreamHandler(
|
||||
final file = File('${_hlsTempDir.path}/live_$streamId/$segment');
|
||||
if (!file.existsSync()) return Response.notFound('Segment not found');
|
||||
|
||||
return Response.ok(file.openRead(), headers: {
|
||||
'Content-Type': 'video/mp2t',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'max-age=60',
|
||||
});
|
||||
return Response.ok(
|
||||
file.openRead(),
|
||||
headers: {
|
||||
'Content-Type': 'video/mp2t',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'max-age=60',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return router;
|
||||
|
||||
Reference in new issue
Block a user