mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-23 06:58:05 +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
+206
-174
No files matched your search
+50
-44
@@ -60,60 +60,63 @@ class ProxyHandler {
|
|||||||
// NOTE: Authentication REMOVED from proxy to allow browser-initiated requests (img src, etc.)
|
// NOTE: Authentication REMOVED from proxy to allow browser-initiated requests (img src, etc.)
|
||||||
// SSRF protection is still active via domain validation below.
|
// SSRF protection is still active via domain validation below.
|
||||||
|
|
||||||
Uri? targetUrl;
|
Uri? targetUrl;
|
||||||
|
|
||||||
// === PROXY LOGIC ===
|
// === PROXY LOGIC ===
|
||||||
try {
|
try {
|
||||||
// Extract target URL from request
|
// Extract target URL from request
|
||||||
// Format: /api/xtream/http://server:port/path
|
// Format: /api/xtream/http://server:port/path
|
||||||
String apiPath = path.substring('api/xtream/'.length);
|
String apiPath = path.substring('api/xtream/'.length);
|
||||||
|
|
||||||
// Decode URL if it's encoded
|
// Decode URL if it's encoded
|
||||||
if (apiPath.startsWith('http%3A') || apiPath.startsWith('https%3A')) {
|
if (apiPath.startsWith('http%3A') || apiPath.startsWith('https%3A')) {
|
||||||
apiPath = Uri.decodeComponent(apiPath);
|
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://')) {
|
targetUrl = Uri.parse(fullUrl);
|
||||||
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);
|
|
||||||
|
|
||||||
// SSRF Protection - but allow images/static assets from any host
|
// SSRF Protection - but allow images/static assets from any host
|
||||||
// Xtream providers often use separate CDN servers for picons/images
|
// Xtream providers often use separate CDN servers for picons/images
|
||||||
final isStaticAsset = targetUrl.path.endsWith('.png') ||
|
final isStaticAsset = targetUrl.path.endsWith('.png') ||
|
||||||
targetUrl.path.endsWith('.jpg') ||
|
targetUrl.path.endsWith('.jpg') ||
|
||||||
targetUrl.path.endsWith('.jpeg') ||
|
targetUrl.path.endsWith('.jpeg') ||
|
||||||
targetUrl.path.endsWith('.gif') ||
|
targetUrl.path.endsWith('.gif') ||
|
||||||
targetUrl.path.endsWith('.webp') ||
|
targetUrl.path.endsWith('.webp') ||
|
||||||
targetUrl.path.endsWith('.ico') ||
|
targetUrl.path.endsWith('.ico') ||
|
||||||
targetUrl.path.contains('/picons/') ||
|
targetUrl.path.contains('/picons/') ||
|
||||||
targetUrl.path.contains('/logos/');
|
targetUrl.path.contains('/logos/');
|
||||||
|
|
||||||
if (!isStaticAsset) {
|
if (!isStaticAsset) {
|
||||||
// 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,16 +162,17 @@ 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 ||
|
||||||
return Response.ok(
|
targetUrl?.path.endsWith('.jpg') == true) {
|
||||||
base64Decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='),
|
return Response.ok(
|
||||||
headers: {'content-type': 'image/png'},
|
base64Decode(
|
||||||
);
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='),
|
||||||
|
headers: {'content-type': 'image/png'},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.internalServerError(
|
return Response.internalServerError(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -387,125 +387,129 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
// Icon Area
|
// Icon Area
|
||||||
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(
|
||||||
child: channel.streamIcon.isNotEmpty
|
child: channel.streamIcon.isNotEmpty
|
||||||
? Image.network(
|
? Image.network(
|
||||||
_getProxiedIconUrl(channel.streamIcon)!,
|
_getProxiedIconUrl(channel.streamIcon)!,
|
||||||
errorBuilder: (_, __, ___) => Icon(
|
errorBuilder: (_, __, ___) => Icon(
|
||||||
Icons.tv,
|
Icons.tv,
|
||||||
color: Colors.white.withOpacity(0.3),
|
color: Colors.white.withOpacity(0.3),
|
||||||
size: 40,
|
size: 40,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
channel.name.characters.first.toUpperCase(),
|
channel.name.characters.first.toUpperCase(),
|
||||||
style: GoogleFonts.outfit(
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 40,
|
fontSize: 40,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.white24,
|
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,
|
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
),
|
||||||
Consumer(
|
// Footer
|
||||||
builder: (context, ref, _) {
|
Container(
|
||||||
final epgAsync = ref.watch(
|
padding: const EdgeInsets.symmetric(
|
||||||
epgByPlaylistProvider(
|
horizontal: 12, vertical: 8),
|
||||||
EpgRequestKey(
|
color: Colors.black54,
|
||||||
playlist: widget.playlist,
|
child: Column(
|
||||||
streamId: channel.streamId,
|
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(
|
return epgAsync.when(
|
||||||
data: (epgList) {
|
data: (epgList) {
|
||||||
if (epgList.isEmpty) {
|
if (epgList.isEmpty) {
|
||||||
return Text(
|
return Text(
|
||||||
'No Info',
|
'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(
|
style: GoogleFonts.inter(
|
||||||
color: Colors.white54,
|
color: Colors.white24,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
error: (_, __) => Text(
|
||||||
|
'Err',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: Colors.red,
|
||||||
fontSize: 10,
|
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,
|
top: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
child: TvFocusableCard(
|
child: TvFocusableCard(
|
||||||
|
|||||||
@@ -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é
|
||||||
Reference in new issue
Block a user