mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-20 13:38:05 +02:00
Multi-IPTV : chaque fonction utilise la playlist sélectionnée
Le serveur ignorait l'IPTV choisie : direct, VOD, guide, enregistrements, season passes et Auto NBA passaient tous par la playlist la plus récente (ou celle du premier utilisateur pour la capture FFmpeg), et les enregistrements s'affichaient sous toutes les playlists. - get_playlist respecte ?playlist=<id> (prioritaire) puis l'en-tête X-Playlist-Id, avec contrôle du propriétaire (admin : toutes). - Sessions FFmpeg HLS/VOD séparées par IPTV (même stream_id possible) ; le m3u8 propage ?playlist= aux segments, redirections et bascule .ts → HLS du lecteur web aussi. - tv_recordings, season_passes, auto_rules : colonne playlist_id (migrations v4-v6) ; lignes existantes rattachées à la playlist la plus récente de leur propriétaire ; URL de capture avec ?playlist= ; listes filtrées par IPTV ; une règle NBA par IPTV. - Proxy /api/xtream : cache d'allowlist par playlist, paramètre retiré avant l'appel au panneau ; CORS autorise X-Playlist-Id. - Client : AuthedHttp envoie X-Playlist-Id ; XtreamService ajoute ?playlist= aux flux, à l'EPG et aux appels xtream-api (cache distinct). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
29b4e11d13
commit
4f978755fd
15 files changed
+501
-70
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'dart:html' as html;
|
import 'dart:html' as html;
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
@@ -23,9 +24,27 @@ class AuthedHttp {
|
|||||||
return (t != null && t.isNotEmpty) ? t : null;
|
return (t != null && t.isNotEmpty) ? t : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// IPTV sélectionnée (persistée par `playlistPersistenceProvider`).
|
||||||
|
///
|
||||||
|
/// Envoyée à chaque appel : sans elle le serveur prenait la playlist la
|
||||||
|
/// plus récente, et guide, enregistrements et Auto NBA visaient la
|
||||||
|
/// mauvaise IPTV quand plusieurs étaient configurées.
|
||||||
|
static String? get selectedPlaylistId {
|
||||||
|
try {
|
||||||
|
final stored = html.window.localStorage['selected_playlist'];
|
||||||
|
if (stored == null) return null;
|
||||||
|
final id = (jsonDecode(stored) as Map<String, dynamic>)['id'];
|
||||||
|
return (id is String && id.isNotEmpty) ? id : null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Map<String, String> _headers([Map<String, String>? extra]) {
|
static Map<String, String> _headers([Map<String, String>? extra]) {
|
||||||
final token = _token;
|
final token = _token;
|
||||||
|
final playlistId = selectedPlaylistId;
|
||||||
return {
|
return {
|
||||||
|
if (playlistId != null) 'X-Playlist-Id': playlistId,
|
||||||
if (extra != null) ...extra,
|
if (extra != null) ...extra,
|
||||||
if (token != null) 'Authorization': 'Bearer $token',
|
if (token != null) 'Authorization': 'Bearer $token',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ class XtreamService {
|
|||||||
options.headers['Authorization'] = 'Bearer $token';
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
final playlistId = _currentPlaylist?.id;
|
||||||
|
if (playlistId != null && playlistId.isNotEmpty) {
|
||||||
|
options.headers['X-Playlist-Id'] = playlistId;
|
||||||
|
}
|
||||||
handler.next(options);
|
handler.next(options);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -115,7 +119,12 @@ class XtreamService {
|
|||||||
}) {
|
}) {
|
||||||
return _dio.get(
|
return _dio.get(
|
||||||
'$_backendBaseUrl/api/xtream-api',
|
'$_backendBaseUrl/api/xtream-api',
|
||||||
queryParameters: params,
|
// `playlist` dans l'URL : le cache HTTP (clé = URL) ne mélange pas
|
||||||
|
// les réponses de deux IPTV.
|
||||||
|
queryParameters: {
|
||||||
|
...params,
|
||||||
|
if (_playlistId != null) 'playlist': _playlistId!,
|
||||||
|
},
|
||||||
options: options ?? Options(extra: _cacheOptions.toExtra()),
|
options: options ?? Options(extra: _cacheOptions.toExtra()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -165,11 +174,27 @@ class XtreamService {
|
|||||||
_currentPlaylist = playlist;
|
_currentPlaylist = playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? get _playlistId {
|
||||||
|
final id = _currentPlaylist?.id;
|
||||||
|
return (id == null || id.isEmpty) ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute `playlist=<id>` : le serveur sert alors cette IPTV (lecteur,
|
||||||
|
/// hls.js et mpegts.js ne peuvent pas envoyer d'en-tête).
|
||||||
|
String _withPlaylist(String url) {
|
||||||
|
final id = _playlistId;
|
||||||
|
if (id == null) return url;
|
||||||
|
final sep = url.contains('?') ? '&' : '?';
|
||||||
|
return '$url${sep}playlist=${Uri.encodeQueryComponent(id)}';
|
||||||
|
}
|
||||||
|
|
||||||
/// Generate stream URL for live TV (HLS)
|
/// Generate stream URL for live TV (HLS)
|
||||||
/// [quality]: source | high | medium | low (server-side FFmpeg preset)
|
/// [quality]: source | high | medium | low (server-side FFmpeg preset)
|
||||||
String getLiveStreamUrl(String streamId, {String quality = 'high'}) {
|
String getLiveStreamUrl(String streamId, {String quality = 'high'}) {
|
||||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||||
return '$_backendBaseUrl/api/live/$streamId/$quality/playlist.m3u8';
|
return _withPlaylist(
|
||||||
|
'$_backendBaseUrl/api/live/$streamId/$quality/playlist.m3u8',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate stream URL for live TV (Direct MPEG-TS)
|
/// Generate stream URL for live TV (Direct MPEG-TS)
|
||||||
@@ -180,7 +205,7 @@ class XtreamService {
|
|||||||
// Le proxy brut ($_backendBaseUrl/api/live/$streamId.ts) livrait l'audio
|
// Le proxy brut ($_backendBaseUrl/api/live/$streamId.ts) livrait l'audio
|
||||||
// d'origine — AC-3/E-AC-3/MP2 que mpegts.js ne décode pas → chaînes
|
// d'origine — AC-3/E-AC-3/MP2 que mpegts.js ne décode pas → chaînes
|
||||||
// muettes. Il reste utilisé tel quel par le scheduler d'enregistrement.
|
// muettes. Il reste utilisé tel quel par le scheduler d'enregistrement.
|
||||||
return '$_backendBaseUrl/api/live/$streamId/turbo.ts';
|
return _withPlaylist('$_backendBaseUrl/api/live/$streamId/turbo.ts');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate stream URL for VOD (movies)
|
/// Generate stream URL for VOD (movies)
|
||||||
@@ -193,8 +218,10 @@ class XtreamService {
|
|||||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||||
// `ext` : le serveur demande le fichier au panneau sous son extension
|
// `ext` : le serveur demande le fichier au panneau sous son extension
|
||||||
// réelle — un `.mp4` réclamé en `.mkv` est refusé (HTTP 551).
|
// réelle — un `.mp4` réclamé en `.mkv` est refusé (HTTP 551).
|
||||||
return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8'
|
return _withPlaylist(
|
||||||
'${_extQuery(containerExtension, first: true)}';
|
'$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8'
|
||||||
|
'${_extQuery(containerExtension, first: true)}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _extQuery(String ext, {required bool first}) {
|
String _extQuery(String ext, {required bool first}) {
|
||||||
@@ -210,8 +237,10 @@ 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 _withPlaylist(
|
||||||
'${_extQuery(containerExtension, first: false)}';
|
'$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8?type=series'
|
||||||
|
'${_extQuery(containerExtension, first: false)}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authenticate and get server info
|
/// Authenticate and get server info
|
||||||
@@ -749,14 +778,18 @@ class XtreamService {
|
|||||||
await Future.delayed(_throttleDelay);
|
await Future.delayed(_throttleDelay);
|
||||||
}
|
}
|
||||||
|
|
||||||
final cached = _epgCache[streamId];
|
// Cache partagé entre instances : la clé inclut l'IPTV.
|
||||||
|
final cacheKey = '${_playlistId ?? ''}|$streamId';
|
||||||
|
final cached = _epgCache[cacheKey];
|
||||||
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
|
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
|
||||||
return cached.programmes;
|
return cached.programmes;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _epgGate.acquire();
|
await _epgGate.acquire();
|
||||||
try {
|
try {
|
||||||
final response = await AuthedHttp.get(Uri.parse('/api/epg/$streamId'));
|
final response = await AuthedHttp.get(
|
||||||
|
Uri.parse(_withPlaylist('/api/epg/$streamId')),
|
||||||
|
);
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[XtreamService] EPG $streamId : HTTP ${response.statusCode}',
|
'[XtreamService] EPG $streamId : HTTP ${response.statusCode}',
|
||||||
@@ -779,7 +812,7 @@ class XtreamService {
|
|||||||
if (result.length >= 8) break;
|
if (result.length >= 8) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
_epgCache[streamId] = _EpgCacheEntry(
|
_epgCache[cacheKey] = _EpgCacheEntry(
|
||||||
programmes: result,
|
programmes: result,
|
||||||
expiresAt: DateTime.now().add(_epgCacheTtl),
|
expiresAt: DateTime.now().add(_epgCacheTtl),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ pub async fn auto_rules(state: &SharedState, req: &Req, rest: &str) -> Resp {
|
|||||||
};
|
};
|
||||||
let parts = segments(rest);
|
let parts = segments(rest);
|
||||||
if let ("GET" | "HEAD", []) = (req.method.as_str(), parts.as_slice()) {
|
if let ("GET" | "HEAD", []) = (req.method.as_str(), parts.as_slice()) {
|
||||||
let rules = state.db.get_auto_rules_for_user(&user.id);
|
// Une règle NBA par IPTV : celle de la playlist choisie.
|
||||||
|
let rules = match state.selected_playlist_id(req) {
|
||||||
|
Some(pid) => state.db.get_auto_rules_for_playlist(&user.id, &pid),
|
||||||
|
None => state.db.get_auto_rules_for_user(&user.id),
|
||||||
|
};
|
||||||
return json(200, &json!(rules));
|
return json(200, &json!(rules));
|
||||||
}
|
}
|
||||||
if let ("POST", []) = (req.method.as_str(), parts.as_slice()) {
|
if let ("POST", []) = (req.method.as_str(), parts.as_slice()) {
|
||||||
@@ -144,12 +148,15 @@ async fn create(state: &SharedState, req: &Req, user: &User) -> Resp {
|
|||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(r) => return r,
|
Err(r) => return r,
|
||||||
};
|
};
|
||||||
if state
|
let playlist_id = state
|
||||||
.db
|
.get_playlist(req)
|
||||||
.get_auto_rules_for_user(&user.id)
|
.map(|p| p.id)
|
||||||
.iter()
|
.filter(|id| !id.is_empty());
|
||||||
.any(|r| r.kind == "nba")
|
let existing = match &playlist_id {
|
||||||
{
|
Some(pid) => state.db.get_auto_rules_for_playlist(&user.id, pid),
|
||||||
|
None => state.db.get_auto_rules_for_user(&user.id),
|
||||||
|
};
|
||||||
|
if existing.iter().any(|r| r.kind == "nba") {
|
||||||
return json(409, &json!({"error": "La règle NBA existe déjà"}));
|
return json(409, &json!({"error": "La règle NBA existe déjà"}));
|
||||||
}
|
}
|
||||||
let mut rule = AutoRule {
|
let mut rule = AutoRule {
|
||||||
@@ -164,6 +171,7 @@ async fn create(state: &SharedState, req: &Req, user: &User) -> Resp {
|
|||||||
pad_after_min: 45,
|
pad_after_min: 45,
|
||||||
team_filter: Vec::new(),
|
team_filter: Vec::new(),
|
||||||
keep_days: None,
|
keep_days: None,
|
||||||
|
playlist_id: playlist_id.clone(),
|
||||||
created_at: String::new(),
|
created_at: String::new(),
|
||||||
updated_at: String::new(),
|
updated_at: String::new(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -578,6 +578,7 @@ mod tests {
|
|||||||
|
|
||||||
fn playlist(base: &str) -> PlaylistConfig {
|
fn playlist(base: &str) -> PlaylistConfig {
|
||||||
PlaylistConfig {
|
PlaylistConfig {
|
||||||
|
id: String::new(),
|
||||||
dns: base.to_string(),
|
dns: base.to_string(),
|
||||||
username: "u".into(),
|
username: "u".into(),
|
||||||
password: "p".into(),
|
password: "p".into(),
|
||||||
|
|||||||
+27
-8
@@ -157,11 +157,21 @@ const ALLOWED_RESPONSE_HEADERS: [&str; 6] = [
|
|||||||
];
|
];
|
||||||
const ALLOWED_REQUEST_HEADERS: [&str; 4] = ["user-agent", "accept", "range", "referer"];
|
const ALLOWED_REQUEST_HEADERS: [&str; 4] = ["user-agent", "accept", "range", "referer"];
|
||||||
|
|
||||||
|
/// Query sans le paramètre `playlist` (les autres sont recopiés tels quels).
|
||||||
|
fn without_playlist_param(query: &str) -> String {
|
||||||
|
query
|
||||||
|
.split('&')
|
||||||
|
.filter(|pair| !pair.is_empty() && pair.split('=').next() != Some("playlist"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("&")
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ProxyHandler {
|
pub struct ProxyHandler {
|
||||||
asset_failures: AssetFailureCache,
|
asset_failures: AssetFailureCache,
|
||||||
/// Playlist servant à l'allowlist, mise en cache 5 minutes pour éviter
|
/// Playlist servant à l'allowlist, mise en cache 5 minutes pour éviter
|
||||||
/// une requête SQL par segment.
|
/// une requête SQL par segment. Clé : playlist demandée (vide = défaut),
|
||||||
playlist_cache: Mutex<Option<(PlaylistConfig, Instant)>>,
|
/// pour que deux IPTV aient chacune leur allowlist.
|
||||||
|
playlist_cache: Mutex<std::collections::HashMap<String, (PlaylistConfig, Instant)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ProxyError {
|
enum ProxyError {
|
||||||
@@ -173,19 +183,26 @@ impl ProxyHandler {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
asset_failures: AssetFailureCache::new(3, Duration::from_secs(300)),
|
asset_failures: AssetFailureCache::new(3, Duration::from_secs(300)),
|
||||||
playlist_cache: Mutex::new(None),
|
playlist_cache: Mutex::new(std::collections::HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cached_playlist(&self, state: &AppState, req: &Req) -> Option<PlaylistConfig> {
|
fn cached_playlist(&self, state: &AppState, req: &Req) -> Option<PlaylistConfig> {
|
||||||
if let Some((playlist, expiry)) = self.playlist_cache.lock().unwrap().as_ref()
|
let key = req
|
||||||
|
.query_param("playlist")
|
||||||
|
.filter(|p| !p.trim().is_empty())
|
||||||
|
.or_else(|| req.header("x-playlist-id").map(str::to_string))
|
||||||
|
.unwrap_or_default();
|
||||||
|
if let Some((playlist, expiry)) = self.playlist_cache.lock().unwrap().get(&key)
|
||||||
&& Instant::now() < *expiry
|
&& Instant::now() < *expiry
|
||||||
{
|
{
|
||||||
return Some(playlist.clone());
|
return Some(playlist.clone());
|
||||||
}
|
}
|
||||||
let playlist = state.get_playlist(req)?;
|
let playlist = state.get_playlist(req)?;
|
||||||
*self.playlist_cache.lock().unwrap() =
|
self.playlist_cache.lock().unwrap().insert(
|
||||||
Some((playlist.clone(), Instant::now() + Duration::from_secs(300)));
|
key,
|
||||||
|
(playlist.clone(), Instant::now() + Duration::from_secs(300)),
|
||||||
|
);
|
||||||
Some(playlist)
|
Some(playlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,9 +235,11 @@ impl ProxyHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let mut full_url = api_path;
|
let mut full_url = api_path;
|
||||||
if !req.query.is_empty() {
|
// `playlist` choisit l'IPTV côté XtremFlow : ne pas l'envoyer au panneau.
|
||||||
|
let forwarded = without_playlist_param(&req.query);
|
||||||
|
if !forwarded.is_empty() {
|
||||||
full_url.push(if full_url.contains('?') { '&' } else { '?' });
|
full_url.push(if full_url.contains('?') { '&' } else { '?' });
|
||||||
full_url.push_str(&req.query);
|
full_url.push_str(&forwarded);
|
||||||
}
|
}
|
||||||
|
|
||||||
let target = reqwest::Url::parse(&full_url).ok();
|
let target = reqwest::Url::parse(&full_url).ok();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
use crate::dart_time::DartDateTime;
|
use crate::dart_time::DartDateTime;
|
||||||
use crate::http::{self, Req, Resp, json, segments};
|
use crate::http::{self, Req, Resp, json, segments};
|
||||||
use crate::models::{Recording, User};
|
use crate::models::{Recording, User, stream_url_for_playlist};
|
||||||
use crate::services::recording_scheduler::{RecordingScheduler, recordings_dir_path};
|
use crate::services::recording_scheduler::{RecordingScheduler, recordings_dir_path};
|
||||||
use crate::state::SharedState;
|
use crate::state::SharedState;
|
||||||
use crate::util::{media_probe, safe_path};
|
use crate::util::{media_probe, safe_path};
|
||||||
@@ -58,10 +58,14 @@ async fn logs(state: &SharedState, req: &Req, id: &str) -> Resp {
|
|||||||
/// Liste les enregistrements de l'utilisateur (tous pour un admin),
|
/// Liste les enregistrements de l'utilisateur (tous pour un admin),
|
||||||
/// enrichis du suivi : taille, progression, relances, durée réelle.
|
/// enrichis du suivi : taille, progression, relances, durée réelle.
|
||||||
async fn list(state: &SharedState, req: &Req) -> Resp {
|
async fn list(state: &SharedState, req: &Req) -> Resp {
|
||||||
let recordings = match &req.user {
|
let mut recordings = match &req.user {
|
||||||
Some(user) if !user.is_admin => state.db.get_user_recordings(&user.id),
|
Some(user) if !user.is_admin => state.db.get_user_recordings(&user.id),
|
||||||
_ => state.db.get_all_recordings(),
|
_ => state.db.get_all_recordings(),
|
||||||
};
|
};
|
||||||
|
// Une IPTV choisie : seulement ses enregistrements.
|
||||||
|
if let Some(pid) = state.selected_playlist_id(req) {
|
||||||
|
recordings.retain(|r| r.playlist_id.as_deref().is_none_or(|p| p == pid));
|
||||||
|
}
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let mut enriched = Vec::with_capacity(recordings.len());
|
let mut enriched = Vec::with_capacity(recordings.len());
|
||||||
for r in &recordings {
|
for r in &recordings {
|
||||||
@@ -184,11 +188,26 @@ fn create(state: &SharedState, req: &Req) -> Resp {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let title = to_text(data.get("title")).unwrap_or_else(|| "Sans Titre".into());
|
let title = to_text(data.get("title")).unwrap_or_else(|| "Sans Titre".into());
|
||||||
|
// Capture sur l'IPTV choisie par le client (à défaut, la plus récente).
|
||||||
|
let playlist_id = state
|
||||||
|
.get_playlist(req)
|
||||||
|
.map(|p| p.id)
|
||||||
|
.filter(|id| !id.is_empty());
|
||||||
|
let stream_url = match &playlist_id {
|
||||||
|
Some(pid) => stream_url_for_playlist(&stream_url, pid),
|
||||||
|
None => stream_url,
|
||||||
|
};
|
||||||
match state
|
match state
|
||||||
.db
|
.db
|
||||||
.create_recording(&user_id, &channel_id, &stream_url, &title, start, end)
|
.create_recording(&user_id, &channel_id, &stream_url, &title, start, end)
|
||||||
{
|
{
|
||||||
Ok(recording) => json(200, &Value::Object(recording.to_map())),
|
Ok(mut recording) => {
|
||||||
|
if let Some(pid) = &playlist_id {
|
||||||
|
state.db.set_recording_playlist(&recording.id, pid);
|
||||||
|
recording.playlist_id = Some(pid.clone());
|
||||||
|
}
|
||||||
|
json(200, &Value::Object(recording.to_map()))
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!("[RecordingsApi] Erreur à la création: {e}");
|
println!("[RecordingsApi] Erreur à la création: {e}");
|
||||||
json(500, &json!({"error": "Erreur lors de la programmation"}))
|
json(500, &json!({"error": "Erreur lors de la programmation"}))
|
||||||
@@ -261,11 +280,18 @@ pub fn season_passes(state: &SharedState, req: &Req, rest: &str) -> Resp {
|
|||||||
let Some(user) = &req.user else {
|
let Some(user) = &req.user else {
|
||||||
return json(401, &json!({"error": "Authentification requise"}));
|
return json(401, &json!({"error": "Authentification requise"}));
|
||||||
};
|
};
|
||||||
let passes = if user.is_admin {
|
let mut passes = if user.is_admin {
|
||||||
state.db.get_all_season_passes()
|
state.db.get_all_season_passes()
|
||||||
} else {
|
} else {
|
||||||
state.db.get_season_passes_for_user(&user.id)
|
state.db.get_season_passes_for_user(&user.id)
|
||||||
};
|
};
|
||||||
|
if let Some(pid) = state.selected_playlist_id(req) {
|
||||||
|
passes.retain(|p| {
|
||||||
|
p.get("playlist_id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_none_or(|v| v == pid)
|
||||||
|
});
|
||||||
|
}
|
||||||
json(
|
json(
|
||||||
200,
|
200,
|
||||||
&Value::Array(passes.into_iter().map(Value::Object).collect()),
|
&Value::Array(passes.into_iter().map(Value::Object).collect()),
|
||||||
@@ -349,11 +375,28 @@ fn create_pass(state: &SharedState, req: &Req) -> Resp {
|
|||||||
&json!({"error": "Un Season Pass identique existe déjà"}),
|
&json!({"error": "Un Season Pass identique existe déjà"}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let playlist_id = state
|
||||||
|
.get_playlist(req)
|
||||||
|
.map(|p| p.id)
|
||||||
|
.filter(|id| !id.is_empty());
|
||||||
|
let stream_url = match &playlist_id {
|
||||||
|
Some(pid) => stream_url_for_playlist(&stream_url, pid),
|
||||||
|
None => stream_url,
|
||||||
|
};
|
||||||
match state
|
match state
|
||||||
.db
|
.db
|
||||||
.create_season_pass(&user.id, &show_title, &channel_id, &stream_url, match_mode)
|
.create_season_pass(&user.id, &show_title, &channel_id, &stream_url, match_mode)
|
||||||
{
|
{
|
||||||
Ok(pass) => {
|
Ok(mut pass) => {
|
||||||
|
if let Some(pid) = &playlist_id {
|
||||||
|
let id = pass
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
state.db.set_season_pass_playlist(&id, pid);
|
||||||
|
pass.insert("playlist_id".into(), Value::from(pid.clone()));
|
||||||
|
}
|
||||||
println!("[SeasonPass] Créé: \"{show_title}\" sur chaîne {channel_id}");
|
println!("[SeasonPass] Créé: \"{show_title}\" sur chaîne {channel_id}");
|
||||||
json(201, &Value::Object(pass))
|
json(201, &Value::Object(pass))
|
||||||
}
|
}
|
||||||
|
|||||||
+98
-11
@@ -348,6 +348,53 @@ fn audio_args(with_filters: bool) -> Vec<String> {
|
|||||||
args
|
args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ajoute `?playlist=<id>` à chaque URI de segment d'un m3u8.
|
||||||
|
///
|
||||||
|
/// POURQUOI : le lecteur résout les segments relativement au m3u8 et perd
|
||||||
|
/// sa query ; sans elle, le segment serait cherché dans la session d'une
|
||||||
|
/// autre IPTV (même `stream_id`).
|
||||||
|
fn with_segment_query(m3u8: &str, playlist_id: &str) -> String {
|
||||||
|
let enc: String = url::form_urlencoded::byte_serialize(playlist_id.as_bytes()).collect();
|
||||||
|
m3u8.lines()
|
||||||
|
.map(|line| {
|
||||||
|
let t = line.trim();
|
||||||
|
if t.is_empty() || t.starts_with('#') {
|
||||||
|
line.to_string()
|
||||||
|
} else if t.contains('?') {
|
||||||
|
format!("{t}&playlist={enc}")
|
||||||
|
} else {
|
||||||
|
format!("{t}?playlist={enc}")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
+ "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playlist explicitement demandée par la requête (à propager aux segments).
|
||||||
|
fn requested_playlist(req: &Req) -> Option<String> {
|
||||||
|
req.query_param("playlist").filter(|p| !p.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Étiquette de l'IPTV servie, pour séparer les sessions de deux playlists.
|
||||||
|
fn playlist_tag(state: &AppState, req: &Req) -> String {
|
||||||
|
state
|
||||||
|
.get_playlist(req)
|
||||||
|
.map(|p| p.tag())
|
||||||
|
.unwrap_or_else(|| "default".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `?playlist=…` à recopier dans une redirection.
|
||||||
|
fn playlist_suffix(req: &Req, first: bool) -> String {
|
||||||
|
match requested_playlist(req) {
|
||||||
|
Some(p) => {
|
||||||
|
let enc: String = url::form_urlencoded::byte_serialize(p.as_bytes()).collect();
|
||||||
|
format!("{}playlist={enc}", if first { '?' } else { '&' })
|
||||||
|
}
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn hls_headers(resp: Resp) -> Resp {
|
fn hls_headers(resp: Resp) -> Resp {
|
||||||
http::with_headers(
|
http::with_headers(
|
||||||
resp,
|
resp,
|
||||||
@@ -514,7 +561,13 @@ impl Streaming {
|
|||||||
attempt
|
attempt
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_session_playlist(&self, id: &str, attempt: Attempt, label: &str) -> Resp {
|
async fn serve_session_playlist(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
attempt: Attempt,
|
||||||
|
label: &str,
|
||||||
|
playlist_query: Option<&str>,
|
||||||
|
) -> Resp {
|
||||||
let session = match attempt.session {
|
let session = match attempt.session {
|
||||||
Some(s) if attempt.ready => s,
|
Some(s) if attempt.ready => s,
|
||||||
_ => {
|
_ => {
|
||||||
@@ -532,7 +585,13 @@ impl Streaming {
|
|||||||
};
|
};
|
||||||
session.touch();
|
session.touch();
|
||||||
match tokio::fs::read(session.playlist_path()).await {
|
match tokio::fs::read(session.playlist_path()).await {
|
||||||
Ok(bytes) => hls_headers(http::response(200, bytes)),
|
Ok(bytes) => {
|
||||||
|
let body = match playlist_query {
|
||||||
|
Some(q) => with_segment_query(&String::from_utf8_lossy(&bytes), q).into_bytes(),
|
||||||
|
None => bytes,
|
||||||
|
};
|
||||||
|
hls_headers(http::response(200, body))
|
||||||
|
}
|
||||||
Err(_) => http::text(502, format!("{label} transcoder failed: playlist missing")),
|
Err(_) => http::text(502, format!("{label} transcoder failed: playlist missing")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -554,7 +613,10 @@ impl Streaming {
|
|||||||
// relatives des segments se résolvent sous le chemin de qualité.
|
// relatives des segments se résolvent sous le chemin de qualité.
|
||||||
[id, "playlist.m3u8"] => {
|
[id, "playlist.m3u8"] => {
|
||||||
let quality = sanitize_quality(req.query_param("quality").as_deref());
|
let quality = sanitize_quality(req.query_param("quality").as_deref());
|
||||||
http::redirect(&format!("/api/live/{id}/{quality}/playlist.m3u8"))
|
http::redirect(&format!(
|
||||||
|
"/api/live/{id}/{quality}/playlist.m3u8{}",
|
||||||
|
playlist_suffix(req, true)
|
||||||
|
))
|
||||||
}
|
}
|
||||||
[id, "turbo.ts"] => self.turbo(state, req, id).await,
|
[id, "turbo.ts"] => self.turbo(state, req, id).await,
|
||||||
[file] if file.len() > 3 && file.ends_with(".ts") => {
|
[file] if file.len() > 3 && file.ends_with(".ts") => {
|
||||||
@@ -564,7 +626,11 @@ impl Streaming {
|
|||||||
if !is_valid_stream_id(id) || segment.contains("..") {
|
if !is_valid_stream_id(id) || segment.contains("..") {
|
||||||
return http::text(400, "Invalid request");
|
return http::text(400, "Invalid request");
|
||||||
}
|
}
|
||||||
let session_id = format!("live_{id}_{}", sanitize_quality(Some(quality)));
|
let session_id = format!(
|
||||||
|
"live_{}_{id}_{}",
|
||||||
|
playlist_tag(state, req),
|
||||||
|
sanitize_quality(Some(quality))
|
||||||
|
);
|
||||||
self.sessions.touch(&session_id);
|
self.sessions.touch(&session_id);
|
||||||
serve_segment(&self.sessions.base_dir.join(&session_id).join(segment), 60).await
|
serve_segment(&self.sessions.base_dir.join(&session_id).join(segment), 60).await
|
||||||
}
|
}
|
||||||
@@ -590,7 +656,7 @@ impl Streaming {
|
|||||||
playlist.dns, playlist.username, playlist.password
|
playlist.dns, playlist.username, playlist.password
|
||||||
);
|
);
|
||||||
let use_gpu = state.db.is_nvidia_gpu_enabled();
|
let use_gpu = state.db.is_nvidia_gpu_enabled();
|
||||||
let session_id = format!("live_{stream_id}_{quality}");
|
let session_id = format!("live_{}_{stream_id}_{quality}", playlist.tag());
|
||||||
if !self.sessions.contains(&session_id) {
|
if !self.sessions.contains(&session_id) {
|
||||||
println!("[Live HLS] Starting {session_id}: {}", redact_url(&target));
|
println!("[Live HLS] Starting {session_id}: {}", redact_url(&target));
|
||||||
}
|
}
|
||||||
@@ -657,8 +723,13 @@ impl Streaming {
|
|||||||
Some(&playlist),
|
Some(&playlist),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
self.serve_session_playlist(&session_id, attempt, "Live")
|
self.serve_session_playlist(
|
||||||
.await
|
&session_id,
|
||||||
|
attempt,
|
||||||
|
"Live",
|
||||||
|
requested_playlist(req).as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `/api/live/{id}/turbo.ts` — flux MPEG-TS continu pour mpegts.js : vidéo
|
/// `/api/live/{id}/turbo.ts` — flux MPEG-TS continu pour mpegts.js : vidéo
|
||||||
@@ -925,6 +996,9 @@ impl Streaming {
|
|||||||
query.append_pair(key, value);
|
query.append_pair(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(p) = requested_playlist(req) {
|
||||||
|
query.append_pair("playlist", &p);
|
||||||
|
}
|
||||||
let query = query.finish();
|
let query = query.finish();
|
||||||
let suffix = if query.is_empty() {
|
let suffix = if query.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -937,7 +1011,11 @@ impl Streaming {
|
|||||||
if !is_valid_stream_id(id) || segment.contains("..") {
|
if !is_valid_stream_id(id) || segment.contains("..") {
|
||||||
return http::text(400, "Invalid request");
|
return http::text(400, "Invalid request");
|
||||||
}
|
}
|
||||||
let session_id = format!("vod_{id}_{}", sanitize_quality(Some(quality)));
|
let session_id = format!(
|
||||||
|
"vod_{}_{id}_{}",
|
||||||
|
playlist_tag(state, req),
|
||||||
|
sanitize_quality(Some(quality))
|
||||||
|
);
|
||||||
self.sessions.touch(&session_id);
|
self.sessions.touch(&session_id);
|
||||||
serve_segment(
|
serve_segment(
|
||||||
&self.sessions.base_dir.join(&session_id).join(segment),
|
&self.sessions.base_dir.join(&session_id).join(segment),
|
||||||
@@ -983,7 +1061,7 @@ impl Streaming {
|
|||||||
vod_container_extension(params.get("ext").map(String::as_str))
|
vod_container_extension(params.get("ext").map(String::as_str))
|
||||||
);
|
);
|
||||||
let use_gpu = state.db.is_nvidia_gpu_enabled();
|
let use_gpu = state.db.is_nvidia_gpu_enabled();
|
||||||
let session_id = format!("vod_{stream_id}_{quality}");
|
let session_id = format!("vod_{}_{stream_id}_{quality}", playlist.tag());
|
||||||
if !self.sessions.contains(&session_id) {
|
if !self.sessions.contains(&session_id) {
|
||||||
println!(
|
println!(
|
||||||
"[VOD] Starting {session_id} ({content_type}): {}",
|
"[VOD] Starting {session_id} ({content_type}): {}",
|
||||||
@@ -1055,8 +1133,13 @@ impl Streaming {
|
|||||||
Some(&playlist),
|
Some(&playlist),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
self.serve_session_playlist(&session_id, attempt, "VOD")
|
self.serve_session_playlist(
|
||||||
.await
|
&session_id,
|
||||||
|
attempt,
|
||||||
|
"VOD",
|
||||||
|
requested_playlist(req).as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -1314,6 +1397,10 @@ mod tests {
|
|||||||
assert_eq!(sanitize_quality(Some("bogus")), "high");
|
assert_eq!(sanitize_quality(Some("bogus")), "high");
|
||||||
assert_eq!(sanitize_quality(None), "high");
|
assert_eq!(sanitize_quality(None), "high");
|
||||||
assert_eq!(live_video_args("source", true), vec!["-c:v", "copy"]);
|
assert_eq!(live_video_args("source", true), vec!["-c:v", "copy"]);
|
||||||
|
assert_eq!(
|
||||||
|
with_segment_query("#EXTM3U\n#EXTINF:4,\nseg_000.ts\nseg_001.ts?x=1\n", "ab-1"),
|
||||||
|
"#EXTM3U\n#EXTINF:4,\nseg_000.ts?playlist=ab-1\nseg_001.ts?x=1&playlist=ab-1\n"
|
||||||
|
);
|
||||||
assert!(live_video_args("high", true).contains(&"h264_nvenc".to_string()));
|
assert!(live_video_args("high", true).contains(&"h264_nvenc".to_string()));
|
||||||
assert!(vod_video_args("low", false).contains(&"scale=-2:720".to_string()));
|
assert!(vod_video_args("low", false).contains(&"scale=-2:720".to_string()));
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-4
@@ -67,6 +67,7 @@ fn recording_from_row(row: &Row) -> DbResult<Recording> {
|
|||||||
file_path: row.get("file_path")?,
|
file_path: row.get("file_path")?,
|
||||||
error_reason: row.get("error_reason")?,
|
error_reason: row.get("error_reason")?,
|
||||||
source_rule_id: row.get("source_rule_id")?,
|
source_rule_id: row.get("source_rule_id")?,
|
||||||
|
playlist_id: row.get("playlist_id")?,
|
||||||
created_at: parse_date(row.get("created_at")?),
|
created_at: parse_date(row.get("created_at")?),
|
||||||
updated_at: parse_date(row.get("updated_at")?),
|
updated_at: parse_date(row.get("updated_at")?),
|
||||||
})
|
})
|
||||||
@@ -98,6 +99,7 @@ fn auto_rule_from_row(row: &Row) -> DbResult<AutoRule> {
|
|||||||
pad_after_min: row.get("pad_after_min")?,
|
pad_after_min: row.get("pad_after_min")?,
|
||||||
team_filter: json_list(row.get("team_filter")?),
|
team_filter: json_list(row.get("team_filter")?),
|
||||||
keep_days: row.get("keep_days")?,
|
keep_days: row.get("keep_days")?,
|
||||||
|
playlist_id: row.get("playlist_id")?,
|
||||||
created_at: row
|
created_at: row
|
||||||
.get::<_, Option<String>>("created_at")?
|
.get::<_, Option<String>>("created_at")?
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
@@ -191,6 +193,7 @@ impl AppDatabase {
|
|||||||
};
|
};
|
||||||
db.create_tables()?;
|
db.create_tables()?;
|
||||||
db.run_migrations()?;
|
db.run_migrations()?;
|
||||||
|
db.attach_legacy_rows_to_playlists()?;
|
||||||
println!("Database initialized: {path}");
|
println!("Database initialized: {path}");
|
||||||
Ok(db)
|
Ok(db)
|
||||||
}
|
}
|
||||||
@@ -309,7 +312,6 @@ impl AppDatabase {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_recordings_start ON tv_recordings(start_time);
|
CREATE INDEX IF NOT EXISTS idx_recordings_start ON tv_recordings(start_time);
|
||||||
CREATE INDEX IF NOT EXISTS idx_season_passes_user ON season_passes(user_id);
|
CREATE INDEX IF NOT EXISTS idx_season_passes_user ON season_passes(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_auto_rules_user ON auto_rules(user_id);
|
CREATE INDEX IF NOT EXISTS idx_auto_rules_user ON auto_rules(user_id);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_auto_rules_user_kind ON auto_rules(user_id, kind);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_nba_games_tipoff ON nba_games(rule_id, tipoff_utc);
|
CREATE INDEX IF NOT EXISTS idx_nba_games_tipoff ON nba_games(rule_id, tipoff_utc);
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -332,7 +334,7 @@ impl AppDatabase {
|
|||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let migrations: [(i64, &str, &str, &str); 3] = [
|
let migrations: [(i64, &str, &str, &str); 6] = [
|
||||||
// v1 : colonne error_reason absente des bases d'avant son introduction.
|
// v1 : colonne error_reason absente des bases d'avant son introduction.
|
||||||
(1, "tv_recordings", "error_reason", "TEXT"),
|
(1, "tv_recordings", "error_reason", "TEXT"),
|
||||||
// v2 : mode de correspondance des season passes. 'contains' pour les
|
// v2 : mode de correspondance des season passes. 'contains' pour les
|
||||||
@@ -346,6 +348,10 @@ impl AppDatabase {
|
|||||||
),
|
),
|
||||||
// v3 : lien vers la règle automatique (badge « Auto NBA », sidecars).
|
// v3 : lien vers la règle automatique (badge « Auto NBA », sidecars).
|
||||||
(3, "tv_recordings", "source_rule_id", "TEXT"),
|
(3, "tv_recordings", "source_rule_id", "TEXT"),
|
||||||
|
// v4-v6 : IPTV de rattachement (plusieurs playlists par compte).
|
||||||
|
(4, "tv_recordings", "playlist_id", "TEXT"),
|
||||||
|
(5, "season_passes", "playlist_id", "TEXT"),
|
||||||
|
(6, "auto_rules", "playlist_id", "TEXT"),
|
||||||
];
|
];
|
||||||
for (version, table, column, definition) in migrations {
|
for (version, table, column, definition) in migrations {
|
||||||
if version <= current {
|
if version <= current {
|
||||||
@@ -375,6 +381,29 @@ impl AppDatabase {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lignes antérieures au multi-IPTV : rattachées à la playlist la plus
|
||||||
|
/// récente de leur propriétaire, celle qu'elles utilisaient de fait.
|
||||||
|
/// Sans cela elles s'afficheraient sous toutes les playlists. Rejoué à
|
||||||
|
/// chaque démarrage, sans effet une fois tout rattaché.
|
||||||
|
fn attach_legacy_rows_to_playlists(&self) -> DbResult<()> {
|
||||||
|
let conn = self.lock();
|
||||||
|
for table in ["tv_recordings", "season_passes", "auto_rules"] {
|
||||||
|
conn.execute(
|
||||||
|
&format!(
|
||||||
|
"UPDATE {table} SET playlist_id = (SELECT p.id FROM playlists p WHERE p.user_id = {table}.user_id ORDER BY p.created_at DESC LIMIT 1) WHERE playlist_id IS NULL"
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
// Une règle NBA par utilisateur ET par IPTV.
|
||||||
|
conn.execute_batch(
|
||||||
|
"DROP INDEX IF EXISTS idx_auto_rules_user_kind;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_auto_rules_user_kind_playlist
|
||||||
|
ON auto_rules(user_id, kind, IFNULL(playlist_id, ''));",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Crée le compte admin s'il n'existe aucun utilisateur.
|
/// Crée le compte admin s'il n'existe aucun utilisateur.
|
||||||
///
|
///
|
||||||
/// Le mot de passe initial vient de ADMIN_INITIAL_PASSWORD, ou est généré
|
/// Le mot de passe initial vient de ADMIN_INITIAL_PASSWORD, ou est généré
|
||||||
@@ -731,6 +760,7 @@ impl AppDatabase {
|
|||||||
file_path: None,
|
file_path: None,
|
||||||
error_reason: None,
|
error_reason: None,
|
||||||
source_rule_id: None,
|
source_rule_id: None,
|
||||||
|
playlist_id: None,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
})
|
})
|
||||||
@@ -909,6 +939,22 @@ impl AppDatabase {
|
|||||||
.next()
|
.next()
|
||||||
}
|
}
|
||||||
/// Marque un enregistrement comme créé par une règle automatique.
|
/// Marque un enregistrement comme créé par une règle automatique.
|
||||||
|
/// Rattache un enregistrement à l'IPTV qui le capture.
|
||||||
|
pub fn set_recording_playlist(&self, id: &str, playlist_id: &str) {
|
||||||
|
let _ = self.lock().execute(
|
||||||
|
"UPDATE tv_recordings SET playlist_id = ? WHERE id = ?",
|
||||||
|
params![playlist_id, id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rattache un season pass à son IPTV.
|
||||||
|
pub fn set_season_pass_playlist(&self, id: &str, playlist_id: &str) {
|
||||||
|
let _ = self.lock().execute(
|
||||||
|
"UPDATE season_passes SET playlist_id = ? WHERE id = ?",
|
||||||
|
params![playlist_id, id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_recording_source(&self, id: &str, rule_id: &str) {
|
pub fn set_recording_source(&self, id: &str, rule_id: &str) {
|
||||||
let _ = self.lock().execute(
|
let _ = self.lock().execute(
|
||||||
"UPDATE tv_recordings SET source_rule_id = ? WHERE id = ?",
|
"UPDATE tv_recordings SET source_rule_id = ? WHERE id = ?",
|
||||||
@@ -925,7 +971,7 @@ impl AppDatabase {
|
|||||||
rule.created_at = now.clone();
|
rule.created_at = now.clone();
|
||||||
rule.updated_at = now;
|
rule.updated_at = now;
|
||||||
self.lock().execute(
|
self.lock().execute(
|
||||||
"INSERT INTO auto_rules (id, user_id, kind, enabled, default_decision, channel_ids, pad_before_min, pad_after_min, team_filter, keep_days, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO auto_rules (id, user_id, kind, enabled, default_decision, channel_ids, pad_before_min, pad_after_min, team_filter, keep_days, created_at, updated_at, playlist_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
params![
|
params![
|
||||||
rule.id,
|
rule.id,
|
||||||
rule.user_id,
|
rule.user_id,
|
||||||
@@ -938,7 +984,8 @@ impl AppDatabase {
|
|||||||
serde_json::to_string(&rule.team_filter).unwrap_or_else(|_| "[]".into()),
|
serde_json::to_string(&rule.team_filter).unwrap_or_else(|_| "[]".into()),
|
||||||
rule.keep_days,
|
rule.keep_days,
|
||||||
rule.created_at,
|
rule.created_at,
|
||||||
rule.updated_at
|
rule.updated_at,
|
||||||
|
rule.playlist_id
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(rule)
|
Ok(rule)
|
||||||
@@ -988,6 +1035,14 @@ impl AppDatabase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Règles d'un utilisateur pour une IPTV donnée.
|
||||||
|
pub fn get_auto_rules_for_playlist(&self, user_id: &str, playlist_id: &str) -> Vec<AutoRule> {
|
||||||
|
self.query_auto_rules(
|
||||||
|
"SELECT * FROM auto_rules WHERE user_id = ? AND playlist_id = ? ORDER BY created_at ASC",
|
||||||
|
&[user_id, playlist_id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_auto_rule(&self, id: &str) -> Option<AutoRule> {
|
pub fn get_auto_rule(&self, id: &str) -> Option<AutoRule> {
|
||||||
self.query_auto_rules("SELECT * FROM auto_rules WHERE id = ?", &[id])
|
self.query_auto_rules("SELECT * FROM auto_rules WHERE id = ?", &[id])
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1223,6 +1278,7 @@ mod tests {
|
|||||||
pad_after_min: 45,
|
pad_after_min: 45,
|
||||||
team_filter: vec![],
|
team_filter: vec![],
|
||||||
keep_days: None,
|
keep_days: None,
|
||||||
|
playlist_id: None,
|
||||||
created_at: String::new(),
|
created_at: String::new(),
|
||||||
updated_at: String::new(),
|
updated_at: String::new(),
|
||||||
})
|
})
|
||||||
@@ -1326,6 +1382,37 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn une_regle_nba_par_iptv_et_rattachement_des_anciennes_lignes() {
|
||||||
|
let (db, path) = temp_db();
|
||||||
|
let user = db.create_user("u", "x", false).unwrap();
|
||||||
|
// Ligne « ancienne » : règle sans playlist, créée avant les playlists.
|
||||||
|
let legacy = nba_rule(&db, &user.id);
|
||||||
|
let a = db
|
||||||
|
.create_playlist(&user.id, "A", "http://a", "u", "p", None)
|
||||||
|
.unwrap();
|
||||||
|
// Au redémarrage, l'ancienne règle est rattachée à la playlist A.
|
||||||
|
drop(db);
|
||||||
|
let db = AppDatabase::open(path.to_str().unwrap()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
db.get_auto_rule(&legacy.id).unwrap().playlist_id.as_deref(),
|
||||||
|
Some(a.id.as_str())
|
||||||
|
);
|
||||||
|
let b = db
|
||||||
|
.create_playlist(&user.id, "B", "http://b", "u", "p", None)
|
||||||
|
.unwrap();
|
||||||
|
// Une seconde règle NBA est permise sur l'autre IPTV…
|
||||||
|
let mut on_b = legacy.clone();
|
||||||
|
on_b.playlist_id = Some(b.id.clone());
|
||||||
|
db.create_auto_rule(&on_b).unwrap();
|
||||||
|
assert_eq!(db.get_auto_rules_for_playlist(&user.id, &b.id).len(), 1);
|
||||||
|
assert_eq!(db.get_auto_rules_for_playlist(&user.id, &a.id).len(), 1);
|
||||||
|
// …mais pas deux sur la même.
|
||||||
|
assert!(is_unique_violation(
|
||||||
|
&db.create_auto_rule(&on_b).unwrap_err()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mot_de_passe_genere() {
|
fn mot_de_passe_genere() {
|
||||||
let p = generate_password(16);
|
let p = generate_password(16);
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ impl Session {
|
|||||||
/// Configuration Xtream utilisée par les proxys et le streaming.
|
/// Configuration Xtream utilisée par les proxys et le streaming.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PlaylistConfig {
|
pub struct PlaylistConfig {
|
||||||
|
/// Identifiant de la playlist en base (vide dans certains tests).
|
||||||
|
pub id: String,
|
||||||
/// URL du serveur Xtream (sans `/` final).
|
/// URL du serveur Xtream (sans `/` final).
|
||||||
pub dns: String,
|
pub dns: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
@@ -91,6 +93,8 @@ pub struct Recording {
|
|||||||
pub error_reason: Option<String>,
|
pub error_reason: Option<String>,
|
||||||
/// Règle automatique à l'origine de l'enregistrement (None = manuel/season pass).
|
/// Règle automatique à l'origine de l'enregistrement (None = manuel/season pass).
|
||||||
pub source_rule_id: Option<String>,
|
pub source_rule_id: Option<String>,
|
||||||
|
/// IPTV de capture (None = lignes antérieures non rattachées).
|
||||||
|
pub playlist_id: Option<String>,
|
||||||
pub created_at: DartDateTime,
|
pub created_at: DartDateTime,
|
||||||
pub updated_at: DartDateTime,
|
pub updated_at: DartDateTime,
|
||||||
}
|
}
|
||||||
@@ -109,6 +113,7 @@ impl Recording {
|
|||||||
map.insert("file_path".into(), json!(self.file_path));
|
map.insert("file_path".into(), json!(self.file_path));
|
||||||
map.insert("error_reason".into(), json!(self.error_reason));
|
map.insert("error_reason".into(), json!(self.error_reason));
|
||||||
map.insert("source_rule_id".into(), json!(self.source_rule_id));
|
map.insert("source_rule_id".into(), json!(self.source_rule_id));
|
||||||
|
map.insert("playlist_id".into(), json!(self.playlist_id));
|
||||||
map.insert("created_at".into(), json!(self.created_at.to_iso()));
|
map.insert("created_at".into(), json!(self.created_at.to_iso()));
|
||||||
map.insert("updated_at".into(), json!(self.updated_at.to_iso()));
|
map.insert("updated_at".into(), json!(self.updated_at.to_iso()));
|
||||||
map
|
map
|
||||||
@@ -141,6 +146,9 @@ pub struct AutoRule {
|
|||||||
pub team_filter: Vec<String>,
|
pub team_filter: Vec<String>,
|
||||||
/// Rétention en jours des fichiers créés par la règle ; None = illimitée.
|
/// Rétention en jours des fichiers créés par la règle ; None = illimitée.
|
||||||
pub keep_days: Option<i64>,
|
pub keep_days: Option<i64>,
|
||||||
|
/// IPTV de la règle : une règle NBA par playlist.
|
||||||
|
#[serde(default)]
|
||||||
|
pub playlist_id: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -186,3 +194,69 @@ pub struct ScheduledGame {
|
|||||||
pub away_tri: String,
|
pub away_tri: String,
|
||||||
pub tipoff_utc: chrono::DateTime<chrono::Utc>,
|
pub tipoff_utc: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<Playlist> for PlaylistConfig {
|
||||||
|
fn from(p: Playlist) -> Self {
|
||||||
|
Self {
|
||||||
|
id: p.id,
|
||||||
|
dns: p.server_url,
|
||||||
|
username: p.username,
|
||||||
|
password: p.password,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaylistConfig {
|
||||||
|
/// Fragment court et sûr pour les identifiants de session et de
|
||||||
|
/// dossier : deux IPTV peuvent avoir le même `stream_id`.
|
||||||
|
pub fn tag(&self) -> String {
|
||||||
|
let t: String = self
|
||||||
|
.id
|
||||||
|
.chars()
|
||||||
|
.filter(char::is_ascii_alphanumeric)
|
||||||
|
.take(12)
|
||||||
|
.collect();
|
||||||
|
if t.is_empty() { "default".into() } else { t }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URL de flux local liée à une IPTV : `/api/live/12.ts` →
|
||||||
|
/// `/api/live/12.ts?playlist=<id>`. La capture FFmpeg (requête locale sans
|
||||||
|
/// utilisateur) retrouve ainsi la bonne playlist.
|
||||||
|
pub fn stream_url_for_playlist(url: &str, playlist_id: &str) -> String {
|
||||||
|
if playlist_id.is_empty() || !url.starts_with("/api/") || url.contains("playlist=") {
|
||||||
|
return url.to_string();
|
||||||
|
}
|
||||||
|
let enc: String = url::form_urlencoded::byte_serialize(playlist_id.as_bytes()).collect();
|
||||||
|
let sep = if url.contains('?') { '&' } else { '?' };
|
||||||
|
format!("{url}{sep}playlist={enc}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod playlist_url_tests {
|
||||||
|
use super::stream_url_for_playlist;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ajoute_la_playlist_aux_flux_locaux() {
|
||||||
|
assert_eq!(
|
||||||
|
stream_url_for_playlist("/api/live/12.ts", "ab"),
|
||||||
|
"/api/live/12.ts?playlist=ab"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stream_url_for_playlist("/api/live/12.ts?x=1", "ab"),
|
||||||
|
"/api/live/12.ts?x=1&playlist=ab"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stream_url_for_playlist("/api/live/12.ts?playlist=zz", "ab"),
|
||||||
|
"/api/live/12.ts?playlist=zz"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stream_url_for_playlist("http://x/12.ts", "ab"),
|
||||||
|
"http://x/12.ts"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stream_url_for_playlist("/api/live/12.ts", ""),
|
||||||
|
"/api/live/12.ts"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -197,7 +197,7 @@ async fn cors(state: &SharedState, req: Req) -> Resp {
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"access-control-allow-headers",
|
"access-control-allow-headers",
|
||||||
"Origin, Content-Type, Accept, Authorization, Range".into(),
|
"Origin, Content-Type, Accept, Authorization, Range, X-Playlist-Id".into(),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"access-control-expose-headers",
|
"access-control-expose-headers",
|
||||||
|
|||||||
@@ -604,16 +604,14 @@ impl NbaAutoRecorder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn playlist_for(&self, user_id: &str) -> Option<PlaylistConfig> {
|
/// IPTV de la règle ; à défaut (règle ancienne), la plus récente.
|
||||||
self.db
|
fn playlist_for(&self, rule: &AutoRule) -> Option<PlaylistConfig> {
|
||||||
.get_playlists(user_id)
|
rule.playlist_id
|
||||||
.into_iter()
|
.as_deref()
|
||||||
.next()
|
.and_then(|id| self.db.get_playlist_by_id(id))
|
||||||
.map(|p| PlaylistConfig {
|
.filter(|p| p.user_id == rule.user_id)
|
||||||
dns: p.server_url,
|
.or_else(|| self.db.get_playlists(&rule.user_id).into_iter().next())
|
||||||
username: p.username,
|
.map(PlaylistConfig::from)
|
||||||
password: p.password,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dernier diagnostic de la règle : source, diffusions trouvées,
|
/// Dernier diagnostic de la règle : source, diffusions trouvées,
|
||||||
@@ -694,7 +692,7 @@ impl NbaAutoRecorder {
|
|||||||
now - Duration::hours(6),
|
now - Duration::hours(6),
|
||||||
now + Duration::days(SCHEDULE_DAYS),
|
now + Duration::days(SCHEDULE_DAYS),
|
||||||
);
|
);
|
||||||
let playlist = self.playlist_for(&rule.user_id);
|
let playlist = self.playlist_for(rule);
|
||||||
if games.is_empty() {
|
if games.is_empty() {
|
||||||
self.diagnostics.lock().unwrap().insert(
|
self.diagnostics.lock().unwrap().insert(
|
||||||
rule.id.clone(),
|
rule.id.clone(),
|
||||||
@@ -793,16 +791,24 @@ impl NbaAutoRecorder {
|
|||||||
next.conflict = true;
|
next.conflict = true;
|
||||||
} else if let Some(l) = &live {
|
} else if let Some(l) = &live {
|
||||||
let channel = &l.programme.channel_id;
|
let channel = &l.programme.channel_id;
|
||||||
|
let playlist_id =
|
||||||
|
playlist.as_ref().map(|p| p.id.clone()).unwrap_or_default();
|
||||||
match self.db.create_recording(
|
match self.db.create_recording(
|
||||||
&rule.user_id,
|
&rule.user_id,
|
||||||
channel,
|
channel,
|
||||||
&format!("/api/live/{channel}.ts"),
|
&crate::models::stream_url_for_playlist(
|
||||||
|
&format!("/api/live/{channel}.ts"),
|
||||||
|
&playlist_id,
|
||||||
|
),
|
||||||
&recording_title(&game),
|
&recording_title(&game),
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
) {
|
) {
|
||||||
Ok(rec) => {
|
Ok(rec) => {
|
||||||
self.db.set_recording_source(&rec.id, &rule.id);
|
self.db.set_recording_source(&rec.id, &rule.id);
|
||||||
|
if !playlist_id.is_empty() {
|
||||||
|
self.db.set_recording_playlist(&rec.id, &playlist_id);
|
||||||
|
}
|
||||||
next.recording_id = Some(rec.id);
|
next.recording_id = Some(rec.id);
|
||||||
next.status = "scheduled".into();
|
next.status = "scheduled".into();
|
||||||
created += 1;
|
created += 1;
|
||||||
@@ -932,6 +938,7 @@ mod tests {
|
|||||||
pad_after_min: 45,
|
pad_after_min: 45,
|
||||||
team_filter: vec![],
|
team_filter: vec![],
|
||||||
keep_days: None,
|
keep_days: None,
|
||||||
|
playlist_id: None,
|
||||||
created_at: String::new(),
|
created_at: String::new(),
|
||||||
updated_at: String::new(),
|
updated_at: String::new(),
|
||||||
}
|
}
|
||||||
@@ -975,6 +982,7 @@ mod tests {
|
|||||||
file_path: None,
|
file_path: None,
|
||||||
error_reason: None,
|
error_reason: None,
|
||||||
source_rule_id: Some("r".into()),
|
source_rule_id: Some("r".into()),
|
||||||
|
playlist_id: None,
|
||||||
created_at: DartDateTime::parse(start).unwrap(),
|
created_at: DartDateTime::parse(start).unwrap(),
|
||||||
updated_at: DartDateTime::parse(start).unwrap(),
|
updated_at: DartDateTime::parse(start).unwrap(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,8 +454,16 @@ impl RecordingScheduler {
|
|||||||
.unwrap_or("contains")
|
.unwrap_or("contains")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Playlist du PROPRIÉTAIRE du pass, résolue au moment du scan.
|
// IPTV du pass ; à défaut (pass ancien), la plus récente du
|
||||||
let playlists = self.db.get_playlists(&user_id);
|
// propriétaire, résolue au moment du scan.
|
||||||
|
let pass_playlist_id = field("playlist_id");
|
||||||
|
let playlists: Vec<crate::models::Playlist> = self
|
||||||
|
.db
|
||||||
|
.get_playlist_by_id(&pass_playlist_id)
|
||||||
|
.filter(|p| p.user_id == user_id)
|
||||||
|
.into_iter()
|
||||||
|
.chain(self.db.get_playlists(&user_id))
|
||||||
|
.collect();
|
||||||
let Some(playlist) = playlists.first() else {
|
let Some(playlist) = playlists.first() else {
|
||||||
println!(
|
println!(
|
||||||
"[SeasonPass] Aucune playlist pour le propriétaire du pass \"{show_title}\", scan ignoré"
|
"[SeasonPass] Aucune playlist pour le propriétaire du pass \"{show_title}\", scan ignoré"
|
||||||
@@ -544,9 +552,12 @@ impl RecordingScheduler {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.db
|
let stream_url = crate::models::stream_url_for_playlist(&stream_url, &playlist.id);
|
||||||
|
let rec = self
|
||||||
|
.db
|
||||||
.create_recording(&user_id, &channel_id, &stream_url, &title, start, end)
|
.create_recording(&user_id, &channel_id, &stream_url, &title, start, end)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
self.db.set_recording_playlist(&rec.id, &playlist.id);
|
||||||
created += 1;
|
created += 1;
|
||||||
println!(
|
println!(
|
||||||
"[SeasonPass] ✓ Planifié automatiquement: \"{title}\" le {}",
|
"[SeasonPass] ✓ Planifié automatiquement: \"{title}\" le {}",
|
||||||
@@ -1272,6 +1283,7 @@ mod tests {
|
|||||||
file_path: None,
|
file_path: None,
|
||||||
error_reason: None,
|
error_reason: None,
|
||||||
source_rule_id: None,
|
source_rule_id: None,
|
||||||
|
playlist_id: None,
|
||||||
created_at: start,
|
created_at: start,
|
||||||
updated_at: start,
|
updated_at: start,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ mod tests {
|
|||||||
|
|
||||||
fn playlist() -> PlaylistConfig {
|
fn playlist() -> PlaylistConfig {
|
||||||
PlaylistConfig {
|
PlaylistConfig {
|
||||||
|
id: String::new(),
|
||||||
dns: "http://panel.test".into(),
|
dns: "http://panel.test".into(),
|
||||||
username: "u".into(),
|
username: "u".into(),
|
||||||
password: "x".into(),
|
password: "x".into(),
|
||||||
|
|||||||
+45
-8
@@ -37,10 +37,51 @@ pub struct AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
/// Playlist Xtream de la requête : la première playlist de l'utilisateur
|
/// Playlist explicitement choisie par le client (en-tête ou paramètre),
|
||||||
/// authentifié ; à défaut (proxy, streaming), celle du premier
|
/// si l'utilisateur y a droit. Sert à filtrer les listes par IPTV ; sans
|
||||||
/// utilisateur de la base.
|
/// choix explicite (anciens clients), rien n'est filtré.
|
||||||
|
pub fn selected_playlist_id(&self, req: &Req) -> Option<String> {
|
||||||
|
let requested = req
|
||||||
|
.query_param("playlist")
|
||||||
|
.filter(|p| !p.trim().is_empty())
|
||||||
|
.or_else(|| req.header("x-playlist-id").map(str::to_string))?;
|
||||||
|
let requested = requested.trim().to_string();
|
||||||
|
self.get_playlist(req)
|
||||||
|
.map(|p| p.id)
|
||||||
|
.filter(|id| *id == requested)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playlist Xtream de la requête.
|
||||||
|
///
|
||||||
|
/// La playlist choisie par le client (paramètre `?playlist=` de l'URL,
|
||||||
|
/// prioritaire, sinon en-tête `x-playlist-id` = sélection globale) fait
|
||||||
|
/// foi : sans elle, toutes les fonctions
|
||||||
|
/// (direct, guide, enregistrements) tombaient sur la playlist la plus
|
||||||
|
/// récente, même quand une autre IPTV était sélectionnée. Un utilisateur
|
||||||
|
/// authentifié n'accède qu'à ses playlists (admin : toutes). Sans
|
||||||
|
/// utilisateur (flux déjà validés par jeton, ou capture FFmpeg locale),
|
||||||
|
/// l'identifiant — un UUID — suffit.
|
||||||
|
///
|
||||||
|
/// À défaut : la playlist la plus récente de l'utilisateur, sinon celle
|
||||||
|
/// du premier utilisateur de la base.
|
||||||
pub fn get_playlist(&self, req: &Req) -> Option<PlaylistConfig> {
|
pub fn get_playlist(&self, req: &Req) -> Option<PlaylistConfig> {
|
||||||
|
let requested = req
|
||||||
|
.query_param("playlist")
|
||||||
|
.filter(|p| !p.trim().is_empty())
|
||||||
|
.or_else(|| req.header("x-playlist-id").map(str::to_string))
|
||||||
|
.filter(|id| !id.trim().is_empty());
|
||||||
|
if let Some(id) = requested
|
||||||
|
&& let Some(p) = self.db.get_playlist_by_id(id.trim())
|
||||||
|
{
|
||||||
|
let allowed = match &req.user {
|
||||||
|
Some(u) => u.is_admin || p.user_id == u.id,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if allowed {
|
||||||
|
return Some(p.into());
|
||||||
|
}
|
||||||
|
println!("[getPlaylist] Playlist demandée refusée pour cet utilisateur");
|
||||||
|
}
|
||||||
let playlist = match &req.user {
|
let playlist = match &req.user {
|
||||||
Some(user) => self.db.get_playlists(&user.id).into_iter().next(),
|
Some(user) => self.db.get_playlists(&user.id).into_iter().next(),
|
||||||
None => {
|
None => {
|
||||||
@@ -51,11 +92,7 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
match playlist {
|
match playlist {
|
||||||
Some(p) => Some(PlaylistConfig {
|
Some(p) => Some(p.into()),
|
||||||
dns: p.server_url,
|
|
||||||
username: p.username,
|
|
||||||
password: p.password,
|
|
||||||
}),
|
|
||||||
None => {
|
None => {
|
||||||
println!("[getPlaylist] WARNING: No playlist found, returning null");
|
println!("[getPlaylist] WARNING: No playlist found, returning null");
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -652,7 +652,9 @@
|
|||||||
XFPlayer.prototype._hlsEquivalent = function (url) {
|
XFPlayer.prototype._hlsEquivalent = function (url) {
|
||||||
var match = /^(.*\/api\/live\/)([^/?#]+)(?:\/turbo)?\.ts(\?.*)?$/.exec(url);
|
var match = /^(.*\/api\/live\/)([^/?#]+)(?:\/turbo)?\.ts(\?.*)?$/.exec(url);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
return match[1] + match[2] + '/source/playlist.m3u8';
|
// La query (dont ?playlist=<id>) est conservée : sans elle le serveur
|
||||||
|
// servirait la chaîne de même numéro sur une autre IPTV.
|
||||||
|
return match[1] + match[2] + '/source/playlist.m3u8' + (match[3] || '');
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------- Lecture directe ----------------
|
// ---------------- Lecture directe ----------------
|
||||||
|
|||||||
Reference in new issue
Block a user