mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
Le guide du fournisseur est figé sur beaucoup de revendeurs : sur le compte de test, get_simple_data_table répond correctement mais le dernier programme français date de deux jours, et le xmltv.php du panneau ne couvre aucune chaîne française. Aucun correctif côté appel Xtream ne pouvait donc remplir la grille. Nouveau service XmltvEpgService : téléchargement de dumps XMLTV publics, décompression gzip détectée sur le nombre magique, parsing en flux via XmlEventReader — un dump national fait 46 Mo décompressés, en charger l'arbre DOM coûterait plusieurs centaines de mégaoctets dans le conteneur. Mesuré à environ 1 s pour 973 chaînes. Index rafraîchi toutes les 6 h, programmes terminés depuis plus de 6 h écartés à l'indexation. L'appel sortant reste un dernier recours. L'ordre est inchangé — get_simple_data_table, puis get_short_epg — et le dump n'est consulté que si le panneau ne rend aucun programme couvrant l'instant présent : compter les entrées ne suffisait pas, un guide périmé en renvoie des centaines. La source se configure par EPG_XMLTV_URLS ; vider la variable supprime tout appel sortant. La réponse porte un en-tête X-Epg-Source pour savoir qui a répondu. Les identifiants sont rapprochés après normalisation : le dump écrit France.2.fr là où le panneau annonce France2.fr. Repli sur le nom affiché de la chaîne, et table stream_id vers identifiant EPG mise en cache 6 h. Le client passe désormais par /api/epg au lieu d'appeler player_api.php en direct. Il dupliquait le choix de l'action, la conversion des fuseaux et le décodage base64 ; le backend le fait une fois, met en cache pour tous les utilisateurs, et sait basculer sur le repli — bascule impossible depuis le navigateur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
768 lines
25 KiB
Dart
768 lines
25 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:html' as html;
|
|
import 'package:dio/dio.dart';
|
|
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import '../../../core/models/playlist_config.dart';
|
|
import '../../../core/models/iptv_models.dart';
|
|
import '../models/xtream_models.dart' as xm;
|
|
import '../../../core/api/authed_http.dart';
|
|
|
|
/// Limiteur de concurrence minimal (pas de dépendance supplémentaire).
|
|
class _Semaphore {
|
|
_Semaphore(this.max);
|
|
|
|
final int max;
|
|
int _current = 0;
|
|
final List<Completer<void>> _waiters = [];
|
|
|
|
Future<void> acquire() {
|
|
if (_current < max) {
|
|
_current++;
|
|
return Future<void>.value();
|
|
}
|
|
final completer = Completer<void>();
|
|
_waiters.add(completer);
|
|
return completer.future;
|
|
}
|
|
|
|
void release() {
|
|
if (_waiters.isNotEmpty) {
|
|
_waiters.removeAt(0).complete();
|
|
} else {
|
|
_current--;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Xtream Codes API Service
|
|
///
|
|
/// Handles all communication with Xtream API servers
|
|
class XtreamService {
|
|
/// Une grille de chaînes déclenche un appel EPG par tuile visible — une
|
|
/// trentaine de requêtes simultanées vers `player_api.php`. Beaucoup de
|
|
/// panneaux Xtream limitent le débit et répondent en erreur au-delà de
|
|
/// quelques connexions parallèles, ce qui vide l'EPG de toute la grille.
|
|
/// On sérialise donc par petits paquets.
|
|
static final _Semaphore _epgGate = _Semaphore(3);
|
|
|
|
late final Dio _dio;
|
|
late final CacheOptions _cacheOptions;
|
|
|
|
PlaylistConfig? _currentPlaylist;
|
|
|
|
/// Flag to indicate that a playback is currently loading
|
|
/// Used to throttle background requests (EPG, images)
|
|
bool isPlaybackLoading = false;
|
|
|
|
/// Minimum delay for background requests when playback is loading
|
|
static const _throttleDelay = Duration(seconds: 8);
|
|
|
|
XtreamService() {
|
|
_dio = Dio(
|
|
BaseOptions(
|
|
connectTimeout: const Duration(seconds: 30),
|
|
receiveTimeout: const Duration(seconds: 60),
|
|
sendTimeout: const Duration(seconds: 60),
|
|
),
|
|
);
|
|
|
|
// Setup caching for API responses
|
|
// Use MemCacheStore for in-memory caching
|
|
_cacheOptions = CacheOptions(
|
|
store: MemCacheStore(),
|
|
policy: CachePolicy.forceCache, // Prioritize local cache
|
|
maxStale: const Duration(hours: 1), // Increase to 1h for stability
|
|
priority: CachePriority.high,
|
|
);
|
|
|
|
_dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions));
|
|
|
|
// Attach the backend session token: the Xtream gateway
|
|
// (/api/xtream-api) requires authentication and injects the IPTV
|
|
// credentials server-side.
|
|
_dio.interceptors.add(
|
|
InterceptorsWrapper(
|
|
onRequest: (options, handler) {
|
|
if (kIsWeb) {
|
|
final token = html.window.localStorage['auth_token'];
|
|
if (token != null && token.isNotEmpty) {
|
|
options.headers['Authorization'] = 'Bearer $token';
|
|
}
|
|
}
|
|
handler.next(options);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// GET on the authenticated Xtream gateway. Credentials are injected by
|
|
/// the backend; the client only passes the action and its parameters.
|
|
Future<Response<dynamic>> _apiGet(
|
|
Map<String, String> params, {
|
|
Options? options,
|
|
}) {
|
|
return _dio.get(
|
|
'$_backendBaseUrl/api/xtream-api',
|
|
queryParameters: params,
|
|
options: options ?? Options(extra: _cacheOptions.toExtra()),
|
|
);
|
|
}
|
|
|
|
String? _manualBackendUrl;
|
|
|
|
/// Get dynamic base URL for the local backend
|
|
/// Priority:
|
|
/// 1. Manual override from settings
|
|
/// 2. Detected origin (Web)
|
|
/// 3. Host machine IP (Emulator)
|
|
String get _backendBaseUrl {
|
|
if (_manualBackendUrl != null && _manualBackendUrl!.isNotEmpty) {
|
|
return _manualBackendUrl!;
|
|
}
|
|
if (kIsWeb) {
|
|
final origin = Uri.base.origin;
|
|
// If we are developing locally on a different port than the backend
|
|
if (origin.contains('localhost') && !origin.contains('8089')) {
|
|
return 'http://localhost:8089';
|
|
}
|
|
// On mobile devices, 'localhost' is the device itself.
|
|
// If origin is localhost but we are on mobile web, it might fail.
|
|
// We assume the user is accessing via an IP or hostname.
|
|
return origin;
|
|
}
|
|
// Android emulator bridge
|
|
return 'http://10.0.2.2:8089';
|
|
}
|
|
|
|
String get backendBaseUrl => _backendBaseUrl;
|
|
|
|
/// Set manual backend URL override
|
|
void setBackendUrl(String? url) {
|
|
_manualBackendUrl = url;
|
|
}
|
|
|
|
/// Update connection timeouts
|
|
void updateTimeouts(int seconds) {
|
|
_dio.options.connectTimeout = Duration(seconds: seconds);
|
|
_dio.options.receiveTimeout = Duration(seconds: seconds);
|
|
_dio.options.sendTimeout = Duration(seconds: seconds);
|
|
}
|
|
|
|
/// Initialize connection with a playlist
|
|
void setPlaylist(PlaylistConfig playlist) {
|
|
_currentPlaylist = playlist;
|
|
}
|
|
|
|
/// Generate stream URL for live TV (HLS)
|
|
/// [quality]: source | high | medium | low (server-side FFmpeg preset)
|
|
String getLiveStreamUrl(String streamId, {String quality = 'high'}) {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
return '$_backendBaseUrl/api/live/$streamId/$quality/playlist.m3u8';
|
|
}
|
|
|
|
/// Generate stream URL for live TV (Direct MPEG-TS)
|
|
/// Faster zapping on compatible players (mpegts.js)
|
|
String getLiveStreamUrlTs(String streamId) {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
// Backend route injects the Xtream credentials server-side
|
|
return '$_backendBaseUrl/api/live/$streamId.ts';
|
|
}
|
|
|
|
/// Generate stream URL for VOD (movies)
|
|
/// [quality]: source | high | medium | low (server-side FFmpeg preset)
|
|
String getVodStreamUrl(
|
|
String streamId,
|
|
String containerExtension, {
|
|
String quality = 'high',
|
|
}) {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8';
|
|
}
|
|
|
|
/// Generate stream URL for series episodes
|
|
String getSeriesStreamUrl(
|
|
String streamId,
|
|
String containerExtension, {
|
|
String quality = 'high',
|
|
}) {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8?type=series';
|
|
}
|
|
|
|
/// Authenticate and get server info
|
|
Future<Map<String, dynamic>> authenticate() async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
try {
|
|
final response = await _apiGet({});
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
} catch (e) {
|
|
throw Exception('Authentication failed: $e');
|
|
}
|
|
}
|
|
|
|
/// Load categories mapping (category_id -> category_name)
|
|
Future<Map<String, String>> _getLiveCategories() async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
try {
|
|
final response = await _apiGet({'action': 'get_live_categories'});
|
|
|
|
final List<dynamic> categories = response.data as List<dynamic>;
|
|
final Map<String, String> categoryMap = {};
|
|
|
|
for (final cat in categories) {
|
|
final catData = cat as Map<String, dynamic>;
|
|
final id = catData['category_id']?.toString() ?? '';
|
|
final name = catData['category_name']?.toString() ?? 'Unknown';
|
|
if (id.isNotEmpty) {
|
|
categoryMap[id] = name;
|
|
}
|
|
}
|
|
|
|
return categoryMap;
|
|
} catch (e) {
|
|
return {}; // Return empty map on error, channels will be "Uncategorized"
|
|
}
|
|
}
|
|
|
|
/// Get all live TV channels grouped by category
|
|
///
|
|
/// Returns a Map where key is category name and value is list of channels
|
|
Future<Map<String, List<Channel>>> getLiveChannels({
|
|
bool refresh = false,
|
|
}) async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
final options = refresh
|
|
? _cacheOptions
|
|
.copyWith(policy: CachePolicy.refreshForceCache)
|
|
.toExtra()
|
|
: _cacheOptions.toExtra();
|
|
|
|
try {
|
|
// Parallelize categories and streams fetching
|
|
final results = await Future.wait([
|
|
_getLiveCategories(),
|
|
_apiGet(
|
|
{'action': 'get_live_streams'},
|
|
options: Options(extra: options),
|
|
),
|
|
]);
|
|
|
|
final categoryMap = results[0] as Map<String, String>;
|
|
final response = results[1] as Response;
|
|
final List<dynamic> streams = response.data as List<dynamic>;
|
|
final groupedChannels = <String, List<Channel>>{};
|
|
|
|
for (final streamData in streams) {
|
|
final data = streamData as Map<String, dynamic>;
|
|
final categoryId = data['category_id']?.toString() ?? '';
|
|
final categoryName = categoryMap[categoryId] ?? 'Uncategorized';
|
|
data['category_name'] = categoryName;
|
|
|
|
final channel = Channel.fromJson(data);
|
|
groupedChannels.putIfAbsent(categoryName, () => []).add(channel);
|
|
}
|
|
|
|
// Save to local cache for instant display next time
|
|
_saveToLocalCache('live_channels', groupedChannels);
|
|
|
|
return groupedChannels;
|
|
} catch (e) {
|
|
// Try to return from local cache if network fails
|
|
final cached =
|
|
_loadFromLocalCache<Channel>('live_channels', Channel.fromJson);
|
|
if (cached.isNotEmpty) return cached;
|
|
throw Exception('Failed to fetch live channels: $e');
|
|
}
|
|
}
|
|
|
|
void _saveToLocalCache(String key, Map<String, dynamic> data) {
|
|
if (!kIsWeb) return;
|
|
try {
|
|
final jsonStr = jsonEncode(data);
|
|
html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'] =
|
|
jsonStr;
|
|
} catch (e) {
|
|
debugPrint('Error saving to local cache ($key): $e');
|
|
}
|
|
}
|
|
|
|
Map<String, List<T>> _loadFromLocalCache<T>(
|
|
String key,
|
|
T Function(Map<String, dynamic>) fromJson,
|
|
) {
|
|
if (!kIsWeb) return {};
|
|
try {
|
|
final jsonStr =
|
|
html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'];
|
|
if (jsonStr == null) return {};
|
|
|
|
final data = jsonDecode(jsonStr) as Map<String, dynamic>;
|
|
final result = <String, List<T>>{};
|
|
|
|
data.forEach((cat, items) {
|
|
if (items is List) {
|
|
result[cat] =
|
|
items.map((i) => fromJson(i as Map<String, dynamic>)).toList();
|
|
}
|
|
});
|
|
return result;
|
|
} catch (e) {
|
|
debugPrint('Error loading from local cache ($key): $e');
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/// Load VOD categories mapping
|
|
Future<Map<String, String>> _getVodCategories() async {
|
|
if (_currentPlaylist == null) return {};
|
|
|
|
try {
|
|
final response = await _apiGet({'action': 'get_vod_categories'});
|
|
|
|
final List<dynamic> categories = response.data as List<dynamic>;
|
|
final Map<String, String> categoryMap = {};
|
|
|
|
for (final cat in categories) {
|
|
final catData = cat as Map<String, dynamic>;
|
|
final id = catData['category_id']?.toString() ?? '';
|
|
final name = catData['category_name']?.toString() ?? 'Unknown';
|
|
if (id.isNotEmpty) categoryMap[id] = name;
|
|
}
|
|
|
|
return categoryMap;
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/// Get all VOD items (movies) grouped by category
|
|
Future<Map<String, List<VodItem>>> getVodItems({bool refresh = false}) async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
final options = refresh
|
|
? _cacheOptions
|
|
.copyWith(policy: CachePolicy.refreshForceCache)
|
|
.toExtra()
|
|
: _cacheOptions.toExtra();
|
|
|
|
try {
|
|
// Parallelize VOD categories and streams fetching
|
|
final results = await Future.wait([
|
|
_getVodCategories(),
|
|
_apiGet(
|
|
{'action': 'get_vod_streams'},
|
|
options: Options(extra: options),
|
|
),
|
|
]);
|
|
|
|
final categoryMap = results[0] as Map<String, String>;
|
|
final response = results[1] as Response;
|
|
|
|
final List<dynamic> vods = response.data as List<dynamic>;
|
|
final Map<String, List<VodItem>> groupedVods = {};
|
|
|
|
for (final vodData in vods) {
|
|
final data = vodData as Map<String, dynamic>;
|
|
final categoryId = data['category_id']?.toString() ?? '';
|
|
final categoryName = categoryMap[categoryId] ?? 'Uncategorized';
|
|
data['category_name'] = categoryName;
|
|
|
|
final vod = VodItem.fromJson(data);
|
|
groupedVods.putIfAbsent(categoryName, () => []).add(vod);
|
|
}
|
|
|
|
// Save to local cache
|
|
_saveToLocalCache('vod_items', groupedVods);
|
|
|
|
return groupedVods;
|
|
} catch (e) {
|
|
// Try to return from local cache if network fails
|
|
final cached =
|
|
_loadFromLocalCache<VodItem>('vod_items', VodItem.fromJson);
|
|
if (cached.isNotEmpty) return cached;
|
|
throw Exception('Failed to fetch VOD items: $e');
|
|
}
|
|
}
|
|
|
|
/// Load Series categories mapping
|
|
Future<Map<String, String>> _getSeriesCategories() async {
|
|
if (_currentPlaylist == null) return {};
|
|
|
|
try {
|
|
final response = await _apiGet({'action': 'get_series_categories'});
|
|
|
|
final List<dynamic> categories = response.data as List<dynamic>;
|
|
final Map<String, String> categoryMap = {};
|
|
|
|
for (final cat in categories) {
|
|
final catData = cat as Map<String, dynamic>;
|
|
final id = catData['category_id']?.toString() ?? '';
|
|
final name = catData['category_name']?.toString() ?? 'Unknown';
|
|
if (id.isNotEmpty) categoryMap[id] = name;
|
|
}
|
|
|
|
return categoryMap;
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/// Get all series grouped by category
|
|
Future<Map<String, List<Series>>> getSeries({bool refresh = false}) async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
final options = refresh
|
|
? _cacheOptions
|
|
.copyWith(policy: CachePolicy.refreshForceCache)
|
|
.toExtra()
|
|
: _cacheOptions.toExtra();
|
|
|
|
try {
|
|
// Parallelize Series categories and streams fetching
|
|
final results = await Future.wait([
|
|
_getSeriesCategories(),
|
|
_apiGet(
|
|
{'action': 'get_series'},
|
|
options: Options(extra: options),
|
|
),
|
|
]);
|
|
|
|
final categoryMap = results[0] as Map<String, String>;
|
|
final response = results[1] as Response;
|
|
|
|
final List<dynamic> seriesList = response.data as List<dynamic>;
|
|
final Map<String, List<Series>> groupedSeries = {};
|
|
|
|
for (final seriesData in seriesList) {
|
|
final data = seriesData as Map<String, dynamic>;
|
|
final categoryId = data['category_id']?.toString() ?? '';
|
|
final categoryName = categoryMap[categoryId] ?? 'Uncategorized';
|
|
data['category_name'] = categoryName;
|
|
|
|
final series = Series.fromJson(data);
|
|
groupedSeries.putIfAbsent(categoryName, () => []).add(series);
|
|
}
|
|
|
|
// Save to local cache
|
|
_saveToLocalCache('series_items', groupedSeries);
|
|
|
|
return groupedSeries;
|
|
} catch (e) {
|
|
// Try to return from local cache if network fails
|
|
final cached =
|
|
_loadFromLocalCache<Series>('series_items', Series.fromJson);
|
|
if (cached.isNotEmpty) return cached;
|
|
throw Exception('Failed to fetch series: $e');
|
|
}
|
|
}
|
|
|
|
/// Get movies with pagination support
|
|
Future<List<xm.Movie>> getMoviesPaginated({
|
|
int offset = 0,
|
|
int limit = 100,
|
|
}) async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
try {
|
|
// Load categories for mapping
|
|
final categoryMap = await _getVodCategories();
|
|
|
|
final response = await _apiGet({'action': 'get_vod_streams'});
|
|
|
|
final List<dynamic> allMovies = response.data as List<dynamic>;
|
|
|
|
// Apply pagination
|
|
final endIndex = (offset + limit) > allMovies.length
|
|
? allMovies.length
|
|
: offset + limit;
|
|
if (offset >= allMovies.length) return [];
|
|
|
|
final paginatedMovies = allMovies.sublist(offset, endIndex);
|
|
|
|
return paginatedMovies.map((movieData) {
|
|
final data = movieData as Map<String, dynamic>;
|
|
final categoryId = data['category_id']?.toString() ?? '';
|
|
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
|
|
return xm.Movie.fromJson(data);
|
|
}).toList();
|
|
} catch (e) {
|
|
throw Exception('Failed to fetch movies: $e');
|
|
}
|
|
}
|
|
|
|
/// Search movies in the entire catalogue
|
|
Future<List<xm.Movie>> searchMovies(String query) async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
if (query.isEmpty) return [];
|
|
|
|
try {
|
|
final categoryMap = await _getVodCategories();
|
|
|
|
final response = await _apiGet({'action': 'get_vod_streams'});
|
|
|
|
final List<dynamic> allMovies = response.data as List<dynamic>;
|
|
final queryLower = query.toLowerCase();
|
|
|
|
// Filter by search query
|
|
return allMovies
|
|
.where((m) {
|
|
final name = (m['name']?.toString() ?? '').toLowerCase();
|
|
return name.contains(queryLower);
|
|
})
|
|
.take(100) // Limit results
|
|
.map((movieData) {
|
|
final data = movieData as Map<String, dynamic>;
|
|
final categoryId = data['category_id']?.toString() ?? '';
|
|
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
|
|
return xm.Movie.fromJson(data);
|
|
})
|
|
.toList();
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Get series with pagination support (returns flat list)
|
|
Future<List<xm.Series>> getSeriesPaginated({
|
|
int offset = 0,
|
|
int limit = 100,
|
|
}) async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
try {
|
|
// Load categories for mapping
|
|
final categoryMap = await _getSeriesCategories();
|
|
|
|
final response = await _apiGet({'action': 'get_series'});
|
|
|
|
final List<dynamic> allSeries = response.data as List<dynamic>;
|
|
|
|
// Apply pagination
|
|
final endIndex = (offset + limit) > allSeries.length
|
|
? allSeries.length
|
|
: offset + limit;
|
|
if (offset >= allSeries.length) return [];
|
|
|
|
final paginatedSeries = allSeries.sublist(offset, endIndex);
|
|
|
|
return paginatedSeries.map((seriesData) {
|
|
final data = seriesData as Map<String, dynamic>;
|
|
final categoryId = data['category_id']?.toString() ?? '';
|
|
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
|
|
return xm.Series.fromJson(data);
|
|
}).toList();
|
|
} catch (e) {
|
|
throw Exception('Failed to fetch series: $e');
|
|
}
|
|
}
|
|
|
|
/// Search series in the entire catalogue
|
|
Future<List<xm.Series>> searchSeries(String query) async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
if (query.isEmpty) return [];
|
|
|
|
try {
|
|
final categoryMap = await _getSeriesCategories();
|
|
|
|
final response = await _apiGet({'action': 'get_series'});
|
|
|
|
final List<dynamic> allSeries = response.data as List<dynamic>;
|
|
final queryLower = query.toLowerCase();
|
|
|
|
// Filter by search query
|
|
return allSeries
|
|
.where((s) {
|
|
final name = (s['name']?.toString() ?? '').toLowerCase();
|
|
return name.contains(queryLower);
|
|
})
|
|
.take(100) // Limit results
|
|
.map((seriesData) {
|
|
final data = seriesData as Map<String, dynamic>;
|
|
final categoryId = data['category_id']?.toString() ?? '';
|
|
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
|
|
return xm.Series.fromJson(data);
|
|
})
|
|
.toList();
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Get series info with seasons and episodes
|
|
Future<xm.SeriesInfo> getSeriesInfo(String seriesId) async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
try {
|
|
final response = await _apiGet({
|
|
'action': 'get_series_info',
|
|
'series_id': seriesId,
|
|
});
|
|
|
|
return xm.SeriesInfo.fromJson(response.data as Map<String, dynamic>);
|
|
} catch (e) {
|
|
throw Exception('Failed to fetch series info: $e');
|
|
}
|
|
}
|
|
|
|
/// Get VOD (movie) info including duration
|
|
///
|
|
/// Returns detailed movie information including duration in seconds
|
|
Future<int?> getVodDuration(String vodId) async {
|
|
if (_currentPlaylist == null) return null;
|
|
|
|
try {
|
|
final response = await _apiGet({
|
|
'action': 'get_vod_info',
|
|
'vod_id': vodId,
|
|
});
|
|
|
|
final data = response.data as Map<String, dynamic>;
|
|
|
|
// Try to get duration from movie_properties or info
|
|
final movieData = data['movie_data'] as Map<String, dynamic>?;
|
|
final info = data['info'] as Map<String, dynamic>?;
|
|
|
|
// Duration can be in different formats: seconds (int), "HH:MM:SS", or minutes
|
|
String? durationStr = movieData?['duration']?.toString() ??
|
|
info?['duration']?.toString() ??
|
|
info?['duration_secs']?.toString();
|
|
|
|
if (durationStr == null || durationStr.isEmpty) return null;
|
|
|
|
// Parse duration - could be "01:30:00" format or seconds
|
|
if (durationStr.contains(':')) {
|
|
// HH:MM:SS format
|
|
final parts = durationStr.split(':');
|
|
if (parts.length == 3) {
|
|
final hours = int.tryParse(parts[0]) ?? 0;
|
|
final minutes = int.tryParse(parts[1]) ?? 0;
|
|
final seconds = int.tryParse(parts[2]) ?? 0;
|
|
return hours * 3600 + minutes * 60 + seconds;
|
|
} else if (parts.length == 2) {
|
|
// MM:SS format
|
|
final minutes = int.tryParse(parts[0]) ?? 0;
|
|
final seconds = int.tryParse(parts[1]) ?? 0;
|
|
return minutes * 60 + seconds;
|
|
}
|
|
}
|
|
|
|
// Try parsing as seconds directly
|
|
return int.tryParse(durationStr);
|
|
} catch (e) {
|
|
// Duration is optional, don't fail
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Get short EPG for a specific stream
|
|
///
|
|
/// Returns "Now" and "Next" program info
|
|
Future<List<EpgEntry>> getShortEpg(String streamId) async {
|
|
final programmes = await _fetchEpgProgrammes(streamId);
|
|
return programmes
|
|
.map(
|
|
(p) => EpgEntry(
|
|
id: '',
|
|
epgId: '',
|
|
title: p['title']?.toString() ?? '',
|
|
start: p['start']?.toString() ?? '',
|
|
end: p['end']?.toString() ?? '',
|
|
description: p['description']?.toString() ?? '',
|
|
channelId: streamId,
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
/// Get short EPG as ShortEPG object (for EPGWidget)
|
|
Future<xm.ShortEPG> getShortEPG(String streamId) async {
|
|
final programmes = await _fetchEpgProgrammes(streamId);
|
|
if (programmes.isEmpty) return const xm.ShortEPG();
|
|
|
|
final now = DateTime.now();
|
|
for (var i = 0; i < programmes.length; i++) {
|
|
final start = DateTime.tryParse('${programmes[i]['start']}');
|
|
final stop = DateTime.tryParse('${programmes[i]['end']}');
|
|
if (start == null || stop == null) continue;
|
|
if (now.isBefore(start) || !now.isBefore(stop)) continue;
|
|
|
|
final duration = stop.difference(start).inSeconds;
|
|
return xm.ShortEPG(
|
|
nowPlaying: programmes[i]['title']?.toString(),
|
|
nextPlaying: i + 1 < programmes.length
|
|
? programmes[i + 1]['title']?.toString()
|
|
: null,
|
|
progress: duration > 0
|
|
? now.difference(start).inSeconds / duration
|
|
: null,
|
|
);
|
|
}
|
|
return const xm.ShortEPG();
|
|
}
|
|
|
|
/// Guide d'une chaîne, servi par le backend `/api/epg/<streamId>`.
|
|
///
|
|
/// Le client interrogeait auparavant `player_api.php` en direct et devait
|
|
/// donc dupliquer le choix de l'action Xtream, la conversion des fuseaux et
|
|
/// le décodage base64. Le backend fait tout cela une fois, met en cache
|
|
/// 30 minutes pour tous les utilisateurs, et sait basculer sur la source
|
|
/// XMLTV de repli quand le panneau ne rend rien d'actuel — bascule
|
|
/// impossible à faire côté navigateur.
|
|
///
|
|
/// Les programmes reviennent déjà triés, en ISO-8601 UTC et en clair.
|
|
Future<List<Map<String, dynamic>>> _fetchEpgProgrammes(
|
|
String streamId,
|
|
) async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
if (isPlaybackLoading) {
|
|
debugPrint('[XtreamService] Throttling EPG for $streamId');
|
|
await Future.delayed(_throttleDelay);
|
|
}
|
|
|
|
await _epgGate.acquire();
|
|
try {
|
|
final response = await AuthedHttp.get(Uri.parse('/api/epg/$streamId'));
|
|
if (response.statusCode != 200) {
|
|
debugPrint(
|
|
'[XtreamService] EPG $streamId : HTTP ${response.statusCode}',
|
|
);
|
|
return const [];
|
|
}
|
|
|
|
final decoded = json.decode(response.body);
|
|
if (decoded is! Map || decoded['programmes'] is! List) return const [];
|
|
|
|
final now = DateTime.now();
|
|
final result = <Map<String, dynamic>>[];
|
|
for (final item in decoded['programmes'] as List) {
|
|
if (item is! Map) continue;
|
|
final end = DateTime.tryParse('${item['end']}');
|
|
// Les appelants prennent le premier élément comme programme courant :
|
|
// laisser passer les programmes terminés afficherait celui d'hier.
|
|
if (end == null || end.isBefore(now)) continue;
|
|
result.add(Map<String, dynamic>.from(item));
|
|
if (result.length >= 8) break;
|
|
}
|
|
return result;
|
|
} catch (e) {
|
|
// L'EPG reste optionnel : une panne ne doit pas casser la grille.
|
|
debugPrint('[XtreamService] EPG $streamId en échec : $e');
|
|
return const [];
|
|
} finally {
|
|
_epgGate.release();
|
|
}
|
|
}
|
|
|
|
/// Dispose resources
|
|
void dispose() {
|
|
_dio.close();
|
|
}
|
|
}
|