mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-24 07:28:05 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6812a4964d | ||
|
|
fdf98f9659 | ||
|
|
6212948ea5 |
No files matched your search
@@ -48,17 +48,31 @@ Future<void> _claimUpstream(PlaylistConfig playlist, String id) async {
|
|||||||
await Future<void>.delayed(const Duration(seconds: 1));
|
await Future<void>.delayed(const Duration(seconds: 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Délai sans consommation au-delà duquel un flux est tenu pour orphelin.
|
||||||
|
///
|
||||||
|
/// Un lecteur HLS redemande sa playlist toutes les 2 à 4 s ; un flux
|
||||||
|
/// `turbo.ts` reçoit au moins une rafale toutes les ~7 s même sur un panneau
|
||||||
|
/// qui livre par à-coups. 15 s laissent une marge à ces deux rythmes.
|
||||||
|
const _viewerGrace = Duration(seconds: 15);
|
||||||
|
|
||||||
/// Inscrit [process] comme connexion amont de [playlist] tant qu'il vit.
|
/// Inscrit [process] comme connexion amont de [playlist] tant qu'il vit.
|
||||||
|
///
|
||||||
|
/// [lastActivity] : dernier signe de vie du spectateur ; la connexion n'est
|
||||||
|
/// coupée pour en ouvrir une autre que s'il remonte à plus de
|
||||||
|
/// [_viewerGrace].
|
||||||
void _registerUpstream(
|
void _registerUpstream(
|
||||||
PlaylistConfig playlist,
|
PlaylistConfig playlist,
|
||||||
String id,
|
String id,
|
||||||
Process process, {
|
Process process, {
|
||||||
required void Function() release,
|
required void Function() release,
|
||||||
|
required DateTime Function() lastActivity,
|
||||||
}) {
|
}) {
|
||||||
final token = _upstreamSlots.register(
|
final token = _upstreamSlots.register(
|
||||||
id: id,
|
id: id,
|
||||||
account: accountKeyOf(playlist),
|
account: accountKeyOf(playlist),
|
||||||
release: release,
|
release: release,
|
||||||
|
isActive: () =>
|
||||||
|
DateTime.now().difference(lastActivity()) < _viewerGrace,
|
||||||
);
|
);
|
||||||
process.exitCode.then((_) => _upstreamSlots.unregister(id, token: token));
|
process.exitCode.then((_) => _upstreamSlots.unregister(id, token: token));
|
||||||
}
|
}
|
||||||
@@ -99,6 +113,16 @@ String _sanitizeQuality(String? quality) {
|
|||||||
|
|
||||||
bool _isValidStreamId(String id) => RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(id);
|
bool _isValidStreamId(String id) => RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(id);
|
||||||
|
|
||||||
|
/// Extension de conteneur d'un film/épisode, validée (elle finit dans l'URL
|
||||||
|
/// du panneau). `mkv` par défaut : c'était l'unique valeur jusqu'ici, les
|
||||||
|
/// anciens clients qui n'envoient pas `ext` gardent leur comportement.
|
||||||
|
String vodContainerExtension(String? ext) {
|
||||||
|
final value = ext?.toLowerCase();
|
||||||
|
return value != null && RegExp(r'^[a-z0-9]{2,5}$').hasMatch(value)
|
||||||
|
? value
|
||||||
|
: 'mkv';
|
||||||
|
}
|
||||||
|
|
||||||
/// Video encoding args for the requested quality (live profile).
|
/// Video encoding args for the requested quality (live profile).
|
||||||
List<String> _liveVideoArgs(String quality, bool gpu) {
|
List<String> _liveVideoArgs(String quality, bool gpu) {
|
||||||
switch (quality) {
|
switch (quality) {
|
||||||
@@ -264,6 +288,8 @@ Future<_SessionAttempt> _runSession({
|
|||||||
id,
|
id,
|
||||||
session.process,
|
session.process,
|
||||||
release: () => sessionManager.killSession(id),
|
release: () => sessionManager.killSession(id),
|
||||||
|
// Touchée à chaque requête de playlist ou de segment.
|
||||||
|
lastActivity: () => session.lastAccess,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final outcome =
|
final outcome =
|
||||||
@@ -570,11 +596,16 @@ Handler createLiveStreamHandler(
|
|||||||
return Response(503, body: 'FFmpeg start failed');
|
return Response(503, body: 'FFmpeg start failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dernier octet remis au client : un lecteur en vie en reçoit à chaque
|
||||||
|
// rafale ; un client parti (zap mal détecté derrière le proxy) n'en
|
||||||
|
// reçoit plus, la contre-pression bloquant FFmpeg.
|
||||||
|
var lastDelivered = DateTime.now();
|
||||||
_registerUpstream(
|
_registerUpstream(
|
||||||
playlist,
|
playlist,
|
||||||
upstreamId,
|
upstreamId,
|
||||||
process,
|
process,
|
||||||
release: () => process.kill(ProcessSignal.sigterm),
|
release: () => process.kill(ProcessSignal.sigterm),
|
||||||
|
lastActivity: () => lastDelivered,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Journaliser les erreurs FFmpeg (redactées) sans bloquer le flux.
|
// Journaliser les erreurs FFmpeg (redactées) sans bloquer le flux.
|
||||||
@@ -592,7 +623,10 @@ Handler createLiveStreamHandler(
|
|||||||
final body = pipeWithBackpressure(
|
final body = pipeWithBackpressure(
|
||||||
process.stdout,
|
process.stdout,
|
||||||
onStop: () => process.kill(ProcessSignal.sigterm),
|
onStop: () => process.kill(ProcessSignal.sigterm),
|
||||||
);
|
).map((chunk) {
|
||||||
|
lastDelivered = DateTime.now();
|
||||||
|
return chunk;
|
||||||
|
});
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
200,
|
200,
|
||||||
@@ -680,8 +714,11 @@ Handler createVodStreamHandler(
|
|||||||
final contentType = request.url.queryParameters['type'] ?? 'movie';
|
final contentType = request.url.queryParameters['type'] ?? 'movie';
|
||||||
final basePath = contentType == 'series' ? 'series' : 'movie';
|
final basePath = contentType == 'series' ? 'series' : 'movie';
|
||||||
|
|
||||||
|
// Extension réelle du fichier (`container_extension` du catalogue) : le
|
||||||
|
// panneau refuse un `.mkv` imposé à un film stocké en `.mp4`.
|
||||||
final targetUrl =
|
final targetUrl =
|
||||||
'${playlist.dns}/$basePath/${playlist.username}/${playlist.password}/$streamId.mkv';
|
'${playlist.dns}/$basePath/${playlist.username}/${playlist.password}/'
|
||||||
|
'$streamId.${vodContainerExtension(request.url.queryParameters['ext'])}';
|
||||||
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
|
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
|
||||||
final sessionId = 'vod_${streamId}_$quality';
|
final sessionId = 'vod_${streamId}_$quality';
|
||||||
|
|
||||||
@@ -751,10 +788,15 @@ Handler createVodStreamHandler(
|
|||||||
(Request request, String streamId) async {
|
(Request request, String streamId) async {
|
||||||
final quality =
|
final quality =
|
||||||
_sanitizeQuality(request.url.queryParameters['quality']);
|
_sanitizeQuality(request.url.queryParameters['quality']);
|
||||||
final query = request.url.queryParameters['type'] != null
|
final params = request.url.queryParameters;
|
||||||
? '?type=${request.url.queryParameters['type']}'
|
final query = Uri(queryParameters: {
|
||||||
: '';
|
if (params['type'] != null) 'type': params['type']!,
|
||||||
return Response.found('/api/vod/$streamId/$quality/playlist.m3u8$query');
|
if (params['ext'] != null) 'ext': params['ext']!,
|
||||||
|
}).query;
|
||||||
|
return Response.found(
|
||||||
|
'/api/vod/$streamId/$quality/playlist.m3u8'
|
||||||
|
'${query.isEmpty ? '' : '?$query'}',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route: /api/vod/{streamId}/{quality}/{segment}
|
// Route: /api/vod/{streamId}/{quality}/{segment}
|
||||||
|
|||||||
+25
-1
@@ -27,6 +27,7 @@ import 'middleware/auth_middleware.dart';
|
|||||||
import 'middleware/security_middleware.dart';
|
import 'middleware/security_middleware.dart';
|
||||||
import 'services/cleanup_service.dart';
|
import 'services/cleanup_service.dart';
|
||||||
import 'services/recording_scheduler.dart';
|
import 'services/recording_scheduler.dart';
|
||||||
|
import 'utils/asset_versioning.dart';
|
||||||
|
|
||||||
void main(List<String> args) async {
|
void main(List<String> args) async {
|
||||||
// Parse command line arguments
|
// Parse command line arguments
|
||||||
@@ -293,14 +294,37 @@ void main(List<String> args) async {
|
|||||||
listDirectories: false,
|
listDirectories: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Points d'entrée réécrits avec `?v=<empreinte>` sur leurs JS : voir
|
||||||
|
// asset_versioning.dart (un proxy qui cache les .js servait l'ancienne
|
||||||
|
// app et l'ancien lecteur après chaque déploiement).
|
||||||
|
final versionedEntryPoints = buildVersionedEntryPoints(webPath);
|
||||||
|
print('[Server] Assets versionnés : ${versionedEntryPoints.keys.join(', ')}');
|
||||||
|
|
||||||
// Wrap static handler to enforce cache policies
|
// Wrap static handler to enforce cache policies
|
||||||
FutureOr<Response> staticHandler(Request request) async {
|
FutureOr<Response> staticHandler(Request request) async {
|
||||||
|
final path = request.url.path;
|
||||||
|
|
||||||
|
final versioned =
|
||||||
|
versionedEntryPoints[path.isEmpty ? 'index.html' : path];
|
||||||
|
if (versioned != null && request.method == 'GET') {
|
||||||
|
return Response.ok(
|
||||||
|
versioned,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': path.endsWith('.js')
|
||||||
|
? 'text/javascript; charset=utf-8'
|
||||||
|
: 'text/html; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Expires': '0',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final response = await baseStaticHandler(request);
|
final response = await baseStaticHandler(request);
|
||||||
|
|
||||||
// Disable cache for entry points to ensure updates are seen immediately.
|
// Disable cache for entry points to ensure updates are seen immediately.
|
||||||
// Vendored player libraries (hls.js/mpegts.js, ~750 KB) are pinned
|
// Vendored player libraries (hls.js/mpegts.js, ~750 KB) are pinned
|
||||||
// versions: keep them cacheable or every player open re-downloads them.
|
// versions: keep them cacheable or every player open re-downloads them.
|
||||||
final path = request.url.path;
|
|
||||||
final isVendored = path.startsWith('vendor/');
|
final isVendored = path.startsWith('vendor/');
|
||||||
if (!isVendored &&
|
if (!isVendored &&
|
||||||
(path.isEmpty ||
|
(path.isEmpty ||
|
||||||
|
|||||||
@@ -15,10 +15,18 @@ import '../models/playlist_config.dart';
|
|||||||
/// le panneau répondait `HTTP 551` ou ne répondait pas (timeout), d'où les
|
/// le panneau répondait `HTTP 551` ou ne répondait pas (timeout), d'où les
|
||||||
/// films « indisponibles » et les zaps qui échouent.
|
/// films « indisponibles » et les zaps qui échouent.
|
||||||
///
|
///
|
||||||
/// Règle : le dernier flux demandé gagne. Avant d'ouvrir une nouvelle
|
/// Règle : avant d'ouvrir une nouvelle connexion amont, on coupe les
|
||||||
/// connexion amont, on coupe les plus anciennes du même compte jusqu'à lui
|
/// connexions ORPHELINES du même compte (plus personne ne les consomme),
|
||||||
/// faire de la place. Les enregistrements n'y sont pas inscrits : ils ne
|
/// les plus anciennes d'abord, jusqu'à lui faire de la place.
|
||||||
/// sont jamais coupés.
|
///
|
||||||
|
/// Un flux encore regardé n'est jamais coupé. Une première version coupait
|
||||||
|
/// sans distinction (« le dernier gagne ») : deux lecteurs ouverts sur un
|
||||||
|
/// compte à une connexion se coupaient alors en boucle, chacun relançant
|
||||||
|
/// aussitôt — plus aucune image nulle part (constaté en prod). Le second
|
||||||
|
/// lecteur est désormais refusé par le panneau, comme avant, et le premier
|
||||||
|
/// continue.
|
||||||
|
///
|
||||||
|
/// Les enregistrements n'y sont pas inscrits : ils ne sont jamais coupés.
|
||||||
class UpstreamSlots {
|
class UpstreamSlots {
|
||||||
final DateTime Function() _now;
|
final DateTime Function() _now;
|
||||||
final Map<String, _Slot> _slots = {};
|
final Map<String, _Slot> _slots = {};
|
||||||
@@ -31,13 +39,17 @@ class UpstreamSlots {
|
|||||||
/// Renvoie un jeton à repasser à [unregister] : une session relancée sous
|
/// Renvoie un jeton à repasser à [unregister] : une session relancée sous
|
||||||
/// le même identifiant ne doit pas être désinscrite par la fin de
|
/// le même identifiant ne doit pas être désinscrite par la fin de
|
||||||
/// l'ancien processus.
|
/// l'ancien processus.
|
||||||
|
///
|
||||||
|
/// [isActive] : vrai tant qu'un spectateur consomme le flux. Une
|
||||||
|
/// connexion active n'est jamais coupée par [makeRoom].
|
||||||
int register({
|
int register({
|
||||||
required String id,
|
required String id,
|
||||||
required String account,
|
required String account,
|
||||||
required void Function() release,
|
required void Function() release,
|
||||||
|
bool Function()? isActive,
|
||||||
}) {
|
}) {
|
||||||
final token = ++_nextToken;
|
final token = ++_nextToken;
|
||||||
_slots[id] = _Slot(token, account, _now(), release);
|
_slots[id] = _Slot(token, account, _now(), release, isActive);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,14 +72,16 @@ class UpstreamSlots {
|
|||||||
if (max <= 0) return const [];
|
if (max <= 0) return const [];
|
||||||
final others = _slots.entries
|
final others = _slots.entries
|
||||||
.where((e) => e.value.account == account && e.key != keep)
|
.where((e) => e.value.account == account && e.key != keep)
|
||||||
.toList()
|
.toList();
|
||||||
..sort((a, b) => a.value.since.compareTo(b.value.since));
|
|
||||||
|
|
||||||
final excess = others.length - (max - 1);
|
final excess = others.length - (max - 1);
|
||||||
if (excess <= 0) return const [];
|
if (excess <= 0) return const [];
|
||||||
|
|
||||||
|
final orphans = others.where((e) => !e.value.active).toList()
|
||||||
|
..sort((a, b) => a.value.since.compareTo(b.value.since));
|
||||||
|
|
||||||
final freed = <String>[];
|
final freed = <String>[];
|
||||||
for (final entry in others.take(excess)) {
|
for (final entry in orphans.take(excess)) {
|
||||||
_slots.remove(entry.key);
|
_slots.remove(entry.key);
|
||||||
try {
|
try {
|
||||||
entry.value.release();
|
entry.value.release();
|
||||||
@@ -83,7 +97,16 @@ class _Slot {
|
|||||||
final String account;
|
final String account;
|
||||||
final DateTime since;
|
final DateTime since;
|
||||||
final void Function() release;
|
final void Function() release;
|
||||||
_Slot(this.token, this.account, this.since, this.release);
|
final bool Function()? isActive;
|
||||||
|
_Slot(this.token, this.account, this.since, this.release, this.isActive);
|
||||||
|
|
||||||
|
bool get active {
|
||||||
|
try {
|
||||||
|
return isActive?.call() ?? false;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clé de compte : deux playlists sur les mêmes identifiants partagent le
|
/// Clé de compte : deux playlists sur les mêmes identifiants partagent le
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:test/test.dart';
|
||||||
|
import '../utils/asset_versioning.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('versionAssetRefs', () {
|
||||||
|
test('versionne la balise script du lecteur', () {
|
||||||
|
expect(
|
||||||
|
versionAssetRefs(
|
||||||
|
'<script src="xf-player-core.js"></script>',
|
||||||
|
{'xf-player-core.js': 'abc123'},
|
||||||
|
),
|
||||||
|
'<script src="xf-player-core.js?v=abc123"></script>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('versionne mainJsPath dans la config de build Flutter', () {
|
||||||
|
expect(
|
||||||
|
versionAssetRefs(
|
||||||
|
'{"compileTarget":"dart2js","mainJsPath":"main.dart.js"}',
|
||||||
|
{'main.dart.js': 'f00'},
|
||||||
|
),
|
||||||
|
'{"compileTarget":"dart2js","mainJsPath":"main.dart.js?v=f00"}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ne touche ni un nom qui ne fait que contenir l\'asset ni une ref déjà versionnée', () {
|
||||||
|
const html = '<script src="vendor/xf-player-core.js"></script>'
|
||||||
|
'<script src="xf-player-core.js?v=old"></script>';
|
||||||
|
expect(versionAssetRefs(html, {'xf-player-core.js': 'new'}), html);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guillemets simples acceptés', () {
|
||||||
|
expect(
|
||||||
|
versionAssetRefs("<link href='xf-player.css'>", {'xf-player.css': '1'}),
|
||||||
|
"<link href='xf-player.css?v=1'>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('contentVersion', () {
|
||||||
|
test('stable pour un même contenu, différente sinon', () {
|
||||||
|
expect(contentVersion([1, 2, 3]), contentVersion([1, 2, 3]));
|
||||||
|
expect(contentVersion([1, 2, 3]), isNot(contentVersion([1, 2, 4])));
|
||||||
|
expect(contentVersion([1, 2, 3]), hasLength(12));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -53,6 +53,30 @@ void main() {
|
|||||||
expect(slots.countFor('acc'), 1);
|
expect(slots.countFor('acc'), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('un flux encore regardé n\'est jamais coupé (pas de ping-pong entre deux lecteurs)', () {
|
||||||
|
slots.register(
|
||||||
|
id: 'watched',
|
||||||
|
account: 'acc',
|
||||||
|
release: () => released.add('watched'),
|
||||||
|
isActive: () => true,
|
||||||
|
);
|
||||||
|
expect(slots.makeRoom('acc', max: 1, keep: 'new'), isEmpty);
|
||||||
|
expect(released, isEmpty);
|
||||||
|
expect(slots.countFor('acc'), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seuls les orphelins partent, même plus récents qu\'un flux regardé', () {
|
||||||
|
slots.register(
|
||||||
|
id: 'watched',
|
||||||
|
account: 'acc',
|
||||||
|
release: () => released.add('watched'),
|
||||||
|
isActive: () => true,
|
||||||
|
);
|
||||||
|
add('orphan', 'acc');
|
||||||
|
expect(slots.makeRoom('acc', max: 1, keep: 'new'), ['orphan']);
|
||||||
|
expect(released, ['orphan']);
|
||||||
|
});
|
||||||
|
|
||||||
test('quota inconnu ou nul : rien n\'est coupé', () {
|
test('quota inconnu ou nul : rien n\'est coupé', () {
|
||||||
add('a', 'acc');
|
add('a', 'acc');
|
||||||
expect(slots.makeRoom('acc', max: 0, keep: 'new'), isEmpty);
|
expect(slots.makeRoom('acc', max: 0, keep: 'new'), isEmpty);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:test/test.dart';
|
||||||
|
import '../api/streaming_handler.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('vodContainerExtension', () {
|
||||||
|
test('garde l\'extension réelle du catalogue', () {
|
||||||
|
expect(vodContainerExtension('mp4'), 'mp4');
|
||||||
|
expect(vodContainerExtension('MKV'), 'mkv');
|
||||||
|
expect(vodContainerExtension('avi'), 'avi');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ancien client sans ext : mkv, comme avant', () {
|
||||||
|
expect(vodContainerExtension(null), 'mkv');
|
||||||
|
expect(vodContainerExtension(''), 'mkv');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuse tout ce qui pourrait sortir du nom de fichier', () {
|
||||||
|
expect(vodContainerExtension('mp4/../x'), 'mkv');
|
||||||
|
expect(vodContainerExtension('mp4?a=b'), 'mkv');
|
||||||
|
expect(vodContainerExtension('toolongext'), 'mkv');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
||||||
|
/// Versionnage des assets non hachés de la build web.
|
||||||
|
///
|
||||||
|
/// POURQUOI : `main.dart.js`, `flutter_bootstrap.js` et `xf-player-core.js`
|
||||||
|
/// gardent le même nom d'une build à l'autre. Un reverse proxy qui met les
|
||||||
|
/// `.js` en cache (option « Cache Assets » de Nginx Proxy Manager, constatée
|
||||||
|
/// en prod : cache jusqu'à 12 h, même sur un rechargement forcé) servait donc
|
||||||
|
/// l'ANCIEN lecteur et l'ANCIENNE app avec le NOUVEAU serveur après chaque
|
||||||
|
/// déploiement. En ajoutant `?v=<empreinte du contenu>` aux références,
|
||||||
|
/// chaque version a sa propre URL : aucun cache intermédiaire ne peut la
|
||||||
|
/// confondre avec la précédente.
|
||||||
|
|
||||||
|
/// Empreinte courte et stable de [bytes].
|
||||||
|
String contentVersion(List<int> bytes) =>
|
||||||
|
md5.convert(bytes).toString().substring(0, 12);
|
||||||
|
|
||||||
|
/// Ajoute `?v=<version>` à chaque référence entre guillemets à un asset de
|
||||||
|
/// [versions] (`"main.dart.js"` → `"main.dart.js?v=…"`).
|
||||||
|
///
|
||||||
|
/// Seules les références exactes sont réécrites : `vendor/x.js` ou une
|
||||||
|
/// référence déjà versionnée restent intactes.
|
||||||
|
String versionAssetRefs(String content, Map<String, String> versions) {
|
||||||
|
var out = content;
|
||||||
|
versions.forEach((asset, version) {
|
||||||
|
final pattern = RegExp('(["\'])${RegExp.escape(asset)}\\1');
|
||||||
|
out = out.replaceAllMapped(
|
||||||
|
pattern,
|
||||||
|
(m) => '${m[1]}$asset?v=$version${m[1]}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contenus réécrits, indexés par chemin relatif (`index.html`…), prêts à
|
||||||
|
/// être servis à la place des fichiers d'origine.
|
||||||
|
///
|
||||||
|
/// Calculé une fois au démarrage : la build web est figée dans l'image.
|
||||||
|
Map<String, String> buildVersionedEntryPoints(String webPath) {
|
||||||
|
String? read(String name) {
|
||||||
|
final file = File('$webPath/$name');
|
||||||
|
return file.existsSync() ? file.readAsStringSync() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? versionOf(String name) {
|
||||||
|
final file = File('$webPath/$name');
|
||||||
|
return file.existsSync() ? contentVersion(file.readAsBytesSync()) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final rewritten = <String, String>{};
|
||||||
|
|
||||||
|
// Le bootstrap référence main.dart.js : sa version dépend donc de celle
|
||||||
|
// de main.dart.js, d'où une version calculée APRÈS réécriture.
|
||||||
|
final bootstrap = read('flutter_bootstrap.js');
|
||||||
|
final mainVersion = versionOf('main.dart.js');
|
||||||
|
if (bootstrap != null && mainVersion != null) {
|
||||||
|
final versioned =
|
||||||
|
versionAssetRefs(bootstrap, {'main.dart.js': mainVersion});
|
||||||
|
rewritten['flutter_bootstrap.js'] = versioned;
|
||||||
|
final index = read('index.html');
|
||||||
|
if (index != null) {
|
||||||
|
rewritten['index.html'] = versionAssetRefs(index, {
|
||||||
|
'flutter_bootstrap.js': contentVersion(utf8.encode(versioned)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final playerAssets = <String, String>{
|
||||||
|
for (final name in const ['xf-player-core.js', 'xf-player.css'])
|
||||||
|
if (versionOf(name) case final v?) name: v,
|
||||||
|
};
|
||||||
|
for (final page in const [
|
||||||
|
'player.html',
|
||||||
|
'player_lite.html',
|
||||||
|
'player_mobile.html',
|
||||||
|
]) {
|
||||||
|
final html = read(page);
|
||||||
|
if (html != null) rewritten[page] = versionAssetRefs(html, playerAssets);
|
||||||
|
}
|
||||||
|
return rewritten;
|
||||||
|
}
|
||||||
@@ -191,7 +191,16 @@ class XtreamService {
|
|||||||
String quality = 'high',
|
String quality = 'high',
|
||||||
}) {
|
}) {
|
||||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||||
return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8';
|
// `ext` : le serveur demande le fichier au panneau sous son extension
|
||||||
|
// réelle — un `.mp4` réclamé en `.mkv` est refusé (HTTP 551).
|
||||||
|
return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8'
|
||||||
|
'${_extQuery(containerExtension, first: true)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _extQuery(String ext, {required bool first}) {
|
||||||
|
final value = ext.trim().toLowerCase();
|
||||||
|
if (value.isEmpty) return '';
|
||||||
|
return '${first ? '?' : '&'}ext=${Uri.encodeQueryComponent(value)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate stream URL for series episodes
|
/// Generate stream URL for series episodes
|
||||||
@@ -201,7 +210,8 @@ class XtreamService {
|
|||||||
String quality = 'high',
|
String quality = 'high',
|
||||||
}) {
|
}) {
|
||||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||||
return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8?type=series';
|
return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8?type=series'
|
||||||
|
'${_extQuery(containerExtension, first: false)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authenticate and get server info
|
/// Authenticate and get server info
|
||||||
|
|||||||
Reference in new issue
Block a user