pinakes-core: update remaining modules and tests
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I9e0ff5ea33a5cf697473423e88f167ce6a6a6964
This commit is contained in:
parent
c8425a4c34
commit
3d9f8933d2
44 changed files with 1207 additions and 578 deletions
|
|
@ -30,13 +30,18 @@ pub struct CacheStats {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CacheStats {
|
impl CacheStats {
|
||||||
|
#[must_use]
|
||||||
pub fn hit_rate(&self) -> f64 {
|
pub fn hit_rate(&self) -> f64 {
|
||||||
let total = self.hits + self.misses;
|
let total = self.hits + self.misses;
|
||||||
if total == 0 {
|
// Compute ratio using integer arithmetic: hits * 10000 / total gives basis
|
||||||
0.0
|
// points (0..=10000), then scale back to [0.0, 1.0]. Returns 0.0 if total
|
||||||
} else {
|
// is zero.
|
||||||
self.hits as f64 / total as f64
|
let basis_points = self
|
||||||
}
|
.hits
|
||||||
|
.saturating_mul(10_000)
|
||||||
|
.checked_div(total)
|
||||||
|
.unwrap_or(0);
|
||||||
|
f64::from(u32::try_from(basis_points).unwrap_or(u32::MAX)) / 10_000.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,6 +93,7 @@ where
|
||||||
V: Clone + Send + Sync + 'static,
|
V: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
/// Create a new cache with the specified TTL and maximum capacity.
|
/// Create a new cache with the specified TTL and maximum capacity.
|
||||||
|
#[must_use]
|
||||||
pub fn new(ttl: Duration, max_capacity: u64) -> Self {
|
pub fn new(ttl: Duration, max_capacity: u64) -> Self {
|
||||||
let inner = MokaCache::builder()
|
let inner = MokaCache::builder()
|
||||||
.time_to_live(ttl)
|
.time_to_live(ttl)
|
||||||
|
|
@ -101,6 +107,7 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new cache with TTL, max capacity, and time-to-idle.
|
/// Create a new cache with TTL, max capacity, and time-to-idle.
|
||||||
|
#[must_use]
|
||||||
pub fn new_with_idle(
|
pub fn new_with_idle(
|
||||||
ttl: Duration,
|
ttl: Duration,
|
||||||
tti: Duration,
|
tti: Duration,
|
||||||
|
|
@ -120,16 +127,16 @@ where
|
||||||
|
|
||||||
/// Get a value from the cache.
|
/// Get a value from the cache.
|
||||||
pub async fn get(&self, key: &K) -> Option<V> {
|
pub async fn get(&self, key: &K) -> Option<V> {
|
||||||
match self.inner.get(key).await {
|
self.inner.get(key).await.map_or_else(
|
||||||
Some(value) => {
|
|| {
|
||||||
self.metrics.record_hit();
|
|
||||||
Some(value)
|
|
||||||
},
|
|
||||||
None => {
|
|
||||||
self.metrics.record_miss();
|
self.metrics.record_miss();
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
}
|
|value| {
|
||||||
|
self.metrics.record_hit();
|
||||||
|
Some(value)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a value into the cache.
|
/// Insert a value into the cache.
|
||||||
|
|
@ -150,11 +157,13 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current number of entries in the cache.
|
/// Get the current number of entries in the cache.
|
||||||
|
#[must_use]
|
||||||
pub fn entry_count(&self) -> u64 {
|
pub fn entry_count(&self) -> u64 {
|
||||||
self.inner.entry_count()
|
self.inner.entry_count()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get cache statistics.
|
/// Get cache statistics.
|
||||||
|
#[must_use]
|
||||||
pub fn stats(&self) -> CacheStats {
|
pub fn stats(&self) -> CacheStats {
|
||||||
let (hits, misses) = self.metrics.stats();
|
let (hits, misses) = self.metrics.stats();
|
||||||
CacheStats {
|
CacheStats {
|
||||||
|
|
@ -168,11 +177,12 @@ where
|
||||||
|
|
||||||
/// Specialized cache for search query results.
|
/// Specialized cache for search query results.
|
||||||
pub struct QueryCache {
|
pub struct QueryCache {
|
||||||
/// Cache keyed by (query_hash, offset, limit)
|
/// Cache keyed by (`query_hash`, offset, limit)
|
||||||
inner: Cache<String, String>,
|
inner: Cache<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QueryCache {
|
impl QueryCache {
|
||||||
|
#[must_use]
|
||||||
pub fn new(ttl: Duration, max_capacity: u64) -> Self {
|
pub fn new(ttl: Duration, max_capacity: u64) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Cache::new(ttl, max_capacity),
|
inner: Cache::new(ttl, max_capacity),
|
||||||
|
|
@ -224,6 +234,7 @@ impl QueryCache {
|
||||||
self.inner.invalidate_all().await;
|
self.inner.invalidate_all().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn stats(&self) -> CacheStats {
|
pub fn stats(&self) -> CacheStats {
|
||||||
self.inner.stats()
|
self.inner.stats()
|
||||||
}
|
}
|
||||||
|
|
@ -236,6 +247,7 @@ pub struct MetadataCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MetadataCache {
|
impl MetadataCache {
|
||||||
|
#[must_use]
|
||||||
pub fn new(ttl: Duration, max_capacity: u64) -> Self {
|
pub fn new(ttl: Duration, max_capacity: u64) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Cache::new(ttl, max_capacity),
|
inner: Cache::new(ttl, max_capacity),
|
||||||
|
|
@ -257,6 +269,7 @@ impl MetadataCache {
|
||||||
self.inner.invalidate(&content_hash.to_string()).await;
|
self.inner.invalidate(&content_hash.to_string()).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn stats(&self) -> CacheStats {
|
pub fn stats(&self) -> CacheStats {
|
||||||
self.inner.stats()
|
self.inner.stats()
|
||||||
}
|
}
|
||||||
|
|
@ -268,6 +281,7 @@ pub struct MediaCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MediaCache {
|
impl MediaCache {
|
||||||
|
#[must_use]
|
||||||
pub fn new(ttl: Duration, max_capacity: u64) -> Self {
|
pub fn new(ttl: Duration, max_capacity: u64) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Cache::new(ttl, max_capacity),
|
inner: Cache::new(ttl, max_capacity),
|
||||||
|
|
@ -290,6 +304,7 @@ impl MediaCache {
|
||||||
self.inner.invalidate_all().await;
|
self.inner.invalidate_all().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn stats(&self) -> CacheStats {
|
pub fn stats(&self) -> CacheStats {
|
||||||
self.inner.stats()
|
self.inner.stats()
|
||||||
}
|
}
|
||||||
|
|
@ -348,6 +363,7 @@ pub struct CacheLayer {
|
||||||
impl CacheLayer {
|
impl CacheLayer {
|
||||||
/// Create a new cache layer with the specified TTL (using defaults for other
|
/// Create a new cache layer with the specified TTL (using defaults for other
|
||||||
/// settings).
|
/// settings).
|
||||||
|
#[must_use]
|
||||||
pub fn new(ttl_secs: u64) -> Self {
|
pub fn new(ttl_secs: u64) -> Self {
|
||||||
let config = CacheConfig {
|
let config = CacheConfig {
|
||||||
response_ttl_secs: ttl_secs,
|
response_ttl_secs: ttl_secs,
|
||||||
|
|
@ -357,6 +373,7 @@ impl CacheLayer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new cache layer with full configuration.
|
/// Create a new cache layer with full configuration.
|
||||||
|
#[must_use]
|
||||||
pub fn with_config(config: CacheConfig) -> Self {
|
pub fn with_config(config: CacheConfig) -> Self {
|
||||||
Self {
|
Self {
|
||||||
responses: Cache::new(
|
responses: Cache::new(
|
||||||
|
|
@ -401,6 +418,7 @@ impl CacheLayer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get aggregated statistics for all caches.
|
/// Get aggregated statistics for all caches.
|
||||||
|
#[must_use]
|
||||||
pub fn stats(&self) -> CacheLayerStats {
|
pub fn stats(&self) -> CacheLayerStats {
|
||||||
CacheLayerStats {
|
CacheLayerStats {
|
||||||
responses: self.responses.stats(),
|
responses: self.responses.stats(),
|
||||||
|
|
@ -411,7 +429,8 @@ impl CacheLayer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current configuration.
|
/// Get the current configuration.
|
||||||
pub fn config(&self) -> &CacheConfig {
|
#[must_use]
|
||||||
|
pub const fn config(&self) -> &CacheConfig {
|
||||||
&self.config
|
&self.config
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -427,6 +446,7 @@ pub struct CacheLayerStats {
|
||||||
|
|
||||||
impl CacheLayerStats {
|
impl CacheLayerStats {
|
||||||
/// Get the overall hit rate across all caches.
|
/// Get the overall hit rate across all caches.
|
||||||
|
#[must_use]
|
||||||
pub fn overall_hit_rate(&self) -> f64 {
|
pub fn overall_hit_rate(&self) -> f64 {
|
||||||
let total_hits = self.responses.hits
|
let total_hits = self.responses.hits
|
||||||
+ self.queries.hits
|
+ self.queries.hits
|
||||||
|
|
@ -438,15 +458,16 @@ impl CacheLayerStats {
|
||||||
+ self.metadata.misses
|
+ self.metadata.misses
|
||||||
+ self.media.misses;
|
+ self.media.misses;
|
||||||
|
|
||||||
if total_requests == 0 {
|
let basis_points = total_hits
|
||||||
0.0
|
.saturating_mul(10_000)
|
||||||
} else {
|
.checked_div(total_requests)
|
||||||
total_hits as f64 / total_requests as f64
|
.unwrap_or(0);
|
||||||
}
|
f64::from(u32::try_from(basis_points).unwrap_or(u32::MAX)) / 10_000.0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the total number of entries across all caches.
|
/// Get the total number of entries across all caches.
|
||||||
pub fn total_entries(&self) -> u64 {
|
#[must_use]
|
||||||
|
pub const fn total_entries(&self) -> u64 {
|
||||||
self.responses.size
|
self.responses.size
|
||||||
+ self.queries.size
|
+ self.queries.size
|
||||||
+ self.metadata.size
|
+ self.metadata.size
|
||||||
|
|
@ -460,7 +481,7 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_cache_basic_operations() {
|
async fn test_cache_basic_operations() {
|
||||||
let cache: Cache<String, String> = Cache::new(Duration::from_secs(60), 100);
|
let cache: Cache<String, String> = Cache::new(Duration::from_mins(1), 100);
|
||||||
|
|
||||||
// Insert and get
|
// Insert and get
|
||||||
cache.insert("key1".to_string(), "value1".to_string()).await;
|
cache.insert("key1".to_string(), "value1".to_string()).await;
|
||||||
|
|
@ -479,7 +500,7 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_cache_stats() {
|
async fn test_cache_stats() {
|
||||||
let cache: Cache<String, String> = Cache::new(Duration::from_secs(60), 100);
|
let cache: Cache<String, String> = Cache::new(Duration::from_mins(1), 100);
|
||||||
|
|
||||||
cache.insert("key1".to_string(), "value1".to_string()).await;
|
cache.insert("key1".to_string(), "value1".to_string()).await;
|
||||||
let _ = cache.get(&"key1".to_string()).await; // hit
|
let _ = cache.get(&"key1".to_string()).await; // hit
|
||||||
|
|
@ -493,7 +514,7 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_query_cache() {
|
async fn test_query_cache() {
|
||||||
let cache = QueryCache::new(Duration::from_secs(60), 100);
|
let cache = QueryCache::new(Duration::from_mins(1), 100);
|
||||||
|
|
||||||
cache
|
cache
|
||||||
.insert("test query", 0, 10, Some("name"), "results".to_string())
|
.insert("test query", 0, 10, Some("name"), "results".to_string())
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{error::Result, model::*, storage::DynStorageBackend};
|
use crate::{
|
||||||
|
error::Result,
|
||||||
|
model::{
|
||||||
|
AuditAction,
|
||||||
|
Collection,
|
||||||
|
CollectionKind,
|
||||||
|
MediaId,
|
||||||
|
MediaItem,
|
||||||
|
Pagination,
|
||||||
|
},
|
||||||
|
storage::DynStorageBackend,
|
||||||
|
};
|
||||||
|
|
||||||
/// Creates a new collection.
|
/// Creates a new collection.
|
||||||
///
|
///
|
||||||
|
|
@ -15,6 +26,10 @@ use crate::{error::Result, model::*, storage::DynStorageBackend};
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// The created collection
|
/// The created collection
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
|
||||||
pub async fn create_collection(
|
pub async fn create_collection(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
|
@ -39,6 +54,10 @@ pub async fn create_collection(
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// `Ok(())` on success
|
/// `Ok(())` on success
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
|
||||||
pub async fn add_member(
|
pub async fn add_member(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
collection_id: Uuid,
|
collection_id: Uuid,
|
||||||
|
|
@ -68,6 +87,10 @@ pub async fn add_member(
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// `Ok(())` on success
|
/// `Ok(())` on success
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
|
||||||
pub async fn remove_member(
|
pub async fn remove_member(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
collection_id: Uuid,
|
collection_id: Uuid,
|
||||||
|
|
@ -98,6 +121,10 @@ pub async fn remove_member(
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// List of media items in the collection
|
/// List of media items in the collection
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
|
||||||
pub async fn get_members(
|
pub async fn get_members(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
collection_id: Uuid,
|
collection_id: Uuid,
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,15 @@ use crate::{
|
||||||
model::MediaItem,
|
model::MediaItem,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Book enricher that tries OpenLibrary first, then falls back to Google Books
|
/// Book enricher that tries `OpenLibrary` first, then falls back to Google
|
||||||
|
/// Books
|
||||||
pub struct BookEnricher {
|
pub struct BookEnricher {
|
||||||
openlibrary: OpenLibraryClient,
|
openlibrary: OpenLibraryClient,
|
||||||
googlebooks: GoogleBooksClient,
|
googlebooks: GoogleBooksClient,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BookEnricher {
|
impl BookEnricher {
|
||||||
|
#[must_use]
|
||||||
pub fn new(google_api_key: Option<String>) -> Self {
|
pub fn new(google_api_key: Option<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
openlibrary: OpenLibraryClient::new(),
|
openlibrary: OpenLibraryClient::new(),
|
||||||
|
|
@ -27,7 +29,11 @@ impl BookEnricher {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to enrich from OpenLibrary first
|
/// Try to enrich from `OpenLibrary` first
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the metadata cannot be serialized.
|
||||||
pub async fn try_openlibrary(
|
pub async fn try_openlibrary(
|
||||||
&self,
|
&self,
|
||||||
isbn: &str,
|
isbn: &str,
|
||||||
|
|
@ -35,7 +41,7 @@ impl BookEnricher {
|
||||||
match self.openlibrary.fetch_by_isbn(isbn).await {
|
match self.openlibrary.fetch_by_isbn(isbn).await {
|
||||||
Ok(book) => {
|
Ok(book) => {
|
||||||
let metadata_json = serde_json::to_string(&book).map_err(|e| {
|
let metadata_json = serde_json::to_string(&book).map_err(|e| {
|
||||||
PinakesError::External(format!("Failed to serialize metadata: {}", e))
|
PinakesError::External(format!("Failed to serialize metadata: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Some(ExternalMetadata {
|
Ok(Some(ExternalMetadata {
|
||||||
|
|
@ -53,6 +59,10 @@ impl BookEnricher {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to enrich from Google Books
|
/// Try to enrich from Google Books
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the metadata cannot be serialized.
|
||||||
pub async fn try_googlebooks(
|
pub async fn try_googlebooks(
|
||||||
&self,
|
&self,
|
||||||
isbn: &str,
|
isbn: &str,
|
||||||
|
|
@ -61,7 +71,7 @@ impl BookEnricher {
|
||||||
Ok(books) if !books.is_empty() => {
|
Ok(books) if !books.is_empty() => {
|
||||||
let book = &books[0];
|
let book = &books[0];
|
||||||
let metadata_json = serde_json::to_string(book).map_err(|e| {
|
let metadata_json = serde_json::to_string(book).map_err(|e| {
|
||||||
PinakesError::External(format!("Failed to serialize metadata: {}", e))
|
PinakesError::External(format!("Failed to serialize metadata: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Some(ExternalMetadata {
|
Ok(Some(ExternalMetadata {
|
||||||
|
|
@ -79,6 +89,10 @@ impl BookEnricher {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to enrich by searching with title and author
|
/// Try to enrich by searching with title and author
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the metadata cannot be serialized.
|
||||||
pub async fn enrich_by_search(
|
pub async fn enrich_by_search(
|
||||||
&self,
|
&self,
|
||||||
title: &str,
|
title: &str,
|
||||||
|
|
@ -89,7 +103,7 @@ impl BookEnricher {
|
||||||
&& let Some(result) = results.first()
|
&& let Some(result) = results.first()
|
||||||
{
|
{
|
||||||
let metadata_json = serde_json::to_string(result).map_err(|e| {
|
let metadata_json = serde_json::to_string(result).map_err(|e| {
|
||||||
PinakesError::External(format!("Failed to serialize metadata: {}", e))
|
PinakesError::External(format!("Failed to serialize metadata: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
return Ok(Some(ExternalMetadata {
|
return Ok(Some(ExternalMetadata {
|
||||||
|
|
@ -108,7 +122,7 @@ impl BookEnricher {
|
||||||
&& let Some(book) = results.first()
|
&& let Some(book) = results.first()
|
||||||
{
|
{
|
||||||
let metadata_json = serde_json::to_string(book).map_err(|e| {
|
let metadata_json = serde_json::to_string(book).map_err(|e| {
|
||||||
PinakesError::External(format!("Failed to serialize metadata: {}", e))
|
PinakesError::External(format!("Failed to serialize metadata: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
return Ok(Some(ExternalMetadata {
|
return Ok(Some(ExternalMetadata {
|
||||||
|
|
@ -158,7 +172,8 @@ impl MetadataEnricher for BookEnricher {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate confidence score for OpenLibrary metadata
|
/// Calculate confidence score for `OpenLibrary` metadata
|
||||||
|
#[must_use]
|
||||||
pub fn calculate_openlibrary_confidence(
|
pub fn calculate_openlibrary_confidence(
|
||||||
book: &super::openlibrary::OpenLibraryBook,
|
book: &super::openlibrary::OpenLibraryBook,
|
||||||
) -> f64 {
|
) -> f64 {
|
||||||
|
|
@ -187,6 +202,7 @@ pub fn calculate_openlibrary_confidence(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate confidence score for Google Books metadata
|
/// Calculate confidence score for Google Books metadata
|
||||||
|
#[must_use]
|
||||||
pub fn calculate_googlebooks_confidence(
|
pub fn calculate_googlebooks_confidence(
|
||||||
info: &super::googlebooks::VolumeInfo,
|
info: &super::googlebooks::VolumeInfo,
|
||||||
) -> f64 {
|
) -> f64 {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::error::{PinakesError, Result};
|
use crate::error::{PinakesError, Result};
|
||||||
|
|
@ -9,30 +11,33 @@ pub struct GoogleBooksClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GoogleBooksClient {
|
impl GoogleBooksClient {
|
||||||
|
/// Create a new `GoogleBooksClient`.
|
||||||
|
#[must_use]
|
||||||
pub fn new(api_key: Option<String>) -> Self {
|
pub fn new(api_key: Option<String>) -> Self {
|
||||||
Self {
|
let client = reqwest::Client::builder()
|
||||||
client: reqwest::Client::builder()
|
.user_agent("Pinakes/1.0")
|
||||||
.user_agent("Pinakes/1.0")
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.build()
|
||||||
.build()
|
.unwrap_or_else(|_| reqwest::Client::new());
|
||||||
.expect("Failed to build HTTP client"),
|
Self { client, api_key }
|
||||||
api_key,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch book metadata by ISBN
|
/// Fetch book metadata by ISBN
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the HTTP request fails or the response cannot be
|
||||||
|
/// parsed.
|
||||||
pub async fn fetch_by_isbn(&self, isbn: &str) -> Result<Vec<GoogleBook>> {
|
pub async fn fetch_by_isbn(&self, isbn: &str) -> Result<Vec<GoogleBook>> {
|
||||||
let mut url = format!(
|
let mut url =
|
||||||
"https://www.googleapis.com/books/v1/volumes?q=isbn:{}",
|
format!("https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn}");
|
||||||
isbn
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(ref key) = self.api_key {
|
if let Some(ref key) = self.api_key {
|
||||||
url.push_str(&format!("&key={}", key));
|
let _ = write!(url, "&key={key}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = self.client.get(&url).send().await.map_err(|e| {
|
let response = self.client.get(&url).send().await.map_err(|e| {
|
||||||
PinakesError::External(format!("Google Books request failed: {}", e))
|
PinakesError::External(format!("Google Books request failed: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
|
|
@ -44,8 +49,7 @@ impl GoogleBooksClient {
|
||||||
|
|
||||||
let volumes: GoogleBooksResponse = response.json().await.map_err(|e| {
|
let volumes: GoogleBooksResponse = response.json().await.map_err(|e| {
|
||||||
PinakesError::External(format!(
|
PinakesError::External(format!(
|
||||||
"Failed to parse Google Books response: {}",
|
"Failed to parse Google Books response: {e}"
|
||||||
e
|
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|
@ -53,6 +57,11 @@ impl GoogleBooksClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search for books by title and author
|
/// Search for books by title and author
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the HTTP request fails or the response cannot be
|
||||||
|
/// parsed.
|
||||||
pub async fn search(
|
pub async fn search(
|
||||||
&self,
|
&self,
|
||||||
title: &str,
|
title: &str,
|
||||||
|
|
@ -61,20 +70,19 @@ impl GoogleBooksClient {
|
||||||
let mut query = format!("intitle:{}", urlencoding::encode(title));
|
let mut query = format!("intitle:{}", urlencoding::encode(title));
|
||||||
|
|
||||||
if let Some(author) = author {
|
if let Some(author) = author {
|
||||||
query.push_str(&format!("+inauthor:{}", urlencoding::encode(author)));
|
let _ = write!(query, "+inauthor:{}", urlencoding::encode(author));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut url = format!(
|
let mut url = format!(
|
||||||
"https://www.googleapis.com/books/v1/volumes?q={}&maxResults=5",
|
"https://www.googleapis.com/books/v1/volumes?q={query}&maxResults=5"
|
||||||
query
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(ref key) = self.api_key {
|
if let Some(ref key) = self.api_key {
|
||||||
url.push_str(&format!("&key={}", key));
|
let _ = write!(url, "&key={key}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = self.client.get(&url).send().await.map_err(|e| {
|
let response = self.client.get(&url).send().await.map_err(|e| {
|
||||||
PinakesError::External(format!("Google Books search failed: {}", e))
|
PinakesError::External(format!("Google Books search failed: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
|
|
@ -85,13 +93,18 @@ impl GoogleBooksClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
let volumes: GoogleBooksResponse = response.json().await.map_err(|e| {
|
let volumes: GoogleBooksResponse = response.json().await.map_err(|e| {
|
||||||
PinakesError::External(format!("Failed to parse search results: {}", e))
|
PinakesError::External(format!("Failed to parse search results: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(volumes.items)
|
Ok(volumes.items)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download cover image from Google Books
|
/// Download cover image from Google Books
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the HTTP request fails or the response cannot be
|
||||||
|
/// read.
|
||||||
pub async fn fetch_cover(&self, image_link: &str) -> Result<Vec<u8>> {
|
pub async fn fetch_cover(&self, image_link: &str) -> Result<Vec<u8>> {
|
||||||
// Replace thumbnail link with higher resolution if possible
|
// Replace thumbnail link with higher resolution if possible
|
||||||
let high_res_link = image_link
|
let high_res_link = image_link
|
||||||
|
|
@ -100,7 +113,7 @@ impl GoogleBooksClient {
|
||||||
|
|
||||||
let response =
|
let response =
|
||||||
self.client.get(&high_res_link).send().await.map_err(|e| {
|
self.client.get(&high_res_link).send().await.map_err(|e| {
|
||||||
PinakesError::External(format!("Cover download failed: {}", e))
|
PinakesError::External(format!("Cover download failed: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
|
|
@ -111,7 +124,7 @@ impl GoogleBooksClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
|
response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
|
||||||
PinakesError::External(format!("Failed to read cover data: {}", e))
|
PinakesError::External(format!("Failed to read cover data: {e}"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -201,6 +214,7 @@ pub struct ImageLinks {
|
||||||
|
|
||||||
impl ImageLinks {
|
impl ImageLinks {
|
||||||
/// Get the best available image link (highest resolution)
|
/// Get the best available image link (highest resolution)
|
||||||
|
#[must_use]
|
||||||
pub fn best_link(&self) -> Option<&String> {
|
pub fn best_link(&self) -> Option<&String> {
|
||||||
self
|
self
|
||||||
.extra_large
|
.extra_large
|
||||||
|
|
@ -223,11 +237,13 @@ pub struct IndustryIdentifier {
|
||||||
|
|
||||||
impl IndustryIdentifier {
|
impl IndustryIdentifier {
|
||||||
/// Check if this is an ISBN-13
|
/// Check if this is an ISBN-13
|
||||||
|
#[must_use]
|
||||||
pub fn is_isbn13(&self) -> bool {
|
pub fn is_isbn13(&self) -> bool {
|
||||||
self.identifier_type == "ISBN_13"
|
self.identifier_type == "ISBN_13"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if this is an ISBN-10
|
/// Check if this is an ISBN-10
|
||||||
|
#[must_use]
|
||||||
pub fn is_isbn10(&self) -> bool {
|
pub fn is_isbn10(&self) -> bool {
|
||||||
self.identifier_type == "ISBN_10"
|
self.identifier_type == "ISBN_10"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,16 @@ pub struct LastFmEnricher {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LastFmEnricher {
|
impl LastFmEnricher {
|
||||||
|
/// Create a new `LastFmEnricher`.
|
||||||
|
#[must_use]
|
||||||
pub fn new(api_key: String) -> Self {
|
pub fn new(api_key: String) -> Self {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.connect_timeout(Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| reqwest::Client::new());
|
||||||
Self {
|
Self {
|
||||||
client: reqwest::Client::builder()
|
client,
|
||||||
.timeout(Duration::from_secs(10))
|
|
||||||
.connect_timeout(Duration::from_secs(5))
|
|
||||||
.build()
|
|
||||||
.expect("failed to build HTTP client with configured timeouts"),
|
|
||||||
api_key,
|
api_key,
|
||||||
base_url: "https://ws.audioscrobbler.com/2.0".to_string(),
|
base_url: "https://ws.audioscrobbler.com/2.0".to_string(),
|
||||||
}
|
}
|
||||||
|
|
@ -87,9 +90,8 @@ impl MetadataEnricher for LastFmEnricher {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let track = match json.get("track") {
|
let Some(track) = json.get("track") else {
|
||||||
Some(t) => t,
|
return Ok(None);
|
||||||
None => return Ok(None),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mbid = track.get("mbid").and_then(|m| m.as_str()).map(String::from);
|
let mbid = track.get("mbid").and_then(|m| m.as_str()).map(String::from);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::error::{PinakesError, Result};
|
use crate::error::{PinakesError, Result};
|
||||||
|
|
||||||
/// OpenLibrary API client for book metadata enrichment
|
/// `OpenLibrary` API client for book metadata enrichment
|
||||||
pub struct OpenLibraryClient {
|
pub struct OpenLibraryClient {
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
|
|
@ -15,23 +17,31 @@ impl Default for OpenLibraryClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenLibraryClient {
|
impl OpenLibraryClient {
|
||||||
|
/// Create a new `OpenLibraryClient`.
|
||||||
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("Pinakes/1.0")
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| reqwest::Client::new());
|
||||||
Self {
|
Self {
|
||||||
client: reqwest::Client::builder()
|
client,
|
||||||
.user_agent("Pinakes/1.0")
|
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
|
||||||
.build()
|
|
||||||
.expect("Failed to build HTTP client"),
|
|
||||||
base_url: "https://openlibrary.org".to_string(),
|
base_url: "https://openlibrary.org".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch book metadata by ISBN
|
/// Fetch book metadata by ISBN
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the HTTP request fails or the response cannot be
|
||||||
|
/// parsed.
|
||||||
pub async fn fetch_by_isbn(&self, isbn: &str) -> Result<OpenLibraryBook> {
|
pub async fn fetch_by_isbn(&self, isbn: &str) -> Result<OpenLibraryBook> {
|
||||||
let url = format!("{}/isbn/{}.json", self.base_url, isbn);
|
let url = format!("{}/isbn/{}.json", self.base_url, isbn);
|
||||||
|
|
||||||
let response = self.client.get(&url).send().await.map_err(|e| {
|
let response = self.client.get(&url).send().await.map_err(|e| {
|
||||||
PinakesError::External(format!("OpenLibrary request failed: {}", e))
|
PinakesError::External(format!("OpenLibrary request failed: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
|
|
@ -43,13 +53,17 @@ impl OpenLibraryClient {
|
||||||
|
|
||||||
response.json::<OpenLibraryBook>().await.map_err(|e| {
|
response.json::<OpenLibraryBook>().await.map_err(|e| {
|
||||||
PinakesError::External(format!(
|
PinakesError::External(format!(
|
||||||
"Failed to parse OpenLibrary response: {}",
|
"Failed to parse OpenLibrary response: {e}"
|
||||||
e
|
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search for books by title and author
|
/// Search for books by title and author
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the HTTP request fails or the response cannot be
|
||||||
|
/// parsed.
|
||||||
pub async fn search(
|
pub async fn search(
|
||||||
&self,
|
&self,
|
||||||
title: &str,
|
title: &str,
|
||||||
|
|
@ -62,13 +76,13 @@ impl OpenLibraryClient {
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(author) = author {
|
if let Some(author) = author {
|
||||||
url.push_str(&format!("&author={}", urlencoding::encode(author)));
|
let _ = write!(url, "&author={}", urlencoding::encode(author));
|
||||||
}
|
}
|
||||||
|
|
||||||
url.push_str("&limit=5");
|
url.push_str("&limit=5");
|
||||||
|
|
||||||
let response = self.client.get(&url).send().await.map_err(|e| {
|
let response = self.client.get(&url).send().await.map_err(|e| {
|
||||||
PinakesError::External(format!("OpenLibrary search failed: {}", e))
|
PinakesError::External(format!("OpenLibrary search failed: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
|
|
@ -80,13 +94,18 @@ impl OpenLibraryClient {
|
||||||
|
|
||||||
let search_response: OpenLibrarySearchResponse =
|
let search_response: OpenLibrarySearchResponse =
|
||||||
response.json().await.map_err(|e| {
|
response.json().await.map_err(|e| {
|
||||||
PinakesError::External(format!("Failed to parse search results: {}", e))
|
PinakesError::External(format!("Failed to parse search results: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(search_response.docs)
|
Ok(search_response.docs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch cover image by cover ID
|
/// Fetch cover image by cover ID
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the HTTP request fails or the response cannot be
|
||||||
|
/// read.
|
||||||
pub async fn fetch_cover(
|
pub async fn fetch_cover(
|
||||||
&self,
|
&self,
|
||||||
cover_id: i64,
|
cover_id: i64,
|
||||||
|
|
@ -98,13 +117,11 @@ impl OpenLibraryClient {
|
||||||
CoverSize::Large => "L",
|
CoverSize::Large => "L",
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = format!(
|
let url =
|
||||||
"https://covers.openlibrary.org/b/id/{}-{}.jpg",
|
format!("https://covers.openlibrary.org/b/id/{cover_id}-{size_str}.jpg");
|
||||||
cover_id, size_str
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = self.client.get(&url).send().await.map_err(|e| {
|
let response = self.client.get(&url).send().await.map_err(|e| {
|
||||||
PinakesError::External(format!("Cover download failed: {}", e))
|
PinakesError::External(format!("Cover download failed: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
|
|
@ -115,11 +132,16 @@ impl OpenLibraryClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
|
response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
|
||||||
PinakesError::External(format!("Failed to read cover data: {}", e))
|
PinakesError::External(format!("Failed to read cover data: {e}"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch cover by ISBN
|
/// Fetch cover by ISBN
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the HTTP request fails or the response cannot be
|
||||||
|
/// read.
|
||||||
pub async fn fetch_cover_by_isbn(
|
pub async fn fetch_cover_by_isbn(
|
||||||
&self,
|
&self,
|
||||||
isbn: &str,
|
isbn: &str,
|
||||||
|
|
@ -131,13 +153,11 @@ impl OpenLibraryClient {
|
||||||
CoverSize::Large => "L",
|
CoverSize::Large => "L",
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = format!(
|
let url =
|
||||||
"https://covers.openlibrary.org/b/isbn/{}-{}.jpg",
|
format!("https://covers.openlibrary.org/b/isbn/{isbn}-{size_str}.jpg");
|
||||||
isbn, size_str
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = self.client.get(&url).send().await.map_err(|e| {
|
let response = self.client.get(&url).send().await.map_err(|e| {
|
||||||
PinakesError::External(format!("Cover download failed: {}", e))
|
PinakesError::External(format!("Cover download failed: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
|
|
@ -148,7 +168,7 @@ impl OpenLibraryClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
|
response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
|
||||||
PinakesError::External(format!("Failed to read cover data: {}", e))
|
PinakesError::External(format!("Failed to read cover data: {e}"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -220,6 +240,7 @@ pub enum StringOrObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StringOrObject {
|
impl StringOrObject {
|
||||||
|
#[must_use]
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
Self::String(s) => s,
|
Self::String(s) => s,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@ pub struct TmdbEnricher {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TmdbEnricher {
|
impl TmdbEnricher {
|
||||||
|
/// Create a new `TMDb` enricher.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the HTTP client cannot be built (programming error in client
|
||||||
|
/// configuration).
|
||||||
|
#[must_use]
|
||||||
pub fn new(api_key: String) -> Self {
|
pub fn new(api_key: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: reqwest::Client::builder()
|
client: reqwest::Client::builder()
|
||||||
|
|
@ -50,7 +57,7 @@ impl MetadataEnricher for TmdbEnricher {
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.query(&[
|
.query(&[
|
||||||
("api_key", &self.api_key),
|
("api_key", &self.api_key),
|
||||||
("query", &title.to_string()),
|
("query", &title.clone()),
|
||||||
("page", &"1".to_string()),
|
("page", &"1".to_string()),
|
||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
|
|
@ -85,7 +92,7 @@ impl MetadataEnricher for TmdbEnricher {
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let results = json.get("results").and_then(|r| r.as_array());
|
let results = json.get("results").and_then(|r| r.as_array());
|
||||||
if results.is_none_or(|r| r.is_empty()) {
|
if results.is_none_or(std::vec::Vec::is_empty) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,13 +100,14 @@ impl MetadataEnricher for TmdbEnricher {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let movie = &results[0];
|
let movie = &results[0];
|
||||||
let external_id = match movie.get("id").and_then(|id| id.as_i64()) {
|
let external_id = match movie.get("id").and_then(serde_json::Value::as_i64)
|
||||||
|
{
|
||||||
Some(id) => id.to_string(),
|
Some(id) => id.to_string(),
|
||||||
None => return Ok(None),
|
None => return Ok(None),
|
||||||
};
|
};
|
||||||
let popularity = movie
|
let popularity = movie
|
||||||
.get("popularity")
|
.get("popularity")
|
||||||
.and_then(|p| p.as_f64())
|
.and_then(serde_json::Value::as_f64)
|
||||||
.unwrap_or(0.0);
|
.unwrap_or(0.0);
|
||||||
// Normalize popularity to 0-1 range (TMDB popularity can be very high)
|
// Normalize popularity to 0-1 range (TMDB popularity can be very high)
|
||||||
let confidence = (popularity / 100.0).min(1.0);
|
let confidence = (popularity / 100.0).min(1.0);
|
||||||
|
|
|
||||||
|
|
@ -112,19 +112,19 @@ pub enum PinakesError {
|
||||||
|
|
||||||
impl From<rusqlite::Error> for PinakesError {
|
impl From<rusqlite::Error> for PinakesError {
|
||||||
fn from(e: rusqlite::Error) -> Self {
|
fn from(e: rusqlite::Error) -> Self {
|
||||||
PinakesError::Database(e.to_string())
|
Self::Database(e.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<tokio_postgres::Error> for PinakesError {
|
impl From<tokio_postgres::Error> for PinakesError {
|
||||||
fn from(e: tokio_postgres::Error) -> Self {
|
fn from(e: tokio_postgres::Error) -> Self {
|
||||||
PinakesError::Database(e.to_string())
|
Self::Database(e.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<serde_json::Error> for PinakesError {
|
impl From<serde_json::Error> for PinakesError {
|
||||||
fn from(e: serde_json::Error) -> Self {
|
fn from(e: serde_json::Error) -> Self {
|
||||||
PinakesError::Serialization(e.to_string())
|
Self::Serialization(e.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,12 @@ fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
|
||||||
let dlat = (lat2 - lat1).to_radians();
|
let dlat = (lat2 - lat1).to_radians();
|
||||||
let dlon = (lon2 - lon1).to_radians();
|
let dlon = (lon2 - lon1).to_radians();
|
||||||
|
|
||||||
let a = (dlat / 2.0).sin().powi(2)
|
let a = (dlat / 2.0).sin().mul_add(
|
||||||
+ lat1.to_radians().cos()
|
(dlat / 2.0).sin(),
|
||||||
|
lat1.to_radians().cos()
|
||||||
* lat2.to_radians().cos()
|
* lat2.to_radians().cos()
|
||||||
* (dlon / 2.0).sin().powi(2);
|
* (dlon / 2.0).sin().powi(2),
|
||||||
|
);
|
||||||
|
|
||||||
let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
|
let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
|
||||||
|
|
||||||
|
|
@ -127,7 +129,8 @@ pub fn detect_events(
|
||||||
if let (Some((lat1, lon1)), Some((lat2, lon2))) =
|
if let (Some((lat1, lon1)), Some((lat2, lon2))) =
|
||||||
(current_location, item.latitude.zip(item.longitude))
|
(current_location, item.latitude.zip(item.longitude))
|
||||||
{
|
{
|
||||||
current_location = Some(((lat1 + lat2) / 2.0, (lon1 + lon2) / 2.0));
|
current_location =
|
||||||
|
Some((f64::midpoint(lat1, lat2), f64::midpoint(lon1, lon2)));
|
||||||
} else if item.latitude.is_some() && item.longitude.is_some() {
|
} else if item.latitude.is_some() && item.longitude.is_some() {
|
||||||
current_location = item.latitude.zip(item.longitude);
|
current_location = item.latitude.zip(item.longitude);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ pub async fn compute_file_hash(path: &Path) -> Result<ContentHash> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computes the BLAKE3 hash of a byte slice synchronously.
|
/// Computes the BLAKE3 hash of a byte slice synchronously.
|
||||||
|
#[must_use]
|
||||||
pub fn compute_hash_sync(data: &[u8]) -> ContentHash {
|
pub fn compute_hash_sync(data: &[u8]) -> ContentHash {
|
||||||
let hash = blake3::hash(data);
|
let hash = blake3::hash(data);
|
||||||
ContentHash::new(hash.to_hex().to_string())
|
ContentHash::new(hash.to_hex().to_string())
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use std::{
|
use std::{
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
|
sync::Arc,
|
||||||
time::SystemTime,
|
time::SystemTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -12,7 +13,14 @@ use crate::{
|
||||||
links,
|
links,
|
||||||
media_type::{BuiltinMediaType, MediaType},
|
media_type::{BuiltinMediaType, MediaType},
|
||||||
metadata,
|
metadata,
|
||||||
model::*,
|
model::{
|
||||||
|
AuditAction,
|
||||||
|
CustomField,
|
||||||
|
CustomFieldType,
|
||||||
|
MediaId,
|
||||||
|
MediaItem,
|
||||||
|
StorageMode,
|
||||||
|
},
|
||||||
storage::DynStorageBackend,
|
storage::DynStorageBackend,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
};
|
};
|
||||||
|
|
@ -43,7 +51,7 @@ fn get_file_mtime(path: &Path) -> Option<i64> {
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.modified().ok())
|
.and_then(|m| m.modified().ok())
|
||||||
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||||
.map(|d| d.as_secs() as i64)
|
.map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates that a path is within configured root directories.
|
/// Validates that a path is within configured root directories.
|
||||||
|
|
@ -103,6 +111,10 @@ pub async fn import_file(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Import a file with configurable options for incremental scanning
|
/// Import a file with configurable options for incremental scanning
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the file cannot be read, hashed, or stored.
|
||||||
pub async fn import_file_with_options(
|
pub async fn import_file_with_options(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
|
|
@ -161,7 +173,7 @@ pub async fn import_file_with_options(
|
||||||
let path_clone = path.clone();
|
let path_clone = path.clone();
|
||||||
let media_type_clone = media_type.clone();
|
let media_type_clone = media_type.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
metadata::extract_metadata(&path_clone, media_type_clone)
|
metadata::extract_metadata(&path_clone, &media_type_clone)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| PinakesError::MetadataExtraction(e.to_string()))??
|
.map_err(|e| PinakesError::MetadataExtraction(e.to_string()))??
|
||||||
|
|
@ -185,7 +197,7 @@ pub async fn import_file_with_options(
|
||||||
thumbnail::generate_thumbnail(
|
thumbnail::generate_thumbnail(
|
||||||
media_id,
|
media_id,
|
||||||
&source,
|
&source,
|
||||||
media_type_clone,
|
&media_type_clone,
|
||||||
&thumb_dir,
|
&thumb_dir,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
@ -194,7 +206,7 @@ pub async fn import_file_with_options(
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate perceptual hash for image files (if enabled in config)
|
// Generate perceptual hash for image files (if enabled in config)
|
||||||
let perceptual_hash = if options.photo_config.generate_perceptual_hash
|
let perceptual_hash = if options.photo_config.generate_perceptual_hash()
|
||||||
&& media_type.category() == crate::media_type::MediaCategory::Image
|
&& media_type.category() == crate::media_type::MediaCategory::Image
|
||||||
{
|
{
|
||||||
crate::metadata::image::generate_perceptual_hash(&path)
|
crate::metadata::image::generate_perceptual_hash(&path)
|
||||||
|
|
@ -327,6 +339,12 @@ pub(crate) fn should_ignore(
|
||||||
/// Default number of concurrent import tasks.
|
/// Default number of concurrent import tasks.
|
||||||
const DEFAULT_IMPORT_CONCURRENCY: usize = 8;
|
const DEFAULT_IMPORT_CONCURRENCY: usize = 8;
|
||||||
|
|
||||||
|
/// Import all supported files in a directory with default options.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the directory cannot be read or spawned tasks
|
||||||
|
/// fail.
|
||||||
pub async fn import_directory(
|
pub async fn import_directory(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
|
|
@ -342,6 +360,13 @@ pub async fn import_directory(
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Import all supported files in a directory with a specified concurrency
|
||||||
|
/// limit.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the directory cannot be read or spawned tasks
|
||||||
|
/// fail.
|
||||||
pub async fn import_directory_with_concurrency(
|
pub async fn import_directory_with_concurrency(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
|
|
@ -358,7 +383,12 @@ pub async fn import_directory_with_concurrency(
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Import a directory with full options including incremental scanning support
|
/// Import a directory with full options including incremental scanning support.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the directory cannot be read or spawned tasks
|
||||||
|
/// fail.
|
||||||
pub async fn import_directory_with_options(
|
pub async fn import_directory_with_options(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
|
|
@ -377,8 +407,8 @@ pub async fn import_directory_with_options(
|
||||||
walkdir::WalkDir::new(&dir)
|
walkdir::WalkDir::new(&dir)
|
||||||
.follow_links(true)
|
.follow_links(true)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(std::result::Result::ok)
|
||||||
.filter(|e| e.file_type().is_file())
|
.filter(|e| !e.file_type().is_dir())
|
||||||
.filter(|e| MediaType::from_path(e.path()).is_some())
|
.filter(|e| MediaType::from_path(e.path()).is_some())
|
||||||
.filter(|e| !should_ignore(e.path(), &patterns))
|
.filter(|e| !should_ignore(e.path(), &patterns))
|
||||||
.map(|e| e.path().to_path_buf())
|
.map(|e| e.path().to_path_buf())
|
||||||
|
|
@ -392,7 +422,7 @@ pub async fn import_directory_with_options(
|
||||||
let mut join_set = tokio::task::JoinSet::new();
|
let mut join_set = tokio::task::JoinSet::new();
|
||||||
|
|
||||||
for entry_path in entries {
|
for entry_path in entries {
|
||||||
let storage = storage.clone();
|
let storage = Arc::clone(storage);
|
||||||
let path = entry_path.clone();
|
let path = entry_path.clone();
|
||||||
let opts = options.clone();
|
let opts = options.clone();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,10 @@ impl std::str::FromStr for IntegrityStatus {
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// Report containing orphaned items, untracked files, and moved files
|
/// Report containing orphaned items, untracked files, and moved files
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
|
||||||
pub async fn detect_orphans(
|
pub async fn detect_orphans(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
) -> Result<OrphanReport> {
|
) -> Result<OrphanReport> {
|
||||||
|
|
@ -283,6 +287,10 @@ async fn detect_untracked_files(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve orphaned media items by deleting them from the database.
|
/// Resolve orphaned media items by deleting them from the database.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
|
||||||
pub async fn resolve_orphans(
|
pub async fn resolve_orphans(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
action: OrphanAction,
|
action: OrphanAction,
|
||||||
|
|
@ -302,6 +310,10 @@ pub async fn resolve_orphans(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify integrity of media files by recomputing hashes and comparing.
|
/// Verify integrity of media files by recomputing hashes and comparing.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
|
||||||
pub async fn verify_integrity(
|
pub async fn verify_integrity(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
media_ids: Option<&[MediaId]>,
|
media_ids: Option<&[MediaId]>,
|
||||||
|
|
@ -361,6 +373,11 @@ pub async fn verify_integrity(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clean up orphaned thumbnail files that don't correspond to any media item.
|
/// Clean up orphaned thumbnail files that don't correspond to any media item.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if the storage operation or
|
||||||
|
/// filesystem access fails.
|
||||||
pub async fn cleanup_orphaned_thumbnails(
|
pub async fn cleanup_orphaned_thumbnails(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
thumbnail_dir: &Path,
|
thumbnail_dir: &Path,
|
||||||
|
|
|
||||||
|
|
@ -15,18 +15,17 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use crate::model::{LinkType, MarkdownLink, MediaId};
|
use crate::model::{LinkType, MarkdownLink, MediaId};
|
||||||
|
|
||||||
// Compile regexes once at startup to avoid recompilation on every call
|
// Compile regexes once at startup to avoid recompilation on every call.
|
||||||
static WIKILINK_RE: LazyLock<Regex> = LazyLock::new(|| {
|
// Stored as Option so that initialization failure is handled gracefully
|
||||||
Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").expect("valid wikilink regex")
|
// rather than panicking.
|
||||||
});
|
static WIKILINK_RE: LazyLock<Option<Regex>> =
|
||||||
|
LazyLock::new(|| Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").ok());
|
||||||
|
|
||||||
static EMBED_RE: LazyLock<Regex> = LazyLock::new(|| {
|
static EMBED_RE: LazyLock<Option<Regex>> =
|
||||||
Regex::new(r"!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").expect("valid embed regex")
|
LazyLock::new(|| Regex::new(r"!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").ok());
|
||||||
});
|
|
||||||
|
|
||||||
static MARKDOWN_LINK_RE: LazyLock<Regex> = LazyLock::new(|| {
|
static MARKDOWN_LINK_RE: LazyLock<Option<Regex>> =
|
||||||
Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").expect("valid markdown link regex")
|
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").ok());
|
||||||
});
|
|
||||||
|
|
||||||
/// Configuration for context extraction around links
|
/// Configuration for context extraction around links
|
||||||
const CONTEXT_CHARS_BEFORE: usize = 50;
|
const CONTEXT_CHARS_BEFORE: usize = 50;
|
||||||
|
|
@ -38,6 +37,7 @@ const CONTEXT_CHARS_AFTER: usize = 50;
|
||||||
/// - Wikilinks: `[[target]]` and `[[target|display text]]`
|
/// - Wikilinks: `[[target]]` and `[[target|display text]]`
|
||||||
/// - Embeds: `![[target]]`
|
/// - Embeds: `![[target]]`
|
||||||
/// - Markdown links: `[text](path)` (internal paths only, no http/https)
|
/// - Markdown links: `[text](path)` (internal paths only, no http/https)
|
||||||
|
#[must_use]
|
||||||
pub fn extract_links(
|
pub fn extract_links(
|
||||||
source_media_id: MediaId,
|
source_media_id: MediaId,
|
||||||
content: &str,
|
content: &str,
|
||||||
|
|
@ -63,10 +63,13 @@ fn extract_wikilinks(
|
||||||
source_media_id: MediaId,
|
source_media_id: MediaId,
|
||||||
content: &str,
|
content: &str,
|
||||||
) -> Vec<MarkdownLink> {
|
) -> Vec<MarkdownLink> {
|
||||||
|
let Some(re) = WIKILINK_RE.as_ref() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
let mut links = Vec::new();
|
let mut links = Vec::new();
|
||||||
|
|
||||||
for (line_num, line) in content.lines().enumerate() {
|
for (line_num, line) in content.lines().enumerate() {
|
||||||
for cap in WIKILINK_RE.captures_iter(line) {
|
for cap in re.captures_iter(line) {
|
||||||
let Some(full_match) = cap.get(0) else {
|
let Some(full_match) = cap.get(0) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
@ -100,7 +103,11 @@ fn extract_wikilinks(
|
||||||
target_media_id: None, // Will be resolved later
|
target_media_id: None, // Will be resolved later
|
||||||
link_type: LinkType::Wikilink,
|
link_type: LinkType::Wikilink,
|
||||||
link_text: display_text.or_else(|| Some(target.to_string())),
|
link_text: display_text.or_else(|| Some(target.to_string())),
|
||||||
line_number: Some(line_num as i32 + 1), // 1-indexed
|
line_number: Some(
|
||||||
|
i32::try_from(line_num)
|
||||||
|
.unwrap_or(i32::MAX)
|
||||||
|
.saturating_add(1),
|
||||||
|
), // 1-indexed
|
||||||
context: Some(context),
|
context: Some(context),
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
});
|
});
|
||||||
|
|
@ -116,10 +123,13 @@ fn extract_embeds(
|
||||||
source_media_id: MediaId,
|
source_media_id: MediaId,
|
||||||
content: &str,
|
content: &str,
|
||||||
) -> Vec<MarkdownLink> {
|
) -> Vec<MarkdownLink> {
|
||||||
|
let Some(re) = EMBED_RE.as_ref() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
let mut links = Vec::new();
|
let mut links = Vec::new();
|
||||||
|
|
||||||
for (line_num, line) in content.lines().enumerate() {
|
for (line_num, line) in content.lines().enumerate() {
|
||||||
for cap in EMBED_RE.captures_iter(line) {
|
for cap in re.captures_iter(line) {
|
||||||
let Some(full_match) = cap.get(0) else {
|
let Some(full_match) = cap.get(0) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
@ -143,7 +153,11 @@ fn extract_embeds(
|
||||||
target_media_id: None,
|
target_media_id: None,
|
||||||
link_type: LinkType::Embed,
|
link_type: LinkType::Embed,
|
||||||
link_text: display_text.or_else(|| Some(target.to_string())),
|
link_text: display_text.or_else(|| Some(target.to_string())),
|
||||||
line_number: Some(line_num as i32 + 1),
|
line_number: Some(
|
||||||
|
i32::try_from(line_num)
|
||||||
|
.unwrap_or(i32::MAX)
|
||||||
|
.saturating_add(1),
|
||||||
|
),
|
||||||
context: Some(context),
|
context: Some(context),
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
});
|
});
|
||||||
|
|
@ -159,10 +173,13 @@ fn extract_markdown_links(
|
||||||
source_media_id: MediaId,
|
source_media_id: MediaId,
|
||||||
content: &str,
|
content: &str,
|
||||||
) -> Vec<MarkdownLink> {
|
) -> Vec<MarkdownLink> {
|
||||||
|
let Some(re) = MARKDOWN_LINK_RE.as_ref() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
let mut links = Vec::new();
|
let mut links = Vec::new();
|
||||||
|
|
||||||
for (line_num, line) in content.lines().enumerate() {
|
for (line_num, line) in content.lines().enumerate() {
|
||||||
for cap in MARKDOWN_LINK_RE.captures_iter(line) {
|
for cap in re.captures_iter(line) {
|
||||||
let Some(full_match) = cap.get(0) else {
|
let Some(full_match) = cap.get(0) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
@ -215,7 +232,11 @@ fn extract_markdown_links(
|
||||||
target_media_id: None,
|
target_media_id: None,
|
||||||
link_type: LinkType::MarkdownLink,
|
link_type: LinkType::MarkdownLink,
|
||||||
link_text: Some(text.to_string()),
|
link_text: Some(text.to_string()),
|
||||||
line_number: Some(line_num as i32 + 1),
|
line_number: Some(
|
||||||
|
i32::try_from(line_num)
|
||||||
|
.unwrap_or(i32::MAX)
|
||||||
|
.saturating_add(1),
|
||||||
|
),
|
||||||
context: Some(context),
|
context: Some(context),
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
});
|
});
|
||||||
|
|
@ -278,6 +299,7 @@ pub enum ResolutionStrategy {
|
||||||
/// Resolve a link target to possible file paths.
|
/// Resolve a link target to possible file paths.
|
||||||
///
|
///
|
||||||
/// Returns a list of candidate paths to check, in order of preference.
|
/// Returns a list of candidate paths to check, in order of preference.
|
||||||
|
#[must_use]
|
||||||
pub fn resolve_link_candidates(
|
pub fn resolve_link_candidates(
|
||||||
target: &str,
|
target: &str,
|
||||||
source_path: &Path,
|
source_path: &Path,
|
||||||
|
|
@ -307,7 +329,7 @@ pub fn resolve_link_candidates(
|
||||||
candidates.push(relative.clone());
|
candidates.push(relative.clone());
|
||||||
|
|
||||||
// Also try with .md extension
|
// Also try with .md extension
|
||||||
if !target.ends_with(".md") {
|
if !target.to_ascii_lowercase().ends_with(".md") {
|
||||||
candidates.push(relative.with_extension("md"));
|
candidates.push(relative.with_extension("md"));
|
||||||
let mut with_md = relative.clone();
|
let mut with_md = relative.clone();
|
||||||
with_md.set_file_name(format!(
|
with_md.set_file_name(format!(
|
||||||
|
|
@ -319,10 +341,10 @@ pub fn resolve_link_candidates(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Filename with .md extension in root dirs
|
// 3. Filename with .md extension in root dirs
|
||||||
let target_with_md = if target.ends_with(".md") {
|
let target_with_md = if target.to_ascii_lowercase().ends_with(".md") {
|
||||||
target.to_string()
|
target.to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("{}.md", target)
|
format!("{target}.md")
|
||||||
};
|
};
|
||||||
|
|
||||||
for root in root_dirs {
|
for root in root_dirs {
|
||||||
|
|
@ -340,6 +362,7 @@ pub fn resolve_link_candidates(
|
||||||
///
|
///
|
||||||
/// Obsidian uses the `aliases` field in frontmatter to define alternative names
|
/// Obsidian uses the `aliases` field in frontmatter to define alternative names
|
||||||
/// for a note that can be used in wikilinks.
|
/// for a note that can be used in wikilinks.
|
||||||
|
#[must_use]
|
||||||
pub fn extract_aliases(content: &str) -> Vec<String> {
|
pub fn extract_aliases(content: &str) -> Vec<String> {
|
||||||
let Ok(parsed) =
|
let Ok(parsed) =
|
||||||
gray_matter::Matter::<gray_matter::engine::YAML>::new().parse(content)
|
gray_matter::Matter::<gray_matter::engine::YAML>::new().parse(content)
|
||||||
|
|
@ -441,7 +464,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_multiple_links() {
|
fn test_multiple_links() {
|
||||||
let content = r#"
|
let content = r"
|
||||||
# My Note
|
# My Note
|
||||||
|
|
||||||
This links to [[Note A]] and also [[Note B|Note B Title]].
|
This links to [[Note A]] and also [[Note B|Note B Title]].
|
||||||
|
|
@ -449,7 +472,7 @@ This links to [[Note A]] and also [[Note B|Note B Title]].
|
||||||
We also have a markdown link to [config](./config.md).
|
We also have a markdown link to [config](./config.md).
|
||||||
|
|
||||||
And an embedded image: ![[diagram.png]]
|
And an embedded image: ![[diagram.png]]
|
||||||
"#;
|
";
|
||||||
let links = extract_links(test_media_id(), content);
|
let links = extract_links(test_media_id(), content);
|
||||||
|
|
||||||
assert_eq!(links.len(), 4);
|
assert_eq!(links.len(), 4);
|
||||||
|
|
@ -488,7 +511,7 @@ And an embedded image: ![[diagram.png]]
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_aliases() {
|
fn test_extract_aliases() {
|
||||||
let content = r#"---
|
let content = r"---
|
||||||
title: My Note
|
title: My Note
|
||||||
aliases:
|
aliases:
|
||||||
- Alternative Name
|
- Alternative Name
|
||||||
|
|
@ -496,20 +519,20 @@ aliases:
|
||||||
---
|
---
|
||||||
|
|
||||||
# Content here
|
# Content here
|
||||||
"#;
|
";
|
||||||
let aliases = extract_aliases(content);
|
let aliases = extract_aliases(content);
|
||||||
assert_eq!(aliases, vec!["Alternative Name", "Another Alias"]);
|
assert_eq!(aliases, vec!["Alternative Name", "Another Alias"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_single_alias() {
|
fn test_extract_single_alias() {
|
||||||
let content = r#"---
|
let content = r"---
|
||||||
title: My Note
|
title: My Note
|
||||||
aliases: Single Alias
|
aliases: Single Alias
|
||||||
---
|
---
|
||||||
|
|
||||||
# Content
|
# Content
|
||||||
"#;
|
";
|
||||||
let aliases = extract_aliases(content);
|
let aliases = extract_aliases(content);
|
||||||
assert_eq!(aliases, vec!["Single Alias"]);
|
assert_eq!(aliases, vec!["Single Alias"]);
|
||||||
}
|
}
|
||||||
|
|
@ -538,7 +561,7 @@ aliases: Single Alias
|
||||||
#[test]
|
#[test]
|
||||||
fn test_exclude_markdown_images() {
|
fn test_exclude_markdown_images() {
|
||||||
// Test that markdown images  are NOT extracted as links
|
// Test that markdown images  are NOT extracted as links
|
||||||
let content = r#"
|
let content = r"
|
||||||
# My Note
|
# My Note
|
||||||
|
|
||||||
Here's a regular link: [documentation](docs/guide.md)
|
Here's a regular link: [documentation](docs/guide.md)
|
||||||
|
|
@ -551,15 +574,14 @@ Multiple images:
|
||||||
 and 
|
 and 
|
||||||
|
|
||||||
Mixed: [link](file.md) then  then [another](other.md)
|
Mixed: [link](file.md) then  then [another](other.md)
|
||||||
"#;
|
";
|
||||||
let links = extract_links(test_media_id(), content);
|
let links = extract_links(test_media_id(), content);
|
||||||
|
|
||||||
// Should only extract the 4 markdown links, not the 4 images
|
// Should only extract the 4 markdown links, not the 4 images
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
links.len(),
|
links.len(),
|
||||||
4,
|
4,
|
||||||
"Should extract 4 links, not images. Got: {:#?}",
|
"Should extract 4 links, not images. Got: {links:#?}"
|
||||||
links
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify all extracted items are MarkdownLink type (not images)
|
// Verify all extracted items are MarkdownLink type (not images)
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ pub struct ManagedStorageService {
|
||||||
|
|
||||||
impl ManagedStorageService {
|
impl ManagedStorageService {
|
||||||
/// Create a new managed storage service.
|
/// Create a new managed storage service.
|
||||||
pub fn new(
|
#[must_use]
|
||||||
|
pub const fn new(
|
||||||
root_dir: PathBuf,
|
root_dir: PathBuf,
|
||||||
max_upload_size: u64,
|
max_upload_size: u64,
|
||||||
verify_on_read: bool,
|
verify_on_read: bool,
|
||||||
|
|
@ -41,6 +42,10 @@ impl ManagedStorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize the storage directory structure.
|
/// Initialize the storage directory structure.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the directory cannot be created.
|
||||||
pub async fn init(&self) -> Result<()> {
|
pub async fn init(&self) -> Result<()> {
|
||||||
fs::create_dir_all(&self.root_dir).await?;
|
fs::create_dir_all(&self.root_dir).await?;
|
||||||
info!(path = %self.root_dir.display(), "initialized managed storage");
|
info!(path = %self.root_dir.display(), "initialized managed storage");
|
||||||
|
|
@ -50,6 +55,7 @@ impl ManagedStorageService {
|
||||||
/// Get the storage path for a content hash.
|
/// Get the storage path for a content hash.
|
||||||
///
|
///
|
||||||
/// Layout: `<root>/<hash[0:2]>/<hash[2:4]>/<full_hash>`
|
/// Layout: `<root>/<hash[0:2]>/<hash[2:4]>/<full_hash>`
|
||||||
|
#[must_use]
|
||||||
pub fn path(&self, hash: &ContentHash) -> PathBuf {
|
pub fn path(&self, hash: &ContentHash) -> PathBuf {
|
||||||
let h = &hash.0;
|
let h = &hash.0;
|
||||||
if h.len() >= 4 {
|
if h.len() >= 4 {
|
||||||
|
|
@ -61,7 +67,8 @@ impl ManagedStorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a blob exists in storage.
|
/// Check if a blob exists in storage.
|
||||||
pub async fn exists(&self, hash: &ContentHash) -> bool {
|
#[must_use]
|
||||||
|
pub fn exists(&self, hash: &ContentHash) -> bool {
|
||||||
self.path(hash).exists()
|
self.path(hash).exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,6 +77,11 @@ impl ManagedStorageService {
|
||||||
/// Returns the content hash and file size.
|
/// Returns the content hash and file size.
|
||||||
/// If the file already exists with the same hash, returns early
|
/// If the file already exists with the same hash, returns early
|
||||||
/// (deduplication).
|
/// (deduplication).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the file cannot be stored or exceeds the size
|
||||||
|
/// limit.
|
||||||
pub async fn store_stream<R: AsyncRead + Unpin>(
|
pub async fn store_stream<R: AsyncRead + Unpin>(
|
||||||
&self,
|
&self,
|
||||||
mut reader: R,
|
mut reader: R,
|
||||||
|
|
@ -119,14 +131,13 @@ impl ManagedStorageService {
|
||||||
debug!(hash = %hash, "blob already exists, deduplicating");
|
debug!(hash = %hash, "blob already exists, deduplicating");
|
||||||
let _ = fs::remove_file(&temp_path).await;
|
let _ = fs::remove_file(&temp_path).await;
|
||||||
return Ok((hash, total_size));
|
return Ok((hash, total_size));
|
||||||
} else {
|
|
||||||
warn!(
|
|
||||||
hash = %hash,
|
|
||||||
expected = total_size,
|
|
||||||
actual = existing_meta.len(),
|
|
||||||
"size mismatch for existing blob, replacing"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
warn!(
|
||||||
|
hash = %hash,
|
||||||
|
expected = total_size,
|
||||||
|
actual = existing_meta.len(),
|
||||||
|
"size mismatch for existing blob, replacing"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move temp file to final location
|
// Move temp file to final location
|
||||||
|
|
@ -140,6 +151,10 @@ impl ManagedStorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store a file from a path.
|
/// Store a file from a path.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the file cannot be opened or stored.
|
||||||
pub async fn store_file(&self, path: &Path) -> Result<(ContentHash, u64)> {
|
pub async fn store_file(&self, path: &Path) -> Result<(ContentHash, u64)> {
|
||||||
let file = fs::File::open(path).await?;
|
let file = fs::File::open(path).await?;
|
||||||
let reader = BufReader::new(file);
|
let reader = BufReader::new(file);
|
||||||
|
|
@ -147,6 +162,11 @@ impl ManagedStorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store bytes directly.
|
/// Store bytes directly.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the data cannot be stored or exceeds the size
|
||||||
|
/// limit.
|
||||||
pub async fn store_bytes(&self, data: &[u8]) -> Result<(ContentHash, u64)> {
|
pub async fn store_bytes(&self, data: &[u8]) -> Result<(ContentHash, u64)> {
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
let cursor = Cursor::new(data);
|
let cursor = Cursor::new(data);
|
||||||
|
|
@ -154,6 +174,10 @@ impl ManagedStorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a blob for reading.
|
/// Open a blob for reading.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the blob does not exist or cannot be opened.
|
||||||
pub async fn open(&self, hash: &ContentHash) -> Result<fs::File> {
|
pub async fn open(&self, hash: &ContentHash) -> Result<fs::File> {
|
||||||
let path = self.path(hash);
|
let path = self.path(hash);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
|
|
@ -168,6 +192,11 @@ impl ManagedStorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read a blob entirely into memory.
|
/// Read a blob entirely into memory.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the blob does not exist, cannot be read, or
|
||||||
|
/// fails integrity check.
|
||||||
pub async fn read(&self, hash: &ContentHash) -> Result<Vec<u8>> {
|
pub async fn read(&self, hash: &ContentHash) -> Result<Vec<u8>> {
|
||||||
let path = self.path(hash);
|
let path = self.path(hash);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
|
|
@ -180,8 +209,7 @@ impl ManagedStorageService {
|
||||||
let computed = blake3::hash(&data);
|
let computed = blake3::hash(&data);
|
||||||
if computed.to_hex().to_string() != hash.0 {
|
if computed.to_hex().to_string() != hash.0 {
|
||||||
return Err(PinakesError::StorageIntegrity(format!(
|
return Err(PinakesError::StorageIntegrity(format!(
|
||||||
"hash mismatch for blob {}",
|
"hash mismatch for blob {hash}"
|
||||||
hash
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -190,6 +218,11 @@ impl ManagedStorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify the integrity of a stored blob.
|
/// Verify the integrity of a stored blob.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the blob cannot be read or has a hash
|
||||||
|
/// mismatch.
|
||||||
pub async fn verify(&self, hash: &ContentHash) -> Result<bool> {
|
pub async fn verify(&self, hash: &ContentHash) -> Result<bool> {
|
||||||
let path = self.path(hash);
|
let path = self.path(hash);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
|
|
@ -217,8 +250,7 @@ impl ManagedStorageService {
|
||||||
"blob integrity check failed"
|
"blob integrity check failed"
|
||||||
);
|
);
|
||||||
return Err(PinakesError::StorageIntegrity(format!(
|
return Err(PinakesError::StorageIntegrity(format!(
|
||||||
"hash mismatch: expected {}, computed {}",
|
"hash mismatch: expected {hash}, computed {computed}"
|
||||||
hash, computed
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,6 +259,10 @@ impl ManagedStorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a blob from storage.
|
/// Delete a blob from storage.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the blob cannot be removed.
|
||||||
pub async fn delete(&self, hash: &ContentHash) -> Result<()> {
|
pub async fn delete(&self, hash: &ContentHash) -> Result<()> {
|
||||||
let path = self.path(hash);
|
let path = self.path(hash);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
|
|
@ -245,6 +281,11 @@ impl ManagedStorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the size of a stored blob.
|
/// Get the size of a stored blob.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the blob does not exist or metadata cannot be
|
||||||
|
/// read.
|
||||||
pub async fn size(&self, hash: &ContentHash) -> Result<u64> {
|
pub async fn size(&self, hash: &ContentHash) -> Result<u64> {
|
||||||
let path = self.path(hash);
|
let path = self.path(hash);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
|
|
@ -255,18 +296,23 @@ impl ManagedStorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all blob hashes in storage.
|
/// List all blob hashes in storage.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the storage directory cannot be read.
|
||||||
pub async fn list_all(&self) -> Result<Vec<ContentHash>> {
|
pub async fn list_all(&self) -> Result<Vec<ContentHash>> {
|
||||||
let mut hashes = Vec::new();
|
let mut hashes = Vec::new();
|
||||||
|
|
||||||
let mut entries = fs::read_dir(&self.root_dir).await?;
|
let mut entries = fs::read_dir(&self.root_dir).await?;
|
||||||
while let Some(entry) = entries.next_entry().await? {
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.is_dir() && path.file_name().map(|n| n.len()) == Some(2) {
|
if path.is_dir() && path.file_name().map(std::ffi::OsStr::len) == Some(2)
|
||||||
|
{
|
||||||
let mut sub_entries = fs::read_dir(&path).await?;
|
let mut sub_entries = fs::read_dir(&path).await?;
|
||||||
while let Some(sub_entry) = sub_entries.next_entry().await? {
|
while let Some(sub_entry) = sub_entries.next_entry().await? {
|
||||||
let sub_path = sub_entry.path();
|
let sub_path = sub_entry.path();
|
||||||
if sub_path.is_dir()
|
if sub_path.is_dir()
|
||||||
&& sub_path.file_name().map(|n| n.len()) == Some(2)
|
&& sub_path.file_name().map(std::ffi::OsStr::len) == Some(2)
|
||||||
{
|
{
|
||||||
let mut file_entries = fs::read_dir(&sub_path).await?;
|
let mut file_entries = fs::read_dir(&sub_path).await?;
|
||||||
while let Some(file_entry) = file_entries.next_entry().await? {
|
while let Some(file_entry) = file_entries.next_entry().await? {
|
||||||
|
|
@ -287,6 +333,10 @@ impl ManagedStorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate total storage used by all blobs.
|
/// Calculate total storage used by all blobs.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`StorageError`] if listing blobs or querying sizes fails.
|
||||||
pub async fn total_size(&self) -> Result<u64> {
|
pub async fn total_size(&self) -> Result<u64> {
|
||||||
let hashes = self.list_all().await?;
|
let hashes = self.list_all().await?;
|
||||||
let mut total = 0u64;
|
let mut total = 0u64;
|
||||||
|
|
@ -299,6 +349,10 @@ impl ManagedStorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clean up any orphaned temp files.
|
/// Clean up any orphaned temp files.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the temp directory cannot be read.
|
||||||
pub async fn cleanup_temp(&self) -> Result<u64> {
|
pub async fn cleanup_temp(&self) -> Result<u64> {
|
||||||
let temp_dir = self.root_dir.join("temp");
|
let temp_dir = self.root_dir.join("temp");
|
||||||
if !temp_dir.exists() {
|
if !temp_dir.exists() {
|
||||||
|
|
@ -349,7 +403,7 @@ mod tests {
|
||||||
let (hash, size) = service.store_bytes(data).await.unwrap();
|
let (hash, size) = service.store_bytes(data).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(size, data.len() as u64);
|
assert_eq!(size, data.len() as u64);
|
||||||
assert!(service.exists(&hash).await);
|
assert!(service.exists(&hash));
|
||||||
|
|
||||||
let retrieved = service.read(&hash).await.unwrap();
|
let retrieved = service.read(&hash).await.unwrap();
|
||||||
assert_eq!(retrieved, data);
|
assert_eq!(retrieved, data);
|
||||||
|
|
@ -405,9 +459,9 @@ mod tests {
|
||||||
|
|
||||||
let data = b"delete me";
|
let data = b"delete me";
|
||||||
let (hash, _) = service.store_bytes(data).await.unwrap();
|
let (hash, _) = service.store_bytes(data).await.unwrap();
|
||||||
assert!(service.exists(&hash).await);
|
assert!(service.exists(&hash));
|
||||||
|
|
||||||
service.delete(&hash).await.unwrap();
|
service.delete(&hash).await.unwrap();
|
||||||
assert!(!service.exists(&hash).await);
|
assert!(!service.exists(&hash));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,8 @@ pub enum MediaCategory {
|
||||||
|
|
||||||
impl BuiltinMediaType {
|
impl BuiltinMediaType {
|
||||||
/// Get the unique, stable ID for this media type.
|
/// Get the unique, stable ID for this media type.
|
||||||
pub fn id(&self) -> &'static str {
|
#[must_use]
|
||||||
|
pub const fn id(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Mp3 => "mp3",
|
Self::Mp3 => "mp3",
|
||||||
Self::Flac => "flac",
|
Self::Flac => "flac",
|
||||||
|
|
@ -98,6 +99,7 @@ impl BuiltinMediaType {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the display name for this media type
|
/// Get the display name for this media type
|
||||||
|
#[must_use]
|
||||||
pub fn name(&self) -> String {
|
pub fn name(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
Self::Mp3 => "MP3 Audio".to_string(),
|
Self::Mp3 => "MP3 Audio".to_string(),
|
||||||
|
|
@ -133,6 +135,7 @@ impl BuiltinMediaType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn from_extension(ext: &str) -> Option<Self> {
|
pub fn from_extension(ext: &str) -> Option<Self> {
|
||||||
match ext.to_ascii_lowercase().as_str() {
|
match ext.to_ascii_lowercase().as_str() {
|
||||||
"mp3" => Some(Self::Mp3),
|
"mp3" => Some(Self::Mp3),
|
||||||
|
|
@ -176,7 +179,8 @@ impl BuiltinMediaType {
|
||||||
.and_then(Self::from_extension)
|
.and_then(Self::from_extension)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mime_type(&self) -> &'static str {
|
#[must_use]
|
||||||
|
pub const fn mime_type(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Mp3 => "audio/mpeg",
|
Self::Mp3 => "audio/mpeg",
|
||||||
Self::Flac => "audio/flac",
|
Self::Flac => "audio/flac",
|
||||||
|
|
@ -211,7 +215,8 @@ impl BuiltinMediaType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn category(&self) -> MediaCategory {
|
#[must_use]
|
||||||
|
pub const fn category(&self) -> MediaCategory {
|
||||||
match self {
|
match self {
|
||||||
Self::Mp3
|
Self::Mp3
|
||||||
| Self::Flac
|
| Self::Flac
|
||||||
|
|
@ -240,7 +245,8 @@ impl BuiltinMediaType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn extensions(&self) -> &'static [&'static str] {
|
#[must_use]
|
||||||
|
pub const fn extensions(&self) -> &'static [&'static str] {
|
||||||
match self {
|
match self {
|
||||||
Self::Mp3 => &["mp3"],
|
Self::Mp3 => &["mp3"],
|
||||||
Self::Flac => &["flac"],
|
Self::Flac => &["flac"],
|
||||||
|
|
@ -276,7 +282,8 @@ impl BuiltinMediaType {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if this is a RAW image format.
|
/// Returns true if this is a RAW image format.
|
||||||
pub fn is_raw(&self) -> bool {
|
#[must_use]
|
||||||
|
pub const fn is_raw(&self) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
self,
|
self,
|
||||||
Self::Cr2 | Self::Nef | Self::Arw | Self::Dng | Self::Orf | Self::Rw2
|
Self::Cr2 | Self::Nef | Self::Arw | Self::Dng | Self::Orf | Self::Rw2
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ impl MediaType {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the type ID as a string
|
/// Get the type ID as a string
|
||||||
|
#[must_use]
|
||||||
pub fn id(&self) -> String {
|
pub fn id(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
Self::Builtin(b) => b.id().to_string(),
|
Self::Builtin(b) => b.id().to_string(),
|
||||||
|
|
@ -40,6 +41,7 @@ impl MediaType {
|
||||||
|
|
||||||
/// Get the display name for this media type
|
/// Get the display name for this media type
|
||||||
/// For custom types without a registry, returns the ID as the name
|
/// For custom types without a registry, returns the ID as the name
|
||||||
|
#[must_use]
|
||||||
pub fn name(&self) -> String {
|
pub fn name(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
Self::Builtin(b) => b.name(),
|
Self::Builtin(b) => b.name(),
|
||||||
|
|
@ -48,14 +50,14 @@ impl MediaType {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the display name for this media type with registry support
|
/// Get the display name for this media type with registry support
|
||||||
|
#[must_use]
|
||||||
pub fn name_with_registry(&self, registry: &MediaTypeRegistry) -> String {
|
pub fn name_with_registry(&self, registry: &MediaTypeRegistry) -> String {
|
||||||
match self {
|
match self {
|
||||||
Self::Builtin(b) => b.name(),
|
Self::Builtin(b) => b.name(),
|
||||||
Self::Custom(id) => {
|
Self::Custom(id) => {
|
||||||
registry
|
registry
|
||||||
.get(id)
|
.get(id)
|
||||||
.map(|d| d.name.clone())
|
.map_or_else(|| id.clone(), |d| d.name.clone())
|
||||||
.unwrap_or_else(|| id.clone())
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +65,8 @@ impl MediaType {
|
||||||
/// Get the category for this media type
|
/// Get the category for this media type
|
||||||
/// For custom types without a registry, returns [`MediaCategory::Document`]
|
/// For custom types without a registry, returns [`MediaCategory::Document`]
|
||||||
/// as default
|
/// as default
|
||||||
pub fn category(&self) -> MediaCategory {
|
#[must_use]
|
||||||
|
pub const fn category(&self) -> MediaCategory {
|
||||||
match self {
|
match self {
|
||||||
Self::Builtin(b) => b.category(),
|
Self::Builtin(b) => b.category(),
|
||||||
Self::Custom(_) => MediaCategory::Document,
|
Self::Custom(_) => MediaCategory::Document,
|
||||||
|
|
@ -71,6 +74,7 @@ impl MediaType {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the category for this media type with registry support
|
/// Get the category for this media type with registry support
|
||||||
|
#[must_use]
|
||||||
pub fn category_with_registry(
|
pub fn category_with_registry(
|
||||||
&self,
|
&self,
|
||||||
registry: &MediaTypeRegistry,
|
registry: &MediaTypeRegistry,
|
||||||
|
|
@ -88,6 +92,7 @@ impl MediaType {
|
||||||
|
|
||||||
/// Get the MIME type
|
/// Get the MIME type
|
||||||
/// For custom types without a registry, returns "application/octet-stream"
|
/// For custom types without a registry, returns "application/octet-stream"
|
||||||
|
#[must_use]
|
||||||
pub fn mime_type(&self) -> String {
|
pub fn mime_type(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
Self::Builtin(b) => b.mime_type().to_string(),
|
Self::Builtin(b) => b.mime_type().to_string(),
|
||||||
|
|
@ -96,6 +101,7 @@ impl MediaType {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the MIME type with registry support
|
/// Get the MIME type with registry support
|
||||||
|
#[must_use]
|
||||||
pub fn mime_type_with_registry(
|
pub fn mime_type_with_registry(
|
||||||
&self,
|
&self,
|
||||||
registry: &MediaTypeRegistry,
|
registry: &MediaTypeRegistry,
|
||||||
|
|
@ -113,23 +119,31 @@ impl MediaType {
|
||||||
|
|
||||||
/// Get file extensions
|
/// Get file extensions
|
||||||
/// For custom types without a registry, returns an empty vec
|
/// For custom types without a registry, returns an empty vec
|
||||||
|
#[must_use]
|
||||||
pub fn extensions(&self) -> Vec<String> {
|
pub fn extensions(&self) -> Vec<String> {
|
||||||
match self {
|
match self {
|
||||||
Self::Builtin(b) => {
|
Self::Builtin(b) => {
|
||||||
b.extensions().iter().map(|s| s.to_string()).collect()
|
b.extensions()
|
||||||
|
.iter()
|
||||||
|
.map(std::string::ToString::to_string)
|
||||||
|
.collect()
|
||||||
},
|
},
|
||||||
Self::Custom(_) => vec![],
|
Self::Custom(_) => vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get file extensions with registry support
|
/// Get file extensions with registry support
|
||||||
|
#[must_use]
|
||||||
pub fn extensions_with_registry(
|
pub fn extensions_with_registry(
|
||||||
&self,
|
&self,
|
||||||
registry: &MediaTypeRegistry,
|
registry: &MediaTypeRegistry,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
match self {
|
match self {
|
||||||
Self::Builtin(b) => {
|
Self::Builtin(b) => {
|
||||||
b.extensions().iter().map(|s| s.to_string()).collect()
|
b.extensions()
|
||||||
|
.iter()
|
||||||
|
.map(std::string::ToString::to_string)
|
||||||
|
.collect()
|
||||||
},
|
},
|
||||||
Self::Custom(id) => {
|
Self::Custom(id) => {
|
||||||
registry
|
registry
|
||||||
|
|
@ -141,7 +155,8 @@ impl MediaType {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if this is a RAW image format
|
/// Check if this is a RAW image format
|
||||||
pub fn is_raw(&self) -> bool {
|
#[must_use]
|
||||||
|
pub const fn is_raw(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
Self::Builtin(b) => b.is_raw(),
|
Self::Builtin(b) => b.is_raw(),
|
||||||
Self::Custom(_) => false,
|
Self::Custom(_) => false,
|
||||||
|
|
@ -149,13 +164,14 @@ impl MediaType {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a media type from file extension (built-in types only)
|
/// Resolve a media type from file extension (built-in types only)
|
||||||
/// Use from_extension_with_registry for custom types
|
/// Use `from_extension_with_registry` for custom types
|
||||||
pub fn from_extension(ext: &str) -> Option<Self> {
|
pub fn from_extension(ext: &str) -> Option<Self> {
|
||||||
BuiltinMediaType::from_extension(ext).map(Self::Builtin)
|
BuiltinMediaType::from_extension(ext).map(Self::Builtin)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a media type from file extension with registry (includes custom
|
/// Resolve a media type from file extension with registry (includes custom
|
||||||
/// types)
|
/// types)
|
||||||
|
#[must_use]
|
||||||
pub fn from_extension_with_registry(
|
pub fn from_extension_with_registry(
|
||||||
ext: &str,
|
ext: &str,
|
||||||
registry: &MediaTypeRegistry,
|
registry: &MediaTypeRegistry,
|
||||||
|
|
@ -172,7 +188,7 @@ impl MediaType {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a media type from file path (built-in types only)
|
/// Resolve a media type from file path (built-in types only)
|
||||||
/// Use from_path_with_registry for custom types
|
/// Use `from_path_with_registry` for custom types
|
||||||
pub fn from_path(path: &Path) -> Option<Self> {
|
pub fn from_path(path: &Path) -> Option<Self> {
|
||||||
path
|
path
|
||||||
.extension()
|
.extension()
|
||||||
|
|
@ -181,6 +197,7 @@ impl MediaType {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a media type from file path with registry (includes custom types)
|
/// Resolve a media type from file path with registry (includes custom types)
|
||||||
|
#[must_use]
|
||||||
pub fn from_path_with_registry(
|
pub fn from_path_with_registry(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
registry: &MediaTypeRegistry,
|
registry: &MediaTypeRegistry,
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ pub struct MediaTypeRegistry {
|
||||||
|
|
||||||
impl MediaTypeRegistry {
|
impl MediaTypeRegistry {
|
||||||
/// Create a new empty registry
|
/// Create a new empty registry
|
||||||
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
types: HashMap::new(),
|
types: HashMap::new(),
|
||||||
|
|
@ -78,7 +79,7 @@ impl MediaTypeRegistry {
|
||||||
let descriptor = self
|
let descriptor = self
|
||||||
.types
|
.types
|
||||||
.remove(id)
|
.remove(id)
|
||||||
.ok_or_else(|| anyhow!("Media type not found: {}", id))?;
|
.ok_or_else(|| anyhow!("Media type not found: {id}"))?;
|
||||||
|
|
||||||
// Remove extensions
|
// Remove extensions
|
||||||
for ext in &descriptor.extensions {
|
for ext in &descriptor.extensions {
|
||||||
|
|
@ -92,11 +93,13 @@ impl MediaTypeRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a media type descriptor by ID
|
/// Get a media type descriptor by ID
|
||||||
|
#[must_use]
|
||||||
pub fn get(&self, id: &str) -> Option<&MediaTypeDescriptor> {
|
pub fn get(&self, id: &str) -> Option<&MediaTypeDescriptor> {
|
||||||
self.types.get(id)
|
self.types.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a media type by file extension
|
/// Get a media type by file extension
|
||||||
|
#[must_use]
|
||||||
pub fn get_by_extension(&self, ext: &str) -> Option<&MediaTypeDescriptor> {
|
pub fn get_by_extension(&self, ext: &str) -> Option<&MediaTypeDescriptor> {
|
||||||
let ext_lower = ext.to_lowercase();
|
let ext_lower = ext.to_lowercase();
|
||||||
self
|
self
|
||||||
|
|
@ -106,11 +109,13 @@ impl MediaTypeRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all registered media types
|
/// List all registered media types
|
||||||
|
#[must_use]
|
||||||
pub fn list_all(&self) -> Vec<&MediaTypeDescriptor> {
|
pub fn list_all(&self) -> Vec<&MediaTypeDescriptor> {
|
||||||
self.types.values().collect()
|
self.types.values().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List media types from a specific plugin
|
/// List media types from a specific plugin
|
||||||
|
#[must_use]
|
||||||
pub fn list_by_plugin(&self, plugin_id: &str) -> Vec<&MediaTypeDescriptor> {
|
pub fn list_by_plugin(&self, plugin_id: &str) -> Vec<&MediaTypeDescriptor> {
|
||||||
self
|
self
|
||||||
.types
|
.types
|
||||||
|
|
@ -119,7 +124,8 @@ impl MediaTypeRegistry {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List built-in media types (plugin_id is None)
|
/// List built-in media types (`plugin_id` is None)
|
||||||
|
#[must_use]
|
||||||
pub fn list_builtin(&self) -> Vec<&MediaTypeDescriptor> {
|
pub fn list_builtin(&self) -> Vec<&MediaTypeDescriptor> {
|
||||||
self
|
self
|
||||||
.types
|
.types
|
||||||
|
|
@ -129,11 +135,13 @@ impl MediaTypeRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get count of registered types
|
/// Get count of registered types
|
||||||
|
#[must_use]
|
||||||
pub fn count(&self) -> usize {
|
pub fn count(&self) -> usize {
|
||||||
self.types.len()
|
self.types.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a media type is registered
|
/// Check if a media type is registered
|
||||||
|
#[must_use]
|
||||||
pub fn contains(&self, id: &str) -> bool {
|
pub fn contains(&self, id: &str) -> bool {
|
||||||
self.types.contains_key(id)
|
self.types.contains_key(id)
|
||||||
}
|
}
|
||||||
|
|
@ -170,7 +178,7 @@ mod tests {
|
||||||
fn create_test_descriptor(id: &str, ext: &str) -> MediaTypeDescriptor {
|
fn create_test_descriptor(id: &str, ext: &str) -> MediaTypeDescriptor {
|
||||||
MediaTypeDescriptor {
|
MediaTypeDescriptor {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
name: format!("{} Type", id),
|
name: format!("{id} Type"),
|
||||||
category: Some(MediaCategory::Document),
|
category: Some(MediaCategory::Document),
|
||||||
extensions: vec![ext.to_string()],
|
extensions: vec![ext.to_string()],
|
||||||
mime_types: vec![format!("application/{}", id)],
|
mime_types: vec![format!("application/{}", id)],
|
||||||
|
|
@ -183,7 +191,7 @@ mod tests {
|
||||||
let mut registry = MediaTypeRegistry::new();
|
let mut registry = MediaTypeRegistry::new();
|
||||||
let descriptor = create_test_descriptor("test", "tst");
|
let descriptor = create_test_descriptor("test", "tst");
|
||||||
|
|
||||||
registry.register(descriptor.clone()).unwrap();
|
registry.register(descriptor).unwrap();
|
||||||
|
|
||||||
let retrieved = registry.get("test").unwrap();
|
let retrieved = registry.get("test").unwrap();
|
||||||
assert_eq!(retrieved.id, "test");
|
assert_eq!(retrieved.id, "test");
|
||||||
|
|
@ -271,8 +279,8 @@ mod tests {
|
||||||
|
|
||||||
for i in 1..=3 {
|
for i in 1..=3 {
|
||||||
let desc = MediaTypeDescriptor {
|
let desc = MediaTypeDescriptor {
|
||||||
id: format!("type{}", i),
|
id: format!("type{i}"),
|
||||||
name: format!("Type {}", i),
|
name: format!("Type {i}"),
|
||||||
category: Some(MediaCategory::Document),
|
category: Some(MediaCategory::Document),
|
||||||
extensions: vec![format!("t{}", i)],
|
extensions: vec![format!("t{}", i)],
|
||||||
mime_types: vec![format!("application/type{}", i)],
|
mime_types: vec![format!("application/type{}", i)],
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ impl MetadataExtractor for AudioExtractor {
|
||||||
meta.artist = tag.artist().map(|s| s.to_string());
|
meta.artist = tag.artist().map(|s| s.to_string());
|
||||||
meta.album = tag.album().map(|s| s.to_string());
|
meta.album = tag.album().map(|s| s.to_string());
|
||||||
meta.genre = tag.genre().map(|s| s.to_string());
|
meta.genre = tag.genre().map(|s| s.to_string());
|
||||||
meta.year = tag.date().map(|ts| ts.year as i32);
|
meta.year = tag.date().map(|ts| i32::from(ts.year));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(tag) = tagged_file
|
if let Some(tag) = tagged_file
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,11 @@ impl MetadataExtractor for ImageExtractor {
|
||||||
let file = std::fs::File::open(path)?;
|
let file = std::fs::File::open(path)?;
|
||||||
let mut buf_reader = std::io::BufReader::new(&file);
|
let mut buf_reader = std::io::BufReader::new(&file);
|
||||||
|
|
||||||
let exif_data =
|
let Ok(exif_data) =
|
||||||
match exif::Reader::new().read_from_container(&mut buf_reader) {
|
exif::Reader::new().read_from_container(&mut buf_reader)
|
||||||
Ok(exif) => exif,
|
else {
|
||||||
Err(_) => return Ok(meta),
|
return Ok(meta);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Image dimensions
|
// Image dimensions
|
||||||
if let Some(width) = exif_data
|
if let Some(width) = exif_data
|
||||||
|
|
@ -226,7 +226,7 @@ impl MetadataExtractor for ImageExtractor {
|
||||||
fn field_to_u32(field: &exif::Field) -> Option<u32> {
|
fn field_to_u32(field: &exif::Field) -> Option<u32> {
|
||||||
match &field.value {
|
match &field.value {
|
||||||
exif::Value::Long(v) => v.first().copied(),
|
exif::Value::Long(v) => v.first().copied(),
|
||||||
exif::Value::Short(v) => v.first().map(|&x| x as u32),
|
exif::Value::Short(v) => v.first().map(|&x| u32::from(x)),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -274,9 +274,11 @@ fn parse_exif_datetime(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a perceptual hash for an image file.
|
/// Generate a perceptual hash for an image file.
|
||||||
|
///
|
||||||
/// Uses DCT (Discrete Cosine Transform) hash algorithm for robust similarity
|
/// Uses DCT (Discrete Cosine Transform) hash algorithm for robust similarity
|
||||||
/// detection. Returns a hex-encoded hash string, or None if the image cannot be
|
/// detection. Returns a hex-encoded hash string, or None if the image cannot be
|
||||||
/// processed.
|
/// processed.
|
||||||
|
#[must_use]
|
||||||
pub fn generate_perceptual_hash(path: &Path) -> Option<String> {
|
pub fn generate_perceptual_hash(path: &Path) -> Option<String> {
|
||||||
use image_hasher::{HashAlg, HasherConfig};
|
use image_hasher::{HashAlg, HasherConfig};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,13 +34,25 @@ pub struct ExtractedMetadata {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait MetadataExtractor: Send + Sync {
|
pub trait MetadataExtractor: Send + Sync {
|
||||||
|
/// Extract metadata from a file at the given path.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the file cannot be read or parsed.
|
||||||
fn extract(&self, path: &Path) -> Result<ExtractedMetadata>;
|
fn extract(&self, path: &Path) -> Result<ExtractedMetadata>;
|
||||||
fn supported_types(&self) -> Vec<MediaType>;
|
fn supported_types(&self) -> Vec<MediaType>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract metadata from a file using the appropriate extractor for the given
|
||||||
|
/// media type.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if no extractor supports the media type, or if extraction
|
||||||
|
/// fails.
|
||||||
pub fn extract_metadata(
|
pub fn extract_metadata(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
media_type: MediaType,
|
media_type: &MediaType,
|
||||||
) -> Result<ExtractedMetadata> {
|
) -> Result<ExtractedMetadata> {
|
||||||
let extractors: Vec<Box<dyn MetadataExtractor>> = vec![
|
let extractors: Vec<Box<dyn MetadataExtractor>> = vec![
|
||||||
Box::new(audio::AudioExtractor),
|
Box::new(audio::AudioExtractor),
|
||||||
|
|
@ -51,7 +63,7 @@ pub fn extract_metadata(
|
||||||
];
|
];
|
||||||
|
|
||||||
for extractor in &extractors {
|
for extractor in &extractors {
|
||||||
if extractor.supported_types().contains(&media_type) {
|
if extractor.supported_types().contains(media_type) {
|
||||||
return extractor.extract(path);
|
return extractor.extract(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ fn extract_mkv(path: &Path) -> Result<ExtractedMetadata> {
|
||||||
matroska::Settings::Audio(a) => {
|
matroska::Settings::Audio(a) => {
|
||||||
meta.extra.insert(
|
meta.extra.insert(
|
||||||
"sample_rate".to_string(),
|
"sample_rate".to_string(),
|
||||||
format!("{} Hz", a.sample_rate as u32),
|
format!("{:.0} Hz", a.sample_rate),
|
||||||
);
|
);
|
||||||
meta
|
meta
|
||||||
.extra
|
.extra
|
||||||
|
|
@ -64,7 +64,7 @@ fn extract_mkv(path: &Path) -> Result<ExtractedMetadata> {
|
||||||
.insert("audio_codec".to_string(), track.codec_id.clone());
|
.insert("audio_codec".to_string(), track.codec_id.clone());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
_ => {},
|
matroska::Settings::None => {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,7 +99,7 @@ fn extract_mp4(path: &Path) -> Result<ExtractedMetadata> {
|
||||||
meta.genre = tag
|
meta.genre = tag
|
||||||
.genre()
|
.genre()
|
||||||
.map(|s: std::borrow::Cow<'_, str>| s.to_string());
|
.map(|s: std::borrow::Cow<'_, str>| s.to_string());
|
||||||
meta.year = tag.date().map(|ts| ts.year as i32);
|
meta.year = tag.date().map(|ts| i32::from(ts.year));
|
||||||
}
|
}
|
||||||
|
|
||||||
let properties = tagged_file.properties();
|
let properties = tagged_file.properties();
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ impl Opener for WindowsOpener {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the platform-appropriate opener.
|
/// Returns the platform-appropriate opener.
|
||||||
|
#[must_use]
|
||||||
pub fn default_opener() -> Box<dyn Opener> {
|
pub fn default_opener() -> Box<dyn Opener> {
|
||||||
if cfg!(target_os = "macos") {
|
if cfg!(target_os = "macos") {
|
||||||
Box::new(MacOpener)
|
Box::new(MacOpener)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,14 @@ use crate::error::{PinakesError, Result};
|
||||||
/// The canonicalized path if valid, or a `PathNotAllowed` error if the path
|
/// The canonicalized path if valid, or a `PathNotAllowed` error if the path
|
||||||
/// is outside all allowed roots.
|
/// is outside all allowed roots.
|
||||||
///
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a `PathNotAllowed` error if:
|
||||||
|
/// - No allowed roots are configured
|
||||||
|
/// - The path does not exist
|
||||||
|
/// - The path cannot be canonicalized
|
||||||
|
/// - The path is outside all allowed roots
|
||||||
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
|
|
@ -106,6 +114,11 @@ pub fn validate_path(
|
||||||
///
|
///
|
||||||
/// This is a convenience wrapper for `validate_path` when you only have one
|
/// This is a convenience wrapper for `validate_path` when you only have one
|
||||||
/// root.
|
/// root.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a `PathNotAllowed` error if the path is outside the root directory
|
||||||
|
/// or cannot be canonicalized.
|
||||||
pub fn validate_path_single_root(path: &Path, root: &Path) -> Result<PathBuf> {
|
pub fn validate_path_single_root(path: &Path, root: &Path) -> Result<PathBuf> {
|
||||||
validate_path(path, &[root.to_path_buf()])
|
validate_path(path, &[root.to_path_buf()])
|
||||||
}
|
}
|
||||||
|
|
@ -125,6 +138,7 @@ pub fn validate_path_single_root(path: &Path, root: &Path) -> Result<PathBuf> {
|
||||||
///
|
///
|
||||||
/// `true` if the path appears safe (no obvious traversal sequences),
|
/// `true` if the path appears safe (no obvious traversal sequences),
|
||||||
/// `false` if it contains suspicious patterns.
|
/// `false` if it contains suspicious patterns.
|
||||||
|
#[must_use]
|
||||||
pub fn path_looks_safe(path: &str) -> bool {
|
pub fn path_looks_safe(path: &str) -> bool {
|
||||||
// Reject paths with obvious traversal patterns
|
// Reject paths with obvious traversal patterns
|
||||||
!path.contains("..")
|
!path.contains("..")
|
||||||
|
|
@ -148,6 +162,7 @@ pub fn path_looks_safe(path: &str) -> bool {
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// A sanitized filename safe for use on most filesystems.
|
/// A sanitized filename safe for use on most filesystems.
|
||||||
|
#[must_use]
|
||||||
pub fn sanitize_filename(filename: &str) -> String {
|
pub fn sanitize_filename(filename: &str) -> String {
|
||||||
let sanitized: String = filename
|
let sanitized: String = filename
|
||||||
.chars()
|
.chars()
|
||||||
|
|
@ -186,6 +201,14 @@ pub fn sanitize_filename(filename: &str) -> String {
|
||||||
///
|
///
|
||||||
/// The joined path if safe, or an error if the relative path would escape the
|
/// The joined path if safe, or an error if the relative path would escape the
|
||||||
/// base.
|
/// base.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a `PathNotAllowed` error if:
|
||||||
|
/// - The relative path is absolute
|
||||||
|
/// - The relative path contains `..`
|
||||||
|
/// - The base path cannot be canonicalized
|
||||||
|
/// - A path traversal is detected
|
||||||
pub fn safe_join(base: &Path, relative: &str) -> Result<PathBuf> {
|
pub fn safe_join(base: &Path, relative: &str) -> Result<PathBuf> {
|
||||||
// Reject absolute paths in the relative component
|
// Reject absolute paths in the relative component
|
||||||
if relative.starts_with('/') || relative.starts_with('\\') {
|
if relative.starts_with('/') || relative.starts_with('\\') {
|
||||||
|
|
@ -215,7 +238,7 @@ pub fn safe_join(base: &Path, relative: &str) -> Result<PathBuf> {
|
||||||
|
|
||||||
// The joined path might not exist yet, so we can't canonicalize it directly.
|
// The joined path might not exist yet, so we can't canonicalize it directly.
|
||||||
// Instead, we check each component
|
// Instead, we check each component
|
||||||
let mut current = canonical_base.clone();
|
let mut current = canonical_base;
|
||||||
for component in Path::new(relative).components() {
|
for component in Path::new(relative).components() {
|
||||||
use std::path::Component;
|
use std::path::Component;
|
||||||
match component {
|
match component {
|
||||||
|
|
@ -227,7 +250,7 @@ pub fn safe_join(base: &Path, relative: &str) -> Result<PathBuf> {
|
||||||
"path traversal detected".to_string(),
|
"path traversal detected".to_string(),
|
||||||
));
|
));
|
||||||
},
|
},
|
||||||
Component::CurDir => continue,
|
Component::CurDir => {},
|
||||||
_ => {
|
_ => {
|
||||||
return Err(PinakesError::PathNotAllowed(
|
return Err(PinakesError::PathNotAllowed(
|
||||||
"invalid path component".to_string(),
|
"invalid path component".to_string(),
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,17 @@ pub struct PluginLoader {
|
||||||
|
|
||||||
impl PluginLoader {
|
impl PluginLoader {
|
||||||
/// Create a new plugin loader
|
/// Create a new plugin loader
|
||||||
pub fn new(plugin_dirs: Vec<PathBuf>) -> Self {
|
#[must_use]
|
||||||
|
pub const fn new(plugin_dirs: Vec<PathBuf>) -> Self {
|
||||||
Self { plugin_dirs }
|
Self { plugin_dirs }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discover all plugins in configured directories
|
/// Discover all plugins in configured directories
|
||||||
pub async fn discover_plugins(&self) -> Result<Vec<PluginManifest>> {
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if a plugin directory cannot be searched.
|
||||||
|
pub fn discover_plugins(&self) -> Result<Vec<PluginManifest>> {
|
||||||
let mut manifests = Vec::new();
|
let mut manifests = Vec::new();
|
||||||
|
|
||||||
for dir in &self.plugin_dirs {
|
for dir in &self.plugin_dirs {
|
||||||
|
|
@ -31,25 +36,16 @@ impl PluginLoader {
|
||||||
|
|
||||||
info!("Discovering plugins in: {:?}", dir);
|
info!("Discovering plugins in: {:?}", dir);
|
||||||
|
|
||||||
match self.discover_in_directory(dir).await {
|
let found = Self::discover_in_directory(dir);
|
||||||
Ok(found) => {
|
info!("Found {} plugins in {:?}", found.len(), dir);
|
||||||
info!("Found {} plugins in {:?}", found.len(), dir);
|
manifests.extend(found);
|
||||||
manifests.extend(found);
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Error discovering plugins in {:?}: {}", dir, e);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(manifests)
|
Ok(manifests)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discover plugins in a specific directory
|
/// Discover plugins in a specific directory
|
||||||
async fn discover_in_directory(
|
fn discover_in_directory(dir: &Path) -> Vec<PluginManifest> {
|
||||||
&self,
|
|
||||||
dir: &Path,
|
|
||||||
) -> Result<Vec<PluginManifest>> {
|
|
||||||
let mut manifests = Vec::new();
|
let mut manifests = Vec::new();
|
||||||
|
|
||||||
// Walk the directory looking for plugin.toml files
|
// Walk the directory looking for plugin.toml files
|
||||||
|
|
@ -83,10 +79,15 @@ impl PluginLoader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(manifests)
|
manifests
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the WASM binary path from a manifest
|
/// Resolve the WASM binary path from a manifest
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the WASM binary is not found or its path escapes the
|
||||||
|
/// plugin directory.
|
||||||
pub fn resolve_wasm_path(
|
pub fn resolve_wasm_path(
|
||||||
&self,
|
&self,
|
||||||
manifest: &PluginManifest,
|
manifest: &PluginManifest,
|
||||||
|
|
@ -114,14 +115,14 @@ impl PluginLoader {
|
||||||
// traversal)
|
// traversal)
|
||||||
let canonical_wasm = wasm_path
|
let canonical_wasm = wasm_path
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.map_err(|e| anyhow!("Failed to canonicalize WASM path: {}", e))?;
|
.map_err(|e| anyhow!("Failed to canonicalize WASM path: {e}"))?;
|
||||||
let canonical_plugin_dir = plugin_dir
|
let canonical_plugin_dir = plugin_dir
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.map_err(|e| anyhow!("Failed to canonicalize plugin dir: {}", e))?;
|
.map_err(|e| anyhow!("Failed to canonicalize plugin dir: {e}"))?;
|
||||||
if !canonical_wasm.starts_with(&canonical_plugin_dir) {
|
if !canonical_wasm.starts_with(&canonical_plugin_dir) {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"WASM binary path escapes plugin directory: {:?}",
|
"WASM binary path escapes plugin directory: {}",
|
||||||
wasm_path
|
wasm_path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return Ok(canonical_wasm);
|
return Ok(canonical_wasm);
|
||||||
|
|
@ -135,12 +136,19 @@ impl PluginLoader {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download a plugin from a URL
|
/// Download a plugin from a URL
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the URL is not HTTPS, no plugin directories are
|
||||||
|
/// configured, the download fails, the archive is too large, or extraction
|
||||||
|
/// fails.
|
||||||
pub async fn download_plugin(&self, url: &str) -> Result<PathBuf> {
|
pub async fn download_plugin(&self, url: &str) -> Result<PathBuf> {
|
||||||
|
const MAX_PLUGIN_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
|
||||||
|
|
||||||
// Only allow HTTPS downloads
|
// Only allow HTTPS downloads
|
||||||
if !url.starts_with("https://") {
|
if !url.starts_with("https://") {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"Only HTTPS URLs are allowed for plugin downloads: {}",
|
"Only HTTPS URLs are allowed for plugin downloads: {url}"
|
||||||
url
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,15 +161,15 @@ impl PluginLoader {
|
||||||
|
|
||||||
// Download the archive with timeout and size limits
|
// Download the archive with timeout and size limits
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(300))
|
.timeout(std::time::Duration::from_mins(5))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?;
|
.map_err(|e| anyhow!("Failed to build HTTP client: {e}"))?;
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.get(url)
|
.get(url)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("Failed to download plugin: {}", e))?;
|
.map_err(|e| anyhow!("Failed to download plugin: {e}"))?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
|
|
@ -171,21 +179,19 @@ impl PluginLoader {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check content-length header before downloading
|
// Check content-length header before downloading
|
||||||
const MAX_PLUGIN_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
|
|
||||||
if let Some(content_length) = response.content_length()
|
if let Some(content_length) = response.content_length()
|
||||||
&& content_length > MAX_PLUGIN_SIZE
|
&& content_length > MAX_PLUGIN_SIZE
|
||||||
{
|
{
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"Plugin archive too large: {} bytes (max {} bytes)",
|
"Plugin archive too large: {content_length} bytes (max \
|
||||||
content_length,
|
{MAX_PLUGIN_SIZE} bytes)"
|
||||||
MAX_PLUGIN_SIZE
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let bytes = response
|
let bytes = response
|
||||||
.bytes()
|
.bytes()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("Failed to read plugin response: {}", e))?;
|
.map_err(|e| anyhow!("Failed to read plugin response: {e}"))?;
|
||||||
|
|
||||||
// Check actual size after download
|
// Check actual size after download
|
||||||
if bytes.len() as u64 > MAX_PLUGIN_SIZE {
|
if bytes.len() as u64 > MAX_PLUGIN_SIZE {
|
||||||
|
|
@ -204,7 +210,7 @@ impl PluginLoader {
|
||||||
// Extract using tar with -C to target directory
|
// Extract using tar with -C to target directory
|
||||||
let canonical_dest = dest_dir
|
let canonical_dest = dest_dir
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.map_err(|e| anyhow!("Failed to canonicalize dest dir: {}", e))?;
|
.map_err(|e| anyhow!("Failed to canonicalize dest dir: {e}"))?;
|
||||||
let output = std::process::Command::new("tar")
|
let output = std::process::Command::new("tar")
|
||||||
.args([
|
.args([
|
||||||
"xzf",
|
"xzf",
|
||||||
|
|
@ -213,7 +219,7 @@ impl PluginLoader {
|
||||||
&canonical_dest.to_string_lossy(),
|
&canonical_dest.to_string_lossy(),
|
||||||
])
|
])
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| anyhow!("Failed to extract plugin archive: {}", e))?;
|
.map_err(|e| anyhow!("Failed to extract plugin archive: {e}"))?;
|
||||||
|
|
||||||
// Clean up the archive
|
// Clean up the archive
|
||||||
let _ = std::fs::remove_file(&temp_archive);
|
let _ = std::fs::remove_file(&temp_archive);
|
||||||
|
|
@ -231,8 +237,8 @@ impl PluginLoader {
|
||||||
let entry_canonical = entry.path().canonicalize()?;
|
let entry_canonical = entry.path().canonicalize()?;
|
||||||
if !entry_canonical.starts_with(&canonical_dest) {
|
if !entry_canonical.starts_with(&canonical_dest) {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"Extracted file escapes destination directory: {:?}",
|
"Extracted file escapes destination directory: {}",
|
||||||
entry.path()
|
entry.path().display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -255,22 +261,26 @@ impl PluginLoader {
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"No plugin.toml found after extracting archive from: {}",
|
"No plugin.toml found after extracting archive from: {url}"
|
||||||
url
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate a plugin package
|
/// Validate a plugin package
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the path does not exist, is missing `plugin.toml`,
|
||||||
|
/// the WASM binary is not found, or the WASM file is invalid.
|
||||||
pub fn validate_plugin_package(&self, path: &Path) -> Result<()> {
|
pub fn validate_plugin_package(&self, path: &Path) -> Result<()> {
|
||||||
// Check that the path exists
|
// Check that the path exists
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Err(anyhow!("Plugin path does not exist: {:?}", path));
|
return Err(anyhow!("Plugin path does not exist: {}", path.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for plugin.toml
|
// Check for plugin.toml
|
||||||
let manifest_path = path.join("plugin.toml");
|
let manifest_path = path.join("plugin.toml");
|
||||||
if !manifest_path.exists() {
|
if !manifest_path.exists() {
|
||||||
return Err(anyhow!("Missing plugin.toml in {:?}", path));
|
return Err(anyhow!("Missing plugin.toml in {}", path.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate manifest
|
// Parse and validate manifest
|
||||||
|
|
@ -291,21 +301,22 @@ impl PluginLoader {
|
||||||
let canonical_path = path.canonicalize()?;
|
let canonical_path = path.canonicalize()?;
|
||||||
if !canonical_wasm.starts_with(&canonical_path) {
|
if !canonical_wasm.starts_with(&canonical_path) {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"WASM binary path escapes plugin directory: {:?}",
|
"WASM binary path escapes plugin directory: {}",
|
||||||
wasm_path
|
wasm_path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate WASM file
|
// Validate WASM file
|
||||||
let wasm_bytes = std::fs::read(&wasm_path)?;
|
let wasm_bytes = std::fs::read(&wasm_path)?;
|
||||||
if wasm_bytes.len() < 4 || &wasm_bytes[0..4] != b"\0asm" {
|
if wasm_bytes.len() < 4 || &wasm_bytes[0..4] != b"\0asm" {
|
||||||
return Err(anyhow!("Invalid WASM file: {:?}", wasm_path));
|
return Err(anyhow!("Invalid WASM file: {}", wasm_path.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get plugin directory path for a given plugin name
|
/// Get plugin directory path for a given plugin name
|
||||||
|
#[must_use]
|
||||||
pub fn get_plugin_dir(&self, plugin_name: &str) -> Option<PathBuf> {
|
pub fn get_plugin_dir(&self, plugin_name: &str) -> Option<PathBuf> {
|
||||||
for dir in &self.plugin_dirs {
|
for dir in &self.plugin_dirs {
|
||||||
let plugin_dir = dir.join(plugin_name);
|
let plugin_dir = dir.join(plugin_name);
|
||||||
|
|
@ -323,17 +334,17 @@ mod tests {
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn test_discover_plugins_empty() {
|
fn test_discover_plugins_empty() {
|
||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]);
|
let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]);
|
||||||
|
|
||||||
let manifests = loader.discover_plugins().await.unwrap();
|
let manifests = loader.discover_plugins().unwrap();
|
||||||
assert_eq!(manifests.len(), 0);
|
assert_eq!(manifests.len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn test_discover_plugins_with_manifest() {
|
fn test_discover_plugins_with_manifest() {
|
||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let plugin_dir = temp_dir.path().join("test-plugin");
|
let plugin_dir = temp_dir.path().join("test-plugin");
|
||||||
std::fs::create_dir(&plugin_dir).unwrap();
|
std::fs::create_dir(&plugin_dir).unwrap();
|
||||||
|
|
@ -356,7 +367,7 @@ wasm = "plugin.wasm"
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]);
|
let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]);
|
||||||
let manifests = loader.discover_plugins().await.unwrap();
|
let manifests = loader.discover_plugins().unwrap();
|
||||||
|
|
||||||
assert_eq!(manifests.len(), 1);
|
assert_eq!(manifests.len(), 1);
|
||||||
assert_eq!(manifests[0].plugin.name, "test-plugin");
|
assert_eq!(manifests[0].plugin.name, "test-plugin");
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,11 @@ impl From<crate::config::PluginsConfig> for PluginManagerConfig {
|
||||||
|
|
||||||
impl PluginManager {
|
impl PluginManager {
|
||||||
/// Create a new plugin manager
|
/// Create a new plugin manager
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the data or cache directories cannot be created, or
|
||||||
|
/// if the WASM runtime cannot be initialized.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
data_dir: PathBuf,
|
data_dir: PathBuf,
|
||||||
cache_dir: PathBuf,
|
cache_dir: PathBuf,
|
||||||
|
|
@ -123,10 +128,14 @@ impl PluginManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discover and load all plugins from configured directories
|
/// Discover and load all plugins from configured directories
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if plugin discovery fails.
|
||||||
pub async fn discover_and_load_all(&self) -> Result<Vec<String>> {
|
pub async fn discover_and_load_all(&self) -> Result<Vec<String>> {
|
||||||
info!("Discovering plugins from {:?}", self.config.plugin_dirs);
|
info!("Discovering plugins from {:?}", self.config.plugin_dirs);
|
||||||
|
|
||||||
let manifests = self.loader.discover_plugins().await?;
|
let manifests = self.loader.discover_plugins()?;
|
||||||
let mut loaded_plugins = Vec::new();
|
let mut loaded_plugins = Vec::new();
|
||||||
|
|
||||||
for manifest in manifests {
|
for manifest in manifests {
|
||||||
|
|
@ -145,6 +154,12 @@ impl PluginManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a plugin from a manifest file
|
/// Load a plugin from a manifest file
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the plugin ID is invalid, capability validation
|
||||||
|
/// fails, the WASM binary cannot be loaded, or the plugin cannot be
|
||||||
|
/// registered.
|
||||||
async fn load_plugin_from_manifest(
|
async fn load_plugin_from_manifest(
|
||||||
&self,
|
&self,
|
||||||
manifest: &pinakes_plugin_api::PluginManifest,
|
manifest: &pinakes_plugin_api::PluginManifest,
|
||||||
|
|
@ -156,7 +171,7 @@ impl PluginManager {
|
||||||
|| plugin_id.contains('\\')
|
|| plugin_id.contains('\\')
|
||||||
|| plugin_id.contains("..")
|
|| plugin_id.contains("..")
|
||||||
{
|
{
|
||||||
return Err(anyhow::anyhow!("Invalid plugin ID: {}", plugin_id));
|
return Err(anyhow::anyhow!("Invalid plugin ID: {plugin_id}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already loaded
|
// Check if already loaded
|
||||||
|
|
@ -202,7 +217,7 @@ impl PluginManager {
|
||||||
|
|
||||||
// Load WASM binary
|
// Load WASM binary
|
||||||
let wasm_path = self.loader.resolve_wasm_path(manifest)?;
|
let wasm_path = self.loader.resolve_wasm_path(manifest)?;
|
||||||
let wasm_plugin = self.runtime.load_plugin(&wasm_path, context).await?;
|
let wasm_plugin = self.runtime.load_plugin(&wasm_path, context)?;
|
||||||
|
|
||||||
// Initialize plugin
|
// Initialize plugin
|
||||||
let init_succeeded = match wasm_plugin
|
let init_succeeded = match wasm_plugin
|
||||||
|
|
@ -246,13 +261,20 @@ impl PluginManager {
|
||||||
enabled: init_succeeded,
|
enabled: init_succeeded,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut registry = self.registry.write().await;
|
{
|
||||||
registry.register(registered)?;
|
let mut registry = self.registry.write().await;
|
||||||
|
registry.register(registered)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(plugin_id)
|
Ok(plugin_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install a plugin from a file or URL
|
/// Install a plugin from a file or URL
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the plugin cannot be downloaded, the manifest cannot
|
||||||
|
/// be read, or the plugin cannot be loaded.
|
||||||
pub async fn install_plugin(&self, source: &str) -> Result<String> {
|
pub async fn install_plugin(&self, source: &str) -> Result<String> {
|
||||||
info!("Installing plugin from: {}", source);
|
info!("Installing plugin from: {}", source);
|
||||||
|
|
||||||
|
|
@ -276,13 +298,18 @@ impl PluginManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Uninstall a plugin
|
/// Uninstall a plugin
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the plugin ID is invalid, the plugin cannot be shut
|
||||||
|
/// down, cannot be unregistered, or its data directories cannot be removed.
|
||||||
pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> {
|
pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||||
// Validate plugin_id to prevent path traversal
|
// Validate plugin_id to prevent path traversal
|
||||||
if plugin_id.contains('/')
|
if plugin_id.contains('/')
|
||||||
|| plugin_id.contains('\\')
|
|| plugin_id.contains('\\')
|
||||||
|| plugin_id.contains("..")
|
|| plugin_id.contains("..")
|
||||||
{
|
{
|
||||||
return Err(anyhow::anyhow!("Invalid plugin ID: {}", plugin_id));
|
return Err(anyhow::anyhow!("Invalid plugin ID: {plugin_id}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Uninstalling plugin: {}", plugin_id);
|
info!("Uninstalling plugin: {}", plugin_id);
|
||||||
|
|
@ -291,8 +318,10 @@ impl PluginManager {
|
||||||
self.shutdown_plugin(plugin_id).await?;
|
self.shutdown_plugin(plugin_id).await?;
|
||||||
|
|
||||||
// Remove from registry
|
// Remove from registry
|
||||||
let mut registry = self.registry.write().await;
|
{
|
||||||
registry.unregister(plugin_id)?;
|
let mut registry = self.registry.write().await;
|
||||||
|
registry.unregister(plugin_id)?;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove plugin data and cache
|
// Remove plugin data and cache
|
||||||
let plugin_data_dir = self.data_dir.join(plugin_id);
|
let plugin_data_dir = self.data_dir.join(plugin_id);
|
||||||
|
|
@ -309,37 +338,55 @@ impl PluginManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable a plugin
|
/// Enable a plugin
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the plugin ID is not found in the registry.
|
||||||
pub async fn enable_plugin(&self, plugin_id: &str) -> Result<()> {
|
pub async fn enable_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||||
let mut registry = self.registry.write().await;
|
let mut registry = self.registry.write().await;
|
||||||
registry.enable(plugin_id)
|
registry.enable(plugin_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disable a plugin
|
/// Disable a plugin
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the plugin ID is not found in the registry.
|
||||||
pub async fn disable_plugin(&self, plugin_id: &str) -> Result<()> {
|
pub async fn disable_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||||
let mut registry = self.registry.write().await;
|
let mut registry = self.registry.write().await;
|
||||||
registry.disable(plugin_id)
|
registry.disable(plugin_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shutdown a specific plugin
|
/// Shutdown a specific plugin
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the plugin ID is not found in the registry.
|
||||||
pub async fn shutdown_plugin(&self, plugin_id: &str) -> Result<()> {
|
pub async fn shutdown_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||||
debug!("Shutting down plugin: {}", plugin_id);
|
debug!("Shutting down plugin: {}", plugin_id);
|
||||||
|
|
||||||
let registry = self.registry.read().await;
|
let registry = self.registry.read().await;
|
||||||
if let Some(plugin) = registry.get(plugin_id) {
|
if let Some(plugin) = registry.get(plugin_id) {
|
||||||
plugin.wasm_plugin.call_function("shutdown", &[]).await.ok();
|
let _ = plugin.wasm_plugin.call_function("shutdown", &[]).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow::anyhow!("Plugin not found: {}", plugin_id))
|
Err(anyhow::anyhow!("Plugin not found: {plugin_id}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shutdown all plugins
|
/// Shutdown all plugins
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// This function always returns `Ok(())`. Individual plugin shutdown errors
|
||||||
|
/// are logged but do not cause the overall operation to fail.
|
||||||
pub async fn shutdown_all(&self) -> Result<()> {
|
pub async fn shutdown_all(&self) -> Result<()> {
|
||||||
info!("Shutting down all plugins");
|
info!("Shutting down all plugins");
|
||||||
|
|
||||||
let registry = self.registry.read().await;
|
let plugin_ids: Vec<String> = {
|
||||||
let plugin_ids: Vec<String> =
|
let registry = self.registry.read().await;
|
||||||
registry.list_all().iter().map(|p| p.id.clone()).collect();
|
registry.list_all().iter().map(|p| p.id.clone()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
for plugin_id in plugin_ids {
|
for plugin_id in plugin_ids {
|
||||||
if let Err(e) = self.shutdown_plugin(&plugin_id).await {
|
if let Err(e) = self.shutdown_plugin(&plugin_id).await {
|
||||||
|
|
@ -373,6 +420,11 @@ impl PluginManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reload a plugin (for hot-reload during development)
|
/// Reload a plugin (for hot-reload during development)
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if hot-reload is disabled, the plugin is not found, it
|
||||||
|
/// cannot be shut down, or the reloaded plugin cannot be registered.
|
||||||
pub async fn reload_plugin(&self, plugin_id: &str) -> Result<()> {
|
pub async fn reload_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||||
if !self.config.enable_hot_reload {
|
if !self.config.enable_hot_reload {
|
||||||
return Err(anyhow::anyhow!("Hot-reload is disabled"));
|
return Err(anyhow::anyhow!("Hot-reload is disabled"));
|
||||||
|
|
@ -387,15 +439,21 @@ impl PluginManager {
|
||||||
let plugin = registry
|
let plugin = registry
|
||||||
.get(plugin_id)
|
.get(plugin_id)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Plugin not found"))?;
|
.ok_or_else(|| anyhow::anyhow!("Plugin not found"))?;
|
||||||
if let Some(ref manifest_path) = plugin.manifest_path {
|
let manifest = plugin.manifest_path.as_ref().map_or_else(
|
||||||
pinakes_plugin_api::PluginManifest::from_file(manifest_path)
|
|| plugin.manifest.clone(),
|
||||||
.unwrap_or_else(|e| {
|
|manifest_path| {
|
||||||
warn!("Failed to re-read manifest from disk, using cached: {}", e);
|
pinakes_plugin_api::PluginManifest::from_file(manifest_path)
|
||||||
plugin.manifest.clone()
|
.unwrap_or_else(|e| {
|
||||||
})
|
warn!(
|
||||||
} else {
|
"Failed to re-read manifest from disk, using cached: {}",
|
||||||
plugin.manifest.clone()
|
e
|
||||||
}
|
);
|
||||||
|
plugin.manifest.clone()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
drop(registry);
|
||||||
|
manifest
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shutdown and unload current version
|
// Shutdown and unload current version
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ pub struct PluginRegistry {
|
||||||
|
|
||||||
impl PluginRegistry {
|
impl PluginRegistry {
|
||||||
/// Create a new empty registry
|
/// Create a new empty registry
|
||||||
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
plugins: HashMap::new(),
|
plugins: HashMap::new(),
|
||||||
|
|
@ -33,6 +34,10 @@ impl PluginRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a new plugin
|
/// Register a new plugin
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if a plugin with the same ID is already registered.
|
||||||
pub fn register(&mut self, plugin: RegisteredPlugin) -> Result<()> {
|
pub fn register(&mut self, plugin: RegisteredPlugin) -> Result<()> {
|
||||||
if self.plugins.contains_key(&plugin.id) {
|
if self.plugins.contains_key(&plugin.id) {
|
||||||
return Err(anyhow!("Plugin already registered: {}", plugin.id));
|
return Err(anyhow!("Plugin already registered: {}", plugin.id));
|
||||||
|
|
@ -43,15 +48,20 @@ impl PluginRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unregister a plugin by ID
|
/// Unregister a plugin by ID
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the plugin ID is not found.
|
||||||
pub fn unregister(&mut self, plugin_id: &str) -> Result<()> {
|
pub fn unregister(&mut self, plugin_id: &str) -> Result<()> {
|
||||||
self
|
self
|
||||||
.plugins
|
.plugins
|
||||||
.remove(plugin_id)
|
.remove(plugin_id)
|
||||||
.ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?;
|
.ok_or_else(|| anyhow!("Plugin not found: {plugin_id}"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a plugin by ID
|
/// Get a plugin by ID
|
||||||
|
#[must_use]
|
||||||
pub fn get(&self, plugin_id: &str) -> Option<&RegisteredPlugin> {
|
pub fn get(&self, plugin_id: &str) -> Option<&RegisteredPlugin> {
|
||||||
self.plugins.get(plugin_id)
|
self.plugins.get(plugin_id)
|
||||||
}
|
}
|
||||||
|
|
@ -62,48 +72,61 @@ impl PluginRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a plugin is loaded
|
/// Check if a plugin is loaded
|
||||||
|
#[must_use]
|
||||||
pub fn is_loaded(&self, plugin_id: &str) -> bool {
|
pub fn is_loaded(&self, plugin_id: &str) -> bool {
|
||||||
self.plugins.contains_key(plugin_id)
|
self.plugins.contains_key(plugin_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a plugin is enabled. Returns `None` if the plugin is not found.
|
/// Check if a plugin is enabled. Returns `None` if the plugin is not found.
|
||||||
|
#[must_use]
|
||||||
pub fn is_enabled(&self, plugin_id: &str) -> Option<bool> {
|
pub fn is_enabled(&self, plugin_id: &str) -> Option<bool> {
|
||||||
self.plugins.get(plugin_id).map(|p| p.enabled)
|
self.plugins.get(plugin_id).map(|p| p.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable a plugin
|
/// Enable a plugin
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the plugin ID is not found.
|
||||||
pub fn enable(&mut self, plugin_id: &str) -> Result<()> {
|
pub fn enable(&mut self, plugin_id: &str) -> Result<()> {
|
||||||
let plugin = self
|
let plugin = self
|
||||||
.plugins
|
.plugins
|
||||||
.get_mut(plugin_id)
|
.get_mut(plugin_id)
|
||||||
.ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?;
|
.ok_or_else(|| anyhow!("Plugin not found: {plugin_id}"))?;
|
||||||
|
|
||||||
plugin.enabled = true;
|
plugin.enabled = true;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disable a plugin
|
/// Disable a plugin
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the plugin ID is not found.
|
||||||
pub fn disable(&mut self, plugin_id: &str) -> Result<()> {
|
pub fn disable(&mut self, plugin_id: &str) -> Result<()> {
|
||||||
let plugin = self
|
let plugin = self
|
||||||
.plugins
|
.plugins
|
||||||
.get_mut(plugin_id)
|
.get_mut(plugin_id)
|
||||||
.ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?;
|
.ok_or_else(|| anyhow!("Plugin not found: {plugin_id}"))?;
|
||||||
|
|
||||||
plugin.enabled = false;
|
plugin.enabled = false;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all registered plugins
|
/// List all registered plugins
|
||||||
|
#[must_use]
|
||||||
pub fn list_all(&self) -> Vec<&RegisteredPlugin> {
|
pub fn list_all(&self) -> Vec<&RegisteredPlugin> {
|
||||||
self.plugins.values().collect()
|
self.plugins.values().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all enabled plugins
|
/// List all enabled plugins
|
||||||
|
#[must_use]
|
||||||
pub fn list_enabled(&self) -> Vec<&RegisteredPlugin> {
|
pub fn list_enabled(&self) -> Vec<&RegisteredPlugin> {
|
||||||
self.plugins.values().filter(|p| p.enabled).collect()
|
self.plugins.values().filter(|p| p.enabled).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get plugins by kind (e.g., "media_type", "metadata_extractor")
|
/// Get plugins by kind (e.g., "`media_type`", "`metadata_extractor`")
|
||||||
|
#[must_use]
|
||||||
pub fn get_by_kind(&self, kind: &str) -> Vec<&RegisteredPlugin> {
|
pub fn get_by_kind(&self, kind: &str) -> Vec<&RegisteredPlugin> {
|
||||||
self
|
self
|
||||||
.plugins
|
.plugins
|
||||||
|
|
@ -113,11 +136,13 @@ impl PluginRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get count of registered plugins
|
/// Get count of registered plugins
|
||||||
|
#[must_use]
|
||||||
pub fn count(&self) -> usize {
|
pub fn count(&self) -> usize {
|
||||||
self.plugins.len()
|
self.plugins.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get count of enabled plugins
|
/// Get count of enabled plugins
|
||||||
|
#[must_use]
|
||||||
pub fn count_enabled(&self) -> usize {
|
pub fn count_enabled(&self) -> usize {
|
||||||
self.plugins.values().filter(|p| p.enabled).count()
|
self.plugins.values().filter(|p| p.enabled).count()
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +207,7 @@ mod tests {
|
||||||
let plugin =
|
let plugin =
|
||||||
create_test_plugin("test-plugin", vec!["media_type".to_string()]);
|
create_test_plugin("test-plugin", vec!["media_type".to_string()]);
|
||||||
|
|
||||||
registry.register(plugin.clone()).unwrap();
|
registry.register(plugin).unwrap();
|
||||||
|
|
||||||
assert!(registry.is_loaded("test-plugin"));
|
assert!(registry.is_loaded("test-plugin"));
|
||||||
assert!(registry.get("test-plugin").is_some());
|
assert!(registry.get("test-plugin").is_some());
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use std::{path::Path, sync::Arc};
|
||||||
|
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use pinakes_plugin_api::PluginContext;
|
use pinakes_plugin_api::PluginContext;
|
||||||
use wasmtime::*;
|
use wasmtime::{Caller, Config, Engine, Linker, Module, Store, Val, anyhow};
|
||||||
|
|
||||||
/// WASM runtime wrapper for executing plugins
|
/// WASM runtime wrapper for executing plugins
|
||||||
pub struct WasmRuntime {
|
pub struct WasmRuntime {
|
||||||
|
|
@ -13,6 +13,11 @@ pub struct WasmRuntime {
|
||||||
|
|
||||||
impl WasmRuntime {
|
impl WasmRuntime {
|
||||||
/// Create a new WASM runtime
|
/// Create a new WASM runtime
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the WASM engine cannot be created with the given
|
||||||
|
/// configuration.
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
let mut config = Config::new();
|
let mut config = Config::new();
|
||||||
config.wasm_component_model(true);
|
config.wasm_component_model(true);
|
||||||
|
|
@ -25,13 +30,18 @@ impl WasmRuntime {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a plugin from a WASM file
|
/// Load a plugin from a WASM file
|
||||||
pub async fn load_plugin(
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the WASM file does not exist, cannot be read, or
|
||||||
|
/// cannot be compiled.
|
||||||
|
pub fn load_plugin(
|
||||||
&self,
|
&self,
|
||||||
wasm_path: &Path,
|
wasm_path: &Path,
|
||||||
context: PluginContext,
|
context: PluginContext,
|
||||||
) -> Result<WasmPlugin> {
|
) -> Result<WasmPlugin> {
|
||||||
if !wasm_path.exists() {
|
if !wasm_path.exists() {
|
||||||
return Err(anyhow!("WASM file not found: {:?}", wasm_path));
|
return Err(anyhow!("WASM file not found: {}", wasm_path.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let wasm_bytes = std::fs::read(wasm_path)?;
|
let wasm_bytes = std::fs::read(wasm_path)?;
|
||||||
|
|
@ -59,7 +69,8 @@ pub struct WasmPlugin {
|
||||||
|
|
||||||
impl WasmPlugin {
|
impl WasmPlugin {
|
||||||
/// Get the plugin context
|
/// Get the plugin context
|
||||||
pub fn context(&self) -> &PluginContext {
|
#[must_use]
|
||||||
|
pub const fn context(&self) -> &PluginContext {
|
||||||
&self.context
|
&self.context
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,6 +78,11 @@ impl WasmPlugin {
|
||||||
///
|
///
|
||||||
/// Creates a fresh store and instance per invocation with host functions
|
/// Creates a fresh store and instance per invocation with host functions
|
||||||
/// linked, calls the requested exported function, and returns the result.
|
/// linked, calls the requested exported function, and returns the result.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the function cannot be found, instantiation fails,
|
||||||
|
/// or the function call returns an error.
|
||||||
pub async fn call_function(
|
pub async fn call_function(
|
||||||
&self,
|
&self,
|
||||||
function_name: &str,
|
function_name: &str,
|
||||||
|
|
@ -105,19 +121,23 @@ impl WasmPlugin {
|
||||||
let offset = if let Ok(alloc) =
|
let offset = if let Ok(alloc) =
|
||||||
instance.get_typed_func::<i32, i32>(&mut store, "alloc")
|
instance.get_typed_func::<i32, i32>(&mut store, "alloc")
|
||||||
{
|
{
|
||||||
let result = alloc.call_async(&mut store, params.len() as i32).await?;
|
let result = alloc
|
||||||
|
.call_async(
|
||||||
|
&mut store,
|
||||||
|
i32::try_from(params.len()).unwrap_or(i32::MAX),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
if result < 0 {
|
if result < 0 {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"plugin alloc returned negative offset: {}",
|
"plugin alloc returned negative offset: {result}"
|
||||||
result
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
result as usize
|
u32::try_from(result).unwrap_or(0) as usize
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
|
|
||||||
alloc_offset = offset as i32;
|
alloc_offset = i32::try_from(offset).unwrap_or(i32::MAX);
|
||||||
let mem_data = mem.data_mut(&mut store);
|
let mem_data = mem.data_mut(&mut store);
|
||||||
if offset + params.len() <= mem_data.len() {
|
if offset + params.len() <= mem_data.len() {
|
||||||
mem_data[offset..offset + params.len()].copy_from_slice(params);
|
mem_data[offset..offset + params.len()].copy_from_slice(params);
|
||||||
|
|
@ -128,7 +148,7 @@ impl WasmPlugin {
|
||||||
instance
|
instance
|
||||||
.get_func(&mut store, function_name)
|
.get_func(&mut store, function_name)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
anyhow!("exported function '{}' not found", function_name)
|
anyhow!("exported function '{function_name}' not found")
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let func_ty = func.ty(&store);
|
let func_ty = func.ty(&store);
|
||||||
|
|
@ -143,7 +163,10 @@ impl WasmPlugin {
|
||||||
func
|
func
|
||||||
.call_async(
|
.call_async(
|
||||||
&mut store,
|
&mut store,
|
||||||
&[Val::I32(alloc_offset), Val::I32(params.len() as i32)],
|
&[
|
||||||
|
Val::I32(alloc_offset),
|
||||||
|
Val::I32(i32::try_from(params.len()).unwrap_or(i32::MAX)),
|
||||||
|
],
|
||||||
&mut results,
|
&mut results,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -152,7 +175,7 @@ impl WasmPlugin {
|
||||||
} else {
|
} else {
|
||||||
// Generic: fill with zeroes
|
// Generic: fill with zeroes
|
||||||
let params_vals: Vec<Val> =
|
let params_vals: Vec<Val> =
|
||||||
(0..param_count).map(|_| Val::I32(0)).collect();
|
std::iter::repeat_n(Val::I32(0), param_count).collect();
|
||||||
func
|
func
|
||||||
.call_async(&mut store, ¶ms_vals, &mut results)
|
.call_async(&mut store, ¶ms_vals, &mut results)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -177,7 +200,7 @@ impl WasmPlugin {
|
||||||
impl Default for WasmPlugin {
|
impl Default for WasmPlugin {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let engine = Engine::default();
|
let engine = Engine::default();
|
||||||
let module = Module::new(&engine, br#"(module)"#).unwrap();
|
let module = Module::new(&engine, br"(module)").unwrap();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
module: Arc::new(module),
|
module: Arc::new(module),
|
||||||
|
|
@ -198,6 +221,10 @@ impl HostFunctions {
|
||||||
/// Registers all host ABI functions (`host_log`, `host_read_file`,
|
/// Registers all host ABI functions (`host_log`, `host_read_file`,
|
||||||
/// `host_write_file`, `host_http_request`, `host_get_config`,
|
/// `host_write_file`, `host_http_request`, `host_get_config`,
|
||||||
/// `host_get_buffer`) into the given linker.
|
/// `host_get_buffer`) into the given linker.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if any host function cannot be registered in the linker.
|
||||||
pub fn setup_linker(linker: &mut Linker<PluginStoreData>) -> Result<()> {
|
pub fn setup_linker(linker: &mut Linker<PluginStoreData>) -> Result<()> {
|
||||||
linker.func_wrap(
|
linker.func_wrap(
|
||||||
"env",
|
"env",
|
||||||
|
|
@ -209,11 +236,13 @@ impl HostFunctions {
|
||||||
if ptr < 0 || len < 0 {
|
if ptr < 0 || len < 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let memory = caller.get_export("memory").and_then(|e| e.into_memory());
|
let memory = caller
|
||||||
|
.get_export("memory")
|
||||||
|
.and_then(wasmtime::Extern::into_memory);
|
||||||
if let Some(mem) = memory {
|
if let Some(mem) = memory {
|
||||||
let data = mem.data(&caller);
|
let data = mem.data(&caller);
|
||||||
let start = ptr as usize;
|
let start = u32::try_from(ptr).unwrap_or(0) as usize;
|
||||||
let end = start + len as usize;
|
let end = start + u32::try_from(len).unwrap_or(0) as usize;
|
||||||
if end <= data.len()
|
if end <= data.len()
|
||||||
&& let Ok(msg) = std::str::from_utf8(&data[start..end])
|
&& let Ok(msg) = std::str::from_utf8(&data[start..end])
|
||||||
{
|
{
|
||||||
|
|
@ -238,12 +267,14 @@ impl HostFunctions {
|
||||||
if path_ptr < 0 || path_len < 0 {
|
if path_ptr < 0 || path_len < 0 {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
let memory = caller.get_export("memory").and_then(|e| e.into_memory());
|
let memory = caller
|
||||||
|
.get_export("memory")
|
||||||
|
.and_then(wasmtime::Extern::into_memory);
|
||||||
let Some(mem) = memory else { return -1 };
|
let Some(mem) = memory else { return -1 };
|
||||||
|
|
||||||
let data = mem.data(&caller);
|
let data = mem.data(&caller);
|
||||||
let start = path_ptr as usize;
|
let start = u32::try_from(path_ptr).unwrap_or(0) as usize;
|
||||||
let end = start + path_len as usize;
|
let end = start + u32::try_from(path_len).unwrap_or(0) as usize;
|
||||||
if end > data.len() {
|
if end > data.len() {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
@ -254,9 +285,8 @@ impl HostFunctions {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Canonicalize path before checking permissions to prevent traversal
|
// Canonicalize path before checking permissions to prevent traversal
|
||||||
let path = match std::path::Path::new(&path_str).canonicalize() {
|
let Ok(path) = std::path::Path::new(&path_str).canonicalize() else {
|
||||||
Ok(p) => p,
|
return -1;
|
||||||
Err(_) => return -1,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check read permission against canonicalized path
|
// Check read permission against canonicalized path
|
||||||
|
|
@ -276,14 +306,11 @@ impl HostFunctions {
|
||||||
return -2;
|
return -2;
|
||||||
}
|
}
|
||||||
|
|
||||||
match std::fs::read(&path) {
|
std::fs::read(&path).map_or(-1, |contents| {
|
||||||
Ok(contents) => {
|
let len = i32::try_from(contents.len()).unwrap_or(i32::MAX);
|
||||||
let len = contents.len() as i32;
|
caller.data_mut().exchange_buffer = contents;
|
||||||
caller.data_mut().exchange_buffer = contents;
|
len
|
||||||
len
|
})
|
||||||
},
|
|
||||||
Err(_) => -1,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
|
@ -299,14 +326,18 @@ impl HostFunctions {
|
||||||
if path_ptr < 0 || path_len < 0 || data_ptr < 0 || data_len < 0 {
|
if path_ptr < 0 || path_len < 0 || data_ptr < 0 || data_len < 0 {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
let memory = caller.get_export("memory").and_then(|e| e.into_memory());
|
let memory = caller
|
||||||
|
.get_export("memory")
|
||||||
|
.and_then(wasmtime::Extern::into_memory);
|
||||||
let Some(mem) = memory else { return -1 };
|
let Some(mem) = memory else { return -1 };
|
||||||
|
|
||||||
let mem_data = mem.data(&caller);
|
let mem_data = mem.data(&caller);
|
||||||
let path_start = path_ptr as usize;
|
let path_start = u32::try_from(path_ptr).unwrap_or(0) as usize;
|
||||||
let path_end = path_start + path_len as usize;
|
let path_end =
|
||||||
let data_start = data_ptr as usize;
|
path_start + u32::try_from(path_len).unwrap_or(0) as usize;
|
||||||
let data_end = data_start + data_len as usize;
|
let data_start = u32::try_from(data_ptr).unwrap_or(0) as usize;
|
||||||
|
let data_end =
|
||||||
|
data_start + u32::try_from(data_len).unwrap_or(0) as usize;
|
||||||
|
|
||||||
if path_end > mem_data.len() || data_end > mem_data.len() {
|
if path_end > mem_data.len() || data_end > mem_data.len() {
|
||||||
return -1;
|
return -1;
|
||||||
|
|
@ -369,12 +400,14 @@ impl HostFunctions {
|
||||||
if url_ptr < 0 || url_len < 0 {
|
if url_ptr < 0 || url_len < 0 {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
let memory = caller.get_export("memory").and_then(|e| e.into_memory());
|
let memory = caller
|
||||||
|
.get_export("memory")
|
||||||
|
.and_then(wasmtime::Extern::into_memory);
|
||||||
let Some(mem) = memory else { return -1 };
|
let Some(mem) = memory else { return -1 };
|
||||||
|
|
||||||
let data = mem.data(&caller);
|
let data = mem.data(&caller);
|
||||||
let start = url_ptr as usize;
|
let start = u32::try_from(url_ptr).unwrap_or(0) as usize;
|
||||||
let end = start + url_len as usize;
|
let end = start + u32::try_from(url_len).unwrap_or(0) as usize;
|
||||||
if end > data.len() {
|
if end > data.len() {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
@ -413,7 +446,7 @@ impl HostFunctions {
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(bytes)) => {
|
Ok(Ok(bytes)) => {
|
||||||
let len = bytes.len() as i32;
|
let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX);
|
||||||
caller.data_mut().exchange_buffer = bytes.to_vec();
|
caller.data_mut().exchange_buffer = bytes.to_vec();
|
||||||
len
|
len
|
||||||
},
|
},
|
||||||
|
|
@ -421,26 +454,19 @@ impl HostFunctions {
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// block_in_place panicked (e.g. current-thread runtime);
|
// block_in_place panicked (e.g. current-thread runtime);
|
||||||
// fall back to blocking client with timeout
|
// fall back to blocking client with timeout
|
||||||
let client = match reqwest::blocking::Client::builder()
|
let Ok(client) = reqwest::blocking::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
.build()
|
.build()
|
||||||
{
|
else {
|
||||||
Ok(c) => c,
|
return -1;
|
||||||
Err(_) => return -1,
|
|
||||||
};
|
};
|
||||||
match client.get(&url_str).send() {
|
client.get(&url_str).send().map_or(-1, |resp| {
|
||||||
Ok(resp) => {
|
resp.bytes().map_or(-1, |bytes| {
|
||||||
match resp.bytes() {
|
let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX);
|
||||||
Ok(bytes) => {
|
caller.data_mut().exchange_buffer = bytes.to_vec();
|
||||||
let len = bytes.len() as i32;
|
len
|
||||||
caller.data_mut().exchange_buffer = bytes.to_vec();
|
})
|
||||||
len
|
})
|
||||||
},
|
|
||||||
Err(_) => -1,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(_) => -1,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -456,12 +482,14 @@ impl HostFunctions {
|
||||||
if key_ptr < 0 || key_len < 0 {
|
if key_ptr < 0 || key_len < 0 {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
let memory = caller.get_export("memory").and_then(|e| e.into_memory());
|
let memory = caller
|
||||||
|
.get_export("memory")
|
||||||
|
.and_then(wasmtime::Extern::into_memory);
|
||||||
let Some(mem) = memory else { return -1 };
|
let Some(mem) = memory else { return -1 };
|
||||||
|
|
||||||
let data = mem.data(&caller);
|
let data = mem.data(&caller);
|
||||||
let start = key_ptr as usize;
|
let start = u32::try_from(key_ptr).unwrap_or(0) as usize;
|
||||||
let end = start + key_len as usize;
|
let end = start + u32::try_from(key_len).unwrap_or(0) as usize;
|
||||||
if end > data.len() {
|
if end > data.len() {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
@ -471,16 +499,17 @@ impl HostFunctions {
|
||||||
Err(_) => return -1,
|
Err(_) => return -1,
|
||||||
};
|
};
|
||||||
|
|
||||||
match caller.data().context.config.get(&key_str) {
|
let bytes = caller
|
||||||
Some(value) => {
|
.data()
|
||||||
let json = value.to_string();
|
.context
|
||||||
let bytes = json.into_bytes();
|
.config
|
||||||
let len = bytes.len() as i32;
|
.get(&key_str)
|
||||||
caller.data_mut().exchange_buffer = bytes;
|
.map(|value| value.to_string().into_bytes());
|
||||||
len
|
bytes.map_or(-1, |b| {
|
||||||
},
|
let len = i32::try_from(b.len()).unwrap_or(i32::MAX);
|
||||||
None => -1,
|
caller.data_mut().exchange_buffer = b;
|
||||||
}
|
len
|
||||||
|
})
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
|
@ -495,19 +524,22 @@ impl HostFunctions {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
let buf = caller.data().exchange_buffer.clone();
|
let buf = caller.data().exchange_buffer.clone();
|
||||||
let copy_len = buf.len().min(dest_len as usize);
|
let copy_len =
|
||||||
|
buf.len().min(u32::try_from(dest_len).unwrap_or(0) as usize);
|
||||||
|
|
||||||
let memory = caller.get_export("memory").and_then(|e| e.into_memory());
|
let memory = caller
|
||||||
|
.get_export("memory")
|
||||||
|
.and_then(wasmtime::Extern::into_memory);
|
||||||
let Some(mem) = memory else { return -1 };
|
let Some(mem) = memory else { return -1 };
|
||||||
|
|
||||||
let mem_data = mem.data_mut(&mut caller);
|
let mem_data = mem.data_mut(&mut caller);
|
||||||
let start = dest_ptr as usize;
|
let start = u32::try_from(dest_ptr).unwrap_or(0) as usize;
|
||||||
if start + copy_len > mem_data.len() {
|
if start + copy_len > mem_data.len() {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
mem_data[start..start + copy_len].copy_from_slice(&buf[..copy_len]);
|
mem_data[start..start + copy_len].copy_from_slice(&buf[..copy_len]);
|
||||||
copy_len as i32
|
i32::try_from(copy_len).unwrap_or(i32::MAX)
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ pub struct CapabilityEnforcer {
|
||||||
|
|
||||||
impl CapabilityEnforcer {
|
impl CapabilityEnforcer {
|
||||||
/// Create a new capability enforcer with default limits
|
/// Create a new capability enforcer with default limits
|
||||||
pub fn new() -> Self {
|
#[must_use]
|
||||||
|
pub const fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
max_memory_limit: 512 * 1024 * 1024, // 512 MB
|
max_memory_limit: 512 * 1024 * 1024, // 512 MB
|
||||||
max_cpu_time_limit: 60 * 1000, // 60 seconds
|
max_cpu_time_limit: 60 * 1000, // 60 seconds
|
||||||
|
|
@ -36,36 +37,47 @@ impl CapabilityEnforcer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set maximum memory limit
|
/// Set maximum memory limit
|
||||||
pub fn with_max_memory(mut self, bytes: usize) -> Self {
|
#[must_use]
|
||||||
|
pub const fn with_max_memory(mut self, bytes: usize) -> Self {
|
||||||
self.max_memory_limit = bytes;
|
self.max_memory_limit = bytes;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set maximum CPU time limit
|
/// Set maximum CPU time limit
|
||||||
pub fn with_max_cpu_time(mut self, milliseconds: u64) -> Self {
|
#[must_use]
|
||||||
|
pub const fn with_max_cpu_time(mut self, milliseconds: u64) -> Self {
|
||||||
self.max_cpu_time_limit = milliseconds;
|
self.max_cpu_time_limit = milliseconds;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add allowed read path
|
/// Add allowed read path
|
||||||
|
#[must_use]
|
||||||
pub fn allow_read_path(mut self, path: PathBuf) -> Self {
|
pub fn allow_read_path(mut self, path: PathBuf) -> Self {
|
||||||
self.allowed_read_paths.push(path);
|
self.allowed_read_paths.push(path);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add allowed write path
|
/// Add allowed write path
|
||||||
|
#[must_use]
|
||||||
pub fn allow_write_path(mut self, path: PathBuf) -> Self {
|
pub fn allow_write_path(mut self, path: PathBuf) -> Self {
|
||||||
self.allowed_write_paths.push(path);
|
self.allowed_write_paths.push(path);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set default network access policy
|
/// Set default network access policy
|
||||||
pub fn with_network_default(mut self, allow: bool) -> Self {
|
#[must_use]
|
||||||
|
pub const fn with_network_default(mut self, allow: bool) -> Self {
|
||||||
self.allow_network_default = allow;
|
self.allow_network_default = allow;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate capabilities requested by a plugin
|
/// Validate capabilities requested by a plugin
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the plugin requests capabilities that exceed the
|
||||||
|
/// configured system limits, such as memory, CPU time, filesystem paths, or
|
||||||
|
/// network access.
|
||||||
pub fn validate_capabilities(
|
pub fn validate_capabilities(
|
||||||
&self,
|
&self,
|
||||||
capabilities: &Capabilities,
|
capabilities: &Capabilities,
|
||||||
|
|
@ -115,8 +127,8 @@ impl CapabilityEnforcer {
|
||||||
for path in &capabilities.filesystem.read {
|
for path in &capabilities.filesystem.read {
|
||||||
if !self.is_read_allowed(path) {
|
if !self.is_read_allowed(path) {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"Plugin requests read access to {:?} which is not in allowed paths",
|
"Plugin requests read access to {} which is not in allowed paths",
|
||||||
path
|
path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -125,8 +137,8 @@ impl CapabilityEnforcer {
|
||||||
for path in &capabilities.filesystem.write {
|
for path in &capabilities.filesystem.write {
|
||||||
if !self.is_write_allowed(path) {
|
if !self.is_write_allowed(path) {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"Plugin requests write access to {:?} which is not in allowed paths",
|
"Plugin requests write access to {} which is not in allowed paths",
|
||||||
path
|
path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -135,6 +147,7 @@ impl CapabilityEnforcer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a path is allowed for reading
|
/// Check if a path is allowed for reading
|
||||||
|
#[must_use]
|
||||||
pub fn is_read_allowed(&self, path: &Path) -> bool {
|
pub fn is_read_allowed(&self, path: &Path) -> bool {
|
||||||
if self.allowed_read_paths.is_empty() {
|
if self.allowed_read_paths.is_empty() {
|
||||||
return false; // deny-all when unconfigured
|
return false; // deny-all when unconfigured
|
||||||
|
|
@ -150,6 +163,7 @@ impl CapabilityEnforcer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a path is allowed for writing
|
/// Check if a path is allowed for writing
|
||||||
|
#[must_use]
|
||||||
pub fn is_write_allowed(&self, path: &Path) -> bool {
|
pub fn is_write_allowed(&self, path: &Path) -> bool {
|
||||||
if self.allowed_write_paths.is_empty() {
|
if self.allowed_write_paths.is_empty() {
|
||||||
return false; // deny-all when unconfigured
|
return false; // deny-all when unconfigured
|
||||||
|
|
@ -173,11 +187,13 @@ impl CapabilityEnforcer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if network access is allowed for a plugin
|
/// Check if network access is allowed for a plugin
|
||||||
pub fn is_network_allowed(&self, capabilities: &Capabilities) -> bool {
|
#[must_use]
|
||||||
|
pub const fn is_network_allowed(&self, capabilities: &Capabilities) -> bool {
|
||||||
capabilities.network.enabled && self.allow_network_default
|
capabilities.network.enabled && self.allow_network_default
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a specific domain is allowed
|
/// Check if a specific domain is allowed
|
||||||
|
#[must_use]
|
||||||
pub fn is_domain_allowed(
|
pub fn is_domain_allowed(
|
||||||
&self,
|
&self,
|
||||||
capabilities: &Capabilities,
|
capabilities: &Capabilities,
|
||||||
|
|
@ -197,11 +213,13 @@ impl CapabilityEnforcer {
|
||||||
.network
|
.network
|
||||||
.allowed_domains
|
.allowed_domains
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|domains| domains.iter().any(|d| d.eq_ignore_ascii_case(domain)))
|
.is_some_and(|domains| {
|
||||||
.unwrap_or(false)
|
domains.iter().any(|d| d.eq_ignore_ascii_case(domain))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get effective memory limit for a plugin
|
/// Get effective memory limit for a plugin
|
||||||
|
#[must_use]
|
||||||
pub fn get_memory_limit(&self, capabilities: &Capabilities) -> usize {
|
pub fn get_memory_limit(&self, capabilities: &Capabilities) -> usize {
|
||||||
capabilities
|
capabilities
|
||||||
.max_memory_bytes
|
.max_memory_bytes
|
||||||
|
|
@ -210,6 +228,7 @@ impl CapabilityEnforcer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get effective CPU time limit for a plugin
|
/// Get effective CPU time limit for a plugin
|
||||||
|
#[must_use]
|
||||||
pub fn get_cpu_time_limit(&self, capabilities: &Capabilities) -> u64 {
|
pub fn get_cpu_time_limit(&self, capabilities: &Capabilities) -> u64 {
|
||||||
capabilities
|
capabilities
|
||||||
.max_cpu_time_ms
|
.max_cpu_time_ms
|
||||||
|
|
@ -264,8 +283,7 @@ mod tests {
|
||||||
let test_file = allowed_dir.join("test.txt");
|
let test_file = allowed_dir.join("test.txt");
|
||||||
std::fs::write(&test_file, "test").unwrap();
|
std::fs::write(&test_file, "test").unwrap();
|
||||||
|
|
||||||
let enforcer =
|
let enforcer = CapabilityEnforcer::new().allow_read_path(allowed_dir);
|
||||||
CapabilityEnforcer::new().allow_read_path(allowed_dir.clone());
|
|
||||||
|
|
||||||
assert!(enforcer.is_read_allowed(&test_file));
|
assert!(enforcer.is_read_allowed(&test_file));
|
||||||
assert!(!enforcer.is_read_allowed(Path::new("/etc/passwd")));
|
assert!(!enforcer.is_read_allowed(Path::new("/etc/passwd")));
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ pub struct ScanProgress {
|
||||||
const MAX_STORED_ERRORS: usize = 100;
|
const MAX_STORED_ERRORS: usize = 100;
|
||||||
|
|
||||||
impl ScanProgress {
|
impl ScanProgress {
|
||||||
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
is_scanning: Arc::new(AtomicBool::new(false)),
|
is_scanning: Arc::new(AtomicBool::new(false)),
|
||||||
|
|
@ -56,6 +57,7 @@ impl ScanProgress {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn snapshot(&self) -> ScanStatus {
|
pub fn snapshot(&self) -> ScanStatus {
|
||||||
let errors = self
|
let errors = self
|
||||||
.error_messages
|
.error_messages
|
||||||
|
|
@ -112,6 +114,10 @@ impl Default for ScanProgress {
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// Scan status with counts and any errors
|
/// Scan status with counts and any errors
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if the scan fails.
|
||||||
pub async fn scan_directory(
|
pub async fn scan_directory(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
|
|
@ -140,6 +146,10 @@ pub async fn scan_directory(
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// Scan status with counts and any errors
|
/// Scan status with counts and any errors
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if the scan fails.
|
||||||
pub async fn scan_directory_incremental(
|
pub async fn scan_directory_incremental(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
|
|
@ -165,6 +175,10 @@ pub async fn scan_directory_incremental(
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// Scan status with counts and any errors
|
/// Scan status with counts and any errors
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if the scan fails.
|
||||||
pub async fn scan_directory_with_progress(
|
pub async fn scan_directory_with_progress(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
|
|
@ -182,7 +196,11 @@ pub async fn scan_directory_with_progress(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan a directory with full options including progress tracking and
|
/// Scan a directory with full options including progress tracking and
|
||||||
/// incremental mode
|
/// incremental mode.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if the scan fails.
|
||||||
pub async fn scan_directory_with_options(
|
pub async fn scan_directory_with_options(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
|
|
@ -276,6 +294,10 @@ pub async fn scan_directory_with_options(
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// Status for each root directory
|
/// Status for each root directory
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if listing roots or scanning fails.
|
||||||
pub async fn scan_all_roots(
|
pub async fn scan_all_roots(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
ignore_patterns: &[String],
|
ignore_patterns: &[String],
|
||||||
|
|
@ -299,6 +321,10 @@ pub async fn scan_all_roots(
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// Status for each root directory
|
/// Status for each root directory
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if listing roots or scanning fails.
|
||||||
pub async fn scan_all_roots_incremental(
|
pub async fn scan_all_roots_incremental(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
ignore_patterns: &[String],
|
ignore_patterns: &[String],
|
||||||
|
|
@ -321,6 +347,10 @@ pub async fn scan_all_roots_incremental(
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// Status for each root directory
|
/// Status for each root directory
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if listing roots or scanning fails.
|
||||||
pub async fn scan_all_roots_with_progress(
|
pub async fn scan_all_roots_with_progress(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
ignore_patterns: &[String],
|
ignore_patterns: &[String],
|
||||||
|
|
@ -347,6 +377,10 @@ pub async fn scan_all_roots_with_progress(
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// Status for each root directory
|
/// Status for each root directory
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if listing roots or scanning fails.
|
||||||
pub async fn scan_all_roots_with_options(
|
pub async fn scan_all_roots_with_options(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
ignore_patterns: &[String],
|
ignore_patterns: &[String],
|
||||||
|
|
@ -391,6 +425,11 @@ pub struct FileWatcher {
|
||||||
|
|
||||||
impl FileWatcher {
|
impl FileWatcher {
|
||||||
/// Creates a new file watcher for the given directories.
|
/// Creates a new file watcher for the given directories.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if no filesystem watcher could be
|
||||||
|
/// created.
|
||||||
pub fn new(dirs: &[PathBuf]) -> Result<Self> {
|
pub fn new(dirs: &[PathBuf]) -> Result<Self> {
|
||||||
let (tx, rx) = mpsc::channel(1024);
|
let (tx, rx) = mpsc::channel(1024);
|
||||||
|
|
||||||
|
|
@ -419,7 +458,7 @@ impl FileWatcher {
|
||||||
dirs: &[PathBuf],
|
dirs: &[PathBuf],
|
||||||
tx: mpsc::Sender<PathBuf>,
|
tx: mpsc::Sender<PathBuf>,
|
||||||
) -> std::result::Result<Box<dyn Watcher + Send>, notify::Error> {
|
) -> std::result::Result<Box<dyn Watcher + Send>, notify::Error> {
|
||||||
let tx_clone = tx.clone();
|
let tx_clone = tx;
|
||||||
let mut watcher = notify::recommended_watcher(
|
let mut watcher = notify::recommended_watcher(
|
||||||
move |res: notify::Result<notify::Event>| {
|
move |res: notify::Result<notify::Event>| {
|
||||||
if let Ok(event) = res {
|
if let Ok(event) = res {
|
||||||
|
|
@ -444,7 +483,7 @@ impl FileWatcher {
|
||||||
dirs: &[PathBuf],
|
dirs: &[PathBuf],
|
||||||
tx: mpsc::Sender<PathBuf>,
|
tx: mpsc::Sender<PathBuf>,
|
||||||
) -> Result<Box<dyn Watcher + Send>> {
|
) -> Result<Box<dyn Watcher + Send>> {
|
||||||
let tx_clone = tx.clone();
|
let tx_clone = tx;
|
||||||
let poll_interval = std::time::Duration::from_secs(5);
|
let poll_interval = std::time::Duration::from_secs(5);
|
||||||
let config = notify::Config::default().with_poll_interval(poll_interval);
|
let config = notify::Config::default().with_poll_interval(poll_interval);
|
||||||
|
|
||||||
|
|
@ -479,6 +518,10 @@ impl FileWatcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Watches directories and imports files on change.
|
/// Watches directories and imports files on change.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if the watcher cannot be started.
|
||||||
pub async fn watch_and_import(
|
pub async fn watch_and_import(
|
||||||
storage: DynStorageBackend,
|
storage: DynStorageBackend,
|
||||||
dirs: Vec<PathBuf>,
|
dirs: Vec<PathBuf>,
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,14 @@ pub enum Schedule {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Schedule {
|
impl Schedule {
|
||||||
|
#[must_use]
|
||||||
pub fn next_run(&self, from: DateTime<Utc>) -> DateTime<Utc> {
|
pub fn next_run(&self, from: DateTime<Utc>) -> DateTime<Utc> {
|
||||||
match self {
|
match self {
|
||||||
Schedule::Interval { secs } => {
|
Self::Interval { secs } => {
|
||||||
from + chrono::Duration::seconds(*secs as i64)
|
from
|
||||||
|
+ chrono::Duration::seconds(i64::try_from(*secs).unwrap_or(i64::MAX))
|
||||||
},
|
},
|
||||||
Schedule::Daily { hour, minute } => {
|
Self::Daily { hour, minute } => {
|
||||||
let today = from
|
let today = from
|
||||||
.date_naive()
|
.date_naive()
|
||||||
.and_hms_opt(*hour, *minute, 0)
|
.and_hms_opt(*hour, *minute, 0)
|
||||||
|
|
@ -46,26 +48,26 @@ impl Schedule {
|
||||||
today_utc + chrono::Duration::days(1)
|
today_utc + chrono::Duration::days(1)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Schedule::Weekly { day, hour, minute } => {
|
Self::Weekly { day, hour, minute } => {
|
||||||
let current_day = from.weekday().num_days_from_monday();
|
let current_day = from.weekday().num_days_from_monday();
|
||||||
let target_day = *day;
|
let target_day = *day;
|
||||||
let days_ahead = if target_day > current_day {
|
let days_ahead = match target_day.cmp(¤t_day) {
|
||||||
target_day - current_day
|
std::cmp::Ordering::Greater => target_day - current_day,
|
||||||
} else if target_day < current_day {
|
std::cmp::Ordering::Less => 7 - (current_day - target_day),
|
||||||
7 - (current_day - target_day)
|
std::cmp::Ordering::Equal => {
|
||||||
} else {
|
let today = from
|
||||||
let today = from
|
.date_naive()
|
||||||
.date_naive()
|
.and_hms_opt(*hour, *minute, 0)
|
||||||
.and_hms_opt(*hour, *minute, 0)
|
.unwrap_or_default()
|
||||||
.unwrap_or_default()
|
.and_utc();
|
||||||
.and_utc();
|
if today > from {
|
||||||
if today > from {
|
return today;
|
||||||
return today;
|
}
|
||||||
}
|
7
|
||||||
7
|
},
|
||||||
};
|
};
|
||||||
let target_date =
|
let target_date =
|
||||||
from.date_naive() + chrono::Duration::days(days_ahead as i64);
|
from.date_naive() + chrono::Duration::days(i64::from(days_ahead));
|
||||||
target_date
|
target_date
|
||||||
.and_hms_opt(*hour, *minute, 0)
|
.and_hms_opt(*hour, *minute, 0)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
|
|
@ -74,21 +76,22 @@ impl Schedule {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn display_string(&self) -> String {
|
pub fn display_string(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
Schedule::Interval { secs } => {
|
Self::Interval { secs } => {
|
||||||
if *secs >= 3600 {
|
if *secs >= 3600 {
|
||||||
format!("Every {}h", secs / 3600)
|
format!("Every {}h", secs / 3600)
|
||||||
} else if *secs >= 60 {
|
} else if *secs >= 60 {
|
||||||
format!("Every {}m", secs / 60)
|
format!("Every {}m", secs / 60)
|
||||||
} else {
|
} else {
|
||||||
format!("Every {}s", secs)
|
format!("Every {secs}s")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Schedule::Daily { hour, minute } => {
|
Self::Daily { hour, minute } => {
|
||||||
format!("Daily {hour:02}:{minute:02}")
|
format!("Daily {hour:02}:{minute:02}")
|
||||||
},
|
},
|
||||||
Schedule::Weekly { day, hour, minute } => {
|
Self::Weekly { day, hour, minute } => {
|
||||||
let day_name = match day {
|
let day_name = match day {
|
||||||
0 => "Mon",
|
0 => "Mon",
|
||||||
1 => "Tue",
|
1 => "Tue",
|
||||||
|
|
@ -312,13 +315,17 @@ impl TaskScheduler {
|
||||||
|
|
||||||
/// Run a task immediately. Uses a single write lock to avoid TOCTOU races.
|
/// Run a task immediately. Uses a single write lock to avoid TOCTOU races.
|
||||||
pub async fn run_now(&self, id: &str) -> Option<String> {
|
pub async fn run_now(&self, id: &str) -> Option<String> {
|
||||||
let result = {
|
let kind = {
|
||||||
|
let tasks = self.tasks.read().await;
|
||||||
|
tasks.iter().find(|t| t.id == id)?.kind.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Submit the job (cheap: sends to mpsc channel)
|
||||||
|
let job_id = self.job_queue.submit(kind).await;
|
||||||
|
|
||||||
|
{
|
||||||
let mut tasks = self.tasks.write().await;
|
let mut tasks = self.tasks.write().await;
|
||||||
let task = tasks.iter_mut().find(|t| t.id == id)?;
|
let task = tasks.iter_mut().find(|t| t.id == id)?;
|
||||||
|
|
||||||
// Submit the job (cheap: sends to mpsc channel)
|
|
||||||
let job_id = self.job_queue.submit(task.kind.clone()).await;
|
|
||||||
|
|
||||||
task.last_run = Some(Utc::now());
|
task.last_run = Some(Utc::now());
|
||||||
task.last_status = Some("running".to_string());
|
task.last_status = Some("running".to_string());
|
||||||
task.running = true;
|
task.running = true;
|
||||||
|
|
@ -326,13 +333,11 @@ impl TaskScheduler {
|
||||||
if task.enabled {
|
if task.enabled {
|
||||||
task.next_run = Some(task.schedule.next_run(Utc::now()));
|
task.next_run = Some(task.schedule.next_run(Utc::now()));
|
||||||
}
|
}
|
||||||
|
drop(tasks);
|
||||||
Some(job_id.to_string())
|
|
||||||
};
|
|
||||||
if result.is_some() {
|
|
||||||
self.persist_task_state().await;
|
|
||||||
}
|
}
|
||||||
result
|
|
||||||
|
self.persist_task_state().await;
|
||||||
|
Some(job_id.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Main scheduler loop. Uses a two-phase approach per tick to avoid
|
/// Main scheduler loop. Uses a two-phase approach per tick to avoid
|
||||||
|
|
@ -344,7 +349,7 @@ impl TaskScheduler {
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = interval.tick() => {}
|
_ = interval.tick() => {}
|
||||||
_ = self.cancel.cancelled() => {
|
() = self.cancel.cancelled() => {
|
||||||
tracing::info!("scheduler shutting down");
|
tracing::info!("scheduler shutting down");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -514,7 +519,7 @@ mod tests {
|
||||||
let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap();
|
let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
assert_eq!(deserialized.id, "test");
|
assert_eq!(deserialized.id, "test");
|
||||||
assert_eq!(deserialized.enabled, true);
|
assert!(deserialized.enabled);
|
||||||
// running defaults to false on deserialization (skip_serializing)
|
// running defaults to false on deserialization (skip_serializing)
|
||||||
assert!(!deserialized.running);
|
assert!(!deserialized.running);
|
||||||
// last_job_id is skipped entirely
|
// last_job_id is skipped entirely
|
||||||
|
|
@ -603,7 +608,8 @@ mod tests {
|
||||||
async fn test_default_tasks_contain_trash_purge() {
|
async fn test_default_tasks_contain_trash_purge() {
|
||||||
let cancel = CancellationToken::new();
|
let cancel = CancellationToken::new();
|
||||||
let config = Arc::new(RwLock::new(Config::default()));
|
let config = Arc::new(RwLock::new(Config::default()));
|
||||||
let job_queue = JobQueue::new(1, |_, _, _, _| tokio::spawn(async move {}));
|
let job_queue =
|
||||||
|
JobQueue::new(1, 0, |_, _, _, _| tokio::spawn(async move {}));
|
||||||
|
|
||||||
let scheduler = TaskScheduler::new(job_queue, cancel, config, None);
|
let scheduler = TaskScheduler::new(job_queue, cancel, config, None);
|
||||||
let tasks = scheduler.list_tasks().await;
|
let tasks = scheduler.list_tasks().await;
|
||||||
|
|
@ -644,7 +650,7 @@ mod tests {
|
||||||
let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap();
|
let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
assert_eq!(deserialized.id, "trash_purge");
|
assert_eq!(deserialized.id, "trash_purge");
|
||||||
assert_eq!(deserialized.enabled, true);
|
assert!(deserialized.enabled);
|
||||||
assert!(!deserialized.running);
|
assert!(!deserialized.running);
|
||||||
assert!(deserialized.last_job_id.is_none());
|
assert!(deserialized.last_job_id.is_none());
|
||||||
}
|
}
|
||||||
|
|
@ -699,7 +705,7 @@ mod tests {
|
||||||
let json = serde_json::to_string(&task).unwrap();
|
let json = serde_json::to_string(&task).unwrap();
|
||||||
let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap();
|
let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
assert_eq!(deserialized.running, false);
|
assert!(!deserialized.running);
|
||||||
assert!(deserialized.last_job_id.is_none());
|
assert!(deserialized.last_job_id.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@ pub enum SearchQuery {
|
||||||
field: String,
|
field: String,
|
||||||
value: String,
|
value: String,
|
||||||
},
|
},
|
||||||
And(Vec<SearchQuery>),
|
And(Vec<Self>),
|
||||||
Or(Vec<SearchQuery>),
|
Or(Vec<Self>),
|
||||||
Not(Box<SearchQuery>),
|
Not(Box<Self>),
|
||||||
Prefix(String),
|
Prefix(String),
|
||||||
Fuzzy(String),
|
Fuzzy(String),
|
||||||
TypeFilter(String),
|
TypeFilter(String),
|
||||||
|
|
@ -149,18 +149,13 @@ fn parse_date_value(s: &str) -> Option<DateValue> {
|
||||||
/// Returns `None` if the input is invalid or if the value would overflow.
|
/// Returns `None` if the input is invalid or if the value would overflow.
|
||||||
fn parse_size_value(s: &str) -> Option<i64> {
|
fn parse_size_value(s: &str) -> Option<i64> {
|
||||||
let s = s.to_uppercase();
|
let s = s.to_uppercase();
|
||||||
let (num_str, multiplier): (&str, i64) = if let Some(n) = s.strip_suffix("GB")
|
let (num_str, multiplier): (&str, i64) = s
|
||||||
{
|
.strip_suffix("GB")
|
||||||
(n, 1024 * 1024 * 1024)
|
.map(|n| (n, 1024 * 1024 * 1024_i64))
|
||||||
} else if let Some(n) = s.strip_suffix("MB") {
|
.or_else(|| s.strip_suffix("MB").map(|n| (n, 1024 * 1024)))
|
||||||
(n, 1024 * 1024)
|
.or_else(|| s.strip_suffix("KB").map(|n| (n, 1024)))
|
||||||
} else if let Some(n) = s.strip_suffix("KB") {
|
.or_else(|| s.strip_suffix('B').map(|n| (n, 1)))
|
||||||
(n, 1024)
|
.unwrap_or((s.as_str(), 1));
|
||||||
} else if let Some(n) = s.strip_suffix('B') {
|
|
||||||
(n, 1)
|
|
||||||
} else {
|
|
||||||
(s.as_str(), 1)
|
|
||||||
};
|
|
||||||
|
|
||||||
let num: i64 = num_str.parse().ok()?;
|
let num: i64 = num_str.parse().ok()?;
|
||||||
num.checked_mul(multiplier)
|
num.checked_mul(multiplier)
|
||||||
|
|
|
||||||
|
|
@ -21,22 +21,32 @@ pub struct ChunkedUploadManager {
|
||||||
|
|
||||||
impl ChunkedUploadManager {
|
impl ChunkedUploadManager {
|
||||||
/// Create a new chunked upload manager.
|
/// Create a new chunked upload manager.
|
||||||
pub fn new(temp_dir: PathBuf) -> Self {
|
#[must_use]
|
||||||
|
pub const fn new(temp_dir: PathBuf) -> Self {
|
||||||
Self { temp_dir }
|
Self { temp_dir }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize the temp directory.
|
/// Initialize the temp directory.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the directory cannot be created.
|
||||||
pub async fn init(&self) -> Result<()> {
|
pub async fn init(&self) -> Result<()> {
|
||||||
fs::create_dir_all(&self.temp_dir).await?;
|
fs::create_dir_all(&self.temp_dir).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the temp file path for an upload session.
|
/// Get the temp file path for an upload session.
|
||||||
|
#[must_use]
|
||||||
pub fn temp_path(&self, session_id: Uuid) -> PathBuf {
|
pub fn temp_path(&self, session_id: Uuid) -> PathBuf {
|
||||||
self.temp_dir.join(format!("{}.upload", session_id))
|
self.temp_dir.join(format!("{session_id}.upload"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create the temp file for a new upload session.
|
/// Create the temp file for a new upload session.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the file cannot be created or sized.
|
||||||
pub async fn create_temp_file(&self, session: &UploadSession) -> Result<()> {
|
pub async fn create_temp_file(&self, session: &UploadSession) -> Result<()> {
|
||||||
let path = self.temp_path(session.id);
|
let path = self.temp_path(session.id);
|
||||||
|
|
||||||
|
|
@ -54,6 +64,11 @@ impl ChunkedUploadManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a chunk to the temp file.
|
/// Write a chunk to the temp file.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the session file is not found, the chunk index is out
|
||||||
|
/// of range, the chunk size is wrong, or the write fails.
|
||||||
pub async fn write_chunk(
|
pub async fn write_chunk(
|
||||||
&self,
|
&self,
|
||||||
session: &UploadSession,
|
session: &UploadSession,
|
||||||
|
|
@ -128,6 +143,11 @@ impl ChunkedUploadManager {
|
||||||
/// 1. All chunks are received
|
/// 1. All chunks are received
|
||||||
/// 2. File size matches expected
|
/// 2. File size matches expected
|
||||||
/// 3. Content hash matches expected
|
/// 3. Content hash matches expected
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if chunks are missing, the file size does not match, the
|
||||||
|
/// hash does not match, or the file metadata cannot be read.
|
||||||
pub async fn finalize(
|
pub async fn finalize(
|
||||||
&self,
|
&self,
|
||||||
session: &UploadSession,
|
session: &UploadSession,
|
||||||
|
|
@ -147,12 +167,11 @@ impl ChunkedUploadManager {
|
||||||
// Verify chunk indices
|
// Verify chunk indices
|
||||||
let mut indices: Vec<u64> =
|
let mut indices: Vec<u64> =
|
||||||
received_chunks.iter().map(|c| c.chunk_index).collect();
|
received_chunks.iter().map(|c| c.chunk_index).collect();
|
||||||
indices.sort();
|
indices.sort_unstable();
|
||||||
for (i, idx) in indices.iter().enumerate() {
|
for (i, idx) in indices.iter().enumerate() {
|
||||||
if *idx != i as u64 {
|
if *idx != i as u64 {
|
||||||
return Err(PinakesError::InvalidData(format!(
|
return Err(PinakesError::InvalidData(format!(
|
||||||
"chunk {} missing or out of order",
|
"chunk {i} missing or out of order"
|
||||||
i
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -187,6 +206,10 @@ impl ChunkedUploadManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel an upload and clean up temp file.
|
/// Cancel an upload and clean up temp file.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the temp file cannot be removed.
|
||||||
pub async fn cancel(&self, session_id: Uuid) -> Result<()> {
|
pub async fn cancel(&self, session_id: Uuid) -> Result<()> {
|
||||||
let path = self.temp_path(session_id);
|
let path = self.temp_path(session_id);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
|
|
@ -197,6 +220,10 @@ impl ChunkedUploadManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clean up expired temp files.
|
/// Clean up expired temp files.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the temp directory cannot be read.
|
||||||
pub async fn cleanup_expired(&self, max_age_hours: u64) -> Result<u64> {
|
pub async fn cleanup_expired(&self, max_age_hours: u64) -> Result<u64> {
|
||||||
let mut count = 0u64;
|
let mut count = 0u64;
|
||||||
let max_age = std::time::Duration::from_secs(max_age_hours * 3600);
|
let max_age = std::time::Duration::from_secs(max_age_hours * 3600);
|
||||||
|
|
@ -204,7 +231,7 @@ impl ChunkedUploadManager {
|
||||||
let mut entries = fs::read_dir(&self.temp_dir).await?;
|
let mut entries = fs::read_dir(&self.temp_dir).await?;
|
||||||
while let Some(entry) = entries.next_entry().await? {
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.extension().map(|e| e == "upload").unwrap_or(false)
|
if path.extension().is_some_and(|e| e == "upload")
|
||||||
&& let Ok(metadata) = fs::metadata(&path).await
|
&& let Ok(metadata) = fs::metadata(&path).await
|
||||||
&& let Ok(modified) = metadata.modified()
|
&& let Ok(modified) = metadata.modified()
|
||||||
{
|
{
|
||||||
|
|
@ -267,7 +294,7 @@ mod tests {
|
||||||
expected_hash: ContentHash::new(hash.clone()),
|
expected_hash: ContentHash::new(hash.clone()),
|
||||||
expected_size: data.len() as u64,
|
expected_size: data.len() as u64,
|
||||||
chunk_size,
|
chunk_size,
|
||||||
chunk_count: (data.len() as u64 + chunk_size - 1) / chunk_size,
|
chunk_count: (data.len() as u64).div_ceil(chunk_size),
|
||||||
status: UploadStatus::InProgress,
|
status: UploadStatus::InProgress,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
expires_at: Utc::now() + chrono::Duration::hours(24),
|
expires_at: Utc::now() + chrono::Duration::hours(24),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use super::DeviceSyncState;
|
||||||
use crate::config::ConflictResolution;
|
use crate::config::ConflictResolution;
|
||||||
|
|
||||||
/// Detect if there's a conflict between local and server state.
|
/// Detect if there's a conflict between local and server state.
|
||||||
|
#[must_use]
|
||||||
pub fn detect_conflict(state: &DeviceSyncState) -> Option<ConflictInfo> {
|
pub fn detect_conflict(state: &DeviceSyncState) -> Option<ConflictInfo> {
|
||||||
// If either side has no hash, no conflict possible
|
// If either side has no hash, no conflict possible
|
||||||
let local_hash = state.local_hash.as_ref()?;
|
let local_hash = state.local_hash.as_ref()?;
|
||||||
|
|
@ -48,6 +49,7 @@ pub enum ConflictOutcome {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a conflict based on the configured strategy.
|
/// Resolve a conflict based on the configured strategy.
|
||||||
|
#[must_use]
|
||||||
pub fn resolve_conflict(
|
pub fn resolve_conflict(
|
||||||
conflict: &ConflictInfo,
|
conflict: &ConflictInfo,
|
||||||
resolution: ConflictResolution,
|
resolution: ConflictResolution,
|
||||||
|
|
@ -67,20 +69,21 @@ pub fn resolve_conflict(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a new path for the conflicting local file.
|
/// Generate a new path for the conflicting local file.
|
||||||
/// Format: filename.conflict-<short_hash>.ext
|
/// Format: filename.conflict-<`short_hash>.ext`
|
||||||
fn generate_conflict_path(original_path: &str, local_hash: &str) -> String {
|
fn generate_conflict_path(original_path: &str, local_hash: &str) -> String {
|
||||||
let short_hash = &local_hash[..8.min(local_hash.len())];
|
let short_hash = &local_hash[..8.min(local_hash.len())];
|
||||||
|
|
||||||
if let Some((base, ext)) = original_path.rsplit_once('.') {
|
if let Some((base, ext)) = original_path.rsplit_once('.') {
|
||||||
format!("{}.conflict-{}.{}", base, short_hash, ext)
|
format!("{base}.conflict-{short_hash}.{ext}")
|
||||||
} else {
|
} else {
|
||||||
format!("{}.conflict-{}", original_path, short_hash)
|
format!("{original_path}.conflict-{short_hash}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Automatic conflict resolution based on modification times.
|
/// Automatic conflict resolution based on modification times.
|
||||||
/// Useful when ConflictResolution is set to a time-based strategy.
|
/// Useful when `ConflictResolution` is set to a time-based strategy.
|
||||||
pub fn resolve_by_mtime(conflict: &ConflictInfo) -> ConflictOutcome {
|
#[must_use]
|
||||||
|
pub const fn resolve_by_mtime(conflict: &ConflictInfo) -> ConflictOutcome {
|
||||||
match (conflict.local_mtime, conflict.server_mtime) {
|
match (conflict.local_mtime, conflict.server_mtime) {
|
||||||
(Some(local), Some(server)) => {
|
(Some(local), Some(server)) => {
|
||||||
if local > server {
|
if local > server {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ use crate::{
|
||||||
pub struct DeviceId(pub Uuid);
|
pub struct DeviceId(pub Uuid);
|
||||||
|
|
||||||
impl DeviceId {
|
impl DeviceId {
|
||||||
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self(Uuid::now_v7())
|
Self(Uuid::now_v7())
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +71,7 @@ impl std::str::FromStr for DeviceType {
|
||||||
"tablet" => Ok(Self::Tablet),
|
"tablet" => Ok(Self::Tablet),
|
||||||
"server" => Ok(Self::Server),
|
"server" => Ok(Self::Server),
|
||||||
"other" => Ok(Self::Other),
|
"other" => Ok(Self::Other),
|
||||||
_ => Err(format!("unknown device type: {}", s)),
|
_ => Err(format!("unknown device type: {s}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +94,7 @@ pub struct SyncDevice {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SyncDevice {
|
impl SyncDevice {
|
||||||
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
name: String,
|
name: String,
|
||||||
|
|
@ -150,7 +152,7 @@ impl std::str::FromStr for SyncChangeType {
|
||||||
"deleted" => Ok(Self::Deleted),
|
"deleted" => Ok(Self::Deleted),
|
||||||
"moved" => Ok(Self::Moved),
|
"moved" => Ok(Self::Moved),
|
||||||
"metadata_updated" => Ok(Self::MetadataUpdated),
|
"metadata_updated" => Ok(Self::MetadataUpdated),
|
||||||
_ => Err(format!("unknown sync change type: {}", s)),
|
_ => Err(format!("unknown sync change type: {s}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -171,6 +173,7 @@ pub struct SyncLogEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SyncLogEntry {
|
impl SyncLogEntry {
|
||||||
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
change_type: SyncChangeType,
|
change_type: SyncChangeType,
|
||||||
path: String,
|
path: String,
|
||||||
|
|
@ -225,7 +228,7 @@ impl std::str::FromStr for FileSyncStatus {
|
||||||
"pending_download" => Ok(Self::PendingDownload),
|
"pending_download" => Ok(Self::PendingDownload),
|
||||||
"conflict" => Ok(Self::Conflict),
|
"conflict" => Ok(Self::Conflict),
|
||||||
"deleted" => Ok(Self::Deleted),
|
"deleted" => Ok(Self::Deleted),
|
||||||
_ => Err(format!("unknown file sync status: {}", s)),
|
_ => Err(format!("unknown file sync status: {s}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -260,6 +263,7 @@ pub struct SyncConflict {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SyncConflict {
|
impl SyncConflict {
|
||||||
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
device_id: DeviceId,
|
device_id: DeviceId,
|
||||||
path: String,
|
path: String,
|
||||||
|
|
@ -319,7 +323,7 @@ impl std::str::FromStr for UploadStatus {
|
||||||
"failed" => Ok(Self::Failed),
|
"failed" => Ok(Self::Failed),
|
||||||
"expired" => Ok(Self::Expired),
|
"expired" => Ok(Self::Expired),
|
||||||
"cancelled" => Ok(Self::Cancelled),
|
"cancelled" => Ok(Self::Cancelled),
|
||||||
_ => Err(format!("unknown upload status: {}", s)),
|
_ => Err(format!("unknown upload status: {s}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -341,6 +345,7 @@ pub struct UploadSession {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UploadSession {
|
impl UploadSession {
|
||||||
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
device_id: DeviceId,
|
device_id: DeviceId,
|
||||||
target_path: String,
|
target_path: String,
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,10 @@ pub struct AckRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get changes since a cursor position.
|
/// Get changes since a cursor position.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the storage query fails.
|
||||||
pub async fn get_changes(
|
pub async fn get_changes(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
cursor: i64,
|
cursor: i64,
|
||||||
|
|
@ -101,7 +105,7 @@ pub async fn get_changes(
|
||||||
let has_more = changes.len() > limit as usize;
|
let has_more = changes.len() > limit as usize;
|
||||||
let changes: Vec<_> = changes.into_iter().take(limit as usize).collect();
|
let changes: Vec<_> = changes.into_iter().take(limit as usize).collect();
|
||||||
|
|
||||||
let new_cursor = changes.last().map(|c| c.sequence).unwrap_or(cursor);
|
let new_cursor = changes.last().map_or(cursor, |c| c.sequence);
|
||||||
|
|
||||||
Ok(ChangesResponse {
|
Ok(ChangesResponse {
|
||||||
changes,
|
changes,
|
||||||
|
|
@ -111,6 +115,10 @@ pub async fn get_changes(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a change in the sync log.
|
/// Record a change in the sync log.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the storage record operation fails.
|
||||||
pub async fn record_change(
|
pub async fn record_change(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
change_type: SyncChangeType,
|
change_type: SyncChangeType,
|
||||||
|
|
@ -138,6 +146,10 @@ pub async fn record_change(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update device cursor after processing changes.
|
/// Update device cursor after processing changes.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the device lookup or update fails.
|
||||||
pub async fn update_device_cursor(
|
pub async fn update_device_cursor(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
device_id: DeviceId,
|
device_id: DeviceId,
|
||||||
|
|
@ -152,6 +164,10 @@ pub async fn update_device_cursor(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark a file as synced for a device.
|
/// Mark a file as synced for a device.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the storage upsert operation fails.
|
||||||
pub async fn mark_synced(
|
pub async fn mark_synced(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
device_id: DeviceId,
|
device_id: DeviceId,
|
||||||
|
|
@ -176,6 +192,10 @@ pub async fn mark_synced(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark a file as pending download for a device.
|
/// Mark a file as pending download for a device.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the storage lookup or upsert operation fails.
|
||||||
pub async fn mark_pending_download(
|
pub async fn mark_pending_download(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
device_id: DeviceId,
|
device_id: DeviceId,
|
||||||
|
|
@ -211,6 +231,7 @@ pub async fn mark_pending_download(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a device token using UUIDs for randomness.
|
/// Generate a device token using UUIDs for randomness.
|
||||||
|
#[must_use]
|
||||||
pub fn generate_device_token() -> String {
|
pub fn generate_device_token() -> String {
|
||||||
// Concatenate two UUIDs for 256 bits of randomness
|
// Concatenate two UUIDs for 256 bits of randomness
|
||||||
let uuid1 = uuid::Uuid::new_v4();
|
let uuid1 = uuid::Uuid::new_v4();
|
||||||
|
|
@ -219,6 +240,7 @@ pub fn generate_device_token() -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hash a device token for storage.
|
/// Hash a device token for storage.
|
||||||
|
#[must_use]
|
||||||
pub fn hash_device_token(token: &str) -> String {
|
pub fn hash_device_token(token: &str) -> String {
|
||||||
blake3::hash(token.as_bytes()).to_hex().to_string()
|
blake3::hash(token.as_bytes()).to_hex().to_string()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ use crate::{
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// The created tag
|
/// The created tag
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
|
||||||
pub async fn create_tag(
|
pub async fn create_tag(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
|
@ -36,6 +40,10 @@ pub async fn create_tag(
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// `Ok(())` on success
|
/// `Ok(())` on success
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
|
||||||
pub async fn tag_media(
|
pub async fn tag_media(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
media_id: MediaId,
|
media_id: MediaId,
|
||||||
|
|
@ -62,6 +70,10 @@ pub async fn tag_media(
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// `Ok(())` on success
|
/// `Ok(())` on success
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
|
||||||
pub async fn untag_media(
|
pub async fn untag_media(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
media_id: MediaId,
|
media_id: MediaId,
|
||||||
|
|
@ -87,6 +99,10 @@ pub async fn untag_media(
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// List of child tags
|
/// List of child tags
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
|
||||||
pub async fn get_tag_tree(
|
pub async fn get_tag_tree(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
tag_id: Uuid,
|
tag_id: Uuid,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ use crate::{
|
||||||
struct TempFileGuard(PathBuf);
|
struct TempFileGuard(PathBuf);
|
||||||
|
|
||||||
impl TempFileGuard {
|
impl TempFileGuard {
|
||||||
fn new(path: PathBuf) -> Self {
|
const fn new(path: PathBuf) -> Self {
|
||||||
Self(path)
|
Self(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,10 +35,14 @@ impl Drop for TempFileGuard {
|
||||||
///
|
///
|
||||||
/// Supports images (via `image` crate), videos (via ffmpeg), PDFs (via
|
/// Supports images (via `image` crate), videos (via ffmpeg), PDFs (via
|
||||||
/// pdftoppm), and EPUBs (via cover image extraction).
|
/// pdftoppm), and EPUBs (via cover image extraction).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if thumbnail generation fails.
|
||||||
pub fn generate_thumbnail(
|
pub fn generate_thumbnail(
|
||||||
media_id: MediaId,
|
media_id: MediaId,
|
||||||
source_path: &Path,
|
source_path: &Path,
|
||||||
media_type: MediaType,
|
media_type: &MediaType,
|
||||||
thumbnail_dir: &Path,
|
thumbnail_dir: &Path,
|
||||||
) -> Result<Option<PathBuf>> {
|
) -> Result<Option<PathBuf>> {
|
||||||
generate_thumbnail_with_config(
|
generate_thumbnail_with_config(
|
||||||
|
|
@ -50,21 +54,26 @@ pub fn generate_thumbnail(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate a thumbnail with custom configuration.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if thumbnail generation fails.
|
||||||
pub fn generate_thumbnail_with_config(
|
pub fn generate_thumbnail_with_config(
|
||||||
media_id: MediaId,
|
media_id: MediaId,
|
||||||
source_path: &Path,
|
source_path: &Path,
|
||||||
media_type: MediaType,
|
media_type: &MediaType,
|
||||||
thumbnail_dir: &Path,
|
thumbnail_dir: &Path,
|
||||||
config: &ThumbnailConfig,
|
config: &ThumbnailConfig,
|
||||||
) -> Result<Option<PathBuf>> {
|
) -> Result<Option<PathBuf>> {
|
||||||
std::fs::create_dir_all(thumbnail_dir)?;
|
std::fs::create_dir_all(thumbnail_dir)?;
|
||||||
let thumb_path = thumbnail_dir.join(format!("{}.jpg", media_id));
|
let thumb_path = thumbnail_dir.join(format!("{media_id}.jpg"));
|
||||||
|
|
||||||
let result = match media_type.category() {
|
let result = match media_type.category() {
|
||||||
MediaCategory::Image => {
|
MediaCategory::Image => {
|
||||||
if media_type.is_raw() {
|
if media_type.is_raw() {
|
||||||
generate_raw_thumbnail(source_path, &thumb_path, config)
|
generate_raw_thumbnail(source_path, &thumb_path, config)
|
||||||
} else if media_type == MediaType::Builtin(BuiltinMediaType::Heic) {
|
} else if *media_type == MediaType::Builtin(BuiltinMediaType::Heic) {
|
||||||
generate_heic_thumbnail(source_path, &thumb_path, config)
|
generate_heic_thumbnail(source_path, &thumb_path, config)
|
||||||
} else {
|
} else {
|
||||||
generate_image_thumbnail(source_path, &thumb_path, config)
|
generate_image_thumbnail(source_path, &thumb_path, config)
|
||||||
|
|
@ -151,8 +160,7 @@ fn generate_video_thumbnail(
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(PinakesError::MetadataExtraction(format!(
|
return Err(PinakesError::MetadataExtraction(format!(
|
||||||
"ffmpeg exited with status {}",
|
"ffmpeg exited with status {status}"
|
||||||
status
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,8 +188,7 @@ fn generate_pdf_thumbnail(
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(PinakesError::MetadataExtraction(format!(
|
return Err(PinakesError::MetadataExtraction(format!(
|
||||||
"pdftoppm exited with status {}",
|
"pdftoppm exited with status {status}"
|
||||||
status
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,8 +279,7 @@ fn generate_raw_thumbnail(
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
// Guard drops and cleans up temp_ppm
|
// Guard drops and cleans up temp_ppm
|
||||||
return Err(PinakesError::MetadataExtraction(format!(
|
return Err(PinakesError::MetadataExtraction(format!(
|
||||||
"dcraw exited with status {}",
|
"dcraw exited with status {status}"
|
||||||
status
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -320,8 +326,7 @@ fn generate_heic_thumbnail(
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
// Guard drops and cleans up temp_jpg
|
// Guard drops and cleans up temp_jpg
|
||||||
return Err(PinakesError::MetadataExtraction(format!(
|
return Err(PinakesError::MetadataExtraction(format!(
|
||||||
"heif-convert exited with status {}",
|
"heif-convert exited with status {status}"
|
||||||
status
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -357,26 +362,32 @@ pub enum CoverSize {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CoverSize {
|
impl CoverSize {
|
||||||
pub fn dimensions(&self) -> Option<(u32, u32)> {
|
#[must_use]
|
||||||
|
pub const fn dimensions(&self) -> Option<(u32, u32)> {
|
||||||
match self {
|
match self {
|
||||||
CoverSize::Tiny => Some((64, 64)),
|
Self::Tiny => Some((64, 64)),
|
||||||
CoverSize::Grid => Some((320, 320)),
|
Self::Grid => Some((320, 320)),
|
||||||
CoverSize::Preview => Some((1024, 1024)),
|
Self::Preview => Some((1024, 1024)),
|
||||||
CoverSize::Original => None, // No resizing
|
Self::Original => None, // No resizing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn filename(&self) -> &'static str {
|
#[must_use]
|
||||||
|
pub const fn filename(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
CoverSize::Tiny => "tiny.jpg",
|
Self::Tiny => "tiny.jpg",
|
||||||
CoverSize::Grid => "grid.jpg",
|
Self::Grid => "grid.jpg",
|
||||||
CoverSize::Preview => "preview.jpg",
|
Self::Preview => "preview.jpg",
|
||||||
CoverSize::Original => "original.jpg",
|
Self::Original => "original.jpg",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate multi-resolution covers for a book
|
/// Generate multi-resolution covers for a book.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the cover image cannot be decoded or encoded.
|
||||||
pub fn generate_book_covers(
|
pub fn generate_book_covers(
|
||||||
media_id: MediaId,
|
media_id: MediaId,
|
||||||
source_image: &[u8],
|
source_image: &[u8],
|
||||||
|
|
@ -401,26 +412,23 @@ pub fn generate_book_covers(
|
||||||
] {
|
] {
|
||||||
let cover_path = media_cover_dir.join(size.filename());
|
let cover_path = media_cover_dir.join(size.filename());
|
||||||
|
|
||||||
match size.dimensions() {
|
if let Some((width, height)) = size.dimensions() {
|
||||||
Some((width, height)) => {
|
// Generate thumbnail
|
||||||
// Generate thumbnail
|
let thumb = img.thumbnail(width, height);
|
||||||
let thumb = img.thumbnail(width, height);
|
let mut output = std::fs::File::create(&cover_path)?;
|
||||||
let mut output = std::fs::File::create(&cover_path)?;
|
let encoder =
|
||||||
let encoder =
|
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 90);
|
||||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 90);
|
thumb.write_with_encoder(encoder).map_err(|e| {
|
||||||
thumb.write_with_encoder(encoder).map_err(|e| {
|
PinakesError::MetadataExtraction(format!("cover encode: {e}"))
|
||||||
PinakesError::MetadataExtraction(format!("cover encode: {e}"))
|
})?;
|
||||||
})?;
|
} else {
|
||||||
},
|
// Save original
|
||||||
None => {
|
let mut output = std::fs::File::create(&cover_path)?;
|
||||||
// Save original
|
let encoder =
|
||||||
let mut output = std::fs::File::create(&cover_path)?;
|
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 95);
|
||||||
let encoder =
|
img.write_with_encoder(encoder).map_err(|e| {
|
||||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 95);
|
PinakesError::MetadataExtraction(format!("cover encode: {e}"))
|
||||||
img.write_with_encoder(encoder).map_err(|e| {
|
})?;
|
||||||
PinakesError::MetadataExtraction(format!("cover encode: {e}"))
|
|
||||||
})?;
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
results.push((size, cover_path));
|
results.push((size, cover_path));
|
||||||
|
|
@ -429,7 +437,11 @@ pub fn generate_book_covers(
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract full-size cover from an EPUB file
|
/// Extract full-size cover from an EPUB file.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the EPUB cannot be opened or read.
|
||||||
pub fn extract_epub_cover(epub_path: &Path) -> Result<Option<Vec<u8>>> {
|
pub fn extract_epub_cover(epub_path: &Path) -> Result<Option<Vec<u8>>> {
|
||||||
let mut doc = epub::doc::EpubDoc::new(epub_path)
|
let mut doc = epub::doc::EpubDoc::new(epub_path)
|
||||||
.map_err(|e| PinakesError::MetadataExtraction(format!("EPUB open: {e}")))?;
|
.map_err(|e| PinakesError::MetadataExtraction(format!("EPUB open: {e}")))?;
|
||||||
|
|
@ -459,7 +471,11 @@ pub fn extract_epub_cover(epub_path: &Path) -> Result<Option<Vec<u8>>> {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract full-size cover from a PDF file (first page)
|
/// Extract full-size cover from a PDF file (first page).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if pdftoppm cannot be executed or fails.
|
||||||
pub fn extract_pdf_cover(pdf_path: &Path) -> Result<Option<Vec<u8>>> {
|
pub fn extract_pdf_cover(pdf_path: &Path) -> Result<Option<Vec<u8>>> {
|
||||||
let temp_dir = std::env::temp_dir();
|
let temp_dir = std::env::temp_dir();
|
||||||
let temp_prefix =
|
let temp_prefix =
|
||||||
|
|
@ -476,8 +492,7 @@ pub fn extract_pdf_cover(pdf_path: &Path) -> Result<Option<Vec<u8>>> {
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(PinakesError::MetadataExtraction(format!(
|
return Err(PinakesError::MetadataExtraction(format!(
|
||||||
"pdftoppm exited with status {}",
|
"pdftoppm exited with status {status}"
|
||||||
status
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -497,11 +512,13 @@ pub fn extract_pdf_cover(pdf_path: &Path) -> Result<Option<Vec<u8>>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the default covers directory under the data dir
|
/// Returns the default covers directory under the data dir
|
||||||
|
#[must_use]
|
||||||
pub fn default_covers_dir() -> PathBuf {
|
pub fn default_covers_dir() -> PathBuf {
|
||||||
crate::config::Config::default_data_dir().join("covers")
|
crate::config::Config::default_data_dir().join("covers")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the default thumbnail directory under the data dir.
|
/// Returns the default thumbnail directory under the data dir.
|
||||||
|
#[must_use]
|
||||||
pub fn default_thumbnail_dir() -> PathBuf {
|
pub fn default_thumbnail_dir() -> PathBuf {
|
||||||
crate::config::Config::default_data_dir().join("thumbnails")
|
crate::config::Config::default_data_dir().join("thumbnails")
|
||||||
}
|
}
|
||||||
|
|
@ -519,30 +536,37 @@ pub enum ThumbnailSize {
|
||||||
|
|
||||||
impl ThumbnailSize {
|
impl ThumbnailSize {
|
||||||
/// Get the pixel size for this thumbnail variant
|
/// Get the pixel size for this thumbnail variant
|
||||||
pub fn pixels(&self) -> u32 {
|
#[must_use]
|
||||||
|
pub const fn pixels(&self) -> u32 {
|
||||||
match self {
|
match self {
|
||||||
ThumbnailSize::Tiny => 64,
|
Self::Tiny => 64,
|
||||||
ThumbnailSize::Grid => 320,
|
Self::Grid => 320,
|
||||||
ThumbnailSize::Preview => 1024,
|
Self::Preview => 1024,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the subdirectory name for this size
|
/// Get the subdirectory name for this size
|
||||||
pub fn subdir_name(&self) -> &'static str {
|
#[must_use]
|
||||||
|
pub const fn subdir_name(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
ThumbnailSize::Tiny => "tiny",
|
Self::Tiny => "tiny",
|
||||||
ThumbnailSize::Grid => "grid",
|
Self::Grid => "grid",
|
||||||
ThumbnailSize::Preview => "preview",
|
Self::Preview => "preview",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate all thumbnail sizes for a media file
|
/// Generate all thumbnail sizes for a media file.
|
||||||
/// Returns paths to the generated thumbnails (tiny, grid, preview)
|
///
|
||||||
|
/// Returns paths to the generated thumbnails (tiny, grid, preview).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if thumbnail generation fails.
|
||||||
pub fn generate_all_thumbnail_sizes(
|
pub fn generate_all_thumbnail_sizes(
|
||||||
media_id: MediaId,
|
media_id: MediaId,
|
||||||
source_path: &Path,
|
source_path: &Path,
|
||||||
media_type: MediaType,
|
media_type: &MediaType,
|
||||||
thumbnail_base_dir: &Path,
|
thumbnail_base_dir: &Path,
|
||||||
) -> Result<(Option<PathBuf>, Option<PathBuf>, Option<PathBuf>)> {
|
) -> Result<(Option<PathBuf>, Option<PathBuf>, Option<PathBuf>)> {
|
||||||
let sizes = [
|
let sizes = [
|
||||||
|
|
@ -564,7 +588,7 @@ pub fn generate_all_thumbnail_sizes(
|
||||||
let result = generate_thumbnail_with_config(
|
let result = generate_thumbnail_with_config(
|
||||||
media_id,
|
media_id,
|
||||||
source_path,
|
source_path,
|
||||||
media_type.clone(),
|
media_type,
|
||||||
&size_dir,
|
&size_dir,
|
||||||
&config,
|
&config,
|
||||||
)?;
|
)?;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! Transcoding service for media files using FFmpeg.
|
//! Transcoding service for media files using `FFmpeg`.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
|
|
@ -33,7 +33,7 @@ pub struct TranscodeSession {
|
||||||
/// Duration of the source media in seconds, used for progress calculation.
|
/// Duration of the source media in seconds, used for progress calculation.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub duration_secs: Option<f64>,
|
pub duration_secs: Option<f64>,
|
||||||
/// Handle to cancel the child FFmpeg process.
|
/// Handle to cancel the child `FFmpeg` process.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub child_cancel: Option<Arc<tokio::sync::Notify>>,
|
pub child_cancel: Option<Arc<tokio::sync::Notify>>,
|
||||||
}
|
}
|
||||||
|
|
@ -50,7 +50,8 @@ pub enum TranscodeStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TranscodeStatus {
|
impl TranscodeStatus {
|
||||||
pub fn as_str(&self) -> &str {
|
#[must_use]
|
||||||
|
pub const fn as_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
Self::Pending => "pending",
|
Self::Pending => "pending",
|
||||||
Self::Transcoding => "transcoding",
|
Self::Transcoding => "transcoding",
|
||||||
|
|
@ -81,6 +82,7 @@ impl TranscodeStatus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn error_message(&self) -> Option<&str> {
|
pub fn error_message(&self) -> Option<&str> {
|
||||||
match self {
|
match self {
|
||||||
Self::Failed { error } => Some(error),
|
Self::Failed { error } => Some(error),
|
||||||
|
|
@ -89,7 +91,7 @@ impl TranscodeStatus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Service managing transcoding sessions and FFmpeg invocations.
|
/// Service managing transcoding sessions and `FFmpeg` invocations.
|
||||||
pub struct TranscodeService {
|
pub struct TranscodeService {
|
||||||
pub config: TranscodingConfig,
|
pub config: TranscodingConfig,
|
||||||
pub sessions: Arc<RwLock<HashMap<Uuid, TranscodeSession>>>,
|
pub sessions: Arc<RwLock<HashMap<Uuid, TranscodeSession>>>,
|
||||||
|
|
@ -97,6 +99,7 @@ pub struct TranscodeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TranscodeService {
|
impl TranscodeService {
|
||||||
|
#[must_use]
|
||||||
pub fn new(config: TranscodingConfig) -> Self {
|
pub fn new(config: TranscodingConfig) -> Self {
|
||||||
let max_concurrent = config.max_concurrent.max(1);
|
let max_concurrent = config.max_concurrent.max(1);
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -106,10 +109,12 @@ impl TranscodeService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_enabled(&self) -> bool {
|
#[must_use]
|
||||||
|
pub const fn is_enabled(&self) -> bool {
|
||||||
self.config.enabled
|
self.config.enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn cache_dir(&self) -> PathBuf {
|
pub fn cache_dir(&self) -> PathBuf {
|
||||||
self
|
self
|
||||||
.config
|
.config
|
||||||
|
|
@ -119,6 +124,11 @@ impl TranscodeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start a transcode job for a media item.
|
/// Start a transcode job for a media item.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the profile is not found, the session directory cannot
|
||||||
|
/// be created, or the session cannot be stored in the database.
|
||||||
pub async fn start_transcode(
|
pub async fn start_transcode(
|
||||||
&self,
|
&self,
|
||||||
media_id: MediaId,
|
media_id: MediaId,
|
||||||
|
|
@ -135,8 +145,7 @@ impl TranscodeService {
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
crate::error::PinakesError::InvalidOperation(format!(
|
crate::error::PinakesError::InvalidOperation(format!(
|
||||||
"unknown transcode profile: {}",
|
"unknown transcode profile: {profile_name}"
|
||||||
profile_name
|
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|
@ -144,13 +153,15 @@ impl TranscodeService {
|
||||||
let session_dir = self.cache_dir().join(session_id.to_string());
|
let session_dir = self.cache_dir().join(session_id.to_string());
|
||||||
tokio::fs::create_dir_all(&session_dir).await.map_err(|e| {
|
tokio::fs::create_dir_all(&session_dir).await.map_err(|e| {
|
||||||
crate::error::PinakesError::InvalidOperation(format!(
|
crate::error::PinakesError::InvalidOperation(format!(
|
||||||
"failed to create session directory: {}",
|
"failed to create session directory: {e}"
|
||||||
e
|
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let expires_at = Some(
|
let expires_at = Some(
|
||||||
Utc::now() + chrono::Duration::hours(self.config.cache_ttl_hours as i64),
|
Utc::now()
|
||||||
|
+ chrono::Duration::hours(
|
||||||
|
i64::try_from(self.config.cache_ttl_hours).unwrap_or(i64::MAX),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
let cancel_notify = Arc::new(tokio::sync::Notify::new());
|
let cancel_notify = Arc::new(tokio::sync::Notify::new());
|
||||||
|
|
@ -166,7 +177,7 @@ impl TranscodeService {
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
expires_at,
|
expires_at,
|
||||||
duration_secs,
|
duration_secs,
|
||||||
child_cancel: Some(cancel_notify.clone()),
|
child_cancel: Some(Arc::clone(&cancel_notify)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store session in DB
|
// Store session in DB
|
||||||
|
|
@ -179,12 +190,12 @@ impl TranscodeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn the FFmpeg task
|
// Spawn the FFmpeg task
|
||||||
let sessions = self.sessions.clone();
|
let sessions = Arc::clone(&self.sessions);
|
||||||
let semaphore = self.semaphore.clone();
|
let semaphore = Arc::clone(&self.semaphore);
|
||||||
let source = source_path.to_path_buf();
|
let source = source_path.to_path_buf();
|
||||||
let hw_accel = self.config.hardware_acceleration.clone();
|
let hw_accel = self.config.hardware_acceleration.clone();
|
||||||
let storage = storage.clone();
|
let storage = Arc::clone(storage);
|
||||||
let cancel = cancel_notify.clone();
|
let cancel = Arc::clone(&cancel_notify);
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Acquire semaphore permit to limit concurrency
|
// Acquire semaphore permit to limit concurrency
|
||||||
|
|
@ -192,12 +203,14 @@ impl TranscodeService {
|
||||||
Ok(permit) => permit,
|
Ok(permit) => permit,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("failed to acquire transcode semaphore: {}", e);
|
tracing::error!("failed to acquire transcode semaphore: {}", e);
|
||||||
let error_msg = format!("semaphore closed: {}", e);
|
let error_msg = format!("semaphore closed: {e}");
|
||||||
let mut s = sessions.write().await;
|
{
|
||||||
if let Some(sess) = s.get_mut(&session_id) {
|
let mut s = sessions.write().await;
|
||||||
sess.status = TranscodeStatus::Failed {
|
if let Some(sess) = s.get_mut(&session_id) {
|
||||||
error: error_msg.clone(),
|
sess.status = TranscodeStatus::Failed {
|
||||||
};
|
error: error_msg.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Err(e) = storage
|
if let Err(e) = storage
|
||||||
.update_transcode_status(
|
.update_transcode_status(
|
||||||
|
|
@ -234,10 +247,12 @@ impl TranscodeService {
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let mut s = sessions.write().await;
|
{
|
||||||
if let Some(sess) = s.get_mut(&session_id) {
|
let mut s = sessions.write().await;
|
||||||
sess.status = TranscodeStatus::Complete;
|
if let Some(sess) = s.get_mut(&session_id) {
|
||||||
sess.progress = 1.0;
|
sess.status = TranscodeStatus::Complete;
|
||||||
|
sess.progress = 1.0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Err(e) = storage
|
if let Err(e) = storage
|
||||||
.update_transcode_status(session_id, TranscodeStatus::Complete, 1.0)
|
.update_transcode_status(session_id, TranscodeStatus::Complete, 1.0)
|
||||||
|
|
@ -277,6 +292,10 @@ impl TranscodeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel a transcode session and clean up cache files.
|
/// Cancel a transcode session and clean up cache files.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the database status update fails.
|
||||||
pub async fn cancel_transcode(
|
pub async fn cancel_transcode(
|
||||||
&self,
|
&self,
|
||||||
session_id: Uuid,
|
session_id: Uuid,
|
||||||
|
|
@ -359,6 +378,7 @@ impl TranscodeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the path to a specific segment file on disk.
|
/// Resolve the path to a specific segment file on disk.
|
||||||
|
#[must_use]
|
||||||
pub fn segment_path(&self, session_id: Uuid, segment_name: &str) -> PathBuf {
|
pub fn segment_path(&self, session_id: Uuid, segment_name: &str) -> PathBuf {
|
||||||
// Sanitize segment_name to prevent path traversal
|
// Sanitize segment_name to prevent path traversal
|
||||||
let safe_name = std::path::Path::new(segment_name)
|
let safe_name = std::path::Path::new(segment_name)
|
||||||
|
|
@ -381,7 +401,7 @@ impl TranscodeService {
|
||||||
.join(safe_name)
|
.join(safe_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a session for a given media_id and profile.
|
/// Find a session for a given `media_id` and profile.
|
||||||
pub async fn find_session(
|
pub async fn find_session(
|
||||||
&self,
|
&self,
|
||||||
media_id: MediaId,
|
media_id: MediaId,
|
||||||
|
|
@ -396,24 +416,25 @@ impl TranscodeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a resolution string like "360p", "720p", "1080p" into (width, height).
|
/// Parse a resolution string like "360p", "720p", "1080p" into (width, height).
|
||||||
|
#[must_use]
|
||||||
pub fn parse_resolution(res: &str) -> (u32, u32) {
|
pub fn parse_resolution(res: &str) -> (u32, u32) {
|
||||||
match res.trim_end_matches('p') {
|
match res.trim_end_matches('p') {
|
||||||
"360" => (640, 360),
|
"360" => (640, 360),
|
||||||
"480" => (854, 480),
|
"480" => (854, 480),
|
||||||
"720" => (1280, 720),
|
|
||||||
"1080" => (1920, 1080),
|
"1080" => (1920, 1080),
|
||||||
"1440" => (2560, 1440),
|
"1440" => (2560, 1440),
|
||||||
"2160" | "4k" => (3840, 2160),
|
"2160" | "4k" => (3840, 2160),
|
||||||
_ => (1280, 720), // default to 720p
|
_ => (1280, 720), // default to 720p (includes "720")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Estimate bandwidth (bits/sec) from a profile's max_bitrate_kbps.
|
/// Estimate bandwidth (bits/sec) from a profile's `max_bitrate_kbps`.
|
||||||
pub fn estimate_bandwidth(profile: &TranscodeProfile) -> u32 {
|
#[must_use]
|
||||||
|
pub const fn estimate_bandwidth(profile: &TranscodeProfile) -> u32 {
|
||||||
profile.max_bitrate_kbps * 1000
|
profile.max_bitrate_kbps * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build FFmpeg CLI arguments for transcoding.
|
/// Build `FFmpeg` CLI arguments for transcoding.
|
||||||
fn get_ffmpeg_args(
|
fn get_ffmpeg_args(
|
||||||
source: &Path,
|
source: &Path,
|
||||||
output_dir: &Path,
|
output_dir: &Path,
|
||||||
|
|
@ -441,7 +462,7 @@ fn get_ffmpeg_args(
|
||||||
"-b:v".to_string(),
|
"-b:v".to_string(),
|
||||||
format!("{}k", profile.max_bitrate_kbps),
|
format!("{}k", profile.max_bitrate_kbps),
|
||||||
"-vf".to_string(),
|
"-vf".to_string(),
|
||||||
format!("scale={}:{}", w, h),
|
format!("scale={w}:{h}"),
|
||||||
"-f".to_string(),
|
"-f".to_string(),
|
||||||
"hls".to_string(),
|
"hls".to_string(),
|
||||||
"-hls_time".to_string(),
|
"-hls_time".to_string(),
|
||||||
|
|
@ -457,7 +478,7 @@ fn get_ffmpeg_args(
|
||||||
args
|
args
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run FFmpeg as a child process, parsing progress from stdout.
|
/// Run `FFmpeg` as a child process, parsing progress from stdout.
|
||||||
async fn run_ffmpeg(
|
async fn run_ffmpeg(
|
||||||
args: &[String],
|
args: &[String],
|
||||||
sessions: &Arc<RwLock<HashMap<Uuid, TranscodeSession>>>,
|
sessions: &Arc<RwLock<HashMap<Uuid, TranscodeSession>>>,
|
||||||
|
|
@ -477,33 +498,30 @@ async fn run_ffmpeg(
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
crate::error::PinakesError::InvalidOperation(format!(
|
crate::error::PinakesError::InvalidOperation(format!(
|
||||||
"failed to spawn ffmpeg: {}",
|
"failed to spawn ffmpeg: {e}"
|
||||||
e
|
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Capture stderr in a spawned task for error reporting
|
// Capture stderr in a spawned task for error reporting
|
||||||
let stderr_handle = if let Some(stderr) = child.stderr.take() {
|
let stderr_handle = child.stderr.take().map(|stderr| {
|
||||||
let reader = BufReader::new(stderr);
|
let reader = BufReader::new(stderr);
|
||||||
Some(tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut lines = reader.lines();
|
let mut lines = reader.lines();
|
||||||
let mut collected = Vec::new();
|
let mut collected = Vec::new();
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
collected.push(line);
|
collected.push(line);
|
||||||
}
|
}
|
||||||
collected
|
collected
|
||||||
}))
|
})
|
||||||
} else {
|
});
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse progress from stdout
|
// Parse progress from stdout
|
||||||
let stdout_handle = if let Some(stdout) = child.stdout.take() {
|
let stdout_handle = child.stdout.take().map(|stdout| {
|
||||||
let reader = BufReader::new(stdout);
|
let reader = BufReader::new(stdout);
|
||||||
let mut lines = reader.lines();
|
let mut lines = reader.lines();
|
||||||
let sessions = sessions.clone();
|
let sessions = Arc::clone(sessions);
|
||||||
|
|
||||||
Some(tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
// FFmpeg progress output: "out_time_us=12345678"
|
// FFmpeg progress output: "out_time_us=12345678"
|
||||||
if let Some(time_str) = line.strip_prefix("out_time_us=")
|
if let Some(time_str) = line.strip_prefix("out_time_us=")
|
||||||
|
|
@ -512,7 +530,11 @@ async fn run_ffmpeg(
|
||||||
let secs = us / 1_000_000.0;
|
let secs = us / 1_000_000.0;
|
||||||
// Calculate progress based on known duration
|
// Calculate progress based on known duration
|
||||||
let progress = match duration_secs {
|
let progress = match duration_secs {
|
||||||
Some(dur) if dur > 0.0 => (secs / dur).min(0.99) as f32,
|
Some(dur) if dur > 0.0 => {
|
||||||
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
|
let p = (secs / dur).min(0.99) as f32;
|
||||||
|
p
|
||||||
|
},
|
||||||
_ => {
|
_ => {
|
||||||
// Duration unknown; don't update progress
|
// Duration unknown; don't update progress
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -524,19 +546,17 @@ async fn run_ffmpeg(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}))
|
})
|
||||||
} else {
|
});
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wait for child, but also listen for cancellation
|
// Wait for child, but also listen for cancellation
|
||||||
let status = tokio::select! {
|
let status = tokio::select! {
|
||||||
result = child.wait() => {
|
result = child.wait() => {
|
||||||
result.map_err(|e| {
|
result.map_err(|e| {
|
||||||
crate::error::PinakesError::InvalidOperation(format!("ffmpeg process error: {}", e))
|
crate::error::PinakesError::InvalidOperation(format!("ffmpeg process error: {e}"))
|
||||||
})?
|
})?
|
||||||
}
|
}
|
||||||
_ = cancel.notified() => {
|
() = cancel.notified() => {
|
||||||
// Kill the child process on cancel
|
// Kill the child process on cancel
|
||||||
if let Err(e) = child.kill().await {
|
if let Err(e) = child.kill().await {
|
||||||
tracing::error!("failed to kill ffmpeg process: {}", e);
|
tracing::error!("failed to kill ffmpeg process: {}", e);
|
||||||
|
|
@ -569,8 +589,7 @@ async fn run_ffmpeg(
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
return Err(crate::error::PinakesError::InvalidOperation(format!(
|
return Err(crate::error::PinakesError::InvalidOperation(format!(
|
||||||
"ffmpeg exited with status: {}\nstderr:\n{}",
|
"ffmpeg exited with status: {status}\nstderr:\n{last_stderr}"
|
||||||
status, last_stderr
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Upload processing for managed storage.
|
//! Upload processing for managed storage.
|
||||||
//!
|
//!
|
||||||
//! Handles file uploads, metadata extraction, and MediaItem creation
|
//! Handles file uploads, metadata extraction, and `MediaItem` creation
|
||||||
//! for files stored in managed content-addressable storage.
|
//! for files stored in managed content-addressable storage.
|
||||||
|
|
||||||
use std::{collections::HashMap, path::Path};
|
use std::{collections::HashMap, path::Path};
|
||||||
|
|
@ -24,7 +24,11 @@ use crate::{
|
||||||
/// 1. Stores the file in managed storage
|
/// 1. Stores the file in managed storage
|
||||||
/// 2. Checks for duplicates by content hash
|
/// 2. Checks for duplicates by content hash
|
||||||
/// 3. Extracts metadata from the file
|
/// 3. Extracts metadata from the file
|
||||||
/// 4. Creates or updates the MediaItem
|
/// 4. Creates or updates the `MediaItem`
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if storage, hashing, or metadata extraction fails.
|
||||||
pub async fn process_upload<R: AsyncRead + Unpin>(
|
pub async fn process_upload<R: AsyncRead + Unpin>(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
managed: &ManagedStorageService,
|
managed: &ManagedStorageService,
|
||||||
|
|
@ -54,13 +58,10 @@ pub async fn process_upload<R: AsyncRead + Unpin>(
|
||||||
let blob_path = managed.path(&content_hash);
|
let blob_path = managed.path(&content_hash);
|
||||||
|
|
||||||
// Extract metadata
|
// Extract metadata
|
||||||
let extracted =
|
let extracted = metadata::extract_metadata(&blob_path, &media_type).ok();
|
||||||
metadata::extract_metadata(&blob_path, media_type.clone()).ok();
|
|
||||||
|
|
||||||
// Create or get blob record
|
// Create or get blob record
|
||||||
let mime = mime_type
|
let mime = mime_type.map_or_else(|| media_type.mime_type(), String::from);
|
||||||
.map(String::from)
|
|
||||||
.unwrap_or_else(|| media_type.mime_type().to_string());
|
|
||||||
let _blob = storage
|
let _blob = storage
|
||||||
.get_or_create_blob(&content_hash, file_size, &mime)
|
.get_or_create_blob(&content_hash, file_size, &mime)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -123,6 +124,10 @@ pub async fn process_upload<R: AsyncRead + Unpin>(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process an upload from bytes.
|
/// Process an upload from bytes.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if storage or processing fails.
|
||||||
pub async fn process_upload_bytes(
|
pub async fn process_upload_bytes(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
managed: &ManagedStorageService,
|
managed: &ManagedStorageService,
|
||||||
|
|
@ -138,6 +143,10 @@ pub async fn process_upload_bytes(
|
||||||
/// Process an upload from a local file path.
|
/// Process an upload from a local file path.
|
||||||
///
|
///
|
||||||
/// This is useful for migrating existing external files to managed storage.
|
/// This is useful for migrating existing external files to managed storage.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the file cannot be opened or processing fails.
|
||||||
pub async fn process_upload_file(
|
pub async fn process_upload_file(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
managed: &ManagedStorageService,
|
managed: &ManagedStorageService,
|
||||||
|
|
@ -160,6 +169,11 @@ pub async fn process_upload_file(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Migrate an existing external media item to managed storage.
|
/// Migrate an existing external media item to managed storage.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the media item cannot be found, the file is
|
||||||
|
/// missing, or the storage migration fails.
|
||||||
pub async fn migrate_to_managed(
|
pub async fn migrate_to_managed(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
managed: &ManagedStorageService,
|
managed: &ManagedStorageService,
|
||||||
|
|
@ -190,7 +204,7 @@ pub async fn migrate_to_managed(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get or create blob record
|
// Get or create blob record
|
||||||
let mime = item.media_type.mime_type().to_string();
|
let mime = item.media_type.mime_type().clone();
|
||||||
let _blob = storage
|
let _blob = storage
|
||||||
.get_or_create_blob(&new_hash, new_size, &mime)
|
.get_or_create_blob(&new_hash, new_size, &mime)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -227,6 +241,11 @@ fn sanitize_filename(name: &str) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a managed media item and clean up the blob if orphaned.
|
/// Delete a managed media item and clean up the blob if orphaned.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PinakesError`] if the media item cannot be found or deletion
|
||||||
|
/// fails.
|
||||||
pub async fn delete_managed_media(
|
pub async fn delete_managed_media(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
managed: &ManagedStorageService,
|
managed: &ManagedStorageService,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ pub struct UserId(pub Uuid);
|
||||||
|
|
||||||
impl UserId {
|
impl UserId {
|
||||||
/// Creates a new user ID.
|
/// Creates a new user ID.
|
||||||
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self(Uuid::now_v7())
|
Self(Uuid::now_v7())
|
||||||
}
|
}
|
||||||
|
|
@ -96,21 +97,25 @@ pub enum LibraryPermission {
|
||||||
|
|
||||||
impl LibraryPermission {
|
impl LibraryPermission {
|
||||||
/// Checks if read permission is granted.
|
/// Checks if read permission is granted.
|
||||||
pub fn can_read(&self) -> bool {
|
#[must_use]
|
||||||
|
pub const fn can_read(&self) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if write permission is granted.
|
/// Checks if write permission is granted.
|
||||||
pub fn can_write(&self) -> bool {
|
#[must_use]
|
||||||
|
pub const fn can_write(&self) -> bool {
|
||||||
matches!(self, Self::Write | Self::Admin)
|
matches!(self, Self::Write | Self::Admin)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if admin permission is granted.
|
/// Checks if admin permission is granted.
|
||||||
pub fn can_admin(&self) -> bool {
|
#[must_use]
|
||||||
|
pub const fn can_admin(&self) -> bool {
|
||||||
matches!(self, Self::Admin)
|
matches!(self, Self::Admin)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
#[must_use]
|
||||||
|
pub const fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Read => "read",
|
Self::Read => "read",
|
||||||
Self::Write => "write",
|
Self::Write => "write",
|
||||||
|
|
@ -155,7 +160,7 @@ pub struct UpdateUserRequest {
|
||||||
|
|
||||||
/// User authentication
|
/// User authentication
|
||||||
pub mod auth {
|
pub mod auth {
|
||||||
use super::*;
|
use super::{PinakesError, Result};
|
||||||
|
|
||||||
/// Hash a password using Argon2
|
/// Hash a password using Argon2
|
||||||
pub fn hash_password(password: &str) -> Result<String> {
|
pub fn hash_password(password: &str) -> Result<String> {
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ fn test_book_cover_generation() {
|
||||||
|
|
||||||
// Verify all cover files exist
|
// Verify all cover files exist
|
||||||
for (size, path) in &covers {
|
for (size, path) in &covers {
|
||||||
assert!(path.exists(), "Cover {:?} should exist at {:?}", size, path);
|
assert!(path.exists(), "Cover {size:?} should exist at {path:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,13 +176,10 @@ async fn test_openlibrary_isbn_fetch() {
|
||||||
|
|
||||||
// Should either succeed or fail gracefully
|
// Should either succeed or fail gracefully
|
||||||
// We don't assert success because network might not be available
|
// We don't assert success because network might not be available
|
||||||
match result {
|
if let Ok(book) = result {
|
||||||
Ok(book) => {
|
assert!(book.title.is_some());
|
||||||
assert!(book.title.is_some());
|
} else {
|
||||||
},
|
// Network error or book not found - acceptable in tests
|
||||||
Err(_) => {
|
|
||||||
// Network error or book not found - acceptable in tests
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,14 +192,11 @@ async fn test_googlebooks_isbn_fetch() {
|
||||||
// Use a known ISBN
|
// Use a known ISBN
|
||||||
let result = client.fetch_by_isbn("9780547928227").await;
|
let result = client.fetch_by_isbn("9780547928227").await;
|
||||||
|
|
||||||
match result {
|
if let Ok(books) = result {
|
||||||
Ok(books) => {
|
if !books.is_empty() {
|
||||||
if !books.is_empty() {
|
assert!(books[0].volume_info.title.is_some());
|
||||||
assert!(books[0].volume_info.title.is_some());
|
}
|
||||||
}
|
} else {
|
||||||
},
|
// Network error - acceptable in tests
|
||||||
Err(_) => {
|
|
||||||
// Network error - acceptable in tests
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,13 +158,13 @@ async fn test_large_directory_performance() {
|
||||||
storage.add_root_dir(root_dir.clone()).await.unwrap();
|
storage.add_root_dir(root_dir.clone()).await.unwrap();
|
||||||
|
|
||||||
for i in 0..1000 {
|
for i in 0..1000 {
|
||||||
let file_path = root_dir.join(format!("file_{}.mp3", i));
|
let file_path = root_dir.join(format!("file_{i}.mp3"));
|
||||||
fs::write(&file_path, format!("content {}", i)).unwrap();
|
fs::write(&file_path, format!("content {i}")).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
for i in 0..500 {
|
for i in 0..500 {
|
||||||
let file_path = root_dir.join(format!("file_{}.mp3", i));
|
let file_path = root_dir.join(format!("file_{i}.mp3"));
|
||||||
let item = create_test_media_item(file_path, &format!("hash_{}", i));
|
let item = create_test_media_item(file_path, &format!("hash_{i}"));
|
||||||
storage.insert_media(&item).await.unwrap();
|
storage.insert_media(&item).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,8 +174,7 @@ async fn test_large_directory_performance() {
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
elapsed.as_secs() < 5,
|
elapsed.as_secs() < 5,
|
||||||
"Detection took too long: {:?}",
|
"Detection took too long: {elapsed:?}"
|
||||||
elapsed
|
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(report.untracked_paths.len(), 500);
|
assert_eq!(report.untracked_paths.len(), 500);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ mod common;
|
||||||
fn create_test_note_content(num_links: usize) -> String {
|
fn create_test_note_content(num_links: usize) -> String {
|
||||||
let mut content = String::from("# Test Note\n\n");
|
let mut content = String::from("# Test Note\n\n");
|
||||||
for i in 0..num_links {
|
for i in 0..num_links {
|
||||||
content.push_str(&format!("Link {}: [[note_{}]]\n", i, i));
|
content.push_str(&format!("Link {i}: [[note_{i}]]\n"));
|
||||||
}
|
}
|
||||||
content
|
content
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ async fn test_delete_user_sessions() {
|
||||||
// Create multiple sessions for the same user
|
// Create multiple sessions for the same user
|
||||||
for i in 0..3 {
|
for i in 0..3 {
|
||||||
let session = SessionData {
|
let session = SessionData {
|
||||||
session_token: format!("token_{}", i),
|
session_token: format!("token_{i}"),
|
||||||
user_id: None,
|
user_id: None,
|
||||||
username: "testuser".to_string(),
|
username: "testuser".to_string(),
|
||||||
role: "viewer".to_string(),
|
role: "viewer".to_string(),
|
||||||
|
|
@ -152,7 +152,7 @@ async fn test_delete_user_sessions() {
|
||||||
for i in 0..3 {
|
for i in 0..3 {
|
||||||
assert!(
|
assert!(
|
||||||
storage
|
storage
|
||||||
.get_session(&format!("token_{}", i))
|
.get_session(&format!("token_{i}"))
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.is_none()
|
.is_none()
|
||||||
|
|
@ -217,7 +217,7 @@ async fn test_list_active_sessions() {
|
||||||
// Create active sessions for different users
|
// Create active sessions for different users
|
||||||
for i in 0..3 {
|
for i in 0..3 {
|
||||||
let session = SessionData {
|
let session = SessionData {
|
||||||
session_token: format!("user1_token_{}", i),
|
session_token: format!("user1_token_{i}"),
|
||||||
user_id: None,
|
user_id: None,
|
||||||
username: "user1".to_string(),
|
username: "user1".to_string(),
|
||||||
role: "viewer".to_string(),
|
role: "viewer".to_string(),
|
||||||
|
|
@ -230,7 +230,7 @@ async fn test_list_active_sessions() {
|
||||||
|
|
||||||
for i in 0..2 {
|
for i in 0..2 {
|
||||||
let session = SessionData {
|
let session = SessionData {
|
||||||
session_token: format!("user2_token_{}", i),
|
session_token: format!("user2_token_{i}"),
|
||||||
user_id: None,
|
user_id: None,
|
||||||
username: "user2".to_string(),
|
username: "user2".to_string(),
|
||||||
role: "admin".to_string(),
|
role: "admin".to_string(),
|
||||||
|
|
@ -279,9 +279,9 @@ async fn test_concurrent_session_operations() {
|
||||||
let storage = storage.clone();
|
let storage = storage.clone();
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
let session = SessionData {
|
let session = SessionData {
|
||||||
session_token: format!("concurrent_{}", i),
|
session_token: format!("concurrent_{i}"),
|
||||||
user_id: None,
|
user_id: None,
|
||||||
username: format!("user{}", i),
|
username: format!("user{i}"),
|
||||||
role: "viewer".to_string(),
|
role: "viewer".to_string(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
expires_at: now + chrono::Duration::hours(24),
|
expires_at: now + chrono::Duration::hours(24),
|
||||||
|
|
@ -301,7 +301,7 @@ async fn test_concurrent_session_operations() {
|
||||||
for i in 0..10 {
|
for i in 0..10 {
|
||||||
assert!(
|
assert!(
|
||||||
storage
|
storage
|
||||||
.get_session(&format!("concurrent_{}", i))
|
.get_session(&format!("concurrent_{i}"))
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.is_some()
|
.is_some()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue