import 'package:dio/dio.dart'; import 'package:dio_cache_interceptor/dio_cache_interceptor.dart'; import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart'; import '../../../core/models/playlist_config.dart'; import '../../../core/models/iptv_models.dart'; import '../models/xtream_models.dart' as xm; import 'dart:html' as html; /// Xtream Codes API Service /// /// Handles all communication with Xtream API servers class XtreamService { late final Dio _dio; late final CacheOptions _cacheOptions; PlaylistConfig? _currentPlaylist; XtreamService() { _dio = Dio(BaseOptions( connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 15), )); // Setup caching for API responses _cacheOptions = CacheOptions( store: HiveCacheStore('./cache'), policy: CachePolicy.forceCache, maxStale: const Duration(hours: 1), priority: CachePriority.high, ); _dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions)); } /// Check if we're running on HTTPS (production) bool get _isHttps { return html.window.location.protocol == 'https:'; } /// Wrap URL with proxy if needed (for HTTPS to HTTP bridge) String _wrapWithProxy(String url) { // If we're on HTTPS and the target URL is HTTP, use the proxy if (_isHttps && url.startsWith('http://')) { final baseUrl = html.window.location.origin; return '$baseUrl/api/xtream/$url'; } return url; } /// Initialize connection with a playlist void setPlaylist(PlaylistConfig playlist) { _currentPlaylist = playlist; } /// Generate stream URL for live TV String getLiveStreamUrl(String streamId) { if (_currentPlaylist == null) throw Exception('No playlist configured'); final url = '${_currentPlaylist!.dns}/live/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.m3u8'; return _wrapWithProxy(url); } /// Generate stream URL for VOD (movies) String getVodStreamUrl(String streamId, String containerExtension) { if (_currentPlaylist == null) throw Exception('No playlist configured'); final url = '${_currentPlaylist!.dns}/movie/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension'; return _wrapWithProxy(url); } /// Generate stream URL for series episodes String getSeriesStreamUrl(String streamId, String containerExtension) { if (_currentPlaylist == null) throw Exception('No playlist configured'); final url = '${_currentPlaylist!.dns}/series/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension'; return _wrapWithProxy(url); } /// Authenticate and get server info Future> authenticate() async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { final response = await _dio.get( _wrapWithProxy(_currentPlaylist!.apiBaseUrl), queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, }, options: Options(extra: _cacheOptions.toExtra()), ); return response.data as Map; } catch (e) { throw Exception('Authentication failed: $e'); } } /// Get all live TV channels grouped by category /// /// Returns a Map where key is category name and value is list of channels Future>> getLiveChannels() async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { final response = await _dio.get( _wrapWithProxy(_currentPlaylist!.apiBaseUrl), queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, 'action': 'get_live_streams', }, options: Options(extra: _cacheOptions.toExtra()), ); final List streams = response.data as List; final Map> groupedChannels = {}; for (final streamData in streams) { final channel = Channel.fromJson(streamData as Map); final categoryName = channel.categoryName.isEmpty ? 'Uncategorized' : channel.categoryName; if (!groupedChannels.containsKey(categoryName)) { groupedChannels[categoryName] = []; } groupedChannels[categoryName]!.add(channel); } return groupedChannels; } catch (e) { throw Exception('Failed to fetch live channels: $e'); } } /// Get all VOD items (movies) grouped by category Future>> getVodItems() async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { final response = await _dio.get( _wrapWithProxy(_currentPlaylist!.apiBaseUrl), queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, 'action': 'get_vod_streams', }, options: Options(extra: _cacheOptions.toExtra()), ); final List vods = response.data as List; final Map> groupedVods = {}; for (final vodData in vods) { final vod = VodItem.fromJson(vodData as Map); final categoryName = vod.categoryName.isEmpty ? 'Uncategorized' : vod.categoryName; if (!groupedVods.containsKey(categoryName)) { groupedVods[categoryName] = []; } groupedVods[categoryName]!.add(vod); } return groupedVods; } catch (e) { throw Exception('Failed to fetch VOD items: $e'); } } /// Get all series grouped by category Future>> getSeries() async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { final response = await _dio.get( _wrapWithProxy(_currentPlaylist!.apiBaseUrl), queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, 'action': 'get_series', }, options: Options(extra: _cacheOptions.toExtra()), ); final List seriesList = response.data as List; final Map> groupedSeries = {}; for (final seriesData in seriesList) { final series = Series.fromJson(seriesData as Map); final categoryName = series.categoryName.isEmpty ? 'Uncategorized' : series.categoryName; if (!groupedSeries.containsKey(categoryName)) { groupedSeries[categoryName] = []; } groupedSeries[categoryName]!.add(series); } return groupedSeries; } catch (e) { throw Exception('Failed to fetch series: $e'); } } /// Get movies with pagination support Future> getMovies({int offset = 0, int limit = 100}) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { final response = await _dio.get( _wrapWithProxy(_currentPlaylist!.apiBaseUrl), queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, 'action': 'get_vod_streams', }, options: Options(extra: _cacheOptions.toExtra()), ); 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) => xm.Movie.fromJson(movieData as Map)) .toList(); } catch (e) { throw Exception('Failed to fetch movies: $e'); } } /// 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 { final response = await _dio.get( _wrapWithProxy(_currentPlaylist!.apiBaseUrl), queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, 'action': 'get_series', }, options: Options(extra: _cacheOptions.toExtra()), ); 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) => xm.Series.fromJson(seriesData as Map)) .toList(); } catch (e) { throw Exception('Failed to fetch series: $e'); } } /// Get short EPG for a specific stream /// /// Returns "Now" and "Next" program info Future> getShortEpg(String streamId) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { final response = await _dio.get( _wrapWithProxy(_currentPlaylist!.apiBaseUrl), queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, 'action': 'get_short_epg', 'stream_id': streamId, }, options: Options( extra: CacheOptions( store: _cacheOptions.store, policy: CachePolicy.request, maxStale: const Duration(minutes: 5), // EPG changes frequently ).toExtra(), ), ); if (response.data == null || response.data['epg_listings'] == null) { return []; } final List epgData = response.data['epg_listings'] as List; return epgData .map((entry) => EpgEntry.fromJson(entry as Map)) .toList(); } catch (e) { // EPG is optional, don't throw on failure return []; } } /// Dispose resources void dispose() { _dio.close(); } }