Files
yommi_ff/lib/services/m3u_service.dart

187 lines
5.9 KiB
Dart

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<StreamEntry>? _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<File> _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<void> _writeCache(String url, List<StreamEntry> 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<List<StreamEntry>?> 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<String, dynamic>;
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<DateTime?> 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<String, dynamic>;
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<List<StreamEntry>> 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<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')) {
// 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;
}
}