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> _waiters = []; Future acquire() { if (_current < max) { _current++; return Future.value(); } final completer = Completer(); _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); /// Cache mémoire du guide, partagé par toutes les instances. /// /// Une grille de chaînes reconstruit ses tuiles à chaque défilement et /// redemandait le guide à chaque fois. Sur une playlist de 500 chaînes, le /// limiteur de débit du backend (200 requêtes par minute et par IP) coupait /// la grille en 429 au bout de quelques écrans. Le backend garde déjà ces /// réponses 30 minutes ; ce cache évite d'aller les chercher. static final Map _epgCache = {}; static const _epgCacheTtl = Duration(minutes: 10); 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> _apiGet( Map 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'); // Route « turbo » : vidéo copiée, audio réencodé en AAC côté serveur. // Le proxy brut ($_backendBaseUrl/api/live/$streamId.ts) livrait l'audio // d'origine — AC-3/E-AC-3/MP2 que mpegts.js ne décode pas → chaînes // muettes. Il reste utilisé tel quel par le scheduler d'enregistrement. return '$_backendBaseUrl/api/live/$streamId/turbo.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'); // `ext` : le serveur demande le fichier au panneau sous son extension // réelle — un `.mp4` réclamé en `.mkv` est refusé (HTTP 551). return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8' '${_extQuery(containerExtension, first: true)}'; } String _extQuery(String ext, {required bool first}) { final value = ext.trim().toLowerCase(); if (value.isEmpty) return ''; return '${first ? '?' : '&'}ext=${Uri.encodeQueryComponent(value)}'; } /// 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' '${_extQuery(containerExtension, first: false)}'; } /// Authenticate and get server info Future> authenticate() async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { final response = await _apiGet({}); return response.data as Map; } catch (e) { throw Exception('Authentication failed: $e'); } } /// Load categories mapping (category_id -> category_name) Future> _getLiveCategories() async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { final response = await _apiGet({'action': 'get_live_categories'}); final List categories = response.data as List; final Map categoryMap = {}; for (final cat in categories) { final catData = cat as Map; 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>> 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; final response = results[1] as Response; final List streams = response.data as List; final groupedChannels = >{}; for (final streamData in streams) { final data = streamData as Map; 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('live_channels', Channel.fromJson); if (cached.isNotEmpty) return cached; throw Exception('Failed to fetch live channels: $e'); } } void _saveToLocalCache(String key, Map 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> _loadFromLocalCache( String key, T Function(Map) 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; final result = >{}; data.forEach((cat, items) { if (items is List) { result[cat] = items.map((i) => fromJson(i as Map)).toList(); } }); return result; } catch (e) { debugPrint('Error loading from local cache ($key): $e'); return {}; } } /// Load VOD categories mapping Future> _getVodCategories() async { if (_currentPlaylist == null) return {}; try { final response = await _apiGet({'action': 'get_vod_categories'}); final List categories = response.data as List; final Map categoryMap = {}; for (final cat in categories) { final catData = cat as Map; 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>> 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; final response = results[1] as Response; final List vods = response.data as List; final Map> groupedVods = {}; for (final vodData in vods) { final data = vodData as Map; 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('vod_items', VodItem.fromJson); if (cached.isNotEmpty) return cached; throw Exception('Failed to fetch VOD items: $e'); } } /// Load Series categories mapping Future> _getSeriesCategories() async { if (_currentPlaylist == null) return {}; try { final response = await _apiGet({'action': 'get_series_categories'}); final List categories = response.data as List; final Map categoryMap = {}; for (final cat in categories) { final catData = cat as Map; 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>> 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; final response = results[1] as Response; final List seriesList = response.data as List; final Map> groupedSeries = {}; for (final seriesData in seriesList) { final data = seriesData as Map; 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_items', Series.fromJson); if (cached.isNotEmpty) return cached; throw Exception('Failed to fetch series: $e'); } } /// Get movies with pagination support Future> 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 allMovies = response.data as List; // 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; 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> 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 allMovies = response.data as List; 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; 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> 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 allSeries = response.data as List; // 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; 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> 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 allSeries = response.data as List; 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; 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 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); } 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 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; // Try to get duration from movie_properties or info final movieData = data['movie_data'] as Map?; final info = data['info'] as Map?; // 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> 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 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/`. /// /// 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>> _fetchEpgProgrammes( String streamId, ) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); if (isPlaybackLoading) { debugPrint('[XtreamService] Throttling EPG for $streamId'); await Future.delayed(_throttleDelay); } final cached = _epgCache[streamId]; if (cached != null && DateTime.now().isBefore(cached.expiresAt)) { return cached.programmes; } 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 = >[]; 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.from(item)); if (result.length >= 8) break; } _epgCache[streamId] = _EpgCacheEntry( programmes: result, expiresAt: DateTime.now().add(_epgCacheTtl), ); 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(); } } class _EpgCacheEntry { const _EpgCacheEntry({required this.programmes, required this.expiresAt}); final List> programmes; final DateTime expiresAt; }