update de qobuz pour utiliser les sources lazy

This commit is contained in:
2025-12-28 09:56:05 +01:00
parent b915481595
commit 876b1e0147
10 changed files with 467 additions and 40 deletions

3
.claude-env Normal file
View File

@@ -0,0 +1,3 @@
# Configuration PATH pour Claude Code
# Ce fichier sera lu automatiquement pour configurer l'environnement
export PATH="/Users/coissac/mamba/condabin:/opt/homebrew/lib/ruby/gems/3.4.0/bin:/opt/homebrew/opt/ruby/bin:/Users/coissac/go/bin:/Users/coissac/.cargo/bin:/Users/coissac/.modular/pkg/packages.modular.com_mojo/bin:/Applications/quarto/bin:/Users/coissac/.vscode-oss/extensions/vadimcn.vscode-lldb-1.12.0/bin:/Library/Frameworks/Python.framework/Versions/3.12/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/opt/X11/bin:/usr/local/dorado/dorado-0.9.5-osx-arm64/bin:/usr/local/go/bin:/usr/local/src/last-main/bin:/Users/coissac/travail/__MOI__/GO/obitools4/build:/opt/podman/bin:/Applications/quarto/bin:/Users/coissac/.cargo/bin:/Users/coissac/.vscode-oss/extensions/vadimcn.vscode-lldb-1.12.0/bin:/Users/coissac/.vscode-oss/extensions/ms-python.debugpy-2025.14.1-darwin-arm64/bundled/scripts/noConfigScripts:/Users/coissac/.orbstack/bin"

View File

@@ -224,6 +224,28 @@ impl PlaylistManager {
.await
}
/// Récupère l'âge d'une playlist depuis sa création
pub async fn get_playlist_age(&self, id: &str) -> Result<Option<Duration>> {
use std::time::{SystemTime, UNIX_EPOCH};
let persistence = match &self.inner.persistence {
Some(p) => p,
None => return Ok(None),
};
let created_at_nanos = persistence.get_playlist_created_at(id).await?;
if let Some(nanos) = created_at_nanos {
let created_at = UNIX_EPOCH + Duration::from_nanos(nanos as u64);
let age = SystemTime::now()
.duration_since(created_at)
.unwrap_or(Duration::ZERO);
Ok(Some(age))
} else {
Ok(None)
}
}
/// Enregistre un callback d'évènement playlist (update, track joué).
///
/// Retourne un jeton (u64) pour désenregistrer plus tard.

View File

