From fa88746db6fee3855b8ed9e954f36e3ac0a53ce4 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 9 Mar 2026 10:12:55 +0100 Subject: [PATCH] feat: introduce IPTV data models and initial Live TV UI, including EPG overlay. --- bin/api/proxy_handler.dart | 106 +++++----- lib/core/models/iptv_models.dart | 21 +- lib/features/iptv/widgets/epg_overlay.dart | 6 +- lib/features/iptv/widgets/live_tv_tab.dart | 220 +++++++++++---------- task.md | 39 ++-- 5 files changed, 212 insertions(+), 180 deletions(-) diff --git a/bin/api/proxy_handler.dart b/bin/api/proxy_handler.dart index 6ed398f..f832851 100644 --- a/bin/api/proxy_handler.dart +++ b/bin/api/proxy_handler.dart @@ -10,7 +10,7 @@ import '../database/database.dart'; class ProxyHandler { final Future Function(Request) _getPlaylist; final AppDatabase _db; - + static const _allowedHeaders = [ 'content-type', 'content-length', @@ -60,60 +60,63 @@ class ProxyHandler { // NOTE: Authentication REMOVED from proxy to allow browser-initiated requests (img src, etc.) // SSRF protection is still active via domain validation below. - Uri? targetUrl; - - // === PROXY LOGIC === - try { - // Extract target URL from request - // Format: /api/xtream/http://server:port/path - String apiPath = path.substring('api/xtream/'.length); + Uri? targetUrl; - // Decode URL if it's encoded - if (apiPath.startsWith('http%3A') || apiPath.startsWith('https%3A')) { - apiPath = Uri.decodeComponent(apiPath); + // === PROXY LOGIC === + try { + // Extract target URL from request + // Format: /api/xtream/http://server:port/path + String apiPath = path.substring('api/xtream/'.length); + + // Decode URL if it's encoded + if (apiPath.startsWith('http%3A') || apiPath.startsWith('https%3A')) { + apiPath = Uri.decodeComponent(apiPath); + } + + if (!apiPath.startsWith('http://') && !apiPath.startsWith('https://')) { + return Response.badRequest( + body: 'Invalid API URL. Expected format: /api/xtream/http://...', + ); + } + + String fullUrl = apiPath; + if (request.url.query.isNotEmpty) { + if (fullUrl.contains('?')) { + fullUrl = '$fullUrl&${request.url.query}'; + } else { + fullUrl = '$fullUrl?${request.url.query}'; } + } - if (!apiPath.startsWith('http://') && !apiPath.startsWith('https://')) { - return Response.badRequest( - body: 'Invalid API URL. Expected format: /api/xtream/http://...', - ); - } - - String fullUrl = apiPath; - if (request.url.query.isNotEmpty) { - if (fullUrl.contains('?')) { - fullUrl = '$fullUrl&${request.url.query}'; - } else { - fullUrl = '$fullUrl?${request.url.query}'; - } - } - - targetUrl = Uri.parse(fullUrl); + targetUrl = Uri.parse(fullUrl); // SSRF Protection - but allow images/static assets from any host // Xtream providers often use separate CDN servers for picons/images final isStaticAsset = targetUrl.path.endsWith('.png') || - targetUrl.path.endsWith('.jpg') || - targetUrl.path.endsWith('.jpeg') || - targetUrl.path.endsWith('.gif') || - targetUrl.path.endsWith('.webp') || - targetUrl.path.endsWith('.ico') || - targetUrl.path.contains('/picons/') || - targetUrl.path.contains('/logos/'); + targetUrl.path.endsWith('.jpg') || + targetUrl.path.endsWith('.jpeg') || + targetUrl.path.endsWith('.gif') || + targetUrl.path.endsWith('.webp') || + targetUrl.path.endsWith('.ico') || + targetUrl.path.contains('/picons/') || + targetUrl.path.contains('/logos/'); if (!isStaticAsset) { // For API calls, enforce domain allowlist final playlist = await _getPlaylist(request); 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 allowedHost = Uri.parse(playlist.dns).host.toLowerCase(); if (targetHost != allowedHost) { - print('[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)'); - return Response.forbidden('Access to this domain is forbidden by policy'); + print( + '[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') { final bodyBytes = await request.read().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 { response = await client.get(targetUrl, headers: proxyHeaders); } @@ -140,13 +144,14 @@ class ProxyHandler { final responseHeaders = { 'access-control-allow-origin': '*', }; - + if (response.headers['content-type'] != null) { responseHeaders['content-type'] = response.headers['content-type']!; } - + if (response.headers['content-length'] != null) { - responseHeaders['content-length'] = response.headers['content-length']!; + responseHeaders['content-length'] = + response.headers['content-length']!; } return Response( @@ -157,16 +162,17 @@ class ProxyHandler { } finally { client.close(); } - - } catch (e, stackTrace) { + } catch (e) { print('[ProxyHandler] error on $path: $e'); - + // Return transparent 1x1 pixel image fallback for images - if (targetUrl?.path.endsWith('.png') == true || targetUrl?.path.endsWith('.jpg') == true) { - return Response.ok( - base64Decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='), - headers: {'content-type': 'image/png'}, - ); + if (targetUrl?.path.endsWith('.png') == true || + targetUrl?.path.endsWith('.jpg') == true) { + return Response.ok( + base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='), + headers: {'content-type': 'image/png'}, + ); } return Response.internalServerError( diff --git a/lib/core/models/iptv_models.dart b/lib/core/models/iptv_models.dart index e239859..de0cfb6 100644 --- a/lib/core/models/iptv_models.dart +++ b/lib/core/models/iptv_models.dart @@ -1,4 +1,5 @@ import 'dart:convert'; + /// Channel model for Live TV class Channel { 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) { if (text == null || text.isEmpty) return ''; try { @@ -182,10 +198,11 @@ class EpgEntry { /// Calculate progress percentage for current program double getProgress() { try { - final startTime = DateTime.parse(start); - final endTime = DateTime.parse(end); + final startTime = parseDateTime(start); + final endTime = parseDateTime(end); final now = DateTime.now(); + if (startTime == null || endTime == null) return 0.0; if (now.isBefore(startTime)) return 0.0; if (now.isAfter(endTime)) return 1.0; diff --git a/lib/features/iptv/widgets/epg_overlay.dart b/lib/features/iptv/widgets/epg_overlay.dart index d84aaa8..f4536e5 100644 --- a/lib/features/iptv/widgets/epg_overlay.dart +++ b/lib/features/iptv/widgets/epg_overlay.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/models/playlist_config.dart'; +import '../../../core/models/iptv_models.dart'; import '../providers/xtream_provider.dart'; /// Minimal EPG overlay - shows program title and info @@ -32,8 +33,9 @@ class EpgOverlay extends ConsumerWidget { final now = DateTime.now(); final currentProgram = epgEntries.firstWhere( (entry) { - final start = DateTime.parse(entry.start); - final end = DateTime.parse(entry.end); + final start = EpgEntry.parseDateTime(entry.start); + final end = EpgEntry.parseDateTime(entry.end); + if (start == null || end == null) return false; return now.isAfter(start) && now.isBefore(end); }, orElse: () => epgEntries.first, diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index d35ccf9..e3872ff 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -73,7 +73,7 @@ class _LiveTVTabState extends ConsumerState .where((cat) => settings.matchesLiveTvFilter(cat)) .toList(); } - categories.sort(); + // REMOVED: categories.sort(); - Respect API/Settings order List displayedChannels = []; bool showingCategoryGrid = false; @@ -387,125 +387,129 @@ class _LiveTVTabState extends ConsumerState // Icon Area Expanded( child: Container( - decoration: BoxDecoration( - color: Colors.white - .withOpacity(0.05), // Subtle transparent background - borderRadius: - const BorderRadius.vertical(top: Radius.circular(12)), - ), - padding: const EdgeInsets.all(16), - child: Center( - child: channel.streamIcon.isNotEmpty - ? Image.network( - _getProxiedIconUrl(channel.streamIcon)!, - errorBuilder: (_, __, ___) => Icon( - Icons.tv, - color: Colors.white.withOpacity(0.3), - size: 40, - ), - ) - : Text( - channel.name.characters.first.toUpperCase(), - style: GoogleFonts.outfit( - fontSize: 40, - fontWeight: FontWeight.bold, - color: Colors.white24, - ), - ), - ), - ), - ), - // Footer - Container( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - color: Colors.black54, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - channel.name, - style: GoogleFonts.inter( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500, + decoration: BoxDecoration( + color: Colors.white.withOpacity( + 0.05), // Subtle transparent background + borderRadius: const BorderRadius.vertical( + top: Radius.circular(12)), + ), + padding: const EdgeInsets.all(16), + child: Center( + child: channel.streamIcon.isNotEmpty + ? Image.network( + _getProxiedIconUrl(channel.streamIcon)!, + errorBuilder: (_, __, ___) => Icon( + Icons.tv, + color: Colors.white.withOpacity(0.3), + size: 40, + ), + ) + : Text( + channel.name.characters.first.toUpperCase(), + style: GoogleFonts.outfit( + fontSize: 40, + fontWeight: FontWeight.bold, + color: Colors.white24, + ), + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 4), - Consumer( - builder: (context, ref, _) { - final epgAsync = ref.watch( - epgByPlaylistProvider( - EpgRequestKey( - playlist: widget.playlist, - streamId: channel.streamId, - ), + ), + // Footer + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + color: Colors.black54, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + channel.name, + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, ), - ); + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Consumer( + builder: (context, ref, _) { + final epgAsync = ref.watch( + epgByPlaylistProvider( + EpgRequestKey( + playlist: widget.playlist, + streamId: channel.streamId, + ), + ), + ); - return epgAsync.when( - data: (epgList) { - if (epgList.isEmpty) { - return Text( - 'No Info', + return epgAsync.when( + data: (epgList) { + if (epgList.isEmpty) { + return Text( + 'No Info', + style: GoogleFonts.inter( + color: Colors.white54, + fontSize: 10, + ), + ); + } + final now = DateTime.now(); + final currentProgram = epgList.firstWhere( + (entry) { + try { + final start = + EpgEntry.parseDateTime(entry.start); + final end = + EpgEntry.parseDateTime(entry.end); + if (start == null || end == null) + return false; + return now.isAfter(start) && + now.isBefore(end); + } catch (e) { + return false; + } + }, + orElse: () => epgList.first, + ); + + return Text( + currentProgram.title, + style: GoogleFonts.inter( + color: const Color(0xFFFFD700), + fontSize: 10, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + }, + loading: () => Text( + '...', style: GoogleFonts.inter( - color: Colors.white54, + color: Colors.white24, + fontSize: 10, + ), + ), + error: (_, __) => Text( + 'Err', + style: GoogleFonts.inter( + color: Colors.red, fontSize: 10, ), - ); - } - final now = DateTime.now(); - final currentProgram = epgList.firstWhere( - (entry) { - try { - final start = DateTime.parse(entry.start); - final end = DateTime.parse(entry.end); - return now.isAfter(start) && - now.isBefore(end); - } catch (e) { - return false; - } - }, - orElse: () => epgList.first, - ); - - return Text( - currentProgram.title, - style: GoogleFonts.inter( - color: const Color(0xFFFFD700), - fontSize: 10, - fontWeight: FontWeight.w600, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ); }, - loading: () => Text( - '...', - style: GoogleFonts.inter( - color: Colors.white24, - fontSize: 10, - ), - ), - error: (_, __) => Text( - 'Err', - style: GoogleFonts.inter( - color: Colors.red, - fontSize: 10, - ), - ), - ); - }, + ), + ], ), - ], - ), + ), + ], ), - ], - ), - // Record Button Icon Overlay (Positioned top right) - Positioned( + // Record Button Icon Overlay (Positioned top right) + Positioned( top: 8, right: 8, child: TvFocusableCard( diff --git a/task.md b/task.md index 6eec729..2e51e66 100644 --- a/task.md +++ b/task.md @@ -1,24 +1,27 @@ -# Fix Docker & Proxy Issues +# Activation de l'Agent Bmad Master -## Completed +## Context -- [x] Database permissions (`entrypoint.sh` + `Dockerfile`) -- [x] Proxy 401 errors (removed broken mount in `server.dart`) -- [x] Proxy chunked encoding error (buffered response in `proxy_handler.dart`) +Activation de l'agent Bmad Master pour coordonner les modules BMAD et gérer le flux de travail du projet xtremflow. -## Changes +## Current Focus -| File | Change | -|------|--------| -| `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 | +Analyse du workflow Bmad Master et configuration initiale. -## Deploy +## Master Plan -```bash -git add . -git commit -m "fix: database permissions + proxy errors" -git push && docker compose up --build -d -``` +- [x] Analyser le workflow Bmad Master (`bmad-core-agents-bmad-master.md`) +- [x] Configurer l'environnement pour l'agent Master +- [x] Initialiser la session avec l'agent Master +- [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é