Files
xtremobile/test/xmltv_service_test.dart
T
MichaelandClaude Opus 5 8d74bbb320 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>
2026-08-11 19:25:33 +02:00

217 lines
7.2 KiB
Dart

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);
});
});
}