feat: introduce IPTV data models and initial Live TV UI, including EPG overlay.

This commit is contained in:
Michael committed 2026-03-09 10:12:55 +01:00
1 parent 37945b513e
commit fa88746db6
5 files changed
+72 -40

No files matched your search

+15 -9
View File
@@ -105,15 +105,18 @@ class ProxyHandler {
// For API calls, enforce domain allowlist // For API calls, enforce domain allowlist
final playlist = await _getPlaylist(request); final playlist = await _getPlaylist(request);
if (playlist == null) { if (playlist == null) {
return Response.forbidden('No active playlist configuration found to validate request'); return Response.forbidden(
'No active playlist configuration found to validate request');
} }
final targetHost = targetUrl.host.toLowerCase(); final targetHost = targetUrl.host.toLowerCase();
final allowedHost = Uri.parse(playlist.dns).host.toLowerCase(); final allowedHost = Uri.parse(playlist.dns).host.toLowerCase();
if (targetHost != allowedHost) { if (targetHost != allowedHost) {
print('[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)'); print(
return Response.forbidden('Access to this domain is forbidden by policy'); '[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)');
return Response.forbidden(
'Access to this domain is forbidden by policy');
} }
} }
@@ -131,7 +134,8 @@ class ProxyHandler {
if (request.method == 'POST') { if (request.method == 'POST') {
final bodyBytes = await request.read().toList(); final bodyBytes = await request.read().toList();
final body = bodyBytes.expand((i) => i).toList(); final body = bodyBytes.expand((i) => i).toList();
response = await client.post(targetUrl, headers: proxyHeaders, body: body); response =
await client.post(targetUrl, headers: proxyHeaders, body: body);
} else { } else {
response = await client.get(targetUrl, headers: proxyHeaders); response = await client.get(targetUrl, headers: proxyHeaders);
} }
@@ -146,7 +150,8 @@ class ProxyHandler {
} }
if (response.headers['content-length'] != null) { if (response.headers['content-length'] != null) {
responseHeaders['content-length'] = response.headers['content-length']!; responseHeaders['content-length'] =
response.headers['content-length']!;
} }
return Response( return Response(
@@ -157,14 +162,15 @@ class ProxyHandler {
} finally { } finally {
client.close(); client.close();
} }
} catch (e) {
} catch (e, stackTrace) {
print('[ProxyHandler] error on $path: $e'); print('[ProxyHandler] error on $path: $e');
// Return transparent 1x1 pixel image fallback for images // Return transparent 1x1 pixel image fallback for images
if (targetUrl?.path.endsWith('.png') == true || targetUrl?.path.endsWith('.jpg') == true) { if (targetUrl?.path.endsWith('.png') == true ||
targetUrl?.path.endsWith('.jpg') == true) {
return Response.ok( return Response.ok(
base64Decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='), base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='),
headers: {'content-type': 'image/png'}, headers: {'content-type': 'image/png'},
); );
} }
+19 -2
View File
@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
/// Channel model for Live TV /// Channel model for Live TV
class Channel { class Channel {
final String streamId; final String streamId;
@@ -169,6 +170,21 @@ class EpgEntry {
); );
} }
/// Robustly parse date strings from Xtream API
static DateTime? parseDateTime(String? dateStr) {
if (dateStr == null || dateStr.isEmpty) return null;
try {
// Xtream often uses "YYYY-MM-DD HH:MM:SS" which is not ISO 8601
// ISO 8601 needs "T" separator: "YYYY-MM-DDTHH:MM:SS"
final normalizedDate = dateStr.contains(' ') && !dateStr.contains('T')
? dateStr.replaceFirst(' ', 'T')
: dateStr;
return DateTime.tryParse(normalizedDate);
} catch (_) {
return null;
}
}
static String _decodeBase64(String? text) { static String _decodeBase64(String? text) {
if (text == null || text.isEmpty) return ''; if (text == null || text.isEmpty) return '';
try { try {
@@ -182,10 +198,11 @@ class EpgEntry {
/// Calculate progress percentage for current program /// Calculate progress percentage for current program
double getProgress() { double getProgress() {
try { try {
final startTime = DateTime.parse(start); final startTime = parseDateTime(start);
final endTime = DateTime.parse(end); final endTime = parseDateTime(end);
final now = DateTime.now(); final now = DateTime.now();
if (startTime == null || endTime == null) return 0.0;
if (now.isBefore(startTime)) return 0.0; if (now.isBefore(startTime)) return 0.0;
if (now.isAfter(endTime)) return 1.0; if (now.isAfter(endTime)) return 1.0;
+4 -2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import '../../../core/models/iptv_models.dart';
import '../providers/xtream_provider.dart'; import '../providers/xtream_provider.dart';
/// Minimal EPG overlay - shows program title and info /// Minimal EPG overlay - shows program title and info
@@ -32,8 +33,9 @@ class EpgOverlay extends ConsumerWidget {
final now = DateTime.now(); final now = DateTime.now();
final currentProgram = epgEntries.firstWhere( final currentProgram = epgEntries.firstWhere(
(entry) { (entry) {
final start = DateTime.parse(entry.start); final start = EpgEntry.parseDateTime(entry.start);
final end = DateTime.parse(entry.end); final end = EpgEntry.parseDateTime(entry.end);
if (start == null || end == null) return false;
return now.isAfter(start) && now.isBefore(end); return now.isAfter(start) && now.isBefore(end);
}, },
orElse: () => epgEntries.first, orElse: () => epgEntries.first,
+13 -9
View File
@@ -73,7 +73,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
.where((cat) => settings.matchesLiveTvFilter(cat)) .where((cat) => settings.matchesLiveTvFilter(cat))
.toList(); .toList();
} }
categories.sort(); // REMOVED: categories.sort(); - Respect API/Settings order
List<Channel> displayedChannels = []; List<Channel> displayedChannels = [];
bool showingCategoryGrid = false; bool showingCategoryGrid = false;
@@ -388,10 +388,10 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
Expanded( Expanded(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white color: Colors.white.withOpacity(
.withOpacity(0.05), // Subtle transparent background 0.05), // Subtle transparent background
borderRadius: borderRadius: const BorderRadius.vertical(
const BorderRadius.vertical(top: Radius.circular(12)), top: Radius.circular(12)),
), ),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Center( child: Center(
@@ -417,8 +417,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
), ),
// Footer // Footer
Container( Container(
padding: padding: const EdgeInsets.symmetric(
const EdgeInsets.symmetric(horizontal: 12, vertical: 8), horizontal: 12, vertical: 8),
color: Colors.black54, color: Colors.black54,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -460,8 +460,12 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
final currentProgram = epgList.firstWhere( final currentProgram = epgList.firstWhere(
(entry) { (entry) {
try { try {
final start = DateTime.parse(entry.start); final start =
final end = DateTime.parse(entry.end); EpgEntry.parseDateTime(entry.start);
final end =
EpgEntry.parseDateTime(entry.end);
if (start == null || end == null)
return false;
return now.isAfter(start) && return now.isAfter(start) &&
now.isBefore(end); now.isBefore(end);
} catch (e) { } catch (e) {
+21 -18
View File
@@ -1,24 +1,27 @@
# Fix Docker & Proxy Issues # Activation de l'Agent Bmad Master
## Completed ## Context
- [x] Database permissions (`entrypoint.sh` + `Dockerfile`) Activation de l'agent Bmad Master pour coordonner les modules BMAD et gérer le flux de travail du projet xtremflow.
- [x] Proxy 401 errors (removed broken mount in `server.dart`)
- [x] Proxy chunked encoding error (buffered response in `proxy_handler.dart`)
## Changes ## Current Focus
| File | Change | Analyse du workflow Bmad Master et configuration initiale.
|------|--------|
| `entrypoint.sh` | NEW - fixes /app/data permissions, runs as xtremuser via gosu |
| `Dockerfile` | Added gosu, ENTRYPOINT |
| `bin/server.dart` | Removed broken /api/xtream mount, added logging |
| `bin/api/proxy_handler.dart` | Changed from streaming to buffered response |
## Deploy ## Master Plan
```bash - [x] Analyser le workflow Bmad Master (`bmad-core-agents-bmad-master.md`)
git add . - [x] Configurer l'environnement pour l'agent Master
git commit -m "fix: database permissions + proxy errors" - [x] Initialiser la session avec l'agent Master
git push && docker compose up --build -d - [x] Diagnostiquer l'absence d'EPG
``` - [x] Diagnostiquer le problème de tri des chaînes
- [x] Vérifier l'état de l'agent et des sous-modules
## Progress Log
- [x] Identification du workflow dans `.agent/workflows/bmad/`
- [x] Lecture du workflow et de la configuration
- [x] Validation du plan d'activation par Michael
- [x] Activation de l'identité Bmad Master
- [x] Correction du parsing de date EPG (Xtream format support)
- [x] Suppression du tri alphabétique forcé