pinakes-core: add backup, session refresh, share permissions restructure, and fix integrity
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I17da1cf8403bd11d2a6ea31138f97e776a6a6964
This commit is contained in:
parent
672e11b592
commit
4e91cb6679
4 changed files with 3096 additions and 2300 deletions
|
|
@ -20,6 +20,7 @@ pub struct ShareId(pub Uuid);
|
|||
|
||||
impl ShareId {
|
||||
/// Creates a new share ID.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::now_v7())
|
||||
}
|
||||
|
|
@ -49,7 +50,8 @@ pub enum ShareTarget {
|
|||
|
||||
impl ShareTarget {
|
||||
/// Returns the type of target being shared.
|
||||
pub fn target_type(&self) -> &'static str {
|
||||
#[must_use]
|
||||
pub const fn target_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Media { .. } => "media",
|
||||
Self::Collection { .. } => "collection",
|
||||
|
|
@ -59,7 +61,8 @@ impl ShareTarget {
|
|||
}
|
||||
|
||||
/// Returns the ID of the target being shared.
|
||||
pub fn target_id(&self) -> Uuid {
|
||||
#[must_use]
|
||||
pub const fn target_id(&self) -> Uuid {
|
||||
match self {
|
||||
Self::Media { media_id } => media_id.0,
|
||||
Self::Collection { collection_id } => *collection_id,
|
||||
|
|
@ -91,7 +94,8 @@ pub enum ShareRecipient {
|
|||
|
||||
impl ShareRecipient {
|
||||
/// Returns the type of recipient.
|
||||
pub fn recipient_type(&self) -> &'static str {
|
||||
#[must_use]
|
||||
pub const fn recipient_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::PublicLink { .. } => "public_link",
|
||||
Self::User { .. } => "user",
|
||||
|
|
@ -101,75 +105,117 @@ impl ShareRecipient {
|
|||
}
|
||||
}
|
||||
|
||||
/// Read-access permissions granted by a share.
|
||||
#[derive(
|
||||
Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
|
||||
)]
|
||||
pub struct ShareViewPermissions {
|
||||
/// Can view the content
|
||||
pub can_view: bool,
|
||||
/// Can download the content
|
||||
pub can_download: bool,
|
||||
/// Can reshare with others
|
||||
pub can_reshare: bool,
|
||||
}
|
||||
|
||||
/// Write-access permissions granted by a share.
|
||||
#[derive(
|
||||
Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
|
||||
)]
|
||||
pub struct ShareMutatePermissions {
|
||||
/// Can edit the content/metadata
|
||||
pub can_edit: bool,
|
||||
/// Can delete the content
|
||||
pub can_delete: bool,
|
||||
/// Can add new items (for collections)
|
||||
pub can_add: bool,
|
||||
}
|
||||
|
||||
/// Permissions granted by a share.
|
||||
#[derive(
|
||||
Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
|
||||
)]
|
||||
pub struct SharePermissions {
|
||||
/// Can view the content
|
||||
pub can_view: bool,
|
||||
/// Can download the content
|
||||
pub can_download: bool,
|
||||
/// Can edit the content/metadata
|
||||
pub can_edit: bool,
|
||||
/// Can delete the content
|
||||
pub can_delete: bool,
|
||||
/// Can reshare with others
|
||||
pub can_reshare: bool,
|
||||
/// Can add new items (for collections)
|
||||
pub can_add: bool,
|
||||
#[serde(flatten)]
|
||||
pub view: ShareViewPermissions,
|
||||
#[serde(flatten)]
|
||||
pub mutate: ShareMutatePermissions,
|
||||
}
|
||||
|
||||
impl SharePermissions {
|
||||
/// Creates a new share with view-only permissions.
|
||||
#[must_use]
|
||||
pub fn view_only() -> Self {
|
||||
Self {
|
||||
can_view: true,
|
||||
..Default::default()
|
||||
view: ShareViewPermissions {
|
||||
can_view: true,
|
||||
..Default::default()
|
||||
},
|
||||
mutate: ShareMutatePermissions::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new share with download permissions.
|
||||
#[must_use]
|
||||
pub fn download() -> Self {
|
||||
Self {
|
||||
can_view: true,
|
||||
can_download: true,
|
||||
..Default::default()
|
||||
view: ShareViewPermissions {
|
||||
can_view: true,
|
||||
can_download: true,
|
||||
..Default::default()
|
||||
},
|
||||
mutate: ShareMutatePermissions::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new share with edit permissions.
|
||||
#[must_use]
|
||||
pub fn edit() -> Self {
|
||||
Self {
|
||||
can_view: true,
|
||||
can_download: true,
|
||||
can_edit: true,
|
||||
can_add: true,
|
||||
..Default::default()
|
||||
view: ShareViewPermissions {
|
||||
can_view: true,
|
||||
can_download: true,
|
||||
..Default::default()
|
||||
},
|
||||
mutate: ShareMutatePermissions {
|
||||
can_edit: true,
|
||||
can_add: true,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new share with full permissions.
|
||||
pub fn full() -> Self {
|
||||
#[must_use]
|
||||
pub const fn full() -> Self {
|
||||
Self {
|
||||
can_view: true,
|
||||
can_download: true,
|
||||
can_edit: true,
|
||||
can_delete: true,
|
||||
can_reshare: true,
|
||||
can_add: true,
|
||||
view: ShareViewPermissions {
|
||||
can_view: true,
|
||||
can_download: true,
|
||||
can_reshare: true,
|
||||
},
|
||||
mutate: ShareMutatePermissions {
|
||||
can_edit: true,
|
||||
can_delete: true,
|
||||
can_add: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Merges two permission sets, taking the most permissive values.
|
||||
pub fn merge(&self, other: &Self) -> Self {
|
||||
#[must_use]
|
||||
pub const fn merge(&self, other: &Self) -> Self {
|
||||
Self {
|
||||
can_view: self.can_view || other.can_view,
|
||||
can_download: self.can_download || other.can_download,
|
||||
can_edit: self.can_edit || other.can_edit,
|
||||
can_delete: self.can_delete || other.can_delete,
|
||||
can_reshare: self.can_reshare || other.can_reshare,
|
||||
can_add: self.can_add || other.can_add,
|
||||
view: ShareViewPermissions {
|
||||
can_view: self.view.can_view || other.view.can_view,
|
||||
can_download: self.view.can_download || other.view.can_download,
|
||||
can_reshare: self.view.can_reshare || other.view.can_reshare,
|
||||
},
|
||||
mutate: ShareMutatePermissions {
|
||||
can_edit: self.mutate.can_edit || other.mutate.can_edit,
|
||||
can_delete: self.mutate.can_delete || other.mutate.can_delete,
|
||||
can_add: self.mutate.can_add || other.mutate.can_add,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -196,6 +242,7 @@ pub struct Share {
|
|||
|
||||
impl Share {
|
||||
/// Create a new public link share.
|
||||
#[must_use]
|
||||
pub fn new_public_link(
|
||||
owner_id: UserId,
|
||||
target: ShareTarget,
|
||||
|
|
@ -224,6 +271,7 @@ impl Share {
|
|||
}
|
||||
|
||||
/// Create a new user share.
|
||||
#[must_use]
|
||||
pub fn new_user_share(
|
||||
owner_id: UserId,
|
||||
target: ShareTarget,
|
||||
|
|
@ -251,16 +299,19 @@ impl Share {
|
|||
}
|
||||
|
||||
/// Checks if the share has expired.
|
||||
#[must_use]
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.expires_at.map(|exp| exp < Utc::now()).unwrap_or(false)
|
||||
self.expires_at.is_some_and(|exp| exp < Utc::now())
|
||||
}
|
||||
|
||||
/// Checks if this is a public link share.
|
||||
pub fn is_public(&self) -> bool {
|
||||
#[must_use]
|
||||
pub const fn is_public(&self) -> bool {
|
||||
matches!(self.recipient, ShareRecipient::PublicLink { .. })
|
||||
}
|
||||
|
||||
/// Returns the public token if this is a public link share.
|
||||
#[must_use]
|
||||
pub fn public_token(&self) -> Option<&str> {
|
||||
match &self.recipient {
|
||||
ShareRecipient::PublicLink { token, .. } => Some(token),
|
||||
|
|
@ -308,7 +359,7 @@ impl std::str::FromStr for ShareActivityAction {
|
|||
"revoked" => Ok(Self::Revoked),
|
||||
"expired" => Ok(Self::Expired),
|
||||
"password_failed" => Ok(Self::PasswordFailed),
|
||||
_ => Err(format!("unknown share activity action: {}", s)),
|
||||
_ => Err(format!("unknown share activity action: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -327,6 +378,7 @@ pub struct ShareActivity {
|
|||
|
||||
impl ShareActivity {
|
||||
/// Creates a new share activity entry.
|
||||
#[must_use]
|
||||
pub fn new(share_id: ShareId, action: ShareActivityAction) -> Self {
|
||||
Self {
|
||||
id: Uuid::now_v7(),
|
||||
|
|
@ -340,18 +392,21 @@ impl ShareActivity {
|
|||
}
|
||||
|
||||
/// Sets the actor who performed the activity.
|
||||
pub fn with_actor(mut self, actor_id: UserId) -> Self {
|
||||
#[must_use]
|
||||
pub const fn with_actor(mut self, actor_id: UserId) -> Self {
|
||||
self.actor_id = Some(actor_id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the IP address of the actor.
|
||||
#[must_use]
|
||||
pub fn with_ip(mut self, ip: &str) -> Self {
|
||||
self.actor_ip = Some(ip.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets additional details about the activity.
|
||||
#[must_use]
|
||||
pub fn with_details(mut self, details: &str) -> Self {
|
||||
self.details = Some(details.to_string());
|
||||
self
|
||||
|
|
@ -391,7 +446,7 @@ impl std::str::FromStr for ShareNotificationType {
|
|||
"share_revoked" => Ok(Self::ShareRevoked),
|
||||
"share_expiring" => Ok(Self::ShareExpiring),
|
||||
"share_accessed" => Ok(Self::ShareAccessed),
|
||||
_ => Err(format!("unknown share notification type: {}", s)),
|
||||
_ => Err(format!("unknown share notification type: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -409,6 +464,7 @@ pub struct ShareNotification {
|
|||
|
||||
impl ShareNotification {
|
||||
/// Creates a new share notification.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
user_id: UserId,
|
||||
share_id: ShareId,
|
||||
|
|
@ -426,17 +482,23 @@ impl ShareNotification {
|
|||
}
|
||||
|
||||
/// Generates a random share token.
|
||||
#[must_use]
|
||||
pub fn generate_share_token() -> String {
|
||||
// Use UUIDv4 for random tokens - simple string representation
|
||||
Uuid::new_v4().simple().to_string()
|
||||
}
|
||||
|
||||
/// Hashes a share password using Argon2id.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if hashing fails.
|
||||
pub fn hash_share_password(password: &str) -> Result<String, PinakesError> {
|
||||
crate::users::auth::hash_password(password)
|
||||
}
|
||||
|
||||
/// Verifies a share password against an Argon2id hash.
|
||||
#[must_use]
|
||||
pub fn verify_share_password(password: &str, hash: &str) -> bool {
|
||||
crate::users::auth::verify_password(password, hash).unwrap_or(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,19 @@ use crate::{
|
|||
analytics::UsageEvent,
|
||||
enrichment::ExternalMetadata,
|
||||
error::Result,
|
||||
model::*,
|
||||
model::{
|
||||
AuditEntry,
|
||||
Collection,
|
||||
CollectionKind,
|
||||
ContentHash,
|
||||
CustomField,
|
||||
ManagedBlob,
|
||||
ManagedStorageStats,
|
||||
MediaId,
|
||||
MediaItem,
|
||||
Pagination,
|
||||
Tag,
|
||||
},
|
||||
playlists::Playlist,
|
||||
search::{SearchRequest, SearchResults},
|
||||
social::{Comment, Rating, ShareLink},
|
||||
|
|
@ -46,47 +58,99 @@ pub struct SessionData {
|
|||
#[async_trait::async_trait]
|
||||
pub trait StorageBackend: Send + Sync + 'static {
|
||||
// Migrations
|
||||
|
||||
/// Apply all pending database migrations.
|
||||
/// Called on server startup to ensure the schema is up to date.
|
||||
async fn run_migrations(&self) -> Result<()>;
|
||||
|
||||
// Root directories
|
||||
|
||||
/// Register a root directory for media scanning.
|
||||
async fn add_root_dir(&self, path: PathBuf) -> Result<()>;
|
||||
|
||||
/// List all registered root directories.
|
||||
async fn list_root_dirs(&self) -> Result<Vec<PathBuf>>;
|
||||
|
||||
/// Remove a root directory registration.
|
||||
/// Does not delete any media items found under this directory.
|
||||
async fn remove_root_dir(&self, path: &std::path::Path) -> Result<()>;
|
||||
|
||||
// Media CRUD
|
||||
|
||||
/// Insert a new media item into the database.
|
||||
/// Returns `Database` error if an item with the same ID already exists.
|
||||
async fn insert_media(&self, item: &MediaItem) -> Result<()>;
|
||||
|
||||
/// Retrieve a media item by its ID.
|
||||
/// Returns `NotFound` if no item exists with the given ID.
|
||||
async fn get_media(&self, id: MediaId) -> Result<MediaItem>;
|
||||
|
||||
/// Return the total number of media items in the database.
|
||||
async fn count_media(&self) -> Result<u64>;
|
||||
|
||||
/// Look up a media item by its content hash.
|
||||
/// Returns `None` if no item matches the hash.
|
||||
async fn get_media_by_hash(
|
||||
&self,
|
||||
hash: &ContentHash,
|
||||
) -> Result<Option<MediaItem>>;
|
||||
/// Get a media item by its file path (used for incremental scanning)
|
||||
|
||||
/// Get a media item by its file path (used for incremental scanning).
|
||||
/// Returns `None` if no item exists at the given path.
|
||||
async fn get_media_by_path(
|
||||
&self,
|
||||
path: &std::path::Path,
|
||||
) -> Result<Option<MediaItem>>;
|
||||
|
||||
/// List media items with pagination (offset and limit).
|
||||
async fn list_media(&self, pagination: &Pagination)
|
||||
-> Result<Vec<MediaItem>>;
|
||||
|
||||
/// Update an existing media item's metadata.
|
||||
/// Returns `NotFound` if the item does not exist.
|
||||
async fn update_media(&self, item: &MediaItem) -> Result<()>;
|
||||
|
||||
/// Permanently delete a media item by ID.
|
||||
/// Returns `NotFound` if the item does not exist.
|
||||
async fn delete_media(&self, id: MediaId) -> Result<()>;
|
||||
|
||||
/// Delete all media items from the database. Returns the number deleted.
|
||||
async fn delete_all_media(&self) -> Result<u64>;
|
||||
|
||||
// Tags
|
||||
|
||||
/// Create a new tag with an optional parent for hierarchical tagging.
|
||||
async fn create_tag(
|
||||
&self,
|
||||
name: &str,
|
||||
parent_id: Option<Uuid>,
|
||||
) -> Result<Tag>;
|
||||
|
||||
/// Retrieve a tag by its ID. Returns `NotFound` if it does not exist.
|
||||
async fn get_tag(&self, id: Uuid) -> Result<Tag>;
|
||||
|
||||
/// List all tags in the database.
|
||||
async fn list_tags(&self) -> Result<Vec<Tag>>;
|
||||
|
||||
/// Delete a tag by ID. Also removes all media-tag associations.
|
||||
async fn delete_tag(&self, id: Uuid) -> Result<()>;
|
||||
|
||||
/// Associate a tag with a media item. No-op if already tagged.
|
||||
async fn tag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()>;
|
||||
|
||||
/// Remove a tag association from a media item.
|
||||
async fn untag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()>;
|
||||
|
||||
/// Get all tags associated with a media item.
|
||||
async fn get_media_tags(&self, media_id: MediaId) -> Result<Vec<Tag>>;
|
||||
|
||||
/// Get all descendant tags of a parent tag (recursive).
|
||||
async fn get_tag_descendants(&self, tag_id: Uuid) -> Result<Vec<Tag>>;
|
||||
|
||||
// Collections
|
||||
|
||||
/// Create a new collection. Smart collections use `filter_query` for
|
||||
/// automatic membership; manual collections use explicit add/remove.
|
||||
async fn create_collection(
|
||||
&self,
|
||||
name: &str,
|
||||
|
|
@ -94,30 +158,49 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
description: Option<&str>,
|
||||
filter_query: Option<&str>,
|
||||
) -> Result<Collection>;
|
||||
|
||||
/// Retrieve a collection by ID. Returns `NotFound` if it does not exist.
|
||||
async fn get_collection(&self, id: Uuid) -> Result<Collection>;
|
||||
|
||||
/// List all collections.
|
||||
async fn list_collections(&self) -> Result<Vec<Collection>>;
|
||||
|
||||
/// Delete a collection and all its membership entries.
|
||||
async fn delete_collection(&self, id: Uuid) -> Result<()>;
|
||||
|
||||
/// Add a media item to a manual collection at the given position.
|
||||
async fn add_to_collection(
|
||||
&self,
|
||||
collection_id: Uuid,
|
||||
media_id: MediaId,
|
||||
position: i32,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Remove a media item from a collection.
|
||||
async fn remove_from_collection(
|
||||
&self,
|
||||
collection_id: Uuid,
|
||||
media_id: MediaId,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Get all media items in a collection, ordered by position.
|
||||
async fn get_collection_members(
|
||||
&self,
|
||||
collection_id: Uuid,
|
||||
) -> Result<Vec<MediaItem>>;
|
||||
|
||||
// Search
|
||||
|
||||
/// Execute a full-text search with filters, sorting, and pagination.
|
||||
/// Uses FTS5 on `SQLite` and tsvector/trigram on `PostgreSQL`.
|
||||
async fn search(&self, request: &SearchRequest) -> Result<SearchResults>;
|
||||
|
||||
// Audit
|
||||
|
||||
/// Record an audit log entry for an action on a media item.
|
||||
async fn record_audit(&self, entry: &AuditEntry) -> Result<()>;
|
||||
|
||||
/// List audit entries, optionally filtered to a specific media item.
|
||||
async fn list_audit_entries(
|
||||
&self,
|
||||
media_id: Option<MediaId>,
|
||||
|
|
@ -125,16 +208,22 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
) -> Result<Vec<AuditEntry>>;
|
||||
|
||||
// Custom fields
|
||||
|
||||
/// Set a custom field on a media item (upserts if the field name exists).
|
||||
async fn set_custom_field(
|
||||
&self,
|
||||
media_id: MediaId,
|
||||
name: &str,
|
||||
field: &CustomField,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Get all custom fields for a media item, keyed by field name.
|
||||
async fn get_custom_fields(
|
||||
&self,
|
||||
media_id: MediaId,
|
||||
) -> Result<std::collections::HashMap<String, CustomField>>;
|
||||
|
||||
/// Delete a custom field from a media item by name.
|
||||
async fn delete_custom_field(
|
||||
&self,
|
||||
media_id: MediaId,
|
||||
|
|
@ -142,8 +231,13 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
) -> Result<()>;
|
||||
|
||||
// Batch operations (transactional where supported)
|
||||
|
||||
/// Delete multiple media items in a single transaction.
|
||||
/// Returns the number of items actually deleted.
|
||||
async fn batch_delete_media(&self, ids: &[MediaId]) -> Result<u64>;
|
||||
|
||||
/// Apply multiple tags to multiple media items.
|
||||
/// Returns the number of new associations created.
|
||||
async fn batch_tag_media(
|
||||
&self,
|
||||
media_ids: &[MediaId],
|
||||
|
|
@ -151,11 +245,15 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
) -> Result<u64>;
|
||||
|
||||
// Integrity
|
||||
|
||||
/// List all media item IDs, paths, and content hashes.
|
||||
/// Used by integrity checks to verify files still exist on disk.
|
||||
async fn list_media_paths(
|
||||
&self,
|
||||
) -> Result<Vec<(MediaId, std::path::PathBuf, ContentHash)>>;
|
||||
|
||||
// Batch metadata update (must be implemented per backend for bulk SQL)
|
||||
/// Update metadata fields on multiple media items at once.
|
||||
/// Only non-`None` fields are applied. Returns the number updated.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn batch_update_media(
|
||||
&self,
|
||||
|
|
@ -169,6 +267,8 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
) -> Result<u64>;
|
||||
|
||||
// Saved searches
|
||||
|
||||
/// Persist a search query so it can be re-executed later.
|
||||
async fn save_search(
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
|
|
@ -176,20 +276,41 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
query: &str,
|
||||
sort_order: Option<&str>,
|
||||
) -> Result<()>;
|
||||
|
||||
/// List all saved searches.
|
||||
async fn list_saved_searches(&self)
|
||||
-> Result<Vec<crate::model::SavedSearch>>;
|
||||
|
||||
/// Get a single saved search by ID.
|
||||
async fn get_saved_search(
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
) -> Result<crate::model::SavedSearch>;
|
||||
|
||||
/// Delete a saved search by ID.
|
||||
async fn delete_saved_search(&self, id: uuid::Uuid) -> Result<()>;
|
||||
|
||||
// Duplicates
|
||||
|
||||
/// Find groups of media items with identical content hashes.
|
||||
async fn find_duplicates(&self) -> Result<Vec<Vec<MediaItem>>>;
|
||||
|
||||
/// Find groups of visually similar media using perceptual hashing.
|
||||
/// `threshold` is the maximum Hamming distance to consider a match.
|
||||
async fn find_perceptual_duplicates(
|
||||
&self,
|
||||
threshold: u32,
|
||||
) -> Result<Vec<Vec<MediaItem>>>;
|
||||
|
||||
// Database management
|
||||
|
||||
/// Collect aggregate database statistics (counts, size, backend name).
|
||||
async fn database_stats(&self) -> Result<DatabaseStats>;
|
||||
|
||||
/// Reclaim unused disk space (VACUUM for `SQLite`, VACUUM FULL for Postgres).
|
||||
async fn vacuum(&self) -> Result<()>;
|
||||
|
||||
/// Delete all data from all tables. Destructive; used in tests.
|
||||
async fn clear_all_data(&self) -> Result<()>;
|
||||
|
||||
// Thumbnail helpers
|
||||
|
|
@ -200,18 +321,29 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
) -> Result<Vec<crate::model::MediaId>>;
|
||||
|
||||
// Library statistics
|
||||
|
||||
/// Compute comprehensive library statistics (sizes, counts by type,
|
||||
/// top tags/collections, duplicates).
|
||||
async fn library_statistics(&self) -> Result<LibraryStatistics>;
|
||||
|
||||
// User Management
|
||||
|
||||
/// List all registered users.
|
||||
async fn list_users(&self) -> Result<Vec<crate::users::User>>;
|
||||
|
||||
/// Get a user by ID. Returns `NotFound` if no such user exists.
|
||||
async fn get_user(
|
||||
&self,
|
||||
id: crate::users::UserId,
|
||||
) -> Result<crate::users::User>;
|
||||
|
||||
/// Look up a user by username. Returns `NotFound` if not found.
|
||||
async fn get_user_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> Result<crate::users::User>;
|
||||
|
||||
/// Create a new user with the given credentials and role.
|
||||
async fn create_user(
|
||||
&self,
|
||||
username: &str,
|
||||
|
|
@ -219,6 +351,9 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
role: crate::config::UserRole,
|
||||
profile: Option<crate::users::UserProfile>,
|
||||
) -> Result<crate::users::User>;
|
||||
|
||||
/// Update a user's password, role, or profile. Only non-`None` fields
|
||||
/// are applied.
|
||||
async fn update_user(
|
||||
&self,
|
||||
id: crate::users::UserId,
|
||||
|
|
@ -226,17 +361,25 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
role: Option<crate::config::UserRole>,
|
||||
profile: Option<crate::users::UserProfile>,
|
||||
) -> Result<crate::users::User>;
|
||||
|
||||
/// Delete a user and all associated sessions.
|
||||
async fn delete_user(&self, id: crate::users::UserId) -> Result<()>;
|
||||
|
||||
/// Get the library access grants for a user.
|
||||
async fn get_user_libraries(
|
||||
&self,
|
||||
user_id: crate::users::UserId,
|
||||
) -> Result<Vec<crate::users::UserLibraryAccess>>;
|
||||
|
||||
/// Grant a user access to a library root path with the given permission.
|
||||
async fn grant_library_access(
|
||||
&self,
|
||||
user_id: crate::users::UserId,
|
||||
root_path: &str,
|
||||
permission: crate::users::LibraryPermission,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Revoke a user's access to a library root path.
|
||||
async fn revoke_library_access(
|
||||
&self,
|
||||
user_id: crate::users::UserId,
|
||||
|
|
@ -255,23 +398,21 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
// Default implementation: get the media item's path and check against
|
||||
// user's library access
|
||||
let media = self.get_media(media_id).await?;
|
||||
let path_str = media.path.to_string_lossy().to_string();
|
||||
|
||||
// Get user's library permissions
|
||||
let libraries = self.get_user_libraries(user_id).await?;
|
||||
|
||||
// If user has no library restrictions, they have no access (unless they're
|
||||
// admin) This default impl requires at least one matching library
|
||||
// permission
|
||||
// permission. Use Path::starts_with for component-wise matching to prevent
|
||||
// prefix collisions (e.g. /data/user1 vs /data/user1-other).
|
||||
for lib in &libraries {
|
||||
if path_str.starts_with(&lib.root_path) {
|
||||
if media.path.starts_with(std::path::Path::new(&lib.root_path)) {
|
||||
return Ok(lib.permission);
|
||||
}
|
||||
}
|
||||
|
||||
Err(crate::error::PinakesError::Authorization(format!(
|
||||
"user {} has no access to media {}",
|
||||
user_id, media_id
|
||||
"user {user_id} has no access to media {media_id}"
|
||||
)))
|
||||
}
|
||||
|
||||
|
|
@ -299,6 +440,8 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
}
|
||||
}
|
||||
|
||||
/// Rate a media item (1-5 stars) with an optional text review.
|
||||
/// Upserts: replaces any existing rating by the same user.
|
||||
async fn rate_media(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
|
|
@ -306,14 +449,21 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
stars: u8,
|
||||
review: Option<&str>,
|
||||
) -> Result<Rating>;
|
||||
|
||||
/// Get all ratings for a media item.
|
||||
async fn get_media_ratings(&self, media_id: MediaId) -> Result<Vec<Rating>>;
|
||||
|
||||
/// Get a specific user's rating for a media item, if any.
|
||||
async fn get_user_rating(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
media_id: MediaId,
|
||||
) -> Result<Option<Rating>>;
|
||||
|
||||
/// Delete a rating by ID.
|
||||
async fn delete_rating(&self, id: Uuid) -> Result<()>;
|
||||
|
||||
/// Add a comment on a media item, optionally as a reply to another comment.
|
||||
async fn add_comment(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
|
|
@ -321,31 +471,44 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
text: &str,
|
||||
parent_id: Option<Uuid>,
|
||||
) -> Result<Comment>;
|
||||
|
||||
/// Get all comments for a media item.
|
||||
async fn get_media_comments(&self, media_id: MediaId)
|
||||
-> Result<Vec<Comment>>;
|
||||
|
||||
/// Delete a comment by ID.
|
||||
async fn delete_comment(&self, id: Uuid) -> Result<()>;
|
||||
|
||||
/// Mark a media item as a favorite for a user.
|
||||
async fn add_favorite(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
media_id: MediaId,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Remove a media item from a user's favorites.
|
||||
async fn remove_favorite(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
media_id: MediaId,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Get a user's favorited media items with pagination.
|
||||
async fn get_user_favorites(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
pagination: &Pagination,
|
||||
) -> Result<Vec<MediaItem>>;
|
||||
|
||||
/// Check whether a media item is in a user's favorites.
|
||||
async fn is_favorite(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
media_id: MediaId,
|
||||
) -> Result<bool>;
|
||||
|
||||
/// Create a public share link for a media item with optional password
|
||||
/// and expiry.
|
||||
async fn create_share_link(
|
||||
&self,
|
||||
media_id: MediaId,
|
||||
|
|
@ -354,10 +517,17 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
password_hash: Option<&str>,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<ShareLink>;
|
||||
|
||||
/// Look up a share link by its token. Returns `NotFound` if invalid.
|
||||
async fn get_share_link(&self, token: &str) -> Result<ShareLink>;
|
||||
|
||||
/// Increment the view counter for a share link.
|
||||
async fn increment_share_views(&self, token: &str) -> Result<()>;
|
||||
|
||||
/// Delete a share link by ID.
|
||||
async fn delete_share_link(&self, id: Uuid) -> Result<()>;
|
||||
|
||||
/// Create a new playlist. Smart playlists auto-populate via `filter_query`.
|
||||
async fn create_playlist(
|
||||
&self,
|
||||
owner_id: UserId,
|
||||
|
|
@ -367,11 +537,18 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
is_smart: bool,
|
||||
filter_query: Option<&str>,
|
||||
) -> Result<Playlist>;
|
||||
|
||||
/// Get a playlist by ID. Returns `NotFound` if it does not exist.
|
||||
async fn get_playlist(&self, id: Uuid) -> Result<Playlist>;
|
||||
|
||||
/// List playlists, optionally filtered to a specific owner.
|
||||
async fn list_playlists(
|
||||
&self,
|
||||
owner_id: Option<UserId>,
|
||||
) -> Result<Vec<Playlist>>;
|
||||
|
||||
/// Update a playlist's name, description, or visibility.
|
||||
/// Only non-`None` fields are applied.
|
||||
async fn update_playlist(
|
||||
&self,
|
||||
id: Uuid,
|
||||
|
|
@ -379,22 +556,32 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
description: Option<&str>,
|
||||
is_public: Option<bool>,
|
||||
) -> Result<Playlist>;
|
||||
|
||||
/// Delete a playlist and all its item associations.
|
||||
async fn delete_playlist(&self, id: Uuid) -> Result<()>;
|
||||
|
||||
/// Add a media item to a playlist at the given position.
|
||||
async fn add_to_playlist(
|
||||
&self,
|
||||
playlist_id: Uuid,
|
||||
media_id: MediaId,
|
||||
position: i32,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Remove a media item from a playlist.
|
||||
async fn remove_from_playlist(
|
||||
&self,
|
||||
playlist_id: Uuid,
|
||||
media_id: MediaId,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Get all media items in a playlist, ordered by position.
|
||||
async fn get_playlist_items(
|
||||
&self,
|
||||
playlist_id: Uuid,
|
||||
) -> Result<Vec<MediaItem>>;
|
||||
|
||||
/// Move a media item to a new position within a playlist.
|
||||
async fn reorder_playlist(
|
||||
&self,
|
||||
playlist_id: Uuid,
|
||||
|
|
@ -402,69 +589,106 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
new_position: i32,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Record a usage/analytics event (play, view, download, etc.).
|
||||
async fn record_usage_event(&self, event: &UsageEvent) -> Result<()>;
|
||||
|
||||
/// Query usage events, optionally filtered by media item and/or user.
|
||||
async fn get_usage_events(
|
||||
&self,
|
||||
media_id: Option<MediaId>,
|
||||
user_id: Option<UserId>,
|
||||
limit: u64,
|
||||
) -> Result<Vec<UsageEvent>>;
|
||||
|
||||
/// Get the most-viewed media items with their view counts.
|
||||
async fn get_most_viewed(&self, limit: u64) -> Result<Vec<(MediaItem, u64)>>;
|
||||
|
||||
/// Get media items recently viewed by a user, most recent first.
|
||||
async fn get_recently_viewed(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
limit: u64,
|
||||
) -> Result<Vec<MediaItem>>;
|
||||
|
||||
/// Update playback/watch progress for a user on a media item (in seconds).
|
||||
async fn update_watch_progress(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
media_id: MediaId,
|
||||
progress_secs: f64,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Get the stored watch progress (in seconds) for a user/media pair.
|
||||
async fn get_watch_progress(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
media_id: MediaId,
|
||||
) -> Result<Option<f64>>;
|
||||
|
||||
/// Delete usage events older than the given timestamp.
|
||||
/// Returns the number of events deleted.
|
||||
async fn cleanup_old_events(&self, before: DateTime<Utc>) -> Result<u64>;
|
||||
|
||||
/// Add a subtitle track for a media item.
|
||||
async fn add_subtitle(&self, subtitle: &Subtitle) -> Result<()>;
|
||||
|
||||
/// Get all subtitle tracks for a media item.
|
||||
async fn get_media_subtitles(
|
||||
&self,
|
||||
media_id: MediaId,
|
||||
) -> Result<Vec<Subtitle>>;
|
||||
|
||||
/// Delete a subtitle track by ID.
|
||||
async fn delete_subtitle(&self, id: Uuid) -> Result<()>;
|
||||
|
||||
/// Adjust the timing offset (in milliseconds) for a subtitle track.
|
||||
async fn update_subtitle_offset(
|
||||
&self,
|
||||
id: Uuid,
|
||||
offset_ms: i64,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Store metadata fetched from an external source (e.g., `MusicBrainz`,
|
||||
/// `TMDb`).
|
||||
async fn store_external_metadata(
|
||||
&self,
|
||||
meta: &ExternalMetadata,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Get all external metadata records for a media item.
|
||||
async fn get_external_metadata(
|
||||
&self,
|
||||
media_id: MediaId,
|
||||
) -> Result<Vec<ExternalMetadata>>;
|
||||
|
||||
/// Delete an external metadata record by ID.
|
||||
async fn delete_external_metadata(&self, id: Uuid) -> Result<()>;
|
||||
|
||||
/// Create a new transcoding session for a media item.
|
||||
async fn create_transcode_session(
|
||||
&self,
|
||||
session: &TranscodeSession,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Get a transcoding session by ID.
|
||||
async fn get_transcode_session(&self, id: Uuid) -> Result<TranscodeSession>;
|
||||
|
||||
/// List transcoding sessions, optionally filtered to a media item.
|
||||
async fn list_transcode_sessions(
|
||||
&self,
|
||||
media_id: Option<MediaId>,
|
||||
) -> Result<Vec<TranscodeSession>>;
|
||||
|
||||
/// Update the status and progress of a transcoding session.
|
||||
async fn update_transcode_status(
|
||||
&self,
|
||||
id: Uuid,
|
||||
status: TranscodeStatus,
|
||||
progress: f32,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Delete transcode sessions that expired before the given timestamp.
|
||||
/// Returns the number of sessions cleaned up.
|
||||
async fn cleanup_expired_transcodes(
|
||||
&self,
|
||||
before: DateTime<Utc>,
|
||||
|
|
@ -479,16 +703,26 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
session_token: &str,
|
||||
) -> Result<Option<SessionData>>;
|
||||
|
||||
/// Update the last_accessed timestamp for a session
|
||||
/// Update the `last_accessed` timestamp for a session
|
||||
async fn touch_session(&self, session_token: &str) -> Result<()>;
|
||||
|
||||
/// Extend a session's expiry time.
|
||||
/// Only extends sessions that have not already expired.
|
||||
/// Returns the new expiry time, or None if the session was not found or
|
||||
/// already expired.
|
||||
async fn extend_session(
|
||||
&self,
|
||||
session_token: &str,
|
||||
new_expires_at: DateTime<Utc>,
|
||||
) -> Result<Option<DateTime<Utc>>>;
|
||||
|
||||
/// Delete a specific session
|
||||
async fn delete_session(&self, session_token: &str) -> Result<()>;
|
||||
|
||||
/// Delete all sessions for a specific user
|
||||
async fn delete_user_sessions(&self, username: &str) -> Result<u64>;
|
||||
|
||||
/// Delete all expired sessions (where expires_at < now)
|
||||
/// Delete all expired sessions (where `expires_at` < now)
|
||||
async fn delete_expired_sessions(&self) -> Result<u64>;
|
||||
|
||||
/// List all active sessions (optionally filtered by username)
|
||||
|
|
@ -533,7 +767,7 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
/// List all series with book counts
|
||||
async fn list_series(&self) -> Result<Vec<(String, u64)>>;
|
||||
|
||||
/// Get all books in a series, ordered by series_index
|
||||
/// Get all books in a series, ordered by `series_index`
|
||||
async fn get_series_books(&self, series_name: &str)
|
||||
-> Result<Vec<MediaItem>>;
|
||||
|
||||
|
|
@ -591,10 +825,10 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
/// deleted.
|
||||
async fn decrement_blob_ref(&self, hash: &ContentHash) -> Result<bool>;
|
||||
|
||||
/// Update the last_verified timestamp for a blob
|
||||
/// Update the `last_verified` timestamp for a blob
|
||||
async fn update_blob_verified(&self, hash: &ContentHash) -> Result<()>;
|
||||
|
||||
/// List orphaned blobs (reference_count = 0)
|
||||
/// List orphaned blobs (`reference_count` = 0)
|
||||
async fn list_orphaned_blobs(&self) -> Result<Vec<ManagedBlob>>;
|
||||
|
||||
/// Delete a blob record
|
||||
|
|
@ -635,7 +869,7 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
/// Delete a sync device
|
||||
async fn delete_device(&self, id: crate::sync::DeviceId) -> Result<()>;
|
||||
|
||||
/// Update the last_seen_at timestamp for a device
|
||||
/// Update the `last_seen_at` timestamp for a device
|
||||
async fn touch_device(&self, id: crate::sync::DeviceId) -> Result<()>;
|
||||
|
||||
/// Record a change in the sync log
|
||||
|
|
@ -830,13 +1064,19 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
user_id: UserId,
|
||||
) -> Result<Vec<crate::sharing::ShareNotification>>;
|
||||
|
||||
/// Mark a notification as read
|
||||
async fn mark_notification_read(&self, id: Uuid) -> Result<()>;
|
||||
/// Mark a notification as read. Scoped to `user_id` to prevent cross-user
|
||||
/// modification. Silently no-ops if the notification belongs to a different
|
||||
/// user, which avoids leaking notification existence via error responses.
|
||||
async fn mark_notification_read(
|
||||
&self,
|
||||
id: Uuid,
|
||||
user_id: UserId,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Mark all notifications as read for a user
|
||||
async fn mark_all_notifications_read(&self, user_id: UserId) -> Result<()>;
|
||||
|
||||
/// Rename a media item (changes file_name and updates path accordingly).
|
||||
/// Rename a media item (changes `file_name` and updates path accordingly).
|
||||
/// For external storage, this actually renames the file on disk.
|
||||
/// For managed storage, this only updates the metadata.
|
||||
/// Returns the old path for sync log recording.
|
||||
|
|
@ -866,7 +1106,7 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
Ok(results)
|
||||
}
|
||||
|
||||
/// Soft delete a media item (set deleted_at timestamp).
|
||||
/// Soft delete a media item (set `deleted_at` timestamp).
|
||||
async fn soft_delete_media(&self, id: MediaId) -> Result<()>;
|
||||
|
||||
/// Restore a soft-deleted media item.
|
||||
|
|
@ -919,15 +1159,24 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
depth: u32,
|
||||
) -> Result<crate::model::GraphData>;
|
||||
|
||||
/// Resolve unresolved links by matching target_path against media item paths.
|
||||
/// Returns the number of links that were resolved.
|
||||
/// Resolve unresolved links by matching `target_path` against media item
|
||||
/// paths. Returns the number of links that were resolved.
|
||||
async fn resolve_links(&self) -> Result<u64>;
|
||||
|
||||
/// Update the links_extracted_at timestamp for a media item.
|
||||
/// Update the `links_extracted_at` timestamp for a media item.
|
||||
async fn mark_links_extracted(&self, media_id: MediaId) -> Result<()>;
|
||||
|
||||
/// Get count of unresolved links (links where target_media_id is NULL).
|
||||
/// Get count of unresolved links (links where `target_media_id` is NULL).
|
||||
async fn count_unresolved_links(&self) -> Result<u64>;
|
||||
|
||||
/// Create a backup of the database to the specified path.
|
||||
/// Default implementation returns unsupported; `SQLite` overrides with
|
||||
/// VACUUM INTO.
|
||||
async fn backup(&self, _dest: &std::path::Path) -> Result<()> {
|
||||
Err(crate::error::PinakesError::InvalidOperation(
|
||||
"backup not supported for this storage backend".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Comprehensive library statistics.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue