Skip to content

Commit e073dbb

Browse files
committed
cascade delete triggers and face recognition test fix
Add migration 051 with cascade delete triggers for all parent-child relationships (movies→medias, books→medias, series→episodes, tags→children, people→faces/mappings, channels→variants). Includes one-time orphan cleanup. Fix face recognition test to use relative paths (test_data/face1.jpg and face2.webp) so it works cross-platform, and assert on similarity score.
1 parent 6094020 commit e073dbb

File tree

11 files changed

+918
-171
lines changed

11 files changed

+918
-171
lines changed

src/model/medias.rs

Lines changed: 106 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2309,25 +2309,7 @@ impl ModelController {
23092309
) -> crate::error::Result<Vec<u8>> {
23102310
requesting_user.check_file_role(library_id, media_id, LibraryRole::Read)?;
23112311

2312-
let store = self.store.get_library_store(library_id)?;
2313-
let source =
2314-
store
2315-
.get_media_source(&media_id)
2316-
.await?
2317-
.ok_or(SourcesError::UnableToFindSource(
2318-
library_id.to_string(),
2319-
media_id.to_string(),
2320-
format!("mediaid: {}", media_id),
2321-
"get_video_thumb".to_string(),
2322-
))?;
2323-
2324-
let m = self.source_for_library(&library_id).await?;
2325-
let local_path = m.local_path(&source.source);
2326-
let uri = if let Some(local_path) = local_path {
2327-
local_path.to_str().unwrap().to_string()
2328-
} else {
2329-
ModelController::get_temporary_local_read_url(library_id, media_id, None).await?
2330-
};
2312+
let uri = self.get_media_uri(library_id, media_id, None).await?;
23312313
let thumb = video_tools::thumb_video(&uri, time).await?;
23322314
let mut cursor = std::io::Cursor::new(thumb);
23332315
let thumb = resize_image_reader(Box::pin(cursor), 512, format, quality, false).await?;
@@ -2390,6 +2372,110 @@ impl ModelController {
23902372
Ok(uri)
23912373
}
23922374

2375+
/// Returns a local file path (for local sources) or a temporary authenticated URL
2376+
/// (for plugin/virtual sources) that can be used as FFmpeg input.
2377+
pub async fn get_media_uri(
2378+
&self,
2379+
library_id: &str,
2380+
media_id: &str,
2381+
delay: Option<u64>,
2382+
) -> crate::error::Result<String> {
2383+
let store = self.store.get_library_store(library_id)?;
2384+
let source =
2385+
store
2386+
.get_media_source(&media_id)
2387+
.await?
2388+
.ok_or(SourcesError::UnableToFindSource(
2389+
library_id.to_string(),
2390+
media_id.to_string(),
2391+
format!("mediaid: {}", media_id),
2392+
"get_media_uri".to_string(),
2393+
))?;
2394+
2395+
let m = self.source_for_library(library_id).await?;
2396+
let local_path = m.local_path(&source.source);
2397+
if let Some(local_path) = local_path {
2398+
Ok(local_path.to_string_lossy().to_string())
2399+
} else {
2400+
Ok(ModelController::get_temporary_local_read_url(library_id, media_id, delay).await?)
2401+
}
2402+
}
2403+
2404+
// -- Media HLS session management --
2405+
2406+
pub async fn get_or_create_media_hls_session(
2407+
&self,
2408+
library_id: &str,
2409+
media_id: &str,
2410+
convert_request: Option<VideoConvertRequest>,
2411+
requesting_user: &ConnectedUser,
2412+
) -> RsResult<String> {
2413+
requesting_user.check_file_role(library_id, media_id, LibraryRole::Read)?;
2414+
2415+
// Crypt libraries are not supported for HLS
2416+
if self.cache_get_library_crypt(library_id).await {
2417+
return Err(crate::Error::UnavailableForCryptedLibraries);
2418+
}
2419+
2420+
// Compute session key
2421+
let convert_hash = if let Some(ref req) = convert_request {
2422+
use std::collections::hash_map::DefaultHasher;
2423+
use std::hash::{Hash, Hasher};
2424+
let json = serde_json::to_string(req).unwrap_or_default();
2425+
let mut hasher = DefaultHasher::new();
2426+
json.hash(&mut hasher);
2427+
format!("{:x}", hasher.finish())
2428+
} else {
2429+
"default".to_string()
2430+
};
2431+
let key = format!("{}:{}:{}", library_id, media_id, convert_hash);
2432+
2433+
// Fast path: check if session already exists
2434+
{
2435+
let sessions = self.media_hls_sessions.read().await;
2436+
if let Some(session) = sessions.get(&key) {
2437+
session.touch();
2438+
return Ok(key);
2439+
}
2440+
}
2441+
2442+
// Resolve input: local path or temp URL (12 hours for movie sessions)
2443+
let uri = self.get_media_uri(library_id, media_id, Some(43200)).await?;
2444+
2445+
// Create and start the session
2446+
crate::tools::media_hls_session::start_media_hls_session(
2447+
key.clone(),
2448+
library_id.to_string(),
2449+
media_id.to_string(),
2450+
&uri,
2451+
convert_request,
2452+
self.media_hls_sessions.clone(),
2453+
)
2454+
.await?;
2455+
2456+
Ok(key)
2457+
}
2458+
2459+
pub async fn stop_media_hls_session(
2460+
&self,
2461+
library_id: &str,
2462+
media_id: &str,
2463+
) -> RsResult<()> {
2464+
let keys_to_stop: Vec<String> = {
2465+
let sessions = self.media_hls_sessions.read().await;
2466+
sessions
2467+
.keys()
2468+
.filter(|k| k.starts_with(&format!("{}:{}:", library_id, media_id)))
2469+
.cloned()
2470+
.collect()
2471+
};
2472+
2473+
for key in &keys_to_stop {
2474+
crate::tools::media_hls_session::stop_session(key, &self.media_hls_sessions).await;
2475+
}
2476+
Ok(())
2477+
}
2478+
23932479
pub async fn get_file_share_token(
23942480
&self,
23952481
library_id: &str,
@@ -3275,25 +3361,7 @@ impl ModelController {
32753361
) -> crate::Result<()> {
32763362
requesting_user.check_file_role(library_id, media_id, LibraryRole::Read)?;
32773363

3278-
let store = self.store.get_library_store(library_id)?;
3279-
let source =
3280-
store
3281-
.get_media_source(&media_id)
3282-
.await?
3283-
.ok_or(SourcesError::UnableToFindSource(
3284-
library_id.to_string(),
3285-
media_id.to_string(),
3286-
format!("mediaid: {}", media_id),
3287-
"update_video_infos".to_string(),
3288-
))?;
3289-
3290-
let m = self.source_for_library(&library_id).await?;
3291-
let local_path = m.local_path(&source.source);
3292-
let uri = if let Some(local_path) = local_path {
3293-
local_path.to_str().unwrap().to_string()
3294-
} else {
3295-
ModelController::get_temporary_local_read_url(library_id, media_id, Some(240)).await?
3296-
};
3364+
let uri = self.get_media_uri(library_id, media_id, Some(240)).await?;
32973365

32983366
let videos_infos = probe_video(&uri).await?;
32993367

src/model/mod.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ pub struct ModelController {
144144
pub hls_sessions: Arc<RwLock<HashMap<String, crate::tools::hls_session::HlsSession>>>,
145145
/// Active IPTV streams per library: library_id → set of channel_ids currently streaming
146146
pub active_streams: Arc<RwLock<HashMap<String, HashSet<String>>>>,
147+
148+
/// Media HLS sessions: key = "library:media:convert_hash"
149+
pub media_hls_sessions: Arc<RwLock<HashMap<String, crate::tools::media_hls_session::MediaHlsSession>>>,
147150
}
148151

149152
// Constructor
@@ -171,6 +174,8 @@ impl ModelController {
171174

172175
hls_sessions: Arc::new(RwLock::new(HashMap::new())),
173176
active_streams: Arc::new(RwLock::new(HashMap::new())),
177+
178+
media_hls_sessions: Arc::new(RwLock::new(HashMap::new())),
174179
};
175180

176181
let pm_forload = mc.plugin_manager.clone();
@@ -224,7 +229,7 @@ impl ModelController {
224229
crate::tools::hls_session::cleanup_orphaned_dirs().await;
225230
});
226231

227-
// Spawn HLS session cleanup loop
232+
// Spawn HLS session cleanup loop (channels)
228233
let mc_cleanup = mc.clone();
229234
tokio::spawn(async move {
230235
loop {
@@ -236,6 +241,15 @@ impl ModelController {
236241
}
237242
});
238243

244+
// Spawn media HLS session cleanup loop
245+
let mc_media_cleanup = mc.clone();
246+
tokio::spawn(async move {
247+
loop {
248+
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
249+
crate::tools::media_hls_session::cleanup_stale_sessions(&mc_media_cleanup.media_hls_sessions).await;
250+
}
251+
});
252+
239253
Ok(mc)
240254
}
241255
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
-- ================================================
2+
-- Migration 051: CASCADE DELETE TRIGGERS
3+
-- Replaces manual cleanup with trigger-based cascades
4+
-- ================================================
5+
6+
-- 1. MEDIA CHILDREN CASCADE
7+
-- When a media is deleted, remove all referencing records
8+
CREATE TRIGGER cascade_delete_media_children AFTER DELETE ON medias
9+
BEGIN
10+
DELETE FROM media_tag_mapping WHERE media_ref = OLD.id;
11+
DELETE FROM media_serie_mapping WHERE media_ref = OLD.id;
12+
DELETE FROM media_people_mapping WHERE media_ref = OLD.id;
13+
DELETE FROM shares WHERE media_ref = OLD.id;
14+
DELETE FROM unassigned_faces WHERE media_ref = OLD.id;
15+
DELETE FROM people_faces WHERE media_ref = OLD.id;
16+
DELETE FROM ratings WHERE type = 'media' AND ref = OLD.id;
17+
DELETE FROM media_progress WHERE media_ref = OLD.id;
18+
UPDATE request_processing SET media_ref = NULL WHERE media_ref = OLD.id;
19+
END;
20+
21+
-- 2. MOVIE → MEDIAS CASCADE DELETE
22+
DROP TRIGGER IF EXISTS modified_modified_movie_delete;
23+
CREATE TRIGGER cascade_delete_movie_medias AFTER DELETE ON movies
24+
BEGIN
25+
DELETE FROM medias WHERE movie = OLD.id;
26+
END;
27+
28+
-- 3. BOOK CASCADE DELETE
29+
-- When a book is deleted, remove its medias and mappings
30+
DROP TRIGGER IF EXISTS modified_medias_book_delete;
31+
CREATE TRIGGER cascade_delete_book_children AFTER DELETE ON books
32+
BEGIN
33+
DELETE FROM medias WHERE book = OLD.id;
34+
DELETE FROM book_tag_mapping WHERE book_ref = OLD.id;
35+
DELETE FROM book_people_mapping WHERE book_ref = OLD.id;
36+
END;
37+
38+
-- 4. SERIES CASCADE DELETE
39+
-- When a series is deleted, remove episodes, mappings, and detach books
40+
CREATE TRIGGER cascade_delete_serie_children AFTER DELETE ON series
41+
BEGIN
42+
DELETE FROM episodes WHERE serie_ref = OLD.id;
43+
DELETE FROM media_serie_mapping WHERE serie_ref = OLD.id;
44+
UPDATE books SET serie_ref = NULL, chapter = NULL WHERE serie_ref = OLD.id;
45+
END;
46+
47+
-- 5. EPISODE CASCADE DELETE
48+
-- When an episode is deleted, remove its media mappings
49+
CREATE TRIGGER cascade_delete_episode_children AFTER DELETE ON episodes
50+
BEGIN
51+
DELETE FROM media_serie_mapping
52+
WHERE serie_ref = OLD.serie_ref AND season = OLD.season AND episode = OLD.number;
53+
END;
54+
55+
-- 6. TAG CASCADE DELETE (path-based to catch all descendants without recursive triggers)
56+
CREATE TRIGGER cascade_delete_tag_children AFTER DELETE ON tags
57+
BEGIN
58+
-- Clean up mappings for all descendant tags first
59+
DELETE FROM media_tag_mapping WHERE tag_ref IN (SELECT id FROM tags WHERE path LIKE OLD.path || OLD.name || '/%');
60+
DELETE FROM book_tag_mapping WHERE tag_ref IN (SELECT id FROM tags WHERE path LIKE OLD.path || OLD.name || '/%');
61+
DELETE FROM channel_tag_mapping WHERE tag_ref IN (SELECT id FROM tags WHERE path LIKE OLD.path || OLD.name || '/%');
62+
-- Delete all descendant tags
63+
DELETE FROM tags WHERE path LIKE OLD.path || OLD.name || '/%';
64+
-- Clean up mappings for this tag itself
65+
DELETE FROM media_tag_mapping WHERE tag_ref = OLD.id;
66+
DELETE FROM book_tag_mapping WHERE tag_ref = OLD.id;
67+
DELETE FROM channel_tag_mapping WHERE tag_ref = OLD.id;
68+
END;
69+
70+
-- 7. PEOPLE CASCADE DELETE
71+
CREATE TRIGGER cascade_delete_people_children AFTER DELETE ON people
72+
BEGIN
73+
DELETE FROM media_people_mapping WHERE people_ref = OLD.id;
74+
DELETE FROM book_people_mapping WHERE people_ref = OLD.id;
75+
DELETE FROM people_faces WHERE people_ref = OLD.id;
76+
END;
77+
78+
-- 8. CHANNEL CASCADE DELETE
79+
CREATE TRIGGER cascade_delete_channel_children AFTER DELETE ON channels
80+
BEGIN
81+
DELETE FROM channel_variants WHERE channel_ref = OLD.id;
82+
DELETE FROM channel_tag_mapping WHERE channel_ref = OLD.id;
83+
END;
84+
85+
-- 9. PEOPLE FACES → media_people_mapping CASCADE
86+
CREATE TRIGGER cascade_delete_face_refs AFTER DELETE ON people_faces
87+
BEGIN
88+
UPDATE media_people_mapping SET people_face_ref = NULL WHERE people_face_ref = OLD.id;
89+
END;
90+
91+
-- 10. CLEAN UP ALL EXISTING ORPHANED RECORDS
92+
-- Medias pointing to non-existent parents
93+
DELETE FROM medias WHERE movie IS NOT NULL AND movie NOT IN (SELECT id FROM movies);
94+
DELETE FROM medias WHERE book IS NOT NULL AND book NOT IN (SELECT id FROM books);
95+
-- Orphaned episodes
96+
DELETE FROM episodes WHERE serie_ref NOT IN (SELECT id FROM series);
97+
-- Detach books from non-existent series
98+
UPDATE books SET serie_ref = NULL, chapter = NULL WHERE serie_ref IS NOT NULL AND serie_ref NOT IN (SELECT id FROM series);
99+
-- Orphaned media mappings
100+
DELETE FROM media_tag_mapping WHERE media_ref NOT IN (SELECT id FROM medias);
101+
DELETE FROM media_tag_mapping WHERE tag_ref NOT IN (SELECT id FROM tags);
102+
DELETE FROM media_serie_mapping WHERE media_ref NOT IN (SELECT id FROM medias);
103+
DELETE FROM media_serie_mapping WHERE serie_ref NOT IN (SELECT id FROM series);
104+
DELETE FROM media_people_mapping WHERE media_ref NOT IN (SELECT id FROM medias);
105+
DELETE FROM media_people_mapping WHERE people_ref NOT IN (SELECT id FROM people);
106+
-- Orphaned book mappings
107+
DELETE FROM book_tag_mapping WHERE book_ref NOT IN (SELECT id FROM books);
108+
DELETE FROM book_tag_mapping WHERE tag_ref NOT IN (SELECT id FROM tags);
109+
DELETE FROM book_people_mapping WHERE book_ref NOT IN (SELECT id FROM books);
110+
DELETE FROM book_people_mapping WHERE people_ref NOT IN (SELECT id FROM people);
111+
-- Orphaned channel mappings
112+
DELETE FROM channel_tag_mapping WHERE channel_ref NOT IN (SELECT id FROM channels);
113+
DELETE FROM channel_tag_mapping WHERE tag_ref NOT IN (SELECT id FROM tags);
114+
-- Orphaned channel variants
115+
DELETE FROM channel_variants WHERE channel_ref NOT IN (SELECT id FROM channels);
116+
-- Orphaned face data
117+
DELETE FROM people_faces WHERE media_ref IS NOT NULL AND media_ref NOT IN (SELECT id FROM medias);
118+
DELETE FROM people_faces WHERE people_ref NOT IN (SELECT id FROM people);
119+
DELETE FROM unassigned_faces WHERE media_ref NOT IN (SELECT id FROM medias);
120+
-- Orphaned shares, ratings, progress
121+
DELETE FROM shares WHERE media_ref NOT IN (SELECT id FROM medias);
122+
DELETE FROM ratings WHERE type = 'media' AND ref NOT IN (SELECT id FROM medias);
123+
DELETE FROM media_progress WHERE media_ref NOT IN (SELECT id FROM medias);
124+
-- Orphaned request_processing
125+
UPDATE request_processing SET media_ref = NULL WHERE media_ref IS NOT NULL AND media_ref NOT IN (SELECT id FROM medias);
126+
-- Orphaned tag parent references
127+
DELETE FROM tags WHERE parent IS NOT NULL AND parent NOT IN (SELECT id FROM tags);
128+
-- Orphaned people_face_ref in media_people_mapping
129+
UPDATE media_people_mapping SET people_face_ref = NULL WHERE people_face_ref IS NOT NULL AND people_face_ref NOT IN (SELECT id FROM people_faces);

0 commit comments

Comments
 (0)