Skip to content

Commit 369dd0c

Browse files
author
Your Name
committed
feat: implement streaming movie discovery and display functionality with new service, handlers, and UI components.
1 parent e43c269 commit 369dd0c

File tree

9 files changed

+553
-10
lines changed

9 files changed

+553
-10
lines changed

src/domain/services/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub mod mqtt_service;
1414
pub mod news_service;
1515
pub mod proxmox_service;
1616
pub mod slack_service;
17+
pub mod streaming_service;
1718
pub mod system_service;
1819
pub mod trivy_service;
1920
pub mod weather_service;
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
use aws_sdk_s3::Client as S3Client;
2+
use chrono::Utc;
3+
use reqwest::Client;
4+
use serde_json::{json, Value};
5+
6+
const CACHE_PREFIX: &str = "streaming/";
7+
const BASE_URL: &str = "https://cinestream.info";
8+
9+
pub async fn get_streaming_data() -> Result<Value, String> {
10+
tracing::debug!("🎬 Streaming service: Attempting to get data from cache");
11+
12+
match get_aggregated_streaming().await {
13+
Ok(data) if !data["items"].as_array().map(|a| a.is_empty()).unwrap_or(true) => {
14+
tracing::info!("✅ Streaming cache HIT");
15+
Ok(data)
16+
}
17+
_ => {
18+
tracing::warn!("⚠️ Streaming cache MISS - fetching fresh data");
19+
fetch_fresh_streaming().await
20+
}
21+
}
22+
}
23+
24+
pub async fn force_refresh() -> Result<Value, String> {
25+
fetch_fresh_streaming().await
26+
}
27+
28+
async fn fetch_fresh_streaming() -> Result<Value, String> {
29+
tracing::info!("🔄 Fetching fresh streaming movies from Cinestream...");
30+
31+
let client = Client::builder()
32+
.timeout(std::time::Duration::from_secs(30))
33+
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
34+
.build()
35+
.map_err(|e| e.to_string())?;
36+
37+
let url = format!("{}/films-ajoutes-recemment/1", BASE_URL);
38+
let response = client.get(url).send().await.map_err(|e| e.to_string())?;
39+
40+
if !response.status().is_success() {
41+
return Err(format!("Failed to fetch cinestream: HTTP {}", response.status()));
42+
}
43+
44+
let html = response.text().await.map_err(|e| e.to_string())?;
45+
let items = parse_cinestream_html(&html);
46+
47+
if items.is_empty() {
48+
return Err("No movies parsed from cinestream".to_string());
49+
}
50+
51+
let response_data = json!({
52+
"items": items,
53+
"cached_at": Utc::now().to_rfc3339()
54+
});
55+
56+
// Cache the result in S3
57+
if let Ok(s3_client) = create_s3_client().await {
58+
let bucket = std::env::var("S3_BUCKET").unwrap_or_else(|_| "kusanagi".to_string());
59+
let key = format!("{}latest.json", CACHE_PREFIX);
60+
61+
let json_bytes = serde_json::to_vec(&response_data).unwrap_or_default();
62+
63+
let _ = s3_client
64+
.put_object()
65+
.bucket(bucket)
66+
.key(key)
67+
.body(json_bytes.into())
68+
.content_type("application/json")
69+
.send()
70+
.await;
71+
}
72+
73+
Ok(json!({
74+
"items": items,
75+
"cached_at": Utc::now().to_rfc3339()
76+
}))
77+
}
78+
79+
fn parse_cinestream_html(html: &str) -> Vec<Value> {
80+
let mut movies = Vec::new();
81+
82+
// Cinestream uses a grid of <article> elements for movies
83+
let mut current_pos = 0;
84+
while let Some(article_start) = html[current_pos..].find("<article") {
85+
let absolute_start = current_pos + article_start;
86+
if let Some(article_end) = html[absolute_start..].find("</article>") {
87+
let absolute_end = absolute_start + article_end + 10;
88+
let article_content = &html[absolute_start..absolute_end];
89+
90+
if let Some(movie) = parse_movie_article(article_content) {
91+
movies.push(movie);
92+
}
93+
94+
current_pos = absolute_end;
95+
} else {
96+
break;
97+
}
98+
}
99+
100+
movies
101+
}
102+
103+
fn parse_movie_article(content: &str) -> Option<Value> {
104+
// Extract title
105+
let title = extract_simple_content(content, "text-lg text-foreground font-bold", "</span>")?;
106+
107+
// Extract URL
108+
let url_start = content.find("href=\"")? + 6;
109+
let url_end = content[url_start..].find("\"")?;
110+
let path = &content[url_start..url_start + url_end];
111+
let url = if path.starts_with("http") {
112+
path.to_string()
113+
} else {
114+
format!("{}{}", BASE_URL, path)
115+
};
116+
117+
// Extract Poster
118+
let poster_url = if let Some(img_pos) = content.find("<img") {
119+
if let Some(src_pos) = content[img_pos..].find("src=\"") {
120+
let src_start = img_pos + src_pos + 5;
121+
if let Some(src_end) = content[src_start..].find("\"") {
122+
Some(content[src_start..src_start + src_end].replace("&amp;", "&"))
123+
} else { None }
124+
} else { None }
125+
} else { None };
126+
127+
// Extract Year
128+
let year = extract_simple_content(content, "text-muted-foreground\">", "</span>")
129+
.unwrap_or_else(|| "N/A".to_string());
130+
131+
// Extract Genres
132+
let genres = extract_simple_content(content, "truncate-multiline\">", "</span>")
133+
.unwrap_or_else(|| "".to_string());
134+
135+
// Extract Language and Quality (Top badges)
136+
// <div class="absolute top-1 left-1 ..."><span>TrueFrench</span></div>
137+
// <div class="absolute top-1 right-1 ..."><span>HDLight</span></div>
138+
139+
let mut language = "Unknown".to_string();
140+
let mut quality = "HD".to_string();
141+
142+
if let Some(lang_pos) = content.find("top-1 left-1") {
143+
if let Some(span_pos) = content[lang_pos..].find("<span>") {
144+
let start = lang_pos + span_pos + 6;
145+
if let Some(end) = content[start..].find("</span>") {
146+
language = content[start..start + end].to_string();
147+
}
148+
}
149+
}
150+
151+
if let Some(quality_pos) = content.find("top-1 right-1") {
152+
if let Some(span_pos) = content[quality_pos..].find("<span>") {
153+
let start = quality_pos + span_pos + 6;
154+
if let Some(end) = content[start..].find("</span>") {
155+
quality = content[start..start + end].to_string();
156+
}
157+
}
158+
}
159+
160+
Some(json!({
161+
"title": title,
162+
"url": url,
163+
"poster_url": poster_url,
164+
"year": year,
165+
"genres": genres,
166+
"language": language,
167+
"quality": quality,
168+
"source": "Cinestream"
169+
}))
170+
}
171+
172+
fn extract_simple_content(html: &str, class_marker: &str, end_tag: &str) -> Option<String> {
173+
if let Some(marker_pos) = html.find(class_marker) {
174+
let content_start = marker_pos + class_marker.len();
175+
// Skip > if we just searched for a class
176+
let start = if html[content_start..].starts_with(">") {
177+
content_start + 1
178+
} else {
179+
content_start
180+
};
181+
182+
if let Some(end_pos) = html[start..].find(end_tag) {
183+
return Some(html[start..start + end_pos].trim().to_string());
184+
}
185+
}
186+
None
187+
}
188+
189+
async fn get_aggregated_streaming() -> Result<Value, String> {
190+
let s3_client = create_s3_client().await?;
191+
let bucket = std::env::var("S3_BUCKET").unwrap_or_else(|_| "kusanagi".to_string());
192+
let key = format!("{}latest.json", CACHE_PREFIX);
193+
194+
let result = s3_client
195+
.get_object()
196+
.bucket(bucket)
197+
.key(key)
198+
.send()
199+
.await
200+
.map_err(|e| e.to_string())?;
201+
202+
let body = result.body.collect().await.map_err(|e| e.to_string())?;
203+
let val: Value = serde_json::from_slice(&body.into_bytes()).map_err(|e| e.to_string())?;
204+
Ok(val)
205+
}
206+
207+
async fn create_s3_client() -> Result<S3Client, String> {
208+
let endpoint = std::env::var("S3_ENDPOINT").unwrap_or_else(|_| "http://192.168.0.170:9010".to_string());
209+
let region = std::env::var("S3_REGION").unwrap_or_else(|_| "us-east-1".to_string());
210+
let access_key = std::env::var("S3_ACCESS_KEY").map_err(|_| "S3_ACCESS_KEY not set".to_string())?;
211+
let secret_key = std::env::var("S3_SECRET_KEY").map_err(|_| "S3_SECRET_KEY not set".to_string())?;
212+
213+
let credentials = aws_sdk_s3::config::Credentials::new(access_key, secret_key, None, None, "custom");
214+
let s3_config = aws_sdk_s3::config::Builder::new()
215+
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
216+
.region(aws_sdk_s3::config::Region::new(region))
217+
.endpoint_url(&endpoint)
218+
.credentials_provider(credentials)
219+
.force_path_style(true)
220+
.build();
221+
222+
Ok(S3Client::from_conf(s3_config))
223+
}

