Files
iptv-ren/lib/services/xtream_api.dart.backup

1902 lines
56 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/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<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();
}
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');
}
}
}