From 4f978755fdcf9b2d7d7d02abbbf88437dd044f58 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 18 Oct 2026 19:53:45 +0200 Subject: [PATCH] =?UTF-8?q?Multi-IPTV=20:=20chaque=20fonction=20utilise=20?= =?UTF-8?q?la=20playlist=20s=C3=A9lectionn=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= (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 --- lib/core/api/authed_http.dart | 19 +++ .../iptv/services/xtream_service.dart | 53 +++++++-- server/src/api/auto_rules.rs | 22 ++-- server/src/api/epg.rs | 1 + server/src/api/proxy.rs | 35 ++++-- server/src/api/recordings.rs | 53 ++++++++- server/src/api/streaming.rs | 109 ++++++++++++++++-- server/src/db.rs | 95 ++++++++++++++- server/src/models.rs | 74 ++++++++++++ server/src/server.rs | 2 +- server/src/services/nba_autorec.rs | 32 +++-- server/src/services/recording_scheduler.rs | 18 ++- server/src/services/upstream_slots.rs | 1 + server/src/state.rs | 53 +++++++-- web/xf-player-core.js | 4 +- 15 files changed, 501 insertions(+), 70 deletions(-) diff --git a/lib/core/api/authed_http.dart b/lib/core/api/authed_http.dart index c150a92..3e14b95 100644 --- a/lib/core/api/authed_http.dart +++ b/lib/core/api/authed_http.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:html' as html; import 'package:http/http.dart' as http; @@ -23,9 +24,27 @@ class AuthedHttp { 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)['id']; + return (id is String && id.isNotEmpty) ? id : null; + } catch (_) { + return null; + } + } + static Map _headers([Map? extra]) { final token = _token; + final playlistId = selectedPlaylistId; return { + if (playlistId != null) 'X-Playlist-Id': playlistId, if (extra != null) ...extra, if (token != null) 'Authorization': 'Bearer $token', }; diff --git a/lib/features/iptv/services/xtream_service.dart b/lib/features/iptv/services/xtream_service.dart index 654a6ac..174c274 100644 --- a/lib/features/iptv/services/xtream_service.dart +++ b/lib/features/iptv/services/xtream_service.dart @@ -101,6 +101,10 @@ class XtreamService { options.headers['Authorization'] = 'Bearer $token'; } } + final playlistId = _currentPlaylist?.id; + if (playlistId != null && playlistId.isNotEmpty) { + options.headers['X-Playlist-Id'] = playlistId; + } handler.next(options); }, ), @@ -115,7 +119,12 @@ class XtreamService { }) { return _dio.get( '$_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()), ); } @@ -165,11 +174,27 @@ class XtreamService { _currentPlaylist = playlist; } + String? get _playlistId { + final id = _currentPlaylist?.id; + return (id == null || id.isEmpty) ? null : id; + } + + /// Ajoute `playlist=` : 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) /// [quality]: source | high | medium | low (server-side FFmpeg preset) String getLiveStreamUrl(String streamId, {String quality = 'high'}) { 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) @@ -180,7 +205,7 @@ class XtreamService { // 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 // 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) @@ -193,8 +218,10 @@ class XtreamService { if (_currentPlaylist == null) throw Exception('No playlist configured'); // `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)}'; + return _withPlaylist( + '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8' + '${_extQuery(containerExtension, first: true)}', + ); } String _extQuery(String ext, {required bool first}) { @@ -210,8 +237,10 @@ class XtreamService { String quality = 'high', }) { if (_currentPlaylist == null) throw Exception('No playlist configured'); - return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8?type=series' - '${_extQuery(containerExtension, first: false)}'; + return _withPlaylist( + '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8?type=series' + '${_extQuery(containerExtension, first: false)}', + ); } /// Authenticate and get server info @@ -749,14 +778,18 @@ class XtreamService { 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)) { return cached.programmes; } await _epgGate.acquire(); 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) { debugPrint( '[XtreamService] EPG $streamId : HTTP ${response.statusCode}', @@ -779,7 +812,7 @@ class XtreamService { if (result.length >= 8) break; } - _epgCache[streamId] = _EpgCacheEntry( + _epgCache[cacheKey] = _EpgCacheEntry( programmes: result, expiresAt: DateTime.now().add(_epgCacheTtl), ); diff --git a/server/src/api/auto_rules.rs b/server/src/api/auto_rules.rs index d4c7799..c201fc2 100644 --- a/server/src/api/auto_rules.rs +++ b/server/src/api/auto_rules.rs @@ -30,7 +30,11 @@ pub async fn auto_rules(state: &SharedState, req: &Req, rest: &str) -> Resp { }; let parts = segments(rest); 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)); } 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, Err(r) => return r, }; - if state - .db - .get_auto_rules_for_user(&user.id) - .iter() - .any(|r| r.kind == "nba") - { + let playlist_id = state + .get_playlist(req) + .map(|p| p.id) + .filter(|id| !id.is_empty()); + 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à"})); } let mut rule = AutoRule { @@ -164,6 +171,7 @@ async fn create(state: &SharedState, req: &Req, user: &User) -> Resp { pad_after_min: 45, team_filter: Vec::new(), keep_days: None, + playlist_id: playlist_id.clone(), created_at: String::new(), updated_at: String::new(), }; diff --git a/server/src/api/epg.rs b/server/src/api/epg.rs index 2d2b1af..fe06b1a 100644 --- a/server/src/api/epg.rs +++ b/server/src/api/epg.rs @@ -578,6 +578,7 @@ mod tests { fn playlist(base: &str) -> PlaylistConfig { PlaylistConfig { + id: String::new(), dns: base.to_string(), username: "u".into(), password: "p".into(), diff --git a/server/src/api/proxy.rs b/server/src/api/proxy.rs index a7b3232..6e6951f 100644 --- a/server/src/api/proxy.rs +++ b/server/src/api/proxy.rs @@ -157,11 +157,21 @@ const ALLOWED_RESPONSE_HEADERS: [&str; 6] = [ ]; 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::>() + .join("&") +} + pub struct ProxyHandler { asset_failures: AssetFailureCache, /// Playlist servant à l'allowlist, mise en cache 5 minutes pour éviter - /// une requête SQL par segment. - playlist_cache: Mutex>, + /// une requête SQL par segment. Clé : playlist demandée (vide = défaut), + /// pour que deux IPTV aient chacune leur allowlist. + playlist_cache: Mutex>, } enum ProxyError { @@ -173,19 +183,26 @@ impl ProxyHandler { pub fn new() -> Self { Self { 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 { - 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 { return Some(playlist.clone()); } let playlist = state.get_playlist(req)?; - *self.playlist_cache.lock().unwrap() = - Some((playlist.clone(), Instant::now() + Duration::from_secs(300))); + self.playlist_cache.lock().unwrap().insert( + key, + (playlist.clone(), Instant::now() + Duration::from_secs(300)), + ); Some(playlist) } @@ -218,9 +235,11 @@ impl ProxyHandler { ); } 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_str(&req.query); + full_url.push_str(&forwarded); } let target = reqwest::Url::parse(&full_url).ok(); diff --git a/server/src/api/recordings.rs b/server/src/api/recordings.rs index f62eb16..7ea83a9 100644 --- a/server/src/api/recordings.rs +++ b/server/src/api/recordings.rs @@ -3,7 +3,7 @@ use crate::dart_time::DartDateTime; 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::state::SharedState; 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), /// enrichis du suivi : taille, progression, relances, durée réelle. 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), _ => 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 mut enriched = Vec::with_capacity(recordings.len()); 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()); + // 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 .db .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) => { println!("[RecordingsApi] Erreur à la création: {e}"); 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 { 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() } else { 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( 200, &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à"}), ); } + 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 .db .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}"); json(201, &Value::Object(pass)) } diff --git a/server/src/api/streaming.rs b/server/src/api/streaming.rs index b87d5a9..5b08275 100644 --- a/server/src/api/streaming.rs +++ b/server/src/api/streaming.rs @@ -348,6 +348,53 @@ fn audio_args(with_filters: bool) -> Vec { args } +/// Ajoute `?playlist=` à 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::>() + .join("\n") + + "\n" +} + +/// Playlist explicitement demandée par la requête (à propager aux segments). +fn requested_playlist(req: &Req) -> Option { + 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 { http::with_headers( resp, @@ -514,7 +561,13 @@ impl Streaming { 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 { Some(s) if attempt.ready => s, _ => { @@ -532,7 +585,13 @@ impl Streaming { }; session.touch(); 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")), } } @@ -554,7 +613,10 @@ impl Streaming { // relatives des segments se résolvent sous le chemin de qualité. [id, "playlist.m3u8"] => { 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, [file] if file.len() > 3 && file.ends_with(".ts") => { @@ -564,7 +626,11 @@ impl Streaming { if !is_valid_stream_id(id) || segment.contains("..") { 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); 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 ); 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) { println!("[Live HLS] Starting {session_id}: {}", redact_url(&target)); } @@ -657,8 +723,13 @@ impl Streaming { Some(&playlist), ) .await; - self.serve_session_playlist(&session_id, attempt, "Live") - .await + self.serve_session_playlist( + &session_id, + attempt, + "Live", + requested_playlist(req).as_deref(), + ) + .await } /// `/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); } } + if let Some(p) = requested_playlist(req) { + query.append_pair("playlist", &p); + } let query = query.finish(); let suffix = if query.is_empty() { String::new() @@ -937,7 +1011,11 @@ impl Streaming { if !is_valid_stream_id(id) || segment.contains("..") { 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); serve_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)) ); 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) { println!( "[VOD] Starting {session_id} ({content_type}): {}", @@ -1055,8 +1133,13 @@ impl Streaming { Some(&playlist), ) .await; - self.serve_session_playlist(&session_id, attempt, "VOD") - .await + self.serve_session_playlist( + &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(None), "high"); 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!(vod_video_args("low", false).contains(&"scale=-2:720".to_string())); } diff --git a/server/src/db.rs b/server/src/db.rs index 5f940d7..863ebf2 100644 --- a/server/src/db.rs +++ b/server/src/db.rs @@ -67,6 +67,7 @@ fn recording_from_row(row: &Row) -> DbResult { file_path: row.get("file_path")?, error_reason: row.get("error_reason")?, source_rule_id: row.get("source_rule_id")?, + playlist_id: row.get("playlist_id")?, created_at: parse_date(row.get("created_at")?), updated_at: parse_date(row.get("updated_at")?), }) @@ -98,6 +99,7 @@ fn auto_rule_from_row(row: &Row) -> DbResult { pad_after_min: row.get("pad_after_min")?, team_filter: json_list(row.get("team_filter")?), keep_days: row.get("keep_days")?, + playlist_id: row.get("playlist_id")?, created_at: row .get::<_, Option>("created_at")? .unwrap_or_default(), @@ -191,6 +193,7 @@ impl AppDatabase { }; db.create_tables()?; db.run_migrations()?; + db.attach_legacy_rows_to_playlists()?; println!("Database initialized: {path}"); 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_season_passes_user ON season_passes(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); "#, ) @@ -332,7 +334,7 @@ impl AppDatabase { |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. (1, "tv_recordings", "error_reason", "TEXT"), // 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). (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 { if version <= current { @@ -375,6 +381,29 @@ impl AppDatabase { 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. /// /// Le mot de passe initial vient de ADMIN_INITIAL_PASSWORD, ou est généré @@ -731,6 +760,7 @@ impl AppDatabase { file_path: None, error_reason: None, source_rule_id: None, + playlist_id: None, created_at: now, updated_at: now, }) @@ -909,6 +939,22 @@ impl AppDatabase { .next() } /// 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) { let _ = self.lock().execute( "UPDATE tv_recordings SET source_rule_id = ? WHERE id = ?", @@ -925,7 +971,7 @@ impl AppDatabase { rule.created_at = now.clone(); rule.updated_at = now; 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![ rule.id, rule.user_id, @@ -938,7 +984,8 @@ impl AppDatabase { serde_json::to_string(&rule.team_filter).unwrap_or_else(|_| "[]".into()), rule.keep_days, rule.created_at, - rule.updated_at + rule.updated_at, + rule.playlist_id ], )?; 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 { + 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 { self.query_auto_rules("SELECT * FROM auto_rules WHERE id = ?", &[id]) .into_iter() @@ -1223,6 +1278,7 @@ mod tests { pad_after_min: 45, team_filter: vec![], keep_days: None, + playlist_id: None, created_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] fn mot_de_passe_genere() { let p = generate_password(16); diff --git a/server/src/models.rs b/server/src/models.rs index 727f788..fcb98f4 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -70,6 +70,8 @@ impl Session { /// Configuration Xtream utilisée par les proxys et le streaming. #[derive(Clone, Debug)] pub struct PlaylistConfig { + /// Identifiant de la playlist en base (vide dans certains tests). + pub id: String, /// URL du serveur Xtream (sans `/` final). pub dns: String, pub username: String, @@ -91,6 +93,8 @@ pub struct Recording { pub error_reason: Option, /// Règle automatique à l'origine de l'enregistrement (None = manuel/season pass). pub source_rule_id: Option, + /// IPTV de capture (None = lignes antérieures non rattachées). + pub playlist_id: Option, pub created_at: DartDateTime, pub updated_at: DartDateTime, } @@ -109,6 +113,7 @@ impl Recording { map.insert("file_path".into(), json!(self.file_path)); map.insert("error_reason".into(), json!(self.error_reason)); 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("updated_at".into(), json!(self.updated_at.to_iso())); map @@ -141,6 +146,9 @@ pub struct AutoRule { pub team_filter: Vec, /// Rétention en jours des fichiers créés par la règle ; None = illimitée. pub keep_days: Option, + /// IPTV de la règle : une règle NBA par playlist. + #[serde(default)] + pub playlist_id: Option, pub created_at: String, pub updated_at: String, } @@ -186,3 +194,69 @@ pub struct ScheduledGame { pub away_tri: String, pub tipoff_utc: chrono::DateTime, } + +impl From 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=`. 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" + ); + } +} diff --git a/server/src/server.rs b/server/src/server.rs index f93cd9d..b757148 100644 --- a/server/src/server.rs +++ b/server/src/server.rs @@ -197,7 +197,7 @@ async fn cors(state: &SharedState, req: Req) -> Resp { ), ( "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", diff --git a/server/src/services/nba_autorec.rs b/server/src/services/nba_autorec.rs index cfc4402..9607e84 100644 --- a/server/src/services/nba_autorec.rs +++ b/server/src/services/nba_autorec.rs @@ -604,16 +604,14 @@ impl NbaAutoRecorder { } } - fn playlist_for(&self, user_id: &str) -> Option { - self.db - .get_playlists(user_id) - .into_iter() - .next() - .map(|p| PlaylistConfig { - dns: p.server_url, - username: p.username, - password: p.password, - }) + /// IPTV de la règle ; à défaut (règle ancienne), la plus récente. + fn playlist_for(&self, rule: &AutoRule) -> Option { + rule.playlist_id + .as_deref() + .and_then(|id| self.db.get_playlist_by_id(id)) + .filter(|p| p.user_id == rule.user_id) + .or_else(|| self.db.get_playlists(&rule.user_id).into_iter().next()) + .map(PlaylistConfig::from) } /// Dernier diagnostic de la règle : source, diffusions trouvées, @@ -694,7 +692,7 @@ impl NbaAutoRecorder { now - Duration::hours(6), now + Duration::days(SCHEDULE_DAYS), ); - let playlist = self.playlist_for(&rule.user_id); + let playlist = self.playlist_for(rule); if games.is_empty() { self.diagnostics.lock().unwrap().insert( rule.id.clone(), @@ -793,16 +791,24 @@ impl NbaAutoRecorder { next.conflict = true; } else if let Some(l) = &live { 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( &rule.user_id, channel, - &format!("/api/live/{channel}.ts"), + &crate::models::stream_url_for_playlist( + &format!("/api/live/{channel}.ts"), + &playlist_id, + ), &recording_title(&game), start, end, ) { Ok(rec) => { 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.status = "scheduled".into(); created += 1; @@ -932,6 +938,7 @@ mod tests { pad_after_min: 45, team_filter: vec![], keep_days: None, + playlist_id: None, created_at: String::new(), updated_at: String::new(), } @@ -975,6 +982,7 @@ mod tests { file_path: None, error_reason: None, source_rule_id: Some("r".into()), + playlist_id: None, created_at: DartDateTime::parse(start).unwrap(), updated_at: DartDateTime::parse(start).unwrap(), } diff --git a/server/src/services/recording_scheduler.rs b/server/src/services/recording_scheduler.rs index 66c8f21..7b0e70b 100644 --- a/server/src/services/recording_scheduler.rs +++ b/server/src/services/recording_scheduler.rs @@ -454,8 +454,16 @@ impl RecordingScheduler { .unwrap_or("contains") .to_string(); - // Playlist du PROPRIÉTAIRE du pass, résolue au moment du scan. - let playlists = self.db.get_playlists(&user_id); + // IPTV du pass ; à défaut (pass ancien), la plus récente du + // propriétaire, résolue au moment du scan. + let pass_playlist_id = field("playlist_id"); + let playlists: Vec = 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 { println!( "[SeasonPass] Aucune playlist pour le propriétaire du pass \"{show_title}\", scan ignoré" @@ -544,9 +552,12 @@ impl RecordingScheduler { 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) .map_err(|e| e.to_string())?; + self.db.set_recording_playlist(&rec.id, &playlist.id); created += 1; println!( "[SeasonPass] ✓ Planifié automatiquement: \"{title}\" le {}", @@ -1272,6 +1283,7 @@ mod tests { file_path: None, error_reason: None, source_rule_id: None, + playlist_id: None, created_at: start, updated_at: start, } diff --git a/server/src/services/upstream_slots.rs b/server/src/services/upstream_slots.rs index 29382e3..1788260 100644 --- a/server/src/services/upstream_slots.rs +++ b/server/src/services/upstream_slots.rs @@ -357,6 +357,7 @@ mod tests { fn playlist() -> PlaylistConfig { PlaylistConfig { + id: String::new(), dns: "http://panel.test".into(), username: "u".into(), password: "x".into(), diff --git a/server/src/state.rs b/server/src/state.rs index 869491e..7b38e02 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -37,10 +37,51 @@ pub struct AppState { } impl AppState { - /// Playlist Xtream de la requête : la première playlist de l'utilisateur - /// authentifié ; à défaut (proxy, streaming), celle du premier - /// utilisateur de la base. + /// Playlist explicitement choisie par le client (en-tête ou paramètre), + /// si l'utilisateur y a droit. Sert à filtrer les listes par IPTV ; sans + /// choix explicite (anciens clients), rien n'est filtré. + pub fn selected_playlist_id(&self, req: &Req) -> Option { + 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 { + 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 { Some(user) => self.db.get_playlists(&user.id).into_iter().next(), None => { @@ -51,11 +92,7 @@ impl AppState { } }; match playlist { - Some(p) => Some(PlaylistConfig { - dns: p.server_url, - username: p.username, - password: p.password, - }), + Some(p) => Some(p.into()), None => { println!("[getPlaylist] WARNING: No playlist found, returning null"); None diff --git a/web/xf-player-core.js b/web/xf-player-core.js index ced173c..7ef8dcd 100644 --- a/web/xf-player-core.js +++ b/web/xf-player-core.js @@ -652,7 +652,9 @@ XFPlayer.prototype._hlsEquivalent = function (url) { var match = /^(.*\/api\/live\/)([^/?#]+)(?:\/turbo)?\.ts(\?.*)?$/.exec(url); if (!match) return null; - return match[1] + match[2] + '/source/playlist.m3u8'; + // La query (dont ?playlist=) 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 ----------------