mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +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
@@ -0,0 +1,216 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xtremobile/features/iptv/services/xmltv_service.dart';
|
||||
|
||||
/// Fixture shaped exactly like the community dumps the fallback consumes
|
||||
/// (channel ids with dots, `+0200` offsets, base-less titles).
|
||||
String _fixture(DateTime now) {
|
||||
String stamp(DateTime value) {
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
final utc = value.toUtc();
|
||||
return '${utc.year}${two(utc.month)}${two(utc.day)}'
|
||||
'${two(utc.hour)}${two(utc.minute)}${two(utc.second)} +0000';
|
||||
}
|
||||
|
||||
final airingStart = now.subtract(const Duration(minutes: 20));
|
||||
final airingEnd = now.add(const Duration(minutes: 40));
|
||||
final nextEnd = airingEnd.add(const Duration(minutes: 50));
|
||||
final farFuture = now.add(const Duration(days: 4));
|
||||
|
||||
return '''<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<tv generator-info-name="none">
|
||||
<channel id="TF1.fr">
|
||||
<display-name lang="fr">TF1</display-name>
|
||||
</channel>
|
||||
<channel id="France.3.-.Nord.Pas-de-Calais.fr">
|
||||
<display-name lang="fr">France 3 - Nord Pas-de-Calais</display-name>
|
||||
</channel>
|
||||
<channel id="Cine.Premier.fr">
|
||||
<display-name lang="fr">Ciné+ Premier</display-name>
|
||||
</channel>
|
||||
<programme channel="TF1.fr" start="${stamp(airingStart)}" stop="${stamp(airingEnd)}">
|
||||
<title lang="fr">Journal de 20h</title>
|
||||
<desc lang="fr">Le rendez-vous d'information.</desc>
|
||||
</programme>
|
||||
<programme channel="TF1.fr" start="${stamp(airingEnd)}" stop="${stamp(nextEnd)}">
|
||||
<title lang="fr">Film du mardi</title>
|
||||
</programme>
|
||||
<programme channel="France.3.-.Nord.Pas-de-Calais.fr" start="${stamp(airingStart)}" stop="${stamp(airingEnd)}">
|
||||
<title lang="fr">Ici 19/20</title>
|
||||
</programme>
|
||||
<programme channel="Cine.Premier.fr" start="${stamp(farFuture)}" stop="${stamp(farFuture.add(const Duration(hours: 1)))}">
|
||||
<title lang="fr">Hors fenêtre</title>
|
||||
</programme>
|
||||
</tv>''';
|
||||
}
|
||||
|
||||
Stream<List<int>> _bytes(String xml) => Stream.value(utf8.encode(xml));
|
||||
|
||||
void main() {
|
||||
group('parseXmltvTime', () {
|
||||
test('applies the trailing UTC offset', () {
|
||||
final parsed = XmltvService.parseXmltvTime('20260811183000 +0200');
|
||||
expect(parsed, isNotNull);
|
||||
expect(
|
||||
parsed!.toUtc(),
|
||||
DateTime.utc(2026, 8, 11, 16, 30),
|
||||
reason: '18:30 at +0200 is 16:30 UTC',
|
||||
);
|
||||
});
|
||||
|
||||
test('handles a negative offset', () {
|
||||
final parsed = XmltvService.parseXmltvTime('20260811183000 -0430');
|
||||
expect(parsed!.toUtc(), DateTime.utc(2026, 8, 11, 23, 0));
|
||||
});
|
||||
|
||||
test('treats a missing offset as UTC', () {
|
||||
final parsed = XmltvService.parseXmltvTime('20260811183000');
|
||||
expect(parsed!.toUtc(), DateTime.utc(2026, 8, 11, 18, 30));
|
||||
});
|
||||
|
||||
test('rejects malformed input', () {
|
||||
expect(XmltvService.parseXmltvTime(null), isNull);
|
||||
expect(XmltvService.parseXmltvTime(''), isNull);
|
||||
expect(XmltvService.parseXmltvTime('2026081118'), isNull);
|
||||
expect(XmltvService.parseXmltvTime('not-a-timestamp!'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('normalizeName', () {
|
||||
test('collapses quality suffixes', () {
|
||||
expect(XmltvService.normalizeName('TF1 HD'), 'tf1');
|
||||
expect(XmltvService.normalizeName('tf1-fhd'), 'tf1');
|
||||
expect(XmltvService.normalizeName('TF1 4K'), 'tf1');
|
||||
expect(XmltvService.normalizeName('TF1'), 'tf1');
|
||||
});
|
||||
|
||||
test('strips country prefixes in both forms', () {
|
||||
expect(XmltvService.normalizeName('FR: TF1'), 'tf1');
|
||||
expect(XmltvService.normalizeName('|FR| TF1 HD'), 'tf1');
|
||||
expect(XmltvService.normalizeName('[FR] TF1'), 'tf1');
|
||||
});
|
||||
|
||||
test('keeps a dash-separated name intact', () {
|
||||
// "Ciné - Premier" must not lose its first word to prefix stripping.
|
||||
expect(XmltvService.normalizeName('Ciné - Premier'), 'cinepremier');
|
||||
});
|
||||
|
||||
test('folds accents and keeps the plus sign', () {
|
||||
expect(XmltvService.normalizeName('Ciné+ Émotion'), 'cine+emotion');
|
||||
});
|
||||
|
||||
test('is empty for a blank label', () {
|
||||
expect(XmltvService.normalizeName(' '), '');
|
||||
});
|
||||
});
|
||||
|
||||
group('indexFromBytes + lookup', () {
|
||||
late XmltvService service;
|
||||
late DateTime now;
|
||||
|
||||
setUp(() async {
|
||||
service = XmltvService(Directory.systemTemp.path);
|
||||
now = DateTime.now();
|
||||
final indexed = await service.indexFromBytes(
|
||||
_bytes(_fixture(now)),
|
||||
now: now,
|
||||
);
|
||||
expect(indexed, isTrue);
|
||||
});
|
||||
|
||||
test('matches on epg_channel_id', () {
|
||||
final epg = service.shortEpgFor(
|
||||
streamId: '123',
|
||||
epgChannelId: 'TF1.fr',
|
||||
channelName: 'anything',
|
||||
);
|
||||
expect(epg, isNotNull);
|
||||
expect(epg!.title, 'Journal de 20h');
|
||||
expect(epg.id, '123');
|
||||
expect(epg.description, "Le rendez-vous d'information.");
|
||||
});
|
||||
|
||||
test('falls back to the channel name when the id is unknown', () {
|
||||
final epg = service.shortEpgFor(
|
||||
streamId: '456',
|
||||
epgChannelId: '',
|
||||
channelName: 'FR: TF1 HD',
|
||||
);
|
||||
expect(epg, isNotNull);
|
||||
expect(epg!.title, 'Journal de 20h');
|
||||
});
|
||||
|
||||
test('matches a name reconstructed from a dotted channel id', () {
|
||||
final epg = service.shortEpgFor(
|
||||
streamId: '789',
|
||||
epgChannelId: 'not-in-guide',
|
||||
channelName: 'France 3 - Nord Pas-de-Calais',
|
||||
);
|
||||
expect(epg, isNotNull);
|
||||
expect(epg!.title, 'Ici 19/20');
|
||||
});
|
||||
|
||||
test('returns the currently airing programme, not the next one', () {
|
||||
final programme = service.programmeFor(
|
||||
epgChannelId: 'TF1.fr',
|
||||
at: now,
|
||||
);
|
||||
expect(programme!.title, 'Journal de 20h');
|
||||
expect(programme.covers(now), isTrue);
|
||||
});
|
||||
|
||||
test('falls forward to the next entry when nothing is airing', () {
|
||||
// 10 minutes into the gap-free schedule's second slot.
|
||||
final during = now.add(const Duration(minutes: 50));
|
||||
final programme = service.programmeFor(
|
||||
epgChannelId: 'TF1.fr',
|
||||
at: during,
|
||||
);
|
||||
expect(programme!.title, 'Film du mardi');
|
||||
});
|
||||
|
||||
test('drops programmes outside the retention window', () {
|
||||
// The Ciné+ entry is 4 days out — beyond windowForward, so the channel
|
||||
// holds no programme at all.
|
||||
final epg = service.shortEpgFor(
|
||||
streamId: '999',
|
||||
epgChannelId: 'Cine.Premier.fr',
|
||||
);
|
||||
expect(epg, isNull);
|
||||
});
|
||||
|
||||
test('returns null for a channel absent from the guide', () {
|
||||
final epg = service.shortEpgFor(
|
||||
streamId: '000',
|
||||
epgChannelId: 'unknown.channel',
|
||||
channelName: 'Chaîne Inconnue',
|
||||
);
|
||||
expect(epg, isNull);
|
||||
});
|
||||
|
||||
test('parses a gzipped payload transparently', () async {
|
||||
final gzipped = XmltvService(Directory.systemTemp.path);
|
||||
final compressed = gzip.encode(utf8.encode(_fixture(now)));
|
||||
final indexed = await gzipped.indexFromBytes(
|
||||
Stream.value(compressed),
|
||||
now: now,
|
||||
);
|
||||
expect(indexed, isTrue);
|
||||
expect(
|
||||
gzipped.shortEpgFor(streamId: '1', epgChannelId: 'TF1.fr')?.title,
|
||||
'Journal de 20h',
|
||||
);
|
||||
});
|
||||
|
||||
test('reports failure on a payload with no programme', () async {
|
||||
final empty = XmltvService(Directory.systemTemp.path);
|
||||
final indexed = await empty.indexFromBytes(
|
||||
_bytes('<?xml version="1.0"?><tv></tv>'),
|
||||
);
|
||||
expect(indexed, isFalse);
|
||||
expect(empty.isLoaded, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user