//! Module de gestion de la base de données SQLite pour le cache //! //! Ce module fournit une interface générique pour gérer les métadonnées //! des éléments en cache, avec tracking des accès et des statistiques. use chrono::Utc; use rusqlite::{params, Connection, Error, OptionalExtension}; use serde::Serialize; use serde_json::{Map, Number, Value}; use tracing::{trace, warn}; use std::path::Path; use std::str::FromStr; use std::sync::{Mutex, MutexGuard}; use std::time::Instant; #[cfg(feature = "openapi")] use utoipa::ToSchema; /// Entrée de cache représentant un élément dans la base de données #[derive(Debug, Serialize, Clone)] #[cfg_attr(feature = "openapi", derive(ToSchema))] pub struct CacheEntry { /// Clé primaire unique de l'élément (hash SHA1 de l'URL) #[cfg_attr(feature = "openapi", schema(example = "1a2b3c4d5e6f7a8b"))] pub pk: String, /// URL source de l'élément #[cfg_attr(feature = "openapi", schema(example = "https://example.com/resource"))] pub id: Option, /// Collection à laquelle appartient l'élément (optionnel) #[cfg_attr(feature = "openapi", schema(example = "album:123"))] pub collection: Option, /// Nombre d'accès à l'élément #[cfg_attr(feature = "openapi", schema(example = 42))] pub hits: i32, /// Date/heure du dernier accès (RFC3339) #[cfg_attr(feature = "openapi", schema(example = "2025-01-15T10:30:00Z"))] pub last_used: Option, /// Métadonnées JSON optionnelles (ex: métadonnées audio, EXIF images, etc.) #[cfg_attr( feature = "openapi", schema(example = r#"{"title":"Track","artist":"Artist"}"#) )] pub metadata: Option, } /// Base de données SQLite pour le cache /// /// Gère les métadonnées des éléments en cache : /// - Clés primaires (pk) et URLs sources /// - Statistiques d'utilisation (hits, last_used) /// - Opérations CRUD de base #[derive(Debug)] pub struct DB { conn: Mutex, } struct ConnGuard<'a> { ctx: &'static str, guard: MutexGuard<'a, Connection>, } impl<'a> Drop for ConnGuard<'a> { fn drop(&mut self) { trace!("DB mutex → released ({})", self.ctx); } } impl<'a> std::ops::Deref for ConnGuard<'a> { type Target = Connection; fn deref(&self) -> &Self::Target { &self.guard } } impl<'a> std::ops::DerefMut for ConnGuard<'a> { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.guard } } impl DB { fn lock_conn(&self, ctx: &'static str) -> ConnGuard<'_> { // trace!("DB mutex → acquiring ({ctx})"); let start = Instant::now(); let guard = self.conn.lock().unwrap(); let waited = start.elapsed(); // trace!("DB mutex → acquired ({ctx}) in {:?}", waited); if waited > std::time::Duration::from_millis(50) { warn!("DB mutex wait >50 ms ({}): {:?}", ctx, waited); } ConnGuard { ctx, guard } } /// Initialise une nouvelle base de données avec une table personnalisée /// /// # Arguments /// /// * `path` - Chemin vers le fichier de base de données SQLite /// * `table_name` - Nom de la table à créer /// /// # Exemple /// /// ```rust,no_run /// use pmocache::db::DB; /// use std::path::Path; /// /// let db = DB::init(Path::new("cache.db"), "my_cache").unwrap(); /// ``` pub fn init(path: &Path) -> Result { let conn = Connection::open(path)?; conn.execute( "CREATE TABLE IF NOT EXISTS asset ( pk TEXT PRIMARY KEY, collection TEXT, id TEXT, hits INTEGER DEFAULT 0, last_used TEXT, lazy_pk TEXT )", [], )?; conn.execute( "CREATE TABLE IF NOT EXISTS metadata ( pk TEXT, key TEXT, value_type TEXT NOT NULL CHECK (value_type IN ('string','number','boolean','null')), value TEXT, PRIMARY KEY (pk, key), FOREIGN KEY (pk) REFERENCES asset (pk) ON DELETE CASCADE )" , [])?; // Créer un index sur la collection pour les requêtes rapides conn.execute( "CREATE INDEX IF NOT EXISTS idx_asset_collection ON ASSET (collection)", [], )?; // Créer un index composite pour optimiser la politique LRU (get_oldest) conn.execute( "CREATE INDEX IF NOT EXISTS idx_asset_lru ON asset (last_used ASC, hits ASC)", [], )?; // Crée un index composite pour rendre unique les ids si défini dans une collection conn.execute( "CREATE UNIQUE INDEX IF NOT EXISTS asset_collection_id_unique ON asset (collection, id) WHERE id IS NOT NULL;", [], )?; // LAZY PK SUPPORT: Index sur lazy_pk pour lookups rapides (lazy_pk → real pk) conn.execute( "CREATE INDEX IF NOT EXISTS idx_asset_lazy_pk ON asset (lazy_pk)", [], )?; // Index unique sur lazy_pk (non-NULL) pour éviter les doublons // Un lazy_pk ne peut pointer que vers un seul entry conn.execute( "CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_lazy_pk_unique ON asset (lazy_pk) WHERE lazy_pk IS NOT NULL", [], )?; Ok(Self { conn: Mutex::new(conn), }) } /// Ajoute ou met à jour une entrée dans la base de données /// /// # Arguments /// /// * `pk` - Clé primaire de l'élément /// * `url` - URL source de l'élément /// * `collection` - Collection optionnelle à laquelle appartient l'élément pub fn add( &self, pk: &str, id: Option<&str>, collection: Option<&str>, ) -> rusqlite::Result<()> { self.add_with_metadata(pk, id, collection, None) } /// Ajoute ou met à jour une entrée avec métadonnées JSON optionnelles /// /// # Arguments /// /// * `pk` - Clé primaire de l'élément /// * `url` - URL source de l'élément /// * `collection` - Collection optionnelle à laquelle appartient l'élément /// * `metadata_json` - Métadonnées JSON optionnelles pub fn add_with_metadata( &self, pk: &str, id: Option<&str>, collection: Option<&str>, metadata: Option<&Value>, ) -> rusqlite::Result<()> { // Bloc pour limiter la durée du lock { let conn = self.lock_conn("add_with_metadata"); conn.execute( "INSERT INTO asset (pk, id, collection, hits, last_used) VALUES (?1, ?2, ?3, 0, ?4) ON CONFLICT(pk) DO UPDATE SET id = excluded.id, collection = excluded.collection, last_used = excluded.last_used", params![pk, id, collection, Utc::now().to_rfc3339()], )?; } // Lock libéré ici // Appeler set_metadata après avoir libéré le lock pour éviter un deadlock if let Some(metadata) = metadata { self.set_metadata(pk, metadata)?; } Ok(()) } /// Remplace toutes les métadonnées associées à une entrée. /// /// # Arguments /// /// * `pk` - Clé primaire de l'élément ciblé. /// * `metadata` - Objet JSON complet décrivant les nouvelles métadonnées. /// /// # Errors /// /// Retourne une erreur si `metadata` n'est pas un objet JSON ou si l'écriture /// SQLite échoue. pub fn set_metadata(&self, pk: &str, metadata: &Value) -> rusqlite::Result<()> { let metadata_obj = metadata.as_object().ok_or_else(|| { Error::InvalidParameterName("metadata must be a JSON object".to_owned()) })?; let mut conn = self.lock_conn("set_metadata"); let tx = conn.transaction()?; tx.execute("DELETE FROM metadata WHERE pk = ?1", params![pk])?; for (key, value) in metadata_obj.iter() { let (value_type, value_text): (&str, Option) = match value { Value::Null => ("null", None), Value::Bool(b) => ("boolean", Some(b.to_string())), Value::Number(n) => ("number", Some(n.to_string())), Value::String(s) => ("string", Some(s.clone())), Value::Array(_) | Value::Object(_) => ("string", Some(value.to_string())), }; tx.execute( "INSERT INTO metadata (pk, key, value_type, value) VALUES (?1, ?2, ?3, ?4)", params![pk, key, value_type, value_text.as_deref()], )?; } tx.commit() } /// Insère ou met à jour une métadonnée individuelle. /// /// # Arguments /// /// * `pk` - Clé primaire de l'élément concerné. /// * `key` - Nom de la métadonnée à enregistrer. /// * `value` - Valeur JSON à stocker pour cette clé. pub fn set_a_metadata(&self, pk: &str, key: &str, value: Value) -> rusqlite::Result<()> { let (value_type, value_text): (&str, Option) = match value { Value::Null => ("null", None), Value::Bool(b) => ("boolean", Some(b.to_string())), Value::Number(n) => ("number", Some(n.to_string())), Value::String(s) => ("string", Some(s)), Value::Array(arr) => ("string", Some(Value::Array(arr).to_string())), Value::Object(map) => ("string", Some(Value::Object(map).to_string())), }; let conn = self.lock_conn("set_a_metadata"); conn.execute( "INSERT INTO metadata (pk, key, value_type, value) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(pk, key) DO UPDATE SET value_type = excluded.value_type, value = excluded.value", params![pk, key, value_type, value_text.as_deref()], )?; Ok(()) } /// Alias interne pour récupérer une métadonnée individuelle. /// /// Préférer `get_metadata_value` pour les appels externes. pub fn get_a_metadata(&self, pk: &str, key: &str) -> rusqlite::Result> { let conn = self.lock_conn("get_a_metadata"); conn.query_row( "SELECT value_type, value FROM metadata WHERE pk = ?1 AND key = ?2", params![pk, key], |row| { let value_type: String = row.get(0)?; let raw: Option = row.get(1)?; decode_metadata_value(key, &value_type, raw) }, ) .optional() } /// Récupère toutes les métadonnées d'une entrée sous forme d'objet JSON. /// /// Retourne `Ok(None)` si aucune métadonnée n'est présente. pub fn get_metadata(&self, pk: &str) -> rusqlite::Result> { let conn = self.lock_conn("get_metadata"); let mut stmt = conn.prepare("SELECT key, value_type, value FROM metadata WHERE pk = ?1")?; let rows = stmt.query_map([pk], |row| { let key: String = row.get(0)?; let value_type: String = row.get(1)?; let value: Option = row.get(2)?; Ok((key, value_type, value)) })?; let mut metadata = Map::new(); let mut found = false; for row in rows { let (key, value_type, raw) = row?; found = true; let value = match value_type.as_str() { "null" => Value::Null, "boolean" => { let raw = raw.as_deref().ok_or_else(|| { Error::InvalidParameterName(format!( "missing boolean metadata for key '{key}'" )) })?; let parsed = raw.parse::().map_err(|_| { Error::InvalidParameterName(format!( "invalid boolean metadata for key '{key}'" )) })?; Value::Bool(parsed) } "number" => { let raw = raw.as_deref().ok_or_else(|| { Error::InvalidParameterName(format!( "missing number metadata for key '{key}'" )) })?; let number = Number::from_str(raw).map_err(|_| { Error::InvalidParameterName(format!( "invalid number metadata for key '{key}'" )) })?; Value::Number(number) } "string" => Value::String(raw.unwrap_or_default()), other => { return Err(Error::InvalidParameterName(format!( "unknown metadata type '{other}' for key '{key}'" ))) } }; metadata.insert(key, value); } if found { Ok(Some(Value::Object(metadata))) } else { Ok(None) } } /// Enregistre l'URL d'origine liée à un élément du cache. /// /// Cette méthode détecte automatiquement les collisions de pk : /// si le pk existe déjà avec une URL différente, un log d'erreur est émis. pub fn set_origin_url(&self, pk: &str, origin_url: &str) -> rusqlite::Result<()> { // Vérifier si ce pk a déjà une URL d'origine différente (détection de collision) if let Ok(Some(existing_url)) = self.get_origin_url(pk) { if existing_url != origin_url { tracing::error!( "🚨 COLLISION DE PK DÉTECTÉE: pk='{}' existe déjà avec origin_url='{}' mais tentative d'enregistrement avec origin_url='{}'", pk, existing_url, origin_url ); tracing::error!( " Cela indique que deux fichiers différents ont généré le même pk. Considérez augmenter la taille du header pour le calcul du pk." ); } } self.set_a_metadata(pk, "origin_url", Value::String(origin_url.to_owned())) } /// Récupère l'URL d'origine précédemment stockée pour un élément. /// /// Retourne `Ok(None)` lorsqu'aucune URL n'a été définie. pub fn get_origin_url(&self, pk: &str) -> rusqlite::Result> { match self.get_metadata_value(pk, "origin_url")? { Some(Value::String(url)) => Ok(Some(url)), Some(Value::Null) | None => Ok(None), Some(other) => Err(Error::InvalidParameterName(format!( "metadata 'origin_url' must be a string, got {other}" ))), } } /// Recherche un pk par son URL d'origine. /// /// Cette méthode permet de vérifier si un fichier avec une URL donnée /// est déjà en cache avant de lancer un téléchargement. /// /// # Arguments /// /// * `origin_url` - L'URL d'origine à rechercher /// /// # Returns /// /// * `Ok(Some(pk))` - Le pk du fichier en cache avec cette URL /// * `Ok(None)` - Aucun fichier avec cette URL n'est en cache pub fn get_pk_by_origin_url(&self, origin_url: &str) -> rusqlite::Result> { let conn = self.lock_conn("get_pk_by_origin_url"); conn.query_row( "SELECT pk FROM metadata WHERE key = 'origin_url' AND value = ?", [origin_url], |row| row.get(0), ) .optional() } /// Récupère uniquement les métadonnées JSON d'une entrée /// /// # Arguments /// /// * `pk` - Clé primaire de l'élément /// /// # Returns /// /// Les métadonnées JSON si présentes, None sinon pub fn get_metadata_json(&self, pk: &str) -> rusqlite::Result> { Ok(self.get_metadata(pk)?.map(|value| value.to_string())) } /// Récupère une métadonnée individuelle, si elle existe. pub fn get_metadata_value(&self, pk: &str, key: &str) -> rusqlite::Result> { let conn = self.lock_conn("get_metadata_value"); conn.query_row( "SELECT value_type, value FROM metadata WHERE pk = ?1 AND key = ?2", params![pk, key], |row| { let value_type: String = row.get(0)?; let raw: Option = row.get(1)?; decode_metadata_value(key, &value_type, raw) }, ) .optional() } /// Récupère une entrée de la base de données par sa clé /// /// # Arguments /// * `pk` - Clé primaire de l'élément à récupérer. /// * `with_metadata` - Charge les métadonnées associées si `true`. pub fn get(&self, pk: &str, with_metadata: bool) -> rusqlite::Result { let mut entry = { let conn = self.lock_conn("get"); conn.query_row( "SELECT pk, id, collection, hits, last_used \ FROM asset \ WHERE pk = ?1", [pk], |row| { Ok(CacheEntry { pk: row.get(0)?, id: row.get::<_, Option>(1)?, collection: row.get(2)?, hits: row.get(3)?, last_used: row.get(4)?, metadata: None, }) }, )? }; if with_metadata { entry.metadata = self.get_metadata(&entry.pk)?; } Ok(entry) } /// Récupère une entrée en utilisant la paire `(collection, id)`. /// /// # Arguments /// /// * `collection` - Collection dans laquelle chercher. /// * `id` - Identifiant logique de l'élément. /// * `with_metadata` - Charge les métadonnées associées si `true`. pub fn get_from_id( &self, collection: &str, id: &str, with_metadata: bool, ) -> rusqlite::Result { let mut entry = { let conn = self.lock_conn("get_from_id"); conn.query_row( "SELECT pk, id, collection, hits, last_used \ FROM asset \ WHERE collection = ?1 AND id = ?2", params![collection, id], |row| { Ok(CacheEntry { pk: row.get(0)?, id: row.get::<_, Option>(1)?, collection: row.get(2)?, hits: row.get(3)?, last_used: row.get(4)?, metadata: None, }) }, )? }; if with_metadata { entry.metadata = self.get_metadata(&entry.pk)?; } Ok(entry) } /// Indique si une collection contient un identifiant donné. /// /// Retourne `false` si l'enregistrement n'existe pas ou si la requête échoue. pub fn does_collection_contain_id(&self, collection: &str, id: &str) -> bool { let conn = self.lock_conn("does_collection_contain_id"); conn.query_row( "SELECT EXISTS(SELECT 1 FROM asset WHERE collection = ?1 AND id = ?2)", params![collection, id], |row| row.get::<_, i64>(0), ) .map(|flag| flag != 0) .unwrap_or(false) } /// Retourne la clé primaire associée à la paire `(collection, id)`. /// /// # Errors /// /// Retourne `QueryReturnedNoRows` si aucun enregistrement ne correspond. pub fn get_pk_from_id(&self, collection: &str, id: &str) -> rusqlite::Result { let conn = self.lock_conn("get_pk_from_id"); conn.query_row( "SELECT pk FROM asset WHERE collection = ?1 AND id = ?2", params![collection, id], |row| row.get(0), ) } /// Définit ou remplace l'identifiant logique (`id`) d'une entrée. /// /// Retourne `QueryReturnedNoRows` si la clé primaire est inconnue. pub fn set_id(&self, pk: &str, id: &str) -> rusqlite::Result<()> { let conn = self.lock_conn("set_id"); let updated = conn.execute("UPDATE asset SET id = ?2 WHERE pk = ?1", params![pk, id])?; if updated == 0 { return Err(Error::QueryReturnedNoRows); } Ok(()) } /// Met à jour le compteur d'accès et la date du dernier accès /// /// # Arguments /// /// * `pk` - Clé primaire de l'élément pub fn update_hit(&self, pk: &str) -> rusqlite::Result<()> { let conn = self.lock_conn("update_hit"); conn.execute( &"UPDATE asset SET hits = hits + 1, last_used = ?1 WHERE pk = ?2", params![Utc::now().to_rfc3339(), pk], )?; Ok(()) } /// Purge toutes les entrées de la base de données. pub fn purge(&self) -> rusqlite::Result<()> { let conn = self.lock_conn("purge"); conn.execute("DELETE FROM asset", [])?; Ok(()) } /// Récupère toutes les entrées, triées par nombre d'accès décroissant. /// /// # Arguments /// /// * `include_metadata` - Ajoute les métadonnées à chaque entrée si `true`. pub fn get_all(&self, include_metadata: bool) -> rusqlite::Result> { let mut entries = { let conn = self.lock_conn("get_all"); let mut stmt = conn.prepare( "SELECT pk, id, collection, hits, last_used FROM asset ORDER BY hits DESC", )?; let rows = stmt.query_map([], |row| { Ok(CacheEntry { pk: row.get(0)?, id: row.get::<_, Option>(1)?, collection: row.get(2)?, hits: row.get(3)?, last_used: row.get(4)?, metadata: None, }) })?; rows.collect::>>()? }; if include_metadata { for entry in entries.iter_mut() { entry.metadata = self.get_metadata(&entry.pk)?; } } Ok(entries) } /// Récupère toutes les entrées d'une collection spécifique /// /// # Arguments /// /// * `collection` - Identifiant de la collection. /// * `include_metadata` - Ajoute les métadonnées à chaque entrée si `true`. pub fn get_by_collection( &self, collection: &str, include_metadata: bool, ) -> rusqlite::Result> { let mut entries = { let conn = self.lock_conn("get_by_collection"); let mut stmt = conn.prepare( "SELECT pk, id, collection, hits, last_used FROM asset WHERE collection = ?1 ORDER BY hits DESC", )?; let rows = stmt.query_map([collection], |row| { Ok(CacheEntry { pk: row.get(0)?, id: row.get::<_, Option>(1)?, collection: row.get(2)?, hits: row.get(3)?, last_used: row.get(4)?, metadata: None, }) })?; rows.collect::>>()? }; if include_metadata { for entry in entries.iter_mut() { entry.metadata = self.get_metadata(&entry.pk)?; } } Ok(entries) } /// Supprime toutes les entrées d'une collection. /// /// Les métadonnées associées sont supprimées automatiquement grâce à la /// contrainte `ON DELETE CASCADE`. pub fn delete_collection(&self, collection: &str) -> rusqlite::Result<()> { let conn = self.lock_conn("delete_collection"); conn.execute("DELETE FROM asset WHERE collection = ?1", [collection])?; Ok(()) } /// Supprime une entrée de la base de données ainsi que ses métadonnées. pub fn delete(&self, pk: &str) -> rusqlite::Result<()> { let conn = self.lock_conn("delete"); conn.execute("DELETE FROM asset WHERE pk = ?1", [pk])?; Ok(()) } /// Compte le nombre total d'entrées dans le cache /// /// # Returns /// /// Le nombre total d'entrées pub fn count(&self) -> rusqlite::Result { let conn = self.lock_conn("count"); let count: i64 = conn.query_row("SELECT COUNT(*) FROM asset", [], |row| row.get(0))?; Ok(count as usize) } /// Récupère les N entrées les plus anciennes (LRU - Least Recently Used) /// /// Trie par last_used (les plus anciens en premier), puis par hits (les moins utilisés). /// Utile pour implémenter une politique d'éviction LRU. /// /// # Arguments /// /// * `limit` - Nombre maximum d'entrées à récupérer /// /// # Returns /// /// Liste des entrées les plus anciennes, triées par last_used ASC pub fn get_oldest(&self, limit: usize) -> rusqlite::Result> { let conn = self.lock_conn("get_oldest"); let mut stmt = conn.prepare( "SELECT pk, id, collection, hits, last_used FROM asset ORDER BY last_used ASC, hits ASC LIMIT ?1", )?; let entries = stmt .query_map([limit], |row| { Ok(CacheEntry { pk: row.get(0)?, id: row.get::<_, Option>(1)?, collection: row.get(2)?, hits: row.get(3)?, last_used: row.get(4)?, metadata: None, }) })? .collect::>>()?; Ok(entries) } // ============================================================================ // LAZY PK SUPPORT // ============================================================================ /// Ajoute une entrée en mode lazy (pk = lazy_pk tant que non téléchargé) /// /// Utilisé pour créer des entries sans télécharger le fichier. /// Le lazy_pk est calculé à partir de l'URL et sert temporairement /// également de pk pour satisfaire les contraintes de clé étrangère. /// /// # Arguments /// /// * `lazy_pk` - PK temporaire (format "L:" + hash(url)) /// * `id` - Identifiant optionnel /// * `collection` - Collection optionnelle pub fn add_lazy( &self, lazy_pk: &str, id: Option<&str>, collection: Option<&str>, ) -> rusqlite::Result<()> { let conn = self.lock_conn("add_lazy"); conn.execute( "INSERT INTO asset (pk, lazy_pk, id, collection, hits, last_used) VALUES (?1, ?1, ?2, ?3, 0, ?4)", params![lazy_pk, id, collection, Utc::now().to_rfc3339()], )?; Ok(()) } /// Récupère le real pk associé à un lazy_pk /// /// # Arguments /// /// * `lazy_pk` - Le lazy PK à rechercher /// /// # Returns /// /// * `Ok(Some(pk))` - Le real pk si le fichier a été téléchargé /// * `Ok(None)` - Pas encore téléchargé ou lazy_pk inconnu pub fn get_pk_by_lazy_pk(&self, lazy_pk: &str) -> rusqlite::Result> { let conn = self.lock_conn("get_pk_by_lazy_pk"); let result: Option<(String, Option)> = conn .query_row( "SELECT pk, lazy_pk FROM asset WHERE lazy_pk = ?1", [lazy_pk], |row| Ok((row.get(0)?, row.get(1)?)), ) .optional()?; Ok(result.and_then(|(pk, lazy)| { if let Some(lazy_pk_value) = lazy { if pk == lazy_pk_value { None } else { Some(pk) } } else { Some(pk) } })) } /// Vérifie l'existence d'une entrée lazy sans télécharger le fichier /// /// Retourne `true` si une ligne avec `lazy_pk` existe, même si `pk` est `NULL`. pub fn has_lazy_entry(&self, lazy_pk: &str) -> rusqlite::Result { let conn = self.lock_conn("has_lazy_entry"); let exists: Option = conn .query_row( "SELECT 1 FROM asset WHERE lazy_pk = ?1 LIMIT 1", [lazy_pk], |row| row.get(0), ) .optional()?; Ok(exists.is_some()) } /// Transition d'une entry lazy vers downloaded (ajoute le real pk) /// /// Cette méthode est appelée après le téléchargement d'un fichier lazy. /// Elle crée une nouvelle entry avec le real pk ET garde le lazy_pk /// pour permettre aux Control Points de continuer à utiliser l'URL lazy. /// /// # Arguments /// /// * `lazy_pk` - Le lazy PK de l'entry originale /// * `real_pk` - Le real PK calculé après téléchargement pub fn update_lazy_to_downloaded(&self, lazy_pk: &str, real_pk: &str) -> rusqlite::Result<()> { let mut conn = self.lock_conn("update_lazy_to_downloaded"); let tx = conn.transaction()?; // 1. Récupérer l'entry lazy (pk = lazy_pk tant que pas téléchargé) let (old_pk, collection, id, hits): (String, Option, Option, i32) = tx .query_row( "SELECT pk, collection, id, hits FROM asset WHERE lazy_pk = ?1", [lazy_pk], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), ) .optional()? .ok_or_else(|| Error::QueryReturnedNoRows)?; if old_pk == real_pk { // Rien à faire si déjà commuté return Ok(()); } let now = Utc::now().to_rfc3339(); let hits_to_add = if hits > 0 { hits } else { 1 }; // 2. Créer/mettre à jour l'entry avec le real pk tx.execute( "INSERT INTO asset (pk, lazy_pk, collection, id, hits, last_used) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(pk) DO UPDATE SET lazy_pk = excluded.lazy_pk, collection = COALESCE(excluded.collection, collection), id = COALESCE(excluded.id, id), hits = hits + excluded.hits, last_used = excluded.last_used", params![real_pk, lazy_pk, collection, id, hits_to_add, now], )?; // 3. Re-pointer les métadonnées vers le real pk tx.execute( "UPDATE metadata SET pk = ?1 WHERE pk = ?2", params![real_pk, old_pk], )?; // 4. Supprimer l'ancienne entry lazy tx.execute("DELETE FROM asset WHERE pk = ?1", [old_pk])?; tx.commit() } /// Recherche une entry par son origin_url /// /// Retourne (pk, lazy_pk) si trouvé. Vérifie à la fois les entries /// eager (avec pk) et lazy (avec lazy_pk). /// /// # Arguments /// /// * `url` - L'URL d'origine à rechercher /// /// # Returns /// /// * `Ok(Some((Some(pk), Some(lazy_pk))))` - Entry téléchargée (lazy→eager) /// * `Ok(Some((Some(pk), None)))` - Entry eager (jamais lazy) /// * `Ok(Some((None, Some(lazy_pk))))` - Entry lazy (pas encore téléchargée) /// * `Ok(None)` - URL inconnue pub fn get_entry_by_url( &self, url: &str, ) -> rusqlite::Result, Option)>> { let conn = self.lock_conn("get_entry_by_url"); // Chercher via origin_url dans metadata // On joint avec asset pour récupérer pk et lazy_pk let raw: Option<(String, Option)> = conn .query_row( "SELECT a.pk, a.lazy_pk FROM asset a JOIN metadata m ON a.pk = m.pk WHERE m.key = 'origin_url' AND m.value = ?1 LIMIT 1", [url], |row| Ok((row.get(0)?, row.get(1)?)), ) .optional()?; let result = raw.map(|(pk, lazy_pk)| { if let Some(ref lazy) = lazy_pk { if pk == *lazy { (None, Some(lazy.clone())) } else { (Some(pk), lazy_pk) } } else { (Some(pk), lazy_pk) } }); Ok(result) } /// Met à jour le compteur d'accès pour une entry lazy (pk = NULL) /// /// # Arguments /// /// * `lazy_pk` - Le lazy PK de l'entry pub fn update_hit_by_lazy_pk(&self, lazy_pk: &str) -> rusqlite::Result<()> { let conn = self.lock_conn("update_hit_by_lazy_pk"); conn.execute( "UPDATE asset SET hits = hits + 1, last_used = ?1 WHERE lazy_pk = ?2", params![Utc::now().to_rfc3339(), lazy_pk], )?; Ok(()) } /// Enregistre l'URL d'origine pour une entry lazy /// /// Contrairement à `set_origin_url()` qui utilise le pk, cette méthode /// utilise le lazy_pk comme clé dans la table metadata (car pk = NULL). /// /// # Arguments /// /// * `lazy_pk` - Le lazy PK de l'entry /// * `origin_url` - L'URL d'origine à stocker pub fn set_origin_url_for_lazy(&self, lazy_pk: &str, origin_url: &str) -> rusqlite::Result<()> { self.set_a_metadata_by_key(lazy_pk, "origin_url", Value::String(origin_url.to_owned())) } /// Version générique de set_a_metadata qui accepte une clé arbitraire /// /// Utilisé en interne pour stocker des métadonnées avec lazy_pk au lieu de pk pub fn set_a_metadata_by_key( &self, key: &str, metadata_key: &str, value: Value, ) -> rusqlite::Result<()> { let (value_type, value_text): (&str, Option) = match value { Value::Null => ("null", None), Value::Bool(b) => ("boolean", Some(b.to_string())), Value::Number(n) => ("number", Some(n.to_string())), Value::String(s) => ("string", Some(s)), Value::Array(arr) => ("string", Some(Value::Array(arr).to_string())), Value::Object(map) => ("string", Some(Value::Object(map).to_string())), }; let conn = self.lock_conn("set_a_metadata_by_key"); conn.execute( "INSERT INTO metadata (pk, key, value_type, value) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(pk, key) DO UPDATE SET value_type = excluded.value_type, value = excluded.value", params![key, metadata_key, value_type, value_text.as_deref()], )?; Ok(()) } } /// Convertit une ligne de la table `metadata` en valeur JSON. fn decode_metadata_value( key: &str, value_type: &str, raw: Option, ) -> rusqlite::Result { match value_type { "null" => Ok(Value::Null), "boolean" => { let raw = raw.as_deref().ok_or_else(|| { Error::InvalidParameterName(format!("missing boolean metadata for '{key}'")) })?; raw.parse::().map(Value::Bool).map_err(|_| { Error::InvalidParameterName(format!("invalid boolean metadata for '{key}'")) }) } "number" => { let raw = raw.as_deref().ok_or_else(|| { Error::InvalidParameterName(format!("missing number metadata for '{key}'")) })?; Number::from_str(raw).map(Value::Number).map_err(|_| { Error::InvalidParameterName(format!("invalid number metadata for '{key}'")) }) } "string" => { let raw = raw.unwrap_or_default(); let trimmed = raw.trim_start(); if trimmed.starts_with('{') || trimmed.starts_with('[') { if let Ok(json) = serde_json::from_str::(&raw) { return Ok(json); } } Ok(Value::String(raw)) } other => Err(Error::InvalidParameterName(format!( "unknown metadata type '{other}' for key '{key}'" ))), } }