mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-22 22:58:14 +02:00
feat: XMLTV EPG fallback, Warm Cinema theme, background release
Align the Android app with xtremflow on three fronts. EPG: add XmltvService, a streaming XMLTV parser (gunzip-aware, indexed to a -3h/+18h window, JSON disk cache with a 6h TTL). XtreamServiceMobile now falls back through the provider's xmltv.php, a user-supplied URL, then a community guide whenever get_short_epg returns nothing. Channels are matched on epg_channel_id first, then on a normalized name (accents, HD/FHD/4K suffixes, "FR:" and "|FR|" prefixes). A streamId -> Channel index populated by getLiveChannels supplies those keys, so no getShortEPG call site changed. EPG requests are also funnelled through a 3-slot semaphore, as in xtremflow: a 30-tile grid used to fire 30 parallel player_api.php calls, which rate-limited panels answered with errors, emptying the guide for the whole screen. Theme: port the Warm Cinema charter (ember/charcoal/cream, Fraunces + Karla) into app_colors, app_theme and app_decorations. The 21 Apple TV colour names already in use are kept as compatibility aliases. mobile_theme now derives from AppTheme instead of duplicating its 390 lines. Playback lifecycle: stop reacting to AppLifecycleState.inactive, which fires on transient Android interruptions such as pulling down the notification shade and was killing playback. Both engines now release on paused/hidden/detached via _releasePlayback(): stream stopped, decoder released, timers cancelled, wakelock dropped. An _isReleased latch keeps the watchdog and reconnect paths from restarting a stream from the background. Long-run stability: cancel the six player stream subscriptions on dispose, cap demuxer-max-back-bytes at 8MB (mpv defaults to 50MiB, which combined with the 150MB readahead pushed the native heap toward an Android memory kill after a few hours), rebuild on position only while the controls are visible, and dispose the Lite player's focus nodes. Startup: prewarmHost() pre-resolves DNS and opens then closes a bare TCP/TLS socket before playback. No HTTP request is issued, so no provider connection slot is consumed. Capped at 1.5s. Verified: flutter analyze clean on all touched files; 19/19 tests, including a run over a real 46MB/69802-programme dump (517 channels indexed in 1.1s, 5/5 name probes matched); flutter build apk --release succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
63e77c5bc5
commit
8d74bbb320
15 files changed
+2138
-852
No files matched your search
@@ -7,8 +7,38 @@ library;
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import '../models/xtream_models.dart' as model;
|
||||
import 'package:xtremobile/core/api/dns_resolver.dart';
|
||||
import 'package:xtremobile/core/models/playlist_config.dart';
|
||||
import 'xmltv_service.dart';
|
||||
|
||||
/// Counting semaphore used to cap concurrent EPG requests.
|
||||
class _Semaphore {
|
||||
_Semaphore(this.maxConcurrent);
|
||||
|
||||
final int maxConcurrent;
|
||||
int _current = 0;
|
||||
final _waiting = <Completer<void>>[];
|
||||
|
||||
Future<void> acquire() {
|
||||
if (_current < maxConcurrent) {
|
||||
_current++;
|
||||
return Future.value();
|
||||
}
|
||||
final completer = Completer<void>();
|
||||
_waiting.add(completer);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (_waiting.isNotEmpty) {
|
||||
_waiting.removeAt(0).complete();
|
||||
return;
|
||||
}
|
||||
if (_current > 0) _current--;
|
||||
}
|
||||
}
|
||||
|
||||
/// EPG cache entry with TTL
|
||||
class _EpgCacheEntry {
|
||||
@@ -22,19 +52,32 @@ class _EpgCacheEntry {
|
||||
DateTime.now().difference(timestamp).inSeconds > ttlSeconds;
|
||||
}
|
||||
|
||||
/// Default community XMLTV guides, used only when the provider has no EPG of
|
||||
/// its own. Ordered by preference; the first one that yields programmes wins.
|
||||
const List<XmltvSource> kDefaultPublicXmltvSources = [
|
||||
XmltvSource(
|
||||
label: 'EPGShare FR1',
|
||||
url: 'https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz',
|
||||
),
|
||||
];
|
||||
|
||||
/// Xtream API Service for Mobile
|
||||
///
|
||||
/// Optimized for Android with:
|
||||
/// - Batch EPG loading (N+1 prevention)
|
||||
/// - Provider-level caching with TTL
|
||||
/// - XMLTV fallback when the provider serves no EPG
|
||||
/// - Configurable timeouts and retries
|
||||
/// - Memory-efficient streaming
|
||||
class XtreamServiceMobile {
|
||||
final String cacheDir;
|
||||
late Dio _dio;
|
||||
late String _baseUrl;
|
||||
late String _username;
|
||||
late String _password;
|
||||
|
||||
/// Replaced with the configured instance in [setPlaylistAsync]; initialised
|
||||
/// eagerly so [dispose] is safe even if the service was never configured.
|
||||
Dio _dio = Dio();
|
||||
String _baseUrl = '';
|
||||
String _username = '';
|
||||
String _password = '';
|
||||
|
||||
// Batch EPG cache to prevent N+1 queries
|
||||
final Map<String, _EpgCacheEntry> _epgBatchCache = {};
|
||||
@@ -42,8 +85,54 @@ class XtreamServiceMobile {
|
||||
// In-flight batch requests to deduplicate concurrent calls
|
||||
final Map<String, Future<Map<String, model.ShortEPG>>> _inFlightBatches = {};
|
||||
|
||||
/// A channel grid asks for EPG one tile at a time, so a screenful fires ~30
|
||||
/// simultaneous `player_api.php` calls. Many Xtream panels rate-limit past a
|
||||
/// handful of parallel connections and answer with errors, which empties the
|
||||
/// EPG of the whole grid. Requests are therefore funnelled in small packets.
|
||||
static final _Semaphore _epgGate = _Semaphore(3);
|
||||
|
||||
/// streamId → channel, populated by [getLiveChannels].
|
||||
///
|
||||
/// The XMLTV fallback matches on `epg_channel_id` / channel name, neither of
|
||||
/// which the per-stream EPG calls carry — this index supplies them without
|
||||
/// changing any call site.
|
||||
final Map<String, model.Channel> _channelIndex = {};
|
||||
|
||||
late final XmltvService _xmltv = XmltvService(cacheDir);
|
||||
|
||||
/// User-provided XMLTV URL (empty = not configured).
|
||||
String _customXmltvUrl = '';
|
||||
|
||||
/// Whether the community guides may be used as the last resort.
|
||||
bool _allowPublicXmltv = true;
|
||||
|
||||
/// Set once every XMLTV source has failed, so a channel grid does not retry
|
||||
/// a multi-megabyte download for each of its tiles.
|
||||
bool _xmltvExhausted = false;
|
||||
|
||||
XtreamServiceMobile(this.cacheDir);
|
||||
|
||||
/// Label of the XMLTV source currently backing the fallback, if any.
|
||||
String? get xmltvSourceLabel => _xmltv.loadedSourceLabel;
|
||||
|
||||
/// Configure the XMLTV fallback. Safe to call at any time.
|
||||
void setXmltvConfig({String? customUrl, bool? allowPublicFallback}) {
|
||||
final normalizedUrl = customUrl?.trim() ?? _customXmltvUrl;
|
||||
final changed = normalizedUrl != _customXmltvUrl ||
|
||||
(allowPublicFallback ?? _allowPublicXmltv) != _allowPublicXmltv;
|
||||
|
||||
_customXmltvUrl = normalizedUrl;
|
||||
_allowPublicXmltv = allowPublicFallback ?? _allowPublicXmltv;
|
||||
|
||||
if (changed) {
|
||||
// Sources changed — drop the "nothing works" latch and any indexed guide
|
||||
// so the new configuration is actually tried.
|
||||
_xmltvExhausted = false;
|
||||
_xmltv.clear();
|
||||
_epgBatchCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize with playlist configuration
|
||||
Future<void> setPlaylistAsync(PlaylistConfig config) async {
|
||||
_baseUrl = config.dns;
|
||||
@@ -59,11 +148,56 @@ class XtreamServiceMobile {
|
||||
),
|
||||
);
|
||||
|
||||
// Warm the DNS entry for the panel host now, so the first zap does not pay
|
||||
// for resolution. Fire-and-forget: playback must never wait on this.
|
||||
unawaited(prewarmHost());
|
||||
|
||||
if (kDebugMode) {
|
||||
print('✅ [XtreamServiceMobile] Initialized with URL: $_baseUrl');
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve and pre-connect to the panel host.
|
||||
///
|
||||
/// Opens a bare TCP/TLS socket and closes it immediately: no HTTP request is
|
||||
/// issued, so this never consumes one of the provider's connection slots,
|
||||
/// while still leaving the DNS entry cached and the route warm.
|
||||
Future<void> prewarmHost() async {
|
||||
final uri = Uri.tryParse(_baseUrl);
|
||||
if (uri == null || uri.host.isEmpty) return;
|
||||
|
||||
try {
|
||||
await DnsResolver.resolve(uri.host).timeout(
|
||||
const Duration(seconds: 3),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
} catch (_) {
|
||||
// DNS pre-resolution is best effort.
|
||||
}
|
||||
|
||||
final isSecure = uri.scheme == 'https';
|
||||
final port = uri.hasPort ? uri.port : (isSecure ? 443 : 80);
|
||||
|
||||
try {
|
||||
final socket = isSecure
|
||||
? await SecureSocket.connect(
|
||||
uri.host,
|
||||
port,
|
||||
timeout: const Duration(milliseconds: 1200),
|
||||
onBadCertificate: (_) => true,
|
||||
)
|
||||
: await Socket.connect(
|
||||
uri.host,
|
||||
port,
|
||||
timeout: const Duration(milliseconds: 1200),
|
||||
);
|
||||
socket.destroy();
|
||||
if (kDebugMode) print('🔥 [Prewarm] ${uri.host}:$port ready');
|
||||
} catch (e) {
|
||||
if (kDebugMode) print('⚠️ [Prewarm] ${uri.host}:$port failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get live channels for a category (with batch EPG support)
|
||||
/// If categoryId is empty, fetches ALL channels from all categories
|
||||
Future<List<model.Channel>> getLiveChannels(String categoryId) async {
|
||||
@@ -98,6 +232,11 @@ class XtreamServiceMobile {
|
||||
.map((e) => model.Channel.fromJson(e))
|
||||
.toList();
|
||||
|
||||
// Keep the EPG-matching keys reachable from a bare streamId.
|
||||
for (final channel in channels) {
|
||||
_channelIndex[channel.streamId] = channel;
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
final categories = channels.map((c) => c.categoryName).toSet();
|
||||
print(
|
||||
@@ -195,6 +334,7 @@ class XtreamServiceMobile {
|
||||
|
||||
// Load EPG for each stream_id individually (API doesn't support true batch)
|
||||
for (final streamId in streamIds) {
|
||||
await _epgGate.acquire();
|
||||
try {
|
||||
final response = await _dio
|
||||
.get(
|
||||
@@ -244,6 +384,22 @@ class XtreamServiceMobile {
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) print('⚠️ EPG failed for $streamId: $e');
|
||||
} finally {
|
||||
_epgGate.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fallback: whatever the provider could not answer for ──────────────
|
||||
final missing =
|
||||
streamIds.where((id) => !result.containsKey(id)).toList(growable: false);
|
||||
if (missing.isNotEmpty) {
|
||||
final recovered = await _resolveFromXmltv(missing);
|
||||
result.addAll(recovered);
|
||||
if (kDebugMode && recovered.isNotEmpty) {
|
||||
print(
|
||||
'📺 XMLTV fallback recovered ${recovered.length}/${missing.length} '
|
||||
'channels via "${_xmltv.loadedSourceLabel}"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +409,59 @@ class XtreamServiceMobile {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// XMLTV sources to try, in the order requested: the provider's own dump
|
||||
/// first (it is often populated even when `get_short_epg` is not), then the
|
||||
/// user's URL, then the community guides.
|
||||
List<XmltvSource> _xmltvSources() {
|
||||
return [
|
||||
XmltvSource(
|
||||
label: 'Fournisseur (xmltv.php)',
|
||||
url: '$_baseUrl/xmltv.php?username=$_username&password=$_password',
|
||||
),
|
||||
if (_customXmltvUrl.isNotEmpty)
|
||||
XmltvSource(label: 'URL personnalisée', url: _customXmltvUrl),
|
||||
if (_allowPublicXmltv) ...kDefaultPublicXmltvSources,
|
||||
];
|
||||
}
|
||||
|
||||
/// Resolve missing EPG entries from the XMLTV index, loading it on demand.
|
||||
Future<Map<String, model.ShortEPG>> _resolveFromXmltv(
|
||||
List<String> streamIds,
|
||||
) async {
|
||||
if (_xmltvExhausted) return {};
|
||||
|
||||
// Nothing to match on: the channel list has not been loaded in this
|
||||
// session, so neither epg_channel_id nor the name is known.
|
||||
final known = streamIds
|
||||
.map((id) => _channelIndex[id])
|
||||
.whereType<model.Channel>()
|
||||
.toList();
|
||||
if (known.isEmpty) return {};
|
||||
|
||||
try {
|
||||
final loaded = await _xmltv.ensureLoaded(_xmltvSources());
|
||||
if (!loaded) {
|
||||
_xmltvExhausted = true;
|
||||
return {};
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) print('⚠️ XMLTV load failed: $e');
|
||||
_xmltvExhausted = true;
|
||||
return {};
|
||||
}
|
||||
|
||||
final resolved = <String, model.ShortEPG>{};
|
||||
for (final channel in known) {
|
||||
final epg = _xmltv.shortEpgFor(
|
||||
streamId: channel.streamId,
|
||||
epgChannelId: channel.epgChannelId,
|
||||
channelName: channel.name,
|
||||
);
|
||||
if (epg != null) resolved[channel.streamId] = epg;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// Get live TV categories
|
||||
Future<List<model.Category>> getLiveCategories() async {
|
||||
try {
|
||||
@@ -530,6 +739,13 @@ class XtreamServiceMobile {
|
||||
_inFlightBatches.clear();
|
||||
}
|
||||
|
||||
/// Drop the XMLTV index (keeps its disk cache) and allow a fresh attempt.
|
||||
void resetXmltv() {
|
||||
_xmltv.clear();
|
||||
_xmltvExhausted = false;
|
||||
_epgBatchCache.clear();
|
||||
}
|
||||
|
||||
/// Get Live Stream URL
|
||||
String getLiveStreamUrl(String streamId) {
|
||||
return '$_baseUrl/live/$_username/$_password/$streamId.ts';
|
||||
@@ -564,5 +780,8 @@ class XtreamServiceMobile {
|
||||
/// Dispose service resources
|
||||
void dispose() {
|
||||
clearCache();
|
||||
_channelIndex.clear();
|
||||
_xmltv.clear();
|
||||
_dio.close(force: true);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user