@@ -259,6 +259,28 @@ impl PersistenceManager {
Ok(ids)
}
/// Récupère le timestamp de création d'une playlist
pub async fn get_playlist_created_at(&self, id: &str) -> Result<Option<i64>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn
.prepare("SELECT created_at FROM playlists WHERE id = ?1")
.map_err(|e| {
crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e))
})?;
let result = stmt.query_row(params![id], |row| row.get(0));
match result {
Ok(created_at) => Ok(Some(created_at)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(crate::Error::PersistenceError(format!(
"Failed to get created_at: {}",
e
))),
}
}
/// Supprime tous les tracks contenant un cache_pk donné
pub async fn remove_by_cache_pk(&self, cache_pk: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();

View File

@@ -14,6 +14,8 @@ pub struct QobuzCache {
albums: Arc<MokaCache<String, Album>>,
/// Cache des tracks (TTL: 1 heure)
tracks: Arc<MokaCache<String, Track>>,
/// Cache des tracks d'un album complet (TTL: 1 heure)
album_tracks: Arc<MokaCache<String, Vec<Track>>>,
/// Cache des artistes (TTL: 1 heure)
artists: Arc<MokaCache<String, Artist>>,
/// Cache des playlists (TTL: 30 minutes)
@@ -45,6 +47,12 @@ impl QobuzCache {
.time_to_live(Duration::from_secs(3600)) // 1 heure
.build(),
),
album_tracks: Arc::new(
MokaCache::builder()
.max_capacity(max_capacity)
.time_to_live(Duration::from_secs(3600)) // 1 heure
.build(),
),
artists: Arc::new(
MokaCache::builder()
.max_capacity(max_capacity / 2)
@@ -106,6 +114,23 @@ impl QobuzCache {
self.tracks.invalidate(id).await;
}
// ============ Album Tracks (liste complète des tracks d'un album) ============
/// Récupère la liste complète des tracks d'un album depuis le cache
pub async fn get_album_tracks(&self, album_id: &str) -> Option<Vec<Track>> {
self.album_tracks.get(album_id).await
}
/// Ajoute la liste complète des tracks d'un album au cache
pub async fn put_album_tracks(&self, album_id: String, tracks: Vec<Track>) {
self.album_tracks.insert(album_id, tracks).await;
}
/// Invalide la liste des tracks d'un album du cache
pub async fn invalidate_album_tracks(&self, album_id: &str) {
self.album_tracks.invalidate(album_id).await;
}
// ============ Artists ============
/// Récupère un artiste depuis le cache
@@ -180,6 +205,7 @@ impl QobuzCache {
pub async fn clear_all(&self) {
self.albums.invalidate_all();
self.tracks.invalidate_all();
self.album_tracks.invalidate_all();
self.artists.invalidate_all();
self.playlists.invalidate_all();
self.searches.invalidate_all();
@@ -190,6 +216,7 @@ impl QobuzCache {
pub async fn stats(&self) -> CacheStats {
self.albums.run_pending_tasks().await;
self.tracks.run_pending_tasks().await;
self.album_tracks.run_pending_tasks().await;
self.artists.run_pending_tasks().await;
self.playlists.run_pending_tasks().await;
self.searches.run_pending_tasks().await;
@@ -198,6 +225,7 @@ impl QobuzCache {
CacheStats {
albums_count: self.albums.entry_count(),
tracks_count: self.tracks.entry_count(),
album_tracks_count: self.album_tracks.entry_count(),
artists_count: self.artists.entry_count(),
playlists_count: self.playlists.entry_count(),
searches_count: self.searches.entry_count(),
@@ -219,6 +247,8 @@ pub struct CacheStats {
pub albums_count: u64,
/// Nombre de tracks en cache
pub tracks_count: u64,
/// Nombre de listes complètes de tracks d'albums en cache
pub album_tracks_count: u64,
/// Nombre d'artistes en cache
pub artists_count: u64,
/// Nombre de playlists en cache
@@ -234,6 +264,7 @@ impl CacheStats {
pub fn total_count(&self) -> u64 {
self.albums_count
+ self.tracks_count
+ self.album_tracks_count
+ self.artists_count
+ self.playlists_count
+ self.searches_count

View File

@@ -584,14 +584,24 @@ impl QobuzClient {
/// Récupère les tracks d'un album
pub async fn get_album_tracks(&self, album_id: &str) -> Result<Vec<Track>> {
// Vérifier le cache d'abord
if let Some(tracks) = self.cache.get_album_tracks(album_id).await {
debug!("Album tracks for {} found in cache", album_id);
return Ok(tracks);
}
// Sinon, récupérer depuis l'API
let tracks = self
.call_with_auth_repair("get_album_tracks", || self.api.get_album_tracks(album_id))
.await?;
// Mettre les tracks en cache
// Mettre les tracks en cache (individuellement ET la liste complète)
for track in &tracks {
self.cache.put_track(track.id.clone(), track.clone()).await;
}
self.cache
.put_album_tracks(album_id.to_string(), tracks.clone())
.await;
Ok(tracks)
}

View File

@@ -27,10 +27,10 @@ impl ToDIDL for Album {
///
/// ```rust,ignore
/// let album = client.get_album("12345").await?;
/// let container = album.to_didl_container("0$qobuz$albums")?;
/// let container = album.to_didl_container("qobuz:favorites")?;
/// ```
fn to_didl_container(&self, parent_id: &str) -> Result<Container> {
let id = format!("0$qobuz$album${}", self.id);
let id = format!("qobuz:album:{}", self.id);
Ok(Container {
id,
@@ -71,10 +71,10 @@ impl ToDIDL for Track {
///
/// ```rust,ignore
/// let track = client.get_track("98765").await?;
/// let item = track.to_didl_item("0$qobuz$album$12345")?;
/// let item = track.to_didl_item("qobuz:album:12345")?;
/// ```
fn to_didl_item(&self, parent_id: &str) -> Result<Item> {
let id = format!("0$qobuz$track${}", self.id);
let id = format!("qobuz:track:{}", self.id);
// Déterminer l'artiste à afficher
let artist_name = self
@@ -128,7 +128,7 @@ impl ToDIDL for Track {
impl ToDIDL for Playlist {
/// Convertit une playlist en Container DIDL
fn to_didl_container(&self, parent_id: &str) -> Result<Container> {
let id = format!("0$qobuz$playlist${}", self.id);
let id = format!("qobuz:playlist:{}", self.id);
Ok(Container {
id,
@@ -211,7 +211,7 @@ mod tests {
};
let container = album.to_didl_container("parent").unwrap();
assert_eq!(container.id, "0$qobuz$album$123");
assert_eq!(container.id, "qobuz:album:123");
assert_eq!(container.parent_id, "parent");
assert!(container.title.contains("Test Album"));
}
@@ -234,7 +234,7 @@ mod tests {
};
let item = track.to_didl_item("parent").unwrap();
assert_eq!(item.id, "0$qobuz$track$789");
assert_eq!(item.id, "qobuz:track:789");
assert_eq!(item.parent_id, "parent");
assert_eq!(item.title, "Test Track");
}

View File

@@ -14,7 +14,10 @@ use pmosource::SourceCacheManager;
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
use serde_json::json;
use std::sync::Arc;
use std::time::SystemTime;
use std::time::{Duration, SystemTime};
/// TTL pour les playlists d'albums (7 jours)
const ALBUM_PLAYLIST_TTL: Duration = Duration::from_secs(7 * 24 * 3600);
/// Default image for Qobuz (300x300 WebP, embedded in binary)
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
@@ -322,6 +325,13 @@ impl QobuzSource {
);
}
// Stocker le track_id Qobuz pour reconstruction DIDL ultérieure
let _ = self.inner.cache_manager.set_audio_metadata(
&cached_audio_pk,
"qobuz_track_id",
json!(track.id),
);
// 4. Store metadata
self.inner
.cache_manager
@@ -398,7 +408,7 @@ impl QobuzSource {
// 3. Batch insert into playlist (single DB transaction)
let playlist_manager = pmoplaylist::PlaylistManager();
let writer = playlist_manager
.get_write_handle(playlist_id.to_string())
.get_persistent_write_handle(playlist_id.to_string())
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
@@ -489,7 +499,7 @@ impl QobuzSource {
// 3. Batch insert into playlist (single DB transaction)
let playlist_manager = pmoplaylist::PlaylistManager();
let writer = playlist_manager
.get_write_handle(playlist_id.to_string())
.get_persistent_write_handle(playlist_id.to_string())
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
@@ -511,6 +521,181 @@ impl QobuzSource {
Ok(lazy_pks.len())
}
/// Vérifie si une playlist d'album existe et est valide (non expirée ET non vide)
async fn is_album_playlist_valid(&self, playlist_id: &str) -> Result<bool> {
let playlist_manager = pmoplaylist::PlaylistManager();
if !playlist_manager.exists(playlist_id).await {
return Ok(false);
}
// Vérifier l'âge
match playlist_manager.get_playlist_age(playlist_id).await {
Ok(Some(age)) if age < ALBUM_PLAYLIST_TTL => {
// Playlist non expirée, vérifier qu'elle contient des tracks
match playlist_manager.get_read_handle(playlist_id).await {
Ok(reader) => {
let count = reader.remaining().await.unwrap_or(0);
Ok(count > 0) // Valide seulement si non vide
}
Err(_) => Ok(false),
}
}
_ => Ok(false),
}
}
/// Adapte les Items d'une playlist pour correspondre au schéma UPnP Qobuz
async fn adapt_playlist_items_to_qobuz(
&self,
items: Vec<Item>,
album_id: &str,
) -> Result<Vec<Item>> {
use tracing::warn;
let parent_id = format!("qobuz:album:{}", album_id);
let mut adapted = Vec::with_capacity(items.len());
for mut item in items {
// Extraire cache_pk depuis l'URL du resource
let cache_pk = if let Some(resource) = item.resources.first() {
resource.url
.strip_prefix("/audio/flac/")
.map(|s| s.to_string())
} else {
None
};
if let Some(pk) = cache_pk {
// Récupérer track_id depuis metadata
if let Ok(Some(track_id_value)) = self.inner.cache_manager.get_audio_metadata(&pk, "qobuz_track_id") {
if let Some(track_id) = track_id_value.as_str() {
item.id = format!("qobuz:track:{}", track_id);
} else {
warn!("qobuz_track_id not a string for {}", pk);
}
} else {
warn!("No qobuz_track_id metadata for {}", pk);
}
}
item.parent_id = parent_id.clone();
adapted.push(item);
}
Ok(adapted)
}
/// Récupère ou crée une playlist lazy pour un album
async fn get_or_create_album_playlist_items(
&self,
album_id: &str,
limit: usize,
) -> Result<Vec<Item>> {
use tracing::{debug, info};
let playlist_id = format!("qobuz-album-{}", album_id);
let playlist_manager = pmoplaylist::PlaylistManager();
// Vérifier validité (existe ET non expirée)
let is_valid = self.is_album_playlist_valid(&playlist_id).await?;
if is_valid {
debug!("Album playlist {} found and valid", playlist_id);
let reader = playlist_manager
.get_read_handle(&playlist_id)
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
let items = reader
.to_items(limit)
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
return self.adapt_playlist_items_to_qobuz(items, album_id).await;
}
// Playlist invalide/inexistante : (re)créer
info!("Album playlist {} creating/refreshing", playlist_id);
// 1. Métadonnées album
let album = self
.inner
.client
.get_album(album_id)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
// 2. Cache cover
let cover_pk = if let Some(ref image_url) = album.image {
self.inner
.cache_manager
.cache_cover(image_url)
.await
.ok()
} else {
None
};
// 3. Créer ou récupérer playlist
let writer = if playlist_manager.exists(&playlist_id).await {
let writer = playlist_manager
.get_persistent_write_handle(playlist_id.clone())
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
writer
.flush()
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
writer
} else {
playlist_manager
.create_persistent_playlist_with_role(
playlist_id.clone(),
pmoplaylist::PlaylistRole::Album,
)
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?
};
// 4. Métadonnées playlist
writer
.set_title(album.title.clone())
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
if let Some(pk) = cover_pk {
writer
.set_cover_pk(Some(pk))
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
}
// IMPORTANT: Libérer le write lock avant d'appeler add_album_to_playlist
drop(writer);
// 5. Ajouter tracks (réutilise add_album_to_playlist existant)
self.add_album_to_playlist(&playlist_id, album_id).await?;
// 6. Récupérer items
let reader = playlist_manager
.get_read_handle(&playlist_id)
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
let items = reader
.to_items(limit)
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
// 7. Adapter IDs
self.adapt_playlist_items_to_qobuz(items, album_id).await
}
/// Increment update counter (called on catalog changes)
async fn increment_update_id(&self) {
let mut counter = self.inner.update_counter.write().await;
@@ -620,22 +805,9 @@ impl MusicSource for QobuzSource {
}
ObjectIdType::Album(album_id) => {
// Get tracks in album
let tracks = self
.inner
.client
.get_album_tracks(&album_id)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let items: Vec<Item> = tracks
.into_iter()
.filter_map(|track| {
track
.to_didl_item(&format!("qobuz:album:{}", album_id))
.ok()
})
.collect();
let items = self
.get_or_create_album_playlist_items(&album_id, usize::MAX)
.await?;
Ok(BrowseResult::Items(items))
}
@@ -1041,23 +1213,14 @@ impl MusicSource for QobuzSource {
) -> Result<BrowseResult> {
match self.parse_object_id(object_id) {
ObjectIdType::Album(album_id) => {
// Qobuz returns all tracks, so we slice them
let tracks = self
.inner
.client
.get_album_tracks(&album_id)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let all_items = self
.get_or_create_album_playlist_items(&album_id, usize::MAX)
.await?;
let items: Vec<Item> = tracks
let items: Vec<Item> = all_items
.into_iter()
.skip(offset)
.take(limit)
.filter_map(|track| {
track
.to_didl_item(&format!("qobuz:album:{}", album_id))
.ok()
})
.collect();
Ok(BrowseResult::Items(items))

128
pmoupnp/src/config_ext.rs Normal file
View File

@@ -0,0 +1,128 @@
//! Extension pour intégrer la configuration UPnP dans pmoconfig
//!
//! Ce module fournit le trait `UpnpConfigExt` qui permet d'ajouter facilement
//! des méthodes de configuration UPnP à pmoconfig::Config.
//!
//! Il suit le même pattern que pmocache/src/config_ext.rs pour la cohérence.
use anyhow::Result;
use pmoconfig::Config;
use serde_yaml::Value;
// Constantes par défaut pour les noms UPnP
const DEFAULT_MANUFACTURER: &str = "PMOMusic";
const DEFAULT_UDN_PREFIX: &str = "pmomusic";
const DEFAULT_MODEL_NAME_PREFIX: &str = "PMOMusic";
const DEFAULT_FRIENDLY_NAME_PREFIX: &str = "PMOMusic";
/// Trait d'extension pour ajouter la configuration UPnP à pmoconfig
///
/// Ce trait étend `pmoconfig::Config` avec des méthodes pour configurer
/// les noms et identifiants des devices UPnP.
///
/// # Exemple
///
/// ```rust,ignore
/// use pmoconfig::get_config;
/// use pmoupnp::UpnpConfigExt;
///
/// let config = get_config();
/// let manufacturer = config.get_upnp_manufacturer()?;
/// let udn_prefix = config.get_upnp_udn_prefix()?;
/// ```
pub trait UpnpConfigExt {
/// Récupère le fabricant pour les devices UPnP
///
/// # Returns
///
/// Le nom du fabricant à afficher dans les descripteurs UPnP (défaut: "PMOMusic")
fn get_upnp_manufacturer(&self) -> Result<String>;
/// Définit le fabricant pour les devices UPnP
fn set_upnp_manufacturer(&self, manufacturer: String) -> Result<()>;
/// Récupère le préfixe UDN pour les devices UPnP
///
/// # Returns
///
/// Le préfixe utilisé pour générer les UDN (défaut: "pmomusic")
fn get_upnp_udn_prefix(&self) -> Result<String>;
/// Définit le préfixe UDN pour les devices UPnP
fn set_upnp_udn_prefix(&self, prefix: String) -> Result<()>;
/// Récupère le préfixe pour les noms de modèle des devices UPnP
///
/// # Returns
///
/// Le préfixe utilisé pour construire les model names (défaut: "PMOMusic")
fn get_upnp_model_name_prefix(&self) -> Result<String>;
/// Définit le préfixe pour les noms de modèle des devices UPnP
fn set_upnp_model_name_prefix(&self, prefix: String) -> Result<()>;
/// Récupère le préfixe pour les noms conviviaux des devices UPnP
///
/// # Returns
///
/// Le préfixe utilisé pour construire les friendly names (défaut: "PMOMusic")
fn get_upnp_friendly_name_prefix(&self) -> Result<String>;
/// Définit le préfixe pour les noms conviviaux des devices UPnP
fn set_upnp_friendly_name_prefix(&self, prefix: String) -> Result<()>;
}
impl UpnpConfigExt for Config {
fn get_upnp_manufacturer(&self) -> Result<String> {
match self.get_value(&["host", "upnp", "manufacturer"]) {
Ok(Value::String(s)) if !s.is_empty() => Ok(s),
_ => Ok(DEFAULT_MANUFACTURER.to_string()),
}
}
fn set_upnp_manufacturer(&self, manufacturer: String) -> Result<()> {
self.set_value(
&["host", "upnp", "manufacturer"],
Value::String(manufacturer),
)
}
fn get_upnp_udn_prefix(&self) -> Result<String> {
match self.get_value(&["host", "upnp", "udn_prefix"]) {
Ok(Value::String(s)) if !s.is_empty() => Ok(s),
_ => Ok(DEFAULT_UDN_PREFIX.to_string()),
}
}
fn set_upnp_udn_prefix(&self, prefix: String) -> Result<()> {
self.set_value(&["host", "upnp", "udn_prefix"], Value::String(prefix))
}
fn get_upnp_model_name_prefix(&self) -> Result<String> {
match self.get_value(&["host", "upnp", "model_name_prefix"]) {
Ok(Value::String(s)) if !s.is_empty() => Ok(s),
_ => Ok(DEFAULT_MODEL_NAME_PREFIX.to_string()),
}
}
fn set_upnp_model_name_prefix(&self, prefix: String) -> Result<()> {
self.set_value(
&["host", "upnp", "model_name_prefix"],
Value::String(prefix),
)
}
fn get_upnp_friendly_name_prefix(&self) -> Result<String> {
match self.get_value(&["host", "upnp", "friendly_name_prefix"]) {
Ok(Value::String(s)) if !s.is_empty() => Ok(s),
_ => Ok(DEFAULT_FRIENDLY_NAME_PREFIX.to_string()),
}
}
fn set_upnp_friendly_name_prefix(&self, prefix: String) -> Result<()> {
self.set_value(
&["host", "upnp", "friendly_name_prefix"],
Value::String(prefix),
)
}
}

View File

@@ -3,6 +3,7 @@ mod object_trait;
pub mod actions;
pub mod cache_registry;
pub mod config_ext;
pub mod devices;
pub mod services;
pub mod soap;
@@ -20,6 +21,7 @@ use std::{collections::HashMap, sync::Arc};
pub use pmoaudiocache::get_audio_cache;
pub use pmocovers::get_cover_cache;
pub use crate::config_ext::UpnpConfigExt;
pub use crate::object_trait::*;
pub use crate::upnp_server::UpnpServerExt;

46
rust-analyzer.toml Normal file
View File

@@ -0,0 +1,46 @@
# Configuration rust-analyzer pour gros workspace (23 crates)
# Optimisé pour éviter les crashs et limiter l'utilisation mémoire
# Limiter l'analyse en arrière-plan
[checkOnSave]
enable = true
# N'analyser que la cible par défaut (pas tous les targets)
allTargets = false
# Utiliser clippy au lieu de cargo check (optionnel, commentez si trop lent)
# command = "clippy"
# Désactiver les build scripts pour réduire la charge
[cargo]
buildScripts.enable = false
# Ne charger que les crates nécessaires
loadOutDirsFromCheck = false
# Désactiver les proc-macros si elles causent des problèmes
[procMacro]
enable = true
# Si les crashs persistent, passez à false ci-dessus
# Limiter la complétion
[completion]
limit = 50
# Désactiver certains diagnostics coûteux
[diagnostics]
disabled = [
"unresolved-proc-macro",
"macro-error",
]
# Optimisations de performance
[inlayHints]
# Réduire les hints pour améliorer les perfs
maxLength = 25
# Limiter la profondeur d'analyse des types
[typing]
autoClosingAngleBrackets.enable = false
# Pour les très gros workspaces, décommenter pour analyser moins de crates
# [linkedProjects]
# Spécifier uniquement les crates que vous éditez activement
# Par exemple : ["PMOMusic/Cargo.toml", "pmocontrol/Cargo.toml"]