2025-10-12 21:39:20 +02:00
|
|
|
|
//! 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;
|
2025-10-26 09:18:48 +01:00
|
|
|
|
use rusqlite::{params, Connection, Error, OptionalExtension};
|
2025-10-19 13:42:29 +02:00
|
|
|
|
use serde::Serialize;
|
2025-10-26 09:18:48 +01:00
|
|
|
|
use serde_json::{Map, Number, Value};
|
2025-10-26 18:57:13 +01:00
|
|
|
|
use tracing::{trace, warn};
|
2025-10-26 09:18:48 +01:00
|
|
|
|
|
2025-10-12 21:39:20 +02:00
|
|
|
|
use std::path::Path;
|
2025-10-26 09:18:48 +01:00
|
|
|
|
use std::str::FromStr;
|
2025-10-26 18:57:13 +01:00
|
|
|
|
use std::sync::{Mutex, MutexGuard};
|
|
|
|
|
|
use std::time::Instant;
|
2025-10-12 21:39:20 +02:00
|
|
|
|
|
|
|
|
|
|
#[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"))]
|
2025-10-26 18:57:13 +01:00
|
|
|
|
pub id: Option<String>,
|
2025-10-12 21:39:20 +02:00
|
|
|
|
/// Collection à laquelle appartient l'élément (optionnel)
|
|
|
|
|
|
#[cfg_attr(feature = "openapi", schema(example = "album:123"))]
|
|
|
|
|
|
pub collection: Option<String>,
|
|
|
|
|
|
/// 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<String>,
|
2025-10-17 23:08:06 +02:00
|
|
|
|
/// Métadonnées JSON optionnelles (ex: métadonnées audio, EXIF images, etc.)
|
2025-10-19 13:42:29 +02:00
|
|
|
|
#[cfg_attr(
|
|
|
|
|
|
feature = "openapi",
|
|
|
|
|
|
schema(example = r#"{"title":"Track","artist":"Artist"}"#)
|
|
|
|
|
|
)]
|
2025-10-26 09:18:48 +01:00
|
|
|
|
pub metadata: Option<Value>,
|
2025-10-12 21:39:20 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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<Connection>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-26 18:57:13 +01:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-12 21:39:20 +02:00
|
|
|
|
impl DB {
|
2025-10-26 21:35:02 +01:00
|
|
|
|
fn lock_conn(&self, ctx: &'static str) -> ConnGuard<'_> {
|
2025-11-20 09:24:09 +01:00
|
|
|
|
// trace!("DB mutex → acquiring ({ctx})");
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let start = Instant::now();
|
|
|
|
|
|
let guard = self.conn.lock().unwrap();
|
|
|
|
|
|
let waited = start.elapsed();
|
|
|
|
|
|
|
2025-11-20 09:24:09 +01:00
|
|
|
|
// trace!("DB mutex → acquired ({ctx}) in {:?}", waited);
|
2025-10-26 18:57:13 +01:00
|
|
|
|
if waited > std::time::Duration::from_millis(50) {
|
|
|
|
|
|
warn!("DB mutex wait >50 ms ({}): {:?}", ctx, waited);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ConnGuard { ctx, guard }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-12 21:39:20 +02:00
|
|
|
|
/// 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();
|
|
|
|
|
|
/// ```
|
2025-10-26 09:18:48 +01:00
|
|
|
|
pub fn init(path: &Path) -> Result<Self, rusqlite::Error> {
|
2025-10-12 21:39:20 +02:00
|
|
|
|
let conn = Connection::open(path)?;
|
|
|
|
|
|
|
2025-10-26 09:18:48 +01:00
|
|
|
|
conn.execute(
|
|
|
|
|
|
"CREATE TABLE IF NOT EXISTS asset (
|
2025-10-12 21:39:20 +02:00
|
|
|
|
pk TEXT PRIMARY KEY,
|
|
|
|
|
|
collection TEXT,
|
2025-10-26 09:18:48 +01:00
|
|
|
|
id TEXT,
|
2025-10-12 21:39:20 +02:00
|
|
|
|
hits INTEGER DEFAULT 0,
|
2025-12-12 21:42:04 +00:00
|
|
|
|
last_used TEXT,
|
|
|
|
|
|
lazy_pk TEXT
|
2025-10-12 21:39:20 +02:00
|
|
|
|
)",
|
2025-10-26 09:18:48 +01:00
|
|
|
|
[],
|
|
|
|
|
|
)?;
|
|
|
|
|
|
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
|
|
|
|
|
|
)"
|
|
|
|
|
|
, [])?;
|
2025-10-12 21:39:20 +02:00
|
|
|
|
|
|
|
|
|
|
// Créer un index sur la collection pour les requêtes rapides
|
2025-10-26 09:18:48 +01:00
|
|
|
|
conn.execute(
|
|
|
|
|
|
"CREATE INDEX IF NOT EXISTS idx_asset_collection
|
|
|
|
|
|
ON ASSET (collection)",
|
|
|
|
|
|
[],
|
|
|
|
|
|
)?;
|
2025-10-12 21:39:20 +02:00
|
|
|
|
|
2025-10-17 23:08:06 +02:00
|
|
|
|
// Créer un index composite pour optimiser la politique LRU (get_oldest)
|
2025-10-26 09:18:48 +01:00
|
|
|
|
conn.execute(
|
|
|
|
|
|
"CREATE INDEX IF NOT EXISTS idx_asset_lru
|
|
|
|
|
|
ON asset (last_used ASC, hits ASC)",
|
|
|
|
|
|
[],
|
|
|
|
|
|
)?;
|
2025-10-17 23:08:06 +02:00
|
|
|
|
|
2025-10-26 09:18:48 +01:00
|
|
|
|
// Crée un index composite pour rendre unique les ids si défini dans une collection
|
|
|
|
|
|
conn.execute(
|
2025-12-12 16:36:31 +00:00
|
|
|
|
"CREATE UNIQUE INDEX
|
2025-10-26 09:18:48 +01:00
|
|
|
|
IF NOT EXISTS asset_collection_id_unique
|
|
|
|
|
|
ON asset (collection, id)
|
|
|
|
|
|
WHERE id IS NOT NULL;",
|
|
|
|
|
|
[],
|
|
|
|
|
|
)?;
|
2025-10-17 23:08:06 +02:00
|
|
|
|
|
2025-12-12 21:42:04 +00:00
|
|
|
|
// LAZY PK SUPPORT: Index sur lazy_pk pour lookups rapides (lazy_pk → real pk)
|
2025-12-12 16:36:31 +00:00
|
|
|
|
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",
|
|
|
|
|
|
[],
|
|
|
|
|
|
)?;
|
|
|
|
|
|
|
2025-10-12 21:39:20 +02:00
|
|
|
|
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
|
2025-10-26 09:18:48 +01:00
|
|
|
|
pub fn add(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
pk: &str,
|
|
|
|
|
|
id: Option<&str>,
|
|
|
|
|
|
collection: Option<&str>,
|
|
|
|
|
|
) -> rusqlite::Result<()> {
|
|
|
|
|
|
self.add_with_metadata(pk, id, collection, None)
|
2025-10-17 23:08:06 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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,
|
2025-10-26 09:18:48 +01:00
|
|
|
|
id: Option<&str>,
|
2025-10-17 23:08:06 +02:00
|
|
|
|
collection: Option<&str>,
|
2025-10-26 09:18:48 +01:00
|
|
|
|
metadata: Option<&Value>,
|
2025-10-17 23:08:06 +02:00
|
|
|
|
) -> rusqlite::Result<()> {
|
2025-11-06 10:07:40 +00:00
|
|
|
|
// 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)?;
|
2025-10-26 09:18:48 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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())
|
|
|
|
|
|
})?;
|
|
|
|
|
|
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let mut conn = self.lock_conn("set_metadata");
|
2025-10-26 09:18:48 +01:00
|
|
|
|
|
|
|
|
|
|
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<String>) = 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<String>) = 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())),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let conn = self.lock_conn("set_a_metadata");
|
2025-10-12 21:39:20 +02:00
|
|
|
|
|
|
|
|
|
|
conn.execute(
|
2025-10-26 09:18:48 +01:00
|
|
|
|
"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()],
|
2025-10-12 21:39:20 +02:00
|
|
|
|
)?;
|
|
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-26 09:18:48 +01:00
|
|
|
|
/// 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<Option<Value>> {
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let conn = self.lock_conn("get_a_metadata");
|
2025-10-26 09:18:48 +01:00
|
|
|
|
|
|
|
|
|
|
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<String> = 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<Option<Value>> {
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let conn = self.lock_conn("get_metadata");
|
2025-10-26 09:18:48 +01:00
|
|
|
|
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<String> = 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::<bool>().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.
|
2025-11-26 10:35:45 +01:00
|
|
|
|
///
|
|
|
|
|
|
/// 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.
|
2025-10-26 09:18:48 +01:00
|
|
|
|
pub fn set_origin_url(&self, pk: &str, origin_url: &str) -> rusqlite::Result<()> {
|
2025-11-26 10:35:45 +01:00
|
|
|
|
// 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."
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-26 09:18:48 +01:00
|
|
|
|
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.
|
|
|
|
|
|
///
|
2025-10-26 13:05:46 +01:00
|
|
|
|
/// Retourne `Ok(None)` lorsqu'aucune URL n'a été définie.
|
2025-10-26 09:18:48 +01:00
|
|
|
|
pub fn get_origin_url(&self, pk: &str) -> rusqlite::Result<Option<String>> {
|
|
|
|
|
|
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}"
|
|
|
|
|
|
))),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-26 10:35:45 +01:00
|
|
|
|
/// 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<Option<String>> {
|
|
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-26 09:18:48 +01:00
|
|
|
|
/// 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<Option<String>> {
|
|
|
|
|
|
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<Option<Value>> {
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let conn = self.lock_conn("get_metadata_value");
|
2025-10-26 09:18:48 +01:00
|
|
|
|
|
|
|
|
|
|
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<String> = row.get(1)?;
|
|
|
|
|
|
decode_metadata_value(key, &value_type, raw)
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
.optional()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-12 21:39:20 +02:00
|
|
|
|
/// Récupère une entrée de la base de données par sa clé
|
|
|
|
|
|
///
|
|
|
|
|
|
/// # Arguments
|
2025-10-26 09:18:48 +01:00
|
|
|
|
/// * `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<CacheEntry> {
|
|
|
|
|
|
let mut entry = {
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let conn = self.lock_conn("get");
|
2025-10-26 09:18:48 +01:00
|
|
|
|
conn.query_row(
|
|
|
|
|
|
"SELECT pk, id, collection, hits, last_used \
|
|
|
|
|
|
FROM asset \
|
|
|
|
|
|
WHERE pk = ?1",
|
|
|
|
|
|
[pk],
|
|
|
|
|
|
|row| {
|
|
|
|
|
|
Ok(CacheEntry {
|
|
|
|
|
|
pk: row.get(0)?,
|
2025-10-26 18:57:13 +01:00
|
|
|
|
id: row.get::<_, Option<String>>(1)?,
|
2025-10-26 09:18:48 +01:00
|
|
|
|
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<CacheEntry> {
|
|
|
|
|
|
let mut entry = {
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let conn = self.lock_conn("get_from_id");
|
2025-10-26 09:18:48 +01:00
|
|
|
|
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)?,
|
2025-10-26 18:57:13 +01:00
|
|
|
|
id: row.get::<_, Option<String>>(1)?,
|
2025-10-26 09:18:48 +01:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-26 13:05:46 +01:00
|
|
|
|
/// 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 {
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let conn = self.lock_conn("does_collection_contain_id");
|
2025-10-26 13:05:46 +01:00
|
|
|
|
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<String> {
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let conn = self.lock_conn("get_pk_from_id");
|
2025-10-26 13:05:46 +01:00
|
|
|
|
conn.query_row(
|
|
|
|
|
|
"SELECT pk FROM asset WHERE collection = ?1 AND id = ?2",
|
|
|
|
|
|
params![collection, id],
|
|
|
|
|
|
|row| row.get(0),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-26 09:18:48 +01:00
|
|
|
|
/// Définit ou remplace l'identifiant logique (`id`) d'une entrée.
|
2025-10-12 21:39:20 +02:00
|
|
|
|
///
|
2025-10-26 09:18:48 +01:00
|
|
|
|
/// Retourne `QueryReturnedNoRows` si la clé primaire est inconnue.
|
|
|
|
|
|
pub fn set_id(&self, pk: &str, id: &str) -> rusqlite::Result<()> {
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let conn = self.lock_conn("set_id");
|
2025-10-26 09:18:48 +01:00
|
|
|
|
let updated = conn.execute("UPDATE asset SET id = ?2 WHERE pk = ?1", params![pk, id])?;
|
|
|
|
|
|
|
|
|
|
|
|
if updated == 0 {
|
|
|
|
|
|
return Err(Error::QueryReturnedNoRows);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(())
|
2025-10-12 21:39:20 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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<()> {
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let conn = self.lock_conn("update_hit");
|
2025-10-12 21:39:20 +02:00
|
|
|
|
|
2025-10-26 09:18:48 +01:00
|
|
|
|
conn.execute(
|
|
|
|
|
|
&"UPDATE asset
|
|
|
|
|
|
SET hits = hits + 1, last_used = ?1
|
|
|
|
|
|
WHERE pk = ?2",
|
|
|
|
|
|
params![Utc::now().to_rfc3339(), pk],
|
|
|
|
|
|
)?;
|
2025-10-12 21:39:20 +02:00
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-26 09:18:48 +01:00
|
|
|
|
/// Purge toutes les entrées de la base de données.
|
2025-10-12 21:39:20 +02:00
|
|
|
|
pub fn purge(&self) -> rusqlite::Result<()> {
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let conn = self.lock_conn("purge");
|
2025-10-26 09:18:48 +01:00
|
|
|
|
conn.execute("DELETE FROM asset", [])?;
|
2025-10-12 21:39:20 +02:00
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-26 09:18:48 +01:00
|
|
|
|
/// 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<Vec<CacheEntry>> {
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let mut entries = {
|
|
|
|
|
|
let conn = self.lock_conn("get_all");
|
2025-10-12 21:39:20 +02:00
|
|
|
|
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let mut stmt = conn.prepare(
|
|
|
|
|
|
"SELECT pk, id, collection, hits, last_used
|
2025-10-26 09:18:48 +01:00
|
|
|
|
FROM asset
|
|
|
|
|
|
ORDER BY hits DESC",
|
2025-10-26 18:57:13 +01:00
|
|
|
|
)?;
|
2025-10-12 21:39:20 +02:00
|
|
|
|
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let rows = stmt.query_map([], |row| {
|
2025-10-19 13:42:29 +02:00
|
|
|
|
Ok(CacheEntry {
|
|
|
|
|
|
pk: row.get(0)?,
|
2025-10-26 18:57:13 +01:00
|
|
|
|
id: row.get::<_, Option<String>>(1)?,
|
2025-10-19 13:42:29 +02:00
|
|
|
|
collection: row.get(2)?,
|
|
|
|
|
|
hits: row.get(3)?,
|
|
|
|
|
|
last_used: row.get(4)?,
|
2025-10-26 09:18:48 +01:00
|
|
|
|
metadata: None,
|
2025-10-19 13:42:29 +02:00
|
|
|
|
})
|
2025-10-26 18:57:13 +01:00
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
|
|
rows.collect::<rusqlite::Result<Vec<_>>>()?
|
|
|
|
|
|
};
|
2025-10-12 21:39:20 +02:00
|
|
|
|
|
2025-10-26 09:18:48 +01:00
|
|
|
|
if include_metadata {
|
|
|
|
|
|
for entry in entries.iter_mut() {
|
|
|
|
|
|
entry.metadata = self.get_metadata(&entry.pk)?;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-12 21:39:20 +02:00
|
|
|
|
Ok(entries)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Récupère toutes les entrées d'une collection spécifique
|
|
|
|
|
|
///
|
|
|
|
|
|
/// # Arguments
|
|
|
|
|
|
///
|
2025-10-26 09:18:48 +01:00
|
|
|
|
/// * `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<Vec<CacheEntry>> {
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let mut entries = {
|
|
|
|
|
|
let conn = self.lock_conn("get_by_collection");
|
2025-10-12 21:39:20 +02:00
|
|
|
|
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let mut stmt = conn.prepare(
|
|
|
|
|
|
"SELECT pk, id, collection, hits, last_used
|
2025-10-26 09:18:48 +01:00
|
|
|
|
FROM asset
|
|
|
|
|
|
WHERE collection = ?1 ORDER BY hits DESC",
|
2025-10-26 18:57:13 +01:00
|
|
|
|
)?;
|
|
|
|
|
|
let rows = stmt.query_map([collection], |row| {
|
2025-10-19 13:42:29 +02:00
|
|
|
|
Ok(CacheEntry {
|
|
|
|
|
|
pk: row.get(0)?,
|
2025-10-26 18:57:13 +01:00
|
|
|
|
id: row.get::<_, Option<String>>(1)?,
|
2025-10-19 13:42:29 +02:00
|
|
|
|
collection: row.get(2)?,
|
|
|
|
|
|
hits: row.get(3)?,
|
|
|
|
|
|
last_used: row.get(4)?,
|
2025-10-26 09:18:48 +01:00
|
|
|
|
metadata: None,
|
2025-10-19 13:42:29 +02:00
|
|
|
|
})
|
2025-10-26 18:57:13 +01:00
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
|
|
rows.collect::<rusqlite::Result<Vec<_>>>()?
|
|
|
|
|
|
};
|
2025-10-12 21:39:20 +02:00
|
|
|
|
|
2025-10-26 09:18:48 +01:00
|
|
|
|
if include_metadata {
|
|
|
|
|
|
for entry in entries.iter_mut() {
|
|
|
|
|
|
entry.metadata = self.get_metadata(&entry.pk)?;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-12 21:39:20 +02:00
|
|
|
|
Ok(entries)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-26 09:18:48 +01:00
|
|
|
|
/// Supprime toutes les entrées d'une collection.
|
2025-10-12 21:39:20 +02:00
|
|
|
|
///
|
2025-10-26 09:18:48 +01:00
|
|
|
|
/// Les métadonnées associées sont supprimées automatiquement grâce à la
|
|
|
|
|
|
/// contrainte `ON DELETE CASCADE`.
|
2025-10-12 21:39:20 +02:00
|
|
|
|
pub fn delete_collection(&self, collection: &str) -> rusqlite::Result<()> {
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let conn = self.lock_conn("delete_collection");
|
2025-10-26 09:18:48 +01:00
|
|
|
|
conn.execute("DELETE FROM asset WHERE collection = ?1", [collection])?;
|
2025-10-12 21:39:20 +02:00
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-26 09:18:48 +01:00
|
|
|
|
/// Supprime une entrée de la base de données ainsi que ses métadonnées.
|
2025-10-12 21:39:20 +02:00
|
|
|
|
pub fn delete(&self, pk: &str) -> rusqlite::Result<()> {
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let conn = self.lock_conn("delete");
|
2025-10-26 09:18:48 +01:00
|
|
|
|
conn.execute("DELETE FROM asset WHERE pk = ?1", [pk])?;
|
2025-10-12 21:39:20 +02:00
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
2025-10-17 22:15:00 +02:00
|
|
|
|
|
|
|
|
|
|
/// Compte le nombre total d'entrées dans le cache
|
|
|
|
|
|
///
|
|
|
|
|
|
/// # Returns
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Le nombre total d'entrées
|
|
|
|
|
|
pub fn count(&self) -> rusqlite::Result<usize> {
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let conn = self.lock_conn("count");
|
2025-10-26 09:18:48 +01:00
|
|
|
|
let count: i64 = conn.query_row("SELECT COUNT(*) FROM asset", [], |row| row.get(0))?;
|
2025-10-17 22:15:00 +02:00
|
|
|
|
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<Vec<CacheEntry>> {
|
2025-10-26 18:57:13 +01:00
|
|
|
|
let conn = self.lock_conn("get_oldest");
|
2025-10-26 09:18:48 +01:00
|
|
|
|
|
|
|
|
|
|
let mut stmt = conn.prepare(
|
Revue de code complète et amélioration des trois crates de cache
## Corrections de bugs
- **CRITIQUE**: Correction du bug SQL dans `pmocache/src/db.rs:get_oldest()`
- La requête référençait des colonnes inexistantes (`source_url`, `metadata_json`)
- Corrigé pour utiliser les bonnes colonnes de la table `asset` (`id`)
## Refactoring et simplifications
- **Factorisation majeure** dans `pmocache/src/cache.rs`:
- Extraction de 3 méthodes helpers pour éliminer ~90 lignes de code dupliqué
entre `add_from_url()` et `add_from_reader()`:
- `check_cached_and_complete()`: vérification cache et intégrité
- `check_ongoing_download()`: gestion des téléchargements en cours
- `finalize_download()`: finalisation avec prébuffering et nettoyage
- Les deux méthodes sont maintenant beaucoup plus lisibles et maintenables
- **Simplification** de `enforce_limit()`:
- Utilisation de `get_file_paths()` au lieu d'itérations manuelles complexes
- Suppression des boucles imbriquées pour une logique plus claire
- **Correction** d'import manquant: ajout de `AsyncReadExt` dans `cache.rs`
## Tests complets ajoutés
### pmocache (27 tests)
- `tests/test_db.rs`: 24 tests couvrant toutes les opérations DB
- CRUD de base (add, get, delete, purge)
- Gestion des métadonnées (tous types JSON)
- Collections (get_by_collection, delete_collection)
- LRU et éviction (get_oldest, count)
- URLs d'origine (set_origin_url, get_origin_url)
- Indexation par (collection, id)
- `tests/test_cache.rs`: 16 tests d'intégration du cache
- Ajout depuis fichier, reader, URL
- Déduplication basée sur contenu
- Collections et gestion
- Éviction LRU automatique
- Purge et consolidation
- Métadonnées et touch
- Prébuffering et téléchargements
### pmoaudiocache (4 tests)
- `tests/test_cache.rs`: Tests spécifiques audio
- Création et configuration
- Collections d'albums
- Éviction LRU avec limite
### pmocovers (6 tests)
- `tests/test_cache.rs`: Tests de cache d'images
- Conversion WebP automatique
- Déduplication d'images identiques
- Gestion de collections
- Éviction LRU
- `tests/test_webp.rs`: Tests du module WebP
- Encodage WebP depuis différents formats
- Redimensionnement carré avec préservation du ratio
- Génération et mise en cache de variantes
- Tests avec différentes tailles (portrait, landscape, carré)
## Améliorations de la couverture
- Passage de **0 test** à **37 tests** au total
- Ajout de `tempfile = "3"` comme dev-dependency dans `pmocache/Cargo.toml`
- Couverture des cas nominaux et des cas limites
- Tests d'intégration et unitaires
## Préservation des APIs
- ✅ Aucune API publique n'a été modifiée ou cassée
- ✅ Toutes les fonctions helpers sont privées (non exposées)
- ✅ Les signatures publiques restent identiques
- ✅ Rétrocompatibilité totale garantie
2025-11-06 08:43:11 +00:00
|
|
|
|
"SELECT pk, id, collection, hits, last_used
|
2025-10-26 09:18:48 +01:00
|
|
|
|
FROM asset
|
2025-10-17 22:15:00 +02:00
|
|
|
|
ORDER BY last_used ASC, hits ASC
|
|
|
|
|
|
LIMIT ?1",
|
2025-10-26 09:18:48 +01:00
|
|
|
|
)?;
|
2025-10-17 22:15:00 +02:00
|
|
|
|
|
2025-10-19 13:42:29 +02:00
|
|
|
|
let entries = stmt
|
|
|
|
|
|
.query_map([limit], |row| {
|
|
|
|
|
|
Ok(CacheEntry {
|
|
|
|
|
|
pk: row.get(0)?,
|
2025-10-26 18:57:13 +01:00
|
|
|
|
id: row.get::<_, Option<String>>(1)?,
|
2025-10-19 13:42:29 +02:00
|
|
|
|
collection: row.get(2)?,
|
|
|
|
|
|
hits: row.get(3)?,
|
|
|
|
|
|
last_used: row.get(4)?,
|
2025-10-26 09:18:48 +01:00
|
|
|
|
metadata: None,
|
2025-10-19 13:42:29 +02:00
|
|
|
|
})
|
|
|
|
|
|
})?
|
|
|
|
|
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
2025-10-17 22:15:00 +02:00
|
|
|
|
|
|
|
|
|
|
Ok(entries)
|
|
|
|
|
|
}
|
2025-12-12 16:36:31 +00:00
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// LAZY PK SUPPORT
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
2025-12-15 11:18:58 +01:00
|
|
|
|
/// Ajoute une entrée en mode lazy (pk = lazy_pk tant que non téléchargé)
|
2025-12-12 16:36:31 +00:00
|
|
|
|
///
|
|
|
|
|
|
/// Utilisé pour créer des entries sans télécharger le fichier.
|
2025-12-15 11:18:58 +01:00
|
|
|
|
/// Le lazy_pk est calculé à partir de l'URL et sert temporairement
|
|
|
|
|
|
/// également de pk pour satisfaire les contraintes de clé étrangère.
|
2025-12-12 16:36:31 +00:00
|
|
|
|
///
|
|
|
|
|
|
/// # 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)
|
2025-12-15 11:18:58 +01:00
|
|
|
|
VALUES (?1, ?1, ?2, ?3, 0, ?4)",
|
2025-12-12 16:36:31 +00:00
|
|
|
|
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é
|
2025-12-15 11:18:58 +01:00
|
|
|
|
/// * `Ok(None)` - Pas encore téléchargé ou lazy_pk inconnu
|
2025-12-12 16:36:31 +00:00
|
|
|
|
pub fn get_pk_by_lazy_pk(&self, lazy_pk: &str) -> rusqlite::Result<Option<String>> {
|
|
|
|
|
|
let conn = self.lock_conn("get_pk_by_lazy_pk");
|
|
|
|
|
|
|
2025-12-15 11:18:58 +01:00
|
|
|
|
let result: Option<(String, Option<String>)> = 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<bool> {
|
|
|
|
|
|
let conn = self.lock_conn("has_lazy_entry");
|
|
|
|
|
|
|
|
|
|
|
|
let exists: Option<i64> = conn
|
|
|
|
|
|
.query_row(
|
|
|
|
|
|
"SELECT 1 FROM asset WHERE lazy_pk = ?1 LIMIT 1",
|
|
|
|
|
|
[lazy_pk],
|
|
|
|
|
|
|row| row.get(0),
|
|
|
|
|
|
)
|
|
|
|
|
|
.optional()?;
|
|
|
|
|
|
|
|
|
|
|
|
Ok(exists.is_some())
|
2025-12-12 16:36:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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
|
2025-12-15 11:18:58 +01:00
|
|
|
|
pub fn update_lazy_to_downloaded(&self, lazy_pk: &str, real_pk: &str) -> rusqlite::Result<()> {
|
2025-12-12 16:36:31 +00:00
|
|
|
|
let mut conn = self.lock_conn("update_lazy_to_downloaded");
|
|
|
|
|
|
|
|
|
|
|
|
let tx = conn.transaction()?;
|
|
|
|
|
|
|
2025-12-15 11:18:58 +01:00
|
|
|
|
// 1. Récupérer l'entry lazy (pk = lazy_pk tant que pas téléchargé)
|
|
|
|
|
|
let (old_pk, collection, id, hits): (String, Option<String>, Option<String>, i32) = tx
|
2025-12-12 16:36:31 +00:00
|
|
|
|
.query_row(
|
2025-12-15 11:18:58 +01:00
|
|
|
|
"SELECT pk, collection, id, hits FROM asset WHERE lazy_pk = ?1",
|
2025-12-12 16:36:31 +00:00
|
|
|
|
[lazy_pk],
|
2025-12-15 11:18:58 +01:00
|
|
|
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
2025-12-12 16:36:31 +00:00
|
|
|
|
)
|
|
|
|
|
|
.optional()?
|
|
|
|
|
|
.ok_or_else(|| Error::QueryReturnedNoRows)?;
|
|
|
|
|
|
|
2025-12-15 11:18:58 +01:00
|
|
|
|
if old_pk == real_pk {
|
|
|
|
|
|
// Rien à faire si déjà commuté
|
|
|
|
|
|
return Ok(());
|
|
|
|
|
|
}
|
2025-12-12 16:36:31 +00:00
|
|
|
|
|
2025-12-15 11:18:58 +01:00
|
|
|
|
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
|
2025-12-12 16:36:31 +00:00
|
|
|
|
tx.execute(
|
|
|
|
|
|
"INSERT INTO asset (pk, lazy_pk, collection, id, hits, last_used)
|
2025-12-15 11:18:58 +01:00
|
|
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
2025-12-12 16:36:31 +00:00
|
|
|
|
ON CONFLICT(pk) DO UPDATE SET
|
|
|
|
|
|
lazy_pk = excluded.lazy_pk,
|
2025-12-15 11:18:58 +01:00
|
|
|
|
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],
|
2025-12-12 16:36:31 +00:00
|
|
|
|
)?;
|
|
|
|
|
|
|
2025-12-15 11:18:58 +01:00
|
|
|
|
// 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])?;
|
|
|
|
|
|
|
2025-12-12 16:36:31 +00:00
|
|
|
|
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<(Option<String>, Option<String>)>> {
|
|
|
|
|
|
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
|
2025-12-15 11:18:58 +01:00
|
|
|
|
let raw: Option<(String, Option<String>)> = conn
|
2025-12-12 16:36:31 +00:00
|
|
|
|
.query_row(
|
|
|
|
|
|
"SELECT a.pk, a.lazy_pk
|
|
|
|
|
|
FROM asset a
|
2025-12-15 11:18:58 +01:00
|
|
|
|
JOIN metadata m ON a.pk = m.pk
|
2025-12-12 16:36:31 +00:00
|
|
|
|
WHERE m.key = 'origin_url' AND m.value = ?1
|
|
|
|
|
|
LIMIT 1",
|
|
|
|
|
|
[url],
|
|
|
|
|
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
|
|
|
|
)
|
|
|
|
|
|
.optional()?;
|
|
|
|
|
|
|
2025-12-15 11:18:58 +01:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-12-12 16:36:31 +00:00
|
|
|
|
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
|
2025-12-15 11:18:58 +01:00
|
|
|
|
pub fn set_origin_url_for_lazy(&self, lazy_pk: &str, origin_url: &str) -> rusqlite::Result<()> {
|
2025-12-12 16:36:31 +00:00
|
|
|
|
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
|
2025-12-12 21:42:04 +00:00
|
|
|
|
pub fn set_a_metadata_by_key(
|
2025-12-12 16:36:31 +00:00
|
|
|
|
&self,
|
|
|
|
|
|
key: &str,
|
|
|
|
|
|
metadata_key: &str,
|
|
|
|
|
|
value: Value,
|
|
|
|
|
|
) -> rusqlite::Result<()> {
|
|
|
|
|
|
let (value_type, value_text): (&str, Option<String>) = 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(())
|
|
|
|
|
|
}
|
2025-10-26 09:18:48 +01:00
|
|
|
|
}
|
2025-10-17 23:08:06 +02:00
|
|
|
|
|
2025-10-26 09:18:48 +01:00
|
|
|
|
/// Convertit une ligne de la table `metadata` en valeur JSON.
|
|
|
|
|
|
fn decode_metadata_value(
|
|
|
|
|
|
key: &str,
|
|
|
|
|
|
value_type: &str,
|
|
|
|
|
|
raw: Option<String>,
|
|
|
|
|
|
) -> rusqlite::Result<Value> {
|
|
|
|
|
|
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::<bool>().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::<Value>(&raw) {
|
|
|
|
|
|
return Ok(json);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(Value::String(raw))
|
|
|
|
|
|
}
|
|
|
|
|
|
other => Err(Error::InvalidParameterName(format!(
|
|
|
|
|
|
"unknown metadata type '{other}' for key '{key}'"
|
|
|
|
|
|
))),
|
2025-10-17 23:08:06 +02:00
|
|
|
|
}
|
2025-10-12 21:39:20 +02:00
|
|
|
|
}
|