2019 lines
60 KiB
Dart
2019 lines
60 KiB
Dart
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<String, String> metadata;
|
||
|
||
M3UChannel({
|
||
required this.name,
|
||
required this.url,
|
||
this.groupTitle,
|
||
this.tvgLogo,
|
||
this.tvgId,
|
||
this.tvgName,
|
||
this.metadata = const {},
|
||
});
|
||
|
||
Map<String, dynamic> 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<M3UChannel> channels;
|
||
final String sourceUrl;
|
||
final DateTime downloadTime;
|
||
final int totalChannels;
|
||
final Map<String, int> groupsCount;
|
||
|
||
M3UDownloadResult({
|
||
required this.channels,
|
||
required this.sourceUrl,
|
||
required this.downloadTime,
|
||
required this.totalChannels,
|
||
required this.groupsCount,
|
||
});
|
||
|
||
Map<String, dynamic> 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<String, String> _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<Map<String, dynamic>> 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<XtreamUserInfo> getUserInfo() async {
|
||
final data = await authenticate();
|
||
return XtreamUserInfo.fromJson(data);
|
||
}
|
||
|
||
Future<List<XtreamCategory>> 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<dynamic> data = json.decode(response.body);
|
||
return data.map((e) => XtreamCategory.fromJson(e)).toList();
|
||
}
|
||
throw Exception('Failed to load categories');
|
||
}
|
||
|
||
Future<List<XtreamCategory>> 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<dynamic> data = json.decode(response.body);
|
||
return data.map((e) => XtreamCategory.fromJson(e)).toList();
|
||
}
|
||
throw Exception('Failed to load VOD categories');
|
||
}
|
||
|
||
Future<List<XtreamCategory>> 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<dynamic> data = json.decode(response.body);
|
||
return data.map((e) => XtreamCategory.fromJson(e)).toList();
|
||
}
|
||
throw Exception('Failed to load series categories');
|
||
}
|
||
|
||
Future<List<XtreamStream>> 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<dynamic> 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<List<XtreamStream>> 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<dynamic> 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<List<XtreamSeries>> 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<dynamic> data = json.decode(response.body);
|
||
return data.map((e) => XtreamSeries.fromJson(e)).toList();
|
||
}
|
||
throw Exception('Failed to load series');
|
||
}
|
||
|
||
Future<List<XtreamEpisode>> 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<dynamic> episodesData = data['episodes'] ?? [];
|
||
|
||
final List<XtreamEpisode> allEpisodes = [];
|
||
for (final seasonData in episodesData) {
|
||
final season = seasonData['season_number'] ?? 0;
|
||
final List<dynamic> 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<List<XtreamStream>> 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<M3UDownloadResult> 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 = <String, int>{};
|
||
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<M3UChannel> _parseM3UToChannels(String m3uContent) {
|
||
final List<M3UChannel> channels = [];
|
||
final lines = m3uContent.split('\n');
|
||
|
||
M3UChannel? currentChannel;
|
||
Map<String, String> 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<String> 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<String> getCountriesOptimized(List<XtreamStream> streams, {int maxChannelsToProcess = 2000}) {
|
||
print('DEBUG: getCountriesOptimized() called with ${streams.length} streams, processing max $maxChannelsToProcess');
|
||
|
||
final countries = <String>{};
|
||
|
||
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<String> getCountries(List<XtreamStream> streams, {Map<String, String>? 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 = <String>{};
|
||
|
||
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 = <String>[];
|
||
|
||
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 = <String>{};
|
||
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<String> _sortCountriesByPriority(List<String> countries) {
|
||
// Define priority order
|
||
final priorityOrder = <String, int>{
|
||
// 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<XtreamStream> filterByCountry(List<XtreamStream> streams, String country,
|
||
{Map<String, String>? 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<XtreamStream> filterByCategory(List<XtreamStream> 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<XtreamStream> _parseM3U(String m3uContent, {void Function(int loaded, int total)? onProgress}) {
|
||
print('DEBUG: _parseM3U() START - content length: ${m3uContent.length} chars');
|
||
final List<XtreamStream> 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<String> 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');
|
||
}
|
||
}
|
||
}
|