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/Oceania 'AF': 7, 'AF': 7, 'AFG': 7, 'Albania': 7, 'AR-KIDS': 7, 'AR-SP': 7, 'ARM': 7, 'Armenia': 7, 'AZE': 7, 'Azerbaijan': 7, 'BAB': 7, 'BAHR': 7, 'Bahrain': 7, 'BD': 7, 'Bangladesh': 7, 'BH': 7, 'CN': 7, 'China': 7, 'DZ': 7, 'Algeria': 7, 'EGY': 7, 'Egypt': 7, 'ID': 7, 'Indonesia': 7, 'IL': 7, 'Israel': 7, 'IN': 7, 'India': 7, 'IQ': 7, 'Iraq': 7, 'IR': 7, 'Iran': 7, 'ISLAM': 7, 'JP': 7, 'Japan': 7, 'JOR': 7, 'Jordan': 7, 'KH': 7, 'Cambodia': 7, 'KSA': 7, 'Saudi Arabia': 7, 'KU': 7, 'KUW': 7, 'Kuwait': 7, 'KZ': 7, 'Kazakhstan': 7, 'LBY': 7, 'Libya': 7, 'LEB': 7, 'Lebanon': 7, 'MA': 7, 'Morocco': 7, 'MY': 7, 'Malaysia': 7, 'MYHD': 7, 'OMAN': 7, 'OSN': 7, 'PALES': 7, 'Palestine': 7, 'PH': 7, 'Philippines': 7, 'PK': 7, 'Pakistan': 7, 'QA': 7, 'Qatar': 7, 'SYR': 7, 'Syria': 7, 'TH': 7, 'Thailand': 7, 'TN': 7, 'Tunisia': 7, 'TR': 7, 'Turkey': 7, 'UAE': 7, 'United Arab Emirates': 7, 'YEMEN': 7, 'Yemen': 7, // 8. Africa (second to last) 'ANGOLA': 8, 'BENIN': 8, 'BURKINAFASO': 8, 'CAMEROON': 8, 'CAPEVERDE': 8, 'CONGO': 8, 'COTEDIVOIRE': 8, 'DJIBOUTI': 8, 'ERITREA': 8, 'ETHIOPIA': 8, 'GABON': 8, 'GAMBIA': 8, 'GHANA': 8, 'GUINEE': 8, 'KENYA': 8, 'MALAWI': 8, 'MALI': 8, 'MOZAMBIQUE': 8, 'NIGERIA': 8, 'ROT': 8, 'ROWANDA': 8, 'SENEGAL': 8, 'SOMAL': 8, 'SUDAN': 8, 'TANZANIA': 8, 'TCHAD': 8, 'TOGO': 8, 'UGANDA': 8, 'ZA': 8, 'South Africa': 8, 'ZAMBIA': 8, // 9. Árabe (Arabic channels) - NEAR THE END (after Africa) 'Árabe': 9, // 10. Special groups (priority 100 - absolute last) '24/7 AR': 100, '24/7 IN': 100, '24/7-AR': 100, '24/7-DE': 100, '24/7-ES': 100, '24/7-GR': 100, '24/7-IN': 100, '24/7-MY': 100, '24/7-PT': 100, '24/7-RO': 100, '24/7-TR': 100, 'ART': 100, 'BEIN': 100, 'CINE': 100, 'CINE SD': 100, 'CINE Y SERIE': 100, 'DSTV': 100, 'EXYU': 100, 'EZD': 100, 'GENERAL': 100, 'ICC-CA': 100, 'ICC-CAR': 100, 'ICC-DSTV': 100, 'ICC-IN': 100, 'ICC-NZ': 100, 'ICC-PK': 100, 'ICC-UK': 100, 'LATINO': 100, 'MBC': 100, 'MOVIES': 100, 'MUSIC': 100, 'PPV': 100, 'RELIGIOUS': 100, 'SIN': 100, 'Sin País': 100, 'TOD': 100, 'VIP': 100, 'VIP - PK': 100, 'XMAS': 100, }; // 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; } /// 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(); } 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'); } } }