import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import '../models/xtream_models.dart'; /// Represents a channel parsed from M3U playlist with all metadata class M3UChannel { final String name; final String url; final String? groupTitle; final String? tvgLogo; final String? tvgId; final String? tvgName; final Map metadata; M3UChannel({ required this.name, required this.url, this.groupTitle, this.tvgLogo, this.tvgId, this.tvgName, this.metadata = const {}, }); Map toJson() { return { 'name': name, 'url': url, 'group-title': groupTitle, 'tvg-logo': tvgLogo, 'tvg-id': tvgId, 'tvg-name': tvgName, 'metadata': metadata, }; } } /// Result of downloading and parsing M3U playlist class M3UDownloadResult { final List channels; final String sourceUrl; final DateTime downloadTime; final int totalChannels; final Map groupsCount; M3UDownloadResult({ required this.channels, required this.sourceUrl, required this.downloadTime, required this.totalChannels, required this.groupsCount, }); Map toJson() { return { 'download_info': { 'source_url': sourceUrl, 'download_time': downloadTime.toIso8601String(), 'total_channels': totalChannels, 'groups_count': groupsCount, }, 'channels': channels.map((c) => c.toJson()).toList(), }; } } class XtreamApiService { XtreamApiService({http.Client? httpClient}) : _httpClient = httpClient ?? http.Client(); final http.Client _httpClient; String? _server; String? _username; String? _password; String? _baseUrl; final Map _countryExtractionCache = {}; static final RegExp _leadingCodeRegex = RegExp(r'^([a-z]{2,3})\s*[-:=]\s*'); static final RegExp _bracketCodeRegex = RegExp(r'[\[\(]([a-z]{2,3})[\]\)]'); // Country normalization mapping: code/variation -> standard name static const Map _countryMapping = { // AMÉRICA - Prioridad alta 'arg': 'Argentina', 'argentina': 'Argentina', 'pe': 'Perú', 'per': 'Perú', 'peru': 'Perú', 'perú': 'Perú', 'bo': 'Bolivia', 'bol': 'Bolivia', 'bolivia': 'Bolivia', 'br': 'Brasil', 'bra': 'Brasil', 'brazil': 'Brasil', 'cl': 'Chile', 'chl': 'Chile', 'chile': 'Chile', 'co': 'Colombia', 'col': 'Colombia', 'colombia': 'Colombia', 'ec': 'Ecuador', 'ecu': 'Ecuador', 'ecuador': 'Ecuador', 'py': 'Paraguay', 'par': 'Paraguay', 'paraguay': 'Paraguay', 'uy': 'Uruguay', 'uru': 'Uruguay', 'uruguay': 'Uruguay', 've': 'Venezuela', 'ven': 'Venezuela', 'venezuela': 'Venezuela', // Centroamérica y Caribe 'cr': 'Costa Rica', 'cri': 'Costa Rica', 'costarica': 'Costa Rica', 'sv': 'El Salvador', 'sal': 'El Salvador', 'elsalvador': 'El Salvador', 'gt': 'Guatemala', 'guatemala': 'Guatemala', 'hn': 'Honduras', 'hon': 'Honduras', 'honduras': 'Honduras', 'ni': 'Nicaragua', 'nic': 'Nicaragua', 'nicaragua': 'Nicaragua', 'pa': 'Panamá', 'pan': 'Panamá', 'panama': 'Panamá', 'panamá': 'Panamá', 'do': 'República Dominicana', 'dom': 'República Dominicana', 'republicadominicana': 'República Dominicana', 'rd': 'República Dominicana', 'pr': 'Puerto Rico', 'pri': 'Puerto Rico', 'puertorico': 'Puerto Rico', 'cu': 'Cuba', 'cuba': 'Cuba', 'ht': 'Haití', 'haiti': 'Haití', 'haití': 'Haití', 'jm': 'Jamaica', 'jamaica': 'Jamaica', // Norteamérica 'us': 'Estados Unidos', 'usa': 'Estados Unidos', 'unitedstates': 'Estados Unidos', 'ca': 'Canadá', 'can': 'Canadá', 'canada': 'Canadá', 'canadá': 'Canadá', 'mx': 'México', 'mex': 'México', 'mexico': 'México', 'méxico': 'México', // EUROPA 'es': 'España', 'españa': 'España', 'spain': 'España', 'uk': 'Reino Unido', 'gb': 'Reino Unido', 'unitedkingdom': 'Reino Unido', 'fr': 'Francia', 'fra': 'Francia', 'france': 'Francia', 'de': 'Alemania', 'ger': 'Alemania', 'germany': 'Alemania', 'alemania': 'Alemania', 'it': 'Italia', 'ita': 'Italia', 'italy': 'Italia', 'italia': 'Italia', 'pt': 'Portugal', 'prt': 'Portugal', 'portugal': 'Portugal', // Europa del Norte 'se': 'Suecia', 'sw': 'Suecia', 'swe': 'Suecia', 'sweden': 'Suecia', 'suecia': 'Suecia', 'no': 'Noruega', 'nor': 'Noruega', 'norway': 'Noruega', 'noruega': 'Noruega', 'dk': 'Dinamarca', 'din': 'Dinamarca', 'denmark': 'Dinamarca', 'dinamarca': 'Dinamarca', 'fi': 'Finlandia', 'fin': 'Finlandia', 'finland': 'Finlandia', 'finlandia': 'Finlandia', // Europa del Este 'ru': 'Rusia', 'rus': 'Rusia', 'russia': 'Rusia', 'pl': 'Polonia', 'pol': 'Polonia', 'poland': 'Polonia', 'polonia': 'Polonia', 'ua': 'Ucrania', 'ukr': 'Ucrania', 'ukraine': 'Ucrania', 'ucrania': 'Ucrania', 'cz': 'República Checa', 'cze': 'República Checa', 'czechrepublic': 'República Checa', 'sk': 'Eslovaquia', 'svk': 'Eslovaquia', 'slovakia': 'Eslovaquia', 'hu': 'Hungría', 'hun': 'Hungría', 'hungary': 'Hungría', 'hungria': 'Hungría', 'ro': 'Rumania', 'rou': 'Rumania', 'romania': 'Rumania', 'bg': 'Bulgaria', 'bgr': 'Bulgaria', 'bulgaria': 'Bulgaria', 'al': 'Albania', 'alb': 'Albania', 'albania': 'Albania', 'hr': 'Croacia', 'hrv': 'Croacia', 'croatia': 'Croacia', 'croacia': 'Croacia', 'rs': 'Serbia', 'srb': 'Serbia', 'serbia': 'Serbia', 'ba': 'Bosnia y Herzegovina', 'bih': 'Bosnia y Herzegovina', 'bosnia': 'Bosnia y Herzegovina', 'mk': 'Macedonia del Norte', 'mkd': 'Macedonia del Norte', 'macedonia': 'Macedonia del Norte', 'si': 'Eslovenia', 'svn': 'Eslovenia', 'slovenia': 'Eslovenia', 'md': 'Moldavia', 'mda': 'Moldavia', 'moldova': 'Moldavia', 'lt': 'Lituania', 'ltu': 'Lituania', 'lithuania': 'Lituania', 'lituania': 'Lituania', 'lv': 'Letonia', 'lva': 'Letonia', 'latvia': 'Letonia', 'letonia': 'Letonia', 'ee': 'Estonia', 'est': 'Estonia', 'estonia': 'Estonia', 'by': 'Bielorrusia', 'blr': 'Bielorrusia', 'belarus': 'Bielorrusia', // Europa Occidental 'nl': 'Países Bajos', 'nld': 'Países Bajos', 'netherlands': 'Países Bajos', 'paisesbajos': 'Países Bajos', 'holanda': 'Países Bajos', 'be': 'Bélgica', 'bel': 'Bélgica', 'belgium': 'Bélgica', 'belgica': 'Bélgica', 'at': 'Austria', 'aut': 'Austria', 'austria': 'Austria', 'ch': 'Suiza', 'che': 'Suiza', 'switzerland': 'Suiza', 'suiza': 'Suiza', 'ie': 'Irlanda', 'irl': 'Irlanda', 'ireland': 'Irlanda', 'irlanda': 'Irlanda', 'gr': 'Grecia', 'grc': 'Grecia', 'greece': 'Grecia', 'grecia': 'Grecia', // ASIA 'in': 'India', 'ind': 'India', 'india': 'India', 'cn': 'China', 'chn': 'China', 'china': 'China', 'jp': 'Japón', 'jpn': 'Japón', 'japan': 'Japón', 'japon': 'Japón', 'kr': 'Corea del Sur', 'kor': 'Corea del Sur', 'southkorea': 'Corea del Sur', 'kors': 'Corea del Sur', 'kp': 'Corea del Norte', 'prk': 'Corea del Norte', 'northkorea': 'Corea del Norte', 'korn': 'Corea del Norte', 'th': 'Tailandia', 'tha': 'Tailandia', 'thailand': 'Tailandia', 'tailandia': 'Tailandia', 'vn': 'Vietnam', 'vietnam': 'Vietnam', 'my': 'Malasia', 'mys': 'Malasia', 'malaysia': 'Malasia', 'malasia': 'Malasia', 'id': 'Indonesia', 'idn': 'Indonesia', 'indonesia': 'Indonesia', 'ph': 'Filipinas', 'philippines': 'Filipinas', 'filipinas': 'Filipinas', 'sg': 'Singapur', 'sgp': 'Singapur', 'singapore': 'Singapur', 'singapur': 'Singapur', // Medio Oriente 'tr': 'Turquía', 'tur': 'Turquía', 'turkey': 'Turquía', 'turquia': 'Turquía', 'ir': 'Irán', 'irn': 'Irán', 'iran': 'Irán', 'iq': 'Irak', 'irq': 'Irak', 'irak': 'Irak', 'iraq': 'Irak', 'sa': 'Arabia Saudita', 'ksa': 'Arabia Saudita', 'saudiarabia': 'Arabia Saudita', 'arabiasaudita': 'Arabia Saudita', 'ae': 'Emiratos Árabes Unidos', 'uae': 'Emiratos Árabes Unidos', 'unitedarabemirates': 'Emiratos Árabes Unidos', 'emiratos': 'Emiratos Árabes Unidos', 'eg': 'Egipto', 'egy': 'Egipto', 'egypt': 'Egipto', 'egipto': 'Egipto', 'jo': 'Jordania', 'jor': 'Jordania', 'jordan': 'Jordania', 'jordania': 'Jordania', 'sy': 'Siria', 'syr': 'Siria', 'syria': 'Siria', 'lb': 'Líbano', 'lbn': 'Líbano', 'lebanon': 'Líbano', 'libano': 'Líbano', 'il': 'Israel', 'isr': 'Israel', 'israel': 'Israel', 'ps': 'Palestina', 'pse': 'Palestina', 'palestine': 'Palestina', 'palestina': 'Palestina', 'qa': 'Qatar', 'qat': 'Qatar', 'qatar': 'Qatar', 'kw': 'Kuwait', 'kwt': 'Kuwait', 'kuwait': 'Kuwait', 'ku': 'Kuwait', 'bh': 'Baréin', 'bhr': 'Baréin', 'bahrain': 'Baréin', 'barein': 'Baréin', 'om': 'Omán', 'omn': 'Omán', 'oman': 'Omán', 'dz': 'Argelia', 'dza': 'Argelia', 'algeria': 'Argelia', 'argelia': 'Argelia', 'ma': 'Marruecos', 'mar': 'Marruecos', 'morocco': 'Marruecos', 'marruecos': 'Marruecos', 'tn': 'Túnez', 'tun': 'Túnez', 'tunisia': 'Túnez', 'tunez': 'Túnez', 'ly': 'Libia', 'lby': 'Libia', 'libya': 'Libia', 'libia': 'Libia', // ÁFRICA 'za': 'Sudáfrica', 'zaf': 'Sudáfrica', 'southafrica': 'Sudáfrica', 'sudafrica': 'Sudáfrica', 'ng': 'Nigeria', 'nga': 'Nigeria', 'nigeria': 'Nigeria', 'et': 'Etiopía', 'eth': 'Etiopía', 'ethiopia': 'Etiopía', 'etiopia': 'Etiopía', 'ke': 'Kenia', 'ken': 'Kenia', 'kenya': 'Kenia', 'tz': 'Tanzania', 'tza': 'Tanzania', 'tanzania': 'Tanzania', 'ug': 'Uganda', 'uga': 'Uganda', 'uganda': 'Uganda', 'gh': 'Ghana', 'gha': 'Ghana', 'ghana': 'Ghana', 'sd': 'Sudán', 'sdn': 'Sudán', 'sudan': 'Sudán', 'sn': 'Senegal', 'sen': 'Senegal', 'senegal': 'Senegal', 'ml': 'Mali', 'mali': 'Mali', 'bf': 'Burkina Faso', 'bfa': 'Burkina Faso', 'burkinafaso': 'Burkina Faso', 'ci': 'Costa de Marfil', 'civ': 'Costa de Marfil', 'cotedivoire': 'Costa de Marfil', 'ne': 'Níger', 'ner': 'Níger', 'niger': 'Níger', 'td': 'Chad', 'tcd': 'Chad', 'chad': 'Chad', 'cm': 'Camerún', 'cmr': 'Camerún', 'cameroon': 'Camerún', 'camerun': 'Camerún', 'cf': 'República Centroafricana', 'caf': 'República Centroafricana', 'gab': 'Gabón', 'gabon': 'Gabón', 'cg': 'Congo', 'cog': 'Congo', 'cd': 'República Democrática del Congo', 'cod': 'República Democrática del Congo', 'ao': 'Angola', 'ago': 'Angola', 'angola': 'Angola', 'zm': 'Zambia', 'zmb': 'Zambia', 'zambia': 'Zambia', 'zw': 'Zimbabue', 'zwe': 'Zimbabue', 'zimbabwe': 'Zimbabue', 'mz': 'Mozambique', 'moz': 'Mozambique', 'mozambique': 'Mozambique', 'bw': 'Botsuana', 'bwa': 'Botsuana', 'botswana': 'Botsuana', 'na': 'Namibia', 'nam': 'Namibia', 'namibia': 'Namibia', 'mw': 'Malaui', 'mwi': 'Malaui', 'malawi': 'Malaui', 'mg': 'Madagascar', 'mdg': 'Madagascar', 'madagascar': 'Madagascar', 'mu': 'Mauricio', 'mus': 'Mauricio', 'mauritius': 'Mauricio', 'sc': 'Seychelles', 'syc': 'Seychelles', 'seychelles': 'Seychelles', 'km': 'Comoras', 'com': 'Comoras', 'comoros': 'Comoras', 'cv': 'Cabo Verde', 'cpv': 'Cabo Verde', 'capeverde': 'Cabo Verde', 'gw': 'Guinea-Bisáu', 'gnb': 'Guinea-Bisáu', 'guineabissau': 'Guinea-Bisáu', 'gm': 'Gambia', 'gmb': 'Gambia', 'gambia': 'Gambia', 'sl': 'Sierra Leona', 'sle': 'Sierra Leona', 'sierraleone': 'Sierra Leona', 'lr': 'Liberia', 'lbr': 'Liberia', 'liberia': 'Liberia', 'gn': 'Guinea', 'gin': 'Guinea', 'guinea': 'Guinea', 'gq': 'Guinea Ecuatorial', 'gnq': 'Guinea Ecuatorial', 'equatorialguinea': 'Guinea Ecuatorial', 'st': 'Santo Tomé y Príncipe', 'stp': 'Santo Tomé y Príncipe', 'saotomeandprincipe': 'Santo Tomé y Príncipe', 'bj': 'Benín', 'ben': 'Benín', 'benin': 'Benín', 'tg': 'Togo', 'tgo': 'Togo', 'togo': 'Togo', 'rw': 'Ruanda', 'rwa': 'Ruanda', 'rwanda': 'Ruanda', 'bi': 'Burundi', 'bdi': 'Burundi', 'burundi': 'Burundi', 'dj': 'Yibuti', 'dji': 'Yibuti', 'djibouti': 'Yibuti', 'er': 'Eritrea', 'eri': 'Eritrea', 'eritrea': 'Eritrea', 'so': 'Somalia', 'som': 'Somalia', 'somalia': 'Somalia', // Oceanía 'au': 'Australia', 'aus': 'Australia', 'australia': 'Australia', 'nz': 'Nueva Zelanda', 'nzl': 'Nueva Zelanda', 'newzealand': 'Nueva Zelanda', 'nuevazelanda': 'Nueva Zelanda', 'pg': 'Papúa Nueva Guinea', 'png': 'Papúa Nueva Guinea', 'papuanewguinea': 'Papúa Nueva Guinea', 'fj': 'Fiyi', 'fji': 'Fiyi', 'fiji': 'Fiyi', 'sb': 'Islas Salomón', 'slb': 'Islas Salomón', 'solomonislands': 'Islas Salomón', 'vu': 'Vanuatu', 'vut': 'Vanuatu', 'vanuatu': 'Vanuatu', 'nc': 'Nueva Caledonia', 'ncl': 'Nueva Caledonia', 'newcaledonia': 'Nueva Caledonia', 'pf': 'Polinesia Francesa', 'pyf': 'Polinesia Francesa', 'frenchpolynesia': 'Polinesia Francesa', 'wf': 'Wallis y Futuna', 'wlf': 'Wallis y Futuna', 'wallisandfutuna': 'Wallis y Futuna', // GRUPOS ESPECIALES - Se mostrarán como están '24/7': '24/7', '24/7 ar': '24/7 AR', '24/7-ar': '24/7 AR', '24/7-es': '24/7 ES', '24/7-de': '24/7 DE', '24/7-tr': '24/7 TR', '24/7-ro': '24/7 RO', '24/7-gr': '24/7 GR', '24/7-my': '24/7 MY', '24/7-pt': '24/7 PT', '24/7-in': '24/7 IN', 'ar-kids': 'AR Kids', 'ar-sp': 'AR SP', 'ar_ns': 'AR NS', // Idiomas / Languages 'ar': 'Árabe', 'vip': 'VIP', 'vip - pk': 'VIP PK', 'ppv': 'PPV', 'exyu': 'EX-YU', 'dstv': 'DSTV', 'car': 'CAR', 'bein': 'BeIN', 'mbc': 'MBC', 'osn': 'OSN', 'myhd': 'MyHD', 'art': 'ART', 'tod': 'TOD', 'islam': 'Islam', 'latino': 'Latino', 'general': 'General', 'music': 'Music', 'movies': 'Movies', 'cine': 'Cine', 'cine sd': 'Cine SD', 'cine y serie': 'Cine y Serie', 'xmas': 'Xmas', 'sin': 'Sin categoría', 'sin país': 'Sin categoría', 'ezd': 'EZD', 'rot': 'ROT', 'ic': 'IC', 'sh': 'SH', 'bab': 'BAB', 'as': 'AS', 'ei': 'EI', 'su': 'SU', // Otros códigos especiales 'af': 'Afganistán', 'afg': 'Afganistán', 'afghanistan': 'Afganistán', 'arm': 'Armenia', 'armenia': 'Armenia', 'aze': 'Azerbaiyán', 'azerbaijan': 'Azerbaiyán', 'ge': 'Georgia', 'geo': 'Georgia', 'georgia': 'Georgia', 'kz': 'Kazajistán', 'kaz': 'Kazajistán', 'kazakhstan': 'Kazajistán', 'kg': 'Kirguistán', 'kgz': 'Kirguistán', 'kyrgyzstan': 'Kirguistán', 'tj': 'Tayikistán', 'tjk': 'Tayikistán', 'tajikistan': 'Tayikistán', 'tm': 'Turkmenistán', 'tkm': 'Turkmenistán', 'turkmenistan': 'Turkmenistán', 'uz': 'Uzbekistán', 'uzb': 'Uzbekistán', 'uzbekistan': 'Uzbekistán', 'bd': 'Bangladesh', 'bgd': 'Bangladesh', 'bangladesh': 'Bangladesh', 'lk': 'Sri Lanka', 'lka': 'Sri Lanka', 'srilanka': 'Sri Lanka', 'np': 'Nepal', 'npl': 'Nepal', 'nepal': 'Nepal', 'bt': 'Bután', 'btn': 'Bután', 'bhutan': 'Bután', 'mv': 'Maldivas', 'mdv': 'Maldivas', 'maldives': 'Maldivas', 'pk': 'Pakistán', 'pak': 'Pakistán', 'pakistan': 'Pakistán', }; /// Normalize a country string to a standard full name /// /// This function relies on the smart extraction in extractCountryFromChannelName() /// which already handles context-aware disambiguation of codes like "AR" String normalizeCountry(String rawCountry) { final normalized = rawCountry.toLowerCase().trim(); // Direct lookup in the mapping return _countryMapping[normalized] ?? rawCountry; } void setCredentials(String server, String username, String password) { _server = server; _username = username; _password = password; _baseUrl = server.startsWith('http') ? server : 'http://$server'; _countryExtractionCache.clear(); } String? get server => _server; String? get username => _username; Future> authenticate() async { final url = '$_baseUrl/player_api.php'; final response = await _httpClient.get( Uri.parse('$url?username=$_username&password=$_password'), ); if (response.statusCode == 200) { return json.decode(response.body); } throw Exception('Authentication failed: ${response.statusCode}'); } Future getUserInfo() async { final data = await authenticate(); return XtreamUserInfo.fromJson(data); } Future> getLiveCategories() async { final url = '$_baseUrl/player_api.php'; final response = await _httpClient.get( Uri.parse( '$url?username=$_username&password=$_password&action=get_live_categories', ), ); if (response.statusCode == 200) { final List data = json.decode(response.body); return data.map((e) => XtreamCategory.fromJson(e)).toList(); } throw Exception('Failed to load categories'); } Future> getVodCategories() async { final url = '$_baseUrl/player_api.php'; final response = await _httpClient.get( Uri.parse( '$url?username=$_username&password=$_password&action=get_vod_categories', ), ); if (response.statusCode == 200) { final List data = json.decode(response.body); return data.map((e) => XtreamCategory.fromJson(e)).toList(); } throw Exception('Failed to load VOD categories'); } Future> getSeriesCategories() async { final url = '$_baseUrl/player_api.php'; final response = await _httpClient.get( Uri.parse( '$url?username=$_username&password=$_password&action=get_series_categories', ), ); if (response.statusCode == 200) { final List data = json.decode(response.body); return data.map((e) => XtreamCategory.fromJson(e)).toList(); } throw Exception('Failed to load series categories'); } Future> getLiveStreams(String categoryId) async { final url = '$_baseUrl/player_api.php'; String apiUrl = '$url?username=$_username&password=$_password&action=get_live_streams'; if (categoryId.isNotEmpty) { apiUrl += '&category_id=$categoryId'; } final response = await _httpClient.get(Uri.parse(apiUrl)); if (response.statusCode == 200) { final List data = json.decode(response.body); return data.map((e) { final stream = XtreamStream.fromJson(e); stream.url = '$_baseUrl/live/$_username/$_password/${stream.streamId}.ts'; return stream; }).toList(); } throw Exception('Failed to load live streams'); } Future> getVodStreams(String categoryId) async { final url = '$_baseUrl/player_api.php'; String apiUrl = '$url?username=$_username&password=$_password&action=get_vod_streams'; if (categoryId.isNotEmpty) { apiUrl += '&category_id=$categoryId'; } final response = await _httpClient.get(Uri.parse(apiUrl)); if (response.statusCode == 200) { final List data = json.decode(response.body); return data.map((e) { final stream = XtreamStream.fromJson(e); final ext = stream.containerExtension ?? 'm3u8'; stream.url = '$_baseUrl/vod/$_username/$_password/${stream.streamId}.$ext'; return stream; }).toList(); } throw Exception('Failed to load VOD streams'); } Future> getSeries() async { final url = '$_baseUrl/player_api.php'; final response = await _httpClient.get( Uri.parse( '$url?username=$_username&password=$_password&action=get_series', ), ); if (response.statusCode == 200) { final List data = json.decode(response.body); return data.map((e) => XtreamSeries.fromJson(e)).toList(); } throw Exception('Failed to load series'); } Future> getSeriesEpisodes(int seriesId) async { final url = '$_baseUrl/player_api.php'; final response = await _httpClient.get( Uri.parse( '$url?username=$_username&password=$_password&action=get_series_info&series_id=$seriesId', ), ); if (response.statusCode == 200) { final data = json.decode(response.body); final List episodesData = data['episodes'] ?? []; final List allEpisodes = []; for (final seasonData in episodesData) { final List episodes = seasonData['episodes'] ?? []; for (final ep in episodes) { final episode = XtreamEpisode.fromJson(ep); final ext = episode.containerExtension ?? 'm3u8'; episode.url = '$_baseUrl/series/$_username/$_password/${episode.episodeId}.$ext'; allEpisodes.add(episode); } } return allEpisodes; } throw Exception('Failed to load series episodes'); } String getStreamUrl(int streamId, {String type = 'live'}) { final ext = type == 'live' ? 'ts' : 'm3u8'; return '$_baseUrl/$type/$_username/$_password/$streamId.$ext'; } Future> getM3UStreams({ void Function(int loaded, int total)? onProgress, }) async { // Try multiple M3U endpoints final endpoints = [ '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u_plus&output=ts', '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u_plus', '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u', '$_baseUrl/playlist?username=$_username&password=$_password', ]; Exception? lastError; for (final url in endpoints) { try { final response = await _httpClient .get(Uri.parse(url)) .timeout( const Duration( seconds: 60, ), // Increased timeout for large playlists (26k+ channels) ); if (response.statusCode == 200) { // Check if it's valid M3U content (may have BOM or whitespace before #EXTM3U) final bodyTrimmed = response.body.trim(); if (bodyTrimmed.startsWith('#EXTM3U')) { final streams = _parseM3U(response.body, onProgress: onProgress); if (streams.isEmpty) { lastError = Exception('M3U loaded but no streams parsed'); continue; } return streams; } else { lastError = Exception('Invalid M3U format'); } } else { lastError = Exception('HTTP ${response.statusCode}'); } } on TimeoutException { lastError = Exception('Timeout loading M3U'); continue; } catch (e) { lastError = Exception('Error: $e'); continue; // Try next endpoint } } throw lastError ?? Exception('No se pudo cargar la lista M3U desde ningún endpoint'); } /// Downloads M3U playlist and parses it into structured JSON format Future downloadM3UAsJson() async { // Try multiple M3U endpoints final endpoints = [ '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u_plus&output=ts', '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u_plus', '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u', '$_baseUrl/playlist?username=$_username&password=$_password', ]; String? successfulUrl; String? m3uContent; for (final url in endpoints) { try { final response = await _httpClient .get(Uri.parse(url)) .timeout(const Duration(seconds: 30)); if (response.statusCode == 200 && response.body.startsWith('#EXTM3U')) { successfulUrl = url; m3uContent = response.body; break; } } catch (e) { continue; } } if (m3uContent == null || successfulUrl == null) { throw Exception('No se pudo descargar la lista M3U de ningún endpoint'); } // Parse M3U content final channels = _parseM3UToChannels(m3uContent); // Count groups final groupsCount = {}; for (final channel in channels) { final group = channel.groupTitle ?? 'Sin categoría'; groupsCount[group] = (groupsCount[group] ?? 0) + 1; } return M3UDownloadResult( channels: channels, sourceUrl: successfulUrl, downloadTime: DateTime.now(), totalChannels: channels.length, groupsCount: groupsCount, ); } /// Parses M3U content into a list of M3UChannel objects with full metadata List _parseM3UToChannels(String m3uContent) { final List channels = []; final lines = m3uContent.split('\n'); final infoRegex = RegExp(r'#EXTINF:([^,]*),(.*)$'); final attrRegex = RegExp(r'(\w+[-\w]*)="([^"]*)"'); M3UChannel? currentChannel; Map currentMetadata = {}; for (final line in lines) { final trimmed = line.trim(); if (trimmed.startsWith('#EXTINF:')) { // Parse EXTINF line with all attributes currentMetadata = {}; // Extract duration and attributes final infoMatch = infoRegex.firstMatch(trimmed); if (infoMatch != null) { final attrsPart = infoMatch.group(1) ?? ''; final name = infoMatch.group(2)?.trim() ?? ''; // Parse all attributes (tvg-*, group-title, etc.) final matches = attrRegex.allMatches(attrsPart); String? tvgLogo; String? groupTitle; String? tvgId; String? tvgName; for (final match in matches) { final key = match.group(1); final value = match.group(2); if (key != null && value != null) { currentMetadata[key] = value; // Map common attributes switch (key.toLowerCase()) { case 'tvg-logo': tvgLogo = value; break; case 'group-title': groupTitle = value; break; case 'tvg-id': tvgId = value; break; case 'tvg-name': tvgName = value; break; } } } currentChannel = M3UChannel( name: name, url: '', // Will be set on next line groupTitle: groupTitle, tvgLogo: tvgLogo, tvgId: tvgId, tvgName: tvgName, metadata: Map.from(currentMetadata), ); } } else if (trimmed.isNotEmpty && !trimmed.startsWith('#')) { // This is the stream URL if (currentChannel != null) { channels.add( M3UChannel( name: currentChannel.name, url: trimmed, groupTitle: currentChannel.groupTitle, tvgLogo: currentChannel.tvgLogo, tvgId: currentChannel.tvgId, tvgName: currentChannel.tvgName, metadata: currentChannel.metadata, ), ); currentChannel = null; currentMetadata = {}; } } } return channels; } /// Saves M3U data as JSON file to device storage Future saveM3UAsJson( M3UDownloadResult result, { String? customFileName, }) async { try { // Request storage permission var status = await Permission.storage.request(); if (!status.isGranted) { // Try manage external storage for Android 11+ status = await Permission.manageExternalStorage.request(); if (!status.isGranted) { throw Exception('Permiso de almacenamiento denegado'); } } // Get appropriate directory Directory? directory; // Try Downloads folder first (Android) if (Platform.isAndroid) { directory = Directory('/storage/emulated/0/Download'); if (!await directory.exists()) { directory = null; } } // Fallback to app documents directory directory ??= await getApplicationDocumentsDirectory(); // Generate filename final timestamp = DateTime.now() .toIso8601String() .replaceAll(':', '-') .split('.') .first; final fileName = customFileName ?? 'xstream_playlist_$timestamp.json'; final filePath = '${directory.path}/$fileName'; // Create JSON content final jsonContent = const JsonEncoder.withIndent( ' ', ).convert(result.toJson()); // Write file final file = File(filePath); await file.writeAsString(jsonContent); return filePath; } catch (e) { throw Exception('Error al guardar archivo JSON: $e'); } } /// Optimized version of getCountries - only processes a sample of channels for speed /// This dramatically improves load time from 3+ minutes to under 10 seconds List getCountriesOptimized( List streams, { int maxChannelsToProcess = 2000, }) { final countries = {}; if (streams.isEmpty) { return []; } // Sample channels from beginning, middle, and end for better country coverage final sampleSize = streams.length > maxChannelsToProcess ? maxChannelsToProcess : streams.length; final step = streams.length > sampleSize ? streams.length ~/ sampleSize : 1; int processed = 0; for (int i = 0; i < streams.length && processed < sampleSize; i += step) { final stream = streams[i]; String? country; // Extract country from stream name final rawCountryFromName = extractCountryFromChannelName( stream.name, groupTitle: stream.plot, ); if (rawCountryFromName.isNotEmpty) { country = normalizeCountry(rawCountryFromName); } if (country != null && country.isNotEmpty) { countries.add(country); } processed++; } // Sort countries with custom priority order final sortedCountries = _sortCountriesByPriority(countries.toList()); return sortedCountries; } List getCountries( List streams, { Map? categoryToCountryMap, }) { final countries = {}; if (streams.isEmpty) { return []; } for (int i = 0; i < streams.length; i++) { final stream = streams[i]; String? country; // First, try to extract country from stream name (M3U format: "Country|XX - Channel Name") // Pass group title for context to help with ambiguous codes like "AR" final rawCountryFromName = extractCountryFromChannelName( stream.name, groupTitle: stream.plot, ); if (rawCountryFromName.isNotEmpty) { country = normalizeCountry(rawCountryFromName); } // If no country in name and we have category mapping, use category if (country == null && categoryToCountryMap != null && stream.categoryId != null) { final categoryCountry = categoryToCountryMap[stream.categoryId]; if (categoryCountry != null && categoryCountry.isNotEmpty) { country = normalizeCountry(categoryCountry); } } if (country != null && country.isNotEmpty) { countries.add(country); } } // Sort countries with custom priority order final sortedCountries = _sortCountriesByPriority(countries.toList()); return sortedCountries; } /// Sort countries with custom priority: Argentina first, then Peru, then South America, /// then Europe, then Arabs at the end List _sortCountriesByPriority(List countries) { // Define priority order final priorityOrder = { // 1. Argentina (TOP priority) 'Argentina': 1, // 2. Peru (second priority) 'Peru': 2, 'Perú': 2, // 3. Other South American countries 'Bolivia': 3, 'Brasil': 3, 'Brazil': 3, 'Chile': 3, 'Colombia': 3, 'Ecuador': 3, 'Paraguay': 3, 'Uruguay': 3, 'Venezuela': 3, // 4. Central America 'Costa Rica': 4, 'El Salvador': 4, 'Guatemala': 4, 'Honduras': 4, 'Nicaragua': 4, 'Panamá': 4, 'Panama': 4, 'Puerto Rico': 4, 'República Dominicana': 4, // 5. North America 'Canadá': 5, 'Canada': 5, 'Estados Unidos': 5, 'United States': 5, 'USA': 5, 'México': 5, 'Mexico': 5, // 6. Europe 'Alemania': 6, 'Germany': 6, 'AT': 6, 'Austria': 6, 'BE': 6, 'Belgium': 6, 'BG': 6, 'Bulgaria': 6, 'CZ': 6, 'Czech Republic': 6, 'DK': 6, 'Denmark': 6, 'EE': 6, 'Estonia': 6, 'España': 6, 'Spain': 6, 'FI': 6, 'Finland': 6, 'FR': 6, 'France': 6, 'GR': 6, 'Greece': 6, 'HR': 6, 'Croatia': 6, 'HU': 6, 'Hungary': 6, 'IE': 6, 'Ireland': 6, 'Italia': 6, 'Italy': 6, 'LT': 6, 'Lithuania': 6, 'LV': 6, 'Latvia': 6, 'NL': 6, 'Netherlands': 6, 'NO': 6, 'Norway': 6, 'PL': 6, 'Poland': 6, 'Portugal': 6, 'RO': 6, 'Romania': 6, 'RU': 6, 'Russia': 6, 'SK': 6, 'Slovakia': 6, 'SW': 6, 'Sweden': 6, 'UK': 6, 'United Kingdom': 6, 'Reino Unido': 6, 'UKR': 6, 'Ukraine': 6, // 7. Asia (pure Asian countries - no Arab/Middle East) 'BD': 7, 'Bangladesh': 7, 'CN': 7, 'China': 7, 'ID': 7, 'Indonesia': 7, 'IN': 7, 'India': 7, 'JP': 7, 'Japan': 7, 'KH': 7, 'Cambodia': 7, 'KR': 7, 'Korea': 7, 'KZ': 7, 'Kazakhstan': 7, 'MY': 7, 'Malaysia': 7, 'PH': 7, 'Philippines': 7, 'PK': 7, 'Pakistan': 7, 'SG': 7, 'Singapore': 7, 'TH': 7, 'Thailand': 7, 'VN': 7, 'Vietnam': 7, // 8. Africa 'ANGOLA': 8, 'BENIN': 8, 'BURKINAFASO': 8, 'CAMEROON': 8, 'CAPEVERDE': 8, 'CONGO': 8, 'COTEDIVOIRE': 8, 'DJIBOUTI': 8, 'DZ': 8, 'Algeria': 8, 'EGY': 8, 'Egypt': 8, 'ERITREA': 8, 'ETHIOPIA': 8, 'GABON': 8, 'GAMBIA': 8, 'GHANA': 8, 'GUINEE': 8, 'KENYA': 8, 'LBY': 8, 'Libya': 8, 'MA': 8, 'Morocco': 8, 'MALAWI': 8, 'MALI': 8, 'MOZAMBIQUE': 8, 'NIGERIA': 8, 'ROT': 8, 'ROWANDA': 8, 'SENEGAL': 8, 'SOMAL': 8, 'SUDAN': 8, 'TN': 8, 'Tunisia': 8, 'TANZANIA': 8, 'TCHAD': 8, 'TOGO': 8, 'UGANDA': 8, 'ZA': 8, 'South Africa': 8, 'ZAMBIA': 8, // 9. Árabe / Arabic / Middle East (AFTER Africa) 'Árabe': 9, 'AF': 9, 'AFG': 9, 'AL': 9, 'Albania': 9, 'AR-KIDS': 9, 'AR-SP': 9, 'ARM': 9, 'Armenia': 9, 'AZE': 9, 'Azerbaijan': 9, 'BAB': 9, 'BAHR': 9, 'Bahrain': 9, 'BH': 9, 'IQ': 9, 'Iraq': 9, 'IR': 9, 'Iran': 9, 'ISLAM': 9, 'JOR': 9, 'Jordan': 9, 'KSA': 9, 'Saudi Arabia': 9, 'KU': 9, 'KUW': 9, 'Kuwait': 9, 'LEB': 9, 'Lebanon': 9, 'MYHD': 9, 'OMAN': 9, 'OSN': 9, 'PALES': 9, 'Palestine': 9, 'QA': 9, 'Qatar': 9, 'SYR': 9, 'Syria': 9, 'TR': 9, 'Turkey': 9, 'UAE': 9, 'United Arab Emirates': 9, 'YEMEN': 9, 'Yemen': 9, 'IL': 9, 'Israel': 9, // 10. Special groups (24/7, VIP, PPV, etc.) - ABSOLUTE LAST '24/7 AR': 10, '24/7 IN': 10, '24/7-AR': 10, '24/7-DE': 10, '24/7-ES': 10, '24/7-GR': 10, '24/7-IN': 10, '24/7-MY': 10, '24/7-PT': 10, '24/7-RO': 10, '24/7-TR': 10, 'ART': 10, 'BEIN': 10, 'CINE': 10, 'CINE SD': 10, 'CINE Y SERIE': 10, 'DSTV': 10, 'EXYU': 10, 'EZD': 10, 'GENERAL': 10, 'ICC-CA': 10, 'ICC-CAR': 10, 'ICC-DSTV': 10, 'ICC-IN': 10, 'ICC-NZ': 10, 'ICC-PK': 10, 'ICC-UK': 10, 'LATINO': 10, 'MBC': 10, 'MOVIES': 10, 'MUSIC': 10, 'PPV': 10, 'RELIGIOUS': 10, 'SIN': 10, 'Sin País': 10, 'TOD': 10, 'VIP': 10, 'VIP - PK': 10, 'XMAS': 10, }; // Log priority for Argentina and Árabe if (countries.contains('Argentina')) {} if (countries.contains('Árabe')) {} // Sort using custom comparator countries.sort((a, b) { final priorityA = priorityOrder[a] ?? 100; final priorityB = priorityOrder[b] ?? 100; if (priorityA != priorityB) { return priorityA.compareTo(priorityB); } // If same priority, sort alphabetically return a.compareTo(b); }); return countries; } /// Check if a channel name indicates it broadcasts Argentine or Spanish football /// /// IMPORTANT: Only returns true if the channel is from ARGENTINA (ARG|) or SPAIN (ES|) /// This prevents including ESPN/TNT from USA, Netherlands, etc. bool isArgentineFootballChannel(String channelName) { if (channelName.isEmpty) return false; final normalizedName = channelName.toLowerCase(); // STEP 1: Verify channel is from Argentina (ARG|) or Spain (ES|) // This is CRITICAL to avoid including channels from other countries final isArgentine = normalizedName.startsWith('arg|'); final isSpanish = normalizedName.startsWith('es|') || normalizedName.contains('|es|'); if (!isArgentine && !isSpanish) { return false; // Skip channels from other countries (NL, US, UK, etc.) } // STEP 2: Check for football-related keywords // Only for Argentine and Spanish channels if (isArgentine) { // ARGENTINE CHANNELS - Fútbol Argentino keywords final argentineKeywords = [ 'tyc', 'tyc sports', 'tntsports', 'tnt sports', 'espn', 'espn premium', 'espn argentina', 'deportv', 'depo tv', 'depo', 'directv sports', 'directv', 'fox sports', 'futbol', 'fútbol', 'primera', 'liga', 'copa', 'superliga', 'lpf', 'boca', 'river', 'racing', 'independiente', 'san lorenzo', 'game', 'partido', 'cancha', 'gol', 'sports', 'deportes', ]; for (final keyword in argentineKeywords) { if (normalizedName.contains(keyword)) { return true; } } } if (isSpanish) { // SPANISH CHANNELS - Fútbol Español keywords final spanishKeywords = [ 'la liga', 'laliga', 'movistar', 'movistar la liga', 'movistar futbol', 'gol', 'gol tv', 'goltv', 'champions', 'champions league', 'europa league', 'barca', 'barcelona', 'madrid', 'real madrid', 'atletico', 'atlético', 'sevilla', 'valencia', 'betis', 'futbol', 'fútbol', 'liga', 'partido', 'cancha', 'gol', ]; for (final keyword in spanishKeywords) { if (normalizedName.contains(keyword)) { return true; } } } return false; } /// Smart country extraction with multiple strategies /// /// Strategy order: /// 1. Check for exact 3-letter matches (ARG, USA, etc.) /// 2. Check for 2-letter codes with context /// 3. Handle special formats (24/7-AR, AR-KIDS, etc.) /// 4. Use category/group information if available /// 5. Return proper full country names String extractCountryFromChannelName( String channelName, { String? groupTitle, }) { if (channelName.isEmpty) return ''; final normalizedName = channelName.toLowerCase(); final normalizedGroup = groupTitle?.toLowerCase() ?? ''; final cacheKey = '$normalizedName|$normalizedGroup'; final cached = _countryExtractionCache[cacheKey]; if (cached != null) { return cached; } if (!channelName.contains('|')) { final result = _extractCountryFromDirectName( channelName, groupTitle: groupTitle, ); return _cacheCountryExtraction(cacheKey, result); } final parts = channelName.split('|'); if (parts.isEmpty) { return _cacheCountryExtraction(cacheKey, ''); } final countryCode = parts.first.trim(); // Must be at least 2 characters (filter out single letters) if (countryCode.length < 2) { return _cacheCountryExtraction(cacheKey, ''); } // Strategy 1: Check if it's a known group title first if (_isGroupTitle(countryCode)) { final extractedFromGroup = _extractCountryFromGroupFormat(countryCode); if (extractedFromGroup.isNotEmpty) { return _cacheCountryExtraction(cacheKey, extractedFromGroup); } return _cacheCountryExtraction(cacheKey, ''); } // Strategy 2: Try exact match first (case insensitive) final normalizedCode = countryCode.toLowerCase().trim(); // Check for exact 3-letter match first (highest priority) if (countryCode.length == 3) { final exactMatch = _countryMapping[normalizedCode]; if (exactMatch != null) { return _cacheCountryExtraction(cacheKey, exactMatch); } } // Strategy 3: Check for 2-letter match with context if (countryCode.length == 2) { final result = _handleTwoLetterCode( normalizedCode, groupTitle: groupTitle, ); if (result.isNotEmpty) { return _cacheCountryExtraction(cacheKey, result); } } // Strategy 4: Check the mapping final mappedCountry = _countryMapping[normalizedCode]; if (mappedCountry != null) { return _cacheCountryExtraction(cacheKey, mappedCountry); } // Return the raw code if no mapping found return _cacheCountryExtraction(cacheKey, countryCode); } String _cacheCountryExtraction(String cacheKey, String value) { if (_countryExtractionCache.length > 60000) { _countryExtractionCache.clear(); } _countryExtractionCache[cacheKey] = value; return value; } /// Extract country from special group formats like "24/7-AR", "AR-KIDS", etc. String _extractCountryFromGroupFormat(String groupName) { final normalized = groupName.toLowerCase().trim(); // Pattern: 24/7-XX or 24/7 XX final twentyFourSevenMatch = RegExp( r'24/7[-\s]*(\w{2,3})', ).firstMatch(normalized); if (twentyFourSevenMatch != null) { final code = twentyFourSevenMatch.group(1)!.toLowerCase(); final mapped = _countryMapping[code]; if (mapped != null) { return mapped; } } // Pattern: AR-KIDS, AR-SP, etc. final arKidsMatch = RegExp(r'^(\w{2})[-_]').firstMatch(normalized); if (arKidsMatch != null) { final code = arKidsMatch.group(1)!.toLowerCase(); // Only treat as country code if it maps to a known country if (_countryMapping.containsKey(code)) { return _countryMapping[code]!; } } return ''; } /// Handle ambiguous 2-letter codes with context String _handleTwoLetterCode(String code, {String? groupTitle}) { // AR is ambiguous: could be Argentina or Arabic // CRITICAL FIX: AR| always maps to Árabe (Arabic language/category) // ARG| (3-letter) maps to Argentina if (code == 'ar') { // Check group title for context if (groupTitle != null) { final normalizedGroup = groupTitle.toLowerCase(); // If group contains argentina-related terms explicitly, treat as Argentina if (normalizedGroup.contains('argentina') || normalizedGroup.contains('argentino')) { return 'Argentina'; } // If group contains arabic-related terms, treat as Arabic if (normalizedGroup.contains('arab') || normalizedGroup.contains('islam') || normalizedGroup.contains('mbc') || normalizedGroup.contains('bein') || normalizedGroup.contains('osn') || normalizedGroup.contains('myhd') || normalizedGroup.contains('saudi') || normalizedGroup.contains('emirates') || normalizedGroup.contains('gulf')) { return 'Árabe'; } } // DEFAULT: AR without context = Árabe (Arabic) // This is because AR| is the standard prefix for Arabic channels // Argentina uses ARG| (3-letter code) return 'Árabe'; } // US/USA -> Estados Unidos if (code == 'us' || code == 'usa') { return 'Estados Unidos'; } // UK/GB -> Reino Unido if (code == 'uk' || code == 'gb') { return 'Reino Unido'; } // Check if there's a direct mapping final mapped = _countryMapping[code]; if (mapped != null) { return mapped; } return ''; } /// Extract country from channel name when no | separator exists String _extractCountryFromDirectName( String channelName, { String? groupTitle, }) { final normalized = channelName.toLowerCase().trim(); // Check for country codes at the beginning // Pattern: "XX - Channel Name" or "XX: Channel Name" final leadingCodeMatch = _leadingCodeRegex.firstMatch(normalized); if (leadingCodeMatch != null) { final code = leadingCodeMatch.group(1)!; final country = code.length == 3 ? (_countryMapping[code] ?? '') : _handleTwoLetterCode(code, groupTitle: groupTitle); if (country.isNotEmpty) { return country; } } // Check for country codes in brackets // Pattern: "Channel Name [XX]" or "(XX)" final bracketMatch = _bracketCodeRegex.firstMatch(normalized); if (bracketMatch != null) { final code = bracketMatch.group(1)!; final country = code.length == 3 ? (_countryMapping[code] ?? '') : _handleTwoLetterCode(code, groupTitle: groupTitle); if (country.isNotEmpty) { return country; } } return ''; } /// Check if a string is a group title (not a country) bool _isGroupTitle(String name) { final normalized = name.toLowerCase().trim(); // List of known group titles to exclude final groupTitles = { '24/7', '24/7 ar', '24/7 in', '24/7-es', '24/7-de', '24/7-gr', '24/7-my', '24/7-pt', '24/7-ro', '24/7-tr', '24/7-latino', 'vip', 'vip - pk', 'ppv', 'movies', 'cine', 'cine sd', 'cine y serie', 'latino', 'general', 'music', 'religious', 'bein', 'mbc', 'tod', 'osn', 'myhd', 'dstv', 'art', 'icc-ca', 'icc-car', 'icc-dstv', 'icc-in', 'icc-nz', 'icc-pk', 'icc-uk', 'xmas', 'sin', 'ezd', 'exyu', 'rot', 'ar-kids', 'ar-sp', 'islam', 'bab', 'as', 'ei', }; return groupTitles.contains(normalized); } List filterByCountry( List streams, String country, { Map? categoryToCountryMap, }) { // Empty string or "Todos"/"All" means show all channels final normalizedCountry = country.trim(); if (normalizedCountry.isEmpty || normalizedCountry.toLowerCase() == 'todos' || normalizedCountry.toLowerCase() == 'all') { return streams; } return streams.where((s) { String? streamCountry; // First, try to extract country from stream name (M3U format) // Pass group title for context to help with ambiguous codes final rawCountryFromName = extractCountryFromChannelName( s.name, groupTitle: s.plot, ); if (rawCountryFromName.isNotEmpty) { streamCountry = normalizeCountry(rawCountryFromName); } // If no country in name and we have category mapping, use category if (streamCountry == null && categoryToCountryMap != null && s.categoryId != null) { final categoryCountry = categoryToCountryMap[s.categoryId]; if (categoryCountry != null && categoryCountry.isNotEmpty) { streamCountry = normalizeCountry(categoryCountry); } } return streamCountry == normalizedCountry; }).toList(); } /// Filter streams by special category /// /// Currently supports: /// - "Fútbol Argentino" / "Futbol Argentino": Channels broadcasting Argentine football List filterByCategory( List streams, String category, ) { final normalizedCategory = category.trim().toLowerCase(); if (normalizedCategory.isEmpty) { return streams; } // Special case: Fútbol Argentino if (normalizedCategory == 'fútbol argentino' || normalizedCategory == 'futbol argentino') { final filtered = streams.where((s) { return isArgentineFootballChannel(s.name); }).toList(); // Sort: Argentine channels (ARG|) first, then Spanish (ES|) filtered.sort((a, b) { final aIsArgentine = a.name.toLowerCase().startsWith('arg|'); final bIsArgentine = b.name.toLowerCase().startsWith('arg|'); if (aIsArgentine && !bIsArgentine) return -1; // a comes first if (!aIsArgentine && bIsArgentine) return 1; // b comes first return 0; // same priority, keep original order }); return filtered; } // Unknown category - return all streams return streams; } List _parseM3U( String m3uContent, { void Function(int loaded, int total)? onProgress, }) { final List streams = []; final lines = m3uContent.split('\n'); // Count total EXTINF lines to estimate total channels int totalExtinfLines = 0; for (final line in lines) { if (line.trim().startsWith('#EXTINF:')) { totalExtinfLines++; } } XtreamStream? currentStream; int lastReportedProgress = 0; final logoRegex = RegExp(r'tvg-logo="([^"]*)"'); final groupRegex = RegExp(r'group-title="([^"]*)"'); for (final line in lines) { final trimmed = line.trim(); if (trimmed.startsWith('#EXTINF:')) { final info = trimmed.substring('#EXTINF:'.length); final commaIndex = info.indexOf(','); String? streamIcon; String? groupTitle; final name = commaIndex >= 0 ? info.substring(commaIndex + 1).trim() : ''; // Parse attributes: tvg-logo, group-title, tvg-id final attrs = commaIndex >= 0 ? info.substring(0, commaIndex) : info; final logoMatch = logoRegex.firstMatch(attrs); final groupMatch = groupRegex.firstMatch(attrs); if (logoMatch != null) { streamIcon = logoMatch.group(1); } if (groupMatch != null) { groupTitle = groupMatch.group(1); } currentStream = XtreamStream( streamId: streams.length + 1, name: name, streamIcon: streamIcon, containerExtension: 'ts', plot: groupTitle, // Store group as plot for now ); } else if (trimmed.isNotEmpty && !trimmed.startsWith('#')) { // This is the stream URL if (currentStream != null) { currentStream.url = trimmed; streams.add(currentStream); currentStream = null; // Report progress every 100 channels or at the end if (onProgress != null && (streams.length % 100 == 0 || streams.length == totalExtinfLines)) { // Only report if progress changed significantly if (streams.length - lastReportedProgress >= 100 || streams.length == totalExtinfLines) { onProgress(streams.length, totalExtinfLines); lastReportedProgress = streams.length; } } } } } // Final progress report if (onProgress != null) { onProgress(streams.length, totalExtinfLines); } return streams; } /// Saves text content to a file in the Downloads directory Future saveTextFile(String fileName, String content) async { try { // Request storage permissions var status = await Permission.storage.request(); if (!status.isGranted) { // Try manage external storage for Android 11+ status = await Permission.manageExternalStorage.request(); if (!status.isGranted) { throw Exception('Permiso de almacenamiento denegado'); } } // Get Downloads directory Directory? directory; if (Platform.isAndroid) { directory = Directory('/storage/emulated/0/Download'); } else { directory = await getDownloadsDirectory(); } if (directory == null || !directory.existsSync()) { // Fallback to app documents directory directory = await getApplicationDocumentsDirectory(); } final filePath = '${directory.path}/$fileName'; final file = File(filePath); // Write content to file await file.writeAsString(content, flush: true); return filePath; } catch (e) { throw Exception('Error al guardar archivo: $e'); } } }