Compare commits

..
Author SHA1 Message Date
MichaelandClaude Opus 5.5 fdf98f9659 Films : demander le fichier sous sa vraie extension
Le serveur réclamait toujours <id>.mkv au panneau. Un film stocké en .mp4
était refusé (HTTP 551 constaté en prod). Le client transmet désormais
container_extension (?ext=), validé côté serveur ; mkv reste le défaut
pour un client qui ne l'envoie pas. Même approche que xtremobile, qui lit
ces films sans souci.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:09:11 +02:00
MichaelandClaude Opus 5.5 6212948ea5 Déploiement : versionner les JS pour contourner le cache du proxy
main.dart.js, flutter_bootstrap.js et xf-player-core.js gardent le même
nom d'une build à l'autre. En prod, Nginx Proxy Manager les met en cache
(Cache Assets) jusqu'à 12 h, même sur rechargement forcé : après le
dernier déploiement, l'ancienne app et l'ancien lecteur tournaient avec
le nouveau serveur.

Au démarrage, le serveur réécrit index.html, flutter_bootstrap.js et
player*.html pour référencer ces fichiers avec ?v=<empreinte du contenu> :
chaque version a sa propre URL, aucun cache ne peut les confondre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:09:11 +02:00
6 changed files with 214 additions and 8 deletions

No files matched your search

+23 -5
View File
@@ -99,6 +99,16 @@ String _sanitizeQuality(String? quality) {
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).
List<String> _liveVideoArgs(String quality, bool gpu) {
switch (quality) {
@@ -680,8 +690,11 @@ Handler createVodStreamHandler(
final contentType = request.url.queryParameters['type'] ?? '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 =
'${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 sessionId = 'vod_${streamId}_$quality';
@@ -751,10 +764,15 @@ Handler createVodStreamHandler(
(Request request, String streamId) async {
final quality =
_sanitizeQuality(request.url.queryParameters['quality']);
final query = request.url.queryParameters['type'] != null
? '?type=${request.url.queryParameters['type']}'
: '';
return Response.found('/api/vod/$streamId/$quality/playlist.m3u8$query');
final params = request.url.queryParameters;
final query = Uri(queryParameters: {
if (params['type'] != null) 'type': params['type']!,
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}
+25 -1
View File
@@ -27,6 +27,7 @@ import 'middleware/auth_middleware.dart';
import 'middleware/security_middleware.dart';
import 'services/cleanup_service.dart';
import 'services/recording_scheduler.dart';
import 'utils/asset_versioning.dart';
void main(List<String> args) async {
// Parse command line arguments
@@ -293,14 +294,37 @@ void main(List<String> args) async {
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
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);
// Disable cache for entry points to ensure updates are seen immediately.
// Vendored player libraries (hls.js/mpegts.js, ~750 KB) are pinned
// versions: keep them cacheable or every player open re-downloads them.
final path = request.url.path;
final isVendored = path.startsWith('vendor/');
if (!isVendored &&
(path.isEmpty ||
+47
View File
@@ -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));
});
});
}
+23
View File
@@ -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');
});
});
}
+84
View File
@@ -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;
}
+12 -2
View File
@@ -191,7 +191,16 @@ class XtreamService {
String quality = 'high',
}) {
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
@@ -201,7 +210,8 @@ class XtreamService {
String quality = 'high',
}) {
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