src/interfaces/http/handlers/core/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub mod mcp;
1212
pub mod metrics;
1313
pub mod prometheus;
1414
pub mod slack;
15+
pub mod streaming;
1516
pub mod system;
1617
pub mod websocket;
1718

@@ -26,5 +27,6 @@ pub use mcp::*;
2627
pub use metrics::metrics_handler as core_metrics_handler;
2728
pub use prometheus::*;
2829
pub use slack::*;
30+
pub use streaming::*;
2931
pub use system::*;
3032
pub use websocket::*;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
use axum::{response::IntoResponse, Json};
2+
use serde_json::json;
3+
4+
/// Streaming data endpoint
5+
pub async fn streaming() -> impl IntoResponse {
6+
match crate::domain::services::streaming_service::get_streaming_data().await {
7+
Ok(data) => Json(data).into_response(),
8+
Err(e) => Json(json!({
9+
"status": "error",
10+
"message": e,
11+
"items": []
12+
}))
13+
.into_response(),
14+
}
15+
}
16+
17+
/// Force refresh streaming data
18+
pub async fn streaming_refresh() -> impl IntoResponse {
19+
match crate::domain::services::streaming_service::force_refresh().await {
20+
Ok(data) => Json(json!({
21+
"status": "success",
22+
"message": "Streaming data refreshed",
23+
"items": data["items"],
24+
"cached_at": data["cached_at"]
25+
})).into_response(),
26+
Err(e) => Json(json!({
27+
"status": "error",
28+
"message": e
29+
})).into_response(),
30+
}
31+
}

