Skip to main content

jellytau_lib/commands/storage/
people.rs

1//! Person/cast metadata cache commands.
2//!
3//! TRACES: UR-035, UR-036 | IR-023 | DR-040, DR-041
4
5use serde::{Deserialize, Serialize};
6use std::sync::Arc;
7use tauri::State;
8
9use super::DatabaseWrapper;
10use crate::storage::db_service::{DatabaseService, Query, QueryParam};
11
12/// Cached person info returned to frontend
13#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct CachedPerson {
16    pub id: String,
17    pub server_id: String,
18    pub name: String,
19    pub overview: Option<String>,
20    pub primary_image_tag: Option<String>,
21    pub premiere_date: Option<String>,
22    pub end_date: Option<String>,
23}
24
25/// Item-person association for caching
26#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct CachedItemPerson {
29    pub item_id: String,
30    pub person_id: String,
31    pub server_id: String,
32    pub person_type: String,
33    pub role: Option<String>,
34    pub sort_order: i32,
35}
36
37/// Save a person to the cache
38#[tauri::command]
39#[specta::specta]
40pub async fn storage_save_person(
41    db: State<'_, DatabaseWrapper>,
42    person: CachedPerson,
43) -> Result<(), String> {
44    let db_service = {
45        let database = db.0.lock().map_err(|e| e.to_string())?;
46        Arc::new(database.service())
47    };
48
49    let query = Query::with_params(
50        // A real UPSERT, not INSERT OR REPLACE — `people` is now backed by the
51        // `people_fts` index (migration 022), and REPLACE would orphan an index
52        // entry on every re-cache: it fires no AFTER DELETE trigger without
53        // `recursive_triggers`, and reassigns the rowid that `content_rowid`
54        // refers to. Same defect as DR-110 fixed for `items`.
55        //
56        // TRACES: UR-065 | DR-110, DR-111
57        "INSERT INTO people (
58            id, server_id, name, overview, primary_image_tag,
59            premiere_date, end_date, synced_at
60        ) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
61        ON CONFLICT(id) DO UPDATE SET
62            server_id = excluded.server_id,
63            name = excluded.name,
64            overview = excluded.overview,
65            primary_image_tag = excluded.primary_image_tag,
66            premiere_date = excluded.premiere_date,
67            end_date = excluded.end_date,
68            synced_at = CURRENT_TIMESTAMP",
69        vec![
70            QueryParam::String(person.id),
71            QueryParam::String(person.server_id),
72            QueryParam::String(person.name),
73            person
74                .overview
75                .map(QueryParam::String)
76                .unwrap_or(QueryParam::Null),
77            person
78                .primary_image_tag
79                .map(QueryParam::String)
80                .unwrap_or(QueryParam::Null),
81            person
82                .premiere_date
83                .map(QueryParam::String)
84                .unwrap_or(QueryParam::Null),
85            person
86                .end_date
87                .map(QueryParam::String)
88                .unwrap_or(QueryParam::Null),
89        ],
90    );
91
92    db_service.execute(query).await.map_err(|e| e.to_string())?;
93    Ok(())
94}
95
96/// Get a cached person by ID
97#[tauri::command]
98#[specta::specta]
99pub async fn storage_get_person(
100    db: State<'_, DatabaseWrapper>,
101    person_id: String,
102) -> Result<Option<CachedPerson>, String> {
103    let db_service = {
104        let database = db.0.lock().map_err(|e| e.to_string())?;
105        Arc::new(database.service())
106    };
107
108    let query = Query::with_params(
109        "SELECT id, server_id, name, overview, primary_image_tag, premiere_date, end_date
110         FROM people WHERE id = ?",
111        vec![QueryParam::String(person_id)],
112    );
113
114    let result = db_service
115        .query_optional(query, |row| {
116            Ok(CachedPerson {
117                id: row.get(0)?,
118                server_id: row.get(1)?,
119                name: row.get(2)?,
120                overview: row.get(3)?,
121                primary_image_tag: row.get(4)?,
122                premiere_date: row.get(5)?,
123                end_date: row.get(6)?,
124            })
125        })
126        .await
127        .map_err(|e| e.to_string())?;
128
129    Ok(result)
130}
131
132/// Save item-person associations (batch)
133#[tauri::command]
134#[specta::specta]
135pub async fn storage_save_item_people(
136    db: State<'_, DatabaseWrapper>,
137    associations: Vec<CachedItemPerson>,
138) -> Result<(), String> {
139    let db_service = {
140        let database = db.0.lock().map_err(|e| e.to_string())?;
141        Arc::new(database.service())
142    };
143
144    // Clone associations for the closure
145    let associations_clone = associations.clone();
146
147    // Use transaction for batch insert
148    db_service
149        .transaction(move |tx| {
150            for assoc in &associations_clone {
151                let query = Query::with_params(
152                    "INSERT OR REPLACE INTO item_people (
153                    item_id, person_id, server_id, person_type, role, sort_order, synced_at
154                ) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
155                    vec![
156                        QueryParam::String(assoc.item_id.clone()),
157                        QueryParam::String(assoc.person_id.clone()),
158                        QueryParam::String(assoc.server_id.clone()),
159                        QueryParam::String(assoc.person_type.clone()),
160                        assoc
161                            .role
162                            .clone()
163                            .map(QueryParam::String)
164                            .unwrap_or(QueryParam::Null),
165                        QueryParam::Int(assoc.sort_order),
166                    ],
167                );
168                tx.execute(query)?;
169            }
170            Ok(())
171        })
172        .await
173        .map_err(|e| e.to_string())?;
174
175    Ok(())
176}
177
178/// Get people for an item (with person details joined)
179#[tauri::command]
180#[specta::specta]
181pub async fn storage_get_item_people(
182    db: State<'_, DatabaseWrapper>,
183    item_id: String,
184) -> Result<Vec<CachedItemPerson>, String> {
185    let db_service = {
186        let database = db.0.lock().map_err(|e| e.to_string())?;
187        Arc::new(database.service())
188    };
189
190    let query = Query::with_params(
191        "SELECT ip.item_id, ip.person_id, ip.server_id, ip.person_type, ip.role, ip.sort_order
192         FROM item_people ip
193         WHERE ip.item_id = ?
194         ORDER BY ip.sort_order ASC",
195        vec![QueryParam::String(item_id)],
196    );
197
198    let people = db_service
199        .query_many(query, |row| {
200            Ok(CachedItemPerson {
201                item_id: row.get(0)?,
202                person_id: row.get(1)?,
203                server_id: row.get(2)?,
204                person_type: row.get(3)?,
205                role: row.get(4)?,
206                sort_order: row.get(5)?,
207            })
208        })
209        .await
210        .map_err(|e| e.to_string())?;
211
212    Ok(people)
213}