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 { String? _server; String? _username; String? _password; String? _baseUrl; // 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'; } String? get server => _server; String? get username => _username; Future> authenticate() async { final url = '$_baseUrl/player_api.php'; final response = await http.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 http.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 http.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 http.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 http.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 http.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 http.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 http.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 season = seasonData['season_number'] ?? 0; 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 { print('DEBUG: Trying M3U endpoint: $url'); final response = await http.get(Uri.parse(url)).timeout( const Duration(seconds: 60), // Increased timeout for large playlists (26k+ channels) ); print('DEBUG: Response status: ${response.statusCode}, body length: ${response.body.length}'); 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')) { print('DEBUG: Successfully loaded M3U from $url'); final streams = _parseM3U(response.body, onProgress: onProgress); print('DEBUG: Parsed ${streams.length} streams from M3U'); if (streams.isEmpty) { print('DEBUG: WARNING - M3U parsed but 0 streams found'); lastError = Exception('M3U loaded but no streams parsed'); continue; } return streams; } else { print('DEBUG: Response does not start with #EXTM3U, first 100 chars: ${bodyTrimmed.substring(0, bodyTrimmed.length > 100 ? 100 : bodyTrimmed.length)}'); lastError = Exception('Invalid M3U format'); } } else { print('DEBUG: HTTP ${response.statusCode} from $url'); lastError = Exception('HTTP ${response.statusCode}'); } } on TimeoutException catch (e) { print('DEBUG: Timeout loading M3U from $url: $e'); lastError = Exception('Timeout loading M3U'); continue; } catch (e) { print('DEBUG: Error loading M3U from $url: $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 { print('DEBUG: Trying M3U endpoint: $url'); final response = await http.get(Uri.parse(url)).timeout( const Duration(seconds: 30), ); if (response.statusCode == 200 && response.body.startsWith('#EXTM3U')) { print('DEBUG: Successfully loaded M3U from $url'); successfulUrl = url; m3uContent = response.body; break; } } catch (e) { print('DEBUG: Failed to load from $url: $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'); 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 = RegExp(r'#EXTINF:([^,]*),(.*)$').firstMatch(trimmed); if (infoMatch != null) { final attrsPart = infoMatch.group(1) ?? ''; final name = infoMatch.group(2)?.trim() ?? ''; // Parse all attributes (tvg-*, group-title, etc.) final attrRegex = RegExp(r'(\w+[-\w]*)="([^"]*)"'); 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 = {}; } } } print('DEBUG: Parsed ${channels.length} channels from M3U'); 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); print('DEBUG: Saved JSON to: $filePath'); return filePath; } catch (e) { print('DEBUG: Error saving JSON: $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}) { print('DEBUG: getCountriesOptimized() called with ${streams.length} streams, processing max $maxChannelsToProcess'); final countries = {}; if (streams.isEmpty) { print('DEBUG: WARNING - streams list is empty!'); 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; int loggedSamples = 0; final maxSamplesToLog = 10; for (int i = 0; i < streams.length && processed < sampleSize; i += step) { final stream = streams[i]; String? country; // Log sample channels (first few with AR or ARG) if (loggedSamples < maxSamplesToLog) { final name = stream.name; if (name.toLowerCase().startsWith('ar|') || name.toLowerCase().startsWith('arg|') || name.toLowerCase().startsWith('24/7 ar|') || name.toLowerCase().startsWith('24/7-ar|')) { print('DEBUG SAMPLE CHANNEL: Processing "$name"'); print('DEBUG SAMPLE CHANNEL: Group title: "${stream.plot}"'); loggedSamples++; } } // 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++; } print('DEBUG: Processed $processed channels, extracted ${countries.length} unique countries'); // Sort countries with custom priority order final sortedCountries = _sortCountriesByPriority(countries.toList()); print('DEBUG: Returning ${sortedCountries.length} sorted countries'); return sortedCountries; } List getCountries(List streams, {Map? categoryToCountryMap}) { print('DEBUG: ========================================================='); print('DEBUG: getCountries() called with ${streams.length} streams'); if (categoryToCountryMap != null) { print('DEBUG: Using category mapping with ${categoryToCountryMap.length} categories'); } print('DEBUG: ========================================================='); final countries = {}; if (streams.isEmpty) { print('DEBUG: WARNING - streams list is empty!'); return []; } // Sample first 10 stream names for debugging print('DEBUG: First 10 stream names:'); for (int i = 0; i < streams.length && i < 10; i++) { final s = streams[i]; print('DEBUG: [${i + 1}] "${s.name}" (category: ${s.categoryId})'); } // Check how many have | separator int withSeparator = 0; int withoutSeparator = 0; for (int i = 0; i < streams.length && i < 50; i++) { if (streams[i].name.contains('|')) { withSeparator++; } else { withoutSeparator++; } } print('DEBUG: First 50 streams - with | separator: $withSeparator, without: $withoutSeparator'); // Track which patterns we're finding final patternExamples = []; 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); print('DEBUG: Using category for "${stream.name}" -> $country'); } } if (country != null && country.isNotEmpty) { countries.add(country); // Track examples of first 5 patterns found if (patternExamples.length < 5 && !patternExamples.contains('${stream.name} -> $country')) { patternExamples.add('${stream.name} -> $country'); } } } print('DEBUG: Extracted ${countries.length} unique countries'); print('DEBUG: Pattern examples (name -> country):'); for (final example in patternExamples) { print('DEBUG: $example'); } // Show all extracted country codes/short names before normalization final rawCountries = {}; for (int i = 0; i < streams.length && rawCountries.length < 20; i++) { final raw = extractCountryFromChannelName( streams[i].name, groupTitle: streams[i].plot, ); if (raw.isNotEmpty) { rawCountries.add(raw); } } print('DEBUG: First 20 unique raw country values: ${rawCountries.toList()}'); // Sort countries with custom priority order final sortedCountries = _sortCountriesByPriority(countries.toList()); print('DEBUG: getCountries() returning ${sortedCountries.length} sorted countries'); print('DEBUG: Countries list: $sortedCountries'); print('DEBUG: ========================================================='); 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 print('DEBUG SORT: Checking priority assignments...'); if (countries.contains('Argentina')) { print('DEBUG SORT: Argentina priority = ${priorityOrder["Argentina"] ?? 100}'); } if (countries.contains('Árabe')) { print('DEBUG SORT: Árabe priority = ${priorityOrder["Árabe"] ?? 100}'); } // 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}) { print('DEBUG EXTRACTION: ============================================'); print('DEBUG EXTRACTION: Processing channel: \"$channelName\"'); print('DEBUG EXTRACTION: Group title: \"$groupTitle\"'); if (!channelName.contains('|')) { print('DEBUG EXTRACTION: No | separator found, trying direct name extraction'); final result = _extractCountryFromDirectName(channelName, groupTitle: groupTitle); print('DEBUG EXTRACTION: Direct extraction result: \"$result\"'); print('DEBUG EXTRACTION: ============================================'); return result; } final parts = channelName.split('|'); print('DEBUG EXTRACTION: Split into \${parts.length} parts: \$parts'); if (parts.isEmpty) { print('DEBUG EXTRACTION: No parts found, returning empty'); print('DEBUG EXTRACTION: ============================================'); return ''; } final countryCode = parts.first.trim(); print('DEBUG EXTRACTION: Extracted country code: \"$countryCode\" (length: \${countryCode.length})'); // Must be at least 2 characters (filter out single letters) if (countryCode.length < 2) { print('DEBUG EXTRACTION: Code too short (< 2 chars), returning empty'); print('DEBUG EXTRACTION: ============================================'); return ''; } // Strategy 1: Check if it's a known group title first if (_isGroupTitle(countryCode)) { print('DEBUG EXTRACTION: \"$countryCode\" is a group title, extracting from group format'); final extractedFromGroup = _extractCountryFromGroupFormat(countryCode); print('DEBUG EXTRACTION: Group extraction result: \"$extractedFromGroup\"'); print('DEBUG EXTRACTION: ============================================'); if (extractedFromGroup.isNotEmpty) { return extractedFromGroup; } return ''; } // Strategy 2: Try exact match first (case insensitive) final normalizedCode = countryCode.toLowerCase().trim(); print('DEBUG EXTRACTION: Normalized code: \"$normalizedCode\"'); // Check for exact 3-letter match first (highest priority) if (countryCode.length == 3) { print('DEBUG EXTRACTION: Checking 3-letter code match...'); final exactMatch = _countryMapping[normalizedCode]; if (exactMatch != null) { print('DEBUG EXTRACTION: FOUND 3-letter match: \"$countryCode\" -> \"$exactMatch\"'); print('DEBUG EXTRACTION: ============================================'); return exactMatch; } print('DEBUG EXTRACTION: No 3-letter match found in mapping'); } // Strategy 3: Check for 2-letter match with context if (countryCode.length == 2) { print('DEBUG EXTRACTION: Checking 2-letter code with context...'); final result = _handleTwoLetterCode(normalizedCode, groupTitle: groupTitle); print('DEBUG EXTRACTION: 2-letter code result: \"$result\"'); if (result.isNotEmpty) { print('DEBUG EXTRACTION: ============================================'); return result; } } // Strategy 4: Check the mapping print('DEBUG EXTRACTION: Checking general mapping...'); final mappedCountry = _countryMapping[normalizedCode]; if (mappedCountry != null) { print('DEBUG EXTRACTION: FOUND in general mapping: \"$countryCode\" -> \"$mappedCountry\"'); print('DEBUG EXTRACTION: ============================================'); return mappedCountry; } // Return the raw code if no mapping found print('DEBUG EXTRACTION: No mapping found, returning raw code: \"$countryCode\"'); print('DEBUG EXTRACTION: ============================================'); return countryCode; } /// 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}) { print('DEBUG 2LETTER: Handling 2-letter code: "$code" with groupTitle: "$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') { print('DEBUG 2LETTER: Processing AR code...'); // Check group title for context if (groupTitle != null) { final normalizedGroup = groupTitle.toLowerCase(); print('DEBUG 2LETTER: Checking group title context: "$normalizedGroup"'); // If group contains argentina-related terms explicitly, treat as Argentina if (normalizedGroup.contains('argentina') || normalizedGroup.contains('argentino')) { print('DEBUG 2LETTER: AR matched Argentina context -> returning Argentina'); 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')) { print('DEBUG 2LETTER: AR matched Arabic context -> returning Árabe'); return 'Árabe'; } print('DEBUG 2LETTER: Group title does not match Argentina or Arabic patterns'); } else { print('DEBUG 2LETTER: No group title provided for context'); } // DEFAULT: AR without context = Árabe (Arabic) // This is because AR| is the standard prefix for Arabic channels // Argentina uses ARG| (3-letter code) print('DEBUG 2LETTER: AR returning default -> Árabe'); 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 = RegExp(r'^([a-z]{2,3})\s*[-:=]\s*').firstMatch(normalized); if (leadingCodeMatch != null) { final code = leadingCodeMatch.group(1)!; final country = _handleTwoLetterCode(code, groupTitle: groupTitle); if (country.isNotEmpty) { return country; } } // Check for country codes in brackets // Pattern: "Channel Name [XX]" or "(XX)" final bracketMatch = RegExp(r'[\[\(]([a-z]{2,3})[\]\)]').firstMatch(normalized); if (bracketMatch != null) { final code = bracketMatch.group(1)!; final country = _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') { print('DEBUG: Filtering for Fútbol Argentino channels'); 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 }); print('DEBUG: Found ${filtered.length} Argentine football channels (sorted: ARG first, then ES)'); return filtered; } // Unknown category - return all streams return streams; } List _parseM3U(String m3uContent, {void Function(int loaded, int total)? onProgress}) { print('DEBUG: _parseM3U() START - content length: ${m3uContent.length} chars'); final List streams = []; final lines = m3uContent.split('\n'); print('DEBUG: _parseM3U() - ${lines.length} lines to parse'); // Count total EXTINF lines to estimate total channels int totalExtinfLines = 0; for (final line in lines) { if (line.trim().startsWith('#EXTINF:')) { totalExtinfLines++; } } print('DEBUG: _parseM3U() - Estimated total channels: $totalExtinfLines'); XtreamStream? currentStream; int extinfCount = 0; int urlCount = 0; int lastReportedProgress = 0; for (final line in lines) { final trimmed = line.trim(); if (trimmed.startsWith('#EXTINF:')) { extinfCount++; final info = trimmed.substring('#EXTINF:'.length); final parts = info.split(','); String? streamIcon; String? groupTitle; String name = parts.length > 1 ? parts[1].trim() : ''; // Parse attributes: tvg-logo, group-title, tvg-id final attrs = parts[0]; final logoMatch = RegExp(r'tvg-logo="([^"]*)"').firstMatch(attrs); final groupMatch = RegExp(r'group-title="([^"]*)"').firstMatch(attrs); if (logoMatch != null) { streamIcon = logoMatch.group(1); } if (groupMatch != null) { groupTitle = groupMatch.group(1); } // Log first 5 and last 5 channels to see the format if (streams.length < 5 || (streams.length > 25200 && streams.length < 25205)) { print('DEBUG: _parseM3U() - Channel #${streams.length + 1}: name="$name", group="$groupTitle"'); } 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 urlCount++; 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); } print('DEBUG: _parseM3U() END - Parsed ${streams.length} streams ($extinfCount EXTINF lines, $urlCount URLs)'); 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); print('DEBUG: Text file saved to: $filePath'); return filePath; } catch (e) { print('DEBUG: Error saving text file: $e'); throw Exception('Error al guardar archivo: $e'); } } }