src/interfaces/http/routes.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ pub fn configure_routes(state: AppState) -> Router {
125125
.route("/api/argocd/sync", post(argocd_sync))
126126
.route("/api/news", get(news))
127127
.route("/api/news/refresh", post(news_refresh))
128+
.route("/api/streaming", get(streaming))
129+
.route("/api/streaming/refresh", post(streaming_refresh))
128130
// Kubernetes routes - canonical paths only
129131
// Use /api/k8s/* paths (not legacy /api/cluster/overview, /api/nodes/status, /api/pods/status)
130132
// Monitoring routes

static/index.html

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@
9696
<script src="/static/js/security.js?v={{VERSION}}"></script>
9797
<script src="/static/js/cilium-network.js?v={{VERSION}}"></script>
9898
<script src="/static/js/mqtt.js?v={{VERSION}}"></script>
99-
<script src="/static/js/system.js?v={{VERSION}}"></script>
99+
<script system src="/static/js/system.js?v={{VERSION}}"></script>
100+
<script streaming src="/static/js/streaming.js?v={{VERSION}}"></script>
100101

101102
<!-- Main Orchestrator -->
102103
<script src="/static/js/dashboard.js?v={{VERSION}}"></script>
@@ -277,6 +278,12 @@
277278
<span class="nav-link-text">News</span>
278279
</a>
279280
</li>
281+
<li class="nav-item">
282+
<a class="nav-link" data-page="streaming" href="#streaming">
283+
<i class="mdi mdi-play-circle"></i>
284+
<span class="nav-link-text">Streaming</span>
285+
</a>
286+
</li>
280287
<!-- Quotas moved to Metrics section -->
281288
<li class="nav-item" id="pwa-install-item" style="display: none;">
282289
<a class="nav-link" href="#" onclick="installPWA(); return false;">
@@ -500,6 +507,11 @@ <h2 class="section-title">ArgoCD Applications Status</h2>
500507
<div class="loading">Loading...</div>
501508
</section>
502509

510+
<!-- Streaming Section -->
511+
<section class="argocd-section tab-content" data-tab="streaming" style="display: none;">
512+
<div class="loading">Loading...</div>
513+
</section>
514+
503515
<!-- Backups Section -->
504516
<section class="argocd-section tab-content" data-tab="backups" style="display: none;">
505517
<h2 class="section-title">Tâches Planifiées</h2>

0 commit comments

Comments
 (0)