chore: bump deps; fix clippy lints & cleanup
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I4c4815ad145650a07f108614034d2e996a6a6964
This commit is contained in:
parent
c535650f45
commit
cd1161ee5d
41 changed files with 1528 additions and 953 deletions
|
|
@ -6,6 +6,22 @@ use crate::{
|
|||
storage::DynStorageBackend,
|
||||
};
|
||||
|
||||
/// Records an audit action for a media item.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend for persistence
|
||||
/// * `media_id` - Optional media item that was affected
|
||||
/// * `action` - The action being performed
|
||||
/// * `details` - Optional additional details
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Ok(())` on success
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns errors from the storage backend
|
||||
pub async fn record_action(
|
||||
storage: &DynStorageBackend,
|
||||
media_id: Option<MediaId>,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,19 @@ use uuid::Uuid;
|
|||
|
||||
use crate::{error::Result, model::*, storage::DynStorageBackend};
|
||||
|
||||
/// Creates a new collection.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `name` - Collection name
|
||||
/// * `kind` - Manual or virtual collection
|
||||
/// * `description` - Optional description
|
||||
/// * `filter_query` - For virtual collections, the search query
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The created collection
|
||||
pub async fn create_collection(
|
||||
storage: &DynStorageBackend,
|
||||
name: &str,
|
||||
|
|
@ -14,6 +27,18 @@ pub async fn create_collection(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Adds a media item to a collection.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `collection_id` - Target collection
|
||||
/// * `media_id` - Media item to add
|
||||
/// * `position` - Position in the collection order
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Ok(())` on success
|
||||
pub async fn add_member(
|
||||
storage: &DynStorageBackend,
|
||||
collection_id: Uuid,
|
||||
|
|
@ -32,6 +57,17 @@ pub async fn add_member(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Removes a media item from a collection.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `collection_id` - Target collection
|
||||
/// * `media_id` - Media item to remove
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Ok(())` on success
|
||||
pub async fn remove_member(
|
||||
storage: &DynStorageBackend,
|
||||
collection_id: Uuid,
|
||||
|
|
@ -49,6 +85,19 @@ pub async fn remove_member(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Returns all media items in a collection.
|
||||
///
|
||||
/// Virtual collections are evaluated dynamically using their filter query.
|
||||
/// Manual collections return stored members.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `collection_id` - Collection to query
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// List of media items in the collection
|
||||
pub async fn get_members(
|
||||
storage: &DynStorageBackend,
|
||||
collection_id: Uuid,
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ pub struct Config {
|
|||
pub sync: SyncConfig,
|
||||
#[serde(default)]
|
||||
pub sharing: SharingConfig,
|
||||
#[serde(default)]
|
||||
pub trash: TrashConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -284,8 +286,6 @@ impl std::fmt::Display for UserRole {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Plugin Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginsConfig {
|
||||
#[serde(default)]
|
||||
|
|
@ -337,8 +337,6 @@ impl Default for PluginsConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Transcoding Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TranscodingConfig {
|
||||
#[serde(default)]
|
||||
|
|
@ -400,8 +398,6 @@ impl Default for TranscodingConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Enrichment Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct EnrichmentConfig {
|
||||
#[serde(default)]
|
||||
|
|
@ -432,8 +428,6 @@ pub struct EnrichmentSource {
|
|||
pub api_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
// ===== Cloud Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CloudConfig {
|
||||
#[serde(default)]
|
||||
|
|
@ -483,8 +477,6 @@ impl Default for CloudConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Analytics Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnalyticsConfig {
|
||||
#[serde(default)]
|
||||
|
|
@ -509,8 +501,6 @@ impl Default for AnalyticsConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Photo Management Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PhotoConfig {
|
||||
/// Generate perceptual hashes for image duplicate detection (CPU-intensive)
|
||||
|
|
@ -568,8 +558,6 @@ impl Default for PhotoConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Managed Storage Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManagedStorageConfig {
|
||||
/// Enable managed storage for file uploads
|
||||
|
|
@ -613,23 +601,18 @@ impl Default for ManagedStorageConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Sync Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConflictResolution {
|
||||
ServerWins,
|
||||
ClientWins,
|
||||
#[default]
|
||||
KeepBoth,
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl Default for ConflictResolution {
|
||||
fn default() -> Self {
|
||||
Self::KeepBoth
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SyncConfig {
|
||||
/// Enable cross-device sync functionality
|
||||
|
|
@ -697,8 +680,6 @@ impl Default for SyncConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Sharing Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SharingConfig {
|
||||
/// Enable sharing functionality
|
||||
|
|
@ -750,7 +731,29 @@ impl Default for SharingConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Storage Configuration =====
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrashConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_trash_retention_days")]
|
||||
pub retention_days: u64,
|
||||
#[serde(default)]
|
||||
pub auto_empty: bool,
|
||||
}
|
||||
|
||||
fn default_trash_retention_days() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
impl Default for TrashConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
retention_days: default_trash_retention_days(),
|
||||
auto_empty: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StorageConfig {
|
||||
|
|
@ -982,19 +985,19 @@ impl Config {
|
|||
|
||||
/// Ensure all directories needed by this config exist and are writable.
|
||||
pub fn ensure_dirs(&self) -> crate::error::Result<()> {
|
||||
if let Some(ref sqlite) = self.storage.sqlite {
|
||||
if let Some(parent) = sqlite.path.parent() {
|
||||
// Skip if parent is empty string (happens with bare filenames like
|
||||
// "pinakes.db")
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let metadata = std::fs::metadata(parent)?;
|
||||
if metadata.permissions().readonly() {
|
||||
return Err(crate::error::PinakesError::Config(format!(
|
||||
"directory is not writable: {}",
|
||||
parent.display()
|
||||
)));
|
||||
}
|
||||
if let Some(ref sqlite) = self.storage.sqlite
|
||||
&& let Some(parent) = sqlite.path.parent()
|
||||
{
|
||||
// Skip if parent is empty string (happens with bare filenames like
|
||||
// "pinakes.db")
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let metadata = std::fs::metadata(parent)?;
|
||||
if metadata.permissions().readonly() {
|
||||
return Err(crate::error::PinakesError::Config(format!(
|
||||
"directory is not writable: {}",
|
||||
parent.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1139,6 +1142,7 @@ impl Default for Config {
|
|||
managed_storage: ManagedStorageConfig::default(),
|
||||
sync: SyncConfig::default(),
|
||||
sharing: SharingConfig::default(),
|
||||
trash: TrashConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ pub fn detect_events(
|
|||
}
|
||||
|
||||
// Sort by date_taken
|
||||
items.sort_by(|a, b| a.date_taken.unwrap().cmp(&b.date_taken.unwrap()));
|
||||
items.sort_by_key(|a| a.date_taken.unwrap());
|
||||
|
||||
let mut events: Vec<DetectedEvent> = Vec::new();
|
||||
let mut current_event_items: Vec<MediaId> = vec![items[0].id];
|
||||
|
|
@ -181,7 +181,7 @@ pub fn detect_bursts(
|
|||
}
|
||||
|
||||
// Sort by date_taken
|
||||
items.sort_by(|a, b| a.date_taken.unwrap().cmp(&b.date_taken.unwrap()));
|
||||
items.sort_by_key(|a| a.date_taken.unwrap());
|
||||
|
||||
let mut bursts: Vec<Vec<MediaId>> = Vec::new();
|
||||
let mut current_burst: Vec<MediaId> = vec![items[0].id];
|
||||
|
|
|
|||
|
|
@ -4,6 +4,19 @@ use crate::{error::Result, model::ContentHash};
|
|||
|
||||
const BUFFER_SIZE: usize = 65536;
|
||||
|
||||
/// Computes the BLAKE3 hash of a file asynchronously.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Path to the file to hash
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The content hash
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns I/O errors or task execution errors
|
||||
pub async fn compute_file_hash(path: &Path) -> Result<ContentHash> {
|
||||
let path = path.to_path_buf();
|
||||
let hash = tokio::task::spawn_blocking(move || -> Result<ContentHash> {
|
||||
|
|
@ -24,6 +37,7 @@ pub async fn compute_file_hash(path: &Path) -> Result<ContentHash> {
|
|||
Ok(hash)
|
||||
}
|
||||
|
||||
/// Computes the BLAKE3 hash of a byte slice synchronously.
|
||||
pub fn compute_hash_sync(data: &[u8]) -> ContentHash {
|
||||
let hash = blake3::hash(data);
|
||||
ContentHash::new(hash.to_hex().to_string())
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ use crate::{
|
|||
thumbnail,
|
||||
};
|
||||
|
||||
/// Result of importing a single file.
|
||||
pub struct ImportResult {
|
||||
pub media_id: MediaId,
|
||||
pub was_duplicate: bool,
|
||||
|
|
@ -26,7 +27,7 @@ pub struct ImportResult {
|
|||
}
|
||||
|
||||
/// Options for import operations
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ImportOptions {
|
||||
/// Skip files that haven't changed since last scan (based on mtime)
|
||||
pub incremental: bool,
|
||||
|
|
@ -36,16 +37,6 @@ pub struct ImportOptions {
|
|||
pub photo_config: crate::config::PhotoConfig,
|
||||
}
|
||||
|
||||
impl Default for ImportOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
incremental: false,
|
||||
force: false,
|
||||
photo_config: crate::config::PhotoConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the modification time of a file as a Unix timestamp
|
||||
fn get_file_mtime(path: &Path) -> Option<i64> {
|
||||
std::fs::metadata(path)
|
||||
|
|
@ -55,9 +46,20 @@ fn get_file_mtime(path: &Path) -> Option<i64> {
|
|||
.map(|d| d.as_secs() as i64)
|
||||
}
|
||||
|
||||
/// Check that a canonicalized path falls under at least one configured root
|
||||
/// directory. If no roots are configured, all paths are allowed (for ad-hoc
|
||||
/// imports).
|
||||
/// Validates that a path is within configured root directories.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend to query root directories
|
||||
/// * `path` - Path to validate
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Ok(())` if path is within roots or no roots configured
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `InvalidOperation` if path is outside all root directories
|
||||
pub async fn validate_path_in_roots(
|
||||
storage: &DynStorageBackend,
|
||||
path: &Path,
|
||||
|
|
@ -79,6 +81,20 @@ pub async fn validate_path_in_roots(
|
|||
)))
|
||||
}
|
||||
|
||||
/// Imports a file using default options.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `path` - Path to the file to import
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Import result with media ID and status
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `FileNotFound` if path doesn't exist
|
||||
pub async fn import_file(
|
||||
storage: &DynStorageBackend,
|
||||
path: &Path,
|
||||
|
|
@ -236,15 +252,15 @@ pub async fn import_file_with_options(
|
|||
storage.insert_media(&item).await?;
|
||||
|
||||
// Extract and store markdown links for markdown files
|
||||
if is_markdown {
|
||||
if let Err(e) = extract_and_store_links(storage, media_id, &path).await {
|
||||
tracing::warn!(
|
||||
media_id = %media_id,
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"failed to extract markdown links"
|
||||
);
|
||||
}
|
||||
if is_markdown
|
||||
&& let Err(e) = extract_and_store_links(storage, media_id, &path).await
|
||||
{
|
||||
tracing::warn!(
|
||||
media_id = %media_id,
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"failed to extract markdown links"
|
||||
);
|
||||
}
|
||||
|
||||
// Store extracted extra metadata as custom fields
|
||||
|
|
@ -419,12 +435,10 @@ async fn extract_and_store_links(
|
|||
media_id: MediaId,
|
||||
path: &Path,
|
||||
) -> Result<()> {
|
||||
// Read file content
|
||||
let content = tokio::fs::read_to_string(path).await.map_err(|e| {
|
||||
PinakesError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("failed to read markdown file for link extraction: {e}"),
|
||||
))
|
||||
PinakesError::Io(std::io::Error::other(format!(
|
||||
"failed to read markdown file for link extraction: {e}"
|
||||
)))
|
||||
})?;
|
||||
|
||||
// Extract links
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use crate::{
|
|||
storage::DynStorageBackend,
|
||||
};
|
||||
|
||||
/// Report of orphaned, untracked, and moved files.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OrphanReport {
|
||||
/// Media items whose files no longer exist on disk.
|
||||
|
|
@ -24,6 +25,7 @@ pub struct OrphanReport {
|
|||
pub moved_files: Vec<(MediaId, PathBuf, PathBuf)>,
|
||||
}
|
||||
|
||||
/// Action to take when resolving orphans.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OrphanAction {
|
||||
|
|
@ -31,6 +33,7 @@ pub enum OrphanAction {
|
|||
Ignore,
|
||||
}
|
||||
|
||||
/// Report of file integrity verification results.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VerificationReport {
|
||||
pub verified: usize,
|
||||
|
|
@ -39,6 +42,7 @@ pub struct VerificationReport {
|
|||
pub errors: Vec<(MediaId, String)>,
|
||||
}
|
||||
|
||||
/// Status of a media item's file integrity.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IntegrityStatus {
|
||||
|
|
@ -72,9 +76,15 @@ impl std::str::FromStr for IntegrityStatus {
|
|||
}
|
||||
}
|
||||
|
||||
/// Detect orphaned media items (files that no longer exist on disk),
|
||||
/// untracked files (files on disk not in database), and moved files (same hash,
|
||||
/// different path).
|
||||
/// Detect orphaned, untracked, and moved files.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend to query
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Report containing orphaned items, untracked files, and moved files
|
||||
pub async fn detect_orphans(
|
||||
storage: &DynStorageBackend,
|
||||
) -> Result<OrphanReport> {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ pub enum JobKind {
|
|||
media_ids: Vec<MediaId>,
|
||||
},
|
||||
CleanupAnalytics,
|
||||
TrashPurge,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -167,7 +168,7 @@ impl JobQueue {
|
|||
cancel,
|
||||
};
|
||||
|
||||
// If the channel is full we still record the job — it'll stay Pending
|
||||
// If the channel is full we still record the job; it will stay Pending
|
||||
let _ = self.tx.send(item).await;
|
||||
id
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ impl ManagedStorageService {
|
|||
self.verify(hash).await?;
|
||||
}
|
||||
|
||||
fs::File::open(&path).await.map_err(|e| PinakesError::Io(e))
|
||||
fs::File::open(&path).await.map_err(PinakesError::Io)
|
||||
}
|
||||
|
||||
/// Read a blob entirely into memory.
|
||||
|
|
@ -271,11 +271,11 @@ impl ManagedStorageService {
|
|||
let mut file_entries = fs::read_dir(&sub_path).await?;
|
||||
while let Some(file_entry) = file_entries.next_entry().await? {
|
||||
let file_path = file_entry.path();
|
||||
if file_path.is_file() {
|
||||
if let Some(name) = file_path.file_name() {
|
||||
hashes
|
||||
.push(ContentHash::new(name.to_string_lossy().to_string()));
|
||||
}
|
||||
if file_path.is_file()
|
||||
&& let Some(name) = file_path.file_name()
|
||||
{
|
||||
hashes
|
||||
.push(ContentHash::new(name.to_string_lossy().to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -311,15 +311,15 @@ impl ManagedStorageService {
|
|||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
// Check if temp file is old (> 1 hour)
|
||||
if let Ok(meta) = fs::metadata(&path).await {
|
||||
if let Ok(modified) = meta.modified() {
|
||||
let age = std::time::SystemTime::now()
|
||||
.duration_since(modified)
|
||||
.unwrap_or_default();
|
||||
if age.as_secs() > 3600 {
|
||||
let _ = fs::remove_file(&path).await;
|
||||
count += 1;
|
||||
}
|
||||
if let Ok(meta) = fs::metadata(&path).await
|
||||
&& let Ok(modified) = meta.modified()
|
||||
{
|
||||
let age = std::time::SystemTime::now()
|
||||
.duration_since(modified)
|
||||
.unwrap_or_default();
|
||||
if age.as_secs() > 3600 {
|
||||
let _ = fs::remove_file(&path).await;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ use uuid::Uuid;
|
|||
|
||||
use crate::media_type::MediaType;
|
||||
|
||||
/// Unique identifier for a media item.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct MediaId(pub Uuid);
|
||||
|
||||
impl MediaId {
|
||||
/// Creates a new media ID using UUIDv7.
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::now_v7())
|
||||
}
|
||||
|
|
@ -27,10 +29,12 @@ impl Default for MediaId {
|
|||
}
|
||||
}
|
||||
|
||||
/// BLAKE3 content hash for deduplication.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ContentHash(pub String);
|
||||
|
||||
impl ContentHash {
|
||||
/// Creates a new content hash from a hex string.
|
||||
pub fn new(hex: String) -> Self {
|
||||
Self(hex)
|
||||
}
|
||||
|
|
@ -42,8 +46,6 @@ impl fmt::Display for ContentHash {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Managed Storage Types =====
|
||||
|
||||
/// Storage mode for media items
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize,
|
||||
|
|
@ -162,12 +164,14 @@ pub struct MediaItem {
|
|||
pub links_extracted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// A custom field attached to a media item.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CustomField {
|
||||
pub field_type: CustomFieldType,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Type of custom field value.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CustomFieldType {
|
||||
|
|
@ -177,6 +181,7 @@ pub enum CustomFieldType {
|
|||
Boolean,
|
||||
}
|
||||
|
||||
/// A tag that can be applied to media items.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tag {
|
||||
pub id: Uuid,
|
||||
|
|
@ -185,6 +190,7 @@ pub struct Tag {
|
|||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// A collection of media items.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Collection {
|
||||
pub id: Uuid,
|
||||
|
|
@ -196,6 +202,7 @@ pub struct Collection {
|
|||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Kind of collection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CollectionKind {
|
||||
|
|
@ -203,6 +210,7 @@ pub enum CollectionKind {
|
|||
Virtual,
|
||||
}
|
||||
|
||||
/// A member of a collection with position tracking.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CollectionMember {
|
||||
pub collection_id: Uuid,
|
||||
|
|
@ -211,6 +219,7 @@ pub struct CollectionMember {
|
|||
pub added_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// An audit trail entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuditEntry {
|
||||
pub id: Uuid,
|
||||
|
|
@ -329,6 +338,7 @@ impl fmt::Display for AuditAction {
|
|||
}
|
||||
}
|
||||
|
||||
/// Pagination parameters for list queries.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Pagination {
|
||||
pub offset: u64,
|
||||
|
|
@ -337,6 +347,7 @@ pub struct Pagination {
|
|||
}
|
||||
|
||||
impl Pagination {
|
||||
/// Creates a new pagination instance.
|
||||
pub fn new(offset: u64, limit: u64, sort: Option<String>) -> Self {
|
||||
Self {
|
||||
offset,
|
||||
|
|
@ -356,6 +367,7 @@ impl Default for Pagination {
|
|||
}
|
||||
}
|
||||
|
||||
/// A saved search query.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SavedSearch {
|
||||
pub id: Uuid,
|
||||
|
|
@ -367,6 +379,7 @@ pub struct SavedSearch {
|
|||
|
||||
// Book Management Types
|
||||
|
||||
/// Metadata for book-type media.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BookMetadata {
|
||||
pub media_id: MediaId,
|
||||
|
|
@ -385,6 +398,7 @@ pub struct BookMetadata {
|
|||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Information about a book author.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AuthorInfo {
|
||||
pub name: String,
|
||||
|
|
@ -394,6 +408,7 @@ pub struct AuthorInfo {
|
|||
}
|
||||
|
||||
impl AuthorInfo {
|
||||
/// Creates a new author with the given name.
|
||||
pub fn new(name: String) -> Self {
|
||||
Self {
|
||||
name,
|
||||
|
|
@ -403,6 +418,7 @@ impl AuthorInfo {
|
|||
}
|
||||
}
|
||||
|
||||
/// Sets the author's role.
|
||||
pub fn with_role(mut self, role: String) -> Self {
|
||||
self.role = role;
|
||||
self
|
||||
|
|
@ -435,6 +451,7 @@ pub struct ExtractedBookMetadata {
|
|||
pub identifiers: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
/// Reading progress for a book.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReadingProgress {
|
||||
pub media_id: MediaId,
|
||||
|
|
@ -446,6 +463,7 @@ pub struct ReadingProgress {
|
|||
}
|
||||
|
||||
impl ReadingProgress {
|
||||
/// Creates a new reading progress entry.
|
||||
pub fn new(
|
||||
media_id: MediaId,
|
||||
user_id: Uuid,
|
||||
|
|
@ -473,6 +491,7 @@ impl ReadingProgress {
|
|||
}
|
||||
}
|
||||
|
||||
/// Reading status for a book.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReadingStatus {
|
||||
|
|
@ -493,8 +512,6 @@ impl fmt::Display for ReadingStatus {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Markdown Links (Obsidian-style) =====
|
||||
|
||||
/// Type of markdown link
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
|
|
@ -530,7 +547,7 @@ impl std::str::FromStr for LinkType {
|
|||
}
|
||||
}
|
||||
|
||||
/// A markdown link extracted from a file
|
||||
/// A markdown link extracted from a file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MarkdownLink {
|
||||
pub id: Uuid,
|
||||
|
|
@ -549,7 +566,7 @@ pub struct MarkdownLink {
|
|||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Information about a backlink (incoming link)
|
||||
/// Information about a backlink (incoming link).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BacklinkInfo {
|
||||
pub link_id: Uuid,
|
||||
|
|
@ -562,14 +579,14 @@ pub struct BacklinkInfo {
|
|||
pub link_type: LinkType,
|
||||
}
|
||||
|
||||
/// Graph data for visualization
|
||||
/// Graph data for visualization.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct GraphData {
|
||||
pub nodes: Vec<GraphNode>,
|
||||
pub edges: Vec<GraphEdge>,
|
||||
}
|
||||
|
||||
/// A node in the graph visualization
|
||||
/// A node in the graph visualization.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphNode {
|
||||
pub id: String,
|
||||
|
|
@ -582,7 +599,7 @@ pub struct GraphNode {
|
|||
pub backlink_count: u32,
|
||||
}
|
||||
|
||||
/// An edge (link) in the graph visualization
|
||||
/// An edge (link) in the graph visualization.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphEdge {
|
||||
pub source: String,
|
||||
|
|
|
|||
|
|
@ -15,14 +15,9 @@ impl WasmRuntime {
|
|||
/// Create a new WASM runtime
|
||||
pub fn new() -> Result<Self> {
|
||||
let mut config = Config::new();
|
||||
|
||||
// Enable WASM features
|
||||
config.wasm_component_model(true);
|
||||
config.async_support(true);
|
||||
|
||||
// Set resource limits
|
||||
config.max_wasm_stack(1024 * 1024); // 1MB stack
|
||||
config.consume_fuel(true); // Enable fuel metering for CPU limits
|
||||
config.consume_fuel(true); // enable fuel metering for CPU limits
|
||||
|
||||
let engine = Engine::new(&config)?;
|
||||
|
||||
|
|
@ -39,10 +34,7 @@ impl WasmRuntime {
|
|||
return Err(anyhow!("WASM file not found: {:?}", wasm_path));
|
||||
}
|
||||
|
||||
// Read WASM bytes
|
||||
let wasm_bytes = std::fs::read(wasm_path)?;
|
||||
|
||||
// Compile module
|
||||
let module = Module::new(&self.engine, &wasm_bytes)?;
|
||||
|
||||
Ok(WasmPlugin {
|
||||
|
|
@ -82,7 +74,6 @@ impl WasmPlugin {
|
|||
) -> Result<Vec<u8>> {
|
||||
let engine = self.module.engine();
|
||||
|
||||
// Create store with per-invocation data
|
||||
let store_data = PluginStoreData {
|
||||
context: self.context.clone(),
|
||||
exchange_buffer: Vec::new(),
|
||||
|
|
@ -97,17 +88,14 @@ impl WasmPlugin {
|
|||
store.set_fuel(1_000_000_000)?;
|
||||
}
|
||||
|
||||
// Set up linker with host functions
|
||||
let mut linker = Linker::new(engine);
|
||||
HostFunctions::setup_linker(&mut linker)?;
|
||||
|
||||
// Instantiate the module
|
||||
let instance = linker.instantiate_async(&mut store, &self.module).await?;
|
||||
|
||||
// Get the memory export (if available)
|
||||
let memory = instance.get_memory(&mut store, "memory");
|
||||
|
||||
// If there are params and memory is available, write them
|
||||
// If there are params and memory is available, write them to the module
|
||||
let mut alloc_offset: i32 = 0;
|
||||
if !params.is_empty()
|
||||
&& let Some(mem) = &memory
|
||||
|
|
@ -136,7 +124,6 @@ impl WasmPlugin {
|
|||
}
|
||||
}
|
||||
|
||||
// Look up the exported function and call it
|
||||
let func =
|
||||
instance
|
||||
.get_func(&mut store, function_name)
|
||||
|
|
@ -150,9 +137,9 @@ impl WasmPlugin {
|
|||
|
||||
let mut results = vec![Val::I32(0); result_count];
|
||||
|
||||
// Call with appropriate params based on function signature
|
||||
// Call with appropriate params based on function signature; convention:
|
||||
// (ptr, len)
|
||||
if param_count == 2 && !params.is_empty() {
|
||||
// Convention: (ptr, len)
|
||||
func
|
||||
.call_async(
|
||||
&mut store,
|
||||
|
|
@ -171,13 +158,13 @@ impl WasmPlugin {
|
|||
.await?;
|
||||
}
|
||||
|
||||
// Read result from exchange buffer (host functions may have written data)
|
||||
// Prefer data written into the exchange buffer by host functions
|
||||
let exchange = std::mem::take(&mut store.data_mut().exchange_buffer);
|
||||
if !exchange.is_empty() {
|
||||
return Ok(exchange);
|
||||
}
|
||||
|
||||
// Otherwise serialize the return values
|
||||
// Fall back to serialising the WASM return value
|
||||
if let Some(Val::I32(ret)) = results.first() {
|
||||
Ok(ret.to_le_bytes().to_vec())
|
||||
} else {
|
||||
|
|
@ -208,9 +195,10 @@ impl Default for WasmPlugin {
|
|||
pub struct HostFunctions;
|
||||
|
||||
impl HostFunctions {
|
||||
/// Set up host functions in a linker
|
||||
/// Registers all host ABI functions (`host_log`, `host_read_file`,
|
||||
/// `host_write_file`, `host_http_request`, `host_get_config`,
|
||||
/// `host_get_buffer`) into the given linker.
|
||||
pub fn setup_linker(linker: &mut Linker<PluginStoreData>) -> Result<()> {
|
||||
// host_log: log a message from the plugin
|
||||
linker.func_wrap(
|
||||
"env",
|
||||
"host_log",
|
||||
|
|
@ -240,7 +228,6 @@ impl HostFunctions {
|
|||
},
|
||||
)?;
|
||||
|
||||
// host_read_file: read a file into the exchange buffer
|
||||
linker.func_wrap(
|
||||
"env",
|
||||
"host_read_file",
|
||||
|
|
@ -300,7 +287,6 @@ impl HostFunctions {
|
|||
},
|
||||
)?;
|
||||
|
||||
// host_write_file: write data to a file
|
||||
linker.func_wrap(
|
||||
"env",
|
||||
"host_write_file",
|
||||
|
|
@ -373,7 +359,6 @@ impl HostFunctions {
|
|||
},
|
||||
)?;
|
||||
|
||||
// host_http_request: make an HTTP request (blocking)
|
||||
linker.func_wrap(
|
||||
"env",
|
||||
"host_http_request",
|
||||
|
|
@ -461,7 +446,6 @@ impl HostFunctions {
|
|||
},
|
||||
)?;
|
||||
|
||||
// host_get_config: read a config key into the exchange buffer
|
||||
linker.func_wrap(
|
||||
"env",
|
||||
"host_get_config",
|
||||
|
|
@ -500,7 +484,6 @@ impl HostFunctions {
|
|||
},
|
||||
)?;
|
||||
|
||||
// host_get_buffer: copy the exchange buffer to WASM memory
|
||||
linker.func_wrap(
|
||||
"env",
|
||||
"host_get_buffer",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use tracing::{info, warn};
|
|||
|
||||
use crate::{error::Result, import, storage::DynStorageBackend};
|
||||
|
||||
/// Status of a directory scan operation.
|
||||
pub struct ScanStatus {
|
||||
pub scanning: bool,
|
||||
pub files_found: usize,
|
||||
|
|
@ -100,6 +101,17 @@ impl Default for ScanProgress {
|
|||
}
|
||||
}
|
||||
|
||||
/// Scans a directory with default options.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `dir` - Directory to scan
|
||||
/// * `ignore_patterns` - Patterns to exclude
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Scan status with counts and any errors
|
||||
pub async fn scan_directory(
|
||||
storage: &DynStorageBackend,
|
||||
dir: &Path,
|
||||
|
|
@ -115,7 +127,19 @@ pub async fn scan_directory(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Scan a directory with incremental scanning support
|
||||
/// Scans a directory with incremental scanning support.
|
||||
///
|
||||
/// Skips files that haven't changed since last scan based on mtime.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `dir` - Directory to scan
|
||||
/// * `ignore_patterns` - Patterns to exclude
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Scan status with counts and any errors
|
||||
pub async fn scan_directory_incremental(
|
||||
storage: &DynStorageBackend,
|
||||
dir: &Path,
|
||||
|
|
@ -129,6 +153,18 @@ pub async fn scan_directory_incremental(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Scans a directory with progress reporting.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `dir` - Directory to scan
|
||||
/// * `ignore_patterns` - Patterns to exclude
|
||||
/// * `progress` - Optional progress tracker
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Scan status with counts and any errors
|
||||
pub async fn scan_directory_with_progress(
|
||||
storage: &DynStorageBackend,
|
||||
dir: &Path,
|
||||
|
|
@ -230,6 +266,16 @@ pub async fn scan_directory_with_options(
|
|||
Ok(status)
|
||||
}
|
||||
|
||||
/// Scans all configured root directories with default options.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `ignore_patterns` - Patterns to exclude
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Status for each root directory
|
||||
pub async fn scan_all_roots(
|
||||
storage: &DynStorageBackend,
|
||||
ignore_patterns: &[String],
|
||||
|
|
@ -243,7 +289,16 @@ pub async fn scan_all_roots(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Scan all roots incrementally (skip unchanged files)
|
||||
/// Scans all roots incrementally, skipping unchanged files.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `ignore_patterns` - Patterns to exclude
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Status for each root directory
|
||||
pub async fn scan_all_roots_incremental(
|
||||
storage: &DynStorageBackend,
|
||||
ignore_patterns: &[String],
|
||||
|
|
@ -255,6 +310,17 @@ pub async fn scan_all_roots_incremental(
|
|||
scan_all_roots_with_options(storage, ignore_patterns, None, &options).await
|
||||
}
|
||||
|
||||
/// Scans all root directories with progress reporting.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `ignore_patterns` - Patterns to exclude
|
||||
/// * `progress` - Optional progress tracker
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Status for each root directory
|
||||
pub async fn scan_all_roots_with_progress(
|
||||
storage: &DynStorageBackend,
|
||||
ignore_patterns: &[String],
|
||||
|
|
@ -269,7 +335,18 @@ pub async fn scan_all_roots_with_progress(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Scan all roots with full options including progress and incremental mode
|
||||
/// Scans all roots with full options including progress and incremental mode.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `ignore_patterns` - Patterns to exclude
|
||||
/// * `progress` - Optional progress tracker
|
||||
/// * `scan_options` - Scan configuration
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Status for each root directory
|
||||
pub async fn scan_all_roots_with_options(
|
||||
storage: &DynStorageBackend,
|
||||
ignore_patterns: &[String],
|
||||
|
|
@ -306,12 +383,14 @@ pub async fn scan_all_roots_with_options(
|
|||
Ok(statuses)
|
||||
}
|
||||
|
||||
/// Watches directories for file changes and imports modified files.
|
||||
pub struct FileWatcher {
|
||||
_watcher: Box<dyn Watcher + Send>,
|
||||
rx: mpsc::Receiver<PathBuf>,
|
||||
}
|
||||
|
||||
impl FileWatcher {
|
||||
/// Creates a new file watcher for the given directories.
|
||||
pub fn new(dirs: &[PathBuf]) -> Result<Self> {
|
||||
let (tx, rx) = mpsc::channel(1024);
|
||||
|
||||
|
|
@ -393,11 +472,13 @@ impl FileWatcher {
|
|||
Ok(Box::new(watcher))
|
||||
}
|
||||
|
||||
/// Receives the next changed file path.
|
||||
pub async fn next_change(&mut self) -> Option<PathBuf> {
|
||||
self.rx.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
/// Watches directories and imports files on change.
|
||||
pub async fn watch_and_import(
|
||||
storage: DynStorageBackend,
|
||||
dirs: Vec<PathBuf>,
|
||||
|
|
|
|||
|
|
@ -200,6 +200,22 @@ impl TaskScheduler {
|
|||
running: false,
|
||||
last_job_id: None,
|
||||
},
|
||||
ScheduledTask {
|
||||
id: "trash_purge".to_string(),
|
||||
name: "Trash Purge".to_string(),
|
||||
kind: JobKind::TrashPurge,
|
||||
schedule: Schedule::Weekly {
|
||||
day: 0,
|
||||
hour: 3,
|
||||
minute: 0,
|
||||
},
|
||||
enabled: false,
|
||||
last_run: None,
|
||||
next_run: None,
|
||||
last_status: None,
|
||||
running: false,
|
||||
last_job_id: None,
|
||||
},
|
||||
];
|
||||
|
||||
Self {
|
||||
|
|
@ -404,6 +420,7 @@ mod tests {
|
|||
use chrono::TimeZone;
|
||||
|
||||
use super::*;
|
||||
use crate::config::TrashConfig;
|
||||
|
||||
#[test]
|
||||
fn test_interval_next_run() {
|
||||
|
|
@ -453,7 +470,7 @@ mod tests {
|
|||
#[test]
|
||||
fn test_weekly_same_day_future() {
|
||||
// 2025-06-15 is Sunday (day 6). Schedule is Sunday 14:00, current is 10:00
|
||||
// => today.
|
||||
|
||||
let from = Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap();
|
||||
let schedule = Schedule::Weekly {
|
||||
day: 6,
|
||||
|
|
@ -467,7 +484,7 @@ mod tests {
|
|||
#[test]
|
||||
fn test_weekly_same_day_past() {
|
||||
// 2025-06-15 is Sunday (day 6). Schedule is Sunday 08:00, current is 10:00
|
||||
// => next week.
|
||||
|
||||
let from = Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap();
|
||||
let schedule = Schedule::Weekly {
|
||||
day: 6,
|
||||
|
|
@ -545,4 +562,152 @@ mod tests {
|
|||
"Sun 14:30"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trash_purge_job_kind_serde() {
|
||||
let job = JobKind::TrashPurge;
|
||||
let json = serde_json::to_string(&job).unwrap();
|
||||
assert_eq!(json, r#"{"type":"trash_purge"}"#);
|
||||
|
||||
let deserialized: JobKind = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(deserialized, JobKind::TrashPurge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trash_purge_scheduled_task_defaults() {
|
||||
let task = ScheduledTask {
|
||||
id: "trash_purge".to_string(),
|
||||
name: "Trash Purge".to_string(),
|
||||
kind: JobKind::TrashPurge,
|
||||
schedule: Schedule::Weekly {
|
||||
day: 0,
|
||||
hour: 3,
|
||||
minute: 0,
|
||||
},
|
||||
enabled: false,
|
||||
last_run: None,
|
||||
next_run: None,
|
||||
last_status: None,
|
||||
running: false,
|
||||
last_job_id: None,
|
||||
};
|
||||
|
||||
assert_eq!(task.id, "trash_purge");
|
||||
assert_eq!(task.name, "Trash Purge");
|
||||
assert!(matches!(task.kind, JobKind::TrashPurge));
|
||||
assert!(!task.enabled);
|
||||
assert!(!task.running);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_default_tasks_contain_trash_purge() {
|
||||
let cancel = CancellationToken::new();
|
||||
let config = Arc::new(RwLock::new(Config::default()));
|
||||
let job_queue = JobQueue::new(1, |_, _, _, _| tokio::spawn(async move {}));
|
||||
|
||||
let scheduler = TaskScheduler::new(job_queue, cancel, config, None);
|
||||
let tasks = scheduler.list_tasks().await;
|
||||
|
||||
let trash_task = tasks.iter().find(|t| t.id == "trash_purge");
|
||||
assert!(
|
||||
trash_task.is_some(),
|
||||
"trash_purge task should be in default tasks"
|
||||
);
|
||||
|
||||
let task = trash_task.unwrap();
|
||||
assert_eq!(task.id, "trash_purge");
|
||||
assert_eq!(task.name, "Trash Purge");
|
||||
assert!(matches!(task.kind, JobKind::TrashPurge));
|
||||
assert!(!task.enabled, "trash_purge should be disabled by default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trash_purge_serde_roundtrip() {
|
||||
let task = ScheduledTask {
|
||||
id: "trash_purge".to_string(),
|
||||
name: "Trash Purge".to_string(),
|
||||
kind: JobKind::TrashPurge,
|
||||
schedule: Schedule::Weekly {
|
||||
day: 0,
|
||||
hour: 3,
|
||||
minute: 0,
|
||||
},
|
||||
enabled: true,
|
||||
last_run: Some(Utc.with_ymd_and_hms(2025, 1, 15, 10, 0, 0).unwrap()),
|
||||
next_run: Some(Utc.with_ymd_and_hms(2025, 1, 19, 3, 0, 0).unwrap()),
|
||||
last_status: Some("completed".to_string()),
|
||||
running: false,
|
||||
last_job_id: Some(Uuid::now_v7()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&task).unwrap();
|
||||
let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(deserialized.id, "trash_purge");
|
||||
assert_eq!(deserialized.enabled, true);
|
||||
assert!(!deserialized.running);
|
||||
assert!(deserialized.last_job_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_job_kinds_serde() {
|
||||
let kinds: Vec<JobKind> = vec![
|
||||
JobKind::Scan { path: None },
|
||||
JobKind::Scan {
|
||||
path: Some(PathBuf::from("/test")),
|
||||
},
|
||||
JobKind::GenerateThumbnails { media_ids: vec![] },
|
||||
JobKind::VerifyIntegrity { media_ids: vec![] },
|
||||
JobKind::OrphanDetection,
|
||||
JobKind::CleanupThumbnails,
|
||||
JobKind::TrashPurge,
|
||||
];
|
||||
|
||||
for kind in kinds {
|
||||
let json = serde_json::to_string(&kind).unwrap();
|
||||
let deserialized: JobKind = serde_json::from_str(&json).unwrap();
|
||||
assert!(
|
||||
matches!(deserialized, JobKind::Scan { path: None })
|
||||
|| matches!(deserialized, JobKind::Scan { path: Some(_) })
|
||||
|| matches!(deserialized, JobKind::GenerateThumbnails { .. })
|
||||
|| matches!(deserialized, JobKind::VerifyIntegrity { .. })
|
||||
|| matches!(deserialized, JobKind::OrphanDetection)
|
||||
|| matches!(deserialized, JobKind::CleanupThumbnails)
|
||||
|| matches!(deserialized, JobKind::TrashPurge)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_task_serde_skips_runtime_fields() {
|
||||
let task = ScheduledTask {
|
||||
id: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
kind: JobKind::TrashPurge,
|
||||
schedule: Schedule::Daily {
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
},
|
||||
enabled: true,
|
||||
last_run: Some(Utc::now()),
|
||||
next_run: Some(Utc::now()),
|
||||
last_status: Some("running".to_string()),
|
||||
running: true,
|
||||
last_job_id: Some(Uuid::now_v7()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&task).unwrap();
|
||||
let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(deserialized.running, false);
|
||||
assert!(deserialized.last_job_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trash_config_defaults() {
|
||||
let config = TrashConfig::default();
|
||||
assert!(!config.enabled);
|
||||
assert_eq!(config.retention_days, 30);
|
||||
assert!(!config.auto_empty);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use winnow::{
|
|||
token::{take_till, take_while},
|
||||
};
|
||||
|
||||
/// Represents a parsed search query.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SearchQuery {
|
||||
FullText(String),
|
||||
|
|
@ -39,6 +40,7 @@ pub enum SearchQuery {
|
|||
},
|
||||
}
|
||||
|
||||
/// Comparison operators for range queries.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CompareOp {
|
||||
GreaterThan,
|
||||
|
|
@ -47,6 +49,7 @@ pub enum CompareOp {
|
|||
LessOrEqual,
|
||||
}
|
||||
|
||||
/// Date values for date-based queries.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DateValue {
|
||||
Today,
|
||||
|
|
@ -61,6 +64,7 @@ pub enum DateValue {
|
|||
DaysAgo(u32),
|
||||
}
|
||||
|
||||
/// Request for executing a search.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchRequest {
|
||||
pub query: SearchQuery,
|
||||
|
|
@ -68,12 +72,14 @@ pub struct SearchRequest {
|
|||
pub pagination: crate::model::Pagination,
|
||||
}
|
||||
|
||||
/// Results of a search operation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchResults {
|
||||
pub items: Vec<crate::model::MediaItem>,
|
||||
pub total_count: u64,
|
||||
}
|
||||
|
||||
/// Sorting options for search results.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Default)]
|
||||
|
|
@ -139,19 +145,25 @@ fn parse_date_value(s: &str) -> Option<DateValue> {
|
|||
}
|
||||
|
||||
/// Parse size strings like "10MB", "1GB", "500KB" to bytes
|
||||
///
|
||||
/// Returns `None` if the input is invalid or if the value would overflow.
|
||||
fn parse_size_value(s: &str) -> Option<i64> {
|
||||
let s = s.to_uppercase();
|
||||
if let Some(num) = s.strip_suffix("GB") {
|
||||
num.parse::<i64>().ok().map(|n| n * 1024 * 1024 * 1024)
|
||||
} else if let Some(num) = s.strip_suffix("MB") {
|
||||
num.parse::<i64>().ok().map(|n| n * 1024 * 1024)
|
||||
} else if let Some(num) = s.strip_suffix("KB") {
|
||||
num.parse::<i64>().ok().map(|n| n * 1024)
|
||||
} else if let Some(num) = s.strip_suffix('B') {
|
||||
num.parse::<i64>().ok()
|
||||
let (num_str, multiplier): (&str, i64) = if let Some(n) = s.strip_suffix("GB")
|
||||
{
|
||||
(n, 1024 * 1024 * 1024)
|
||||
} else if let Some(n) = s.strip_suffix("MB") {
|
||||
(n, 1024 * 1024)
|
||||
} else if let Some(n) = s.strip_suffix("KB") {
|
||||
(n, 1024)
|
||||
} else if let Some(n) = s.strip_suffix('B') {
|
||||
(n, 1)
|
||||
} else {
|
||||
s.parse::<i64>().ok()
|
||||
}
|
||||
(s.as_str(), 1)
|
||||
};
|
||||
|
||||
let num: i64 = num_str.parse().ok()?;
|
||||
num.checked_mul(multiplier)
|
||||
}
|
||||
|
||||
fn field_match(input: &mut &str) -> ModalResult<SearchQuery> {
|
||||
|
|
@ -332,6 +344,22 @@ fn or_expr(input: &mut &str) -> ModalResult<SearchQuery> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Parses a search query string into a structured query.
|
||||
///
|
||||
/// Supports full-text search, field matches, operators (AND/OR/NOT),
|
||||
/// prefixes, fuzzy matching, and type/tag filters.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `input` - Raw query string
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Parsed query tree
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `SearchParse` error for invalid syntax
|
||||
pub fn parse_search_query(input: &str) -> crate::error::Result<SearchQuery> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
|
|
|
|||
|
|
@ -12,13 +12,14 @@ use chrono::{DateTime, Utc};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{model::MediaId, users::UserId};
|
||||
use crate::{error::PinakesError, model::MediaId, users::UserId};
|
||||
|
||||
/// Unique identifier for a share.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ShareId(pub Uuid);
|
||||
|
||||
impl ShareId {
|
||||
/// Creates a new share ID.
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::now_v7())
|
||||
}
|
||||
|
|
@ -47,6 +48,7 @@ pub enum ShareTarget {
|
|||
}
|
||||
|
||||
impl ShareTarget {
|
||||
/// Returns the type of target being shared.
|
||||
pub fn target_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Media { .. } => "media",
|
||||
|
|
@ -56,6 +58,7 @@ impl ShareTarget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Returns the ID of the target being shared.
|
||||
pub fn target_id(&self) -> Uuid {
|
||||
match self {
|
||||
Self::Media { media_id } => media_id.0,
|
||||
|
|
@ -87,6 +90,7 @@ pub enum ShareRecipient {
|
|||
}
|
||||
|
||||
impl ShareRecipient {
|
||||
/// Returns the type of recipient.
|
||||
pub fn recipient_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::PublicLink { .. } => "public_link",
|
||||
|
|
@ -117,7 +121,7 @@ pub struct SharePermissions {
|
|||
}
|
||||
|
||||
impl SharePermissions {
|
||||
/// View-only permissions
|
||||
/// Creates a new share with view-only permissions.
|
||||
pub fn view_only() -> Self {
|
||||
Self {
|
||||
can_view: true,
|
||||
|
|
@ -125,7 +129,7 @@ impl SharePermissions {
|
|||
}
|
||||
}
|
||||
|
||||
/// Download permissions (includes view)
|
||||
/// Creates a new share with download permissions.
|
||||
pub fn download() -> Self {
|
||||
Self {
|
||||
can_view: true,
|
||||
|
|
@ -134,7 +138,7 @@ impl SharePermissions {
|
|||
}
|
||||
}
|
||||
|
||||
/// Edit permissions (includes view and download)
|
||||
/// Creates a new share with edit permissions.
|
||||
pub fn edit() -> Self {
|
||||
Self {
|
||||
can_view: true,
|
||||
|
|
@ -145,7 +149,7 @@ impl SharePermissions {
|
|||
}
|
||||
}
|
||||
|
||||
/// Full permissions
|
||||
/// Creates a new share with full permissions.
|
||||
pub fn full() -> Self {
|
||||
Self {
|
||||
can_view: true,
|
||||
|
|
@ -157,7 +161,7 @@ impl SharePermissions {
|
|||
}
|
||||
}
|
||||
|
||||
/// Merge permissions (takes the most permissive of each)
|
||||
/// Merges two permission sets, taking the most permissive values.
|
||||
pub fn merge(&self, other: &Self) -> Self {
|
||||
Self {
|
||||
can_view: self.can_view || other.can_view,
|
||||
|
|
@ -246,17 +250,17 @@ impl Share {
|
|||
}
|
||||
}
|
||||
|
||||
/// Check if the share has expired.
|
||||
/// Checks if the share has expired.
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.expires_at.map(|exp| exp < Utc::now()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if this is a public link share.
|
||||
/// Checks if this is a public link share.
|
||||
pub fn is_public(&self) -> bool {
|
||||
matches!(self.recipient, ShareRecipient::PublicLink { .. })
|
||||
}
|
||||
|
||||
/// Get the public token if this is a public link share.
|
||||
/// Returns the public token if this is a public link share.
|
||||
pub fn public_token(&self) -> Option<&str> {
|
||||
match &self.recipient {
|
||||
ShareRecipient::PublicLink { token, .. } => Some(token),
|
||||
|
|
@ -322,6 +326,7 @@ pub struct ShareActivity {
|
|||
}
|
||||
|
||||
impl ShareActivity {
|
||||
/// Creates a new share activity entry.
|
||||
pub fn new(share_id: ShareId, action: ShareActivityAction) -> Self {
|
||||
Self {
|
||||
id: Uuid::now_v7(),
|
||||
|
|
@ -334,16 +339,19 @@ impl ShareActivity {
|
|||
}
|
||||
}
|
||||
|
||||
/// Sets the actor who performed the activity.
|
||||
pub fn with_actor(mut self, actor_id: UserId) -> Self {
|
||||
self.actor_id = Some(actor_id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the IP address of the actor.
|
||||
pub fn with_ip(mut self, ip: &str) -> Self {
|
||||
self.actor_ip = Some(ip.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets additional details about the activity.
|
||||
pub fn with_details(mut self, details: &str) -> Self {
|
||||
self.details = Some(details.to_string());
|
||||
self
|
||||
|
|
@ -400,6 +408,7 @@ pub struct ShareNotification {
|
|||
}
|
||||
|
||||
impl ShareNotification {
|
||||
/// Creates a new share notification.
|
||||
pub fn new(
|
||||
user_id: UserId,
|
||||
share_id: ShareId,
|
||||
|
|
@ -416,20 +425,18 @@ impl ShareNotification {
|
|||
}
|
||||
}
|
||||
|
||||
/// Generate a random share token using UUID.
|
||||
/// Generates a random share token.
|
||||
pub fn generate_share_token() -> String {
|
||||
// Use UUIDv4 for random tokens - simple string representation
|
||||
Uuid::new_v4().simple().to_string()
|
||||
}
|
||||
|
||||
/// Hash a share password.
|
||||
pub fn hash_share_password(password: &str) -> String {
|
||||
// Use BLAKE3 for password hashing (in production, use Argon2)
|
||||
blake3::hash(password.as_bytes()).to_hex().to_string()
|
||||
/// Hashes a share password using Argon2id.
|
||||
pub fn hash_share_password(password: &str) -> Result<String, PinakesError> {
|
||||
crate::users::auth::hash_password(password)
|
||||
}
|
||||
|
||||
/// Verify a share password.
|
||||
/// Verifies a share password against an Argon2id hash.
|
||||
pub fn verify_share_password(password: &str, hash: &str) -> bool {
|
||||
let computed = hash_share_password(password);
|
||||
computed == hash
|
||||
crate::users::auth::verify_password(password, hash).unwrap_or(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,29 +142,13 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
) -> Result<()>;
|
||||
|
||||
// Batch operations (transactional where supported)
|
||||
async fn batch_delete_media(&self, ids: &[MediaId]) -> Result<u64> {
|
||||
let mut count = 0u64;
|
||||
for id in ids {
|
||||
self.delete_media(*id).await?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
async fn batch_delete_media(&self, ids: &[MediaId]) -> Result<u64>;
|
||||
|
||||
async fn batch_tag_media(
|
||||
&self,
|
||||
media_ids: &[MediaId],
|
||||
tag_ids: &[Uuid],
|
||||
) -> Result<u64> {
|
||||
let mut count = 0u64;
|
||||
for media_id in media_ids {
|
||||
for tag_id in tag_ids {
|
||||
self.tag_media(*media_id, *tag_id).await?;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
) -> Result<u64>;
|
||||
|
||||
// Integrity
|
||||
async fn list_media_paths(
|
||||
|
|
@ -342,7 +326,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Ratings =====
|
||||
async fn rate_media(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
|
|
@ -358,7 +341,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
) -> Result<Option<Rating>>;
|
||||
async fn delete_rating(&self, id: Uuid) -> Result<()>;
|
||||
|
||||
// ===== Comments =====
|
||||
async fn add_comment(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
|
|
@ -370,7 +352,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
-> Result<Vec<Comment>>;
|
||||
async fn delete_comment(&self, id: Uuid) -> Result<()>;
|
||||
|
||||
// ===== Favorites =====
|
||||
async fn add_favorite(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
|
|
@ -392,7 +373,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
media_id: MediaId,
|
||||
) -> Result<bool>;
|
||||
|
||||
// ===== Share Links =====
|
||||
async fn create_share_link(
|
||||
&self,
|
||||
media_id: MediaId,
|
||||
|
|
@ -405,7 +385,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
async fn increment_share_views(&self, token: &str) -> Result<()>;
|
||||
async fn delete_share_link(&self, id: Uuid) -> Result<()>;
|
||||
|
||||
// ===== Playlists =====
|
||||
async fn create_playlist(
|
||||
&self,
|
||||
owner_id: UserId,
|
||||
|
|
@ -450,7 +429,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
new_position: i32,
|
||||
) -> Result<()>;
|
||||
|
||||
// ===== Analytics =====
|
||||
async fn record_usage_event(&self, event: &UsageEvent) -> Result<()>;
|
||||
async fn get_usage_events(
|
||||
&self,
|
||||
|
|
@ -477,7 +455,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
) -> Result<Option<f64>>;
|
||||
async fn cleanup_old_events(&self, before: DateTime<Utc>) -> Result<u64>;
|
||||
|
||||
// ===== Subtitles =====
|
||||
async fn add_subtitle(&self, subtitle: &Subtitle) -> Result<()>;
|
||||
async fn get_media_subtitles(
|
||||
&self,
|
||||
|
|
@ -490,7 +467,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
offset_ms: i64,
|
||||
) -> Result<()>;
|
||||
|
||||
// ===== External Metadata (Enrichment) =====
|
||||
async fn store_external_metadata(
|
||||
&self,
|
||||
meta: &ExternalMetadata,
|
||||
|
|
@ -501,7 +477,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
) -> Result<Vec<ExternalMetadata>>;
|
||||
async fn delete_external_metadata(&self, id: Uuid) -> Result<()>;
|
||||
|
||||
// ===== Transcode Sessions =====
|
||||
async fn create_transcode_session(
|
||||
&self,
|
||||
session: &TranscodeSession,
|
||||
|
|
@ -522,7 +497,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
before: DateTime<Utc>,
|
||||
) -> Result<u64>;
|
||||
|
||||
// ===== Session Management =====
|
||||
/// Create a new session in the database
|
||||
async fn create_session(&self, session: &SessionData) -> Result<()>;
|
||||
|
||||
|
|
@ -623,8 +597,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
pagination: &Pagination,
|
||||
) -> Result<Vec<MediaItem>>;
|
||||
|
||||
// ===== Managed Storage =====
|
||||
|
||||
/// Insert a media item that uses managed storage
|
||||
async fn insert_managed_media(&self, item: &MediaItem) -> Result<()>;
|
||||
|
||||
|
|
@ -658,8 +630,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
/// Get managed storage statistics
|
||||
async fn managed_storage_stats(&self) -> Result<ManagedStorageStats>;
|
||||
|
||||
// ===== Sync Devices =====
|
||||
|
||||
/// Register a new sync device
|
||||
async fn register_device(
|
||||
&self,
|
||||
|
|
@ -695,8 +665,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
/// Update the last_seen_at timestamp for a device
|
||||
async fn touch_device(&self, id: crate::sync::DeviceId) -> Result<()>;
|
||||
|
||||
// ===== Sync Log =====
|
||||
|
||||
/// Record a change in the sync log
|
||||
async fn record_sync_change(
|
||||
&self,
|
||||
|
|
@ -716,8 +684,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
/// Clean up old sync log entries
|
||||
async fn cleanup_old_sync_log(&self, before: DateTime<Utc>) -> Result<u64>;
|
||||
|
||||
// ===== Device Sync State =====
|
||||
|
||||
/// Get sync state for a device and path
|
||||
async fn get_device_sync_state(
|
||||
&self,
|
||||
|
|
@ -737,8 +703,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
device_id: crate::sync::DeviceId,
|
||||
) -> Result<Vec<crate::sync::DeviceSyncState>>;
|
||||
|
||||
// ===== Upload Sessions (Chunked Uploads) =====
|
||||
|
||||
/// Create a new upload session
|
||||
async fn create_upload_session(
|
||||
&self,
|
||||
|
|
@ -773,8 +737,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
/// Clean up expired upload sessions
|
||||
async fn cleanup_expired_uploads(&self) -> Result<u64>;
|
||||
|
||||
// ===== Sync Conflicts =====
|
||||
|
||||
/// Record a sync conflict
|
||||
async fn record_conflict(
|
||||
&self,
|
||||
|
|
@ -794,8 +756,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
resolution: crate::config::ConflictResolution,
|
||||
) -> Result<()>;
|
||||
|
||||
// ===== Enhanced Sharing =====
|
||||
|
||||
/// Create a new share
|
||||
async fn create_share(
|
||||
&self,
|
||||
|
|
@ -872,8 +832,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
/// Clean up expired shares
|
||||
async fn cleanup_expired_shares(&self) -> Result<u64>;
|
||||
|
||||
// ===== Share Activity =====
|
||||
|
||||
/// Record share activity
|
||||
async fn record_share_activity(
|
||||
&self,
|
||||
|
|
@ -887,8 +845,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
pagination: &Pagination,
|
||||
) -> Result<Vec<crate::sharing::ShareActivity>>;
|
||||
|
||||
// ===== Share Notifications =====
|
||||
|
||||
/// Create a share notification
|
||||
async fn create_share_notification(
|
||||
&self,
|
||||
|
|
@ -907,8 +863,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
/// Mark all notifications as read for a user
|
||||
async fn mark_all_notifications_read(&self, user_id: UserId) -> Result<()>;
|
||||
|
||||
// ===== File Management =====
|
||||
|
||||
/// Rename a media item (changes file_name and updates path accordingly).
|
||||
/// For external storage, this actually renames the file on disk.
|
||||
/// For managed storage, this only updates the metadata.
|
||||
|
|
@ -939,8 +893,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
Ok(results)
|
||||
}
|
||||
|
||||
// ===== Trash / Soft Delete =====
|
||||
|
||||
/// Soft delete a media item (set deleted_at timestamp).
|
||||
async fn soft_delete_media(&self, id: MediaId) -> Result<()>;
|
||||
|
||||
|
|
@ -960,8 +912,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
/// Count items in trash.
|
||||
async fn count_trash(&self) -> Result<u64>;
|
||||
|
||||
// ===== Markdown Links (Obsidian-style) =====
|
||||
|
||||
/// Save extracted markdown links for a media item.
|
||||
/// This replaces any existing links for the source media.
|
||||
async fn save_markdown_links(
|
||||
|
|
|
|||
|
|
@ -583,8 +583,7 @@ impl StorageBackend for PostgresBackend {
|
|||
crate::storage::migrations::run_postgres_migrations(client).await
|
||||
}
|
||||
|
||||
// ---- Root directories ----
|
||||
|
||||
// Root directories
|
||||
async fn add_root_dir(&self, path: PathBuf) -> Result<()> {
|
||||
let client = self
|
||||
.pool
|
||||
|
|
@ -638,8 +637,7 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Media CRUD ----
|
||||
|
||||
// Media CRUD
|
||||
async fn insert_media(&self, item: &MediaItem) -> Result<()> {
|
||||
let client = self
|
||||
.pool
|
||||
|
|
@ -1032,8 +1030,7 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(count as u64)
|
||||
}
|
||||
|
||||
// ---- Batch Operations ----
|
||||
|
||||
// Batch Operations
|
||||
async fn batch_delete_media(&self, ids: &[MediaId]) -> Result<u64> {
|
||||
if ids.is_empty() {
|
||||
return Ok(0);
|
||||
|
|
@ -1089,8 +1086,7 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(rows)
|
||||
}
|
||||
|
||||
// ---- Tags ----
|
||||
|
||||
// Tags
|
||||
async fn create_tag(
|
||||
&self,
|
||||
name: &str,
|
||||
|
|
@ -1257,8 +1253,7 @@ impl StorageBackend for PostgresBackend {
|
|||
rows.iter().map(row_to_tag).collect()
|
||||
}
|
||||
|
||||
// ---- Collections ----
|
||||
|
||||
// Collections
|
||||
async fn create_collection(
|
||||
&self,
|
||||
name: &str,
|
||||
|
|
@ -1499,8 +1494,7 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(items)
|
||||
}
|
||||
|
||||
// ---- Search ----
|
||||
|
||||
// Search
|
||||
async fn search(&self, request: &SearchRequest) -> Result<SearchResults> {
|
||||
let client = self
|
||||
.pool
|
||||
|
|
@ -1666,8 +1660,7 @@ impl StorageBackend for PostgresBackend {
|
|||
})
|
||||
}
|
||||
|
||||
// ---- Audit ----
|
||||
|
||||
// Audit
|
||||
async fn record_audit(&self, entry: &AuditEntry) -> Result<()> {
|
||||
let client = self
|
||||
.pool
|
||||
|
|
@ -1739,8 +1732,7 @@ impl StorageBackend for PostgresBackend {
|
|||
rows.iter().map(row_to_audit_entry).collect()
|
||||
}
|
||||
|
||||
// ---- Custom fields ----
|
||||
|
||||
// Custom fields
|
||||
async fn set_custom_field(
|
||||
&self,
|
||||
media_id: MediaId,
|
||||
|
|
@ -1821,8 +1813,7 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Duplicates ----
|
||||
|
||||
// Duplicates
|
||||
async fn find_duplicates(&self) -> Result<Vec<Vec<MediaItem>>> {
|
||||
let client = self
|
||||
.pool
|
||||
|
|
@ -2007,8 +1998,7 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(groups)
|
||||
}
|
||||
|
||||
// ---- Database management ----
|
||||
|
||||
// Database management
|
||||
async fn database_stats(&self) -> Result<crate::storage::DatabaseStats> {
|
||||
let client = self
|
||||
.pool
|
||||
|
|
@ -2524,7 +2514,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Ratings =====
|
||||
async fn rate_media(
|
||||
&self,
|
||||
user_id: crate::users::UserId,
|
||||
|
|
@ -2635,7 +2624,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Comments =====
|
||||
async fn add_comment(
|
||||
&self,
|
||||
user_id: crate::users::UserId,
|
||||
|
|
@ -2712,7 +2700,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Favorites =====
|
||||
async fn add_favorite(
|
||||
&self,
|
||||
user_id: crate::users::UserId,
|
||||
|
|
@ -2838,7 +2825,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(count > 0)
|
||||
}
|
||||
|
||||
// ===== Share Links =====
|
||||
async fn create_share_link(
|
||||
&self,
|
||||
media_id: MediaId,
|
||||
|
|
@ -2942,7 +2928,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Playlists =====
|
||||
async fn create_playlist(
|
||||
&self,
|
||||
owner_id: crate::users::UserId,
|
||||
|
|
@ -3250,7 +3235,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Analytics =====
|
||||
async fn record_usage_event(
|
||||
&self,
|
||||
event: &crate::analytics::UsageEvent,
|
||||
|
|
@ -3540,7 +3524,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(affected)
|
||||
}
|
||||
|
||||
// ===== Subtitles =====
|
||||
async fn add_subtitle(
|
||||
&self,
|
||||
subtitle: &crate::subtitles::Subtitle,
|
||||
|
|
@ -3652,7 +3635,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== External Metadata (Enrichment) =====
|
||||
async fn store_external_metadata(
|
||||
&self,
|
||||
meta: &crate::enrichment::ExternalMetadata,
|
||||
|
|
@ -3742,7 +3724,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Transcode Sessions =====
|
||||
async fn create_transcode_session(
|
||||
&self,
|
||||
session: &crate::transcode::TranscodeSession,
|
||||
|
|
@ -3930,8 +3911,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(affected)
|
||||
}
|
||||
|
||||
// ===== Session Management =====
|
||||
|
||||
async fn create_session(
|
||||
&self,
|
||||
session: &crate::storage::SessionData,
|
||||
|
|
@ -4666,10 +4645,6 @@ impl StorageBackend for PostgresBackend {
|
|||
items
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Managed Storage
|
||||
// =========================================================================
|
||||
|
||||
async fn insert_managed_media(&self, item: &MediaItem) -> Result<()> {
|
||||
let client = self.pool.get().await.map_err(|e| {
|
||||
PinakesError::Database(format!("failed to get connection: {e}"))
|
||||
|
|
@ -4967,10 +4942,6 @@ impl StorageBackend for PostgresBackend {
|
|||
})
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Sync Devices
|
||||
// =========================================================================
|
||||
|
||||
async fn register_device(
|
||||
&self,
|
||||
device: &crate::sync::SyncDevice,
|
||||
|
|
@ -5188,10 +5159,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Sync Log
|
||||
// =========================================================================
|
||||
|
||||
async fn record_sync_change(
|
||||
&self,
|
||||
change: &crate::sync::SyncLogEntry,
|
||||
|
|
@ -5310,10 +5277,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(result)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Device Sync State
|
||||
// =========================================================================
|
||||
|
||||
async fn get_device_sync_state(
|
||||
&self,
|
||||
device_id: crate::sync::DeviceId,
|
||||
|
|
@ -5437,10 +5400,6 @@ impl StorageBackend for PostgresBackend {
|
|||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Upload Sessions
|
||||
// =========================================================================
|
||||
|
||||
async fn create_upload_session(
|
||||
&self,
|
||||
session: &crate::sync::UploadSession,
|
||||
|
|
@ -5618,10 +5577,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(result)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Sync Conflicts
|
||||
// =========================================================================
|
||||
|
||||
async fn record_conflict(
|
||||
&self,
|
||||
conflict: &crate::sync::SyncConflict,
|
||||
|
|
@ -5737,10 +5692,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Shares
|
||||
// =========================================================================
|
||||
|
||||
async fn create_share(
|
||||
&self,
|
||||
share: &crate::sharing::Share,
|
||||
|
|
@ -6050,10 +6001,10 @@ impl StorageBackend for PostgresBackend {
|
|||
|
||||
for share in shares {
|
||||
// Skip expired shares
|
||||
if let Some(exp) = share.expires_at {
|
||||
if exp < now {
|
||||
continue;
|
||||
}
|
||||
if let Some(exp) = share.expires_at
|
||||
&& exp < now
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
match (&share.recipient, user_id) {
|
||||
|
|
@ -6167,10 +6118,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(result)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Share Activity
|
||||
// =========================================================================
|
||||
|
||||
async fn record_share_activity(
|
||||
&self,
|
||||
activity: &crate::sharing::ShareActivity,
|
||||
|
|
@ -6244,10 +6191,6 @@ impl StorageBackend for PostgresBackend {
|
|||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Share Notifications
|
||||
// =========================================================================
|
||||
|
||||
async fn create_share_notification(
|
||||
&self,
|
||||
notification: &crate::sharing::ShareNotification,
|
||||
|
|
@ -6349,8 +6292,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== File Management =====
|
||||
|
||||
async fn rename_media(&self, id: MediaId, new_name: &str) -> Result<String> {
|
||||
// Validate the new name
|
||||
if new_name.is_empty() || new_name.contains('/') || new_name.contains('\\')
|
||||
|
|
@ -6468,8 +6409,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(old_path)
|
||||
}
|
||||
|
||||
// ===== Trash / Soft Delete =====
|
||||
|
||||
async fn soft_delete_media(&self, id: MediaId) -> Result<()> {
|
||||
let client = self
|
||||
.pool
|
||||
|
|
@ -6671,8 +6610,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(count as u64)
|
||||
}
|
||||
|
||||
// ===== Markdown Links (Obsidian-style) =====
|
||||
|
||||
async fn save_markdown_links(
|
||||
&self,
|
||||
media_id: MediaId,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -204,17 +204,16 @@ impl ChunkedUploadManager {
|
|||
let mut entries = fs::read_dir(&self.temp_dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "upload").unwrap_or(false) {
|
||||
if let Ok(metadata) = fs::metadata(&path).await {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
let age = std::time::SystemTime::now()
|
||||
.duration_since(modified)
|
||||
.unwrap_or_default();
|
||||
if age > max_age {
|
||||
let _ = fs::remove_file(&path).await;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if path.extension().map(|e| e == "upload").unwrap_or(false)
|
||||
&& let Ok(metadata) = fs::metadata(&path).await
|
||||
&& let Ok(modified) = metadata.modified()
|
||||
{
|
||||
let age = std::time::SystemTime::now()
|
||||
.duration_since(modified)
|
||||
.unwrap_or_default();
|
||||
if age > max_age {
|
||||
let _ = fs::remove_file(&path).await;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,22 +35,19 @@ impl fmt::Display for DeviceId {
|
|||
}
|
||||
|
||||
/// Type of sync device.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default,
|
||||
)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DeviceType {
|
||||
Desktop,
|
||||
Mobile,
|
||||
Tablet,
|
||||
Server,
|
||||
#[default]
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Default for DeviceType {
|
||||
fn default() -> Self {
|
||||
Self::Other
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DeviceType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
|
|
@ -353,7 +350,7 @@ impl UploadSession {
|
|||
timeout_hours: u64,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
let chunk_count = (expected_size + chunk_size - 1) / chunk_size;
|
||||
let chunk_count = expected_size.div_ceil(chunk_size);
|
||||
Self {
|
||||
id: Uuid::now_v7(),
|
||||
device_id,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,17 @@ use crate::{
|
|||
storage::DynStorageBackend,
|
||||
};
|
||||
|
||||
/// Creates a new tag.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `name` - Tag name
|
||||
/// * `parent_id` - Optional parent tag for hierarchy
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The created tag
|
||||
pub async fn create_tag(
|
||||
storage: &DynStorageBackend,
|
||||
name: &str,
|
||||
|
|
@ -14,6 +25,17 @@ pub async fn create_tag(
|
|||
storage.create_tag(name, parent_id).await
|
||||
}
|
||||
|
||||
/// Applies a tag to a media item.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `media_id` - Media item to tag
|
||||
/// * `tag_id` - Tag to apply
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Ok(())` on success
|
||||
pub async fn tag_media(
|
||||
storage: &DynStorageBackend,
|
||||
media_id: MediaId,
|
||||
|
|
@ -29,6 +51,17 @@ pub async fn tag_media(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Removes a tag from a media item.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `media_id` - Media item to untag
|
||||
/// * `tag_id` - Tag to remove
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Ok(())` on success
|
||||
pub async fn untag_media(
|
||||
storage: &DynStorageBackend,
|
||||
media_id: MediaId,
|
||||
|
|
@ -44,6 +77,16 @@ pub async fn untag_media(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Returns all descendants of a tag in the hierarchy.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `tag_id` - Root tag to query
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// List of child tags
|
||||
pub async fn get_tag_tree(
|
||||
storage: &DynStorageBackend,
|
||||
tag_id: Uuid,
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ fn generate_raw_thumbnail(
|
|||
)));
|
||||
}
|
||||
|
||||
// The extracted preview is typically a JPEG — try loading it
|
||||
// The extracted preview is typically a JPEG; try loading it
|
||||
if temp_ppm.exists() {
|
||||
let result = image::open(&temp_ppm);
|
||||
let _ = std::fs::remove_file(&temp_ppm);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use crate::{
|
|||
pub struct UserId(pub Uuid);
|
||||
|
||||
impl UserId {
|
||||
/// Creates a new user ID.
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::now_v7())
|
||||
}
|
||||
|
|
@ -94,14 +95,17 @@ pub enum LibraryPermission {
|
|||
}
|
||||
|
||||
impl LibraryPermission {
|
||||
/// Checks if read permission is granted.
|
||||
pub fn can_read(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Checks if write permission is granted.
|
||||
pub fn can_write(&self) -> bool {
|
||||
matches!(self, Self::Write | Self::Admin)
|
||||
}
|
||||
|
||||
/// Checks if admin permission is granted.
|
||||
pub fn can_admin(&self) -> bool {
|
||||
matches!(self, Self::Admin)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue