2026-01-11 19:49:43 +09:00
|
|
|
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<StreamEntry>? _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<List<StreamEntry>> 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<StreamEntry> parse(String content) {
|
|
|
|
|
final lines = content.replaceAll('\r', '').split('\n');
|
|
|
|
|
final entries = <StreamEntry>[];
|
|
|
|
|
|
|
|
|
|
String? pendingTitle;
|
|
|
|
|
|
|
|
|
|
for (var raw in lines) {
|
|
|
|
|
final line = raw.trim();
|
|
|
|
|
if (line.isEmpty) continue;
|
|
|
|
|
if (line.startsWith('#EXTINF')) {
|
2026-01-11 20:16:01 +09:00
|
|
|
// Format: #EXTINF:-1 tvg-id="" tvg-name="Channel" tvg-logo="https://..." group-title="...",Display title
|
2026-01-11 19:49:43 +09:00
|
|
|
final parts = line.split(',');
|
2026-01-11 20:16:01 +09:00
|
|
|
|
|
|
|
|
// Try to extract attributes like tvg-logo="..."
|
|
|
|
|
final logoMatch = RegExp(r'tvg-logo\s*=\s*"([^"]+)"').firstMatch(line);
|
|
|
|
|
final logo = logoMatch?.group(1);
|
|
|
|
|
|
2026-01-11 19:49:43 +09:00
|
|
|
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();
|
|
|
|
|
}
|
2026-01-11 20:16:01 +09:00
|
|
|
|
|
|
|
|
// 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';
|
2026-01-11 19:49:43 +09:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (line.startsWith('#')) continue;
|
|
|
|
|
|
|
|
|
|
// At this point 'line' is a URL
|
|
|
|
|
final url = line;
|
2026-01-11 20:16:01 +09:00
|
|
|
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));
|
2026-01-11 19:49:43 +09:00
|
|
|
pendingTitle = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entries.isEmpty) {
|
|
|
|
|
throw M3uParseException('No entries found in M3U');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return entries;
|
|
|
|
|
}
|
|
|
|
|
}
|