mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-23 23:18:06 +02:00
- Programme officiel beIN Sports France (api/opta, ~8 jours) comme source : chaîne exacte et direct/rediffusion explicites, recoupés avec le coup d'envoi ESPN (un faux « direct » du guide est écarté). Repli sur l'EPG IPTV si le site est injoignable. Seuls les matchs annoncés en direct sur beIN sont affichés. - Plus aucun enregistrement automatique : seul un « Enregistrer » explicite programme un match ; les programmations sans choix sont annulées. - Un seul flux IPTV : un match qui chevauche un match choisi ou un autre enregistrement est refusé (409) et affiché « Créneau pris » ; la liste est relue après chaque choix et les boutons sont figés pendant la requête. - Interrupteurs lisibles (curseur et piste distincts), boutons Enregistrer / Choisi à la place des interrupteurs par match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
260 lines
8.4 KiB
Rust
260 lines
8.4 KiB
Rust
//! Serveur XtremFlow : API REST, proxy Xtream, streaming FFmpeg,
|
|
//! enregistrements TV et fichiers de la build web Flutter.
|
|
|
|
#![allow(clippy::result_large_err)]
|
|
|
|
mod api;
|
|
mod dart_time;
|
|
mod db;
|
|
mod http;
|
|
mod models;
|
|
mod server;
|
|
mod services;
|
|
mod state;
|
|
mod util;
|
|
|
|
use crate::api::epg::{EpgApi, default_panel_xmltv};
|
|
use crate::api::proxy::ProxyHandler;
|
|
use crate::api::streaming::Streaming;
|
|
use crate::db::AppDatabase;
|
|
use crate::server::RateLimiter;
|
|
use crate::services::cleanup::CleanupService;
|
|
use crate::services::ffmpeg_sessions::FfmpegSessionManager;
|
|
use crate::services::logo_catalog::LogoCatalog;
|
|
use crate::services::nba_autorec::NbaAutoRecorder;
|
|
use crate::services::recording_scheduler::RecordingScheduler;
|
|
use crate::services::xmltv::{XmltvConfig, XmltvEpgService};
|
|
use crate::state::AppState;
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
const DEFAULT_XMLTV: &str = "https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz";
|
|
|
|
struct Args {
|
|
port: u16,
|
|
path: String,
|
|
}
|
|
|
|
/// `--port 8089` / `-p 8089` / `--port=8089`, `--path /app/web`.
|
|
fn parse_args() -> Args {
|
|
let mut args = Args {
|
|
port: 8089,
|
|
path: "/app/web".to_string(),
|
|
};
|
|
let raw: Vec<String> = std::env::args().skip(1).collect();
|
|
let mut i = 0;
|
|
while i < raw.len() {
|
|
let (key, inline) = match raw[i].split_once('=') {
|
|
Some((k, v)) => (k.to_string(), Some(v.to_string())),
|
|
None => (raw[i].clone(), None),
|
|
};
|
|
let mut value = || {
|
|
inline.clone().or_else(|| {
|
|
i += 1;
|
|
raw.get(i).cloned()
|
|
})
|
|
};
|
|
match key.as_str() {
|
|
"--port" | "-p" => {
|
|
if let Some(v) = value() {
|
|
args.port = v.parse().expect("--port doit être un entier");
|
|
}
|
|
}
|
|
"--path" => {
|
|
if let Some(v) = value() {
|
|
args.path = v;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
i += 1;
|
|
}
|
|
args
|
|
}
|
|
|
|
fn http_client(redirects: reqwest::redirect::Policy) -> reqwest::Client {
|
|
reqwest::Client::builder()
|
|
.redirect(redirects)
|
|
.user_agent("VLC/3.0.18 LibVLC/3.0.18")
|
|
.connect_timeout(Duration::from_secs(30))
|
|
.build()
|
|
.expect("client HTTP")
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
let args = parse_args();
|
|
let web_path = args.path.clone();
|
|
|
|
let db_path =
|
|
std::env::var("XTREMFLOW_DB_PATH").unwrap_or_else(|_| "/app/data/xtremflow.db".into());
|
|
let db = Arc::new(AppDatabase::open(&db_path).expect("ouverture de la base"));
|
|
db.seed_admin().expect("création du compte admin");
|
|
|
|
let http = http_client(reqwest::redirect::Policy::limited(5));
|
|
let http_manual = http_client(reqwest::redirect::Policy::none());
|
|
|
|
// Planificateur d'enregistrements (les season passes résolvent la
|
|
// playlist de leur propriétaire à chaque scan).
|
|
let scheduler = Arc::new(RecordingScheduler::new(db.clone(), http.clone()));
|
|
scheduler.start();
|
|
|
|
// Sessions FFmpeg de streaming.
|
|
let sessions = Arc::new(FfmpegSessionManager::new(
|
|
std::env::temp_dir().join("xtremflow_streams"),
|
|
));
|
|
sessions.init();
|
|
let streaming = Arc::new(Streaming::new(sessions.clone(), http.clone()));
|
|
|
|
// Source XMLTV de repli. EPG_XMLTV_URLS vide = aucun appel sortant : le
|
|
// guide se limite alors au panneau de l'abonné.
|
|
let xmltv_urls: Vec<String> = std::env::var("EPG_XMLTV_URLS")
|
|
.unwrap_or_else(|_| DEFAULT_XMLTV.to_string())
|
|
.split(',')
|
|
.map(|u| u.trim().to_string())
|
|
.filter(|u| !u.is_empty())
|
|
.collect();
|
|
if xmltv_urls.is_empty() {
|
|
println!("[EPG] Repli XMLTV désactivé (EPG_XMLTV_URLS vide)");
|
|
} else {
|
|
println!("[EPG] Repli XMLTV : {} source(s)", xmltv_urls.len());
|
|
}
|
|
let xmltv = (!xmltv_urls.is_empty()).then(|| {
|
|
Arc::new(XmltvEpgService::new(
|
|
XmltvConfig::new(xmltv_urls),
|
|
http.clone(),
|
|
))
|
|
});
|
|
let epg = EpgApi::new(xmltv, http.clone(), default_panel_xmltv(http.clone()));
|
|
|
|
// Enregistrement automatique des matchs NBA en direct sur beIN Sports FR.
|
|
let nba = Arc::new(NbaAutoRecorder::new(db.clone(), http.clone(), epg.clone()));
|
|
nba.start();
|
|
|
|
// Purge des temporaires. Jamais le dossier temporaire système en
|
|
// récursif : il contient les sessions HLS.
|
|
let cleanup = Arc::new(CleanupService::new());
|
|
cleanup.add_target("/app/data/logs");
|
|
cleanup.add_target("/app/data/tmp");
|
|
cleanup.start();
|
|
|
|
// Libs de lecture servies depuis l'image : si elles manquent, le lecteur
|
|
// échoue côté navigateur sans que rien ne l'ait signalé. On le dit ici.
|
|
for lib in ["hls.min.js", "mpegts.min.js"] {
|
|
if !std::path::Path::new(&web_path)
|
|
.join("vendor")
|
|
.join(lib)
|
|
.exists()
|
|
{
|
|
println!(
|
|
"[Server] ATTENTION: web/vendor/{lib} absent de {web_path} — la lecture échouera dans le navigateur."
|
|
);
|
|
}
|
|
}
|
|
|
|
let versioned = util::asset_versioning::build_versioned_entry_points(&web_path);
|
|
let mut keys: Vec<&String> = versioned.keys().collect();
|
|
keys.sort();
|
|
println!(
|
|
"[Server] Assets versionnés : {}",
|
|
keys.iter()
|
|
.map(|k| k.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
);
|
|
|
|
let state = Arc::new(AppState {
|
|
db: db.clone(),
|
|
web_path: web_path.clone(),
|
|
versioned,
|
|
http: http.clone(),
|
|
http_manual,
|
|
scheduler: scheduler.clone(),
|
|
nba,
|
|
streaming,
|
|
proxy: ProxyHandler::new(),
|
|
epg,
|
|
logo_catalog: Arc::new(LogoCatalog::new(http.clone())),
|
|
cleanup,
|
|
rate_limit: RateLimiter::new(600),
|
|
login_rate_limit: RateLimiter::new(10),
|
|
allowed_origin: std::env::var("ALLOWED_ORIGIN")
|
|
.ok()
|
|
.filter(|o| !o.is_empty()),
|
|
trusted_proxies: std::env::var("TRUSTED_PROXIES")
|
|
.unwrap_or_default()
|
|
.split(',')
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect(),
|
|
});
|
|
|
|
// Sessions expirées purgées toutes les heures.
|
|
let db_clean = db.clone();
|
|
tokio::spawn(async move {
|
|
let mut interval = tokio::time::interval(Duration::from_secs(3600));
|
|
interval.tick().await;
|
|
loop {
|
|
interval.tick().await;
|
|
db_clean.clean_expired_sessions();
|
|
println!("Cleaned expired sessions");
|
|
}
|
|
});
|
|
|
|
// Arrêt gracieux unique (docker stop / Ctrl+C) : clôturer d'abord les
|
|
// enregistrements (kill FFmpeg, fusion des parties, statut en base),
|
|
// puis les sessions de streaming, puis sortir.
|
|
tokio::spawn(shutdown_on_signal(scheduler, sessions));
|
|
|
|
let app = axum::Router::new()
|
|
.fallback(server::entry)
|
|
.with_state(state);
|
|
let addr = SocketAddr::from(([0, 0, 0, 0], args.port));
|
|
let listener = tokio::net::TcpListener::bind(addr)
|
|
.await
|
|
.unwrap_or_else(|e| panic!("écoute sur {addr} impossible : {e}"));
|
|
|
|
println!("Server started on port {}", args.port);
|
|
println!("Serving static files from: {web_path}");
|
|
println!("REST API available at: /api/auth/* and /api/playlists/*");
|
|
println!("Xtream proxy available at: /api/xtream/* (SSRF Protected)");
|
|
|
|
axum::serve(
|
|
listener,
|
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
|
)
|
|
.await
|
|
.expect("serveur HTTP");
|
|
}
|
|
|
|
async fn shutdown_on_signal(
|
|
scheduler: Arc<RecordingScheduler>,
|
|
sessions: Arc<FfmpegSessionManager>,
|
|
) {
|
|
let signal = wait_for_signal().await;
|
|
println!("[Server] {signal} reçu, arrêt en cours…");
|
|
scheduler.shutdown().await;
|
|
sessions.kill_all();
|
|
// Laisser SIGTERM partir vers les FFmpeg avant de sortir.
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
std::process::exit(0);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
async fn wait_for_signal() -> &'static str {
|
|
use tokio::signal::unix::{SignalKind, signal};
|
|
let mut term = signal(SignalKind::terminate()).expect("SIGTERM");
|
|
let mut int = signal(SignalKind::interrupt()).expect("SIGINT");
|
|
tokio::select! {
|
|
_ = term.recv() => "SIGTERM",
|
|
_ = int.recv() => "SIGINT",
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
async fn wait_for_signal() -> &'static str {
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
"SIGINT"
|
|
}
|