mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-12 01:36:20 +02:00
feat: introduce IPTV data models and initial Live TV UI, including EPG overlay.
This commit is contained in:
1 parent
37945b513e
commit
fa88746db6
5 files changed
+212
-180
No files matched your search
+56
-50
@@ -10,7 +10,7 @@ import '../database/database.dart';
|
||||
class ProxyHandler {
|
||||
final Future<PlaylistConfig?> 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 = <String, String>{
|
||||
'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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -73,7 +73,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
.where((cat) => settings.matchesLiveTvFilter(cat))
|
||||
.toList();
|
||||
}
|
||||
categories.sort();
|
||||
// REMOVED: categories.sort(); - Respect API/Settings order
|
||||
|
||||
List<Channel> displayedChannels = [];
|
||||
bool showingCategoryGrid = false;
|
||||
@@ -387,125 +387,129 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
// 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(
|
||||
|
||||
@@ -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é
|
||||
Reference in new issue
Block a user