import 'dart:async'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; 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(); /// 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!; } final resp = await _client.get(Uri.parse(url)); if (resp.statusCode != 200) { throw Exception('Failed to fetch M3U: HTTP ${resp.statusCode}'); } final content = resp.body; final entries = parse(content); // Update cache _cachedUrl = url; _cachedAt = DateTime.now(); _cachedEntries = entries; return entries; } /// 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; } }