import 'dart:async'; import 'dart:io'; import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; import 'package:flutter/foundation.dart' show debugPrint, unawaited; import '../models/stream_entry.dart'; class M3uParseException implements Exception { final String message; M3uParseException(this.message); @override String toString() => 'M3uParseException: $message'; } class M3UService { final http.Client _client; final Duration cacheTTL; String? _cachedUrl; DateTime? _cachedAt; List? _cachedEntries; M3UService({http.Client? client, this.cacheTTL = const Duration(minutes: 5)}) : _client = client ?? http.Client(); // ----------------------------- Disk cache helpers ----------------------------- // Stores/read simple JSON cache in the app support directory per M3U URL. Future _cacheFileForUrl(String url) async { final dir = await getApplicationSupportDirectory(); final safe = base64Url.encode(utf8.encode(url)).replaceAll('=', ''); // Truncate filename to avoid filesystem issues final short = safe.length > 120 ? safe.substring(0, 120) : safe; return File('${dir.path}/m3u_cache_$short.json'); } Future _writeCache(String url, List entries) async { try { final f = await _cacheFileForUrl(url); final jsonBody = jsonEncode({ 'url': url, 'cachedAt': DateTime.now().toIso8601String(), 'entries': entries .map((e) => {'title': e.title, 'url': e.url, 'logo': e.logo}) .toList() }); await f.writeAsString(jsonBody); } catch (e) { // Cache write failures are non-fatal; log and continue debugPrint('M3U cache write failed: $e'); } } Future?> loadFromCache(String url) async { try { final f = await _cacheFileForUrl(url); if (!await f.exists()) return null; final s = await f.readAsString(); final m = jsonDecode(s) as Map; if (m['entries'] is List) { final list = (m['entries'] as List) .map((e) => StreamEntry( title: e['title'] ?? '', url: e['url'] ?? '', logo: e['logo'], )) .toList(); return list; } } catch (e) { debugPrint('M3U cache read failed: $e'); } return null; } Future lastCacheTime(String url) async { try { final f = await _cacheFileForUrl(url); if (!await f.exists()) return null; final s = await f.readAsString(); final m = jsonDecode(s) as Map; if (m['cachedAt'] is String) return DateTime.parse(m['cachedAt']); } catch (e) { debugPrint('M3U cache timestamp read failed: $e'); } return null; } // ----------------------------- End disk cache helpers ----------------------------- /// Fetches an M3U (over HTTP) and returns parsed list of [StreamEntry]. /// Uses a short in-memory cache to avoid repeated network calls. Future> fetch(String url, {bool forceRefresh = false}) async { if (!forceRefresh && _cachedUrl == url && _cachedEntries != null && _cachedAt != null && DateTime.now().difference(_cachedAt!) < cacheTTL) { return _cachedEntries!; } try { final resp = await _client.get(Uri.parse(url)); if (resp.statusCode != 200) { // Try to return a local cache if available before failing final cached = await loadFromCache(url); if (cached != null) return cached; throw Exception('Failed to fetch M3U: HTTP ${resp.statusCode}'); } final content = resp.body; final entries = parse(content); // Update memory cache _cachedUrl = url; _cachedAt = DateTime.now(); _cachedEntries = entries; // Persist to disk cache (best-effort) _writeCache(url, entries); // fire-and-forget return entries; } catch (e) { // Network or other error — fallback to disk cache if possible final cached = await loadFromCache(url); if (cached != null) return cached; rethrow; } } /// Parse raw M3U content into [StreamEntry] list. List parse(String content) { final lines = content.replaceAll('\r', '').split('\n'); final entries = []; String? pendingTitle; for (var raw in lines) { final line = raw.trim(); if (line.isEmpty) continue; if (line.startsWith('#EXTINF')) { // Format: #EXTINF:-1 tvg-id="" tvg-name="Channel" tvg-logo="https://..." group-title="...",Display title final parts = line.split(','); // Try to extract attributes like tvg-logo="..." final logoMatch = RegExp(r'tvg-logo\s*=\s*"([^"]+)"').firstMatch(line); final logo = logoMatch?.group(1); if (parts.length >= 2) { pendingTitle = parts.sublist(1).join(',').trim(); } else { final idx = line.indexOf(','); if (idx >= 0 && idx < line.length - 1) pendingTitle = line.substring(idx + 1).trim(); } // Attach logo information to the pending title token using a simple marker so the // subsequent URL line can pick it up. if (logo != null && pendingTitle != null) pendingTitle = '$pendingTitle||logo:$logo'; continue; } if (line.startsWith('#')) continue; // At this point 'line' is a URL final url = line; String title = pendingTitle ?? url; String? logo; if (title.contains('||logo:')) { final parts = title.split('||logo:'); title = parts[0]; logo = parts[1]; } entries.add(StreamEntry(title: title, url: url, logo: logo)); pendingTitle = null; } if (entries.isEmpty) { throw M3uParseException('No entries found in M3U'); } return entries; } }