treewide: fix as many Clippy warnings as I humanly can
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I3c99acd032679bb7a04505db1a712b906a6a6964
This commit is contained in:
parent
047801a9da
commit
602cfb68b7
65 changed files with 1191 additions and 540 deletions
|
|
@ -3,10 +3,7 @@
|
|||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::{
|
||||
error::Result,
|
||||
model::{MediaId, MediaItem},
|
||||
};
|
||||
use crate::model::{MediaId, MediaItem};
|
||||
|
||||
/// Configuration for event detection
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -68,15 +65,16 @@ fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
|
|||
}
|
||||
|
||||
/// Detect photo events from a list of media items
|
||||
#[must_use]
|
||||
pub fn detect_events(
|
||||
mut items: Vec<MediaItem>,
|
||||
config: &EventDetectionConfig,
|
||||
) -> Result<Vec<DetectedEvent>> {
|
||||
) -> Vec<DetectedEvent> {
|
||||
// Filter to only photos with date_taken
|
||||
items.retain(|item| item.date_taken.is_some());
|
||||
|
||||
if items.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Sort by date_taken (None < Some, but all are Some after retain)
|
||||
|
|
@ -84,7 +82,7 @@ pub fn detect_events(
|
|||
|
||||
let mut events: Vec<DetectedEvent> = Vec::new();
|
||||
let Some(first_date) = items[0].date_taken else {
|
||||
return Ok(Vec::new());
|
||||
return Vec::new();
|
||||
};
|
||||
let mut current_event_items: Vec<MediaId> = vec![items[0].id];
|
||||
let mut current_start_time = first_date;
|
||||
|
|
@ -171,21 +169,22 @@ pub fn detect_events(
|
|||
});
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
events
|
||||
}
|
||||
|
||||
/// Detect photo bursts (rapid sequences of photos)
|
||||
/// Returns groups of media IDs that are likely burst sequences
|
||||
#[must_use]
|
||||
pub fn detect_bursts(
|
||||
mut items: Vec<MediaItem>,
|
||||
max_gap_secs: i64,
|
||||
min_burst_size: usize,
|
||||
) -> Result<Vec<Vec<MediaId>>> {
|
||||
) -> Vec<Vec<MediaId>> {
|
||||
// Filter to only photos with date_taken
|
||||
items.retain(|item| item.date_taken.is_some());
|
||||
|
||||
if items.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Sort by date_taken (None < Some, but all are Some after retain)
|
||||
|
|
@ -193,7 +192,7 @@ pub fn detect_bursts(
|
|||
|
||||
let mut bursts: Vec<Vec<MediaId>> = Vec::new();
|
||||
let Some(first_date) = items[0].date_taken else {
|
||||
return Ok(Vec::new());
|
||||
return Vec::new();
|
||||
};
|
||||
let mut current_burst: Vec<MediaId> = vec![items[0].id];
|
||||
let mut last_time = first_date;
|
||||
|
|
@ -221,5 +220,5 @@ pub fn detect_bursts(
|
|||
bursts.push(current_burst);
|
||||
}
|
||||
|
||||
Ok(bursts)
|
||||
bursts
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,12 +185,12 @@ impl JobQueue {
|
|||
};
|
||||
|
||||
{
|
||||
let mut map = self.jobs.write().await;
|
||||
map.insert(id, job);
|
||||
// Prune old terminal jobs to prevent unbounded memory growth.
|
||||
// Keep at most 500 completed/failed/cancelled entries, removing the
|
||||
// oldest.
|
||||
const MAX_TERMINAL_JOBS: usize = 500;
|
||||
let mut map = self.jobs.write().await;
|
||||
map.insert(id, job);
|
||||
let mut terminal: Vec<(Uuid, chrono::DateTime<Utc>)> = map
|
||||
.iter()
|
||||
.filter(|(_, j)| {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ use std::{path::Path, process::Command};
|
|||
use crate::error::{PinakesError, Result};
|
||||
|
||||
pub trait Opener: Send + Sync {
|
||||
/// Open the file at `path` with the system default application.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the opener command fails to launch or exits non-zero.
|
||||
fn open(&self, path: &Path) -> Result<()>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ use std::{
|
|||
|
||||
use pinakes_metadata::ExtractedMetadata;
|
||||
use pinakes_plugin::{
|
||||
CapabilityEnforcer,
|
||||
PluginManager,
|
||||
rpc::{
|
||||
CanHandleRequest,
|
||||
|
|
@ -131,7 +132,7 @@ impl PluginPipeline {
|
|||
pub async fn discover_capabilities(&self) -> crate::error::Result<()> {
|
||||
info!("discovering plugin capabilities");
|
||||
|
||||
let timeout = Duration::from_secs(self.timeouts.capability_query_secs);
|
||||
let timeout = Duration::from_secs(self.timeouts.capability_query);
|
||||
let mut caps = CachedCapabilities::new();
|
||||
|
||||
// Discover metadata extractors
|
||||
|
|
@ -322,7 +323,7 @@ impl PluginPipeline {
|
|||
/// Iterates `MediaTypeProvider` plugins in priority order, falling back to
|
||||
/// the built-in resolver at implicit priority 100.
|
||||
pub async fn resolve_media_type(&self, path: &Path) -> Option<MediaType> {
|
||||
let timeout = Duration::from_secs(self.timeouts.processing_secs);
|
||||
let timeout = Duration::from_secs(self.timeouts.processing);
|
||||
let plugins = self.manager.get_enabled_by_kind_sorted("media_type").await;
|
||||
|
||||
let mut builtin_ran = false;
|
||||
|
|
@ -341,11 +342,7 @@ impl PluginPipeline {
|
|||
}
|
||||
|
||||
// Validate the call is allowed for this plugin kind
|
||||
if !self
|
||||
.manager
|
||||
.enforcer()
|
||||
.validate_function_call(kinds, "can_handle")
|
||||
{
|
||||
if !CapabilityEnforcer::validate_function_call(kinds, "can_handle") {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -441,7 +438,7 @@ impl PluginPipeline {
|
|||
path: &Path,
|
||||
media_type: &MediaType,
|
||||
) -> crate::error::Result<ExtractedMetadata> {
|
||||
let timeout = Duration::from_secs(self.timeouts.processing_secs);
|
||||
let timeout = Duration::from_secs(self.timeouts.processing);
|
||||
let plugins = self
|
||||
.manager
|
||||
.get_enabled_by_kind_sorted("metadata_extractor")
|
||||
|
|
@ -475,10 +472,7 @@ impl PluginPipeline {
|
|||
continue;
|
||||
}
|
||||
|
||||
if !self
|
||||
.manager
|
||||
.enforcer()
|
||||
.validate_function_call(kinds, "extract_metadata")
|
||||
if !CapabilityEnforcer::validate_function_call(kinds, "extract_metadata")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
|
@ -558,7 +552,7 @@ impl PluginPipeline {
|
|||
media_type: &MediaType,
|
||||
thumb_dir: &Path,
|
||||
) -> crate::error::Result<Option<PathBuf>> {
|
||||
let timeout = Duration::from_secs(self.timeouts.processing_secs);
|
||||
let timeout = Duration::from_secs(self.timeouts.processing);
|
||||
let plugins = self
|
||||
.manager
|
||||
.get_enabled_by_kind_sorted("thumbnail_generator")
|
||||
|
|
@ -596,11 +590,10 @@ impl PluginPipeline {
|
|||
continue;
|
||||
}
|
||||
|
||||
if !self
|
||||
.manager
|
||||
.enforcer()
|
||||
.validate_function_call(kinds, "generate_thumbnail")
|
||||
{
|
||||
if !CapabilityEnforcer::validate_function_call(
|
||||
kinds,
|
||||
"generate_thumbnail",
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -693,7 +686,7 @@ impl PluginPipeline {
|
|||
event_type: &str,
|
||||
payload: &serde_json::Value,
|
||||
) {
|
||||
let timeout = Duration::from_secs(self.timeouts.event_handler_secs);
|
||||
let timeout = Duration::from_secs(self.timeouts.event_handler);
|
||||
|
||||
// Collect plugin IDs interested in this event
|
||||
let interested_ids: Vec<String> = {
|
||||
|
|
@ -725,11 +718,7 @@ impl PluginPipeline {
|
|||
continue;
|
||||
}
|
||||
|
||||
if !self
|
||||
.manager
|
||||
.enforcer()
|
||||
.validate_function_call(kinds, "handle_event")
|
||||
{
|
||||
if !CapabilityEnforcer::validate_function_call(kinds, "handle_event") {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -789,7 +778,7 @@ impl PluginPipeline {
|
|||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Vec<SearchResultItem> {
|
||||
let timeout = Duration::from_secs(self.timeouts.processing_secs);
|
||||
let timeout = Duration::from_secs(self.timeouts.processing);
|
||||
let plugins = self
|
||||
.manager
|
||||
.get_enabled_by_kind_sorted("search_backend")
|
||||
|
|
@ -801,11 +790,7 @@ impl PluginPipeline {
|
|||
if !self.is_healthy(id).await {
|
||||
continue;
|
||||
}
|
||||
if !self
|
||||
.manager
|
||||
.enforcer()
|
||||
.validate_function_call(kinds, "search")
|
||||
{
|
||||
if !CapabilityEnforcer::validate_function_call(kinds, "search") {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -862,7 +847,7 @@ impl PluginPipeline {
|
|||
|
||||
/// Index a media item in all search backend plugins (fan-out).
|
||||
pub async fn index_item(&self, req: &IndexItemRequest) {
|
||||
let timeout = Duration::from_secs(self.timeouts.processing_secs);
|
||||
let timeout = Duration::from_secs(self.timeouts.processing);
|
||||
let plugins = self
|
||||
.manager
|
||||
.get_enabled_by_kind_sorted("search_backend")
|
||||
|
|
@ -872,11 +857,7 @@ impl PluginPipeline {
|
|||
if !self.is_healthy(id).await {
|
||||
continue;
|
||||
}
|
||||
if !self
|
||||
.manager
|
||||
.enforcer()
|
||||
.validate_function_call(kinds, "index_item")
|
||||
{
|
||||
if !CapabilityEnforcer::validate_function_call(kinds, "index_item") {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -905,7 +886,7 @@ impl PluginPipeline {
|
|||
|
||||
/// Remove a media item from all search backend plugins (fan-out).
|
||||
pub async fn remove_item(&self, media_id: &str) {
|
||||
let timeout = Duration::from_secs(self.timeouts.processing_secs);
|
||||
let timeout = Duration::from_secs(self.timeouts.processing);
|
||||
let plugins = self
|
||||
.manager
|
||||
.get_enabled_by_kind_sorted("search_backend")
|
||||
|
|
@ -919,11 +900,7 @@ impl PluginPipeline {
|
|||
if !self.is_healthy(id).await {
|
||||
continue;
|
||||
}
|
||||
if !self
|
||||
.manager
|
||||
.enforcer()
|
||||
.validate_function_call(kinds, "remove_item")
|
||||
{
|
||||
if !CapabilityEnforcer::validate_function_call(kinds, "remove_item") {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -963,7 +940,7 @@ impl PluginPipeline {
|
|||
|
||||
/// Load a specific theme by ID from the provider that registered it.
|
||||
pub async fn load_theme(&self, theme_id: &str) -> Option<LoadThemeResponse> {
|
||||
let timeout = Duration::from_secs(self.timeouts.processing_secs);
|
||||
let timeout = Duration::from_secs(self.timeouts.processing);
|
||||
|
||||
// Find which plugin owns this theme
|
||||
let owner_id = {
|
||||
|
|
@ -990,11 +967,7 @@ impl PluginPipeline {
|
|||
let plugin = plugins.iter().find(|(id, ..)| id == &owner_id)?;
|
||||
let (id, _priority, kinds, wasm) = plugin;
|
||||
|
||||
if !self
|
||||
.manager
|
||||
.enforcer()
|
||||
.validate_function_call(kinds, "load_theme")
|
||||
{
|
||||
if !CapabilityEnforcer::validate_function_call(kinds, "load_theme") {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ impl SharePermissions {
|
|||
|
||||
/// Merges two permission sets, taking the most permissive values.
|
||||
#[must_use]
|
||||
pub const fn merge(&self, other: &Self) -> Self {
|
||||
pub const fn merge(self, other: Self) -> Self {
|
||||
Self {
|
||||
view: ShareViewPermissions {
|
||||
can_view: self.view.can_view || other.view.can_view,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
/// # Errors
|
||||
///
|
||||
/// Returns an error if migrations fail to apply.
|
||||
#[cfg(feature = "sqlite")]
|
||||
pub fn run_sqlite_migrations(
|
||||
conn: &mut rusqlite::Connection,
|
||||
|
|
@ -7,6 +10,9 @@ pub fn run_sqlite_migrations(
|
|||
.map_err(|e| crate::error::PinakesError::Migration(e.to_string()))
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if migrations fail to apply.
|
||||
#[cfg(feature = "postgres")]
|
||||
pub async fn run_postgres_migrations(
|
||||
client: &mut tokio_postgres::Client,
|
||||
|
|
|
|||
|
|
@ -423,10 +423,12 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
user_id: crate::users::UserId,
|
||||
media_id: crate::model::MediaId,
|
||||
) -> Result<bool> {
|
||||
match self.check_library_access(user_id, media_id).await {
|
||||
Ok(perm) => Ok(perm.can_read()),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
Ok(
|
||||
self
|
||||
.check_library_access(user_id, media_id)
|
||||
.await
|
||||
.is_ok_and(|_perm| crate::users::LibraryPermission::can_read()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if a user has write access to a media item
|
||||
|
|
@ -435,10 +437,12 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
user_id: crate::users::UserId,
|
||||
media_id: crate::model::MediaId,
|
||||
) -> Result<bool> {
|
||||
match self.check_library_access(user_id, media_id).await {
|
||||
Ok(perm) => Ok(perm.can_write()),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
Ok(
|
||||
self
|
||||
.check_library_access(user_id, media_id)
|
||||
.await
|
||||
.is_ok_and(crate::users::LibraryPermission::can_write),
|
||||
)
|
||||
}
|
||||
|
||||
/// Rate a media item (1-5 stars) with an optional text review.
|
||||
|
|
|
|||
|
|
@ -255,7 +255,6 @@ fn row_to_audit_entry(row: &Row) -> rusqlite::Result<AuditEntry> {
|
|||
|
||||
let action = match action_str.as_str() {
|
||||
"imported" => AuditAction::Imported,
|
||||
"updated" => AuditAction::Updated,
|
||||
"deleted" => AuditAction::Deleted,
|
||||
"tagged" => AuditAction::Tagged,
|
||||
"untagged" => AuditAction::Untagged,
|
||||
|
|
@ -725,7 +724,6 @@ impl StorageBackend for SqliteBackend {
|
|||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
drop(stmt);
|
||||
drop(db);
|
||||
rows
|
||||
};
|
||||
Ok(rows)
|
||||
|
|
@ -863,7 +861,6 @@ impl StorageBackend for SqliteBackend {
|
|||
drop(stmt);
|
||||
item.custom_fields = load_custom_fields_sync(&db, item.id)
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
drop(db);
|
||||
item
|
||||
};
|
||||
Ok(item)
|
||||
|
|
@ -902,10 +899,8 @@ impl StorageBackend for SqliteBackend {
|
|||
if let Some(mut item) = result {
|
||||
item.custom_fields = load_custom_fields_sync(&db, item.id)
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
drop(db);
|
||||
Some(item)
|
||||
} else {
|
||||
drop(db);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
|
@ -945,10 +940,8 @@ impl StorageBackend for SqliteBackend {
|
|||
if let Some(mut item) = result {
|
||||
item.custom_fields = load_custom_fields_sync(&db, item.id)
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
drop(db);
|
||||
Some(item)
|
||||
} else {
|
||||
drop(db);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
|
@ -1006,7 +999,6 @@ impl StorageBackend for SqliteBackend {
|
|||
drop(stmt);
|
||||
load_custom_fields_batch(&db, &mut rows)
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
drop(db);
|
||||
rows
|
||||
};
|
||||
Ok(rows)
|
||||
|
|
@ -1062,7 +1054,6 @@ impl StorageBackend for SqliteBackend {
|
|||
],
|
||||
)
|
||||
.map_err(db_ctx("update_media", &item.id))?;
|
||||
drop(db);
|
||||
if changed == 0 {
|
||||
return Err(PinakesError::NotFound(format!(
|
||||
"media item {}",
|
||||
|
|
@ -1088,7 +1079,6 @@ impl StorageBackend for SqliteBackend {
|
|||
id.0.to_string()
|
||||
])
|
||||
.map_err(db_ctx("delete_media", id))?;
|
||||
drop(db);
|
||||
if changed == 0 {
|
||||
return Err(PinakesError::NotFound(format!("media item {id}")));
|
||||
}
|
||||
|
|
@ -1147,7 +1137,6 @@ impl StorageBackend for SqliteBackend {
|
|||
],
|
||||
)
|
||||
.map_err(db_ctx("create_tag", &name))?;
|
||||
drop(db);
|
||||
Tag {
|
||||
id,
|
||||
name,
|
||||
|
|
@ -1184,7 +1173,6 @@ impl StorageBackend for SqliteBackend {
|
|||
}
|
||||
})?;
|
||||
drop(stmt);
|
||||
drop(db);
|
||||
tag
|
||||
};
|
||||
Ok(tag)
|
||||
|
|
@ -1211,7 +1199,6 @@ impl StorageBackend for SqliteBackend {
|
|||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
drop(stmt);
|
||||
drop(db);
|
||||
rows
|
||||
};
|
||||
Ok(rows)
|
||||
|
|
@ -1230,7 +1217,6 @@ impl StorageBackend for SqliteBackend {
|
|||
let changed = db
|
||||
.execute("DELETE FROM tags WHERE id = ?1", params![id.to_string()])
|
||||
.map_err(db_ctx("delete_tag", id))?;
|
||||
drop(db);
|
||||
if changed == 0 {
|
||||
return Err(PinakesError::TagNotFound(id.to_string()));
|
||||
}
|
||||
|
|
@ -1299,7 +1285,6 @@ impl StorageBackend for SqliteBackend {
|
|||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
drop(stmt);
|
||||
drop(db);
|
||||
rows
|
||||
};
|
||||
Ok(rows)
|
||||
|
|
@ -1330,7 +1315,6 @@ impl StorageBackend for SqliteBackend {
|
|||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
drop(stmt);
|
||||
drop(db);
|
||||
rows
|
||||
};
|
||||
Ok(rows)
|
||||
|
|
@ -1372,7 +1356,6 @@ impl StorageBackend for SqliteBackend {
|
|||
],
|
||||
)
|
||||
.map_err(db_ctx("create_collection", &name))?;
|
||||
drop(db);
|
||||
Collection {
|
||||
id,
|
||||
name,
|
||||
|
|
@ -1413,7 +1396,6 @@ impl StorageBackend for SqliteBackend {
|
|||
}
|
||||
})?;
|
||||
drop(stmt);
|
||||
drop(db);
|
||||
collection
|
||||
};
|
||||
Ok(collection)
|
||||
|
|
@ -1441,7 +1423,6 @@ impl StorageBackend for SqliteBackend {
|
|||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
drop(stmt);
|
||||
drop(db);
|
||||
rows
|
||||
};
|
||||
Ok(rows)
|
||||
|
|
@ -1462,7 +1443,6 @@ impl StorageBackend for SqliteBackend {
|
|||
id.to_string()
|
||||
])
|
||||
.map_err(db_ctx("delete_collection", id))?;
|
||||
drop(db);
|
||||
if changed == 0 {
|
||||
return Err(PinakesError::CollectionNotFound(id.to_string()));
|
||||
}
|
||||
|
|
@ -1565,7 +1545,6 @@ impl StorageBackend for SqliteBackend {
|
|||
drop(stmt);
|
||||
load_custom_fields_batch(&db, &mut rows)
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
drop(db);
|
||||
rows
|
||||
};
|
||||
Ok(rows)
|
||||
|
|
@ -1675,7 +1654,6 @@ impl StorageBackend for SqliteBackend {
|
|||
let total_count: i64 = db
|
||||
.query_row(&count_sql, count_param_refs.as_slice(), |row| row.get(0))
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
drop(db);
|
||||
|
||||
SearchResults {
|
||||
items,
|
||||
|
|
@ -1777,7 +1755,6 @@ impl StorageBackend for SqliteBackend {
|
|||
.map_err(|e| PinakesError::Database(e.to_string()))?
|
||||
};
|
||||
drop(stmt);
|
||||
drop(db);
|
||||
rows
|
||||
};
|
||||
|
||||
|
|
@ -1854,7 +1831,6 @@ impl StorageBackend for SqliteBackend {
|
|||
map.insert(name, field);
|
||||
}
|
||||
drop(stmt);
|
||||
drop(db);
|
||||
map
|
||||
};
|
||||
Ok(map)
|
||||
|
|
@ -2907,7 +2883,7 @@ impl StorageBackend for SqliteBackend {
|
|||
crate::users::UserProfile {
|
||||
avatar_path: None,
|
||||
bio: None,
|
||||
preferences: Default::default(),
|
||||
preferences: crate::users::UserPreferences::default(),
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -3017,36 +2993,34 @@ impl StorageBackend for SqliteBackend {
|
|||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
|
||||
// Fetch updated user
|
||||
Ok(
|
||||
db.query_row(
|
||||
"SELECT id, username, password_hash, role, created_at, updated_at \
|
||||
FROM users WHERE id = ?",
|
||||
[&id_str],
|
||||
|row| {
|
||||
let id_str: String = row.get(0)?;
|
||||
let profile = load_user_profile_sync(&db, &id_str)?;
|
||||
Ok(crate::users::User {
|
||||
id: crate::users::UserId(parse_uuid(&id_str)?),
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
role: serde_json::from_str(&row.get::<_, String>(3)?)
|
||||
.unwrap_or(crate::config::UserRole::Viewer),
|
||||
profile,
|
||||
created_at: chrono::DateTime::parse_from_rfc3339(
|
||||
&row.get::<_, String>(4)?,
|
||||
)
|
||||
.unwrap_or_else(|_| chrono::Utc::now().into())
|
||||
.with_timezone(&chrono::Utc),
|
||||
updated_at: chrono::DateTime::parse_from_rfc3339(
|
||||
&row.get::<_, String>(5)?,
|
||||
)
|
||||
.unwrap_or_else(|_| chrono::Utc::now().into())
|
||||
.with_timezone(&chrono::Utc),
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?,
|
||||
db.query_row(
|
||||
"SELECT id, username, password_hash, role, created_at, updated_at \
|
||||
FROM users WHERE id = ?",
|
||||
[&id_str],
|
||||
|row| {
|
||||
let id_str: String = row.get(0)?;
|
||||
let profile = load_user_profile_sync(&db, &id_str)?;
|
||||
Ok(crate::users::User {
|
||||
id: crate::users::UserId(parse_uuid(&id_str)?),
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
role: serde_json::from_str(&row.get::<_, String>(3)?)
|
||||
.unwrap_or(crate::config::UserRole::Viewer),
|
||||
profile,
|
||||
created_at: chrono::DateTime::parse_from_rfc3339(
|
||||
&row.get::<_, String>(4)?,
|
||||
)
|
||||
.unwrap_or_else(|_| chrono::Utc::now().into())
|
||||
.with_timezone(&chrono::Utc),
|
||||
updated_at: chrono::DateTime::parse_from_rfc3339(
|
||||
&row.get::<_, String>(5)?,
|
||||
)
|
||||
.unwrap_or_else(|_| chrono::Utc::now().into())
|
||||
.with_timezone(&chrono::Utc),
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), fut)
|
||||
.await
|
||||
|
|
@ -5949,39 +5923,33 @@ impl StorageBackend for SqliteBackend {
|
|||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
match row {
|
||||
Ok((item, current_page, total_pages)) => {
|
||||
// Calculate status based on progress
|
||||
let calculated_status = total_pages.map_or(
|
||||
// No total pages known, assume reading
|
||||
crate::model::ReadingStatus::Reading,
|
||||
|total| {
|
||||
if total > 0 {
|
||||
let percent = (f64::from(current_page) / f64::from(total)
|
||||
* 100.0)
|
||||
.min(100.0);
|
||||
if percent >= 100.0 {
|
||||
crate::model::ReadingStatus::Completed
|
||||
} else if percent > 0.0 {
|
||||
crate::model::ReadingStatus::Reading
|
||||
} else {
|
||||
crate::model::ReadingStatus::ToRead
|
||||
}
|
||||
} else {
|
||||
crate::model::ReadingStatus::Reading
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Filter by status if specified
|
||||
match status {
|
||||
None => results.push(item),
|
||||
Some(s) if s == calculated_status => results.push(item),
|
||||
_ => {},
|
||||
for (item, current_page, total_pages) in rows.flatten() {
|
||||
// Calculate status based on progress
|
||||
let calculated_status = total_pages.map_or(
|
||||
// No total pages known, assume reading
|
||||
crate::model::ReadingStatus::Reading,
|
||||
|total| {
|
||||
if total > 0 {
|
||||
let percent =
|
||||
(f64::from(current_page) / f64::from(total) * 100.0).min(100.0);
|
||||
if percent >= 100.0 {
|
||||
crate::model::ReadingStatus::Completed
|
||||
} else if percent > 0.0 {
|
||||
crate::model::ReadingStatus::Reading
|
||||
} else {
|
||||
crate::model::ReadingStatus::ToRead
|
||||
}
|
||||
} else {
|
||||
crate::model::ReadingStatus::Reading
|
||||
}
|
||||
},
|
||||
Err(_) => continue,
|
||||
);
|
||||
|
||||
// Filter by status if specified
|
||||
match status {
|
||||
None => results.push(item),
|
||||
Some(s) if s == calculated_status => results.push(item),
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
Ok::<_, PinakesError>(results)
|
||||
|
|
@ -7809,7 +7777,7 @@ impl StorageBackend for SqliteBackend {
|
|||
) if *share_user == uid => {
|
||||
return Ok(Some(share.permissions));
|
||||
},
|
||||
_ => continue,
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -8216,7 +8184,7 @@ impl StorageBackend for SqliteBackend {
|
|||
let new_name = new_name.to_string();
|
||||
|
||||
let (old_path, storage_mode) = tokio::task::spawn_blocking({
|
||||
let conn = conn.clone();
|
||||
let conn = Arc::clone(&conn);
|
||||
let id_str = id_str.clone();
|
||||
move || {
|
||||
let conn = conn.lock().map_err(|e| {
|
||||
|
|
@ -8239,7 +8207,7 @@ impl StorageBackend for SqliteBackend {
|
|||
})??;
|
||||
|
||||
let old_path_buf = std::path::PathBuf::from(&old_path);
|
||||
let parent = old_path_buf.parent().unwrap_or(std::path::Path::new(""));
|
||||
let parent = old_path_buf.parent().unwrap_or_else(|| Path::new(""));
|
||||
let new_path = parent.join(&new_name);
|
||||
let new_path_str = new_path.to_string_lossy().to_string();
|
||||
|
||||
|
|
@ -8288,7 +8256,7 @@ impl StorageBackend for SqliteBackend {
|
|||
let new_dir = new_directory.to_path_buf();
|
||||
|
||||
let (old_path, file_name, storage_mode) = tokio::task::spawn_blocking({
|
||||
let conn = conn.clone();
|
||||
let conn = Arc::clone(&conn);
|
||||
let id_str = id_str.clone();
|
||||
move || {
|
||||
let conn = conn.lock().map_err(|e| {
|
||||
|
|
@ -8788,10 +8756,8 @@ impl StorageBackend for SqliteBackend {
|
|||
let conn = conn.lock().map_err(|e| PinakesError::Database(format!("connection mutex poisoned: {e}")))?;
|
||||
let mut nodes = Vec::new();
|
||||
let mut edges = Vec::new();
|
||||
let mut node_ids = rustc_hash::FxHashSet::default();
|
||||
|
||||
// Get nodes - either all markdown files or those connected to center
|
||||
if let Some(center_id) = center_id_str {
|
||||
let node_ids = if let Some(center_id) = center_id_str {
|
||||
// BFS to find connected nodes within depth
|
||||
let mut frontier = vec![center_id.clone()];
|
||||
let mut visited = rustc_hash::FxHashSet::default();
|
||||
|
|
@ -8839,9 +8805,10 @@ impl StorageBackend for SqliteBackend {
|
|||
frontier = next_frontier;
|
||||
}
|
||||
|
||||
node_ids = visited;
|
||||
visited
|
||||
} else {
|
||||
// Get all markdown files with links (limit to 500 for performance)
|
||||
let mut ids = rustc_hash::FxHashSet::default();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT DISTINCT id FROM media_items
|
||||
WHERE media_type = 'markdown' AND deleted_at IS NULL
|
||||
|
|
@ -8852,10 +8819,10 @@ impl StorageBackend for SqliteBackend {
|
|||
Ok(id)
|
||||
}).map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
for row in rows {
|
||||
node_ids.insert(row.map_err(|e| PinakesError::Database(e.to_string()))?);
|
||||
|
||||
ids.insert(row.map_err(|e| PinakesError::Database(e.to_string()))?);
|
||||
}
|
||||
}
|
||||
ids
|
||||
};
|
||||
|
||||
// Build nodes with metadata
|
||||
for node_id in &node_ids {
|
||||
|
|
@ -9108,11 +9075,6 @@ fn row_to_share(row: &Row) -> rusqlite::Result<crate::sharing::Share> {
|
|||
let password_hash: Option<String> = row.get(7)?;
|
||||
|
||||
let target = match target_type.as_str() {
|
||||
"media" => {
|
||||
crate::sharing::ShareTarget::Media {
|
||||
media_id: MediaId(parse_uuid(&target_id_str)?),
|
||||
}
|
||||
},
|
||||
"collection" => {
|
||||
crate::sharing::ShareTarget::Collection {
|
||||
collection_id: parse_uuid(&target_id_str)?,
|
||||
|
|
@ -9136,12 +9098,6 @@ fn row_to_share(row: &Row) -> rusqlite::Result<crate::sharing::Share> {
|
|||
};
|
||||
|
||||
let recipient = match recipient_type.as_str() {
|
||||
"public_link" => {
|
||||
crate::sharing::ShareRecipient::PublicLink {
|
||||
token: public_token.unwrap_or_default(),
|
||||
password_hash,
|
||||
}
|
||||
},
|
||||
"user" => {
|
||||
crate::sharing::ShareRecipient::User {
|
||||
user_id: crate::users::UserId(parse_uuid(
|
||||
|
|
|
|||
|
|
@ -172,9 +172,8 @@ pub async fn list_embedded_tracks(
|
|||
}
|
||||
})?;
|
||||
|
||||
let streams = match json.get("streams").and_then(|s| s.as_array()) {
|
||||
Some(s) => s,
|
||||
None => return Ok(vec![]),
|
||||
let Some(streams) = json.get("streams").and_then(|s| s.as_array()) else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
|
|
@ -203,7 +202,7 @@ pub async fn list_embedded_tracks(
|
|||
.map(str::to_owned);
|
||||
|
||||
tracks.push(SubtitleTrackInfo {
|
||||
index: idx as u32,
|
||||
index: u32::try_from(idx).unwrap_or(u32::MAX),
|
||||
language,
|
||||
format,
|
||||
title,
|
||||
|
|
|
|||
|
|
@ -367,7 +367,7 @@ pub enum CoverSize {
|
|||
|
||||
impl CoverSize {
|
||||
#[must_use]
|
||||
pub const fn dimensions(&self) -> Option<(u32, u32)> {
|
||||
pub const fn dimensions(self) -> Option<(u32, u32)> {
|
||||
match self {
|
||||
Self::Tiny => Some((64, 64)),
|
||||
Self::Grid => Some((320, 320)),
|
||||
|
|
@ -377,7 +377,7 @@ impl CoverSize {
|
|||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn filename(&self) -> &'static str {
|
||||
pub const fn filename(self) -> &'static str {
|
||||
match self {
|
||||
Self::Tiny => "tiny.jpg",
|
||||
Self::Grid => "grid.jpg",
|
||||
|
|
@ -541,7 +541,7 @@ pub enum ThumbnailSize {
|
|||
impl ThumbnailSize {
|
||||
/// Get the pixel size for this thumbnail variant
|
||||
#[must_use]
|
||||
pub const fn pixels(&self) -> u32 {
|
||||
pub const fn pixels(self) -> u32 {
|
||||
match self {
|
||||
Self::Tiny => 64,
|
||||
Self::Grid => 320,
|
||||
|
|
@ -551,7 +551,7 @@ impl ThumbnailSize {
|
|||
|
||||
/// Get the subdirectory name for this size
|
||||
#[must_use]
|
||||
pub const fn subdir_name(&self) -> &'static str {
|
||||
pub const fn subdir_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Tiny => "tiny",
|
||||
Self::Grid => "grid",
|
||||
|
|
|
|||
|
|
@ -103,9 +103,9 @@ impl TranscodeService {
|
|||
pub fn new(config: TranscodingConfig) -> Self {
|
||||
let max_concurrent = config.max_concurrent.max(1);
|
||||
Self {
|
||||
config,
|
||||
sessions: Arc::new(RwLock::new(FxHashMap::default())),
|
||||
semaphore: Arc::new(Semaphore::new(max_concurrent)),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
//! User management and authentication
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
pub use pinakes_types::model::UserId;
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
config::UserRole,
|
||||
error::{PinakesError, Result},
|
||||
};
|
||||
|
||||
pub use pinakes_types::model::UserId;
|
||||
|
||||
/// User account with profile information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
|
|
@ -67,24 +67,24 @@ pub enum LibraryPermission {
|
|||
impl LibraryPermission {
|
||||
/// Checks if read permission is granted.
|
||||
#[must_use]
|
||||
pub const fn can_read(&self) -> bool {
|
||||
pub const fn can_read() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Checks if write permission is granted.
|
||||
#[must_use]
|
||||
pub const fn can_write(&self) -> bool {
|
||||
pub const fn can_write(self) -> bool {
|
||||
matches!(self, Self::Write | Self::Admin)
|
||||
}
|
||||
|
||||
/// Checks if admin permission is granted.
|
||||
#[must_use]
|
||||
pub const fn can_admin(&self) -> bool {
|
||||
pub const fn can_admin(self) -> bool {
|
||||
matches!(self, Self::Admin)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Read => "read",
|
||||
Self::Write => "write",
|
||||
|
|
@ -132,6 +132,10 @@ pub mod auth {
|
|||
use super::{PinakesError, Result};
|
||||
|
||||
/// Hash a password using Argon2
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if password hashing fails.
|
||||
pub fn hash_password(password: &str) -> Result<String> {
|
||||
use argon2::{
|
||||
Argon2,
|
||||
|
|
@ -150,6 +154,10 @@ pub mod auth {
|
|||
}
|
||||
|
||||
/// Verify a password against a hash
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the hash is invalid or cannot be parsed.
|
||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
|
||||
use argon2::{
|
||||
Argon2,
|
||||
|
|
@ -193,17 +201,17 @@ mod tests {
|
|||
#[test]
|
||||
fn test_library_permission_levels() {
|
||||
let read = LibraryPermission::Read;
|
||||
assert!(read.can_read());
|
||||
assert!(LibraryPermission::can_read());
|
||||
assert!(!read.can_write());
|
||||
assert!(!read.can_admin());
|
||||
|
||||
let write = LibraryPermission::Write;
|
||||
assert!(write.can_read());
|
||||
assert!(LibraryPermission::can_read());
|
||||
assert!(write.can_write());
|
||||
assert!(!write.can_admin());
|
||||
|
||||
let admin = LibraryPermission::Admin;
|
||||
assert!(admin.can_read());
|
||||
assert!(LibraryPermission::can_read());
|
||||
assert!(admin.can_write());
|
||||
assert!(admin.can_admin());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ impl WebhookDispatcher {
|
|||
/// Dispatch an event to all matching webhooks.
|
||||
/// This is fire-and-forget, errors are logged but not propagated.
|
||||
pub fn dispatch(self: &Arc<Self>, event: WebhookEvent) {
|
||||
let this = self.clone();
|
||||
let this = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
this.dispatch_inner(&event).await;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ impl BookEnricher {
|
|||
})?;
|
||||
|
||||
Ok(Some(ExternalMetadata {
|
||||
id: Uuid::new_v4(),
|
||||
id: Uuid::now_v7(),
|
||||
media_id: pinakes_types::model::MediaId(Uuid::nil()), /* Will be set by caller */
|
||||
source: EnrichmentSourceType::OpenLibrary,
|
||||
external_id: None,
|
||||
|
|
@ -104,7 +104,7 @@ impl BookEnricher {
|
|||
})?;
|
||||
|
||||
Ok(Some(ExternalMetadata {
|
||||
id: Uuid::new_v4(),
|
||||
id: Uuid::now_v7(),
|
||||
media_id: pinakes_types::model::MediaId(Uuid::nil()), /* Will be set by caller */
|
||||
source: EnrichmentSourceType::GoogleBooks,
|
||||
external_id: Some(book.id.clone()),
|
||||
|
|
@ -136,7 +136,7 @@ impl BookEnricher {
|
|||
})?;
|
||||
|
||||
return Ok(Some(ExternalMetadata {
|
||||
id: Uuid::new_v4(),
|
||||
id: Uuid::now_v7(),
|
||||
media_id: pinakes_types::model::MediaId(Uuid::nil()),
|
||||
source: EnrichmentSourceType::OpenLibrary,
|
||||
external_id: result.key.clone(),
|
||||
|
|
@ -155,7 +155,7 @@ impl BookEnricher {
|
|||
})?;
|
||||
|
||||
return Ok(Some(ExternalMetadata {
|
||||
id: Uuid::new_v4(),
|
||||
id: Uuid::now_v7(),
|
||||
media_id: pinakes_types::model::MediaId(Uuid::nil()),
|
||||
source: EnrichmentSourceType::GoogleBooks,
|
||||
external_id: Some(book.id.clone()),
|
||||
|
|
|
|||
|
|
@ -20,21 +20,21 @@ pub struct TmdbEnricher {
|
|||
impl TmdbEnricher {
|
||||
/// Create a new `TMDb` enricher.
|
||||
///
|
||||
/// # Panics
|
||||
/// # Errors
|
||||
///
|
||||
/// Panics if the HTTP client cannot be built (programming error in client
|
||||
/// configuration).
|
||||
#[must_use]
|
||||
pub fn new(api_key: String) -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("failed to build HTTP client with configured timeouts"),
|
||||
/// Returns an error if the HTTP client cannot be built (e.g. TLS
|
||||
/// initialisation failure).
|
||||
pub fn new(api_key: String) -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| PinakesError::External(e.to_string()))?;
|
||||
Ok(Self {
|
||||
client,
|
||||
api_key,
|
||||
base_url: "https://api.themoviedb.org/3".to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ mod postgres_migrations {
|
|||
embed_migrations!("migrations/postgres");
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn sqlite_migrations() -> Migrations<'static> {
|
||||
Migrations::new(vec![
|
||||
M::up(include_str!("../migrations/sqlite/V1__initial_schema.sql")),
|
||||
|
|
@ -49,6 +50,7 @@ pub fn sqlite_migrations() -> Migrations<'static> {
|
|||
])
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn postgres_runner() -> refinery::Runner {
|
||||
postgres_migrations::migrations::runner()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,11 +21,7 @@ impl PluginLoader {
|
|||
}
|
||||
|
||||
/// Discover all plugins in configured directories
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if a plugin directory cannot be searched.
|
||||
pub fn discover_plugins(&self) -> Result<Vec<PluginManifest>> {
|
||||
pub fn discover_plugins(&self) -> Vec<PluginManifest> {
|
||||
let mut manifests = Vec::new();
|
||||
|
||||
for dir in &self.plugin_dirs {
|
||||
|
|
@ -41,7 +37,7 @@ impl PluginLoader {
|
|||
manifests.extend(found);
|
||||
}
|
||||
|
||||
Ok(manifests)
|
||||
manifests
|
||||
}
|
||||
|
||||
/// Discover plugins in a specific directory
|
||||
|
|
@ -271,7 +267,7 @@ impl PluginLoader {
|
|||
///
|
||||
/// 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(path: &Path) -> Result<()> {
|
||||
// Check that the path exists
|
||||
if !path.exists() {
|
||||
return Err(anyhow!("Plugin path does not exist: {}", path.display()));
|
||||
|
|
@ -339,7 +335,7 @@ mod tests {
|
|||
let temp_dir = TempDir::new().unwrap();
|
||||
let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]);
|
||||
|
||||
let manifests = loader.discover_plugins().unwrap();
|
||||
let manifests = loader.discover_plugins();
|
||||
assert_eq!(manifests.len(), 0);
|
||||
}
|
||||
|
||||
|
|
@ -367,7 +363,7 @@ wasm = "plugin.wasm"
|
|||
.unwrap();
|
||||
|
||||
let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]);
|
||||
let manifests = loader.discover_plugins().unwrap();
|
||||
let manifests = loader.discover_plugins();
|
||||
|
||||
assert_eq!(manifests.len(), 1);
|
||||
assert_eq!(manifests[0].plugin.name, "test-plugin");
|
||||
|
|
@ -392,17 +388,15 @@ wasm = "plugin.wasm"
|
|||
"#;
|
||||
std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap();
|
||||
|
||||
let loader = PluginLoader::new(vec![]);
|
||||
|
||||
// Should fail without WASM file
|
||||
assert!(loader.validate_plugin_package(&plugin_dir).is_err());
|
||||
assert!(PluginLoader::validate_plugin_package(&plugin_dir).is_err());
|
||||
|
||||
// Create valid WASM file (magic number only)
|
||||
std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00")
|
||||
.unwrap();
|
||||
|
||||
// Should succeed now
|
||||
assert!(loader.validate_plugin_package(&plugin_dir).is_ok());
|
||||
assert!(PluginLoader::validate_plugin_package(&plugin_dir).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -426,7 +420,6 @@ wasm = "plugin.wasm"
|
|||
// Create invalid WASM file
|
||||
std::fs::write(plugin_dir.join("plugin.wasm"), b"not wasm").unwrap();
|
||||
|
||||
let loader = PluginLoader::new(vec![]);
|
||||
assert!(loader.validate_plugin_package(&plugin_dir).is_err());
|
||||
assert!(PluginLoader::validate_plugin_package(&plugin_dir).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ impl PluginManager {
|
|||
pub async fn discover_and_load_all(&self) -> Result<Vec<String>> {
|
||||
info!("Discovering plugins from {:?}", self.config.plugin_dirs);
|
||||
|
||||
let manifests = self.loader.discover_plugins()?;
|
||||
let manifests = self.loader.discover_plugins();
|
||||
let ordered = Self::resolve_load_order(&manifests);
|
||||
let mut loaded_plugins = Vec::new();
|
||||
|
||||
|
|
@ -645,6 +645,7 @@ impl PluginManager {
|
|||
},
|
||||
}
|
||||
}
|
||||
drop(registry);
|
||||
pages
|
||||
}
|
||||
|
||||
|
|
@ -666,6 +667,7 @@ impl PluginManager {
|
|||
merged.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
drop(registry);
|
||||
merged
|
||||
}
|
||||
|
||||
|
|
@ -686,6 +688,7 @@ impl PluginManager {
|
|||
widgets.push((plugin.id.clone(), widget.clone()));
|
||||
}
|
||||
}
|
||||
drop(registry);
|
||||
widgets
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -561,9 +561,7 @@ impl HostFunctions {
|
|||
if let Some(ref allowed) =
|
||||
caller.data().context.capabilities.network.allowed_domains
|
||||
{
|
||||
let parsed = if let Ok(u) = url::Url::parse(&url_str) {
|
||||
u
|
||||
} else {
|
||||
let Ok(parsed) = url::Url::parse(&url_str) else {
|
||||
tracing::warn!(url = %url_str, "plugin provided invalid URL");
|
||||
return -1;
|
||||
};
|
||||
|
|
@ -717,15 +715,12 @@ impl HostFunctions {
|
|||
return -2;
|
||||
}
|
||||
|
||||
match std::env::var(&key_str) {
|
||||
Ok(value) => {
|
||||
let bytes = value.into_bytes();
|
||||
let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX);
|
||||
caller.data_mut().exchange_buffer = bytes;
|
||||
len
|
||||
},
|
||||
Err(_) => -1,
|
||||
}
|
||||
std::env::var(&key_str).map_or(-1, |value| {
|
||||
let bytes = value.into_bytes();
|
||||
let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX);
|
||||
caller.data_mut().exchange_buffer = bytes;
|
||||
len
|
||||
})
|
||||
},
|
||||
)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -242,7 +242,6 @@ impl CapabilityEnforcer {
|
|||
/// bugs from calling wrong functions on plugins. Returns `true` if allowed.
|
||||
#[must_use]
|
||||
pub fn validate_function_call(
|
||||
&self,
|
||||
plugin_kinds: &[String],
|
||||
function_name: &str,
|
||||
) -> bool {
|
||||
|
|
@ -423,51 +422,91 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_validate_function_call_lifecycle_always_allowed() {
|
||||
let enforcer = CapabilityEnforcer::new();
|
||||
let kinds = vec!["metadata_extractor".to_string()];
|
||||
assert!(enforcer.validate_function_call(&kinds, "initialize"));
|
||||
assert!(enforcer.validate_function_call(&kinds, "shutdown"));
|
||||
assert!(enforcer.validate_function_call(&kinds, "health_check"));
|
||||
assert!(CapabilityEnforcer::validate_function_call(
|
||||
&kinds,
|
||||
"initialize"
|
||||
));
|
||||
assert!(CapabilityEnforcer::validate_function_call(
|
||||
&kinds, "shutdown"
|
||||
));
|
||||
assert!(CapabilityEnforcer::validate_function_call(
|
||||
&kinds,
|
||||
"health_check"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_function_call_metadata_extractor() {
|
||||
let enforcer = CapabilityEnforcer::new();
|
||||
let kinds = vec!["metadata_extractor".to_string()];
|
||||
assert!(enforcer.validate_function_call(&kinds, "extract_metadata"));
|
||||
assert!(enforcer.validate_function_call(&kinds, "supported_types"));
|
||||
assert!(!enforcer.validate_function_call(&kinds, "search"));
|
||||
assert!(!enforcer.validate_function_call(&kinds, "generate_thumbnail"));
|
||||
assert!(!enforcer.validate_function_call(&kinds, "can_handle"));
|
||||
assert!(CapabilityEnforcer::validate_function_call(
|
||||
&kinds,
|
||||
"extract_metadata"
|
||||
));
|
||||
assert!(CapabilityEnforcer::validate_function_call(
|
||||
&kinds,
|
||||
"supported_types"
|
||||
));
|
||||
assert!(!CapabilityEnforcer::validate_function_call(
|
||||
&kinds, "search"
|
||||
));
|
||||
assert!(!CapabilityEnforcer::validate_function_call(
|
||||
&kinds,
|
||||
"generate_thumbnail"
|
||||
));
|
||||
assert!(!CapabilityEnforcer::validate_function_call(
|
||||
&kinds,
|
||||
"can_handle"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_function_call_multi_kind() {
|
||||
let enforcer = CapabilityEnforcer::new();
|
||||
let kinds =
|
||||
vec!["media_type".to_string(), "metadata_extractor".to_string()];
|
||||
assert!(enforcer.validate_function_call(&kinds, "can_handle"));
|
||||
assert!(enforcer.validate_function_call(&kinds, "supported_media_types"));
|
||||
assert!(enforcer.validate_function_call(&kinds, "extract_metadata"));
|
||||
assert!(!enforcer.validate_function_call(&kinds, "search"));
|
||||
assert!(CapabilityEnforcer::validate_function_call(
|
||||
&kinds,
|
||||
"can_handle"
|
||||
));
|
||||
assert!(CapabilityEnforcer::validate_function_call(
|
||||
&kinds,
|
||||
"supported_media_types"
|
||||
));
|
||||
assert!(CapabilityEnforcer::validate_function_call(
|
||||
&kinds,
|
||||
"extract_metadata"
|
||||
));
|
||||
assert!(!CapabilityEnforcer::validate_function_call(
|
||||
&kinds, "search"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_function_call_unknown_function() {
|
||||
let enforcer = CapabilityEnforcer::new();
|
||||
let kinds = vec!["metadata_extractor".to_string()];
|
||||
assert!(!enforcer.validate_function_call(&kinds, "unknown_func"));
|
||||
assert!(!enforcer.validate_function_call(&kinds, ""));
|
||||
assert!(!CapabilityEnforcer::validate_function_call(
|
||||
&kinds,
|
||||
"unknown_func"
|
||||
));
|
||||
assert!(!CapabilityEnforcer::validate_function_call(&kinds, ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_function_call_shared_supported_types() {
|
||||
let enforcer = CapabilityEnforcer::new();
|
||||
let extractor = vec!["metadata_extractor".to_string()];
|
||||
let generator = vec!["thumbnail_generator".to_string()];
|
||||
let search = vec!["search_backend".to_string()];
|
||||
assert!(enforcer.validate_function_call(&extractor, "supported_types"));
|
||||
assert!(enforcer.validate_function_call(&generator, "supported_types"));
|
||||
assert!(!enforcer.validate_function_call(&search, "supported_types"));
|
||||
assert!(CapabilityEnforcer::validate_function_call(
|
||||
&extractor,
|
||||
"supported_types"
|
||||
));
|
||||
assert!(CapabilityEnforcer::validate_function_call(
|
||||
&generator,
|
||||
"supported_types"
|
||||
));
|
||||
assert!(!CapabilityEnforcer::validate_function_call(
|
||||
&search,
|
||||
"supported_types"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,8 +94,7 @@ pub const fn resolve_by_mtime(conflict: &ConflictInfo) -> ConflictOutcome {
|
|||
}
|
||||
},
|
||||
(Some(_), None) => ConflictOutcome::UseLocal,
|
||||
(None, Some(_)) => ConflictOutcome::UseServer,
|
||||
(None, None) => ConflictOutcome::UseServer, // Default to server
|
||||
(None, Some(_) | None) => ConflictOutcome::UseServer, // Default to server
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -364,7 +364,7 @@ impl UploadSession {
|
|||
chunk_count,
|
||||
status: UploadStatus::Pending,
|
||||
created_at: now,
|
||||
expires_at: now + chrono::Duration::hours(timeout_hours as i64),
|
||||
expires_at: now + chrono::Duration::hours(timeout_hours.cast_signed()),
|
||||
last_activity: now,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -504,7 +504,7 @@ pub enum UserRole {
|
|||
|
||||
impl UserRole {
|
||||
#[must_use]
|
||||
pub const fn can_read(self) -> bool {
|
||||
pub const fn can_read() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
|
|
@ -533,14 +533,20 @@ impl std::fmt::Display for UserRole {
|
|||
pub struct PluginTimeoutConfig {
|
||||
/// Timeout for capability discovery queries (`supported_types`,
|
||||
/// `interested_events`)
|
||||
#[serde(default = "default_capability_query_timeout")]
|
||||
pub capability_query_secs: u64,
|
||||
#[serde(
|
||||
default = "default_capability_query_timeout",
|
||||
rename = "capability_query_secs"
|
||||
)]
|
||||
pub capability_query: u64,
|
||||
/// Timeout for processing calls (`extract_metadata`, `generate_thumbnail`)
|
||||
#[serde(default = "default_processing_timeout")]
|
||||
pub processing_secs: u64,
|
||||
#[serde(default = "default_processing_timeout", rename = "processing_secs")]
|
||||
pub processing: u64,
|
||||
/// Timeout for event handler calls
|
||||
#[serde(default = "default_event_handler_timeout")]
|
||||
pub event_handler_secs: u64,
|
||||
#[serde(
|
||||
default = "default_event_handler_timeout",
|
||||
rename = "event_handler_secs"
|
||||
)]
|
||||
pub event_handler: u64,
|
||||
}
|
||||
|
||||
const fn default_capability_query_timeout() -> u64 {
|
||||
|
|
@ -558,9 +564,9 @@ const fn default_event_handler_timeout() -> u64 {
|
|||
impl Default for PluginTimeoutConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
capability_query_secs: default_capability_query_timeout(),
|
||||
processing_secs: default_processing_timeout(),
|
||||
event_handler_secs: default_event_handler_timeout(),
|
||||
capability_query: default_capability_query_timeout(),
|
||||
processing: default_processing_timeout(),
|
||||
event_handler: default_event_handler_timeout(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1138,7 +1144,7 @@ pub enum StorageBackendType {
|
|||
|
||||
impl StorageBackendType {
|
||||
#[must_use]
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Sqlite => "sqlite",
|
||||
Self::Postgres => "postgres",
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ pub enum MediaCategory {
|
|||
impl BuiltinMediaType {
|
||||
/// Get the unique, stable ID for this media type.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> &'static str {
|
||||
pub const fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Mp3 => "mp3",
|
||||
Self::Flac => "flac",
|
||||
|
|
@ -100,7 +100,7 @@ impl BuiltinMediaType {
|
|||
|
||||
/// Get the display name for this media type
|
||||
#[must_use]
|
||||
pub fn name(&self) -> String {
|
||||
pub fn name(self) -> String {
|
||||
match self {
|
||||
Self::Mp3 => "MP3 Audio".to_string(),
|
||||
Self::Flac => "FLAC Audio".to_string(),
|
||||
|
|
@ -180,7 +180,7 @@ impl BuiltinMediaType {
|
|||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn mime_type(&self) -> &'static str {
|
||||
pub const fn mime_type(self) -> &'static str {
|
||||
match self {
|
||||
Self::Mp3 => "audio/mpeg",
|
||||
Self::Flac => "audio/flac",
|
||||
|
|
@ -216,7 +216,7 @@ impl BuiltinMediaType {
|
|||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn category(&self) -> MediaCategory {
|
||||
pub const fn category(self) -> MediaCategory {
|
||||
match self {
|
||||
Self::Mp3
|
||||
| Self::Flac
|
||||
|
|
@ -246,7 +246,7 @@ impl BuiltinMediaType {
|
|||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn extensions(&self) -> &'static [&'static str] {
|
||||
pub const fn extensions(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Mp3 => &["mp3"],
|
||||
Self::Flac => &["flac"],
|
||||
|
|
@ -283,7 +283,7 @@ impl BuiltinMediaType {
|
|||
|
||||
/// Returns true if this is a RAW image format.
|
||||
#[must_use]
|
||||
pub const fn is_raw(&self) -> bool {
|
||||
pub const fn is_raw(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Cr2 | Self::Nef | Self::Arw | Self::Dng | Self::Orf | Self::Rw2
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ impl MediaTypeRegistry {
|
|||
}
|
||||
|
||||
/// Register a new media type
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if a media type with the same ID is already registered.
|
||||
pub fn register(&mut self, descriptor: MediaTypeDescriptor) -> Result<()> {
|
||||
// Check if ID is already registered
|
||||
if self.types.contains_key(&descriptor.id) {
|
||||
|
|
@ -74,6 +78,10 @@ impl MediaTypeRegistry {
|
|||
}
|
||||
|
||||
/// Unregister a media type
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if no media type with the given ID is registered.
|
||||
pub fn unregister(&mut self, id: &str) -> Result<()> {
|
||||
let descriptor = self
|
||||
.types
|
||||
|
|
@ -146,6 +154,10 @@ impl MediaTypeRegistry {
|
|||
}
|
||||
|
||||
/// Unregister all types from a specific plugin
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if unregistering any individual type fails.
|
||||
pub fn unregister_plugin(&mut self, plugin_id: &str) -> Result<usize> {
|
||||
let type_ids: Vec<String> = self
|
||||
.types
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ pub enum CustomFieldType {
|
|||
|
||||
impl CustomFieldType {
|
||||
#[must_use]
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Text => "text",
|
||||
Self::Number => "number",
|
||||
|
|
@ -262,7 +262,7 @@ pub enum CollectionKind {
|
|||
|
||||
impl CollectionKind {
|
||||
#[must_use]
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Manual => "manual",
|
||||
Self::Virtual => "virtual",
|
||||
|
|
|
|||
|
|
@ -27,8 +27,6 @@ pub fn create_router(
|
|||
create_router_with_tls(state, rate_limits, None)
|
||||
}
|
||||
|
||||
/// Build a governor rate limiter from per-second and burst-size values.
|
||||
/// Panics if the config is invalid (callers must validate before use).
|
||||
fn build_governor(
|
||||
per_second: u64,
|
||||
burst_size: u32,
|
||||
|
|
@ -38,13 +36,18 @@ fn build_governor(
|
|||
governor::middleware::NoOpMiddleware,
|
||||
>,
|
||||
> {
|
||||
Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.per_second(per_second)
|
||||
.burst_size(burst_size)
|
||||
.finish()
|
||||
.expect("rate limit config was validated at startup"),
|
||||
)
|
||||
// finish() returns None only when per_second=0; clamp to ensure it always
|
||||
// returns Some
|
||||
let per_second = per_second.max(1);
|
||||
let burst_size = burst_size.max(1);
|
||||
let Some(config) = GovernorConfigBuilder::default()
|
||||
.per_second(per_second)
|
||||
.burst_size(burst_size)
|
||||
.finish()
|
||||
else {
|
||||
return build_governor(1, 1);
|
||||
};
|
||||
Arc::new(config)
|
||||
}
|
||||
|
||||
/// Create the router with TLS configuration for security headers
|
||||
|
|
@ -521,8 +524,16 @@ pub fn create_router_with_tls(
|
|||
// CORS configuration: use config-driven origins if specified,
|
||||
// otherwise fall back to default localhost origins
|
||||
let cors = {
|
||||
let origins: Vec<HeaderValue> =
|
||||
if let Ok(config_read) = state.config.try_read() {
|
||||
let default_origins = || {
|
||||
vec![
|
||||
HeaderValue::from_static("http://localhost:3000"),
|
||||
HeaderValue::from_static("http://127.0.0.1:3000"),
|
||||
HeaderValue::from_static("tauri://localhost"),
|
||||
]
|
||||
};
|
||||
let origins: Vec<HeaderValue> = state.config.try_read().map_or_else(
|
||||
|_| default_origins(),
|
||||
|config_read| {
|
||||
if config_read.server.cors_enabled
|
||||
&& !config_read.server.cors_origins.is_empty()
|
||||
{
|
||||
|
|
@ -533,19 +544,10 @@ pub fn create_router_with_tls(
|
|||
.filter_map(|o| HeaderValue::from_str(o).ok())
|
||||
.collect()
|
||||
} else {
|
||||
vec![
|
||||
HeaderValue::from_static("http://localhost:3000"),
|
||||
HeaderValue::from_static("http://127.0.0.1:3000"),
|
||||
HeaderValue::from_static("tauri://localhost"),
|
||||
]
|
||||
default_origins()
|
||||
}
|
||||
} else {
|
||||
vec![
|
||||
HeaderValue::from_static("http://localhost:3000"),
|
||||
HeaderValue::from_static("http://127.0.0.1:3000"),
|
||||
HeaderValue::from_static("tauri://localhost"),
|
||||
]
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
CorsLayer::new()
|
||||
.allow_origin(origins)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
|
|
@ -90,8 +92,10 @@ pub async fn require_auth(
|
|||
if session.expires_at < now {
|
||||
let username = session.username;
|
||||
// Delete expired session in a bounded background task
|
||||
if let Ok(permit) = state.session_semaphore.clone().try_acquire_owned() {
|
||||
let storage = state.storage.clone();
|
||||
if let Ok(permit) =
|
||||
Arc::clone(&state.session_semaphore).try_acquire_owned()
|
||||
{
|
||||
let storage = Arc::clone(&state.storage);
|
||||
let token_owned = token.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = storage.delete_session(&token_owned).await {
|
||||
|
|
@ -105,8 +109,9 @@ pub async fn require_auth(
|
|||
}
|
||||
|
||||
// Update last_accessed timestamp in a bounded background task
|
||||
if let Ok(permit) = state.session_semaphore.clone().try_acquire_owned() {
|
||||
let storage = state.storage.clone();
|
||||
if let Ok(permit) = Arc::clone(&state.session_semaphore).try_acquire_owned()
|
||||
{
|
||||
let storage = Arc::clone(&state.storage);
|
||||
let token_owned = token.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = storage.touch_session(&token_owned).await {
|
||||
|
|
@ -209,7 +214,9 @@ pub async fn require_admin(request: Request, next: Next) -> Response {
|
|||
|
||||
/// Resolve the authenticated username (from request extensions) to a `UserId`.
|
||||
///
|
||||
/// Returns an error if the user cannot be found.
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the user cannot be found in the database.
|
||||
pub async fn resolve_user_id(
|
||||
storage: &pinakes_core::storage::DynStorageBackend,
|
||||
username: &str,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ pub struct CreateShareRequest {
|
|||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct SharePermissionsRequest {
|
||||
pub can_view: Option<bool>,
|
||||
pub can_download: Option<bool>,
|
||||
|
|
@ -47,6 +48,7 @@ pub struct ShareResponse {
|
|||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
#[allow(clippy::struct_excessive_bools, clippy::struct_field_names)]
|
||||
pub struct SharePermissionsResponse {
|
||||
pub can_view: bool,
|
||||
pub can_download: bool,
|
||||
|
|
@ -197,6 +199,6 @@ pub struct AccessSharedRequest {
|
|||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum SharedContentResponse {
|
||||
Single(super::MediaResponse),
|
||||
Single(Box<super::MediaResponse>),
|
||||
Multiple { items: Vec<super::MediaResponse> },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ impl IntoResponse for ApiError {
|
|||
fn into_response(self) -> Response {
|
||||
use pinakes_core::error::PinakesError;
|
||||
let (status, message) = match &self.0 {
|
||||
PinakesError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
PinakesError::NotFound(msg)
|
||||
| PinakesError::TagNotFound(msg)
|
||||
| PinakesError::CollectionNotFound(msg) => {
|
||||
(StatusCode::NOT_FOUND, msg.clone())
|
||||
},
|
||||
PinakesError::FileNotFound(path) => {
|
||||
// Only expose the file name, not the full path
|
||||
let name = path.file_name().map_or_else(
|
||||
|
|
@ -25,10 +29,6 @@ impl IntoResponse for ApiError {
|
|||
tracing::debug!(path = %path.display(), "file not found");
|
||||
(StatusCode::NOT_FOUND, format!("file not found: {name}"))
|
||||
},
|
||||
PinakesError::TagNotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
PinakesError::CollectionNotFound(msg) => {
|
||||
(StatusCode::NOT_FOUND, msg.clone())
|
||||
},
|
||||
PinakesError::DuplicateHash(msg) => (StatusCode::CONFLICT, msg.clone()),
|
||||
PinakesError::UnsupportedMediaType(path) => {
|
||||
let name = path.file_name().map_or_else(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use anyhow::Result;
|
|||
use axum::{Router, response::Redirect, routing::any};
|
||||
use clap::Parser;
|
||||
use pinakes_core::{config::Config, storage::StorageBackend};
|
||||
use pinakes_enrichment::EnrichmentSourceType;
|
||||
use pinakes_server::{app, state::AppState};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::info;
|
||||
|
|
@ -189,7 +190,7 @@ async fn main() -> Result<()> {
|
|||
|
||||
// Start filesystem watcher if configured
|
||||
if config.scanning.watch {
|
||||
let watch_storage = storage.clone();
|
||||
let watch_storage = Arc::clone(&storage);
|
||||
let watch_dirs = config.directories.roots.clone();
|
||||
let watch_ignore = config.scanning.ignore_patterns.clone();
|
||||
tokio::spawn(async move {
|
||||
|
|
@ -245,9 +246,9 @@ async fn main() -> Result<()> {
|
|||
max_concurrent_ops: p.max_concurrent_ops,
|
||||
plugin_timeout_secs: p.plugin_timeout_secs,
|
||||
timeouts: pinakes_types::config::PluginTimeoutConfig {
|
||||
capability_query_secs: p.timeouts.capability_query_secs,
|
||||
processing_secs: p.timeouts.processing_secs,
|
||||
event_handler_secs: p.timeouts.event_handler_secs,
|
||||
capability_query: p.timeouts.capability_query,
|
||||
processing: p.timeouts.processing,
|
||||
event_handler: p.timeouts.event_handler,
|
||||
},
|
||||
max_consecutive_failures: p.max_consecutive_failures,
|
||||
trusted_keys: p.trusted_keys.clone(),
|
||||
|
|
@ -297,7 +298,7 @@ async fn main() -> Result<()> {
|
|||
};
|
||||
|
||||
// Initialize job queue with executor
|
||||
let job_storage = storage.clone();
|
||||
let job_storage = Arc::clone(&storage);
|
||||
let job_config = config.clone();
|
||||
let job_transcode = transcode_service.clone();
|
||||
let job_webhooks = webhook_dispatcher.clone();
|
||||
|
|
@ -306,7 +307,7 @@ async fn main() -> Result<()> {
|
|||
config.jobs.worker_count,
|
||||
config.jobs.job_timeout_secs,
|
||||
move |job_id, kind, cancel, jobs| {
|
||||
let storage = job_storage.clone();
|
||||
let storage = Arc::clone(&job_storage);
|
||||
let config = job_config.clone();
|
||||
let transcode_svc = job_transcode.clone();
|
||||
let webhooks = job_webhooks.clone();
|
||||
|
|
@ -400,10 +401,16 @@ async fn main() -> Result<()> {
|
|||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
#[expect(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "progress ratio; precision loss negligible for \
|
||||
display"
|
||||
)]
|
||||
let progress = i as f32 / total as f32;
|
||||
JobQueue::update_progress(
|
||||
&jobs,
|
||||
job_id,
|
||||
i as f32 / total as f32,
|
||||
progress,
|
||||
format!("{i}/{total}"),
|
||||
)
|
||||
.await;
|
||||
|
|
@ -575,7 +582,12 @@ async fn main() -> Result<()> {
|
|||
enrich_cfg.sources.tmdb.enabled,
|
||||
enrich_cfg.sources.tmdb.api_key.clone(),
|
||||
) {
|
||||
enrichers.push(Box::new(TmdbEnricher::new(key)));
|
||||
match TmdbEnricher::new(key) {
|
||||
Ok(e) => enrichers.push(Box::new(e)),
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to build TMDB enricher: {err}");
|
||||
},
|
||||
}
|
||||
}
|
||||
if let (true, Some(key)) = (
|
||||
enrich_cfg.sources.lastfm.enabled,
|
||||
|
|
@ -613,7 +625,6 @@ async fn main() -> Result<()> {
|
|||
let category = item.media_type.category();
|
||||
for enricher in &enrichers {
|
||||
let source = enricher.source();
|
||||
use pinakes_enrichment::EnrichmentSourceType;
|
||||
let applicable = match source {
|
||||
EnrichmentSourceType::MusicBrainz
|
||||
| EnrichmentSourceType::LastFm => {
|
||||
|
|
@ -674,7 +685,7 @@ async fn main() -> Result<()> {
|
|||
JobKind::CleanupAnalytics => {
|
||||
let retention_days = config.analytics.retention_days;
|
||||
let before = chrono::Utc::now()
|
||||
- chrono::Duration::days(retention_days as i64);
|
||||
- chrono::Duration::days(retention_days.cast_signed());
|
||||
match storage.cleanup_old_events(before).await {
|
||||
Ok(count) => {
|
||||
JobQueue::complete(
|
||||
|
|
@ -690,7 +701,7 @@ async fn main() -> Result<()> {
|
|||
JobKind::TrashPurge => {
|
||||
let retention_days = config.trash.retention_days;
|
||||
let before = chrono::Utc::now()
|
||||
- chrono::Duration::days(retention_days as i64);
|
||||
- chrono::Duration::days(retention_days.cast_signed());
|
||||
|
||||
match storage.purge_old_trash(before).await {
|
||||
Ok(count) => {
|
||||
|
|
@ -723,9 +734,9 @@ async fn main() -> Result<()> {
|
|||
let shutdown_token = tokio_util::sync::CancellationToken::new();
|
||||
let config_arc = Arc::new(RwLock::new(config));
|
||||
let scheduler = pinakes_core::scheduler::TaskScheduler::new(
|
||||
job_queue.clone(),
|
||||
Arc::clone(&job_queue),
|
||||
shutdown_token.clone(),
|
||||
config_arc.clone(),
|
||||
Arc::clone(&config_arc),
|
||||
Some(config_path.clone()),
|
||||
);
|
||||
let scheduler = Arc::new(scheduler);
|
||||
|
|
@ -735,7 +746,7 @@ async fn main() -> Result<()> {
|
|||
|
||||
// Spawn scheduler background loop
|
||||
{
|
||||
let scheduler = scheduler.clone();
|
||||
let scheduler = Arc::clone(&scheduler);
|
||||
tokio::spawn(async move {
|
||||
scheduler.run().await;
|
||||
});
|
||||
|
|
@ -796,8 +807,8 @@ async fn main() -> Result<()> {
|
|||
};
|
||||
|
||||
let state = AppState {
|
||||
storage: storage.clone(),
|
||||
config: config_arc.clone(),
|
||||
storage: Arc::clone(&storage),
|
||||
config: Arc::clone(&config_arc),
|
||||
config_path: Some(config_path),
|
||||
scan_progress: pinakes_core::scan::ScanProgress::new(),
|
||||
job_queue,
|
||||
|
|
@ -816,7 +827,7 @@ async fn main() -> Result<()> {
|
|||
|
||||
// Periodic session cleanup (every 15 minutes)
|
||||
{
|
||||
let storage_clone = storage.clone();
|
||||
let storage_clone = Arc::clone(&storage);
|
||||
let cancel = shutdown_token.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval =
|
||||
|
|
@ -844,7 +855,7 @@ async fn main() -> Result<()> {
|
|||
|
||||
// Periodic chunked upload cleanup (every hour)
|
||||
if let Some(ref manager) = state.chunked_upload_manager {
|
||||
let manager_clone = manager.clone();
|
||||
let manager_clone = Arc::clone(manager);
|
||||
let cancel = shutdown_token.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval =
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ const MAX_LIMIT: u64 = 100;
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_most_viewed(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
|
|
@ -74,6 +77,9 @@ pub async fn get_most_viewed(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_recently_viewed(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -103,6 +109,9 @@ pub async fn get_recently_viewed(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn record_event(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -141,6 +150,9 @@ pub async fn record_event(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_watch_progress(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -174,6 +186,9 @@ pub async fn get_watch_progress(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn update_watch_progress(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ use crate::{
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_audit(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
use argon2::password_hash::PasswordVerifier;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
};
|
||||
use rand::seq::IndexedRandom as _;
|
||||
|
||||
use crate::{
|
||||
dto::{LoginRequest, LoginResponse, UserInfoResponse},
|
||||
|
|
@ -17,6 +19,16 @@ const DUMMY_HASH: &str =
|
|||
"$argon2id$v=19$m=19456,t=2,\
|
||||
p=1$VGltaW5nU2FmZUR1bW15$c2ltdWxhdGVkX2hhc2hfZm9yX3RpbWluZ19zYWZldHk";
|
||||
|
||||
/// Authenticate a user with username and password, creating a session.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the credentials are invalid or the session cannot be
|
||||
/// created.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the CHARSET is empty (it is not).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/auth/login",
|
||||
|
|
@ -53,12 +65,8 @@ pub async fn login(
|
|||
// Always perform password verification to prevent timing attacks.
|
||||
// If the user doesn't exist, we verify against a dummy hash to ensure
|
||||
// consistent response times regardless of whether the username exists.
|
||||
use argon2::password_hash::PasswordVerifier;
|
||||
|
||||
let (hash_to_verify, user_found) = match user {
|
||||
Some(u) => (&u.password_hash as &str, true),
|
||||
None => (DUMMY_HASH, false),
|
||||
};
|
||||
let (hash_to_verify, user_found) =
|
||||
user.map_or((DUMMY_HASH, false), |u| (&u.password_hash as &str, true));
|
||||
|
||||
let parsed_hash = argon2::password_hash::PasswordHash::new(hash_to_verify)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
|
@ -97,13 +105,14 @@ pub async fn login(
|
|||
// Generate session token using unbiased uniform distribution
|
||||
#[expect(clippy::expect_used)]
|
||||
let token: String = {
|
||||
use rand::seq::IndexedRandom;
|
||||
const CHARSET: &[u8] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let mut rng = rand::rng();
|
||||
(0..48)
|
||||
.map(|_| *CHARSET.choose(&mut rng).expect("non-empty charset") as char)
|
||||
.collect()
|
||||
std::iter::repeat_with(|| {
|
||||
*CHARSET.choose(&mut rng).expect("non-empty charset") as char
|
||||
})
|
||||
.take(48)
|
||||
.collect()
|
||||
};
|
||||
|
||||
let role = user.role;
|
||||
|
|
@ -118,7 +127,9 @@ pub async fn login(
|
|||
role: role.to_string(),
|
||||
created_at: now,
|
||||
expires_at: now
|
||||
+ chrono::Duration::hours(config.accounts.session_expiry_hours as i64),
|
||||
+ chrono::Duration::hours(
|
||||
config.accounts.session_expiry_hours.cast_signed(),
|
||||
),
|
||||
last_accessed: now,
|
||||
};
|
||||
|
||||
|
|
@ -195,6 +206,12 @@ pub async fn logout(
|
|||
StatusCode::OK
|
||||
}
|
||||
|
||||
/// Return current user info from the bearer token session.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the token is missing, invalid, or the session lookup
|
||||
/// fails.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/auth/me",
|
||||
|
|
@ -243,6 +260,11 @@ fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
|
|||
|
||||
/// Refresh the current session, extending its expiry by the configured
|
||||
/// duration.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the token is missing, the session does not exist, or the
|
||||
/// database update fails.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/auth/refresh",
|
||||
|
|
@ -261,7 +283,7 @@ pub async fn refresh(
|
|||
let token = extract_bearer_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let config = state.config.read().await;
|
||||
let expiry_hours = config.accounts.session_expiry_hours as i64;
|
||||
let expiry_hours = config.accounts.session_expiry_hours.cast_signed();
|
||||
drop(config);
|
||||
|
||||
let new_expires_at =
|
||||
|
|
@ -297,9 +319,8 @@ pub async fn revoke_all_sessions(
|
|||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> StatusCode {
|
||||
let token = match extract_bearer_token(&headers) {
|
||||
Some(t) => t,
|
||||
None => return StatusCode::UNAUTHORIZED,
|
||||
let Some(token) = extract_bearer_token(&headers) else {
|
||||
return StatusCode::UNAUTHORIZED;
|
||||
};
|
||||
|
||||
// Get current session to find username
|
||||
|
|
@ -340,7 +361,11 @@ pub async fn revoke_all_sessions(
|
|||
}
|
||||
}
|
||||
|
||||
/// List all active sessions (admin only)
|
||||
/// List all active sessions (admin only).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database query fails.
|
||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct SessionListResponse {
|
||||
pub sessions: Vec<SessionInfo>,
|
||||
|
|
@ -367,6 +392,9 @@ pub struct SessionInfo {
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_active_sessions(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<SessionListResponse>, StatusCode> {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ use crate::{error::ApiError, state::AppState};
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn create_backup(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Response, ApiError> {
|
||||
|
|
|
|||
|
|
@ -168,19 +168,23 @@ pub struct AuthorSummary {
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_book_metadata(
|
||||
State(state): State<AppState>,
|
||||
Path(media_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let media_id = MediaId(media_id);
|
||||
let metadata =
|
||||
state
|
||||
.storage
|
||||
.get_book_metadata(media_id)
|
||||
.await?
|
||||
.ok_or(ApiError(PinakesError::NotFound(
|
||||
let metadata = state
|
||||
.storage
|
||||
.get_book_metadata(media_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError(PinakesError::NotFound(
|
||||
"Book metadata not found".to_string(),
|
||||
)))?;
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(Json(BookMetadataResponse::from(metadata)))
|
||||
}
|
||||
|
|
@ -206,6 +210,9 @@ pub async fn get_book_metadata(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_books(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<SearchBooksQuery>,
|
||||
|
|
@ -247,6 +254,9 @@ pub async fn list_books(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_series(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
|
|
@ -276,6 +286,9 @@ pub async fn list_series(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_series_books(
|
||||
State(state): State<AppState>,
|
||||
Path(series_name): Path<String>,
|
||||
|
|
@ -304,6 +317,9 @@ pub async fn get_series_books(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_authors(
|
||||
State(state): State<AppState>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
|
|
@ -338,6 +354,9 @@ pub async fn list_authors(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_author_books(
|
||||
State(state): State<AppState>,
|
||||
Path(author_name): Path<String>,
|
||||
|
|
@ -369,6 +388,9 @@ pub async fn get_author_books(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_reading_progress(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -381,9 +403,11 @@ pub async fn get_reading_progress(
|
|||
.storage
|
||||
.get_reading_progress(user_id.0, media_id)
|
||||
.await?
|
||||
.ok_or(ApiError(PinakesError::NotFound(
|
||||
"Reading progress not found".to_string(),
|
||||
)))?;
|
||||
.ok_or_else(|| {
|
||||
ApiError(PinakesError::NotFound(
|
||||
"Reading progress not found".to_string(),
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(Json(ReadingProgressResponse::from(progress)))
|
||||
}
|
||||
|
|
@ -402,6 +426,9 @@ pub async fn get_reading_progress(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn update_reading_progress(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -438,6 +465,9 @@ pub async fn update_reading_progress(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_reading_list(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ use crate::{
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn create_collection(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateCollectionRequest>,
|
||||
|
|
@ -85,6 +88,9 @@ pub async fn create_collection(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_collections(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<CollectionResponse>>, ApiError> {
|
||||
|
|
@ -107,6 +113,9 @@ pub async fn list_collections(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_collection(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -129,6 +138,9 @@ pub async fn get_collection(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn delete_collection(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -158,6 +170,9 @@ pub async fn delete_collection(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn add_member(
|
||||
State(state): State<AppState>,
|
||||
Path(collection_id): Path<Uuid>,
|
||||
|
|
@ -190,6 +205,9 @@ pub async fn add_member(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn remove_member(
|
||||
State(state): State<AppState>,
|
||||
Path((collection_id, media_id)): Path<(Uuid, Uuid)>,
|
||||
|
|
@ -216,6 +234,9 @@ pub async fn remove_member(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_members(
|
||||
State(state): State<AppState>,
|
||||
Path(collection_id): Path<Uuid>,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ use crate::{
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_config(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<ConfigResponse>, ApiError> {
|
||||
|
|
@ -36,18 +39,15 @@ pub async fn get_config(
|
|||
.config_path
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().to_string());
|
||||
let config_writable = match &state.config_path {
|
||||
Some(path) => {
|
||||
if path.exists() {
|
||||
std::fs::metadata(path).is_ok_and(|m| !m.permissions().readonly())
|
||||
} else {
|
||||
path.parent().is_some_and(|parent| {
|
||||
std::fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly())
|
||||
})
|
||||
}
|
||||
},
|
||||
None => false,
|
||||
};
|
||||
let config_writable = state.config_path.as_ref().is_some_and(|path| {
|
||||
if path.exists() {
|
||||
std::fs::metadata(path).is_ok_and(|m| !m.permissions().readonly())
|
||||
} else {
|
||||
path.parent().is_some_and(|parent| {
|
||||
std::fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly())
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Json(ConfigResponse {
|
||||
backend: config.storage.backend.to_string(),
|
||||
|
|
@ -86,6 +86,9 @@ pub async fn get_config(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_ui_config(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<UiConfigResponse>, ApiError> {
|
||||
|
|
@ -106,6 +109,9 @@ pub async fn get_ui_config(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn update_ui_config(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<UpdateUiConfigRequest>,
|
||||
|
|
@ -153,6 +159,9 @@ pub async fn update_ui_config(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn update_scanning_config(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<UpdateScanningRequest>,
|
||||
|
|
@ -179,18 +188,15 @@ pub async fn update_scanning_config(
|
|||
.config_path
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().to_string());
|
||||
let config_writable = match &state.config_path {
|
||||
Some(path) => {
|
||||
if path.exists() {
|
||||
std::fs::metadata(path).is_ok_and(|m| !m.permissions().readonly())
|
||||
} else {
|
||||
path.parent().is_some_and(|parent| {
|
||||
std::fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly())
|
||||
})
|
||||
}
|
||||
},
|
||||
None => false,
|
||||
};
|
||||
let config_writable = state.config_path.as_ref().is_some_and(|path| {
|
||||
if path.exists() {
|
||||
std::fs::metadata(path).is_ok_and(|m| !m.permissions().readonly())
|
||||
} else {
|
||||
path.parent().is_some_and(|parent| {
|
||||
std::fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly())
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Json(ConfigResponse {
|
||||
backend: config.storage.backend.to_string(),
|
||||
|
|
@ -232,6 +238,9 @@ pub async fn update_scanning_config(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn add_root(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RootDirRequest>,
|
||||
|
|
@ -272,6 +281,9 @@ pub async fn add_root(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn remove_root(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RootDirRequest>,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ use crate::{dto::DatabaseStatsResponse, error::ApiError, state::AppState};
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn database_stats(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<DatabaseStatsResponse>, ApiError> {
|
||||
|
|
@ -40,6 +43,9 @@ pub async fn database_stats(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn vacuum_database(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
|
|
@ -59,6 +65,9 @@ pub async fn vacuum_database(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn clear_database(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ use crate::{
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_duplicates(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<DuplicateGroupResponse>>, ApiError> {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ use crate::{
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn trigger_enrichment(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -52,6 +55,9 @@ pub async fn trigger_enrichment(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_external_metadata(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -79,6 +85,9 @@ pub async fn get_external_metadata(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn batch_enrich(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<BatchDeleteRequest>, // Reuse: has media_ids field
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ pub struct ExportRequest {
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn trigger_export(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
|
|
@ -51,6 +54,9 @@ pub async fn trigger_export(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn trigger_export_with_options(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ExportRequest>,
|
||||
|
|
|
|||
|
|
@ -66,7 +66,8 @@ pub async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
|
|||
Ok(count) => {
|
||||
DatabaseHealth {
|
||||
status: "ok".to_string(),
|
||||
latency_ms: db_start.elapsed().as_millis() as u64,
|
||||
latency_ms: u64::try_from(db_start.elapsed().as_millis())
|
||||
.unwrap_or(u64::MAX),
|
||||
media_count: Some(count),
|
||||
}
|
||||
},
|
||||
|
|
@ -74,7 +75,8 @@ pub async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
|
|||
response.status = "degraded".to_string();
|
||||
DatabaseHealth {
|
||||
status: format!("error: {e}"),
|
||||
latency_ms: db_start.elapsed().as_millis() as u64,
|
||||
latency_ms: u64::try_from(db_start.elapsed().as_millis())
|
||||
.unwrap_or(u64::MAX),
|
||||
media_count: None,
|
||||
}
|
||||
},
|
||||
|
|
@ -147,7 +149,8 @@ pub async fn readiness(State(state): State<AppState>) -> impl IntoResponse {
|
|||
let db_start = Instant::now();
|
||||
match state.storage.count_media().await {
|
||||
Ok(_) => {
|
||||
let latency = db_start.elapsed().as_millis() as u64;
|
||||
let latency =
|
||||
u64::try_from(db_start.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
|
|
@ -203,7 +206,8 @@ pub async fn health_detailed(
|
|||
Ok(count) => ("ok".to_string(), Some(count)),
|
||||
Err(e) => (format!("error: {e}"), None),
|
||||
};
|
||||
let db_latency = db_start.elapsed().as_millis() as u64;
|
||||
let db_latency =
|
||||
u64::try_from(db_start.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
// Check filesystem
|
||||
let roots = state.storage.list_root_dirs().await.unwrap_or_default();
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ pub struct OrphanResolveRequest {
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn trigger_orphan_detection(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
|
|
@ -42,6 +45,9 @@ pub async fn trigger_orphan_detection(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn trigger_verify_integrity(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<VerifyIntegrityRequest>,
|
||||
|
|
@ -73,6 +79,9 @@ pub struct VerifyIntegrityRequest {
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn trigger_cleanup_thumbnails(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
|
|
@ -102,6 +111,9 @@ pub struct GenerateThumbnailsRequest {
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn generate_all_thumbnails(
|
||||
State(state): State<AppState>,
|
||||
body: Option<Json<GenerateThumbnailsRequest>>,
|
||||
|
|
@ -140,6 +152,9 @@ pub async fn generate_all_thumbnails(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn resolve_orphans(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<OrphanResolveRequest>,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ pub async fn list_jobs(State(state): State<AppState>) -> Json<Vec<Job>> {
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_job(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
|
|
@ -57,6 +60,9 @@ pub async fn get_job(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn cancel_job(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ use axum::{
|
|||
Json,
|
||||
extract::{Path, Query, State},
|
||||
};
|
||||
use pinakes_core::{model::MediaId, storage::DynStorageBackend};
|
||||
use pinakes_core::{
|
||||
model::{CustomField, CustomFieldType, MediaId},
|
||||
storage::DynStorageBackend,
|
||||
};
|
||||
use rustc_hash::FxHashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
|
@ -113,6 +116,9 @@ async fn apply_import_post_processing(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn import_media(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ImportRequest>,
|
||||
|
|
@ -156,6 +162,9 @@ pub async fn import_media(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_media(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
|
|
@ -184,6 +193,9 @@ pub async fn list_media(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_media(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -199,7 +211,7 @@ const MAX_SHORT_TEXT: usize = 500;
|
|||
const MAX_LONG_TEXT: usize = 10_000;
|
||||
|
||||
fn validate_optional_text(
|
||||
field: &Option<String>,
|
||||
field: Option<&str>,
|
||||
name: &str,
|
||||
max: usize,
|
||||
) -> Result<(), ApiError> {
|
||||
|
|
@ -231,16 +243,23 @@ fn validate_optional_text(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn update_media(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateMediaRequest>,
|
||||
) -> Result<Json<MediaResponse>, ApiError> {
|
||||
validate_optional_text(&req.title, "title", MAX_SHORT_TEXT)?;
|
||||
validate_optional_text(&req.artist, "artist", MAX_SHORT_TEXT)?;
|
||||
validate_optional_text(&req.album, "album", MAX_SHORT_TEXT)?;
|
||||
validate_optional_text(&req.genre, "genre", MAX_SHORT_TEXT)?;
|
||||
validate_optional_text(&req.description, "description", MAX_LONG_TEXT)?;
|
||||
validate_optional_text(req.title.as_deref(), "title", MAX_SHORT_TEXT)?;
|
||||
validate_optional_text(req.artist.as_deref(), "artist", MAX_SHORT_TEXT)?;
|
||||
validate_optional_text(req.album.as_deref(), "album", MAX_SHORT_TEXT)?;
|
||||
validate_optional_text(req.genre.as_deref(), "genre", MAX_SHORT_TEXT)?;
|
||||
validate_optional_text(
|
||||
req.description.as_deref(),
|
||||
"description",
|
||||
MAX_LONG_TEXT,
|
||||
)?;
|
||||
|
||||
let mut item = state.storage.get_media(MediaId(id)).await?;
|
||||
|
||||
|
|
@ -302,6 +321,9 @@ pub async fn update_media(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn delete_media(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -353,6 +375,9 @@ pub async fn delete_media(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn open_media(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -384,6 +409,9 @@ pub async fn open_media(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn stream_media(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -509,6 +537,9 @@ fn parse_range(header: &str, total_size: u64) -> Option<(u64, u64)> {
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn import_with_options(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ImportWithOptionsRequest>,
|
||||
|
|
@ -557,6 +588,9 @@ pub async fn import_with_options(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn batch_import(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<BatchImportRequest>,
|
||||
|
|
@ -645,6 +679,9 @@ pub async fn batch_import(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn import_directory_endpoint(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<DirectoryImportRequest>,
|
||||
|
|
@ -713,6 +750,50 @@ pub async fn import_directory_endpoint(
|
|||
}))
|
||||
}
|
||||
|
||||
fn walk_dir_preview(
|
||||
dir: &std::path::Path,
|
||||
recursive: bool,
|
||||
roots: &[std::path::PathBuf],
|
||||
result: &mut Vec<DirectoryPreviewFile>,
|
||||
) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
// Skip hidden files/dirs
|
||||
if path
|
||||
.file_name()
|
||||
.is_some_and(|n| n.to_string_lossy().starts_with('.'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if path.is_dir() {
|
||||
if recursive {
|
||||
walk_dir_preview(&path, recursive, roots, result);
|
||||
}
|
||||
} else if path.is_file()
|
||||
&& let Some(mt) = pinakes_core::media_type::MediaType::from_path(&path)
|
||||
{
|
||||
let size = entry.metadata().ok().map_or(0, |m| m.len());
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let media_type = serde_json::to_value(mt)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_default();
|
||||
result.push(DirectoryPreviewFile {
|
||||
path: crate::dto::relativize_path(&path, roots),
|
||||
file_name,
|
||||
media_type,
|
||||
file_size: size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/media/import/preview",
|
||||
|
|
@ -726,6 +807,9 @@ pub async fn import_directory_endpoint(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn preview_directory(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<serde_json::Value>,
|
||||
|
|
@ -765,51 +849,7 @@ pub async fn preview_directory(
|
|||
let files: Vec<DirectoryPreviewFile> =
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut result = Vec::new();
|
||||
fn walk_dir(
|
||||
dir: &std::path::Path,
|
||||
recursive: bool,
|
||||
roots: &[std::path::PathBuf],
|
||||
result: &mut Vec<DirectoryPreviewFile>,
|
||||
) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
// Skip hidden files/dirs
|
||||
if path
|
||||
.file_name()
|
||||
.is_some_and(|n| n.to_string_lossy().starts_with('.'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if path.is_dir() {
|
||||
if recursive {
|
||||
walk_dir(&path, recursive, roots, result);
|
||||
}
|
||||
} else if path.is_file()
|
||||
&& let Some(mt) =
|
||||
pinakes_core::media_type::MediaType::from_path(&path)
|
||||
{
|
||||
let size = entry.metadata().ok().map_or(0, |m| m.len());
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let media_type = serde_json::to_value(mt)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_default();
|
||||
result.push(DirectoryPreviewFile {
|
||||
path: crate::dto::relativize_path(&path, roots),
|
||||
file_name,
|
||||
media_type,
|
||||
file_size: size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
walk_dir(&dir, recursive, &roots_for_walk, &mut result);
|
||||
walk_dir_preview(&dir, recursive, &roots_for_walk, &mut result);
|
||||
result
|
||||
})
|
||||
.await
|
||||
|
|
@ -843,6 +883,9 @@ pub async fn preview_directory(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn set_custom_field(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -862,7 +905,6 @@ pub async fn set_custom_field(
|
|||
)),
|
||||
));
|
||||
}
|
||||
use pinakes_core::model::{CustomField, CustomFieldType};
|
||||
let field_type = match req.field_type.as_str() {
|
||||
"number" => CustomFieldType::Number,
|
||||
"date" => CustomFieldType::Date,
|
||||
|
|
@ -897,6 +939,9 @@ pub async fn set_custom_field(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn delete_custom_field(
|
||||
State(state): State<AppState>,
|
||||
Path((id, name)): Path<(Uuid, String)>,
|
||||
|
|
@ -922,6 +967,9 @@ pub async fn delete_custom_field(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn batch_tag(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<BatchTagRequest>,
|
||||
|
|
@ -943,7 +991,7 @@ pub async fn batch_tag(
|
|||
{
|
||||
Ok(count) => {
|
||||
Ok(Json(BatchOperationResponse {
|
||||
processed: count as usize,
|
||||
processed: usize::try_from(count).unwrap_or(usize::MAX),
|
||||
errors: Vec::new(),
|
||||
}))
|
||||
},
|
||||
|
|
@ -968,6 +1016,9 @@ pub async fn batch_tag(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn delete_all_media(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<BatchOperationResponse>, ApiError> {
|
||||
|
|
@ -986,7 +1037,7 @@ pub async fn delete_all_media(
|
|||
match state.storage.delete_all_media().await {
|
||||
Ok(count) => {
|
||||
Ok(Json(BatchOperationResponse {
|
||||
processed: count as usize,
|
||||
processed: usize::try_from(count).unwrap_or(usize::MAX),
|
||||
errors: Vec::new(),
|
||||
}))
|
||||
},
|
||||
|
|
@ -1013,6 +1064,9 @@ pub async fn delete_all_media(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn batch_delete(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<BatchDeleteRequest>,
|
||||
|
|
@ -1044,7 +1098,7 @@ pub async fn batch_delete(
|
|||
match state.storage.batch_delete_media(&media_ids).await {
|
||||
Ok(count) => {
|
||||
Ok(Json(BatchOperationResponse {
|
||||
processed: count as usize,
|
||||
processed: usize::try_from(count).unwrap_or(usize::MAX),
|
||||
errors: Vec::new(),
|
||||
}))
|
||||
},
|
||||
|
|
@ -1071,6 +1125,9 @@ pub async fn batch_delete(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn batch_add_to_collection(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<BatchCollectionRequest>,
|
||||
|
|
@ -1090,7 +1147,7 @@ pub async fn batch_add_to_collection(
|
|||
&state.storage,
|
||||
req.collection_id,
|
||||
MediaId(*media_id),
|
||||
i as i32,
|
||||
i32::try_from(i).unwrap_or(i32::MAX),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -1115,6 +1172,9 @@ pub async fn batch_add_to_collection(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn batch_update(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<BatchUpdateRequest>,
|
||||
|
|
@ -1144,7 +1204,7 @@ pub async fn batch_update(
|
|||
{
|
||||
Ok(count) => {
|
||||
Ok(Json(BatchOperationResponse {
|
||||
processed: count as usize,
|
||||
processed: usize::try_from(count).unwrap_or(usize::MAX),
|
||||
errors: Vec::new(),
|
||||
}))
|
||||
},
|
||||
|
|
@ -1170,6 +1230,9 @@ pub async fn batch_update(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_thumbnail(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -1214,6 +1277,9 @@ pub async fn get_thumbnail(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_media_count(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<MediaCountResponse>, ApiError> {
|
||||
|
|
@ -1237,6 +1303,9 @@ pub async fn get_media_count(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn rename_media(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -1305,6 +1374,9 @@ pub async fn rename_media(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn move_media_endpoint(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -1368,6 +1440,9 @@ pub async fn move_media_endpoint(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn batch_move_media(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<BatchMoveRequest>,
|
||||
|
|
@ -1451,6 +1526,9 @@ pub async fn batch_move_media(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn soft_delete_media(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -1511,6 +1589,9 @@ pub async fn soft_delete_media(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn restore_media(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -1573,6 +1654,9 @@ pub async fn restore_media(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_trash(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
|
|
@ -1603,6 +1687,9 @@ pub async fn list_trash(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn trash_info(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<TrashInfoResponse>, ApiError> {
|
||||
|
|
@ -1622,6 +1709,9 @@ pub async fn trash_info(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn empty_trash(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<EmptyTrashResponse>, ApiError> {
|
||||
|
|
@ -1656,6 +1746,14 @@ pub async fn empty_trash(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
// axum handlers cannot be generic over hasher types without breaking routing
|
||||
#[expect(
|
||||
clippy::implicit_hasher,
|
||||
reason = "axum handler; generic over hasher breaks routing"
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn permanent_delete_media(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
|
|||
|
|
@ -12,13 +12,16 @@ use axum::{
|
|||
extract::{Path, Query, State},
|
||||
routing::{get, post},
|
||||
};
|
||||
use pinakes_core::model::{
|
||||
BacklinkInfo,
|
||||
GraphData,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
MarkdownLink,
|
||||
MediaId,
|
||||
use pinakes_core::{
|
||||
media_type::{BuiltinMediaType, MediaType},
|
||||
model::{
|
||||
BacklinkInfo,
|
||||
GraphData,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
MarkdownLink,
|
||||
MediaId,
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
|
@ -214,6 +217,9 @@ pub struct UnresolvedLinksResponse {
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_backlinks(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -247,6 +253,9 @@ pub async fn get_backlinks(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_outgoing_links(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -282,6 +291,9 @@ pub async fn get_outgoing_links(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_graph(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<GraphQuery>,
|
||||
|
|
@ -310,6 +322,9 @@ pub async fn get_graph(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn reindex_links(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -320,7 +335,6 @@ pub async fn reindex_links(
|
|||
let media = state.storage.get_media(media_id).await?;
|
||||
|
||||
// Only process markdown files
|
||||
use pinakes_core::media_type::{BuiltinMediaType, MediaType};
|
||||
match &media.media_type {
|
||||
MediaType::Builtin(BuiltinMediaType::Markdown) => {},
|
||||
_ => {
|
||||
|
|
@ -369,6 +383,9 @@ pub async fn reindex_links(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn resolve_links(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<ResolveLinksResponse>, ApiError> {
|
||||
|
|
@ -391,6 +408,9 @@ pub async fn resolve_links(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_unresolved_count(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<UnresolvedLinksResponse>, ApiError> {
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@ pub struct MapMarker {
|
|||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// Get timeline of photos grouped by date
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_timeline(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<TimelineQuery>,
|
||||
|
|
@ -183,6 +187,10 @@ pub async fn get_timeline(
|
|||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// Get photos in a bounding box for map view
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_map_photos(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<MapQuery>,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use axum::{
|
|||
extract::{Extension, Path, State},
|
||||
};
|
||||
use pinakes_core::{model::MediaId, playlists::Playlist, users::UserId};
|
||||
use rand::seq::SliceRandom as _;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
|
|
@ -64,6 +65,9 @@ async fn check_playlist_access(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn create_playlist(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -102,6 +106,9 @@ pub async fn create_playlist(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_playlists(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -130,6 +137,9 @@ pub async fn list_playlists(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_playlist(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -156,6 +166,9 @@ pub async fn get_playlist(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn update_playlist(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -198,6 +211,9 @@ pub async fn update_playlist(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn delete_playlist(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -223,6 +239,9 @@ pub async fn delete_playlist(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn add_item(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -235,7 +254,7 @@ pub async fn add_item(
|
|||
p
|
||||
} else {
|
||||
let items = state.storage.get_playlist_items(id).await?;
|
||||
items.len() as i32
|
||||
i32::try_from(items.len()).unwrap_or(i32::MAX)
|
||||
};
|
||||
state
|
||||
.storage
|
||||
|
|
@ -260,6 +279,9 @@ pub async fn add_item(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn remove_item(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -287,6 +309,9 @@ pub async fn remove_item(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_items(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -318,6 +343,9 @@ pub async fn list_items(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn reorder_item(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -346,6 +374,9 @@ pub async fn reorder_item(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn shuffle_playlist(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -353,7 +384,6 @@ pub async fn shuffle_playlist(
|
|||
) -> Result<Json<Vec<MediaResponse>>, ApiError> {
|
||||
let user_id = resolve_user_id(&state.storage, &username).await?;
|
||||
check_playlist_access(&state.storage, id, user_id, false).await?;
|
||||
use rand::seq::SliceRandom;
|
||||
let mut items = state.storage.get_playlist_items(id).await?;
|
||||
items.shuffle(&mut rand::rng());
|
||||
let roots = state.config.read().await.directories.roots.clone();
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ fn require_plugin_manager(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_plugins(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<PluginResponse>>, ApiError> {
|
||||
|
|
@ -69,6 +72,9 @@ pub async fn list_plugins(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_plugin(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
|
|
@ -99,6 +105,9 @@ pub async fn get_plugin(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn install_plugin(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<InstallPluginRequest>,
|
||||
|
|
@ -140,6 +149,9 @@ pub async fn install_plugin(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn uninstall_plugin(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
|
|
@ -170,6 +182,9 @@ pub async fn uninstall_plugin(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn toggle_plugin(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
|
|
@ -219,6 +234,9 @@ pub async fn toggle_plugin(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_plugin_ui_pages(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<PluginUiPageEntry>>, ApiError> {
|
||||
|
|
@ -249,6 +267,9 @@ pub async fn list_plugin_ui_pages(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_plugin_ui_widgets(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<PluginUiWidgetEntry>>, ApiError> {
|
||||
|
|
@ -275,6 +296,9 @@ pub async fn list_plugin_ui_widgets(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn emit_plugin_event(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<PluginEventRequest>,
|
||||
|
|
@ -297,6 +321,9 @@ pub async fn emit_plugin_event(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_plugin_ui_theme_extensions(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<FxHashMap<String, String>>, ApiError> {
|
||||
|
|
@ -318,6 +345,9 @@ pub async fn list_plugin_ui_theme_extensions(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn reload_plugin(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ const VALID_SORT_ORDERS: &[&str] = &[
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn create_saved_search(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateSavedSearchRequest>,
|
||||
|
|
@ -100,6 +103,9 @@ pub async fn create_saved_search(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_saved_searches(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<SavedSearchResponse>>, ApiError> {
|
||||
|
|
@ -137,6 +143,9 @@ pub async fn list_saved_searches(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn delete_saved_search(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ use crate::{
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn trigger_scan(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ScanRequest>,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ use crate::{dto::ScheduledTaskResponse, error::ApiError, state::AppState};
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_scheduled_tasks(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<ScheduledTaskResponse>>, ApiError> {
|
||||
|
|
@ -50,23 +53,26 @@ pub async fn list_scheduled_tasks(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn toggle_scheduled_task(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
match state.scheduler.toggle_task(&id).await {
|
||||
Some(enabled) => {
|
||||
state.scheduler.toggle_task(&id).await.map_or_else(
|
||||
|| {
|
||||
Err(ApiError(pinakes_core::error::PinakesError::NotFound(
|
||||
format!("scheduled task not found: {id}"),
|
||||
)))
|
||||
},
|
||||
|enabled| {
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": id,
|
||||
"enabled": enabled,
|
||||
})))
|
||||
},
|
||||
None => {
|
||||
Err(ApiError(pinakes_core::error::PinakesError::NotFound(
|
||||
format!("scheduled task not found: {id}"),
|
||||
)))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
|
|
@ -82,21 +88,24 @@ pub async fn toggle_scheduled_task(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn run_scheduled_task_now(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
match state.scheduler.run_now(&id).await {
|
||||
Some(job_id) => {
|
||||
state.scheduler.run_now(&id).await.map_or_else(
|
||||
|| {
|
||||
Err(ApiError(pinakes_core::error::PinakesError::NotFound(
|
||||
format!("scheduled task not found: {id}"),
|
||||
)))
|
||||
},
|
||||
|job_id| {
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": id,
|
||||
"job_id": job_id,
|
||||
})))
|
||||
},
|
||||
None => {
|
||||
Err(ApiError(pinakes_core::error::PinakesError::NotFound(
|
||||
format!("scheduled task not found: {id}"),
|
||||
)))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ fn resolve_sort(sort: Option<&str>) -> SortOrder {
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn search(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<SearchParams>,
|
||||
|
|
@ -87,6 +90,9 @@ pub async fn search(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn search_post(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<SearchRequestBody>,
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ use crate::{
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn create_share(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -149,27 +152,28 @@ pub async fn create_share(
|
|||
};
|
||||
|
||||
// Parse permissions
|
||||
let permissions = if let Some(perms) = req.permissions {
|
||||
SharePermissions {
|
||||
view: ShareViewPermissions {
|
||||
can_view: perms.can_view.unwrap_or(true),
|
||||
can_download: perms.can_download.unwrap_or(false),
|
||||
can_reshare: perms.can_reshare.unwrap_or(false),
|
||||
},
|
||||
mutate: ShareMutatePermissions {
|
||||
can_edit: perms.can_edit.unwrap_or(false),
|
||||
can_delete: perms.can_delete.unwrap_or(false),
|
||||
can_add: perms.can_add.unwrap_or(false),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
SharePermissions::view_only()
|
||||
};
|
||||
let permissions =
|
||||
req
|
||||
.permissions
|
||||
.map_or_else(SharePermissions::view_only, |perms| {
|
||||
SharePermissions {
|
||||
view: ShareViewPermissions {
|
||||
can_view: perms.can_view.unwrap_or(true),
|
||||
can_download: perms.can_download.unwrap_or(false),
|
||||
can_reshare: perms.can_reshare.unwrap_or(false),
|
||||
},
|
||||
mutate: ShareMutatePermissions {
|
||||
can_edit: perms.can_edit.unwrap_or(false),
|
||||
can_delete: perms.can_delete.unwrap_or(false),
|
||||
can_add: perms.can_add.unwrap_or(false),
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate expiration
|
||||
let expires_at = req
|
||||
.expires_in_hours
|
||||
.map(|hours| Utc::now() + chrono::Duration::hours(hours as i64));
|
||||
let expires_at = req.expires_in_hours.map(|hours: u64| {
|
||||
Utc::now() + chrono::Duration::hours(hours.cast_signed())
|
||||
});
|
||||
|
||||
let share = Share {
|
||||
id: ShareId(Uuid::now_v7()),
|
||||
|
|
@ -228,6 +232,9 @@ pub async fn create_share(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_outgoing(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -261,6 +268,9 @@ pub async fn list_outgoing(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_incoming(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -293,6 +303,9 @@ pub async fn list_incoming(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_share(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -337,6 +350,9 @@ pub async fn get_share(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn update_share(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -430,6 +446,9 @@ pub async fn update_share(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn delete_share(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -487,6 +506,9 @@ pub async fn delete_share(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn batch_delete(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -540,6 +562,9 @@ pub async fn batch_delete(
|
|||
(status = 404, description = "Not found"),
|
||||
)
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn access_shared(
|
||||
State(state): State<AppState>,
|
||||
Path(token): Path<String>,
|
||||
|
|
@ -618,8 +643,8 @@ pub async fn access_shared(
|
|||
.await
|
||||
.map_err(|e| ApiError::not_found(format!("Media not found: {e}")))?;
|
||||
|
||||
Ok(Json(SharedContentResponse::Single(MediaResponse::new(
|
||||
item, &roots,
|
||||
Ok(Json(SharedContentResponse::Single(Box::new(
|
||||
MediaResponse::new(item, &roots),
|
||||
))))
|
||||
},
|
||||
ShareTarget::Collection { collection_id } => {
|
||||
|
|
@ -724,6 +749,9 @@ pub async fn access_shared(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_activity(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -767,6 +795,9 @@ pub async fn get_activity(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_notifications(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -796,6 +827,9 @@ pub async fn get_notifications(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn mark_notification_read(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -823,6 +857,9 @@ pub async fn mark_notification_read(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn mark_all_read(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ use crate::{
|
|||
state::AppState,
|
||||
};
|
||||
|
||||
const MAX_SHARE_EXPIRY_HOURS: u64 = 8760; // 1 year
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ShareLinkQuery {
|
||||
pub password: Option<String>,
|
||||
|
|
@ -41,6 +43,9 @@ pub struct ShareLinkQuery {
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn rate_media(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -85,6 +90,9 @@ pub async fn rate_media(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_media_ratings(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -109,6 +117,9 @@ pub async fn get_media_ratings(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn add_comment(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -143,6 +154,9 @@ pub async fn add_comment(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_media_comments(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -165,6 +179,9 @@ pub async fn get_media_comments(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn add_favorite(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -190,6 +207,9 @@ pub async fn add_favorite(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn remove_favorite(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -214,6 +234,9 @@ pub async fn remove_favorite(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_favorites(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -245,6 +268,9 @@ pub async fn list_favorites(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn create_share_link(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -265,19 +291,18 @@ pub async fn create_share_link(
|
|||
},
|
||||
None => None,
|
||||
};
|
||||
const MAX_EXPIRY_HOURS: u64 = 8760; // 1 year
|
||||
if let Some(h) = req.expires_in_hours
|
||||
&& h > MAX_EXPIRY_HOURS
|
||||
&& h > MAX_SHARE_EXPIRY_HOURS
|
||||
{
|
||||
return Err(ApiError(
|
||||
pinakes_core::error::PinakesError::InvalidOperation(format!(
|
||||
"expires_in_hours cannot exceed {MAX_EXPIRY_HOURS}"
|
||||
"expires_in_hours cannot exceed {MAX_SHARE_EXPIRY_HOURS}"
|
||||
)),
|
||||
));
|
||||
}
|
||||
let expires_at = req
|
||||
.expires_in_hours
|
||||
.map(|h| chrono::Utc::now() + chrono::Duration::hours(h as i64));
|
||||
let expires_at = req.expires_in_hours.map(|h: u64| {
|
||||
chrono::Utc::now() + chrono::Duration::hours(h.cast_signed())
|
||||
});
|
||||
let link = state
|
||||
.storage
|
||||
.create_share_link(
|
||||
|
|
@ -305,6 +330,9 @@ pub async fn create_share_link(
|
|||
(status = 404, description = "Not found"),
|
||||
)
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn access_shared_media(
|
||||
State(state): State<AppState>,
|
||||
Path(token): Path<String>,
|
||||
|
|
@ -330,15 +358,10 @@ pub async fn access_shared_media(
|
|||
}
|
||||
// Verify password if set
|
||||
if let Some(ref hash) = link.password_hash {
|
||||
let password = match query.password.as_deref() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Err(ApiError(
|
||||
pinakes_core::error::PinakesError::Authentication(
|
||||
"password required for this share link".into(),
|
||||
),
|
||||
));
|
||||
},
|
||||
let Some(password) = query.password.as_deref() else {
|
||||
return Err(ApiError(pinakes_core::error::PinakesError::Authentication(
|
||||
"password required for this share link".into(),
|
||||
)));
|
||||
};
|
||||
let valid = pinakes_core::users::auth::verify_password(password, hash)
|
||||
.unwrap_or(false);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ use crate::{dto::LibraryStatisticsResponse, error::ApiError, state::AppState};
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn library_statistics(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<LibraryStatisticsResponse>, ApiError> {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
use std::fmt::Write as _;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
|
|
@ -61,6 +63,9 @@ fn escape_xml(s: &str) -> String {
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn hls_master_playlist(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -78,10 +83,11 @@ pub async fn hls_master_playlist(
|
|||
let bandwidth = estimate_bandwidth(profile);
|
||||
let encoded_name =
|
||||
utf8_percent_encode(&profile.name, NON_ALPHANUMERIC).to_string();
|
||||
playlist.push_str(&format!(
|
||||
let _ = write!(
|
||||
playlist,
|
||||
"#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},RESOLUTION={w}x{h}\n/api/v1/\
|
||||
media/{id}/stream/hls/{encoded_name}/playlist.m3u8\n\n",
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
build_response("application/vnd.apple.mpegurl", playlist)
|
||||
|
|
@ -103,6 +109,9 @@ pub async fn hls_master_playlist(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn hls_variant_playlist(
|
||||
State(state): State<AppState>,
|
||||
Path((id, profile)): Path<(Uuid, String)>,
|
||||
|
|
@ -118,6 +127,12 @@ pub async fn hls_variant_playlist(
|
|||
));
|
||||
}
|
||||
let segment_duration = 10.0;
|
||||
#[expect(
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "duration/segment_duration is always non-negative and bounded by \
|
||||
media length"
|
||||
)]
|
||||
let num_segments = (duration / segment_duration).ceil() as usize;
|
||||
|
||||
let mut playlist = String::from(
|
||||
|
|
@ -126,14 +141,20 @@ pub async fn hls_variant_playlist(
|
|||
);
|
||||
for i in 0..num_segments.max(1) {
|
||||
let seg_dur = if i == num_segments.saturating_sub(1) && duration > 0.0 {
|
||||
(i as f64).mul_add(-segment_duration, duration)
|
||||
#[expect(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "segment index is small, precision loss is negligible"
|
||||
)]
|
||||
let i_f64 = i as f64;
|
||||
i_f64.mul_add(-segment_duration, duration)
|
||||
} else {
|
||||
segment_duration
|
||||
};
|
||||
playlist.push_str(&format!("#EXTINF:{seg_dur:.3},\n"));
|
||||
playlist.push_str(&format!(
|
||||
"/api/v1/media/{id}/stream/hls/{profile}/segment{i}.ts\n"
|
||||
));
|
||||
let _ = writeln!(playlist, "#EXTINF:{seg_dur:.3},");
|
||||
let _ = writeln!(
|
||||
playlist,
|
||||
"/api/v1/media/{id}/stream/hls/{profile}/segment{i}.ts"
|
||||
);
|
||||
}
|
||||
playlist.push_str("#EXT-X-ENDLIST\n");
|
||||
|
||||
|
|
@ -157,6 +178,9 @@ pub async fn hls_variant_playlist(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn hls_segment(
|
||||
State(state): State<AppState>,
|
||||
Path((id, profile, segment)): Path<(Uuid, String, String)>,
|
||||
|
|
@ -206,7 +230,7 @@ pub async fn hls_segment(
|
|||
Err(ApiError(
|
||||
pinakes_core::error::PinakesError::InvalidOperation(
|
||||
"no transcode session found; start a transcode first via POST \
|
||||
/media/{id}/transcode"
|
||||
/media/:id/transcode"
|
||||
.into(),
|
||||
),
|
||||
))
|
||||
|
|
@ -225,6 +249,9 @@ pub async fn hls_segment(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn dash_manifest(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -239,7 +266,19 @@ pub async fn dash_manifest(
|
|||
),
|
||||
));
|
||||
}
|
||||
#[expect(
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "duration is always non-negative and bounded; hours/minutes fit \
|
||||
in u32"
|
||||
)]
|
||||
let hours = (duration / 3600.0) as u32;
|
||||
#[expect(
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "duration is always non-negative and bounded; hours/minutes fit \
|
||||
in u32"
|
||||
)]
|
||||
let minutes = ((duration % 3600.0) / 60.0) as u32;
|
||||
let seconds = duration % 60.0;
|
||||
|
||||
|
|
@ -253,12 +292,13 @@ pub async fn dash_manifest(
|
|||
let xml_name = escape_xml(&profile.name);
|
||||
let url_name =
|
||||
utf8_percent_encode(&profile.name, NON_ALPHANUMERIC).to_string();
|
||||
representations.push_str(&format!(
|
||||
r#" <Representation id="{xml_name}" bandwidth="{bandwidth}" width="{w}" height="{h}">
|
||||
let _ = write!(
|
||||
representations,
|
||||
r#" <Representation id="{xml_name}" bandwidth="{bandwidth}" width="{w}" height="{h}">
|
||||
<SegmentTemplate media="/api/v1/media/{id}/stream/dash/{url_name}/segment$Number$.m4s" initialization="/api/v1/media/{id}/stream/dash/{url_name}/init.mp4" duration="10000" timescale="1000" startNumber="0"/>
|
||||
</Representation>
|
||||
"#,
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
let mpd = format!(
|
||||
|
|
@ -291,6 +331,9 @@ pub async fn dash_manifest(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn dash_segment(
|
||||
State(state): State<AppState>,
|
||||
Path((id, profile, segment)): Path<(Uuid, String, String)>,
|
||||
|
|
@ -338,7 +381,7 @@ pub async fn dash_segment(
|
|||
Err(ApiError(
|
||||
pinakes_core::error::PinakesError::InvalidOperation(
|
||||
"no transcode session found; start a transcode first via POST \
|
||||
/media/{id}/transcode"
|
||||
/media/:id/transcode"
|
||||
.into(),
|
||||
),
|
||||
))
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
use std::path::Component;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
|
|
@ -38,6 +40,9 @@ use crate::{
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_subtitles(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -74,6 +79,9 @@ pub async fn list_subtitles(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn add_subtitle(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -139,7 +147,6 @@ pub async fn add_subtitle(
|
|||
|
||||
let path = std::path::PathBuf::from(&path_str);
|
||||
|
||||
use std::path::Component;
|
||||
if !path.is_absolute()
|
||||
|| path.components().any(|c| c == Component::ParentDir)
|
||||
{
|
||||
|
|
@ -204,6 +211,9 @@ pub async fn add_subtitle(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn delete_subtitle(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -227,6 +237,9 @@ pub async fn delete_subtitle(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_subtitle_content(
|
||||
State(state): State<AppState>,
|
||||
Path((media_id, subtitle_id)): Path<(Uuid, Uuid)>,
|
||||
|
|
@ -300,6 +313,9 @@ pub async fn get_subtitle_content(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn update_offset(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ const DEFAULT_CHANGES_LIMIT: u64 = 100;
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn register_device(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -132,6 +135,9 @@ pub async fn register_device(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_devices(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -161,6 +167,9 @@ pub async fn list_devices(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_device(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -197,6 +206,9 @@ pub async fn get_device(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn update_device(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -246,6 +258,9 @@ pub async fn update_device(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn delete_device(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -287,6 +302,9 @@ pub async fn delete_device(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn regenerate_token(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -342,6 +360,9 @@ pub async fn regenerate_token(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_changes(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<GetChangesParams>,
|
||||
|
|
@ -391,6 +412,9 @@ pub async fn get_changes(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn report_changes(
|
||||
State(state): State<AppState>,
|
||||
Extension(_username): Extension<String>,
|
||||
|
|
@ -505,6 +529,9 @@ pub async fn report_changes(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn acknowledge_changes(
|
||||
State(state): State<AppState>,
|
||||
Extension(_username): Extension<String>,
|
||||
|
|
@ -545,6 +572,9 @@ pub async fn acknowledge_changes(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_conflicts(
|
||||
State(state): State<AppState>,
|
||||
Extension(_username): Extension<String>,
|
||||
|
|
@ -587,6 +617,9 @@ pub async fn list_conflicts(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn resolve_conflict(
|
||||
State(state): State<AppState>,
|
||||
Extension(_username): Extension<String>,
|
||||
|
|
@ -625,6 +658,9 @@ pub async fn resolve_conflict(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn create_upload(
|
||||
State(state): State<AppState>,
|
||||
Extension(_username): Extension<String>,
|
||||
|
|
@ -665,7 +701,8 @@ pub async fn create_upload(
|
|||
chunk_count,
|
||||
status: UploadStatus::Pending,
|
||||
created_at: now,
|
||||
expires_at: now + chrono::Duration::hours(upload_timeout_hours as i64),
|
||||
expires_at: now
|
||||
+ chrono::Duration::hours(upload_timeout_hours.cast_signed()),
|
||||
last_activity: now,
|
||||
};
|
||||
|
||||
|
|
@ -706,6 +743,9 @@ pub async fn create_upload(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn upload_chunk(
|
||||
State(state): State<AppState>,
|
||||
Path((session_id, chunk_index)): Path<(Uuid, u64)>,
|
||||
|
|
@ -767,6 +807,9 @@ pub async fn upload_chunk(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_upload_status(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -793,6 +836,9 @@ pub async fn get_upload_status(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn complete_upload(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -809,7 +855,8 @@ pub async fn complete_upload(
|
|||
.await
|
||||
.map_err(|e| ApiError::internal(format!("Failed to get chunks: {e}")))?;
|
||||
|
||||
if chunks.len() != session.chunk_count as usize {
|
||||
if chunks.len() != usize::try_from(session.chunk_count).unwrap_or(usize::MAX)
|
||||
{
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"Missing chunks: expected {}, got {}",
|
||||
session.chunk_count,
|
||||
|
|
@ -961,6 +1008,9 @@ pub async fn complete_upload(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn cancel_upload(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -1004,6 +1054,9 @@ pub async fn cancel_upload(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn download_file(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ use crate::{
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn create_tag(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateTagRequest>,
|
||||
|
|
@ -53,6 +56,9 @@ pub async fn create_tag(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_tags(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<TagResponse>>, ApiError> {
|
||||
|
|
@ -73,6 +79,9 @@ pub async fn list_tags(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_tag(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -95,6 +104,9 @@ pub async fn get_tag(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn delete_tag(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -118,6 +130,9 @@ pub async fn delete_tag(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn tag_media(
|
||||
State(state): State<AppState>,
|
||||
Path(media_id): Path<Uuid>,
|
||||
|
|
@ -154,6 +169,9 @@ pub async fn tag_media(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn untag_media(
|
||||
State(state): State<AppState>,
|
||||
Path((media_id, tag_id)): Path<(Uuid, Uuid)>,
|
||||
|
|
@ -185,6 +203,9 @@ pub async fn untag_media(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_media_tags(
|
||||
State(state): State<AppState>,
|
||||
Path(media_id): Path<Uuid>,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ use crate::{
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn start_transcode(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -55,6 +58,9 @@ pub async fn start_transcode(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_session(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -73,6 +79,9 @@ pub async fn get_session(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_sessions(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
|
|
@ -99,6 +108,9 @@ pub async fn list_sessions(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn cancel_session(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ fn sanitize_content_disposition(filename: &str) -> String {
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn upload_file(
|
||||
State(state): State<AppState>,
|
||||
mut multipart: Multipart,
|
||||
|
|
@ -110,6 +113,9 @@ pub async fn upload_file(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn download_file(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -192,6 +198,9 @@ pub async fn download_file(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn move_to_managed(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -226,6 +235,9 @@ pub async fn move_to_managed(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn managed_stats(
|
||||
State(state): State<AppState>,
|
||||
) -> ApiResult<Json<ManagedStorageStatsResponse>> {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ use crate::{
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_users(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<UserResponse>>, ApiError> {
|
||||
|
|
@ -53,6 +56,9 @@ pub async fn list_users(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn create_user(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateUserRequest>,
|
||||
|
|
@ -116,6 +122,9 @@ pub async fn create_user(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
|
|
@ -151,6 +160,9 @@ pub async fn get_user(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn update_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
|
|
@ -199,6 +211,9 @@ pub async fn update_user(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn delete_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
|
|
@ -227,6 +242,9 @@ pub async fn delete_user(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn get_user_libraries(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
|
|
@ -277,6 +295,9 @@ fn validate_root_path(path: &str) -> Result<(), ApiError> {
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn grant_library_access(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
|
|
@ -316,6 +337,9 @@ pub async fn grant_library_access(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn revoke_library_access(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ pub struct WebhookInfo {
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn list_webhooks(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<WebhookInfo>>, ApiError> {
|
||||
|
|
@ -48,6 +51,9 @@ pub async fn list_webhooks(
|
|||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database operation fails or the request is invalid.
|
||||
pub async fn test_webhook(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
|
|
@ -55,17 +61,20 @@ pub async fn test_webhook(
|
|||
let count = config.webhooks.len();
|
||||
drop(config);
|
||||
|
||||
if let Some(ref dispatcher) = state.webhook_dispatcher {
|
||||
dispatcher.dispatch(pinakes_core::webhooks::WebhookEvent::Test);
|
||||
Ok(Json(serde_json::json!({
|
||||
"webhooks_configured": count,
|
||||
"test_sent": true
|
||||
})))
|
||||
} else {
|
||||
Ok(Json(serde_json::json!({
|
||||
"webhooks_configured": 0,
|
||||
"test_sent": false,
|
||||
"message": "no webhooks configured"
|
||||
})))
|
||||
}
|
||||
state.webhook_dispatcher.as_ref().map_or_else(
|
||||
|| {
|
||||
Ok(Json(serde_json::json!({
|
||||
"webhooks_configured": 0,
|
||||
"test_sent": false,
|
||||
"message": "no webhooks configured"
|
||||
})))
|
||||
},
|
||||
|dispatcher| {
|
||||
dispatcher.dispatch(pinakes_core::webhooks::WebhookEvent::Test);
|
||||
Ok(Json(serde_json::json!({
|
||||
"webhooks_configured": count,
|
||||
"test_sent": true
|
||||
})))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue