initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964
This commit is contained in:
commit
6a73d11c4b
124 changed files with 34856 additions and 0 deletions
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
use flake
|
||||||
7931
Cargo.lock
generated
Normal file
7931
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
100
Cargo.toml
Normal file
100
Cargo.toml
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"crates/pinakes-core",
|
||||||
|
"crates/pinakes-server",
|
||||||
|
"crates/pinakes-tui",
|
||||||
|
"crates/pinakes-ui",
|
||||||
|
]
|
||||||
|
resolver = "3"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
edition = "2024"
|
||||||
|
version = "0.1.0"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
toml = "0.9"
|
||||||
|
|
||||||
|
# CLI argument parsing
|
||||||
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
|
||||||
|
# Date/time
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
# IDs
|
||||||
|
uuid = { version = "1", features = ["v7", "serde"] }
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
thiserror = "2"
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
|
||||||
|
# Hashing
|
||||||
|
blake3 = "1"
|
||||||
|
|
||||||
|
# Metadata extraction
|
||||||
|
lofty = "0.22"
|
||||||
|
lopdf = "0.39"
|
||||||
|
epub = "2"
|
||||||
|
matroska = "0.30"
|
||||||
|
gray_matter = "0.3"
|
||||||
|
kamadak-exif = "0.6"
|
||||||
|
|
||||||
|
# Database - SQLite
|
||||||
|
rusqlite = { version = "0.37", features = ["bundled", "column_decltype"] }
|
||||||
|
|
||||||
|
# Database - PostgreSQL
|
||||||
|
tokio-postgres = { version = "0.7", features = ["with-uuid-1", "with-chrono-0_4", "with-serde_json-1"] }
|
||||||
|
deadpool-postgres = "0.14"
|
||||||
|
postgres-types = { version = "0.2", features = ["derive"] }
|
||||||
|
|
||||||
|
# Migrations
|
||||||
|
refinery = { version = "0.9", features = ["rusqlite", "tokio-postgres"] }
|
||||||
|
|
||||||
|
# Filesystem
|
||||||
|
walkdir = "2"
|
||||||
|
notify = { version = "8", features = ["macos_fsevent"] }
|
||||||
|
|
||||||
|
# Search parser
|
||||||
|
winnow = "0.7"
|
||||||
|
|
||||||
|
# HTTP server
|
||||||
|
axum = { version = "0.8", features = ["macros"] }
|
||||||
|
tower = "0.5"
|
||||||
|
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||||
|
governor = "0.8"
|
||||||
|
tower_governor = "0.6"
|
||||||
|
|
||||||
|
# HTTP client
|
||||||
|
reqwest = { version = "0.13", features = ["json", "query"] }
|
||||||
|
|
||||||
|
# TUI
|
||||||
|
ratatui = "0.30"
|
||||||
|
crossterm = "0.29"
|
||||||
|
|
||||||
|
# Desktop/Web UI
|
||||||
|
dioxus = { version = "0.7", features = ["desktop", "router"] }
|
||||||
|
|
||||||
|
# Async trait (dyn-compatible async methods)
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
# Image processing (thumbnails)
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp"] }
|
||||||
|
|
||||||
|
# Markdown rendering
|
||||||
|
pulldown-cmark = "0.12"
|
||||||
|
|
||||||
|
# Password hashing
|
||||||
|
argon2 = "0.5"
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
mime_guess = "2"
|
||||||
193
README.md
Normal file
193
README.md
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
# Pinakes
|
||||||
|
|
||||||
|
A media cataloging and library management system written in Rust. Pinakes
|
||||||
|
indexes files across configured directories, extracts metadata from audio,
|
||||||
|
video, document, and text files, and provides full-text search with tagging,
|
||||||
|
collections, and audit logging. It supports both SQLite and PostgreSQL backends.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Build all compilable crates
|
||||||
|
cargo build -p pinakes-core -p pinakes-server -p pinakes-tui
|
||||||
|
|
||||||
|
# The Dioxus UI requires GTK3 and libsoup system libraries:
|
||||||
|
# On Debian/Ubuntu: apt install libgtk-3-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev
|
||||||
|
# On Fedora: dnf install gtk3-devel libsoup3-devel webkit2gtk4.1-devel
|
||||||
|
# On Nix: Use the dev shell, everything is provided :)
|
||||||
|
cargo build -p pinakes-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Copy the example config and edit it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp pinakes.toml.example pinakes.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Key settings:
|
||||||
|
|
||||||
|
- `storage.backend` -- `"sqlite"` or `"postgres"`
|
||||||
|
- `storage.sqlite.path` -- Path to the SQLite database file
|
||||||
|
- `storage.postgres.*` -- PostgreSQL connection parameters
|
||||||
|
- `directories.roots` -- Directories to scan for media files
|
||||||
|
- `scanning.watch` -- Enable filesystem watching for automatic imports
|
||||||
|
- `scanning.ignore_patterns` -- Patterns to skip during scanning (e.g., `".*"`,
|
||||||
|
`"node_modules"`)
|
||||||
|
- `server.host` / `server.port` -- Server bind address
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p pinakes-server -- pinakes.toml
|
||||||
|
# or
|
||||||
|
cargo run -p pinakes-server -- --config pinakes.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
The server starts on the configured host:port (default `127.0.0.1:3000`).
|
||||||
|
|
||||||
|
### TUI
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p pinakes-tui
|
||||||
|
# or with a custom server URL:
|
||||||
|
cargo run -p pinakes-tui -- --server http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Keybindings:
|
||||||
|
|
||||||
|
<!-- markdownlint-disable MD013-->
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --------------------- | -------------------------------------------------------- |
|
||||||
|
| `q` / `Ctrl-C` | Quit |
|
||||||
|
| `j` / `k` | Navigate down / up |
|
||||||
|
| `Enter` | Select / confirm |
|
||||||
|
| `Esc` | Back |
|
||||||
|
| `/` | Search |
|
||||||
|
| `i` | Import file |
|
||||||
|
| `o` | Open file |
|
||||||
|
| `d` | Delete (media in library, tag/collection in their views) |
|
||||||
|
| `t` | Tags view |
|
||||||
|
| `c` | Collections view |
|
||||||
|
| `a` | Audit log view |
|
||||||
|
| `s` | Trigger scan |
|
||||||
|
| `r` | Refresh current view |
|
||||||
|
| `n` | Create new tag (in tags view) |
|
||||||
|
| `+` | Tag selected media (in detail view) |
|
||||||
|
| `-` | Untag selected media (in detail view) |
|
||||||
|
| `Tab` / `Shift-Tab` | Next / previous tab |
|
||||||
|
| `PageUp` / `PageDown` | Paginate |
|
||||||
|
|
||||||
|
<!-- markdownlint-enable MD013-->
|
||||||
|
|
||||||
|
### Desktop/Web UI
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p pinakes-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `PINAKES_SERVER_URL` to point at the server if it is not on
|
||||||
|
`localhost:3000`.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All endpoints are under `/api/v1`.
|
||||||
|
|
||||||
|
### Media
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| -------- | -------------------- | ------------------------------------- |
|
||||||
|
| `POST` | `/media/import` | Import a file (`{"path": "..."}`) |
|
||||||
|
| `GET` | `/media` | List media (query: `offset`, `limit`) |
|
||||||
|
| `GET` | `/media/{id}` | Get media item |
|
||||||
|
| `PATCH` | `/media/{id}` | Update metadata |
|
||||||
|
| `DELETE` | `/media/{id}` | Delete media item |
|
||||||
|
| `GET` | `/media/{id}/stream` | Stream file content |
|
||||||
|
| `POST` | `/media/{id}/open` | Open with system viewer |
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | --------------- | ---------------------------------------------- |
|
||||||
|
| `GET` | `/search?q=...` | Search (query: `q`, `sort`, `offset`, `limit`) |
|
||||||
|
|
||||||
|
Search syntax: `term`, `"exact phrase"`, `field:value`, `type:pdf`, `tag:music`,
|
||||||
|
`prefix*`, `fuzzy~`, `-excluded`, `a b` (AND), `a OR b`, `(grouped)`.
|
||||||
|
|
||||||
|
### Tags
|
||||||
|
|
||||||
|
<!-- markdownlint-disable MD013-->
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| -------- | --------------------------- | ------------------------------------------------ |
|
||||||
|
| `POST` | `/tags` | Create tag (`{"name": "...", "parent_id": ...}`) |
|
||||||
|
| `GET` | `/tags` | List all tags |
|
||||||
|
| `GET` | `/tags/{id}` | Get tag |
|
||||||
|
| `DELETE` | `/tags/{id}` | Delete tag |
|
||||||
|
| `POST` | `/media/{id}/tags` | Tag media (`{"tag_id": "..."}`) |
|
||||||
|
| `GET` | `/media/{id}/tags` | List media's tags |
|
||||||
|
| `DELETE` | `/media/{id}/tags/{tag_id}` | Untag media |
|
||||||
|
|
||||||
|
<!-- markdownlint-enable MD013-->
|
||||||
|
|
||||||
|
### Collections
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| -------- | ---------------------------------- | ----------------- |
|
||||||
|
| `POST` | `/collections` | Create collection |
|
||||||
|
| `GET` | `/collections` | List collections |
|
||||||
|
| `GET` | `/collections/{id}` | Get collection |
|
||||||
|
| `DELETE` | `/collections/{id}` | Delete collection |
|
||||||
|
| `POST` | `/collections/{id}/members` | Add member |
|
||||||
|
| `GET` | `/collections/{id}/members` | List members |
|
||||||
|
| `DELETE` | `/collections/{cid}/members/{mid}` | Remove member |
|
||||||
|
|
||||||
|
Virtual collections (kind `"virtual"`) evaluate their `filter_query` as a search
|
||||||
|
query when listing members, returning results dynamically.
|
||||||
|
|
||||||
|
### Audit & Scanning
|
||||||
|
|
||||||
|
<!-- markdownlint-disable MD013-->
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | -------- | ----------------------------------------------------------------------------- |
|
||||||
|
| `GET` | `/audit` | List audit log (query: `offset`, `limit`) |
|
||||||
|
| `POST` | `/scan` | Trigger directory scan (`{"path": "/..."}` or `{"path": null}` for all roots) |
|
||||||
|
|
||||||
|
<!-- markdownlint-enable MD013-->
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Unit and integration tests for the core library (SQLite in-memory)
|
||||||
|
cargo test -p pinakes-core
|
||||||
|
|
||||||
|
# API integration tests for the server
|
||||||
|
cargo test -p pinakes-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Media Types
|
||||||
|
|
||||||
|
| Category | Formats |
|
||||||
|
| -------- | ------------------------------- |
|
||||||
|
| Audio | MP3, FLAC, OGG, WAV, AAC, Opus |
|
||||||
|
| Video | MP4, MKV, AVI, WebM |
|
||||||
|
| Document | PDF, EPUB, DjVu |
|
||||||
|
| Text | Markdown, Plain text |
|
||||||
|
| Image | JPEG, PNG, GIF, WebP, SVG, AVIF |
|
||||||
|
|
||||||
|
Metadata extraction uses lofty (audio, MP4), matroska (MKV), lopdf (PDF), epub
|
||||||
|
(EPUB), and gray_matter (Markdown frontmatter).
|
||||||
|
|
||||||
|
## Storage Backends
|
||||||
|
|
||||||
|
**SQLite** (default) -- Single-file database with WAL mode and FTS5 full-text
|
||||||
|
search. Bundled SQLite guarantees FTS5 availability.
|
||||||
|
|
||||||
|
**PostgreSQL** -- Native async with connection pooling (deadpool-postgres). Uses
|
||||||
|
tsvector with weighted columns for full-text search and pg_trgm for fuzzy
|
||||||
|
matching. Requires the `pg_trgm` extension.
|
||||||
39
crates/pinakes-core/Cargo.toml
Normal file
39
crates/pinakes-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
[package]
|
||||||
|
name = "pinakes-core"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
blake3 = { workspace = true }
|
||||||
|
lofty = { workspace = true }
|
||||||
|
lopdf = { workspace = true }
|
||||||
|
epub = { workspace = true }
|
||||||
|
matroska = { workspace = true }
|
||||||
|
gray_matter = { workspace = true }
|
||||||
|
rusqlite = { workspace = true }
|
||||||
|
tokio-postgres = { workspace = true }
|
||||||
|
deadpool-postgres = { workspace = true }
|
||||||
|
postgres-types = { workspace = true }
|
||||||
|
refinery = { workspace = true }
|
||||||
|
walkdir = { workspace = true }
|
||||||
|
notify = { workspace = true }
|
||||||
|
winnow = { workspace = true }
|
||||||
|
mime_guess = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
kamadak-exif = { workspace = true }
|
||||||
|
image = { workspace = true }
|
||||||
|
tokio-util = { version = "0.7", features = ["rt"] }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
21
crates/pinakes-core/src/audit.rs
Normal file
21
crates/pinakes-core/src/audit.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::model::{AuditAction, AuditEntry, MediaId};
|
||||||
|
use crate::storage::DynStorageBackend;
|
||||||
|
|
||||||
|
pub async fn record_action(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
media_id: Option<MediaId>,
|
||||||
|
action: AuditAction,
|
||||||
|
details: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let entry = AuditEntry {
|
||||||
|
id: Uuid::now_v7(),
|
||||||
|
media_id,
|
||||||
|
action,
|
||||||
|
details,
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
};
|
||||||
|
storage.record_audit(&entry).await
|
||||||
|
}
|
||||||
91
crates/pinakes-core/src/cache.rs
Normal file
91
crates/pinakes-core/src/cache.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::hash::Hash;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
struct CacheEntry<V> {
|
||||||
|
value: V,
|
||||||
|
inserted_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A simple TTL-based in-memory cache with periodic eviction.
|
||||||
|
pub struct Cache<K, V> {
|
||||||
|
entries: Arc<RwLock<HashMap<K, CacheEntry<V>>>>,
|
||||||
|
ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<K, V> Cache<K, V>
|
||||||
|
where
|
||||||
|
K: Eq + Hash + Clone + Send + Sync + 'static,
|
||||||
|
V: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
pub fn new(ttl: Duration) -> Self {
|
||||||
|
let cache = Self {
|
||||||
|
entries: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
ttl,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spawn periodic eviction task
|
||||||
|
let entries = cache.entries.clone();
|
||||||
|
let ttl = cache.ttl;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(ttl);
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut map = entries.write().await;
|
||||||
|
map.retain(|_, entry| now.duration_since(entry.inserted_at) < ttl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cache
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, key: &K) -> Option<V> {
|
||||||
|
let map = self.entries.read().await;
|
||||||
|
if let Some(entry) = map.get(key) {
|
||||||
|
if entry.inserted_at.elapsed() < self.ttl {
|
||||||
|
return Some(entry.value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(&self, key: K, value: V) {
|
||||||
|
let mut map = self.entries.write().await;
|
||||||
|
map.insert(
|
||||||
|
key,
|
||||||
|
CacheEntry {
|
||||||
|
value,
|
||||||
|
inserted_at: Instant::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn invalidate(&self, key: &K) {
|
||||||
|
let mut map = self.entries.write().await;
|
||||||
|
map.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn invalidate_all(&self) {
|
||||||
|
let mut map = self.entries.write().await;
|
||||||
|
map.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application-level cache layer wrapping multiple caches for different data types.
|
||||||
|
pub struct CacheLayer {
|
||||||
|
/// Cache for serialized API responses, keyed by request path + query string.
|
||||||
|
pub responses: Cache<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheLayer {
|
||||||
|
pub fn new(ttl_secs: u64) -> Self {
|
||||||
|
let ttl = Duration::from_secs(ttl_secs);
|
||||||
|
Self {
|
||||||
|
responses: Cache::new(ttl),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
crates/pinakes-core/src/collections.rs
Normal file
78
crates/pinakes-core/src/collections.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::model::*;
|
||||||
|
use crate::storage::DynStorageBackend;
|
||||||
|
|
||||||
|
pub async fn create_collection(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
name: &str,
|
||||||
|
kind: CollectionKind,
|
||||||
|
description: Option<&str>,
|
||||||
|
filter_query: Option<&str>,
|
||||||
|
) -> Result<Collection> {
|
||||||
|
storage
|
||||||
|
.create_collection(name, kind, description, filter_query)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_member(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
collection_id: Uuid,
|
||||||
|
media_id: MediaId,
|
||||||
|
position: i32,
|
||||||
|
) -> Result<()> {
|
||||||
|
storage
|
||||||
|
.add_to_collection(collection_id, media_id, position)
|
||||||
|
.await?;
|
||||||
|
crate::audit::record_action(
|
||||||
|
storage,
|
||||||
|
Some(media_id),
|
||||||
|
AuditAction::AddedToCollection,
|
||||||
|
Some(format!("collection_id={collection_id}")),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_member(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
collection_id: Uuid,
|
||||||
|
media_id: MediaId,
|
||||||
|
) -> Result<()> {
|
||||||
|
storage
|
||||||
|
.remove_from_collection(collection_id, media_id)
|
||||||
|
.await?;
|
||||||
|
crate::audit::record_action(
|
||||||
|
storage,
|
||||||
|
Some(media_id),
|
||||||
|
AuditAction::RemovedFromCollection,
|
||||||
|
Some(format!("collection_id={collection_id}")),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_members(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
collection_id: Uuid,
|
||||||
|
) -> Result<Vec<MediaItem>> {
|
||||||
|
let collection = storage.get_collection(collection_id).await?;
|
||||||
|
|
||||||
|
match collection.kind {
|
||||||
|
CollectionKind::Virtual => {
|
||||||
|
// Virtual collections evaluate their filter_query dynamically
|
||||||
|
if let Some(ref query_str) = collection.filter_query {
|
||||||
|
let query = crate::search::parse_search_query(query_str)?;
|
||||||
|
let request = crate::search::SearchRequest {
|
||||||
|
query,
|
||||||
|
sort: crate::search::SortOrder::DateDesc,
|
||||||
|
pagination: Pagination::new(0, 10000, None),
|
||||||
|
};
|
||||||
|
let results = storage.search(&request).await?;
|
||||||
|
Ok(results.items)
|
||||||
|
} else {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CollectionKind::Manual => storage.get_collection_members(collection_id).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
437
crates/pinakes-core/src/config.rs
Normal file
437
crates/pinakes-core/src/config.rs
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub storage: StorageConfig,
|
||||||
|
pub directories: DirectoryConfig,
|
||||||
|
pub scanning: ScanningConfig,
|
||||||
|
pub server: ServerConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ui: UiConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub accounts: AccountsConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub jobs: JobsConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub thumbnails: ThumbnailConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub webhooks: Vec<WebhookConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub scheduled_tasks: Vec<ScheduledTaskConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ScheduledTaskConfig {
|
||||||
|
pub id: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub schedule: crate::scheduler::Schedule,
|
||||||
|
pub last_run: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct JobsConfig {
|
||||||
|
#[serde(default = "default_worker_count")]
|
||||||
|
pub worker_count: usize,
|
||||||
|
#[serde(default = "default_cache_ttl")]
|
||||||
|
pub cache_ttl_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_worker_count() -> usize {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
fn default_cache_ttl() -> u64 {
|
||||||
|
60
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for JobsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
worker_count: default_worker_count(),
|
||||||
|
cache_ttl_secs: default_cache_ttl(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ThumbnailConfig {
|
||||||
|
#[serde(default = "default_thumb_size")]
|
||||||
|
pub size: u32,
|
||||||
|
#[serde(default = "default_thumb_quality")]
|
||||||
|
pub quality: u8,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ffmpeg_path: Option<String>,
|
||||||
|
#[serde(default = "default_video_seek")]
|
||||||
|
pub video_seek_secs: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_thumb_size() -> u32 {
|
||||||
|
320
|
||||||
|
}
|
||||||
|
fn default_thumb_quality() -> u8 {
|
||||||
|
80
|
||||||
|
}
|
||||||
|
fn default_video_seek() -> u32 {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ThumbnailConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
size: default_thumb_size(),
|
||||||
|
quality: default_thumb_quality(),
|
||||||
|
ffmpeg_path: None,
|
||||||
|
video_seek_secs: default_video_seek(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WebhookConfig {
|
||||||
|
pub url: String,
|
||||||
|
pub events: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub secret: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UiConfig {
|
||||||
|
#[serde(default = "default_theme")]
|
||||||
|
pub theme: String,
|
||||||
|
#[serde(default = "default_view")]
|
||||||
|
pub default_view: String,
|
||||||
|
#[serde(default = "default_page_size")]
|
||||||
|
pub default_page_size: usize,
|
||||||
|
#[serde(default = "default_view_mode")]
|
||||||
|
pub default_view_mode: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_play_media: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_thumbnails: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sidebar_collapsed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_theme() -> String {
|
||||||
|
"dark".to_string()
|
||||||
|
}
|
||||||
|
fn default_view() -> String {
|
||||||
|
"library".to_string()
|
||||||
|
}
|
||||||
|
fn default_page_size() -> usize {
|
||||||
|
48
|
||||||
|
}
|
||||||
|
fn default_view_mode() -> String {
|
||||||
|
"grid".to_string()
|
||||||
|
}
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UiConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
theme: default_theme(),
|
||||||
|
default_view: default_view(),
|
||||||
|
default_page_size: default_page_size(),
|
||||||
|
default_view_mode: default_view_mode(),
|
||||||
|
auto_play_media: false,
|
||||||
|
show_thumbnails: true,
|
||||||
|
sidebar_collapsed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct AccountsConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub users: Vec<UserAccount>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UserAccount {
|
||||||
|
pub username: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub role: UserRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum UserRole {
|
||||||
|
Admin,
|
||||||
|
Editor,
|
||||||
|
#[default]
|
||||||
|
Viewer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRole {
|
||||||
|
pub fn can_read(self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_write(self) -> bool {
|
||||||
|
matches!(self, Self::Admin | Self::Editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_admin(self) -> bool {
|
||||||
|
matches!(self, Self::Admin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for UserRole {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Admin => write!(f, "admin"),
|
||||||
|
Self::Editor => write!(f, "editor"),
|
||||||
|
Self::Viewer => write!(f, "viewer"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StorageConfig {
|
||||||
|
pub backend: StorageBackendType,
|
||||||
|
pub sqlite: Option<SqliteConfig>,
|
||||||
|
pub postgres: Option<PostgresConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum StorageBackendType {
|
||||||
|
Sqlite,
|
||||||
|
Postgres,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SqliteConfig {
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PostgresConfig {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub database: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub max_connections: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DirectoryConfig {
|
||||||
|
pub roots: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ScanningConfig {
|
||||||
|
pub watch: bool,
|
||||||
|
pub poll_interval_secs: u64,
|
||||||
|
pub ignore_patterns: Vec<String>,
|
||||||
|
#[serde(default = "default_import_concurrency")]
|
||||||
|
pub import_concurrency: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_import_concurrency() -> usize {
|
||||||
|
8
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
/// Optional API key for bearer token authentication.
|
||||||
|
/// If set, all requests (except /health) must include `Authorization: Bearer <key>`.
|
||||||
|
/// Can also be set via `PINAKES_API_KEY` environment variable.
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_file(path: &Path) -> crate::error::Result<Self> {
|
||||||
|
let content = std::fs::read_to_string(path).map_err(|e| {
|
||||||
|
crate::error::PinakesError::Config(format!("failed to read config file: {e}"))
|
||||||
|
})?;
|
||||||
|
toml::from_str(&content)
|
||||||
|
.map_err(|e| crate::error::PinakesError::Config(format!("failed to parse config: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try loading from file, falling back to defaults if the file doesn't exist.
|
||||||
|
pub fn load_or_default(path: &Path) -> crate::error::Result<Self> {
|
||||||
|
if path.exists() {
|
||||||
|
Self::from_file(path)
|
||||||
|
} else {
|
||||||
|
let config = Self::default();
|
||||||
|
// Ensure the data directory exists for the default SQLite database
|
||||||
|
config.ensure_dirs()?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the current config to a TOML file.
|
||||||
|
pub fn save_to_file(&self, path: &Path) -> crate::error::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let content = toml::to_string_pretty(self).map_err(|e| {
|
||||||
|
crate::error::PinakesError::Config(format!("failed to serialize config: {e}"))
|
||||||
|
})?;
|
||||||
|
std::fs::write(path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure all directories needed by this config exist and are writable.
|
||||||
|
pub fn ensure_dirs(&self) -> crate::error::Result<()> {
|
||||||
|
if let Some(ref sqlite) = self.storage.sqlite
|
||||||
|
&& let Some(parent) = sqlite.path.parent()
|
||||||
|
{
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
let metadata = std::fs::metadata(parent)?;
|
||||||
|
if metadata.permissions().readonly() {
|
||||||
|
return Err(crate::error::PinakesError::Config(format!(
|
||||||
|
"directory is not writable: {}",
|
||||||
|
parent.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the default config file path following XDG conventions.
|
||||||
|
pub fn default_config_path() -> PathBuf {
|
||||||
|
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
||||||
|
PathBuf::from(xdg).join("pinakes").join("pinakes.toml")
|
||||||
|
} else if let Ok(home) = std::env::var("HOME") {
|
||||||
|
PathBuf::from(home)
|
||||||
|
.join(".config")
|
||||||
|
.join("pinakes")
|
||||||
|
.join("pinakes.toml")
|
||||||
|
} else {
|
||||||
|
PathBuf::from("pinakes.toml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate configuration values for correctness.
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
if self.server.port == 0 {
|
||||||
|
return Err("server port cannot be 0".into());
|
||||||
|
}
|
||||||
|
if self.server.host.is_empty() {
|
||||||
|
return Err("server host cannot be empty".into());
|
||||||
|
}
|
||||||
|
if self.scanning.poll_interval_secs == 0 {
|
||||||
|
return Err("poll interval cannot be 0".into());
|
||||||
|
}
|
||||||
|
if self.scanning.import_concurrency == 0 || self.scanning.import_concurrency > 256 {
|
||||||
|
return Err("import_concurrency must be between 1 and 256".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the default data directory following XDG conventions.
|
||||||
|
pub fn default_data_dir() -> PathBuf {
|
||||||
|
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||||
|
PathBuf::from(xdg).join("pinakes")
|
||||||
|
} else if let Ok(home) = std::env::var("HOME") {
|
||||||
|
PathBuf::from(home)
|
||||||
|
.join(".local")
|
||||||
|
.join("share")
|
||||||
|
.join("pinakes")
|
||||||
|
} else {
|
||||||
|
PathBuf::from("pinakes-data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
let data_dir = Self::default_data_dir();
|
||||||
|
Self {
|
||||||
|
storage: StorageConfig {
|
||||||
|
backend: StorageBackendType::Sqlite,
|
||||||
|
sqlite: Some(SqliteConfig {
|
||||||
|
path: data_dir.join("pinakes.db"),
|
||||||
|
}),
|
||||||
|
postgres: None,
|
||||||
|
},
|
||||||
|
directories: DirectoryConfig { roots: vec![] },
|
||||||
|
scanning: ScanningConfig {
|
||||||
|
watch: false,
|
||||||
|
poll_interval_secs: 300,
|
||||||
|
ignore_patterns: vec![
|
||||||
|
".*".to_string(),
|
||||||
|
"node_modules".to_string(),
|
||||||
|
"__pycache__".to_string(),
|
||||||
|
"target".to_string(),
|
||||||
|
],
|
||||||
|
import_concurrency: default_import_concurrency(),
|
||||||
|
},
|
||||||
|
server: ServerConfig {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 3000,
|
||||||
|
api_key: None,
|
||||||
|
},
|
||||||
|
ui: UiConfig::default(),
|
||||||
|
accounts: AccountsConfig::default(),
|
||||||
|
jobs: JobsConfig::default(),
|
||||||
|
thumbnails: ThumbnailConfig::default(),
|
||||||
|
webhooks: vec![],
|
||||||
|
scheduled_tasks: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_config_with_concurrency(concurrency: usize) -> Config {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.scanning.import_concurrency = concurrency;
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_import_concurrency_zero() {
|
||||||
|
let config = test_config_with_concurrency(0);
|
||||||
|
assert!(config.validate().is_err());
|
||||||
|
assert!(
|
||||||
|
config
|
||||||
|
.validate()
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("import_concurrency")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_import_concurrency_too_high() {
|
||||||
|
let config = test_config_with_concurrency(257);
|
||||||
|
assert!(config.validate().is_err());
|
||||||
|
assert!(
|
||||||
|
config
|
||||||
|
.validate()
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("import_concurrency")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_import_concurrency_valid() {
|
||||||
|
let config = test_config_with_concurrency(8);
|
||||||
|
assert!(config.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_import_concurrency_boundary_low() {
|
||||||
|
let config = test_config_with_concurrency(1);
|
||||||
|
assert!(config.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_import_concurrency_boundary_high() {
|
||||||
|
let config = test_config_with_concurrency(256);
|
||||||
|
assert!(config.validate().is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
59
crates/pinakes-core/src/error.rs
Normal file
59
crates/pinakes-core/src/error.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum PinakesError {
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Database(String),
|
||||||
|
|
||||||
|
#[error("migration error: {0}")]
|
||||||
|
Migration(String),
|
||||||
|
|
||||||
|
#[error("configuration error: {0}")]
|
||||||
|
Config(String),
|
||||||
|
|
||||||
|
#[error("media item not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("duplicate content hash: {0}")]
|
||||||
|
DuplicateHash(String),
|
||||||
|
|
||||||
|
#[error("unsupported media type for path: {0}")]
|
||||||
|
UnsupportedMediaType(PathBuf),
|
||||||
|
|
||||||
|
#[error("metadata extraction failed: {0}")]
|
||||||
|
MetadataExtraction(String),
|
||||||
|
|
||||||
|
#[error("search query parse error: {0}")]
|
||||||
|
SearchParse(String),
|
||||||
|
|
||||||
|
#[error("file not found at path: {0}")]
|
||||||
|
FileNotFound(PathBuf),
|
||||||
|
|
||||||
|
#[error("tag not found: {0}")]
|
||||||
|
TagNotFound(String),
|
||||||
|
|
||||||
|
#[error("collection not found: {0}")]
|
||||||
|
CollectionNotFound(String),
|
||||||
|
|
||||||
|
#[error("invalid operation: {0}")]
|
||||||
|
InvalidOperation(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<rusqlite::Error> for PinakesError {
|
||||||
|
fn from(e: rusqlite::Error) -> Self {
|
||||||
|
PinakesError::Database(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<tokio_postgres::Error> for PinakesError {
|
||||||
|
fn from(e: tokio_postgres::Error) -> Self {
|
||||||
|
PinakesError::Database(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, PinakesError>;
|
||||||
106
crates/pinakes-core/src/events.rs
Normal file
106
crates/pinakes-core/src/events.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::config::WebhookConfig;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PinakesEvent {
|
||||||
|
MediaImported {
|
||||||
|
media_id: String,
|
||||||
|
},
|
||||||
|
MediaUpdated {
|
||||||
|
media_id: String,
|
||||||
|
},
|
||||||
|
MediaDeleted {
|
||||||
|
media_id: String,
|
||||||
|
},
|
||||||
|
ScanCompleted {
|
||||||
|
files_found: usize,
|
||||||
|
files_processed: usize,
|
||||||
|
},
|
||||||
|
IntegrityMismatch {
|
||||||
|
media_id: String,
|
||||||
|
expected: String,
|
||||||
|
actual: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PinakesEvent {
|
||||||
|
pub fn event_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::MediaImported { .. } => "media_imported",
|
||||||
|
Self::MediaUpdated { .. } => "media_updated",
|
||||||
|
Self::MediaDeleted { .. } => "media_deleted",
|
||||||
|
Self::ScanCompleted { .. } => "scan_completed",
|
||||||
|
Self::IntegrityMismatch { .. } => "integrity_mismatch",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EventBus {
|
||||||
|
tx: broadcast::Sender<PinakesEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventBus {
|
||||||
|
pub fn new(webhooks: Vec<WebhookConfig>) -> Arc<Self> {
|
||||||
|
let (tx, _) = broadcast::channel(256);
|
||||||
|
|
||||||
|
// Spawn webhook delivery task
|
||||||
|
if !webhooks.is_empty() {
|
||||||
|
let mut rx: broadcast::Receiver<PinakesEvent> = tx.subscribe();
|
||||||
|
let webhooks = Arc::new(webhooks);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok(event) = rx.recv().await {
|
||||||
|
let event_name = event.event_name();
|
||||||
|
for hook in webhooks.iter() {
|
||||||
|
if hook.events.iter().any(|e| e == event_name || e == "*") {
|
||||||
|
let url = hook.url.clone();
|
||||||
|
let event_clone = event.clone();
|
||||||
|
let secret = hook.secret.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
deliver_webhook(&url, &event_clone, secret.as_deref()).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Arc::new(Self { tx })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit(&self, event: PinakesEvent) {
|
||||||
|
// Ignore send errors (no receivers)
|
||||||
|
let _ = self.tx.send(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn deliver_webhook(url: &str, event: &PinakesEvent, _secret: Option<&str>) {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let body = serde_json::to_string(event).unwrap_or_default();
|
||||||
|
|
||||||
|
for attempt in 0..3 {
|
||||||
|
match client
|
||||||
|
.post(url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(body.clone())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) if resp.status().is_success() => return,
|
||||||
|
Ok(resp) => {
|
||||||
|
warn!(url, status = %resp.status(), attempt, "webhook delivery failed");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(url, error = %e, attempt, "webhook delivery error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(1 << attempt)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
crates/pinakes-core/src/export.rs
Normal file
68
crates/pinakes-core/src/export.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::jobs::ExportFormat;
|
||||||
|
use crate::storage::DynStorageBackend;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ExportResult {
|
||||||
|
pub items_exported: usize,
|
||||||
|
pub output_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export library data to the specified format.
|
||||||
|
pub async fn export_library(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
format: &ExportFormat,
|
||||||
|
destination: &Path,
|
||||||
|
) -> Result<ExportResult> {
|
||||||
|
let pagination = crate::model::Pagination {
|
||||||
|
offset: 0,
|
||||||
|
limit: u64::MAX,
|
||||||
|
sort: None,
|
||||||
|
};
|
||||||
|
let items = storage.list_media(&&pagination).await?;
|
||||||
|
let count = items.len();
|
||||||
|
|
||||||
|
match format {
|
||||||
|
ExportFormat::Json => {
|
||||||
|
let json = serde_json::to_string_pretty(&items)
|
||||||
|
.map_err(|e| crate::error::PinakesError::Config(format!("json serialize: {e}")))?;
|
||||||
|
std::fs::write(destination, json)?;
|
||||||
|
}
|
||||||
|
ExportFormat::Csv => {
|
||||||
|
let mut csv = String::new();
|
||||||
|
csv.push_str("id,path,file_name,media_type,content_hash,file_size,title,artist,album,genre,year,duration_secs,description,created_at,updated_at\n");
|
||||||
|
for item in &items {
|
||||||
|
csv.push_str(&format!(
|
||||||
|
"{},{},{},{:?},{},{},{},{},{},{},{},{},{},{},{}\n",
|
||||||
|
item.id,
|
||||||
|
item.path.display(),
|
||||||
|
item.file_name,
|
||||||
|
item.media_type,
|
||||||
|
item.content_hash,
|
||||||
|
item.file_size,
|
||||||
|
item.title.as_deref().unwrap_or(""),
|
||||||
|
item.artist.as_deref().unwrap_or(""),
|
||||||
|
item.album.as_deref().unwrap_or(""),
|
||||||
|
item.genre.as_deref().unwrap_or(""),
|
||||||
|
item.year.map(|y| y.to_string()).unwrap_or_default(),
|
||||||
|
item.duration_secs
|
||||||
|
.map(|d| d.to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
item.description.as_deref().unwrap_or(""),
|
||||||
|
item.created_at,
|
||||||
|
item.updated_at,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
std::fs::write(destination, csv)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ExportResult {
|
||||||
|
items_exported: count,
|
||||||
|
output_path: destination.to_string_lossy().to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
31
crates/pinakes-core/src/hash.rs
Normal file
31
crates/pinakes-core/src/hash.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::model::ContentHash;
|
||||||
|
|
||||||
|
const BUFFER_SIZE: usize = 65536;
|
||||||
|
|
||||||
|
pub async fn compute_file_hash(path: &Path) -> Result<ContentHash> {
|
||||||
|
let path = path.to_path_buf();
|
||||||
|
let hash = tokio::task::spawn_blocking(move || -> Result<ContentHash> {
|
||||||
|
let mut hasher = blake3::Hasher::new();
|
||||||
|
let mut file = std::fs::File::open(&path)?;
|
||||||
|
let mut buf = vec![0u8; BUFFER_SIZE];
|
||||||
|
loop {
|
||||||
|
let n = std::io::Read::read(&mut file, &mut buf)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
Ok(ContentHash::new(hasher.finalize().to_hex().to_string()))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::error::PinakesError::Io(std::io::Error::other(e)))??;
|
||||||
|
Ok(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_hash_sync(data: &[u8]) -> ContentHash {
|
||||||
|
let hash = blake3::hash(data);
|
||||||
|
ContentHash::new(hash.to_hex().to_string())
|
||||||
|
}
|
||||||
250
crates/pinakes-core/src/import.rs
Normal file
250
crates/pinakes-core/src/import.rs
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::audit;
|
||||||
|
use crate::error::{PinakesError, Result};
|
||||||
|
use crate::hash::compute_file_hash;
|
||||||
|
use crate::media_type::MediaType;
|
||||||
|
use crate::metadata;
|
||||||
|
use crate::model::*;
|
||||||
|
use crate::storage::DynStorageBackend;
|
||||||
|
use crate::thumbnail;
|
||||||
|
|
||||||
|
pub struct ImportResult {
|
||||||
|
pub media_id: MediaId,
|
||||||
|
pub was_duplicate: bool,
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check that a canonicalized path falls under at least one configured root directory.
|
||||||
|
/// If no roots are configured, all paths are allowed (for ad-hoc imports).
|
||||||
|
pub async fn validate_path_in_roots(storage: &DynStorageBackend, path: &Path) -> Result<()> {
|
||||||
|
let roots = storage.list_root_dirs().await?;
|
||||||
|
if roots.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for root in &roots {
|
||||||
|
if let Ok(canonical_root) = root.canonicalize()
|
||||||
|
&& path.starts_with(&canonical_root)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(PinakesError::InvalidOperation(format!(
|
||||||
|
"path {} is not within any configured root directory",
|
||||||
|
path.display()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn import_file(storage: &DynStorageBackend, path: &Path) -> Result<ImportResult> {
|
||||||
|
let path = path.canonicalize()?;
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(PinakesError::FileNotFound(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_path_in_roots(storage, &path).await?;
|
||||||
|
|
||||||
|
let media_type = MediaType::from_path(&path)
|
||||||
|
.ok_or_else(|| PinakesError::UnsupportedMediaType(path.clone()))?;
|
||||||
|
|
||||||
|
let content_hash = compute_file_hash(&path).await?;
|
||||||
|
|
||||||
|
if let Some(existing) = storage.get_media_by_hash(&content_hash).await? {
|
||||||
|
return Ok(ImportResult {
|
||||||
|
media_id: existing.id,
|
||||||
|
was_duplicate: true,
|
||||||
|
path: path.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_meta = std::fs::metadata(&path)?;
|
||||||
|
let file_size = file_meta.len();
|
||||||
|
|
||||||
|
let extracted = {
|
||||||
|
let path_clone = path.clone();
|
||||||
|
tokio::task::spawn_blocking(move || metadata::extract_metadata(&path_clone, media_type))
|
||||||
|
.await
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(e.to_string()))??
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let media_id = MediaId::new();
|
||||||
|
|
||||||
|
// Generate thumbnail for image types
|
||||||
|
let thumb_path = {
|
||||||
|
let source = path.clone();
|
||||||
|
let thumb_dir = thumbnail::default_thumbnail_dir();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
thumbnail::generate_thumbnail(media_id, &source, media_type, &thumb_dir)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(e.to_string()))??
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = MediaItem {
|
||||||
|
id: media_id,
|
||||||
|
path: path.clone(),
|
||||||
|
file_name,
|
||||||
|
media_type,
|
||||||
|
content_hash,
|
||||||
|
file_size,
|
||||||
|
title: extracted.title,
|
||||||
|
artist: extracted.artist,
|
||||||
|
album: extracted.album,
|
||||||
|
genre: extracted.genre,
|
||||||
|
year: extracted.year,
|
||||||
|
duration_secs: extracted.duration_secs,
|
||||||
|
description: extracted.description,
|
||||||
|
thumbnail_path: thumb_path,
|
||||||
|
custom_fields: std::collections::HashMap::new(),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
storage.insert_media(&item).await?;
|
||||||
|
|
||||||
|
// Store extracted extra metadata as custom fields
|
||||||
|
for (key, value) in &extracted.extra {
|
||||||
|
let field = CustomField {
|
||||||
|
field_type: CustomFieldType::Text,
|
||||||
|
value: value.clone(),
|
||||||
|
};
|
||||||
|
if let Err(e) = storage.set_custom_field(media_id, key, &field).await {
|
||||||
|
tracing::warn!(
|
||||||
|
media_id = %media_id,
|
||||||
|
field = %key,
|
||||||
|
error = %e,
|
||||||
|
"failed to store extracted metadata as custom field"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audit::record_action(
|
||||||
|
storage,
|
||||||
|
Some(media_id),
|
||||||
|
AuditAction::Imported,
|
||||||
|
Some(format!("path={}", path.display())),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!(media_id = %media_id, path = %path.display(), "imported media file");
|
||||||
|
|
||||||
|
Ok(ImportResult {
|
||||||
|
media_id,
|
||||||
|
was_duplicate: false,
|
||||||
|
path: path.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn should_ignore(path: &std::path::Path, patterns: &[String]) -> bool {
|
||||||
|
for component in path.components() {
|
||||||
|
if let std::path::Component::Normal(name) = component {
|
||||||
|
let name_str = name.to_string_lossy();
|
||||||
|
for pattern in patterns {
|
||||||
|
if pattern.starts_with('.')
|
||||||
|
&& name_str.starts_with('.')
|
||||||
|
&& pattern == name_str.as_ref()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Simple glob: ".*" matches any dotfile
|
||||||
|
if pattern == ".*" && name_str.starts_with('.') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if name_str == pattern.as_str() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default number of concurrent import tasks.
|
||||||
|
const DEFAULT_IMPORT_CONCURRENCY: usize = 8;
|
||||||
|
|
||||||
|
pub async fn import_directory(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
dir: &Path,
|
||||||
|
ignore_patterns: &[String],
|
||||||
|
) -> Result<Vec<std::result::Result<ImportResult, PinakesError>>> {
|
||||||
|
import_directory_with_concurrency(storage, dir, ignore_patterns, DEFAULT_IMPORT_CONCURRENCY)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn import_directory_with_concurrency(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
dir: &Path,
|
||||||
|
ignore_patterns: &[String],
|
||||||
|
concurrency: usize,
|
||||||
|
) -> Result<Vec<std::result::Result<ImportResult, PinakesError>>> {
|
||||||
|
let concurrency = concurrency.clamp(1, 256);
|
||||||
|
let dir = dir.to_path_buf();
|
||||||
|
let patterns = ignore_patterns.to_vec();
|
||||||
|
|
||||||
|
let entries: Vec<PathBuf> = {
|
||||||
|
let dir = dir.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
walkdir::WalkDir::new(&dir)
|
||||||
|
.follow_links(true)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.file_type().is_file())
|
||||||
|
.filter(|e| MediaType::from_path(e.path()).is_some())
|
||||||
|
.filter(|e| !should_ignore(e.path(), &patterns))
|
||||||
|
.map(|e| e.path().to_path_buf())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| PinakesError::Io(std::io::Error::other(e)))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut results = Vec::with_capacity(entries.len());
|
||||||
|
let mut join_set = tokio::task::JoinSet::new();
|
||||||
|
let mut pending_paths: Vec<PathBuf> = Vec::new();
|
||||||
|
|
||||||
|
for entry_path in entries {
|
||||||
|
let storage = storage.clone();
|
||||||
|
let path = entry_path.clone();
|
||||||
|
pending_paths.push(entry_path);
|
||||||
|
|
||||||
|
join_set.spawn(async move {
|
||||||
|
let result = import_file(&storage, &path).await;
|
||||||
|
(path, result)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limit concurrency by draining when we hit the cap
|
||||||
|
if join_set.len() >= concurrency
|
||||||
|
&& let Some(Ok((path, result))) = join_set.join_next().await
|
||||||
|
{
|
||||||
|
match result {
|
||||||
|
Ok(r) => results.push(Ok(r)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = %path.display(), error = %e, "failed to import file");
|
||||||
|
results.push(Err(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain remaining tasks
|
||||||
|
while let Some(Ok((path, result))) = join_set.join_next().await {
|
||||||
|
match result {
|
||||||
|
Ok(r) => results.push(Ok(r)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = %path.display(), error = %e, "failed to import file");
|
||||||
|
results.push(Err(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
201
crates/pinakes-core/src/integrity.rs
Normal file
201
crates/pinakes-core/src/integrity.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::hash::compute_file_hash;
|
||||||
|
use crate::model::{ContentHash, MediaId};
|
||||||
|
use crate::storage::DynStorageBackend;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrphanReport {
|
||||||
|
/// Media items whose files no longer exist on disk.
|
||||||
|
pub orphaned_ids: Vec<MediaId>,
|
||||||
|
/// Files on disk that are not tracked in the database.
|
||||||
|
pub untracked_paths: Vec<PathBuf>,
|
||||||
|
/// Files that appear to have moved (same hash, different path).
|
||||||
|
pub moved_files: Vec<(MediaId, PathBuf, PathBuf)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum OrphanAction {
|
||||||
|
Delete,
|
||||||
|
Ignore,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VerificationReport {
|
||||||
|
pub verified: usize,
|
||||||
|
pub mismatched: Vec<(MediaId, String, String)>,
|
||||||
|
pub missing: Vec<MediaId>,
|
||||||
|
pub errors: Vec<(MediaId, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum IntegrityStatus {
|
||||||
|
Unverified,
|
||||||
|
Verified,
|
||||||
|
Mismatch,
|
||||||
|
Missing,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for IntegrityStatus {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Unverified => write!(f, "unverified"),
|
||||||
|
Self::Verified => write!(f, "verified"),
|
||||||
|
Self::Mismatch => write!(f, "mismatch"),
|
||||||
|
Self::Missing => write!(f, "missing"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for IntegrityStatus {
|
||||||
|
type Err = String;
|
||||||
|
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"unverified" => Ok(Self::Unverified),
|
||||||
|
"verified" => Ok(Self::Verified),
|
||||||
|
"mismatch" => Ok(Self::Mismatch),
|
||||||
|
"missing" => Ok(Self::Missing),
|
||||||
|
_ => Err(format!("unknown integrity status: {s}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect orphaned media items (files that no longer exist on disk).
|
||||||
|
pub async fn detect_orphans(storage: &DynStorageBackend) -> Result<OrphanReport> {
|
||||||
|
let media_paths = storage.list_media_paths().await?;
|
||||||
|
let mut orphaned_ids = Vec::new();
|
||||||
|
let moved_files = Vec::new();
|
||||||
|
|
||||||
|
for (id, path, _hash) in &media_paths {
|
||||||
|
if !path.exists() {
|
||||||
|
orphaned_ids.push(*id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
orphaned = orphaned_ids.len(),
|
||||||
|
total = media_paths.len(),
|
||||||
|
"orphan detection complete"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(OrphanReport {
|
||||||
|
orphaned_ids,
|
||||||
|
untracked_paths: Vec::new(),
|
||||||
|
moved_files,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve orphaned media items by deleting them from the database.
|
||||||
|
pub async fn resolve_orphans(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
action: OrphanAction,
|
||||||
|
ids: &[MediaId],
|
||||||
|
) -> Result<u64> {
|
||||||
|
match action {
|
||||||
|
OrphanAction::Delete => {
|
||||||
|
let count = storage.batch_delete_media(ids).await?;
|
||||||
|
info!(count, "resolved orphans by deletion");
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
OrphanAction::Ignore => {
|
||||||
|
info!(count = ids.len(), "orphans ignored");
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify integrity of media files by recomputing hashes and comparing.
|
||||||
|
pub async fn verify_integrity(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
media_ids: Option<&[MediaId]>,
|
||||||
|
) -> Result<VerificationReport> {
|
||||||
|
let all_paths = storage.list_media_paths().await?;
|
||||||
|
|
||||||
|
let paths_to_check: Vec<(MediaId, PathBuf, ContentHash)> = if let Some(ids) = media_ids {
|
||||||
|
let id_set: std::collections::HashSet<MediaId> = ids.iter().copied().collect();
|
||||||
|
all_paths
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(id, _, _)| id_set.contains(id))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
all_paths
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut report = VerificationReport {
|
||||||
|
verified: 0,
|
||||||
|
mismatched: Vec::new(),
|
||||||
|
missing: Vec::new(),
|
||||||
|
errors: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (id, path, expected_hash) in paths_to_check {
|
||||||
|
if !path.exists() {
|
||||||
|
report.missing.push(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match compute_file_hash(&path).await {
|
||||||
|
Ok(actual_hash) => {
|
||||||
|
if actual_hash.0 == expected_hash.0 {
|
||||||
|
report.verified += 1;
|
||||||
|
} else {
|
||||||
|
report
|
||||||
|
.mismatched
|
||||||
|
.push((id, expected_hash.0.clone(), actual_hash.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
report.errors.push((id, e.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
verified = report.verified,
|
||||||
|
mismatched = report.mismatched.len(),
|
||||||
|
missing = report.missing.len(),
|
||||||
|
errors = report.errors.len(),
|
||||||
|
"integrity verification complete"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up orphaned thumbnail files that don't correspond to any media item.
|
||||||
|
pub async fn cleanup_orphaned_thumbnails(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
thumbnail_dir: &Path,
|
||||||
|
) -> Result<usize> {
|
||||||
|
let media_paths = storage.list_media_paths().await?;
|
||||||
|
let known_ids: std::collections::HashSet<String> = media_paths
|
||||||
|
.iter()
|
||||||
|
.map(|(id, _, _)| id.0.to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut removed = 0;
|
||||||
|
|
||||||
|
if thumbnail_dir.exists() {
|
||||||
|
let entries = std::fs::read_dir(thumbnail_dir)?;
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||||
|
if !known_ids.contains(stem) {
|
||||||
|
if let Err(e) = std::fs::remove_file(&path) {
|
||||||
|
warn!(path = %path.display(), error = %e, "failed to remove orphaned thumbnail");
|
||||||
|
} else {
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(removed, "orphaned thumbnail cleanup complete");
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
226
crates/pinakes-core/src/jobs.rs
Normal file
226
crates/pinakes-core/src/jobs.rs
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use tokio::sync::{RwLock, mpsc};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::model::MediaId;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "type")]
|
||||||
|
pub enum JobKind {
|
||||||
|
Scan {
|
||||||
|
path: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
GenerateThumbnails {
|
||||||
|
media_ids: Vec<MediaId>,
|
||||||
|
},
|
||||||
|
VerifyIntegrity {
|
||||||
|
media_ids: Vec<MediaId>,
|
||||||
|
},
|
||||||
|
OrphanDetection,
|
||||||
|
CleanupThumbnails,
|
||||||
|
Export {
|
||||||
|
format: ExportFormat,
|
||||||
|
destination: PathBuf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ExportFormat {
|
||||||
|
Json,
|
||||||
|
Csv,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "state")]
|
||||||
|
pub enum JobStatus {
|
||||||
|
Pending,
|
||||||
|
Running { progress: f32, message: String },
|
||||||
|
Completed { result: Value },
|
||||||
|
Failed { error: String },
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Job {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub kind: JobKind,
|
||||||
|
pub status: JobStatus,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WorkerItem {
|
||||||
|
job_id: Uuid,
|
||||||
|
kind: JobKind,
|
||||||
|
cancel: CancellationToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct JobQueue {
|
||||||
|
jobs: Arc<RwLock<HashMap<Uuid, Job>>>,
|
||||||
|
cancellations: Arc<RwLock<HashMap<Uuid, CancellationToken>>>,
|
||||||
|
tx: mpsc::Sender<WorkerItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobQueue {
|
||||||
|
/// Create a new job queue and spawn `worker_count` background workers.
|
||||||
|
///
|
||||||
|
/// The `executor` callback is invoked for each job; it receives the job kind,
|
||||||
|
/// a progress-reporting callback, and a cancellation token.
|
||||||
|
pub fn new<F>(worker_count: usize, executor: F) -> Arc<Self>
|
||||||
|
where
|
||||||
|
F: Fn(
|
||||||
|
Uuid,
|
||||||
|
JobKind,
|
||||||
|
CancellationToken,
|
||||||
|
Arc<RwLock<HashMap<Uuid, Job>>>,
|
||||||
|
) -> tokio::task::JoinHandle<()>
|
||||||
|
+ Send
|
||||||
|
+ Sync
|
||||||
|
+ 'static,
|
||||||
|
{
|
||||||
|
let (tx, rx) = mpsc::channel::<WorkerItem>(256);
|
||||||
|
let rx = Arc::new(tokio::sync::Mutex::new(rx));
|
||||||
|
let jobs: Arc<RwLock<HashMap<Uuid, Job>>> = Arc::new(RwLock::new(HashMap::new()));
|
||||||
|
let cancellations: Arc<RwLock<HashMap<Uuid, CancellationToken>>> =
|
||||||
|
Arc::new(RwLock::new(HashMap::new()));
|
||||||
|
|
||||||
|
let executor = Arc::new(executor);
|
||||||
|
|
||||||
|
for _ in 0..worker_count {
|
||||||
|
let rx = rx.clone();
|
||||||
|
let jobs = jobs.clone();
|
||||||
|
let cancellations = cancellations.clone();
|
||||||
|
let executor = executor.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let item = {
|
||||||
|
let mut guard = rx.lock().await;
|
||||||
|
guard.recv().await
|
||||||
|
};
|
||||||
|
let Some(item) = item else { break };
|
||||||
|
|
||||||
|
// Mark as running
|
||||||
|
{
|
||||||
|
let mut map = jobs.write().await;
|
||||||
|
if let Some(job) = map.get_mut(&item.job_id) {
|
||||||
|
job.status = JobStatus::Running {
|
||||||
|
progress: 0.0,
|
||||||
|
message: "starting".to_string(),
|
||||||
|
};
|
||||||
|
job.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = executor(item.job_id, item.kind, item.cancel, jobs.clone());
|
||||||
|
let _ = handle.await;
|
||||||
|
|
||||||
|
// Clean up cancellation token
|
||||||
|
cancellations.write().await.remove(&item.job_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Arc::new(Self {
|
||||||
|
jobs,
|
||||||
|
cancellations,
|
||||||
|
tx,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submit a new job, returning its ID.
|
||||||
|
pub async fn submit(&self, kind: JobKind) -> Uuid {
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let now = Utc::now();
|
||||||
|
let cancel = CancellationToken::new();
|
||||||
|
|
||||||
|
let job = Job {
|
||||||
|
id,
|
||||||
|
kind: kind.clone(),
|
||||||
|
status: JobStatus::Pending,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.jobs.write().await.insert(id, job);
|
||||||
|
self.cancellations.write().await.insert(id, cancel.clone());
|
||||||
|
|
||||||
|
let item = WorkerItem {
|
||||||
|
job_id: id,
|
||||||
|
kind,
|
||||||
|
cancel,
|
||||||
|
};
|
||||||
|
|
||||||
|
// If the channel is full we still record the job — it'll stay Pending
|
||||||
|
let _ = self.tx.send(item).await;
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the status of a job.
|
||||||
|
pub async fn status(&self, id: Uuid) -> Option<Job> {
|
||||||
|
self.jobs.read().await.get(&id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all jobs, most recent first.
|
||||||
|
pub async fn list(&self) -> Vec<Job> {
|
||||||
|
let map = self.jobs.read().await;
|
||||||
|
let mut jobs: Vec<Job> = map.values().cloned().collect();
|
||||||
|
jobs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||||
|
jobs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel a running or pending job.
|
||||||
|
pub async fn cancel(&self, id: Uuid) -> bool {
|
||||||
|
if let Some(token) = self.cancellations.read().await.get(&id) {
|
||||||
|
token.cancel();
|
||||||
|
let mut map = self.jobs.write().await;
|
||||||
|
if let Some(job) = map.get_mut(&id) {
|
||||||
|
job.status = JobStatus::Cancelled;
|
||||||
|
job.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a job's progress. Called by executors.
|
||||||
|
pub async fn update_progress(
|
||||||
|
jobs: &Arc<RwLock<HashMap<Uuid, Job>>>,
|
||||||
|
id: Uuid,
|
||||||
|
progress: f32,
|
||||||
|
message: String,
|
||||||
|
) {
|
||||||
|
let mut map = jobs.write().await;
|
||||||
|
if let Some(job) = map.get_mut(&id) {
|
||||||
|
job.status = JobStatus::Running { progress, message };
|
||||||
|
job.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a job as completed.
|
||||||
|
pub async fn complete(jobs: &Arc<RwLock<HashMap<Uuid, Job>>>, id: Uuid, result: Value) {
|
||||||
|
let mut map = jobs.write().await;
|
||||||
|
if let Some(job) = map.get_mut(&id) {
|
||||||
|
job.status = JobStatus::Completed { result };
|
||||||
|
job.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a job as failed.
|
||||||
|
pub async fn fail(jobs: &Arc<RwLock<HashMap<Uuid, Job>>>, id: Uuid, error: String) {
|
||||||
|
let mut map = jobs.write().await;
|
||||||
|
if let Some(job) = map.get_mut(&id) {
|
||||||
|
job.status = JobStatus::Failed { error };
|
||||||
|
job.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
crates/pinakes-core/src/lib.rs
Normal file
21
crates/pinakes-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
pub mod audit;
|
||||||
|
pub mod cache;
|
||||||
|
pub mod collections;
|
||||||
|
pub mod config;
|
||||||
|
pub mod error;
|
||||||
|
pub mod events;
|
||||||
|
pub mod export;
|
||||||
|
pub mod hash;
|
||||||
|
pub mod import;
|
||||||
|
pub mod integrity;
|
||||||
|
pub mod jobs;
|
||||||
|
pub mod media_type;
|
||||||
|
pub mod metadata;
|
||||||
|
pub mod model;
|
||||||
|
pub mod opener;
|
||||||
|
pub mod scan;
|
||||||
|
pub mod scheduler;
|
||||||
|
pub mod search;
|
||||||
|
pub mod storage;
|
||||||
|
pub mod tags;
|
||||||
|
pub mod thumbnail;
|
||||||
209
crates/pinakes-core/src/media_type.rs
Normal file
209
crates/pinakes-core/src/media_type.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum MediaType {
|
||||||
|
// Audio
|
||||||
|
Mp3,
|
||||||
|
Flac,
|
||||||
|
Ogg,
|
||||||
|
Wav,
|
||||||
|
Aac,
|
||||||
|
Opus,
|
||||||
|
|
||||||
|
// Video
|
||||||
|
Mp4,
|
||||||
|
Mkv,
|
||||||
|
Avi,
|
||||||
|
Webm,
|
||||||
|
|
||||||
|
// Documents
|
||||||
|
Pdf,
|
||||||
|
Epub,
|
||||||
|
Djvu,
|
||||||
|
|
||||||
|
// Text
|
||||||
|
Markdown,
|
||||||
|
PlainText,
|
||||||
|
|
||||||
|
// Images
|
||||||
|
Jpeg,
|
||||||
|
Png,
|
||||||
|
Gif,
|
||||||
|
Webp,
|
||||||
|
Svg,
|
||||||
|
Avif,
|
||||||
|
Tiff,
|
||||||
|
Bmp,
|
||||||
|
|
||||||
|
// RAW Images
|
||||||
|
Cr2,
|
||||||
|
Nef,
|
||||||
|
Arw,
|
||||||
|
Dng,
|
||||||
|
Orf,
|
||||||
|
Rw2,
|
||||||
|
|
||||||
|
// HEIC/HEIF
|
||||||
|
Heic,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum MediaCategory {
|
||||||
|
Audio,
|
||||||
|
Video,
|
||||||
|
Document,
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaType {
|
||||||
|
pub fn from_extension(ext: &str) -> Option<Self> {
|
||||||
|
match ext.to_ascii_lowercase().as_str() {
|
||||||
|
"mp3" => Some(Self::Mp3),
|
||||||
|
"flac" => Some(Self::Flac),
|
||||||
|
"ogg" | "oga" => Some(Self::Ogg),
|
||||||
|
"wav" => Some(Self::Wav),
|
||||||
|
"aac" | "m4a" => Some(Self::Aac),
|
||||||
|
"opus" => Some(Self::Opus),
|
||||||
|
"mp4" | "m4v" => Some(Self::Mp4),
|
||||||
|
"mkv" => Some(Self::Mkv),
|
||||||
|
"avi" => Some(Self::Avi),
|
||||||
|
"webm" => Some(Self::Webm),
|
||||||
|
"pdf" => Some(Self::Pdf),
|
||||||
|
"epub" => Some(Self::Epub),
|
||||||
|
"djvu" => Some(Self::Djvu),
|
||||||
|
"md" | "markdown" => Some(Self::Markdown),
|
||||||
|
"txt" | "text" => Some(Self::PlainText),
|
||||||
|
"jpg" | "jpeg" => Some(Self::Jpeg),
|
||||||
|
"png" => Some(Self::Png),
|
||||||
|
"gif" => Some(Self::Gif),
|
||||||
|
"webp" => Some(Self::Webp),
|
||||||
|
"svg" => Some(Self::Svg),
|
||||||
|
"avif" => Some(Self::Avif),
|
||||||
|
"tiff" | "tif" => Some(Self::Tiff),
|
||||||
|
"bmp" => Some(Self::Bmp),
|
||||||
|
"cr2" => Some(Self::Cr2),
|
||||||
|
"nef" => Some(Self::Nef),
|
||||||
|
"arw" => Some(Self::Arw),
|
||||||
|
"dng" => Some(Self::Dng),
|
||||||
|
"orf" => Some(Self::Orf),
|
||||||
|
"rw2" => Some(Self::Rw2),
|
||||||
|
"heic" | "heif" => Some(Self::Heic),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_path(path: &Path) -> Option<Self> {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.and_then(Self::from_extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mime_type(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Mp3 => "audio/mpeg",
|
||||||
|
Self::Flac => "audio/flac",
|
||||||
|
Self::Ogg => "audio/ogg",
|
||||||
|
Self::Wav => "audio/wav",
|
||||||
|
Self::Aac => "audio/aac",
|
||||||
|
Self::Opus => "audio/opus",
|
||||||
|
Self::Mp4 => "video/mp4",
|
||||||
|
Self::Mkv => "video/x-matroska",
|
||||||
|
Self::Avi => "video/x-msvideo",
|
||||||
|
Self::Webm => "video/webm",
|
||||||
|
Self::Pdf => "application/pdf",
|
||||||
|
Self::Epub => "application/epub+zip",
|
||||||
|
Self::Djvu => "image/vnd.djvu",
|
||||||
|
Self::Markdown => "text/markdown",
|
||||||
|
Self::PlainText => "text/plain",
|
||||||
|
Self::Jpeg => "image/jpeg",
|
||||||
|
Self::Png => "image/png",
|
||||||
|
Self::Gif => "image/gif",
|
||||||
|
Self::Webp => "image/webp",
|
||||||
|
Self::Svg => "image/svg+xml",
|
||||||
|
Self::Avif => "image/avif",
|
||||||
|
Self::Tiff => "image/tiff",
|
||||||
|
Self::Bmp => "image/bmp",
|
||||||
|
Self::Cr2 => "image/x-canon-cr2",
|
||||||
|
Self::Nef => "image/x-nikon-nef",
|
||||||
|
Self::Arw => "image/x-sony-arw",
|
||||||
|
Self::Dng => "image/x-adobe-dng",
|
||||||
|
Self::Orf => "image/x-olympus-orf",
|
||||||
|
Self::Rw2 => "image/x-panasonic-rw2",
|
||||||
|
Self::Heic => "image/heic",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn category(&self) -> MediaCategory {
|
||||||
|
match self {
|
||||||
|
Self::Mp3 | Self::Flac | Self::Ogg | Self::Wav | Self::Aac | Self::Opus => {
|
||||||
|
MediaCategory::Audio
|
||||||
|
}
|
||||||
|
Self::Mp4 | Self::Mkv | Self::Avi | Self::Webm => MediaCategory::Video,
|
||||||
|
Self::Pdf | Self::Epub | Self::Djvu => MediaCategory::Document,
|
||||||
|
Self::Markdown | Self::PlainText => MediaCategory::Text,
|
||||||
|
Self::Jpeg
|
||||||
|
| Self::Png
|
||||||
|
| Self::Gif
|
||||||
|
| Self::Webp
|
||||||
|
| Self::Svg
|
||||||
|
| Self::Avif
|
||||||
|
| Self::Tiff
|
||||||
|
| Self::Bmp
|
||||||
|
| Self::Cr2
|
||||||
|
| Self::Nef
|
||||||
|
| Self::Arw
|
||||||
|
| Self::Dng
|
||||||
|
| Self::Orf
|
||||||
|
| Self::Rw2
|
||||||
|
| Self::Heic => MediaCategory::Image,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extensions(&self) -> &'static [&'static str] {
|
||||||
|
match self {
|
||||||
|
Self::Mp3 => &["mp3"],
|
||||||
|
Self::Flac => &["flac"],
|
||||||
|
Self::Ogg => &["ogg", "oga"],
|
||||||
|
Self::Wav => &["wav"],
|
||||||
|
Self::Aac => &["aac", "m4a"],
|
||||||
|
Self::Opus => &["opus"],
|
||||||
|
Self::Mp4 => &["mp4", "m4v"],
|
||||||
|
Self::Mkv => &["mkv"],
|
||||||
|
Self::Avi => &["avi"],
|
||||||
|
Self::Webm => &["webm"],
|
||||||
|
Self::Pdf => &["pdf"],
|
||||||
|
Self::Epub => &["epub"],
|
||||||
|
Self::Djvu => &["djvu"],
|
||||||
|
Self::Markdown => &["md", "markdown"],
|
||||||
|
Self::PlainText => &["txt", "text"],
|
||||||
|
Self::Jpeg => &["jpg", "jpeg"],
|
||||||
|
Self::Png => &["png"],
|
||||||
|
Self::Gif => &["gif"],
|
||||||
|
Self::Webp => &["webp"],
|
||||||
|
Self::Svg => &["svg"],
|
||||||
|
Self::Avif => &["avif"],
|
||||||
|
Self::Tiff => &["tiff", "tif"],
|
||||||
|
Self::Bmp => &["bmp"],
|
||||||
|
Self::Cr2 => &["cr2"],
|
||||||
|
Self::Nef => &["nef"],
|
||||||
|
Self::Arw => &["arw"],
|
||||||
|
Self::Dng => &["dng"],
|
||||||
|
Self::Orf => &["orf"],
|
||||||
|
Self::Rw2 => &["rw2"],
|
||||||
|
Self::Heic => &["heic", "heif"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if this is a RAW image format.
|
||||||
|
pub fn is_raw(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::Cr2 | Self::Nef | Self::Arw | Self::Dng | Self::Orf | Self::Rw2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
81
crates/pinakes-core/src/metadata/audio.rs
Normal file
81
crates/pinakes-core/src/metadata/audio.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use lofty::file::{AudioFile, TaggedFileExt};
|
||||||
|
use lofty::tag::Accessor;
|
||||||
|
|
||||||
|
use crate::error::{PinakesError, Result};
|
||||||
|
use crate::media_type::MediaType;
|
||||||
|
|
||||||
|
use super::{ExtractedMetadata, MetadataExtractor};
|
||||||
|
|
||||||
|
pub struct AudioExtractor;
|
||||||
|
|
||||||
|
impl MetadataExtractor for AudioExtractor {
|
||||||
|
fn extract(&self, path: &Path) -> Result<ExtractedMetadata> {
|
||||||
|
let tagged_file = lofty::read_from_path(path)
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(format!("audio metadata: {e}")))?;
|
||||||
|
|
||||||
|
let mut meta = ExtractedMetadata::default();
|
||||||
|
|
||||||
|
if let Some(tag) = tagged_file
|
||||||
|
.primary_tag()
|
||||||
|
.or_else(|| tagged_file.first_tag())
|
||||||
|
{
|
||||||
|
meta.title = tag.title().map(|s| s.to_string());
|
||||||
|
meta.artist = tag.artist().map(|s| s.to_string());
|
||||||
|
meta.album = tag.album().map(|s| s.to_string());
|
||||||
|
meta.genre = tag.genre().map(|s| s.to_string());
|
||||||
|
meta.year = tag.year().map(|y| y as i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tag) = tagged_file
|
||||||
|
.primary_tag()
|
||||||
|
.or_else(|| tagged_file.first_tag())
|
||||||
|
{
|
||||||
|
if let Some(track) = tag.track() {
|
||||||
|
meta.extra
|
||||||
|
.insert("track_number".to_string(), track.to_string());
|
||||||
|
}
|
||||||
|
if let Some(disc) = tag.disk() {
|
||||||
|
meta.extra
|
||||||
|
.insert("disc_number".to_string(), disc.to_string());
|
||||||
|
}
|
||||||
|
if let Some(comment) = tag.comment() {
|
||||||
|
meta.extra
|
||||||
|
.insert("comment".to_string(), comment.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let properties = tagged_file.properties();
|
||||||
|
let duration = properties.duration();
|
||||||
|
if !duration.is_zero() {
|
||||||
|
meta.duration_secs = Some(duration.as_secs_f64());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(bitrate) = properties.audio_bitrate() {
|
||||||
|
meta.extra
|
||||||
|
.insert("bitrate".to_string(), format!("{bitrate} kbps"));
|
||||||
|
}
|
||||||
|
if let Some(sample_rate) = properties.sample_rate() {
|
||||||
|
meta.extra
|
||||||
|
.insert("sample_rate".to_string(), format!("{sample_rate} Hz"));
|
||||||
|
}
|
||||||
|
if let Some(channels) = properties.channels() {
|
||||||
|
meta.extra
|
||||||
|
.insert("channels".to_string(), channels.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_types(&self) -> &[MediaType] {
|
||||||
|
&[
|
||||||
|
MediaType::Mp3,
|
||||||
|
MediaType::Flac,
|
||||||
|
MediaType::Ogg,
|
||||||
|
MediaType::Wav,
|
||||||
|
MediaType::Aac,
|
||||||
|
MediaType::Opus,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
192
crates/pinakes-core/src/metadata/document.rs
Normal file
192
crates/pinakes-core/src/metadata/document.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::error::{PinakesError, Result};
|
||||||
|
use crate::media_type::MediaType;
|
||||||
|
|
||||||
|
use super::{ExtractedMetadata, MetadataExtractor};
|
||||||
|
|
||||||
|
pub struct DocumentExtractor;
|
||||||
|
|
||||||
|
impl MetadataExtractor for DocumentExtractor {
|
||||||
|
fn extract(&self, path: &Path) -> Result<ExtractedMetadata> {
|
||||||
|
match MediaType::from_path(path) {
|
||||||
|
Some(MediaType::Pdf) => extract_pdf(path),
|
||||||
|
Some(MediaType::Epub) => extract_epub(path),
|
||||||
|
Some(MediaType::Djvu) => extract_djvu(path),
|
||||||
|
_ => Ok(ExtractedMetadata::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_types(&self) -> &[MediaType] {
|
||||||
|
&[MediaType::Pdf, MediaType::Epub, MediaType::Djvu]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_pdf(path: &Path) -> Result<ExtractedMetadata> {
|
||||||
|
let doc = lopdf::Document::load(path)
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(format!("PDF load: {e}")))?;
|
||||||
|
|
||||||
|
let mut meta = ExtractedMetadata::default();
|
||||||
|
|
||||||
|
// Find the Info dictionary via the trailer
|
||||||
|
if let Ok(info_ref) = doc.trailer.get(b"Info") {
|
||||||
|
let info_obj = if let Ok(reference) = info_ref.as_reference() {
|
||||||
|
doc.get_object(reference).ok()
|
||||||
|
} else {
|
||||||
|
Some(info_ref)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(obj) = info_obj
|
||||||
|
&& let Ok(dict) = obj.as_dict()
|
||||||
|
{
|
||||||
|
if let Ok(title) = dict.get(b"Title") {
|
||||||
|
meta.title = pdf_object_to_string(title);
|
||||||
|
}
|
||||||
|
if let Ok(author) = dict.get(b"Author") {
|
||||||
|
meta.artist = pdf_object_to_string(author);
|
||||||
|
}
|
||||||
|
if let Ok(subject) = dict.get(b"Subject") {
|
||||||
|
meta.description = pdf_object_to_string(subject);
|
||||||
|
}
|
||||||
|
if let Ok(creator) = dict.get(b"Creator") {
|
||||||
|
meta.extra.insert(
|
||||||
|
"creator".to_string(),
|
||||||
|
pdf_object_to_string(creator).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Ok(producer) = dict.get(b"Producer") {
|
||||||
|
meta.extra.insert(
|
||||||
|
"producer".to_string(),
|
||||||
|
pdf_object_to_string(producer).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page count
|
||||||
|
let page_count = doc.get_pages().len();
|
||||||
|
if page_count > 0 {
|
||||||
|
meta.extra
|
||||||
|
.insert("page_count".to_string(), page_count.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pdf_object_to_string(obj: &lopdf::Object) -> Option<String> {
|
||||||
|
match obj {
|
||||||
|
lopdf::Object::String(bytes, _) => Some(String::from_utf8_lossy(bytes).into_owned()),
|
||||||
|
lopdf::Object::Name(name) => Some(String::from_utf8_lossy(name).into_owned()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_epub(path: &Path) -> Result<ExtractedMetadata> {
|
||||||
|
let doc = epub::doc::EpubDoc::new(path)
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(format!("EPUB parse: {e}")))?;
|
||||||
|
|
||||||
|
let mut meta = ExtractedMetadata {
|
||||||
|
title: doc.mdata("title").map(|item| item.value.clone()),
|
||||||
|
artist: doc.mdata("creator").map(|item| item.value.clone()),
|
||||||
|
description: doc.mdata("description").map(|item| item.value.clone()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(lang) = doc.mdata("language") {
|
||||||
|
meta.extra
|
||||||
|
.insert("language".to_string(), lang.value.clone());
|
||||||
|
}
|
||||||
|
if let Some(publisher) = doc.mdata("publisher") {
|
||||||
|
meta.extra
|
||||||
|
.insert("publisher".to_string(), publisher.value.clone());
|
||||||
|
}
|
||||||
|
if let Some(date) = doc.mdata("date") {
|
||||||
|
meta.extra.insert("date".to_string(), date.value.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_djvu(path: &Path) -> Result<ExtractedMetadata> {
|
||||||
|
// DjVu files contain metadata in SEXPR (S-expression) format within
|
||||||
|
// ANTa/ANTz chunks, or in the DIRM chunk. We parse the raw bytes to
|
||||||
|
// extract any metadata fields we can find.
|
||||||
|
let data = std::fs::read(path)
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(format!("DjVu read: {e}")))?;
|
||||||
|
|
||||||
|
let mut meta = ExtractedMetadata::default();
|
||||||
|
|
||||||
|
// DjVu files start with "AT&T" magic followed by FORM:DJVU or FORM:DJVM
|
||||||
|
if data.len() < 16 {
|
||||||
|
return Ok(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for metadata annotations in the file. DjVu metadata is stored
|
||||||
|
// as S-expressions like (metadata (key "value") ...) within ANTa chunks.
|
||||||
|
let content = String::from_utf8_lossy(&data);
|
||||||
|
|
||||||
|
// Look for (metadata ...) blocks
|
||||||
|
if let Some(meta_start) = content.find("(metadata") {
|
||||||
|
let remainder = &content[meta_start..];
|
||||||
|
// Extract key-value pairs like (title "Some Title")
|
||||||
|
extract_djvu_field(remainder, "title", &mut meta.title);
|
||||||
|
extract_djvu_field(remainder, "author", &mut meta.artist);
|
||||||
|
|
||||||
|
let mut desc = None;
|
||||||
|
extract_djvu_field(remainder, "subject", &mut desc);
|
||||||
|
if desc.is_none() {
|
||||||
|
extract_djvu_field(remainder, "description", &mut desc);
|
||||||
|
}
|
||||||
|
meta.description = desc;
|
||||||
|
|
||||||
|
let mut year_str = None;
|
||||||
|
extract_djvu_field(remainder, "year", &mut year_str);
|
||||||
|
if let Some(ref y) = year_str {
|
||||||
|
meta.year = y.parse().ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut creator = None;
|
||||||
|
extract_djvu_field(remainder, "creator", &mut creator);
|
||||||
|
if let Some(c) = creator {
|
||||||
|
meta.extra.insert("creator".to_string(), c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check for booklet-style metadata that some DjVu encoders write
|
||||||
|
// outside the metadata SEXPR
|
||||||
|
if meta.title.is_none()
|
||||||
|
&& let Some(title_start) = content.find("(bookmarks")
|
||||||
|
{
|
||||||
|
let remainder = &content[title_start..];
|
||||||
|
// First bookmark title is often the document title
|
||||||
|
if let Some(q1) = remainder.find('"') {
|
||||||
|
let after_q1 = &remainder[q1 + 1..];
|
||||||
|
if let Some(q2) = after_q1.find('"') {
|
||||||
|
let val = &after_q1[..q2];
|
||||||
|
if !val.is_empty() {
|
||||||
|
meta.title = Some(val.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_djvu_field(sexpr: &str, key: &str, out: &mut Option<String>) {
|
||||||
|
// Look for patterns like (key "value") in the S-expression
|
||||||
|
let pattern = format!("({key}");
|
||||||
|
if let Some(start) = sexpr.find(&pattern) {
|
||||||
|
let remainder = &sexpr[start + pattern.len()..];
|
||||||
|
// Find the quoted value
|
||||||
|
if let Some(q1) = remainder.find('"') {
|
||||||
|
let after_q1 = &remainder[q1 + 1..];
|
||||||
|
if let Some(q2) = after_q1.find('"') {
|
||||||
|
let val = &after_q1[..q2];
|
||||||
|
if !val.is_empty() {
|
||||||
|
*out = Some(val.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
213
crates/pinakes-core/src/metadata/image.rs
Normal file
213
crates/pinakes-core/src/metadata/image.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::media_type::MediaType;
|
||||||
|
|
||||||
|
use super::{ExtractedMetadata, MetadataExtractor};
|
||||||
|
|
||||||
|
pub struct ImageExtractor;
|
||||||
|
|
||||||
|
impl MetadataExtractor for ImageExtractor {
|
||||||
|
fn extract(&self, path: &Path) -> Result<ExtractedMetadata> {
|
||||||
|
let mut meta = ExtractedMetadata::default();
|
||||||
|
|
||||||
|
let file = std::fs::File::open(path)?;
|
||||||
|
let mut buf_reader = std::io::BufReader::new(&file);
|
||||||
|
|
||||||
|
let exif_data = match exif::Reader::new().read_from_container(&mut buf_reader) {
|
||||||
|
Ok(exif) => exif,
|
||||||
|
Err(_) => return Ok(meta),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Image dimensions
|
||||||
|
if let Some(width) = exif_data
|
||||||
|
.get_field(exif::Tag::PixelXDimension, exif::In::PRIMARY)
|
||||||
|
.or_else(|| exif_data.get_field(exif::Tag::ImageWidth, exif::In::PRIMARY))
|
||||||
|
&& let Some(w) = field_to_u32(width)
|
||||||
|
{
|
||||||
|
meta.extra.insert("width".to_string(), w.to_string());
|
||||||
|
}
|
||||||
|
if let Some(height) = exif_data
|
||||||
|
.get_field(exif::Tag::PixelYDimension, exif::In::PRIMARY)
|
||||||
|
.or_else(|| exif_data.get_field(exif::Tag::ImageLength, exif::In::PRIMARY))
|
||||||
|
&& let Some(h) = field_to_u32(height)
|
||||||
|
{
|
||||||
|
meta.extra.insert("height".to_string(), h.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera make and model
|
||||||
|
if let Some(make) = exif_data.get_field(exif::Tag::Make, exif::In::PRIMARY) {
|
||||||
|
let val = make.display_value().to_string();
|
||||||
|
if !val.is_empty() {
|
||||||
|
meta.extra.insert("camera_make".to_string(), val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(model) = exif_data.get_field(exif::Tag::Model, exif::In::PRIMARY) {
|
||||||
|
let val = model.display_value().to_string();
|
||||||
|
if !val.is_empty() {
|
||||||
|
meta.extra.insert("camera_model".to_string(), val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date taken
|
||||||
|
if let Some(date) = exif_data
|
||||||
|
.get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY)
|
||||||
|
.or_else(|| exif_data.get_field(exif::Tag::DateTime, exif::In::PRIMARY))
|
||||||
|
{
|
||||||
|
let val = date.display_value().to_string();
|
||||||
|
if !val.is_empty() {
|
||||||
|
meta.extra.insert("date_taken".to_string(), val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPS coordinates
|
||||||
|
if let (Some(lat), Some(lat_ref), Some(lon), Some(lon_ref)) = (
|
||||||
|
exif_data.get_field(exif::Tag::GPSLatitude, exif::In::PRIMARY),
|
||||||
|
exif_data.get_field(exif::Tag::GPSLatitudeRef, exif::In::PRIMARY),
|
||||||
|
exif_data.get_field(exif::Tag::GPSLongitude, exif::In::PRIMARY),
|
||||||
|
exif_data.get_field(exif::Tag::GPSLongitudeRef, exif::In::PRIMARY),
|
||||||
|
) && let (Some(lat_val), Some(lon_val)) =
|
||||||
|
(dms_to_decimal(lat, lat_ref), dms_to_decimal(lon, lon_ref))
|
||||||
|
{
|
||||||
|
meta.extra
|
||||||
|
.insert("gps_latitude".to_string(), format!("{lat_val:.6}"));
|
||||||
|
meta.extra
|
||||||
|
.insert("gps_longitude".to_string(), format!("{lon_val:.6}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exposure info
|
||||||
|
if let Some(iso) =
|
||||||
|
exif_data.get_field(exif::Tag::PhotographicSensitivity, exif::In::PRIMARY)
|
||||||
|
{
|
||||||
|
let val = iso.display_value().to_string();
|
||||||
|
if !val.is_empty() {
|
||||||
|
meta.extra.insert("iso".to_string(), val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(exposure) = exif_data.get_field(exif::Tag::ExposureTime, exif::In::PRIMARY) {
|
||||||
|
let val = exposure.display_value().to_string();
|
||||||
|
if !val.is_empty() {
|
||||||
|
meta.extra.insert("exposure_time".to_string(), val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(aperture) = exif_data.get_field(exif::Tag::FNumber, exif::In::PRIMARY) {
|
||||||
|
let val = aperture.display_value().to_string();
|
||||||
|
if !val.is_empty() {
|
||||||
|
meta.extra.insert("f_number".to_string(), val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(focal) = exif_data.get_field(exif::Tag::FocalLength, exif::In::PRIMARY) {
|
||||||
|
let val = focal.display_value().to_string();
|
||||||
|
if !val.is_empty() {
|
||||||
|
meta.extra.insert("focal_length".to_string(), val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lens model
|
||||||
|
if let Some(lens) = exif_data.get_field(exif::Tag::LensModel, exif::In::PRIMARY) {
|
||||||
|
let val = lens.display_value().to_string();
|
||||||
|
if !val.is_empty() && val != "\"\"" {
|
||||||
|
meta.extra
|
||||||
|
.insert("lens_model".to_string(), val.trim_matches('"').to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flash
|
||||||
|
if let Some(flash) = exif_data.get_field(exif::Tag::Flash, exif::In::PRIMARY) {
|
||||||
|
let val = flash.display_value().to_string();
|
||||||
|
if !val.is_empty() {
|
||||||
|
meta.extra.insert("flash".to_string(), val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orientation
|
||||||
|
if let Some(orientation) = exif_data.get_field(exif::Tag::Orientation, exif::In::PRIMARY) {
|
||||||
|
let val = orientation.display_value().to_string();
|
||||||
|
if !val.is_empty() {
|
||||||
|
meta.extra.insert("orientation".to_string(), val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Software
|
||||||
|
if let Some(software) = exif_data.get_field(exif::Tag::Software, exif::In::PRIMARY) {
|
||||||
|
let val = software.display_value().to_string();
|
||||||
|
if !val.is_empty() {
|
||||||
|
meta.extra.insert("software".to_string(), val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image description as title
|
||||||
|
if let Some(desc) = exif_data.get_field(exif::Tag::ImageDescription, exif::In::PRIMARY) {
|
||||||
|
let val = desc.display_value().to_string();
|
||||||
|
if !val.is_empty() && val != "\"\"" {
|
||||||
|
meta.title = Some(val.trim_matches('"').to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artist
|
||||||
|
if let Some(artist) = exif_data.get_field(exif::Tag::Artist, exif::In::PRIMARY) {
|
||||||
|
let val = artist.display_value().to_string();
|
||||||
|
if !val.is_empty() && val != "\"\"" {
|
||||||
|
meta.artist = Some(val.trim_matches('"').to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copyright as description
|
||||||
|
if let Some(copyright) = exif_data.get_field(exif::Tag::Copyright, exif::In::PRIMARY) {
|
||||||
|
let val = copyright.display_value().to_string();
|
||||||
|
if !val.is_empty() && val != "\"\"" {
|
||||||
|
meta.description = Some(val.trim_matches('"').to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_types(&self) -> &[MediaType] {
|
||||||
|
&[
|
||||||
|
MediaType::Jpeg,
|
||||||
|
MediaType::Png,
|
||||||
|
MediaType::Gif,
|
||||||
|
MediaType::Webp,
|
||||||
|
MediaType::Avif,
|
||||||
|
MediaType::Tiff,
|
||||||
|
MediaType::Bmp,
|
||||||
|
// RAW formats (TIFF-based, kamadak-exif handles these)
|
||||||
|
MediaType::Cr2,
|
||||||
|
MediaType::Nef,
|
||||||
|
MediaType::Arw,
|
||||||
|
MediaType::Dng,
|
||||||
|
MediaType::Orf,
|
||||||
|
MediaType::Rw2,
|
||||||
|
// HEIC
|
||||||
|
MediaType::Heic,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_to_u32(field: &exif::Field) -> Option<u32> {
|
||||||
|
match &field.value {
|
||||||
|
exif::Value::Long(v) => v.first().copied(),
|
||||||
|
exif::Value::Short(v) => v.first().map(|&x| x as u32),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dms_to_decimal(dms_field: &exif::Field, ref_field: &exif::Field) -> Option<f64> {
|
||||||
|
if let exif::Value::Rational(ref rationals) = dms_field.value
|
||||||
|
&& rationals.len() >= 3
|
||||||
|
{
|
||||||
|
let degrees = rationals[0].to_f64();
|
||||||
|
let minutes = rationals[1].to_f64();
|
||||||
|
let seconds = rationals[2].to_f64();
|
||||||
|
let mut decimal = degrees + minutes / 60.0 + seconds / 3600.0;
|
||||||
|
|
||||||
|
let ref_str = ref_field.display_value().to_string();
|
||||||
|
if ref_str.contains('S') || ref_str.contains('W') {
|
||||||
|
decimal = -decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Some(decimal);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
40
crates/pinakes-core/src/metadata/markdown.rs
Normal file
40
crates/pinakes-core/src/metadata/markdown.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::media_type::MediaType;
|
||||||
|
|
||||||
|
use super::{ExtractedMetadata, MetadataExtractor};
|
||||||
|
|
||||||
|
pub struct MarkdownExtractor;
|
||||||
|
|
||||||
|
impl MetadataExtractor for MarkdownExtractor {
|
||||||
|
fn extract(&self, path: &Path) -> Result<ExtractedMetadata> {
|
||||||
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
let parsed = gray_matter::Matter::<gray_matter::engine::YAML>::new().parse(&content);
|
||||||
|
|
||||||
|
let mut meta = ExtractedMetadata::default();
|
||||||
|
|
||||||
|
if let Some(data) = parsed.ok().and_then(|p| p.data)
|
||||||
|
&& let gray_matter::Pod::Hash(map) = data
|
||||||
|
{
|
||||||
|
if let Some(gray_matter::Pod::String(title)) = map.get("title") {
|
||||||
|
meta.title = Some(title.clone());
|
||||||
|
}
|
||||||
|
if let Some(gray_matter::Pod::String(author)) = map.get("author") {
|
||||||
|
meta.artist = Some(author.clone());
|
||||||
|
}
|
||||||
|
if let Some(gray_matter::Pod::String(desc)) = map.get("description") {
|
||||||
|
meta.description = Some(desc.clone());
|
||||||
|
}
|
||||||
|
if let Some(gray_matter::Pod::String(date)) = map.get("date") {
|
||||||
|
meta.extra.insert("date".to_string(), date.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_types(&self) -> &[MediaType] {
|
||||||
|
&[MediaType::Markdown, MediaType::PlainText]
|
||||||
|
}
|
||||||
|
}
|
||||||
46
crates/pinakes-core/src/metadata/mod.rs
Normal file
46
crates/pinakes-core/src/metadata/mod.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
pub mod audio;
|
||||||
|
pub mod document;
|
||||||
|
pub mod image;
|
||||||
|
pub mod markdown;
|
||||||
|
pub mod video;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::media_type::MediaType;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ExtractedMetadata {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub genre: Option<String>,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub duration_secs: Option<f64>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub extra: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait MetadataExtractor: Send + Sync {
|
||||||
|
fn extract(&self, path: &Path) -> Result<ExtractedMetadata>;
|
||||||
|
fn supported_types(&self) -> &[MediaType];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_metadata(path: &Path, media_type: MediaType) -> Result<ExtractedMetadata> {
|
||||||
|
let extractors: Vec<Box<dyn MetadataExtractor>> = vec![
|
||||||
|
Box::new(audio::AudioExtractor),
|
||||||
|
Box::new(document::DocumentExtractor),
|
||||||
|
Box::new(video::VideoExtractor),
|
||||||
|
Box::new(markdown::MarkdownExtractor),
|
||||||
|
Box::new(image::ImageExtractor),
|
||||||
|
];
|
||||||
|
|
||||||
|
for extractor in &extractors {
|
||||||
|
if extractor.supported_types().contains(&media_type) {
|
||||||
|
return extractor.extract(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ExtractedMetadata::default())
|
||||||
|
}
|
||||||
120
crates/pinakes-core/src/metadata/video.rs
Normal file
120
crates/pinakes-core/src/metadata/video.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::error::{PinakesError, Result};
|
||||||
|
use crate::media_type::MediaType;
|
||||||
|
|
||||||
|
use super::{ExtractedMetadata, MetadataExtractor};
|
||||||
|
|
||||||
|
pub struct VideoExtractor;
|
||||||
|
|
||||||
|
impl MetadataExtractor for VideoExtractor {
|
||||||
|
fn extract(&self, path: &Path) -> Result<ExtractedMetadata> {
|
||||||
|
match MediaType::from_path(path) {
|
||||||
|
Some(MediaType::Mkv) => extract_mkv(path),
|
||||||
|
Some(MediaType::Mp4) => extract_mp4(path),
|
||||||
|
_ => Ok(ExtractedMetadata::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_types(&self) -> &[MediaType] {
|
||||||
|
&[
|
||||||
|
MediaType::Mp4,
|
||||||
|
MediaType::Mkv,
|
||||||
|
MediaType::Avi,
|
||||||
|
MediaType::Webm,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_mkv(path: &Path) -> Result<ExtractedMetadata> {
|
||||||
|
let file = std::fs::File::open(path)?;
|
||||||
|
let mkv = matroska::Matroska::open(file)
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(format!("MKV parse: {e}")))?;
|
||||||
|
|
||||||
|
let mut meta = ExtractedMetadata {
|
||||||
|
title: mkv.info.title.clone(),
|
||||||
|
duration_secs: mkv.info.duration.map(|dur| dur.as_secs_f64()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract resolution and codec info from tracks
|
||||||
|
for track in &mkv.tracks {
|
||||||
|
match &track.settings {
|
||||||
|
matroska::Settings::Video(v) => {
|
||||||
|
meta.extra.insert(
|
||||||
|
"resolution".to_string(),
|
||||||
|
format!("{}x{}", v.pixel_width, v.pixel_height),
|
||||||
|
);
|
||||||
|
if !track.codec_id.is_empty() {
|
||||||
|
meta.extra
|
||||||
|
.insert("video_codec".to_string(), track.codec_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matroska::Settings::Audio(a) => {
|
||||||
|
meta.extra.insert(
|
||||||
|
"sample_rate".to_string(),
|
||||||
|
format!("{} Hz", a.sample_rate as u32),
|
||||||
|
);
|
||||||
|
meta.extra
|
||||||
|
.insert("channels".to_string(), a.channels.to_string());
|
||||||
|
if !track.codec_id.is_empty() {
|
||||||
|
meta.extra
|
||||||
|
.insert("audio_codec".to_string(), track.codec_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_mp4(path: &Path) -> Result<ExtractedMetadata> {
|
||||||
|
use lofty::file::{AudioFile, TaggedFileExt};
|
||||||
|
use lofty::tag::Accessor;
|
||||||
|
|
||||||
|
let tagged_file = lofty::read_from_path(path)
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(format!("MP4 metadata: {e}")))?;
|
||||||
|
|
||||||
|
let mut meta = ExtractedMetadata::default();
|
||||||
|
|
||||||
|
if let Some(tag) = tagged_file
|
||||||
|
.primary_tag()
|
||||||
|
.or_else(|| tagged_file.first_tag())
|
||||||
|
{
|
||||||
|
meta.title = tag
|
||||||
|
.title()
|
||||||
|
.map(|s: std::borrow::Cow<'_, str>| s.to_string());
|
||||||
|
meta.artist = tag
|
||||||
|
.artist()
|
||||||
|
.map(|s: std::borrow::Cow<'_, str>| s.to_string());
|
||||||
|
meta.album = tag
|
||||||
|
.album()
|
||||||
|
.map(|s: std::borrow::Cow<'_, str>| s.to_string());
|
||||||
|
meta.genre = tag
|
||||||
|
.genre()
|
||||||
|
.map(|s: std::borrow::Cow<'_, str>| s.to_string());
|
||||||
|
meta.year = tag.year().map(|y| y as i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
let properties = tagged_file.properties();
|
||||||
|
let duration = properties.duration();
|
||||||
|
if !duration.is_zero() {
|
||||||
|
meta.duration_secs = Some(duration.as_secs_f64());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(bitrate) = properties.audio_bitrate() {
|
||||||
|
meta.extra
|
||||||
|
.insert("audio_bitrate".to_string(), format!("{bitrate} kbps"));
|
||||||
|
}
|
||||||
|
if let Some(sample_rate) = properties.sample_rate() {
|
||||||
|
meta.extra
|
||||||
|
.insert("sample_rate".to_string(), format!("{sample_rate} Hz"));
|
||||||
|
}
|
||||||
|
if let Some(channels) = properties.channels() {
|
||||||
|
meta.extra
|
||||||
|
.insert("channels".to_string(), channels.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
191
crates/pinakes-core/src/model.rs
Normal file
191
crates/pinakes-core/src/model.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::media_type::MediaType;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct MediaId(pub Uuid);
|
||||||
|
|
||||||
|
impl MediaId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(Uuid::now_v7())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MediaId {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MediaId {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct ContentHash(pub String);
|
||||||
|
|
||||||
|
impl ContentHash {
|
||||||
|
pub fn new(hex: String) -> Self {
|
||||||
|
Self(hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ContentHash {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MediaItem {
|
||||||
|
pub id: MediaId,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub file_name: String,
|
||||||
|
pub media_type: MediaType,
|
||||||
|
pub content_hash: ContentHash,
|
||||||
|
pub file_size: u64,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub genre: Option<String>,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub duration_secs: Option<f64>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub thumbnail_path: Option<PathBuf>,
|
||||||
|
pub custom_fields: HashMap<String, CustomField>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CustomField {
|
||||||
|
pub field_type: CustomFieldType,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum CustomFieldType {
|
||||||
|
Text,
|
||||||
|
Number,
|
||||||
|
Date,
|
||||||
|
Boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Tag {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Collection {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub kind: CollectionKind,
|
||||||
|
pub filter_query: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum CollectionKind {
|
||||||
|
Manual,
|
||||||
|
Virtual,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CollectionMember {
|
||||||
|
pub collection_id: Uuid,
|
||||||
|
pub media_id: MediaId,
|
||||||
|
pub position: i32,
|
||||||
|
pub added_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AuditEntry {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub media_id: Option<MediaId>,
|
||||||
|
pub action: AuditAction,
|
||||||
|
pub details: Option<String>,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AuditAction {
|
||||||
|
Imported,
|
||||||
|
Updated,
|
||||||
|
Deleted,
|
||||||
|
Tagged,
|
||||||
|
Untagged,
|
||||||
|
AddedToCollection,
|
||||||
|
RemovedFromCollection,
|
||||||
|
Opened,
|
||||||
|
Scanned,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for AuditAction {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let s = match self {
|
||||||
|
Self::Imported => "imported",
|
||||||
|
Self::Updated => "updated",
|
||||||
|
Self::Deleted => "deleted",
|
||||||
|
Self::Tagged => "tagged",
|
||||||
|
Self::Untagged => "untagged",
|
||||||
|
Self::AddedToCollection => "added_to_collection",
|
||||||
|
Self::RemovedFromCollection => "removed_from_collection",
|
||||||
|
Self::Opened => "opened",
|
||||||
|
Self::Scanned => "scanned",
|
||||||
|
};
|
||||||
|
write!(f, "{s}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Pagination {
|
||||||
|
pub offset: u64,
|
||||||
|
pub limit: u64,
|
||||||
|
pub sort: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pagination {
|
||||||
|
pub fn new(offset: u64, limit: u64, sort: Option<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
sort,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Pagination {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
offset: 0,
|
||||||
|
limit: 50,
|
||||||
|
sort: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SavedSearch {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub query: String,
|
||||||
|
pub sort_order: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
79
crates/pinakes-core/src/opener.rs
Normal file
79
crates/pinakes-core/src/opener.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use crate::error::{PinakesError, Result};
|
||||||
|
|
||||||
|
pub trait Opener: Send + Sync {
|
||||||
|
fn open(&self, path: &Path) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linux opener using xdg-open
|
||||||
|
pub struct XdgOpener;
|
||||||
|
|
||||||
|
impl Opener for XdgOpener {
|
||||||
|
fn open(&self, path: &Path) -> Result<()> {
|
||||||
|
let status = Command::new("xdg-open")
|
||||||
|
.arg(path)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| PinakesError::InvalidOperation(format!("failed to run xdg-open: {e}")))?;
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(PinakesError::InvalidOperation(format!(
|
||||||
|
"xdg-open exited with status {status}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// macOS opener using the `open` command
|
||||||
|
pub struct MacOpener;
|
||||||
|
|
||||||
|
impl Opener for MacOpener {
|
||||||
|
fn open(&self, path: &Path) -> Result<()> {
|
||||||
|
let status = Command::new("open")
|
||||||
|
.arg(path)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| PinakesError::InvalidOperation(format!("failed to run open: {e}")))?;
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(PinakesError::InvalidOperation(format!(
|
||||||
|
"open exited with status {status}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows opener using `cmd /c start`
|
||||||
|
pub struct WindowsOpener;
|
||||||
|
|
||||||
|
impl Opener for WindowsOpener {
|
||||||
|
fn open(&self, path: &Path) -> Result<()> {
|
||||||
|
let status = Command::new("cmd")
|
||||||
|
.args(["/C", "start", ""])
|
||||||
|
.arg(path)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| {
|
||||||
|
PinakesError::InvalidOperation(format!("failed to run cmd /c start: {e}"))
|
||||||
|
})?;
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(PinakesError::InvalidOperation(format!(
|
||||||
|
"cmd /c start exited with status {status}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the platform-appropriate opener.
|
||||||
|
pub fn default_opener() -> Box<dyn Opener> {
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
Box::new(MacOpener)
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
Box::new(WindowsOpener)
|
||||||
|
} else {
|
||||||
|
Box::new(XdgOpener)
|
||||||
|
}
|
||||||
|
}
|
||||||
283
crates/pinakes-core/src/scan.rs
Normal file
283
crates/pinakes-core/src/scan.rs
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use notify::{PollWatcher, RecursiveMode, Watcher};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::import;
|
||||||
|
use crate::storage::DynStorageBackend;
|
||||||
|
|
||||||
|
pub struct ScanStatus {
|
||||||
|
pub scanning: bool,
|
||||||
|
pub files_found: usize,
|
||||||
|
pub files_processed: usize,
|
||||||
|
pub errors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared scan progress that can be read by the status endpoint while a scan runs.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ScanProgress {
|
||||||
|
pub is_scanning: Arc<AtomicBool>,
|
||||||
|
pub files_found: Arc<AtomicUsize>,
|
||||||
|
pub files_processed: Arc<AtomicUsize>,
|
||||||
|
pub error_count: Arc<AtomicUsize>,
|
||||||
|
pub error_messages: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_STORED_ERRORS: usize = 100;
|
||||||
|
|
||||||
|
impl ScanProgress {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
is_scanning: Arc::new(AtomicBool::new(false)),
|
||||||
|
files_found: Arc::new(AtomicUsize::new(0)),
|
||||||
|
files_processed: Arc::new(AtomicUsize::new(0)),
|
||||||
|
error_count: Arc::new(AtomicUsize::new(0)),
|
||||||
|
error_messages: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> ScanStatus {
|
||||||
|
let errors = self
|
||||||
|
.error_messages
|
||||||
|
.lock()
|
||||||
|
.map(|v| v.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
ScanStatus {
|
||||||
|
scanning: self.is_scanning.load(Ordering::Acquire),
|
||||||
|
files_found: self.files_found.load(Ordering::Acquire),
|
||||||
|
files_processed: self.files_processed.load(Ordering::Acquire),
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin(&self) {
|
||||||
|
self.is_scanning.store(true, Ordering::Release);
|
||||||
|
self.files_found.store(0, Ordering::Release);
|
||||||
|
self.files_processed.store(0, Ordering::Release);
|
||||||
|
self.error_count.store(0, Ordering::Release);
|
||||||
|
if let Ok(mut msgs) = self.error_messages.lock() {
|
||||||
|
msgs.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_error(&self, message: String) {
|
||||||
|
self.error_count.fetch_add(1, Ordering::Release);
|
||||||
|
if let Ok(mut msgs) = self.error_messages.lock()
|
||||||
|
&& msgs.len() < MAX_STORED_ERRORS
|
||||||
|
{
|
||||||
|
msgs.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&self) {
|
||||||
|
self.is_scanning.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ScanProgress {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scan_directory(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
dir: &Path,
|
||||||
|
ignore_patterns: &[String],
|
||||||
|
) -> Result<ScanStatus> {
|
||||||
|
scan_directory_with_progress(storage, dir, ignore_patterns, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scan_directory_with_progress(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
dir: &Path,
|
||||||
|
ignore_patterns: &[String],
|
||||||
|
progress: Option<&ScanProgress>,
|
||||||
|
) -> Result<ScanStatus> {
|
||||||
|
info!(dir = %dir.display(), "starting directory scan");
|
||||||
|
|
||||||
|
if let Some(p) = progress {
|
||||||
|
p.begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = import::import_directory(storage, dir, ignore_patterns).await?;
|
||||||
|
// Note: for configurable concurrency, use import_directory_with_concurrency directly
|
||||||
|
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
let mut processed = 0;
|
||||||
|
for result in &results {
|
||||||
|
match result {
|
||||||
|
Ok(_) => processed += 1,
|
||||||
|
Err(e) => {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if let Some(p) = progress {
|
||||||
|
p.record_error(msg.clone());
|
||||||
|
}
|
||||||
|
errors.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(p) = progress {
|
||||||
|
p.files_found.store(results.len(), Ordering::Release);
|
||||||
|
p.files_processed.store(processed, Ordering::Release);
|
||||||
|
p.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = ScanStatus {
|
||||||
|
scanning: false,
|
||||||
|
files_found: results.len(),
|
||||||
|
files_processed: processed,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scan_all_roots(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
ignore_patterns: &[String],
|
||||||
|
) -> Result<Vec<ScanStatus>> {
|
||||||
|
scan_all_roots_with_progress(storage, ignore_patterns, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scan_all_roots_with_progress(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
ignore_patterns: &[String],
|
||||||
|
progress: Option<&ScanProgress>,
|
||||||
|
) -> Result<Vec<ScanStatus>> {
|
||||||
|
let roots = storage.list_root_dirs().await?;
|
||||||
|
let mut statuses = Vec::new();
|
||||||
|
|
||||||
|
for root in roots {
|
||||||
|
match scan_directory_with_progress(storage, &root, ignore_patterns, progress).await {
|
||||||
|
Ok(status) => statuses.push(status),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(root = %root.display(), error = %e, "failed to scan root directory");
|
||||||
|
statuses.push(ScanStatus {
|
||||||
|
scanning: false,
|
||||||
|
files_found: 0,
|
||||||
|
files_processed: 0,
|
||||||
|
errors: vec![e.to_string()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(statuses)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FileWatcher {
|
||||||
|
_watcher: Box<dyn Watcher + Send>,
|
||||||
|
rx: mpsc::Receiver<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileWatcher {
|
||||||
|
pub fn new(dirs: &[PathBuf]) -> Result<Self> {
|
||||||
|
let (tx, rx) = mpsc::channel(1024);
|
||||||
|
|
||||||
|
// Try the recommended (native) watcher first, fall back to polling
|
||||||
|
let watcher: Box<dyn Watcher + Send> = match Self::try_native_watcher(dirs, tx.clone()) {
|
||||||
|
Ok(w) => {
|
||||||
|
info!("using native filesystem watcher");
|
||||||
|
w
|
||||||
|
}
|
||||||
|
Err(native_err) => {
|
||||||
|
warn!(error = %native_err, "native watcher failed, falling back to polling");
|
||||||
|
Self::polling_watcher(dirs, tx)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
_watcher: watcher,
|
||||||
|
rx,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_native_watcher(
|
||||||
|
dirs: &[PathBuf],
|
||||||
|
tx: mpsc::Sender<PathBuf>,
|
||||||
|
) -> std::result::Result<Box<dyn Watcher + Send>, notify::Error> {
|
||||||
|
let tx_clone = tx.clone();
|
||||||
|
let mut watcher =
|
||||||
|
notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||||
|
if let Ok(event) = res {
|
||||||
|
for path in event.paths {
|
||||||
|
if tx_clone.blocking_send(path).is_err() {
|
||||||
|
tracing::warn!("filesystem watcher channel closed, stopping");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for dir in dirs {
|
||||||
|
watcher.watch(dir, RecursiveMode::Recursive)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Box::new(watcher))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn polling_watcher(
|
||||||
|
dirs: &[PathBuf],
|
||||||
|
tx: mpsc::Sender<PathBuf>,
|
||||||
|
) -> Result<Box<dyn Watcher + Send>> {
|
||||||
|
let tx_clone = tx.clone();
|
||||||
|
let poll_interval = std::time::Duration::from_secs(5);
|
||||||
|
let config = notify::Config::default().with_poll_interval(poll_interval);
|
||||||
|
|
||||||
|
let mut watcher = PollWatcher::new(
|
||||||
|
move |res: notify::Result<notify::Event>| {
|
||||||
|
if let Ok(event) = res {
|
||||||
|
for path in event.paths {
|
||||||
|
if tx_clone.blocking_send(path).is_err() {
|
||||||
|
tracing::warn!("filesystem watcher channel closed, stopping");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
.map_err(|e| crate::error::PinakesError::Io(std::io::Error::other(e)))?;
|
||||||
|
|
||||||
|
for dir in dirs {
|
||||||
|
watcher
|
||||||
|
.watch(dir, RecursiveMode::Recursive)
|
||||||
|
.map_err(|e| crate::error::PinakesError::Io(std::io::Error::other(e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Box::new(watcher))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn next_change(&mut self) -> Option<PathBuf> {
|
||||||
|
self.rx.recv().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn watch_and_import(
|
||||||
|
storage: DynStorageBackend,
|
||||||
|
dirs: Vec<PathBuf>,
|
||||||
|
ignore_patterns: Vec<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut watcher = FileWatcher::new(&dirs)?;
|
||||||
|
info!("filesystem watcher started");
|
||||||
|
|
||||||
|
while let Some(path) = watcher.next_change().await {
|
||||||
|
if path.is_file()
|
||||||
|
&& crate::media_type::MediaType::from_path(&path).is_some()
|
||||||
|
&& !crate::import::should_ignore(&path, &ignore_patterns)
|
||||||
|
{
|
||||||
|
info!(path = %path.display(), "detected file change, importing");
|
||||||
|
if let Err(e) = import::import_file(&storage, &path).await {
|
||||||
|
warn!(path = %path.display(), error = %e, "failed to import changed file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
517
crates/pinakes-core/src/scheduler.rs
Normal file
517
crates/pinakes-core/src/scheduler.rs
Normal file
|
|
@ -0,0 +1,517 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Datelike, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::jobs::{JobKind, JobQueue};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "type")]
|
||||||
|
pub enum Schedule {
|
||||||
|
Interval { secs: u64 },
|
||||||
|
Daily { hour: u32, minute: u32 },
|
||||||
|
Weekly { day: u32, hour: u32, minute: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Schedule {
|
||||||
|
pub fn next_run(&self, from: DateTime<Utc>) -> DateTime<Utc> {
|
||||||
|
match self {
|
||||||
|
Schedule::Interval { secs } => from + chrono::Duration::seconds(*secs as i64),
|
||||||
|
Schedule::Daily { hour, minute } => {
|
||||||
|
let today = from
|
||||||
|
.date_naive()
|
||||||
|
.and_hms_opt(*hour, *minute, 0)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let today_utc = today.and_utc();
|
||||||
|
if today_utc > from {
|
||||||
|
today_utc
|
||||||
|
} else {
|
||||||
|
today_utc + chrono::Duration::days(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Schedule::Weekly { day, hour, minute } => {
|
||||||
|
let current_day = from.weekday().num_days_from_monday();
|
||||||
|
let target_day = *day;
|
||||||
|
let days_ahead = if target_day > current_day {
|
||||||
|
target_day - current_day
|
||||||
|
} else if target_day < current_day {
|
||||||
|
7 - (current_day - target_day)
|
||||||
|
} else {
|
||||||
|
let today = from
|
||||||
|
.date_naive()
|
||||||
|
.and_hms_opt(*hour, *minute, 0)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.and_utc();
|
||||||
|
if today > from {
|
||||||
|
return today;
|
||||||
|
}
|
||||||
|
7
|
||||||
|
};
|
||||||
|
let target_date = from.date_naive() + chrono::Duration::days(days_ahead as i64);
|
||||||
|
target_date
|
||||||
|
.and_hms_opt(*hour, *minute, 0)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.and_utc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display_string(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Schedule::Interval { secs } => {
|
||||||
|
if *secs >= 3600 {
|
||||||
|
format!("Every {}h", secs / 3600)
|
||||||
|
} else if *secs >= 60 {
|
||||||
|
format!("Every {}m", secs / 60)
|
||||||
|
} else {
|
||||||
|
format!("Every {}s", secs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Schedule::Daily { hour, minute } => format!("Daily {hour:02}:{minute:02}"),
|
||||||
|
Schedule::Weekly { day, hour, minute } => {
|
||||||
|
let day_name = match day {
|
||||||
|
0 => "Mon",
|
||||||
|
1 => "Tue",
|
||||||
|
2 => "Wed",
|
||||||
|
3 => "Thu",
|
||||||
|
4 => "Fri",
|
||||||
|
5 => "Sat",
|
||||||
|
_ => "Sun",
|
||||||
|
};
|
||||||
|
format!("{day_name} {hour:02}:{minute:02}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ScheduledTask {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: JobKind,
|
||||||
|
pub schedule: Schedule,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub last_run: Option<DateTime<Utc>>,
|
||||||
|
pub next_run: Option<DateTime<Utc>>,
|
||||||
|
pub last_status: Option<String>,
|
||||||
|
/// Whether a job for this task is currently running. Skipped during serialization.
|
||||||
|
#[serde(default, skip_serializing)]
|
||||||
|
pub running: bool,
|
||||||
|
/// The job ID of the last submitted job. Skipped during serialization/deserialization.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub last_job_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TaskScheduler {
|
||||||
|
tasks: Arc<RwLock<Vec<ScheduledTask>>>,
|
||||||
|
job_queue: Arc<JobQueue>,
|
||||||
|
cancel: CancellationToken,
|
||||||
|
config: Arc<RwLock<Config>>,
|
||||||
|
config_path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskScheduler {
|
||||||
|
pub fn new(
|
||||||
|
job_queue: Arc<JobQueue>,
|
||||||
|
cancel: CancellationToken,
|
||||||
|
config: Arc<RwLock<Config>>,
|
||||||
|
config_path: Option<PathBuf>,
|
||||||
|
) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
let default_tasks = vec![
|
||||||
|
ScheduledTask {
|
||||||
|
id: "periodic_scan".to_string(),
|
||||||
|
name: "Periodic Scan".to_string(),
|
||||||
|
kind: JobKind::Scan { path: None },
|
||||||
|
schedule: Schedule::Interval { secs: 3600 },
|
||||||
|
enabled: true,
|
||||||
|
last_run: None,
|
||||||
|
next_run: Some(now + chrono::Duration::seconds(3600)),
|
||||||
|
last_status: None,
|
||||||
|
running: false,
|
||||||
|
last_job_id: None,
|
||||||
|
},
|
||||||
|
ScheduledTask {
|
||||||
|
id: "integrity_check".to_string(),
|
||||||
|
name: "Integrity Check".to_string(),
|
||||||
|
kind: JobKind::VerifyIntegrity { media_ids: vec![] },
|
||||||
|
schedule: Schedule::Weekly {
|
||||||
|
day: 0,
|
||||||
|
hour: 3,
|
||||||
|
minute: 0,
|
||||||
|
},
|
||||||
|
enabled: false,
|
||||||
|
last_run: None,
|
||||||
|
next_run: None,
|
||||||
|
last_status: None,
|
||||||
|
running: false,
|
||||||
|
last_job_id: None,
|
||||||
|
},
|
||||||
|
ScheduledTask {
|
||||||
|
id: "orphan_detection".to_string(),
|
||||||
|
name: "Orphan Detection".to_string(),
|
||||||
|
kind: JobKind::OrphanDetection,
|
||||||
|
schedule: Schedule::Daily { hour: 2, minute: 0 },
|
||||||
|
enabled: false,
|
||||||
|
last_run: None,
|
||||||
|
next_run: None,
|
||||||
|
last_status: None,
|
||||||
|
running: false,
|
||||||
|
last_job_id: None,
|
||||||
|
},
|
||||||
|
ScheduledTask {
|
||||||
|
id: "thumbnail_cleanup".to_string(),
|
||||||
|
name: "Thumbnail Cleanup".to_string(),
|
||||||
|
kind: JobKind::CleanupThumbnails,
|
||||||
|
schedule: Schedule::Weekly {
|
||||||
|
day: 6,
|
||||||
|
hour: 4,
|
||||||
|
minute: 0,
|
||||||
|
},
|
||||||
|
enabled: false,
|
||||||
|
last_run: None,
|
||||||
|
next_run: None,
|
||||||
|
last_status: None,
|
||||||
|
running: false,
|
||||||
|
last_job_id: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
Self {
|
||||||
|
tasks: Arc::new(RwLock::new(default_tasks)),
|
||||||
|
job_queue,
|
||||||
|
cancel,
|
||||||
|
config,
|
||||||
|
config_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore saved task state from config. Should be called once after construction.
|
||||||
|
pub async fn restore_state(&self) {
|
||||||
|
let saved = self.config.read().await.scheduled_tasks.clone();
|
||||||
|
if saved.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut tasks = self.tasks.write().await;
|
||||||
|
for saved_task in &saved {
|
||||||
|
if let Some(task) = tasks.iter_mut().find(|t| t.id == saved_task.id) {
|
||||||
|
task.enabled = saved_task.enabled;
|
||||||
|
task.schedule = saved_task.schedule.clone();
|
||||||
|
if let Some(Ok(dt)) = saved_task
|
||||||
|
.last_run
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| DateTime::parse_from_rfc3339(s))
|
||||||
|
{
|
||||||
|
task.last_run = Some(dt.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
if task.enabled {
|
||||||
|
let from = task.last_run.unwrap_or_else(Utc::now);
|
||||||
|
task.next_run = Some(task.schedule.next_run(from));
|
||||||
|
} else {
|
||||||
|
task.next_run = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist current task state to config file.
|
||||||
|
async fn persist_task_state(&self) {
|
||||||
|
let tasks = self.tasks.read().await;
|
||||||
|
let task_configs: Vec<crate::config::ScheduledTaskConfig> = tasks
|
||||||
|
.iter()
|
||||||
|
.map(|t| crate::config::ScheduledTaskConfig {
|
||||||
|
id: t.id.clone(),
|
||||||
|
enabled: t.enabled,
|
||||||
|
schedule: t.schedule.clone(),
|
||||||
|
last_run: t.last_run.map(|dt| dt.to_rfc3339()),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
drop(tasks);
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut config = self.config.write().await;
|
||||||
|
config.scheduled_tasks = task_configs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref path) = self.config_path {
|
||||||
|
let config = self.config.read().await;
|
||||||
|
if let Err(e) = config.save_to_file(path) {
|
||||||
|
tracing::warn!(error = %e, "failed to persist scheduler state to config file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_tasks(&self) -> Vec<ScheduledTask> {
|
||||||
|
self.tasks.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn toggle_task(&self, id: &str) -> Option<bool> {
|
||||||
|
let result = {
|
||||||
|
let mut tasks = self.tasks.write().await;
|
||||||
|
if let Some(task) = tasks.iter_mut().find(|t| t.id == id) {
|
||||||
|
task.enabled = !task.enabled;
|
||||||
|
if task.enabled {
|
||||||
|
task.next_run = Some(task.schedule.next_run(Utc::now()));
|
||||||
|
} else {
|
||||||
|
task.next_run = None;
|
||||||
|
}
|
||||||
|
Some(task.enabled)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if result.is_some() {
|
||||||
|
self.persist_task_state().await;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a task immediately. Uses a single write lock to avoid TOCTOU races.
|
||||||
|
pub async fn run_now(&self, id: &str) -> Option<String> {
|
||||||
|
let result = {
|
||||||
|
let mut tasks = self.tasks.write().await;
|
||||||
|
let task = tasks.iter_mut().find(|t| t.id == id)?;
|
||||||
|
|
||||||
|
// Submit the job (cheap: sends to mpsc channel)
|
||||||
|
let job_id = self.job_queue.submit(task.kind.clone()).await;
|
||||||
|
|
||||||
|
task.last_run = Some(Utc::now());
|
||||||
|
task.last_status = Some("running".to_string());
|
||||||
|
task.running = true;
|
||||||
|
task.last_job_id = Some(job_id);
|
||||||
|
if task.enabled {
|
||||||
|
task.next_run = Some(task.schedule.next_run(Utc::now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(job_id.to_string())
|
||||||
|
};
|
||||||
|
if result.is_some() {
|
||||||
|
self.persist_task_state().await;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main scheduler loop. Uses a two-phase approach per tick to avoid
|
||||||
|
/// holding the write lock across await points. Returns when the
|
||||||
|
/// cancellation token is triggered.
|
||||||
|
pub async fn run(&self) {
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = interval.tick() => {}
|
||||||
|
_ = self.cancel.cancelled() => {
|
||||||
|
tracing::info!("scheduler shutting down");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Check completed jobs and update running status
|
||||||
|
{
|
||||||
|
use crate::jobs::JobStatus;
|
||||||
|
let mut tasks = self.tasks.write().await;
|
||||||
|
for task in tasks.iter_mut() {
|
||||||
|
if !task.running {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(job_id) = task.last_job_id else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(job) = self.job_queue.status(job_id).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match &job.status {
|
||||||
|
JobStatus::Completed { .. } => {
|
||||||
|
task.running = false;
|
||||||
|
task.last_status = Some("completed".to_string());
|
||||||
|
}
|
||||||
|
JobStatus::Failed { error } => {
|
||||||
|
task.running = false;
|
||||||
|
task.last_status = Some(format!("failed: {error}"));
|
||||||
|
}
|
||||||
|
JobStatus::Cancelled => {
|
||||||
|
task.running = false;
|
||||||
|
task.last_status = Some("cancelled".to_string());
|
||||||
|
}
|
||||||
|
_ => {} // still pending or running
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Collect due tasks and submit jobs
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut to_submit: Vec<(usize, JobKind)> = Vec::new();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut tasks = self.tasks.write().await;
|
||||||
|
for (i, task) in tasks.iter_mut().enumerate() {
|
||||||
|
if !task.enabled || task.running {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let due = task.next_run.is_some_and(|next| now >= next);
|
||||||
|
if due {
|
||||||
|
to_submit.push((i, task.kind.clone()));
|
||||||
|
task.last_run = Some(now);
|
||||||
|
task.last_status = Some("running".to_string());
|
||||||
|
task.running = true;
|
||||||
|
task.next_run = Some(task.schedule.next_run(now));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit jobs without holding the lock
|
||||||
|
for (idx, kind) in to_submit {
|
||||||
|
let job_id = self.job_queue.submit(kind).await;
|
||||||
|
let mut tasks = self.tasks.write().await;
|
||||||
|
if let Some(task) = tasks.get_mut(idx) {
|
||||||
|
task.last_job_id = Some(job_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::TimeZone;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_interval_next_run() {
|
||||||
|
let from = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
|
||||||
|
let schedule = Schedule::Interval { secs: 3600 };
|
||||||
|
let next = schedule.next_run(from);
|
||||||
|
assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 13, 0, 0).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_daily_next_run_future_today() {
|
||||||
|
// 10:00 UTC, schedule is 14:00 => same day
|
||||||
|
let from = Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap();
|
||||||
|
let schedule = Schedule::Daily {
|
||||||
|
hour: 14,
|
||||||
|
minute: 0,
|
||||||
|
};
|
||||||
|
let next = schedule.next_run(from);
|
||||||
|
assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_daily_next_run_past_today() {
|
||||||
|
// 16:00 UTC, schedule is 14:00 => next day
|
||||||
|
let from = Utc.with_ymd_and_hms(2025, 6, 15, 16, 0, 0).unwrap();
|
||||||
|
let schedule = Schedule::Daily {
|
||||||
|
hour: 14,
|
||||||
|
minute: 0,
|
||||||
|
};
|
||||||
|
let next = schedule.next_run(from);
|
||||||
|
assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 16, 14, 0, 0).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_weekly_next_run() {
|
||||||
|
// 2025-06-15 is a Sunday (day 6). Target is Monday (day 0) at 03:00.
|
||||||
|
let from = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
|
||||||
|
let schedule = Schedule::Weekly {
|
||||||
|
day: 0,
|
||||||
|
hour: 3,
|
||||||
|
minute: 0,
|
||||||
|
};
|
||||||
|
let next = schedule.next_run(from);
|
||||||
|
assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 16, 3, 0, 0).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_weekly_same_day_future() {
|
||||||
|
// 2025-06-15 is Sunday (day 6). Schedule is Sunday 14:00, current is 10:00 => today.
|
||||||
|
let from = Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap();
|
||||||
|
let schedule = Schedule::Weekly {
|
||||||
|
day: 6,
|
||||||
|
hour: 14,
|
||||||
|
minute: 0,
|
||||||
|
};
|
||||||
|
let next = schedule.next_run(from);
|
||||||
|
assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_weekly_same_day_past() {
|
||||||
|
// 2025-06-15 is Sunday (day 6). Schedule is Sunday 08:00, current is 10:00 => next week.
|
||||||
|
let from = Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap();
|
||||||
|
let schedule = Schedule::Weekly {
|
||||||
|
day: 6,
|
||||||
|
hour: 8,
|
||||||
|
minute: 0,
|
||||||
|
};
|
||||||
|
let next = schedule.next_run(from);
|
||||||
|
assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 22, 8, 0, 0).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serde_roundtrip() {
|
||||||
|
let task = ScheduledTask {
|
||||||
|
id: "test".to_string(),
|
||||||
|
name: "Test Task".to_string(),
|
||||||
|
kind: JobKind::Scan { path: None },
|
||||||
|
schedule: Schedule::Interval { secs: 3600 },
|
||||||
|
enabled: true,
|
||||||
|
last_run: Some(Utc::now()),
|
||||||
|
next_run: Some(Utc::now()),
|
||||||
|
last_status: Some("completed".to_string()),
|
||||||
|
running: true,
|
||||||
|
last_job_id: Some(Uuid::now_v7()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&task).unwrap();
|
||||||
|
let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(deserialized.id, "test");
|
||||||
|
assert_eq!(deserialized.enabled, true);
|
||||||
|
// running defaults to false on deserialization (skip_serializing)
|
||||||
|
assert!(!deserialized.running);
|
||||||
|
// last_job_id is skipped entirely
|
||||||
|
assert!(deserialized.last_job_id.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_display_string() {
|
||||||
|
assert_eq!(
|
||||||
|
Schedule::Interval { secs: 3600 }.display_string(),
|
||||||
|
"Every 1h"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Schedule::Interval { secs: 300 }.display_string(),
|
||||||
|
"Every 5m"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Schedule::Interval { secs: 30 }.display_string(),
|
||||||
|
"Every 30s"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Schedule::Daily { hour: 3, minute: 0 }.display_string(),
|
||||||
|
"Daily 03:00"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Schedule::Weekly {
|
||||||
|
day: 0,
|
||||||
|
hour: 3,
|
||||||
|
minute: 0
|
||||||
|
}
|
||||||
|
.display_string(),
|
||||||
|
"Mon 03:00"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Schedule::Weekly {
|
||||||
|
day: 6,
|
||||||
|
hour: 14,
|
||||||
|
minute: 30
|
||||||
|
}
|
||||||
|
.display_string(),
|
||||||
|
"Sun 14:30"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
256
crates/pinakes-core/src/search.rs
Normal file
256
crates/pinakes-core/src/search.rs
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use winnow::combinator::{alt, delimited, preceded, repeat};
|
||||||
|
use winnow::token::{take_till, take_while};
|
||||||
|
use winnow::{ModalResult, Parser};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum SearchQuery {
|
||||||
|
FullText(String),
|
||||||
|
FieldMatch { field: String, value: String },
|
||||||
|
And(Vec<SearchQuery>),
|
||||||
|
Or(Vec<SearchQuery>),
|
||||||
|
Not(Box<SearchQuery>),
|
||||||
|
Prefix(String),
|
||||||
|
Fuzzy(String),
|
||||||
|
TypeFilter(String),
|
||||||
|
TagFilter(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SearchRequest {
|
||||||
|
pub query: SearchQuery,
|
||||||
|
pub sort: SortOrder,
|
||||||
|
pub pagination: crate::model::Pagination,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SearchResults {
|
||||||
|
pub items: Vec<crate::model::MediaItem>,
|
||||||
|
pub total_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub enum SortOrder {
|
||||||
|
#[default]
|
||||||
|
Relevance,
|
||||||
|
DateAsc,
|
||||||
|
DateDesc,
|
||||||
|
NameAsc,
|
||||||
|
NameDesc,
|
||||||
|
SizeAsc,
|
||||||
|
SizeDesc,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ws<'i>(input: &mut &'i str) -> ModalResult<&'i str> {
|
||||||
|
take_while(0.., ' ').parse_next(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quoted_string(input: &mut &str) -> ModalResult<String> {
|
||||||
|
delimited('"', take_till(0.., '"'), '"')
|
||||||
|
.map(|s: &str| s.to_string())
|
||||||
|
.parse_next(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bare_word(input: &mut &str) -> ModalResult<String> {
|
||||||
|
take_while(1.., |c: char| !c.is_whitespace() && c != ')' && c != '(')
|
||||||
|
.map(|s: &str| s.to_string())
|
||||||
|
.parse_next(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn word_or_quoted(input: &mut &str) -> ModalResult<String> {
|
||||||
|
alt((quoted_string, bare_word)).parse_next(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_expr(input: &mut &str) -> ModalResult<SearchQuery> {
|
||||||
|
preceded(('-', ws), atom)
|
||||||
|
.map(|q| SearchQuery::Not(Box::new(q)))
|
||||||
|
.parse_next(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_match(input: &mut &str) -> ModalResult<SearchQuery> {
|
||||||
|
let field_name =
|
||||||
|
take_while(1.., |c: char| c.is_alphanumeric() || c == '_').map(|s: &str| s.to_string());
|
||||||
|
(field_name, ':', word_or_quoted)
|
||||||
|
.map(|(field, _, value)| match field.as_str() {
|
||||||
|
"type" => SearchQuery::TypeFilter(value),
|
||||||
|
"tag" => SearchQuery::TagFilter(value),
|
||||||
|
_ => SearchQuery::FieldMatch { field, value },
|
||||||
|
})
|
||||||
|
.parse_next(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefix_expr(input: &mut &str) -> ModalResult<SearchQuery> {
|
||||||
|
let word = take_while(1.., |c: char| {
|
||||||
|
!c.is_whitespace() && c != ')' && c != '(' && c != '*'
|
||||||
|
})
|
||||||
|
.map(|s: &str| s.to_string());
|
||||||
|
(word, '*')
|
||||||
|
.map(|(w, _)| SearchQuery::Prefix(w))
|
||||||
|
.parse_next(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fuzzy_expr(input: &mut &str) -> ModalResult<SearchQuery> {
|
||||||
|
let word = take_while(1.., |c: char| {
|
||||||
|
!c.is_whitespace() && c != ')' && c != '(' && c != '~'
|
||||||
|
})
|
||||||
|
.map(|s: &str| s.to_string());
|
||||||
|
(word, '~')
|
||||||
|
.map(|(w, _)| SearchQuery::Fuzzy(w))
|
||||||
|
.parse_next(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paren_expr(input: &mut &str) -> ModalResult<SearchQuery> {
|
||||||
|
delimited(('(', ws), or_expr, (ws, ')')).parse_next(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_or_keyword(input: &mut &str) -> ModalResult<()> {
|
||||||
|
if let Some(rest) = input.strip_prefix("OR")
|
||||||
|
&& (rest.is_empty() || rest.starts_with(' ') || rest.starts_with(')'))
|
||||||
|
{
|
||||||
|
return Err(winnow::error::ErrMode::Backtrack(
|
||||||
|
winnow::error::ContextError::new(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn full_text(input: &mut &str) -> ModalResult<SearchQuery> {
|
||||||
|
not_or_keyword.parse_next(input)?;
|
||||||
|
word_or_quoted.map(SearchQuery::FullText).parse_next(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn atom(input: &mut &str) -> ModalResult<SearchQuery> {
|
||||||
|
alt((
|
||||||
|
paren_expr,
|
||||||
|
not_expr,
|
||||||
|
field_match,
|
||||||
|
prefix_expr,
|
||||||
|
fuzzy_expr,
|
||||||
|
full_text,
|
||||||
|
))
|
||||||
|
.parse_next(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn and_expr(input: &mut &str) -> ModalResult<SearchQuery> {
|
||||||
|
let first = atom.parse_next(input)?;
|
||||||
|
let rest: Vec<SearchQuery> = repeat(0.., preceded(ws, atom)).parse_next(input)?;
|
||||||
|
if rest.is_empty() {
|
||||||
|
Ok(first)
|
||||||
|
} else {
|
||||||
|
let mut terms = vec![first];
|
||||||
|
terms.extend(rest);
|
||||||
|
Ok(SearchQuery::And(terms))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn or_expr(input: &mut &str) -> ModalResult<SearchQuery> {
|
||||||
|
let first = and_expr.parse_next(input)?;
|
||||||
|
let rest: Vec<SearchQuery> =
|
||||||
|
repeat(0.., preceded((ws, "OR", ws), and_expr)).parse_next(input)?;
|
||||||
|
if rest.is_empty() {
|
||||||
|
Ok(first)
|
||||||
|
} else {
|
||||||
|
let mut terms = vec![first];
|
||||||
|
terms.extend(rest);
|
||||||
|
Ok(SearchQuery::Or(terms))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_search_query(input: &str) -> crate::error::Result<SearchQuery> {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(SearchQuery::FullText(String::new()));
|
||||||
|
}
|
||||||
|
let mut input = trimmed;
|
||||||
|
or_expr
|
||||||
|
.parse_next(&mut input)
|
||||||
|
.map_err(|e| crate::error::PinakesError::SearchParse(format!("{e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_simple_text() {
|
||||||
|
let q = parse_search_query("hello").unwrap();
|
||||||
|
assert_eq!(q, SearchQuery::FullText("hello".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_field_match() {
|
||||||
|
let q = parse_search_query("artist:Beatles").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
q,
|
||||||
|
SearchQuery::FieldMatch {
|
||||||
|
field: "artist".into(),
|
||||||
|
value: "Beatles".into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_type_filter() {
|
||||||
|
let q = parse_search_query("type:pdf").unwrap();
|
||||||
|
assert_eq!(q, SearchQuery::TypeFilter("pdf".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tag_filter() {
|
||||||
|
let q = parse_search_query("tag:music").unwrap();
|
||||||
|
assert_eq!(q, SearchQuery::TagFilter("music".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_and_implicit() {
|
||||||
|
let q = parse_search_query("hello world").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
q,
|
||||||
|
SearchQuery::And(vec![
|
||||||
|
SearchQuery::FullText("hello".into()),
|
||||||
|
SearchQuery::FullText("world".into()),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_or() {
|
||||||
|
let q = parse_search_query("hello OR world").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
q,
|
||||||
|
SearchQuery::Or(vec![
|
||||||
|
SearchQuery::FullText("hello".into()),
|
||||||
|
SearchQuery::FullText("world".into()),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_not() {
|
||||||
|
let q = parse_search_query("-excluded").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
q,
|
||||||
|
SearchQuery::Not(Box::new(SearchQuery::FullText("excluded".into())))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prefix() {
|
||||||
|
let q = parse_search_query("hel*").unwrap();
|
||||||
|
assert_eq!(q, SearchQuery::Prefix("hel".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fuzzy() {
|
||||||
|
let q = parse_search_query("hello~").unwrap();
|
||||||
|
assert_eq!(q, SearchQuery::Fuzzy("hello".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quoted() {
|
||||||
|
let q = parse_search_query("\"hello world\"").unwrap();
|
||||||
|
assert_eq!(q, SearchQuery::FullText("hello world".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
26
crates/pinakes-core/src/storage/migrations.rs
Normal file
26
crates/pinakes-core/src/storage/migrations.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
use crate::error::{PinakesError, Result};
|
||||||
|
|
||||||
|
mod sqlite_migrations {
|
||||||
|
use refinery::embed_migrations;
|
||||||
|
embed_migrations!("../../migrations/sqlite");
|
||||||
|
}
|
||||||
|
|
||||||
|
mod postgres_migrations {
|
||||||
|
use refinery::embed_migrations;
|
||||||
|
embed_migrations!("../../migrations/postgres");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_sqlite_migrations(conn: &mut rusqlite::Connection) -> Result<()> {
|
||||||
|
sqlite_migrations::migrations::runner()
|
||||||
|
.run(conn)
|
||||||
|
.map_err(|e| PinakesError::Migration(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_postgres_migrations(client: &mut tokio_postgres::Client) -> Result<()> {
|
||||||
|
postgres_migrations::migrations::runner()
|
||||||
|
.run_async(client)
|
||||||
|
.await
|
||||||
|
.map_err(|e| PinakesError::Migration(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
209
crates/pinakes-core/src/storage/mod.rs
Normal file
209
crates/pinakes-core/src/storage/mod.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
pub mod migrations;
|
||||||
|
pub mod postgres;
|
||||||
|
pub mod sqlite;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::model::*;
|
||||||
|
use crate::search::{SearchRequest, SearchResults};
|
||||||
|
|
||||||
|
/// Statistics about the database.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct DatabaseStats {
|
||||||
|
pub media_count: u64,
|
||||||
|
pub tag_count: u64,
|
||||||
|
pub collection_count: u64,
|
||||||
|
pub audit_count: u64,
|
||||||
|
pub database_size_bytes: u64,
|
||||||
|
pub backend_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait StorageBackend: Send + Sync + 'static {
|
||||||
|
// Migrations
|
||||||
|
async fn run_migrations(&self) -> Result<()>;
|
||||||
|
|
||||||
|
// Root directories
|
||||||
|
async fn add_root_dir(&self, path: PathBuf) -> Result<()>;
|
||||||
|
async fn list_root_dirs(&self) -> Result<Vec<PathBuf>>;
|
||||||
|
async fn remove_root_dir(&self, path: &std::path::Path) -> Result<()>;
|
||||||
|
|
||||||
|
// Media CRUD
|
||||||
|
async fn insert_media(&self, item: &MediaItem) -> Result<()>;
|
||||||
|
async fn get_media(&self, id: MediaId) -> Result<MediaItem>;
|
||||||
|
async fn count_media(&self) -> Result<u64>;
|
||||||
|
async fn get_media_by_hash(&self, hash: &ContentHash) -> Result<Option<MediaItem>>;
|
||||||
|
async fn list_media(&self, pagination: &Pagination) -> Result<Vec<MediaItem>>;
|
||||||
|
async fn update_media(&self, item: &MediaItem) -> Result<()>;
|
||||||
|
async fn delete_media(&self, id: MediaId) -> Result<()>;
|
||||||
|
async fn delete_all_media(&self) -> Result<u64>;
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
async fn create_tag(&self, name: &str, parent_id: Option<Uuid>) -> Result<Tag>;
|
||||||
|
async fn get_tag(&self, id: Uuid) -> Result<Tag>;
|
||||||
|
async fn list_tags(&self) -> Result<Vec<Tag>>;
|
||||||
|
async fn delete_tag(&self, id: Uuid) -> Result<()>;
|
||||||
|
async fn tag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()>;
|
||||||
|
async fn untag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()>;
|
||||||
|
async fn get_media_tags(&self, media_id: MediaId) -> Result<Vec<Tag>>;
|
||||||
|
async fn get_tag_descendants(&self, tag_id: Uuid) -> Result<Vec<Tag>>;
|
||||||
|
|
||||||
|
// Collections
|
||||||
|
async fn create_collection(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
kind: CollectionKind,
|
||||||
|
description: Option<&str>,
|
||||||
|
filter_query: Option<&str>,
|
||||||
|
) -> Result<Collection>;
|
||||||
|
async fn get_collection(&self, id: Uuid) -> Result<Collection>;
|
||||||
|
async fn list_collections(&self) -> Result<Vec<Collection>>;
|
||||||
|
async fn delete_collection(&self, id: Uuid) -> Result<()>;
|
||||||
|
async fn add_to_collection(
|
||||||
|
&self,
|
||||||
|
collection_id: Uuid,
|
||||||
|
media_id: MediaId,
|
||||||
|
position: i32,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn remove_from_collection(&self, collection_id: Uuid, media_id: MediaId) -> Result<()>;
|
||||||
|
async fn get_collection_members(&self, collection_id: Uuid) -> Result<Vec<MediaItem>>;
|
||||||
|
|
||||||
|
// Search
|
||||||
|
async fn search(&self, request: &SearchRequest) -> Result<SearchResults>;
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
async fn record_audit(&self, entry: &AuditEntry) -> Result<()>;
|
||||||
|
async fn list_audit_entries(
|
||||||
|
&self,
|
||||||
|
media_id: Option<MediaId>,
|
||||||
|
pagination: &Pagination,
|
||||||
|
) -> Result<Vec<AuditEntry>>;
|
||||||
|
|
||||||
|
// Custom fields
|
||||||
|
async fn set_custom_field(
|
||||||
|
&self,
|
||||||
|
media_id: MediaId,
|
||||||
|
name: &str,
|
||||||
|
field: &CustomField,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn get_custom_fields(
|
||||||
|
&self,
|
||||||
|
media_id: MediaId,
|
||||||
|
) -> Result<std::collections::HashMap<String, CustomField>>;
|
||||||
|
async fn delete_custom_field(&self, media_id: MediaId, name: &str) -> Result<()>;
|
||||||
|
|
||||||
|
// Batch operations (transactional where supported)
|
||||||
|
async fn batch_delete_media(&self, ids: &[MediaId]) -> Result<u64> {
|
||||||
|
let mut count = 0u64;
|
||||||
|
for id in ids {
|
||||||
|
self.delete_media(*id).await?;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn batch_tag_media(&self, media_ids: &[MediaId], tag_ids: &[Uuid]) -> Result<u64> {
|
||||||
|
let mut count = 0u64;
|
||||||
|
for media_id in media_ids {
|
||||||
|
for tag_id in tag_ids {
|
||||||
|
self.tag_media(*media_id, *tag_id).await?;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integrity
|
||||||
|
async fn list_media_paths(&self) -> Result<Vec<(MediaId, std::path::PathBuf, ContentHash)>>;
|
||||||
|
|
||||||
|
// Batch metadata update
|
||||||
|
async fn batch_update_media(
|
||||||
|
&self,
|
||||||
|
ids: &[MediaId],
|
||||||
|
title: Option<&str>,
|
||||||
|
artist: Option<&str>,
|
||||||
|
album: Option<&str>,
|
||||||
|
genre: Option<&str>,
|
||||||
|
year: Option<i32>,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<u64> {
|
||||||
|
let mut count = 0u64;
|
||||||
|
for id in ids {
|
||||||
|
let mut item = self.get_media(*id).await?;
|
||||||
|
if let Some(v) = title {
|
||||||
|
item.title = Some(v.to_string());
|
||||||
|
}
|
||||||
|
if let Some(v) = artist {
|
||||||
|
item.artist = Some(v.to_string());
|
||||||
|
}
|
||||||
|
if let Some(v) = album {
|
||||||
|
item.album = Some(v.to_string());
|
||||||
|
}
|
||||||
|
if let Some(v) = genre {
|
||||||
|
item.genre = Some(v.to_string());
|
||||||
|
}
|
||||||
|
if let Some(v) = &year {
|
||||||
|
item.year = Some(*v);
|
||||||
|
}
|
||||||
|
if let Some(v) = description {
|
||||||
|
item.description = Some(v.to_string());
|
||||||
|
}
|
||||||
|
item.updated_at = chrono::Utc::now();
|
||||||
|
self.update_media(&item).await?;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saved searches
|
||||||
|
async fn save_search(
|
||||||
|
&self,
|
||||||
|
id: uuid::Uuid,
|
||||||
|
name: &str,
|
||||||
|
query: &str,
|
||||||
|
sort_order: Option<&str>,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn list_saved_searches(&self) -> Result<Vec<crate::model::SavedSearch>>;
|
||||||
|
async fn delete_saved_search(&self, id: uuid::Uuid) -> Result<()>;
|
||||||
|
|
||||||
|
// Duplicates
|
||||||
|
async fn find_duplicates(&self) -> Result<Vec<Vec<MediaItem>>>;
|
||||||
|
|
||||||
|
// Database management
|
||||||
|
async fn database_stats(&self) -> Result<DatabaseStats>;
|
||||||
|
async fn vacuum(&self) -> Result<()>;
|
||||||
|
async fn clear_all_data(&self) -> Result<()>;
|
||||||
|
|
||||||
|
// Thumbnail helpers
|
||||||
|
/// List all media IDs, optionally filtering to those missing thumbnails.
|
||||||
|
async fn list_media_ids_for_thumbnails(
|
||||||
|
&self,
|
||||||
|
only_missing: bool,
|
||||||
|
) -> Result<Vec<crate::model::MediaId>>;
|
||||||
|
|
||||||
|
// Library statistics
|
||||||
|
async fn library_statistics(&self) -> Result<LibraryStatistics>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Comprehensive library statistics.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct LibraryStatistics {
|
||||||
|
pub total_media: u64,
|
||||||
|
pub total_size_bytes: u64,
|
||||||
|
pub avg_file_size_bytes: u64,
|
||||||
|
pub media_by_type: Vec<(String, u64)>,
|
||||||
|
pub storage_by_type: Vec<(String, u64)>,
|
||||||
|
pub newest_item: Option<String>,
|
||||||
|
pub oldest_item: Option<String>,
|
||||||
|
pub top_tags: Vec<(String, u64)>,
|
||||||
|
pub top_collections: Vec<(String, u64)>,
|
||||||
|
pub total_tags: u64,
|
||||||
|
pub total_collections: u64,
|
||||||
|
pub total_duplicates: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type DynStorageBackend = Arc<dyn StorageBackend>;
|
||||||
1847
crates/pinakes-core/src/storage/postgres.rs
Normal file
1847
crates/pinakes-core/src/storage/postgres.rs
Normal file
File diff suppressed because it is too large
Load diff
1649
crates/pinakes-core/src/storage/sqlite.rs
Normal file
1649
crates/pinakes-core/src/storage/sqlite.rs
Normal file
File diff suppressed because it is too large
Load diff
43
crates/pinakes-core/src/tags.rs
Normal file
43
crates/pinakes-core/src/tags.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::model::{AuditAction, MediaId, Tag};
|
||||||
|
use crate::storage::DynStorageBackend;
|
||||||
|
|
||||||
|
pub async fn create_tag(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
name: &str,
|
||||||
|
parent_id: Option<Uuid>,
|
||||||
|
) -> Result<Tag> {
|
||||||
|
storage.create_tag(name, parent_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn tag_media(storage: &DynStorageBackend, media_id: MediaId, tag_id: Uuid) -> Result<()> {
|
||||||
|
storage.tag_media(media_id, tag_id).await?;
|
||||||
|
crate::audit::record_action(
|
||||||
|
storage,
|
||||||
|
Some(media_id),
|
||||||
|
AuditAction::Tagged,
|
||||||
|
Some(format!("tag_id={tag_id}")),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn untag_media(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
media_id: MediaId,
|
||||||
|
tag_id: Uuid,
|
||||||
|
) -> Result<()> {
|
||||||
|
storage.untag_media(media_id, tag_id).await?;
|
||||||
|
crate::audit::record_action(
|
||||||
|
storage,
|
||||||
|
Some(media_id),
|
||||||
|
AuditAction::Untagged,
|
||||||
|
Some(format!("tag_id={tag_id}")),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_tag_tree(storage: &DynStorageBackend, tag_id: Uuid) -> Result<Vec<Tag>> {
|
||||||
|
storage.get_tag_descendants(tag_id).await
|
||||||
|
}
|
||||||
278
crates/pinakes-core/src/thumbnail.rs
Normal file
278
crates/pinakes-core/src/thumbnail.rs
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::config::ThumbnailConfig;
|
||||||
|
use crate::error::{PinakesError, Result};
|
||||||
|
use crate::media_type::{MediaCategory, MediaType};
|
||||||
|
use crate::model::MediaId;
|
||||||
|
|
||||||
|
/// Generate a thumbnail for a media file and return the path to the thumbnail.
|
||||||
|
///
|
||||||
|
/// Supports images (via `image` crate), videos (via ffmpeg), PDFs (via pdftoppm),
|
||||||
|
/// and EPUBs (via cover image extraction).
|
||||||
|
pub fn generate_thumbnail(
|
||||||
|
media_id: MediaId,
|
||||||
|
source_path: &Path,
|
||||||
|
media_type: MediaType,
|
||||||
|
thumbnail_dir: &Path,
|
||||||
|
) -> Result<Option<PathBuf>> {
|
||||||
|
generate_thumbnail_with_config(
|
||||||
|
media_id,
|
||||||
|
source_path,
|
||||||
|
media_type,
|
||||||
|
thumbnail_dir,
|
||||||
|
&ThumbnailConfig::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_thumbnail_with_config(
|
||||||
|
media_id: MediaId,
|
||||||
|
source_path: &Path,
|
||||||
|
media_type: MediaType,
|
||||||
|
thumbnail_dir: &Path,
|
||||||
|
config: &ThumbnailConfig,
|
||||||
|
) -> Result<Option<PathBuf>> {
|
||||||
|
std::fs::create_dir_all(thumbnail_dir)?;
|
||||||
|
let thumb_path = thumbnail_dir.join(format!("{}.jpg", media_id));
|
||||||
|
|
||||||
|
let result = match media_type.category() {
|
||||||
|
MediaCategory::Image => {
|
||||||
|
if media_type.is_raw() {
|
||||||
|
generate_raw_thumbnail(source_path, &thumb_path, config)
|
||||||
|
} else if media_type == MediaType::Heic {
|
||||||
|
generate_heic_thumbnail(source_path, &thumb_path, config)
|
||||||
|
} else {
|
||||||
|
generate_image_thumbnail(source_path, &thumb_path, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MediaCategory::Video => generate_video_thumbnail(source_path, &thumb_path, config),
|
||||||
|
MediaCategory::Document => match media_type {
|
||||||
|
MediaType::Pdf => generate_pdf_thumbnail(source_path, &thumb_path, config),
|
||||||
|
MediaType::Epub => generate_epub_thumbnail(source_path, &thumb_path, config),
|
||||||
|
_ => return Ok(None),
|
||||||
|
},
|
||||||
|
_ => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
info!(media_id = %media_id, category = ?media_type.category(), "generated thumbnail");
|
||||||
|
Ok(Some(thumb_path))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(media_id = %media_id, error = %e, "failed to generate thumbnail");
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_image_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> {
|
||||||
|
let img = image::open(source)
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(format!("image open: {e}")))?;
|
||||||
|
|
||||||
|
let thumb = img.thumbnail(config.size, config.size);
|
||||||
|
|
||||||
|
let mut output = std::fs::File::create(dest)?;
|
||||||
|
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality);
|
||||||
|
thumb
|
||||||
|
.write_with_encoder(encoder)
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(format!("thumbnail encode: {e}")))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_video_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> {
|
||||||
|
let ffmpeg = config.ffmpeg_path.as_deref().unwrap_or("ffmpeg");
|
||||||
|
|
||||||
|
let status = Command::new(ffmpeg)
|
||||||
|
.args(["-ss", &config.video_seek_secs.to_string(), "-i"])
|
||||||
|
.arg(source)
|
||||||
|
.args([
|
||||||
|
"-vframes",
|
||||||
|
"1",
|
||||||
|
"-vf",
|
||||||
|
&format!("scale={}:{}", config.size, config.size),
|
||||||
|
"-y",
|
||||||
|
])
|
||||||
|
.arg(dest)
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map_err(|e| {
|
||||||
|
PinakesError::MetadataExtraction(format!("ffmpeg not found or failed to execute: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err(PinakesError::MetadataExtraction(format!(
|
||||||
|
"ffmpeg exited with status {}",
|
||||||
|
status
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_pdf_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> {
|
||||||
|
// Use pdftoppm to render first page, then resize with image crate
|
||||||
|
let temp_prefix = dest.with_extension("tmp");
|
||||||
|
let status = Command::new("pdftoppm")
|
||||||
|
.args(["-jpeg", "-f", "1", "-l", "1", "-singlefile"])
|
||||||
|
.arg(source)
|
||||||
|
.arg(&temp_prefix)
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map_err(|e| {
|
||||||
|
PinakesError::MetadataExtraction(format!(
|
||||||
|
"pdftoppm not found or failed to execute: {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err(PinakesError::MetadataExtraction(format!(
|
||||||
|
"pdftoppm exited with status {}",
|
||||||
|
status
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// pdftoppm outputs <prefix>.jpg
|
||||||
|
let rendered = temp_prefix.with_extension("jpg");
|
||||||
|
if rendered.exists() {
|
||||||
|
// Resize to thumbnail size
|
||||||
|
let img = image::open(&rendered)
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(format!("pdf thumbnail open: {e}")))?;
|
||||||
|
let thumb = img.thumbnail(config.size, config.size);
|
||||||
|
let mut output = std::fs::File::create(dest)?;
|
||||||
|
let encoder =
|
||||||
|
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality);
|
||||||
|
thumb
|
||||||
|
.write_with_encoder(encoder)
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(format!("pdf thumbnail encode: {e}")))?;
|
||||||
|
let _ = std::fs::remove_file(&rendered);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(PinakesError::MetadataExtraction(
|
||||||
|
"pdftoppm did not produce output".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_epub_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> {
|
||||||
|
// Try to extract cover image from EPUB
|
||||||
|
let mut doc = epub::doc::EpubDoc::new(source)
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(format!("epub open: {e}")))?;
|
||||||
|
|
||||||
|
let cover_data = doc.get_cover().map(|(data, _mime)| data).or_else(|| {
|
||||||
|
// Fallback: try to find a cover image in the resources
|
||||||
|
doc.get_resource("cover-image")
|
||||||
|
.map(|(data, _)| data)
|
||||||
|
.or_else(|| doc.get_resource("cover").map(|(data, _)| data))
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(data) = cover_data {
|
||||||
|
let img = image::load_from_memory(&data)
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(format!("epub cover decode: {e}")))?;
|
||||||
|
let thumb = img.thumbnail(config.size, config.size);
|
||||||
|
let mut output = std::fs::File::create(dest)?;
|
||||||
|
let encoder =
|
||||||
|
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality);
|
||||||
|
thumb
|
||||||
|
.write_with_encoder(encoder)
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(format!("epub thumbnail encode: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(PinakesError::MetadataExtraction(
|
||||||
|
"no cover image found in epub".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_raw_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> {
|
||||||
|
// Try dcraw to extract embedded JPEG preview, then resize
|
||||||
|
let temp_ppm = dest.with_extension("ppm");
|
||||||
|
let status = Command::new("dcraw")
|
||||||
|
.args(["-e", "-c"])
|
||||||
|
.arg(source)
|
||||||
|
.stdout(std::fs::File::create(&temp_ppm).map_err(|e| {
|
||||||
|
PinakesError::MetadataExtraction(format!("failed to create temp file: {e}"))
|
||||||
|
})?)
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(format!("dcraw not found or failed: {e}")))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
let _ = std::fs::remove_file(&temp_ppm);
|
||||||
|
return Err(PinakesError::MetadataExtraction(format!(
|
||||||
|
"dcraw exited with status {}",
|
||||||
|
status
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The extracted preview is typically a JPEG — try loading it
|
||||||
|
if temp_ppm.exists() {
|
||||||
|
let result = image::open(&temp_ppm);
|
||||||
|
let _ = std::fs::remove_file(&temp_ppm);
|
||||||
|
let img = result
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(format!("raw preview decode: {e}")))?;
|
||||||
|
let thumb = img.thumbnail(config.size, config.size);
|
||||||
|
let mut output = std::fs::File::create(dest)?;
|
||||||
|
let encoder =
|
||||||
|
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality);
|
||||||
|
thumb
|
||||||
|
.write_with_encoder(encoder)
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(format!("raw thumbnail encode: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(PinakesError::MetadataExtraction(
|
||||||
|
"dcraw did not produce output".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_heic_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> {
|
||||||
|
// Use heif-convert to convert to JPEG, then resize
|
||||||
|
let temp_jpg = dest.with_extension("tmp.jpg");
|
||||||
|
let status = Command::new("heif-convert")
|
||||||
|
.arg(source)
|
||||||
|
.arg(&temp_jpg)
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map_err(|e| {
|
||||||
|
PinakesError::MetadataExtraction(format!("heif-convert not found or failed: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
let _ = std::fs::remove_file(&temp_jpg);
|
||||||
|
return Err(PinakesError::MetadataExtraction(format!(
|
||||||
|
"heif-convert exited with status {}",
|
||||||
|
status
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if temp_jpg.exists() {
|
||||||
|
let result = image::open(&temp_jpg);
|
||||||
|
let _ = std::fs::remove_file(&temp_jpg);
|
||||||
|
let img =
|
||||||
|
result.map_err(|e| PinakesError::MetadataExtraction(format!("heic decode: {e}")))?;
|
||||||
|
let thumb = img.thumbnail(config.size, config.size);
|
||||||
|
let mut output = std::fs::File::create(dest)?;
|
||||||
|
let encoder =
|
||||||
|
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality);
|
||||||
|
thumb
|
||||||
|
.write_with_encoder(encoder)
|
||||||
|
.map_err(|e| PinakesError::MetadataExtraction(format!("heic thumbnail encode: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(PinakesError::MetadataExtraction(
|
||||||
|
"heif-convert did not produce output".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the default thumbnail directory under the data dir.
|
||||||
|
pub fn default_thumbnail_dir() -> PathBuf {
|
||||||
|
crate::config::Config::default_data_dir().join("thumbnails")
|
||||||
|
}
|
||||||
414
crates/pinakes-core/tests/integration_test.rs
Normal file
414
crates/pinakes-core/tests/integration_test.rs
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use pinakes_core::model::*;
|
||||||
|
use pinakes_core::storage::StorageBackend;
|
||||||
|
use pinakes_core::storage::sqlite::SqliteBackend;
|
||||||
|
|
||||||
|
async fn setup() -> Arc<SqliteBackend> {
|
||||||
|
let backend = SqliteBackend::in_memory().expect("in-memory SQLite");
|
||||||
|
backend.run_migrations().await.expect("migrations");
|
||||||
|
Arc::new(backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_media_crud() {
|
||||||
|
let storage = setup().await;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let id = MediaId::new();
|
||||||
|
let item = MediaItem {
|
||||||
|
id,
|
||||||
|
path: "/tmp/test.txt".into(),
|
||||||
|
file_name: "test.txt".to_string(),
|
||||||
|
media_type: pinakes_core::media_type::MediaType::PlainText,
|
||||||
|
content_hash: ContentHash::new("abc123".to_string()),
|
||||||
|
file_size: 100,
|
||||||
|
title: Some("Test Title".to_string()),
|
||||||
|
artist: None,
|
||||||
|
album: None,
|
||||||
|
genre: None,
|
||||||
|
year: Some(2024),
|
||||||
|
duration_secs: None,
|
||||||
|
description: Some("A test file".to_string()),
|
||||||
|
thumbnail_path: None,
|
||||||
|
custom_fields: HashMap::new(),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insert
|
||||||
|
storage.insert_media(&item).await.unwrap();
|
||||||
|
|
||||||
|
// Get
|
||||||
|
let fetched = storage.get_media(id).await.unwrap();
|
||||||
|
assert_eq!(fetched.id, id);
|
||||||
|
assert_eq!(fetched.title.as_deref(), Some("Test Title"));
|
||||||
|
assert_eq!(fetched.file_size, 100);
|
||||||
|
|
||||||
|
// Get by hash
|
||||||
|
let by_hash = storage
|
||||||
|
.get_media_by_hash(&ContentHash::new("abc123".into()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(by_hash.is_some());
|
||||||
|
assert_eq!(by_hash.unwrap().id, id);
|
||||||
|
|
||||||
|
// Update
|
||||||
|
let mut updated = fetched;
|
||||||
|
updated.title = Some("Updated Title".to_string());
|
||||||
|
storage.update_media(&updated).await.unwrap();
|
||||||
|
let re_fetched = storage.get_media(id).await.unwrap();
|
||||||
|
assert_eq!(re_fetched.title.as_deref(), Some("Updated Title"));
|
||||||
|
|
||||||
|
// List
|
||||||
|
let list = storage.list_media(&Pagination::default()).await.unwrap();
|
||||||
|
assert_eq!(list.len(), 1);
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
storage.delete_media(id).await.unwrap();
|
||||||
|
let result = storage.get_media(id).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_tags() {
|
||||||
|
let storage = setup().await;
|
||||||
|
|
||||||
|
// Create tags
|
||||||
|
let parent = storage.create_tag("Music", None).await.unwrap();
|
||||||
|
let child = storage.create_tag("Rock", Some(parent.id)).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parent.name, "Music");
|
||||||
|
assert_eq!(child.parent_id, Some(parent.id));
|
||||||
|
|
||||||
|
// List tags
|
||||||
|
let tags = storage.list_tags().await.unwrap();
|
||||||
|
assert_eq!(tags.len(), 2);
|
||||||
|
|
||||||
|
// Get descendants
|
||||||
|
let descendants = storage.get_tag_descendants(parent.id).await.unwrap();
|
||||||
|
assert!(descendants.iter().any(|t| t.name == "Rock"));
|
||||||
|
|
||||||
|
// Tag media
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let id = MediaId::new();
|
||||||
|
let item = MediaItem {
|
||||||
|
id,
|
||||||
|
path: "/tmp/song.mp3".into(),
|
||||||
|
file_name: "song.mp3".to_string(),
|
||||||
|
media_type: pinakes_core::media_type::MediaType::Mp3,
|
||||||
|
content_hash: ContentHash::new("hash1".to_string()),
|
||||||
|
file_size: 5000,
|
||||||
|
title: Some("Test Song".to_string()),
|
||||||
|
artist: Some("Test Artist".to_string()),
|
||||||
|
album: None,
|
||||||
|
genre: None,
|
||||||
|
year: None,
|
||||||
|
duration_secs: Some(180.0),
|
||||||
|
description: None,
|
||||||
|
thumbnail_path: None,
|
||||||
|
custom_fields: HashMap::new(),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
storage.insert_media(&item).await.unwrap();
|
||||||
|
storage.tag_media(id, parent.id).await.unwrap();
|
||||||
|
|
||||||
|
let media_tags = storage.get_media_tags(id).await.unwrap();
|
||||||
|
assert_eq!(media_tags.len(), 1);
|
||||||
|
assert_eq!(media_tags[0].name, "Music");
|
||||||
|
|
||||||
|
// Untag
|
||||||
|
storage.untag_media(id, parent.id).await.unwrap();
|
||||||
|
let media_tags = storage.get_media_tags(id).await.unwrap();
|
||||||
|
assert_eq!(media_tags.len(), 0);
|
||||||
|
|
||||||
|
// Delete tag
|
||||||
|
storage.delete_tag(child.id).await.unwrap();
|
||||||
|
let tags = storage.list_tags().await.unwrap();
|
||||||
|
assert_eq!(tags.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_collections() {
|
||||||
|
let storage = setup().await;
|
||||||
|
|
||||||
|
let col = storage
|
||||||
|
.create_collection("Favorites", CollectionKind::Manual, Some("My faves"), None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(col.name, "Favorites");
|
||||||
|
assert_eq!(col.kind, CollectionKind::Manual);
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let id = MediaId::new();
|
||||||
|
let item = MediaItem {
|
||||||
|
id,
|
||||||
|
path: "/tmp/doc.pdf".into(),
|
||||||
|
file_name: "doc.pdf".to_string(),
|
||||||
|
media_type: pinakes_core::media_type::MediaType::Pdf,
|
||||||
|
content_hash: ContentHash::new("pdfhash".to_string()),
|
||||||
|
file_size: 10000,
|
||||||
|
title: None,
|
||||||
|
artist: None,
|
||||||
|
album: None,
|
||||||
|
genre: None,
|
||||||
|
year: None,
|
||||||
|
duration_secs: None,
|
||||||
|
description: None,
|
||||||
|
thumbnail_path: None,
|
||||||
|
custom_fields: HashMap::new(),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
storage.insert_media(&item).await.unwrap();
|
||||||
|
|
||||||
|
storage.add_to_collection(col.id, id, 0).await.unwrap();
|
||||||
|
let members = storage.get_collection_members(col.id).await.unwrap();
|
||||||
|
assert_eq!(members.len(), 1);
|
||||||
|
assert_eq!(members[0].id, id);
|
||||||
|
|
||||||
|
storage.remove_from_collection(col.id, id).await.unwrap();
|
||||||
|
let members = storage.get_collection_members(col.id).await.unwrap();
|
||||||
|
assert_eq!(members.len(), 0);
|
||||||
|
|
||||||
|
// List collections
|
||||||
|
let cols = storage.list_collections().await.unwrap();
|
||||||
|
assert_eq!(cols.len(), 1);
|
||||||
|
|
||||||
|
storage.delete_collection(col.id).await.unwrap();
|
||||||
|
let cols = storage.list_collections().await.unwrap();
|
||||||
|
assert_eq!(cols.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_custom_fields() {
|
||||||
|
let storage = setup().await;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let id = MediaId::new();
|
||||||
|
let item = MediaItem {
|
||||||
|
id,
|
||||||
|
path: "/tmp/test.md".into(),
|
||||||
|
file_name: "test.md".to_string(),
|
||||||
|
media_type: pinakes_core::media_type::MediaType::Markdown,
|
||||||
|
content_hash: ContentHash::new("mdhash".to_string()),
|
||||||
|
file_size: 500,
|
||||||
|
title: None,
|
||||||
|
artist: None,
|
||||||
|
album: None,
|
||||||
|
genre: None,
|
||||||
|
year: None,
|
||||||
|
duration_secs: None,
|
||||||
|
description: None,
|
||||||
|
thumbnail_path: None,
|
||||||
|
custom_fields: HashMap::new(),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
storage.insert_media(&item).await.unwrap();
|
||||||
|
|
||||||
|
// Set custom field
|
||||||
|
let field = CustomField {
|
||||||
|
field_type: CustomFieldType::Text,
|
||||||
|
value: "important".to_string(),
|
||||||
|
};
|
||||||
|
storage
|
||||||
|
.set_custom_field(id, "priority", &field)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Get custom fields
|
||||||
|
let fields = storage.get_custom_fields(id).await.unwrap();
|
||||||
|
assert_eq!(fields.len(), 1);
|
||||||
|
assert_eq!(fields["priority"].value, "important");
|
||||||
|
|
||||||
|
// Verify custom fields are loaded with get_media
|
||||||
|
let media = storage.get_media(id).await.unwrap();
|
||||||
|
assert_eq!(media.custom_fields.len(), 1);
|
||||||
|
assert_eq!(media.custom_fields["priority"].value, "important");
|
||||||
|
|
||||||
|
// Delete custom field
|
||||||
|
storage.delete_custom_field(id, "priority").await.unwrap();
|
||||||
|
let fields = storage.get_custom_fields(id).await.unwrap();
|
||||||
|
assert_eq!(fields.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_search() {
|
||||||
|
let storage = setup().await;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
// Insert a few items
|
||||||
|
for (i, (name, title, artist)) in [
|
||||||
|
("song1.mp3", "Bohemian Rhapsody", "Queen"),
|
||||||
|
("song2.mp3", "Stairway to Heaven", "Led Zeppelin"),
|
||||||
|
("doc.pdf", "Rust Programming", ""),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let item = MediaItem {
|
||||||
|
id: MediaId::new(),
|
||||||
|
path: format!("/tmp/{name}").into(),
|
||||||
|
file_name: name.to_string(),
|
||||||
|
media_type: pinakes_core::media_type::MediaType::from_path(std::path::Path::new(name))
|
||||||
|
.unwrap(),
|
||||||
|
content_hash: ContentHash::new(format!("hash{i}")),
|
||||||
|
file_size: 1000 * (i as u64 + 1),
|
||||||
|
title: Some(title.to_string()),
|
||||||
|
artist: if artist.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(artist.to_string())
|
||||||
|
},
|
||||||
|
album: None,
|
||||||
|
genre: None,
|
||||||
|
year: None,
|
||||||
|
duration_secs: None,
|
||||||
|
description: None,
|
||||||
|
thumbnail_path: None,
|
||||||
|
custom_fields: HashMap::new(),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
storage.insert_media(&item).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-text search
|
||||||
|
let request = pinakes_core::search::SearchRequest {
|
||||||
|
query: pinakes_core::search::parse_search_query("Bohemian").unwrap(),
|
||||||
|
sort: pinakes_core::search::SortOrder::Relevance,
|
||||||
|
pagination: Pagination::new(0, 50, None),
|
||||||
|
};
|
||||||
|
let results = storage.search(&request).await.unwrap();
|
||||||
|
assert_eq!(results.total_count, 1);
|
||||||
|
assert_eq!(results.items[0].title.as_deref(), Some("Bohemian Rhapsody"));
|
||||||
|
|
||||||
|
// Type filter
|
||||||
|
let request = pinakes_core::search::SearchRequest {
|
||||||
|
query: pinakes_core::search::parse_search_query("type:pdf").unwrap(),
|
||||||
|
sort: pinakes_core::search::SortOrder::Relevance,
|
||||||
|
pagination: Pagination::new(0, 50, None),
|
||||||
|
};
|
||||||
|
let results = storage.search(&request).await.unwrap();
|
||||||
|
assert_eq!(results.total_count, 1);
|
||||||
|
assert_eq!(results.items[0].file_name, "doc.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_audit_log() {
|
||||||
|
let storage = setup().await;
|
||||||
|
|
||||||
|
let entry = AuditEntry {
|
||||||
|
id: uuid::Uuid::now_v7(),
|
||||||
|
media_id: None,
|
||||||
|
action: AuditAction::Scanned,
|
||||||
|
details: Some("test scan".to_string()),
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
};
|
||||||
|
storage.record_audit(&entry).await.unwrap();
|
||||||
|
|
||||||
|
let entries = storage
|
||||||
|
.list_audit_entries(None, &Pagination::new(0, 10, None))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(entries.len(), 1);
|
||||||
|
assert_eq!(entries[0].action, AuditAction::Scanned);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_import_with_dedup() {
|
||||||
|
let storage = setup().await as pinakes_core::storage::DynStorageBackend;
|
||||||
|
|
||||||
|
// Create a temp file
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let file_path = dir.path().join("test.txt");
|
||||||
|
std::fs::write(&file_path, "hello world").unwrap();
|
||||||
|
|
||||||
|
// First import
|
||||||
|
let result1 = pinakes_core::import::import_file(&storage, &file_path)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!result1.was_duplicate);
|
||||||
|
|
||||||
|
// Second import of same file
|
||||||
|
let result2 = pinakes_core::import::import_file(&storage, &file_path)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(result2.was_duplicate);
|
||||||
|
assert_eq!(result1.media_id, result2.media_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_root_dirs() {
|
||||||
|
let storage = setup().await;
|
||||||
|
|
||||||
|
storage.add_root_dir("/tmp/music".into()).await.unwrap();
|
||||||
|
storage.add_root_dir("/tmp/docs".into()).await.unwrap();
|
||||||
|
|
||||||
|
let dirs = storage.list_root_dirs().await.unwrap();
|
||||||
|
assert_eq!(dirs.len(), 2);
|
||||||
|
|
||||||
|
storage
|
||||||
|
.remove_root_dir(std::path::Path::new("/tmp/music"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let dirs = storage.list_root_dirs().await.unwrap();
|
||||||
|
assert_eq!(dirs.len(), 1);
|
||||||
|
assert_eq!(dirs[0], std::path::PathBuf::from("/tmp/docs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_library_statistics_empty() {
|
||||||
|
let storage = setup().await;
|
||||||
|
let stats = storage.library_statistics().await.unwrap();
|
||||||
|
assert_eq!(stats.total_media, 0);
|
||||||
|
assert_eq!(stats.total_size_bytes, 0);
|
||||||
|
assert_eq!(stats.avg_file_size_bytes, 0);
|
||||||
|
assert!(stats.media_by_type.is_empty());
|
||||||
|
assert!(stats.storage_by_type.is_empty());
|
||||||
|
assert!(stats.top_tags.is_empty());
|
||||||
|
assert!(stats.top_collections.is_empty());
|
||||||
|
assert!(stats.newest_item.is_none());
|
||||||
|
assert!(stats.oldest_item.is_none());
|
||||||
|
assert_eq!(stats.total_tags, 0);
|
||||||
|
assert_eq!(stats.total_collections, 0);
|
||||||
|
assert_eq!(stats.total_duplicates, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_library_statistics_with_data() {
|
||||||
|
let storage = setup().await;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let item = MediaItem {
|
||||||
|
id: MediaId::new(),
|
||||||
|
path: "/tmp/stats_test.mp3".into(),
|
||||||
|
file_name: "stats_test.mp3".to_string(),
|
||||||
|
media_type: pinakes_core::media_type::MediaType::Mp3,
|
||||||
|
content_hash: ContentHash::new("stats_hash".to_string()),
|
||||||
|
file_size: 5000,
|
||||||
|
title: Some("Stats Song".to_string()),
|
||||||
|
artist: None,
|
||||||
|
album: None,
|
||||||
|
genre: None,
|
||||||
|
year: None,
|
||||||
|
duration_secs: Some(120.0),
|
||||||
|
description: None,
|
||||||
|
thumbnail_path: None,
|
||||||
|
custom_fields: HashMap::new(),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
storage.insert_media(&item).await.unwrap();
|
||||||
|
|
||||||
|
let stats = storage.library_statistics().await.unwrap();
|
||||||
|
assert_eq!(stats.total_media, 1);
|
||||||
|
assert_eq!(stats.total_size_bytes, 5000);
|
||||||
|
assert_eq!(stats.avg_file_size_bytes, 5000);
|
||||||
|
assert!(!stats.media_by_type.is_empty());
|
||||||
|
assert!(stats.newest_item.is_some());
|
||||||
|
assert!(stats.oldest_item.is_some());
|
||||||
|
}
|
||||||
30
crates/pinakes-server/Cargo.toml
Normal file
30
crates/pinakes-server/Cargo.toml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
[package]
|
||||||
|
name = "pinakes-server"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pinakes-core = { path = "../pinakes-core" }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
axum = { workspace = true }
|
||||||
|
tower = { workspace = true }
|
||||||
|
tower-http = { workspace = true }
|
||||||
|
governor = { workspace = true }
|
||||||
|
tower_governor = { workspace = true }
|
||||||
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
|
argon2 = { workspace = true }
|
||||||
|
rand = "0.9"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
http-body-util = "0.1"
|
||||||
244
crates/pinakes-server/src/app.rs
Normal file
244
crates/pinakes-server/src/app.rs
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use axum::extract::DefaultBodyLimit;
|
||||||
|
use axum::http::{HeaderValue, Method, header};
|
||||||
|
use axum::middleware;
|
||||||
|
use axum::routing::{delete, get, patch, post, put};
|
||||||
|
use tower_governor::GovernorLayer;
|
||||||
|
use tower_governor::governor::GovernorConfigBuilder;
|
||||||
|
use tower_http::cors::CorsLayer;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
|
use crate::auth;
|
||||||
|
use crate::routes;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub fn create_router(state: AppState) -> Router {
|
||||||
|
// Global rate limit: 100 requests/sec per IP
|
||||||
|
let global_governor = Arc::new(
|
||||||
|
GovernorConfigBuilder::default()
|
||||||
|
.per_second(1)
|
||||||
|
.burst_size(100)
|
||||||
|
.finish()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Strict rate limit for login: 5 requests/min per IP
|
||||||
|
let login_governor = Arc::new(
|
||||||
|
GovernorConfigBuilder::default()
|
||||||
|
.per_second(12) // replenish one every 12 seconds
|
||||||
|
.burst_size(5)
|
||||||
|
.finish()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Login route with strict rate limiting
|
||||||
|
let login_route = Router::new()
|
||||||
|
.route("/auth/login", post(routes::auth::login))
|
||||||
|
.layer(GovernorLayer {
|
||||||
|
config: login_governor,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read-only routes: any authenticated user (Viewer+)
|
||||||
|
let viewer_routes = Router::new()
|
||||||
|
.route("/health", get(routes::health::health))
|
||||||
|
.route("/media/count", get(routes::media::get_media_count))
|
||||||
|
.route("/media", get(routes::media::list_media))
|
||||||
|
.route("/media/{id}", get(routes::media::get_media))
|
||||||
|
.route("/media/{id}/stream", get(routes::media::stream_media))
|
||||||
|
.route("/media/{id}/thumbnail", get(routes::media::get_thumbnail))
|
||||||
|
.route("/media/{media_id}/tags", get(routes::tags::get_media_tags))
|
||||||
|
.route("/search", get(routes::search::search))
|
||||||
|
.route("/search", post(routes::search::search_post))
|
||||||
|
.route("/tags", get(routes::tags::list_tags))
|
||||||
|
.route("/tags/{id}", get(routes::tags::get_tag))
|
||||||
|
.route("/collections", get(routes::collections::list_collections))
|
||||||
|
.route(
|
||||||
|
"/collections/{id}",
|
||||||
|
get(routes::collections::get_collection),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/collections/{id}/members",
|
||||||
|
get(routes::collections::get_members),
|
||||||
|
)
|
||||||
|
.route("/audit", get(routes::audit::list_audit))
|
||||||
|
.route("/scan/status", get(routes::scan::scan_status))
|
||||||
|
.route("/config", get(routes::config::get_config))
|
||||||
|
.route("/config/ui", get(routes::config::get_ui_config))
|
||||||
|
.route("/database/stats", get(routes::database::database_stats))
|
||||||
|
.route("/duplicates", get(routes::duplicates::list_duplicates))
|
||||||
|
// Statistics
|
||||||
|
.route("/statistics", get(routes::statistics::library_statistics))
|
||||||
|
// Scheduled tasks (read)
|
||||||
|
.route(
|
||||||
|
"/tasks/scheduled",
|
||||||
|
get(routes::scheduled_tasks::list_scheduled_tasks),
|
||||||
|
)
|
||||||
|
// Jobs
|
||||||
|
.route("/jobs", get(routes::jobs::list_jobs))
|
||||||
|
.route("/jobs/{id}", get(routes::jobs::get_job))
|
||||||
|
// Saved searches (read)
|
||||||
|
.route(
|
||||||
|
"/searches/saved",
|
||||||
|
get(routes::saved_searches::list_saved_searches),
|
||||||
|
)
|
||||||
|
// Webhooks (read)
|
||||||
|
.route("/webhooks", get(routes::webhooks::list_webhooks))
|
||||||
|
// Auth endpoints (self-service) — login handled separately with stricter rate limit
|
||||||
|
.route("/auth/logout", post(routes::auth::logout))
|
||||||
|
.route("/auth/me", get(routes::auth::me));
|
||||||
|
|
||||||
|
// Write routes: Editor+ required
|
||||||
|
let editor_routes = Router::new()
|
||||||
|
.route("/media/import", post(routes::media::import_media))
|
||||||
|
.route(
|
||||||
|
"/media/import/options",
|
||||||
|
post(routes::media::import_with_options),
|
||||||
|
)
|
||||||
|
.route("/media/import/batch", post(routes::media::batch_import))
|
||||||
|
.route(
|
||||||
|
"/media/import/directory",
|
||||||
|
post(routes::media::import_directory_endpoint),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/media/import/preview",
|
||||||
|
post(routes::media::preview_directory),
|
||||||
|
)
|
||||||
|
.route("/media/batch/tag", post(routes::media::batch_tag))
|
||||||
|
.route("/media/batch/delete", post(routes::media::batch_delete))
|
||||||
|
.route("/media/batch/update", patch(routes::media::batch_update))
|
||||||
|
.route(
|
||||||
|
"/media/batch/collection",
|
||||||
|
post(routes::media::batch_add_to_collection),
|
||||||
|
)
|
||||||
|
.route("/media/all", delete(routes::media::delete_all_media))
|
||||||
|
.route("/media/{id}", patch(routes::media::update_media))
|
||||||
|
.route("/media/{id}", delete(routes::media::delete_media))
|
||||||
|
.route("/media/{id}/open", post(routes::media::open_media))
|
||||||
|
.route(
|
||||||
|
"/media/{id}/custom-fields",
|
||||||
|
post(routes::media::set_custom_field),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/media/{id}/custom-fields/{name}",
|
||||||
|
delete(routes::media::delete_custom_field),
|
||||||
|
)
|
||||||
|
.route("/tags", post(routes::tags::create_tag))
|
||||||
|
.route("/tags/{id}", delete(routes::tags::delete_tag))
|
||||||
|
.route("/media/{media_id}/tags", post(routes::tags::tag_media))
|
||||||
|
.route(
|
||||||
|
"/media/{media_id}/tags/{tag_id}",
|
||||||
|
delete(routes::tags::untag_media),
|
||||||
|
)
|
||||||
|
.route("/collections", post(routes::collections::create_collection))
|
||||||
|
.route(
|
||||||
|
"/collections/{id}",
|
||||||
|
delete(routes::collections::delete_collection),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/collections/{id}/members",
|
||||||
|
post(routes::collections::add_member),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/collections/{collection_id}/members/{media_id}",
|
||||||
|
delete(routes::collections::remove_member),
|
||||||
|
)
|
||||||
|
.route("/scan", post(routes::scan::trigger_scan))
|
||||||
|
.route("/jobs/{id}/cancel", post(routes::jobs::cancel_job))
|
||||||
|
// Saved searches (write)
|
||||||
|
.route(
|
||||||
|
"/searches/saved",
|
||||||
|
post(routes::saved_searches::create_saved_search),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/searches/saved/{id}",
|
||||||
|
delete(routes::saved_searches::delete_saved_search),
|
||||||
|
)
|
||||||
|
// Integrity
|
||||||
|
.route(
|
||||||
|
"/jobs/orphan-detection",
|
||||||
|
post(routes::integrity::trigger_orphan_detection),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/jobs/verify-integrity",
|
||||||
|
post(routes::integrity::trigger_verify_integrity),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/jobs/cleanup-thumbnails",
|
||||||
|
post(routes::integrity::trigger_cleanup_thumbnails),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/jobs/generate-thumbnails",
|
||||||
|
post(routes::integrity::generate_all_thumbnails),
|
||||||
|
)
|
||||||
|
.route("/orphans/resolve", post(routes::integrity::resolve_orphans))
|
||||||
|
// Export
|
||||||
|
.route("/jobs/export", post(routes::export::trigger_export))
|
||||||
|
.route(
|
||||||
|
"/jobs/export/options",
|
||||||
|
post(routes::export::trigger_export_with_options),
|
||||||
|
)
|
||||||
|
// Scheduled tasks (write)
|
||||||
|
.route(
|
||||||
|
"/tasks/scheduled/{id}/toggle",
|
||||||
|
post(routes::scheduled_tasks::toggle_scheduled_task),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/tasks/scheduled/{id}/run-now",
|
||||||
|
post(routes::scheduled_tasks::run_scheduled_task_now),
|
||||||
|
)
|
||||||
|
// Webhooks
|
||||||
|
.route("/webhooks/test", post(routes::webhooks::test_webhook))
|
||||||
|
.layer(middleware::from_fn(auth::require_editor));
|
||||||
|
|
||||||
|
// Admin-only routes: destructive/config operations
|
||||||
|
let admin_routes = Router::new()
|
||||||
|
.route(
|
||||||
|
"/config/scanning",
|
||||||
|
put(routes::config::update_scanning_config),
|
||||||
|
)
|
||||||
|
.route("/config/roots", post(routes::config::add_root))
|
||||||
|
.route("/config/roots", delete(routes::config::remove_root))
|
||||||
|
.route("/config/ui", put(routes::config::update_ui_config))
|
||||||
|
.route("/database/vacuum", post(routes::database::vacuum_database))
|
||||||
|
.route("/database/clear", post(routes::database::clear_database))
|
||||||
|
.layer(middleware::from_fn(auth::require_admin));
|
||||||
|
|
||||||
|
let api = Router::new()
|
||||||
|
.merge(login_route)
|
||||||
|
.merge(viewer_routes)
|
||||||
|
.merge(editor_routes)
|
||||||
|
.merge(admin_routes);
|
||||||
|
|
||||||
|
// CORS: allow same-origin by default, plus the desktop UI origin
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_origin([
|
||||||
|
"http://localhost:3000".parse::<HeaderValue>().unwrap(),
|
||||||
|
"http://127.0.0.1:3000".parse::<HeaderValue>().unwrap(),
|
||||||
|
"tauri://localhost".parse::<HeaderValue>().unwrap(),
|
||||||
|
])
|
||||||
|
.allow_methods([
|
||||||
|
Method::GET,
|
||||||
|
Method::POST,
|
||||||
|
Method::PUT,
|
||||||
|
Method::PATCH,
|
||||||
|
Method::DELETE,
|
||||||
|
])
|
||||||
|
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
|
||||||
|
.allow_credentials(true);
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.nest("/api/v1", api)
|
||||||
|
.layer(DefaultBodyLimit::max(10 * 1024 * 1024))
|
||||||
|
.layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
auth::require_auth,
|
||||||
|
))
|
||||||
|
.layer(GovernorLayer {
|
||||||
|
config: global_governor,
|
||||||
|
})
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.layer(cors)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
164
crates/pinakes-server/src/auth.rs
Normal file
164
crates/pinakes-server/src/auth.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
use axum::extract::{Request, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::middleware::Next;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
|
||||||
|
use pinakes_core::config::UserRole;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Constant-time string comparison to prevent timing attacks on API keys.
|
||||||
|
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||||
|
if a.len() != b.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
a.as_bytes()
|
||||||
|
.iter()
|
||||||
|
.zip(b.as_bytes())
|
||||||
|
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
|
||||||
|
== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Axum middleware that checks for a valid Bearer token.
|
||||||
|
///
|
||||||
|
/// If `accounts.enabled == true`: look up bearer token in session store.
|
||||||
|
/// If `accounts.enabled == false`: use existing api_key logic (unchanged behavior).
|
||||||
|
/// Skips authentication for the `/health` and `/auth/login` path suffixes.
|
||||||
|
pub async fn require_auth(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
mut request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
let path = request.uri().path().to_string();
|
||||||
|
|
||||||
|
// Always allow health and login endpoints
|
||||||
|
if path.ends_with("/health") || path.ends_with("/auth/login") {
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = state.config.read().await;
|
||||||
|
|
||||||
|
if config.accounts.enabled {
|
||||||
|
// Session-based auth
|
||||||
|
let token = request
|
||||||
|
.headers()
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.strip_prefix("Bearer "))
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
drop(config);
|
||||||
|
|
||||||
|
let Some(token) = token else {
|
||||||
|
tracing::debug!(path = %path, "rejected: missing Authorization header");
|
||||||
|
return unauthorized("missing Authorization header");
|
||||||
|
};
|
||||||
|
|
||||||
|
let sessions = state.sessions.read().await;
|
||||||
|
let Some(session) = sessions.get(&token) else {
|
||||||
|
tracing::debug!(path = %path, "rejected: invalid session token");
|
||||||
|
return unauthorized("invalid or expired session token");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check session expiry
|
||||||
|
if session.is_expired() {
|
||||||
|
let username = session.username.clone();
|
||||||
|
drop(sessions);
|
||||||
|
// Remove expired session
|
||||||
|
let mut sessions_mut = state.sessions.write().await;
|
||||||
|
sessions_mut.remove(&token);
|
||||||
|
tracing::info!(username = %username, "session expired");
|
||||||
|
return unauthorized("session expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject role and username into request extensions
|
||||||
|
request.extensions_mut().insert(session.role);
|
||||||
|
request.extensions_mut().insert(session.username.clone());
|
||||||
|
} else {
|
||||||
|
// Legacy API key auth
|
||||||
|
let api_key = std::env::var("PINAKES_API_KEY")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| config.server.api_key.clone());
|
||||||
|
drop(config);
|
||||||
|
|
||||||
|
if let Some(ref expected_key) = api_key {
|
||||||
|
if expected_key.is_empty() {
|
||||||
|
// Empty key means no auth required
|
||||||
|
request.extensions_mut().insert(UserRole::Admin);
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth_header = request
|
||||||
|
.headers()
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|v| v.to_str().ok());
|
||||||
|
|
||||||
|
match auth_header {
|
||||||
|
Some(header) if header.starts_with("Bearer ") => {
|
||||||
|
let token = &header[7..];
|
||||||
|
if !constant_time_eq(token, expected_key.as_str()) {
|
||||||
|
tracing::warn!(path = %path, "rejected: invalid API key");
|
||||||
|
return unauthorized("invalid api key");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return unauthorized(
|
||||||
|
"missing or malformed Authorization header, expected: Bearer <api_key>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// When no api_key is configured, or key matches, grant admin
|
||||||
|
request.extensions_mut().insert(UserRole::Admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
next.run(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Middleware: requires Editor or Admin role.
|
||||||
|
pub async fn require_editor(request: Request, next: Next) -> Response {
|
||||||
|
let role = request
|
||||||
|
.extensions()
|
||||||
|
.get::<UserRole>()
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(UserRole::Viewer);
|
||||||
|
if role.can_write() {
|
||||||
|
next.run(request).await
|
||||||
|
} else {
|
||||||
|
forbidden("editor role required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Middleware: requires Admin role.
|
||||||
|
pub async fn require_admin(request: Request, next: Next) -> Response {
|
||||||
|
let role = request
|
||||||
|
.extensions()
|
||||||
|
.get::<UserRole>()
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(UserRole::Viewer);
|
||||||
|
if role.can_admin() {
|
||||||
|
next.run(request).await
|
||||||
|
} else {
|
||||||
|
forbidden("admin role required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unauthorized(message: &str) -> Response {
|
||||||
|
let body = format!(r#"{{"error":"{message}"}}"#);
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
[("content-type", "application/json")],
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forbidden(message: &str) -> Response {
|
||||||
|
let body = format!(r#"{{"error":"{message}"}}"#);
|
||||||
|
(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
[("content-type", "application/json")],
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
553
crates/pinakes-server/src/dto.rs
Normal file
553
crates/pinakes-server/src/dto.rs
Normal file
|
|
@ -0,0 +1,553 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// Media
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MediaResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub path: String,
|
||||||
|
pub file_name: String,
|
||||||
|
pub media_type: String,
|
||||||
|
pub content_hash: String,
|
||||||
|
pub file_size: u64,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub genre: Option<String>,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub duration_secs: Option<f64>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub has_thumbnail: bool,
|
||||||
|
pub custom_fields: HashMap<String, CustomFieldResponse>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CustomFieldResponse {
|
||||||
|
pub field_type: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ImportRequest {
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ImportResponse {
|
||||||
|
pub media_id: String,
|
||||||
|
pub was_duplicate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateMediaRequest {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub genre: Option<String>,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct TagResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub parent_id: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateTagRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct TagMediaRequest {
|
||||||
|
pub tag_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collections
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CollectionResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub kind: String,
|
||||||
|
pub filter_query: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateCollectionRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub filter_query: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AddMemberRequest {
|
||||||
|
pub media_id: Uuid,
|
||||||
|
pub position: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SearchParams {
|
||||||
|
pub q: String,
|
||||||
|
pub sort: Option<String>,
|
||||||
|
pub offset: Option<u64>,
|
||||||
|
pub limit: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SearchResponse {
|
||||||
|
pub items: Vec<MediaResponse>,
|
||||||
|
pub total_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AuditEntryResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub media_id: Option<String>,
|
||||||
|
pub action: String,
|
||||||
|
pub details: Option<String>,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search (POST body)
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SearchRequestBody {
|
||||||
|
pub q: String,
|
||||||
|
pub sort: Option<String>,
|
||||||
|
pub offset: Option<u64>,
|
||||||
|
pub limit: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ScanRequest {
|
||||||
|
pub path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ScanResponse {
|
||||||
|
pub files_found: usize,
|
||||||
|
pub files_processed: usize,
|
||||||
|
pub errors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ScanJobResponse {
|
||||||
|
pub job_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ScanStatusResponse {
|
||||||
|
pub scanning: bool,
|
||||||
|
pub files_found: usize,
|
||||||
|
pub files_processed: usize,
|
||||||
|
pub error_count: usize,
|
||||||
|
pub errors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PaginationParams {
|
||||||
|
pub offset: Option<u64>,
|
||||||
|
pub limit: Option<u64>,
|
||||||
|
pub sort: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct OpenRequest {
|
||||||
|
pub media_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ConfigResponse {
|
||||||
|
pub backend: String,
|
||||||
|
pub database_path: Option<String>,
|
||||||
|
pub roots: Vec<String>,
|
||||||
|
pub scanning: ScanningConfigResponse,
|
||||||
|
pub server: ServerConfigResponse,
|
||||||
|
pub ui: UiConfigResponse,
|
||||||
|
pub config_path: Option<String>,
|
||||||
|
pub config_writable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ScanningConfigResponse {
|
||||||
|
pub watch: bool,
|
||||||
|
pub poll_interval_secs: u64,
|
||||||
|
pub ignore_patterns: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ServerConfigResponse {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateScanningRequest {
|
||||||
|
pub watch: Option<bool>,
|
||||||
|
pub poll_interval_secs: Option<u64>,
|
||||||
|
pub ignore_patterns: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RootDirRequest {
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced Import
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ImportWithOptionsRequest {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub tag_ids: Option<Vec<Uuid>>,
|
||||||
|
pub new_tags: Option<Vec<String>>,
|
||||||
|
pub collection_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct BatchImportRequest {
|
||||||
|
pub paths: Vec<PathBuf>,
|
||||||
|
pub tag_ids: Option<Vec<Uuid>>,
|
||||||
|
pub new_tags: Option<Vec<String>>,
|
||||||
|
pub collection_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct BatchImportResponse {
|
||||||
|
pub results: Vec<BatchImportItemResult>,
|
||||||
|
pub total: usize,
|
||||||
|
pub imported: usize,
|
||||||
|
pub duplicates: usize,
|
||||||
|
pub errors: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct BatchImportItemResult {
|
||||||
|
pub path: String,
|
||||||
|
pub media_id: Option<String>,
|
||||||
|
pub was_duplicate: bool,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DirectoryImportRequest {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub tag_ids: Option<Vec<Uuid>>,
|
||||||
|
pub new_tags: Option<Vec<String>>,
|
||||||
|
pub collection_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DirectoryPreviewResponse {
|
||||||
|
pub files: Vec<DirectoryPreviewFile>,
|
||||||
|
pub total_count: usize,
|
||||||
|
pub total_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DirectoryPreviewFile {
|
||||||
|
pub path: String,
|
||||||
|
pub file_name: String,
|
||||||
|
pub media_type: String,
|
||||||
|
pub file_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom Fields
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SetCustomFieldRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub field_type: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media update extended
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateMediaFullRequest {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub genre: Option<String>,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch operations
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct BatchTagRequest {
|
||||||
|
pub media_ids: Vec<Uuid>,
|
||||||
|
pub tag_ids: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct BatchCollectionRequest {
|
||||||
|
pub media_ids: Vec<Uuid>,
|
||||||
|
pub collection_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct BatchDeleteRequest {
|
||||||
|
pub media_ids: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct BatchUpdateRequest {
|
||||||
|
pub media_ids: Vec<Uuid>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub genre: Option<String>,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct BatchOperationResponse {
|
||||||
|
pub processed: usize,
|
||||||
|
pub errors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search with sort
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MediaCountResponse {
|
||||||
|
pub count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database management
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DatabaseStatsResponse {
|
||||||
|
pub media_count: u64,
|
||||||
|
pub tag_count: u64,
|
||||||
|
pub collection_count: u64,
|
||||||
|
pub audit_count: u64,
|
||||||
|
pub database_size_bytes: u64,
|
||||||
|
pub backend_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI Config
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct UiConfigResponse {
|
||||||
|
pub theme: String,
|
||||||
|
pub default_view: String,
|
||||||
|
pub default_page_size: usize,
|
||||||
|
pub default_view_mode: String,
|
||||||
|
pub auto_play_media: bool,
|
||||||
|
pub show_thumbnails: bool,
|
||||||
|
pub sidebar_collapsed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateUiConfigRequest {
|
||||||
|
pub theme: Option<String>,
|
||||||
|
pub default_view: Option<String>,
|
||||||
|
pub default_page_size: Option<usize>,
|
||||||
|
pub default_view_mode: Option<String>,
|
||||||
|
pub auto_play_media: Option<bool>,
|
||||||
|
pub show_thumbnails: Option<bool>,
|
||||||
|
pub sidebar_collapsed: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&pinakes_core::config::UiConfig> for UiConfigResponse {
|
||||||
|
fn from(ui: &pinakes_core::config::UiConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
theme: ui.theme.clone(),
|
||||||
|
default_view: ui.default_view.clone(),
|
||||||
|
default_page_size: ui.default_page_size,
|
||||||
|
default_view_mode: ui.default_view_mode.clone(),
|
||||||
|
auto_play_media: ui.auto_play_media,
|
||||||
|
show_thumbnails: ui.show_thumbnails,
|
||||||
|
sidebar_collapsed: ui.sidebar_collapsed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Library Statistics
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LibraryStatisticsResponse {
|
||||||
|
pub total_media: u64,
|
||||||
|
pub total_size_bytes: u64,
|
||||||
|
pub avg_file_size_bytes: u64,
|
||||||
|
pub media_by_type: Vec<TypeCountResponse>,
|
||||||
|
pub storage_by_type: Vec<TypeCountResponse>,
|
||||||
|
pub newest_item: Option<String>,
|
||||||
|
pub oldest_item: Option<String>,
|
||||||
|
pub top_tags: Vec<TypeCountResponse>,
|
||||||
|
pub top_collections: Vec<TypeCountResponse>,
|
||||||
|
pub total_tags: u64,
|
||||||
|
pub total_collections: u64,
|
||||||
|
pub total_duplicates: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct TypeCountResponse {
|
||||||
|
pub name: String,
|
||||||
|
pub count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<pinakes_core::storage::LibraryStatistics> for LibraryStatisticsResponse {
|
||||||
|
fn from(stats: pinakes_core::storage::LibraryStatistics) -> Self {
|
||||||
|
Self {
|
||||||
|
total_media: stats.total_media,
|
||||||
|
total_size_bytes: stats.total_size_bytes,
|
||||||
|
avg_file_size_bytes: stats.avg_file_size_bytes,
|
||||||
|
media_by_type: stats
|
||||||
|
.media_by_type
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, count)| TypeCountResponse { name, count })
|
||||||
|
.collect(),
|
||||||
|
storage_by_type: stats
|
||||||
|
.storage_by_type
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, count)| TypeCountResponse { name, count })
|
||||||
|
.collect(),
|
||||||
|
newest_item: stats.newest_item,
|
||||||
|
oldest_item: stats.oldest_item,
|
||||||
|
top_tags: stats
|
||||||
|
.top_tags
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, count)| TypeCountResponse { name, count })
|
||||||
|
.collect(),
|
||||||
|
top_collections: stats
|
||||||
|
.top_collections
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, count)| TypeCountResponse { name, count })
|
||||||
|
.collect(),
|
||||||
|
total_tags: stats.total_tags,
|
||||||
|
total_collections: stats.total_collections,
|
||||||
|
total_duplicates: stats.total_duplicates,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scheduled Tasks
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ScheduledTaskResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub schedule: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub last_run: Option<String>,
|
||||||
|
pub next_run: Option<String>,
|
||||||
|
pub last_status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicates
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DuplicateGroupResponse {
|
||||||
|
pub content_hash: String,
|
||||||
|
pub items: Vec<MediaResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub username: String,
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct UserInfoResponse {
|
||||||
|
pub username: String,
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversion helpers
|
||||||
|
impl From<pinakes_core::model::MediaItem> for MediaResponse {
|
||||||
|
fn from(item: pinakes_core::model::MediaItem) -> Self {
|
||||||
|
Self {
|
||||||
|
id: item.id.0.to_string(),
|
||||||
|
path: item.path.to_string_lossy().to_string(),
|
||||||
|
file_name: item.file_name,
|
||||||
|
media_type: serde_json::to_value(item.media_type)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(String::from))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
content_hash: item.content_hash.0,
|
||||||
|
file_size: item.file_size,
|
||||||
|
title: item.title,
|
||||||
|
artist: item.artist,
|
||||||
|
album: item.album,
|
||||||
|
genre: item.genre,
|
||||||
|
year: item.year,
|
||||||
|
duration_secs: item.duration_secs,
|
||||||
|
description: item.description,
|
||||||
|
has_thumbnail: item.thumbnail_path.is_some(),
|
||||||
|
custom_fields: item
|
||||||
|
.custom_fields
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| {
|
||||||
|
(
|
||||||
|
k,
|
||||||
|
CustomFieldResponse {
|
||||||
|
field_type: format!("{:?}", v.field_type).to_lowercase(),
|
||||||
|
value: v.value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
created_at: item.created_at,
|
||||||
|
updated_at: item.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<pinakes_core::model::Tag> for TagResponse {
|
||||||
|
fn from(tag: pinakes_core::model::Tag) -> Self {
|
||||||
|
Self {
|
||||||
|
id: tag.id.to_string(),
|
||||||
|
name: tag.name,
|
||||||
|
parent_id: tag.parent_id.map(|id| id.to_string()),
|
||||||
|
created_at: tag.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<pinakes_core::model::Collection> for CollectionResponse {
|
||||||
|
fn from(col: pinakes_core::model::Collection) -> Self {
|
||||||
|
Self {
|
||||||
|
id: col.id.to_string(),
|
||||||
|
name: col.name,
|
||||||
|
description: col.description,
|
||||||
|
kind: format!("{:?}", col.kind).to_lowercase(),
|
||||||
|
filter_query: col.filter_query,
|
||||||
|
created_at: col.created_at,
|
||||||
|
updated_at: col.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<pinakes_core::model::AuditEntry> for AuditEntryResponse {
|
||||||
|
fn from(entry: pinakes_core::model::AuditEntry) -> Self {
|
||||||
|
Self {
|
||||||
|
id: entry.id.to_string(),
|
||||||
|
media_id: entry.media_id.map(|id| id.0.to_string()),
|
||||||
|
action: entry.action.to_string(),
|
||||||
|
details: entry.details,
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
crates/pinakes-server/src/error.rs
Normal file
69
crates/pinakes-server/src/error.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ErrorResponse {
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ApiError(pub pinakes_core::error::PinakesError);
|
||||||
|
|
||||||
|
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::FileNotFound(path) => {
|
||||||
|
// Only expose the file name, not the full path
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
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(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("unsupported media type: {name}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PinakesError::SearchParse(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||||
|
PinakesError::InvalidOperation(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||||
|
PinakesError::Config(_) => {
|
||||||
|
tracing::error!(error = %self.0, "configuration error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal configuration error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tracing::error!(error = %self.0, "internal server error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal server error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = serde_json::to_string(&ErrorResponse {
|
||||||
|
error: message.clone(),
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|_| format!(r#"{{"error":"{}"}}"#, message));
|
||||||
|
(status, [("content-type", "application/json")], body).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<pinakes_core::error::PinakesError> for ApiError {
|
||||||
|
fn from(e: pinakes_core::error::PinakesError) -> Self {
|
||||||
|
Self(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
crates/pinakes-server/src/lib.rs
Normal file
6
crates/pinakes-server/src/lib.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
pub mod app;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod dto;
|
||||||
|
pub mod error;
|
||||||
|
pub mod routes;
|
||||||
|
pub mod state;
|
||||||
448
crates/pinakes-server/src/main.rs
Normal file
448
crates/pinakes-server/src/main.rs
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tracing::info;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use pinakes_core::config::Config;
|
||||||
|
use pinakes_core::storage::StorageBackend;
|
||||||
|
|
||||||
|
use pinakes_server::app;
|
||||||
|
use pinakes_server::state::AppState;
|
||||||
|
|
||||||
|
/// Pinakes media cataloging server
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "pinakes-server", version, about)]
|
||||||
|
struct Cli {
|
||||||
|
/// Path to configuration file
|
||||||
|
#[arg(short, long, env = "PINAKES_CONFIG")]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Override listen host
|
||||||
|
#[arg(long)]
|
||||||
|
host: Option<String>,
|
||||||
|
|
||||||
|
/// Override listen port
|
||||||
|
#[arg(short, long)]
|
||||||
|
port: Option<u16>,
|
||||||
|
|
||||||
|
/// Set log level (trace, debug, info, warn, error)
|
||||||
|
#[arg(long, default_value = "info")]
|
||||||
|
log_level: String,
|
||||||
|
|
||||||
|
/// Log output format (compact, full, pretty, json)
|
||||||
|
#[arg(long, default_value = "compact")]
|
||||||
|
log_format: String,
|
||||||
|
|
||||||
|
/// Run database migrations only, then exit
|
||||||
|
#[arg(long)]
|
||||||
|
migrate_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_config_path(explicit: Option<&std::path::Path>) -> PathBuf {
|
||||||
|
if let Some(path) = explicit {
|
||||||
|
return path.to_path_buf();
|
||||||
|
}
|
||||||
|
// Check current directory
|
||||||
|
let local = PathBuf::from("pinakes.toml");
|
||||||
|
if local.exists() {
|
||||||
|
return local;
|
||||||
|
}
|
||||||
|
// XDG default
|
||||||
|
Config::default_config_path()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
// Initialize logging
|
||||||
|
let env_filter = EnvFilter::try_new(&cli.log_level).unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
|
||||||
|
match cli.log_format.as_str() {
|
||||||
|
"json" => {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(env_filter)
|
||||||
|
.json()
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
"pretty" => {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(env_filter)
|
||||||
|
.pretty()
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
"full" => {
|
||||||
|
tracing_subscriber::fmt().with_env_filter(env_filter).init();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(env_filter)
|
||||||
|
.compact()
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_path = resolve_config_path(cli.config.as_deref());
|
||||||
|
info!(path = %config_path.display(), "loading configuration");
|
||||||
|
|
||||||
|
let mut config = Config::load_or_default(&config_path)?;
|
||||||
|
config.ensure_dirs()?;
|
||||||
|
config
|
||||||
|
.validate()
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid configuration: {e}"))?;
|
||||||
|
|
||||||
|
// Apply CLI overrides
|
||||||
|
if let Some(host) = cli.host {
|
||||||
|
config.server.host = host;
|
||||||
|
}
|
||||||
|
if let Some(port) = cli.port {
|
||||||
|
config.server.port = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage backend initialization
|
||||||
|
let storage: pinakes_core::storage::DynStorageBackend = match config.storage.backend {
|
||||||
|
pinakes_core::config::StorageBackendType::Sqlite => {
|
||||||
|
let sqlite_config = config.storage.sqlite.as_ref().ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"sqlite storage selected but [storage.sqlite] config section missing"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
info!(path = %sqlite_config.path.display(), "initializing sqlite storage");
|
||||||
|
let backend = pinakes_core::storage::sqlite::SqliteBackend::new(&sqlite_config.path)?;
|
||||||
|
backend.run_migrations().await?;
|
||||||
|
Arc::new(backend)
|
||||||
|
}
|
||||||
|
pinakes_core::config::StorageBackendType::Postgres => {
|
||||||
|
let pg_config = config.storage.postgres.as_ref().ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"postgres storage selected but [storage.postgres] config section missing"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
info!(host = %pg_config.host, port = pg_config.port, database = %pg_config.database, "initializing postgres storage");
|
||||||
|
let backend = pinakes_core::storage::postgres::PostgresBackend::new(pg_config).await?;
|
||||||
|
backend.run_migrations().await?;
|
||||||
|
Arc::new(backend)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if cli.migrate_only {
|
||||||
|
info!("migrations complete, exiting");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register root directories
|
||||||
|
for root in &config.directories.roots {
|
||||||
|
if root.exists() {
|
||||||
|
storage.add_root_dir(root.clone()).await?;
|
||||||
|
info!(path = %root.display(), "registered root directory");
|
||||||
|
} else {
|
||||||
|
tracing::warn!(path = %root.display(), "root directory does not exist, skipping");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start filesystem watcher if configured
|
||||||
|
if config.scanning.watch {
|
||||||
|
let watch_storage = storage.clone();
|
||||||
|
let watch_dirs = config.directories.roots.clone();
|
||||||
|
let watch_ignore = config.scanning.ignore_patterns.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) =
|
||||||
|
pinakes_core::scan::watch_and_import(watch_storage, watch_dirs, watch_ignore).await
|
||||||
|
{
|
||||||
|
tracing::error!(error = %e, "filesystem watcher failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
info!("filesystem watcher started");
|
||||||
|
}
|
||||||
|
|
||||||
|
let addr = format!("{}:{}", config.server.host, config.server.port);
|
||||||
|
|
||||||
|
// Initialize job queue with executor
|
||||||
|
let job_storage = storage.clone();
|
||||||
|
let job_config = config.clone();
|
||||||
|
let job_queue = pinakes_core::jobs::JobQueue::new(
|
||||||
|
config.jobs.worker_count,
|
||||||
|
move |job_id, kind, cancel, jobs| {
|
||||||
|
let storage = job_storage.clone();
|
||||||
|
let config = job_config.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
use pinakes_core::jobs::{JobKind, JobQueue};
|
||||||
|
let result = match kind {
|
||||||
|
JobKind::Scan { path } => {
|
||||||
|
let ignore = config.scanning.ignore_patterns.clone();
|
||||||
|
let res = if let Some(p) = path {
|
||||||
|
pinakes_core::scan::scan_directory(&storage, &p, &ignore).await
|
||||||
|
} else {
|
||||||
|
pinakes_core::scan::scan_all_roots(&storage, &ignore)
|
||||||
|
.await
|
||||||
|
.map(|statuses| {
|
||||||
|
let total_found: usize =
|
||||||
|
statuses.iter().map(|s| s.files_found).sum();
|
||||||
|
let total_processed: usize =
|
||||||
|
statuses.iter().map(|s| s.files_processed).sum();
|
||||||
|
let all_errors: Vec<String> =
|
||||||
|
statuses.into_iter().flat_map(|s| s.errors).collect();
|
||||||
|
pinakes_core::scan::ScanStatus {
|
||||||
|
scanning: false,
|
||||||
|
files_found: total_found,
|
||||||
|
files_processed: total_processed,
|
||||||
|
errors: all_errors,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
match res {
|
||||||
|
Ok(status) => {
|
||||||
|
JobQueue::complete(
|
||||||
|
&jobs,
|
||||||
|
job_id,
|
||||||
|
serde_json::json!({
|
||||||
|
"files_found": status.files_found,
|
||||||
|
"files_processed": status.files_processed,
|
||||||
|
"errors": status.errors,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
JobQueue::fail(&jobs, job_id, e.to_string()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JobKind::GenerateThumbnails { media_ids } => {
|
||||||
|
let thumb_dir = pinakes_core::thumbnail::default_thumbnail_dir();
|
||||||
|
let thumb_config = config.thumbnails.clone();
|
||||||
|
let total = media_ids.len();
|
||||||
|
let mut generated = 0usize;
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
for (i, mid) in media_ids.iter().enumerate() {
|
||||||
|
if cancel.is_cancelled() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
JobQueue::update_progress(
|
||||||
|
&jobs,
|
||||||
|
job_id,
|
||||||
|
i as f32 / total as f32,
|
||||||
|
format!("{}/{}", i, total),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match storage.get_media(*mid).await {
|
||||||
|
Ok(item) => {
|
||||||
|
let source = item.path.clone();
|
||||||
|
let mt = item.media_type;
|
||||||
|
let id = item.id;
|
||||||
|
let td = thumb_dir.clone();
|
||||||
|
let tc = thumb_config.clone();
|
||||||
|
let res = tokio::task::spawn_blocking(move || {
|
||||||
|
pinakes_core::thumbnail::generate_thumbnail_with_config(
|
||||||
|
id, &source, mt, &td, &tc,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match res {
|
||||||
|
Ok(Ok(Some(path))) => {
|
||||||
|
let mut updated = item;
|
||||||
|
updated.thumbnail_path = Some(path);
|
||||||
|
let _ = storage.update_media(&updated).await;
|
||||||
|
generated += 1;
|
||||||
|
}
|
||||||
|
Ok(Ok(None)) => {}
|
||||||
|
Ok(Err(e)) => errors.push(format!("{}: {}", mid, e)),
|
||||||
|
Err(e) => errors.push(format!("{}: {}", mid, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => errors.push(format!("{}: {}", mid, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JobQueue::complete(
|
||||||
|
&jobs,
|
||||||
|
job_id,
|
||||||
|
serde_json::json!({
|
||||||
|
"generated": generated, "errors": errors
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
JobKind::VerifyIntegrity { media_ids } => {
|
||||||
|
let ids = if media_ids.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(media_ids.as_slice())
|
||||||
|
};
|
||||||
|
match pinakes_core::integrity::verify_integrity(&storage, ids).await {
|
||||||
|
Ok(report) => {
|
||||||
|
JobQueue::complete(
|
||||||
|
&jobs,
|
||||||
|
job_id,
|
||||||
|
serde_json::to_value(&report).unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JobKind::OrphanDetection => {
|
||||||
|
match pinakes_core::integrity::detect_orphans(&storage).await {
|
||||||
|
Ok(report) => {
|
||||||
|
JobQueue::complete(
|
||||||
|
&jobs,
|
||||||
|
job_id,
|
||||||
|
serde_json::to_value(&report).unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JobKind::CleanupThumbnails => {
|
||||||
|
let thumb_dir = pinakes_core::thumbnail::default_thumbnail_dir();
|
||||||
|
match pinakes_core::integrity::cleanup_orphaned_thumbnails(
|
||||||
|
&storage, &thumb_dir,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(removed) => {
|
||||||
|
JobQueue::complete(
|
||||||
|
&jobs,
|
||||||
|
job_id,
|
||||||
|
serde_json::json!({ "removed": removed }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JobKind::Export {
|
||||||
|
format,
|
||||||
|
destination,
|
||||||
|
} => {
|
||||||
|
match pinakes_core::export::export_library(&storage, &format, &destination)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => {
|
||||||
|
JobQueue::complete(
|
||||||
|
&jobs,
|
||||||
|
job_id,
|
||||||
|
serde_json::to_value(&result).unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = result;
|
||||||
|
drop(cancel);
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize cache layer
|
||||||
|
let cache = std::sync::Arc::new(pinakes_core::cache::CacheLayer::new(
|
||||||
|
config.jobs.cache_ttl_secs,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Initialize scheduler with cancellation support
|
||||||
|
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(),
|
||||||
|
shutdown_token.clone(),
|
||||||
|
config_arc.clone(),
|
||||||
|
Some(config_path.clone()),
|
||||||
|
);
|
||||||
|
let scheduler = Arc::new(scheduler);
|
||||||
|
|
||||||
|
// Restore saved scheduler state from config
|
||||||
|
scheduler.restore_state().await;
|
||||||
|
|
||||||
|
// Spawn scheduler background loop
|
||||||
|
{
|
||||||
|
let scheduler = scheduler.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
scheduler.run().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
storage: storage.clone(),
|
||||||
|
config: config_arc,
|
||||||
|
config_path: Some(config_path),
|
||||||
|
scan_progress: pinakes_core::scan::ScanProgress::new(),
|
||||||
|
sessions: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||||
|
job_queue,
|
||||||
|
cache,
|
||||||
|
scheduler,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Periodic session cleanup (every 15 minutes)
|
||||||
|
{
|
||||||
|
let sessions = state.sessions.clone();
|
||||||
|
let cancel = shutdown_token.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(15 * 60));
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = interval.tick() => {
|
||||||
|
pinakes_server::state::cleanup_expired_sessions(&sessions).await;
|
||||||
|
}
|
||||||
|
_ = cancel.cancelled() => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let router = app::create_router(state);
|
||||||
|
|
||||||
|
info!(addr = %addr, "server listening");
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
|
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||||
|
)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
shutdown_token.cancel();
|
||||||
|
info!("server shut down");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
match tokio::signal::ctrl_c().await {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "failed to install Ctrl+C handler");
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let terminate = async {
|
||||||
|
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||||
|
Ok(mut signal) => {
|
||||||
|
signal.recv().await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "failed to install SIGTERM handler");
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let terminate = std::future::pending::<()>();
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => info!("received Ctrl+C, shutting down"),
|
||||||
|
_ = terminate => info!("received SIGTERM, shutting down"),
|
||||||
|
}
|
||||||
|
}
|
||||||
23
crates/pinakes-server/src/routes/audit.rs
Normal file
23
crates/pinakes-server/src/routes/audit.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Query, State};
|
||||||
|
|
||||||
|
use crate::dto::*;
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use pinakes_core::model::Pagination;
|
||||||
|
|
||||||
|
pub async fn list_audit(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<PaginationParams>,
|
||||||
|
) -> Result<Json<Vec<AuditEntryResponse>>, ApiError> {
|
||||||
|
let pagination = Pagination::new(
|
||||||
|
params.offset.unwrap_or(0),
|
||||||
|
params.limit.unwrap_or(50).min(1000),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let entries = state.storage.list_audit_entries(None, &pagination).await?;
|
||||||
|
Ok(Json(
|
||||||
|
entries.into_iter().map(AuditEntryResponse::from).collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
119
crates/pinakes-server/src/routes/auth.rs
Normal file
119
crates/pinakes-server/src/routes/auth.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
|
||||||
|
use crate::dto::{LoginRequest, LoginResponse, UserInfoResponse};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub async fn login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<LoginRequest>,
|
||||||
|
) -> Result<Json<LoginResponse>, StatusCode> {
|
||||||
|
// Limit input sizes to prevent DoS
|
||||||
|
if req.username.len() > 255 || req.password.len() > 1024 {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = state.config.read().await;
|
||||||
|
if !config.accounts.enabled {
|
||||||
|
return Err(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = config
|
||||||
|
.accounts
|
||||||
|
.users
|
||||||
|
.iter()
|
||||||
|
.find(|u| u.username == req.username);
|
||||||
|
|
||||||
|
let user = match user {
|
||||||
|
Some(u) => u,
|
||||||
|
None => {
|
||||||
|
tracing::warn!(username = %req.username, "login failed: unknown user");
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify password using argon2
|
||||||
|
use argon2::password_hash::PasswordVerifier;
|
||||||
|
let hash = &user.password_hash;
|
||||||
|
let parsed_hash = argon2::password_hash::PasswordHash::new(hash)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let valid = argon2::Argon2::default()
|
||||||
|
.verify_password(req.password.as_bytes(), &parsed_hash)
|
||||||
|
.is_ok();
|
||||||
|
if !valid {
|
||||||
|
tracing::warn!(username = %req.username, "login failed: invalid password");
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate session token
|
||||||
|
use rand::Rng;
|
||||||
|
let token: String = rand::rng()
|
||||||
|
.sample_iter(&rand::distr::Alphanumeric)
|
||||||
|
.take(48)
|
||||||
|
.map(char::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let role = user.role;
|
||||||
|
let username = user.username.clone();
|
||||||
|
|
||||||
|
// Store session
|
||||||
|
{
|
||||||
|
let mut sessions = state.sessions.write().await;
|
||||||
|
sessions.insert(
|
||||||
|
token.clone(),
|
||||||
|
crate::state::SessionInfo {
|
||||||
|
username: username.clone(),
|
||||||
|
role,
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(username = %username, role = %role, "login successful");
|
||||||
|
|
||||||
|
Ok(Json(LoginResponse {
|
||||||
|
token,
|
||||||
|
username,
|
||||||
|
role: role.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> StatusCode {
|
||||||
|
if let Some(token) = extract_bearer_token(&headers) {
|
||||||
|
let mut sessions = state.sessions.write().await;
|
||||||
|
sessions.remove(token);
|
||||||
|
}
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn me(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<UserInfoResponse>, StatusCode> {
|
||||||
|
let config = state.config.read().await;
|
||||||
|
if !config.accounts.enabled {
|
||||||
|
// When accounts are not enabled, return a default admin user
|
||||||
|
return Ok(Json(UserInfoResponse {
|
||||||
|
username: "admin".to_string(),
|
||||||
|
role: "admin".to_string(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
drop(config);
|
||||||
|
|
||||||
|
let token = extract_bearer_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
let sessions = state.sessions.read().await;
|
||||||
|
let session = sessions.get(token).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
Ok(Json(UserInfoResponse {
|
||||||
|
username: session.username.clone(),
|
||||||
|
role: session.role.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||||
|
headers
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.strip_prefix("Bearer "))
|
||||||
|
}
|
||||||
101
crates/pinakes-server/src/routes/collections.rs
Normal file
101
crates/pinakes-server/src/routes/collections.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::*;
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use pinakes_core::model::{CollectionKind, MediaId};
|
||||||
|
|
||||||
|
pub async fn create_collection(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<CreateCollectionRequest>,
|
||||||
|
) -> Result<Json<CollectionResponse>, ApiError> {
|
||||||
|
if req.name.is_empty() || req.name.len() > 255 {
|
||||||
|
return Err(ApiError(
|
||||||
|
pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
|
"collection name must be 1-255 characters".into(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(ref desc) = req.description
|
||||||
|
&& desc.len() > 10_000
|
||||||
|
{
|
||||||
|
return Err(ApiError(
|
||||||
|
pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
|
"description exceeds 10000 characters".into(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let kind = match req.kind.as_str() {
|
||||||
|
"virtual" => CollectionKind::Virtual,
|
||||||
|
_ => CollectionKind::Manual,
|
||||||
|
};
|
||||||
|
let col = pinakes_core::collections::create_collection(
|
||||||
|
&state.storage,
|
||||||
|
&req.name,
|
||||||
|
kind,
|
||||||
|
req.description.as_deref(),
|
||||||
|
req.filter_query.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(CollectionResponse::from(col)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_collections(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Vec<CollectionResponse>>, ApiError> {
|
||||||
|
let cols = state.storage.list_collections().await?;
|
||||||
|
Ok(Json(
|
||||||
|
cols.into_iter().map(CollectionResponse::from).collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_collection(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<CollectionResponse>, ApiError> {
|
||||||
|
let col = state.storage.get_collection(id).await?;
|
||||||
|
Ok(Json(CollectionResponse::from(col)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_collection(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
state.storage.delete_collection(id).await?;
|
||||||
|
Ok(Json(serde_json::json!({"deleted": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_member(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(collection_id): Path<Uuid>,
|
||||||
|
Json(req): Json<AddMemberRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
pinakes_core::collections::add_member(
|
||||||
|
&state.storage,
|
||||||
|
collection_id,
|
||||||
|
MediaId(req.media_id),
|
||||||
|
req.position.unwrap_or(0),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({"added": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_member(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((collection_id, media_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
pinakes_core::collections::remove_member(&state.storage, collection_id, MediaId(media_id))
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({"removed": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_members(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(collection_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Vec<MediaResponse>>, ApiError> {
|
||||||
|
let items = pinakes_core::collections::get_members(&state.storage, collection_id).await?;
|
||||||
|
Ok(Json(items.into_iter().map(MediaResponse::from).collect()))
|
||||||
|
}
|
||||||
217
crates/pinakes-server/src/routes/config.rs
Normal file
217
crates/pinakes-server/src/routes/config.rs
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
|
||||||
|
use crate::dto::*;
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub async fn get_config(State(state): State<AppState>) -> Result<Json<ConfigResponse>, ApiError> {
|
||||||
|
let config = state.config.read().await;
|
||||||
|
let roots = state.storage.list_root_dirs().await?;
|
||||||
|
|
||||||
|
let config_path = state
|
||||||
|
.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)
|
||||||
|
.map(|m| !m.permissions().readonly())
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
path.parent()
|
||||||
|
.map(|parent| {
|
||||||
|
std::fs::metadata(parent)
|
||||||
|
.map(|m| !m.permissions().readonly())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ConfigResponse {
|
||||||
|
backend: format!("{:?}", config.storage.backend).to_lowercase(),
|
||||||
|
database_path: config
|
||||||
|
.storage
|
||||||
|
.sqlite
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.path.to_string_lossy().to_string()),
|
||||||
|
roots: roots
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.collect(),
|
||||||
|
scanning: ScanningConfigResponse {
|
||||||
|
watch: config.scanning.watch,
|
||||||
|
poll_interval_secs: config.scanning.poll_interval_secs,
|
||||||
|
ignore_patterns: config.scanning.ignore_patterns.clone(),
|
||||||
|
},
|
||||||
|
server: ServerConfigResponse {
|
||||||
|
host: config.server.host.clone(),
|
||||||
|
port: config.server.port,
|
||||||
|
},
|
||||||
|
ui: UiConfigResponse::from(&config.ui),
|
||||||
|
config_path,
|
||||||
|
config_writable,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_ui_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<UiConfigResponse>, ApiError> {
|
||||||
|
let config = state.config.read().await;
|
||||||
|
Ok(Json(UiConfigResponse::from(&config.ui)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_ui_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<UpdateUiConfigRequest>,
|
||||||
|
) -> Result<Json<UiConfigResponse>, ApiError> {
|
||||||
|
let mut config = state.config.write().await;
|
||||||
|
if let Some(theme) = req.theme {
|
||||||
|
config.ui.theme = theme;
|
||||||
|
}
|
||||||
|
if let Some(default_view) = req.default_view {
|
||||||
|
config.ui.default_view = default_view;
|
||||||
|
}
|
||||||
|
if let Some(default_page_size) = req.default_page_size {
|
||||||
|
config.ui.default_page_size = default_page_size;
|
||||||
|
}
|
||||||
|
if let Some(default_view_mode) = req.default_view_mode {
|
||||||
|
config.ui.default_view_mode = default_view_mode;
|
||||||
|
}
|
||||||
|
if let Some(auto_play) = req.auto_play_media {
|
||||||
|
config.ui.auto_play_media = auto_play;
|
||||||
|
}
|
||||||
|
if let Some(show_thumbs) = req.show_thumbnails {
|
||||||
|
config.ui.show_thumbnails = show_thumbs;
|
||||||
|
}
|
||||||
|
if let Some(collapsed) = req.sidebar_collapsed {
|
||||||
|
config.ui.sidebar_collapsed = collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref path) = state.config_path {
|
||||||
|
config.save_to_file(path).map_err(ApiError)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(UiConfigResponse::from(&config.ui)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_scanning_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<UpdateScanningRequest>,
|
||||||
|
) -> Result<Json<ConfigResponse>, ApiError> {
|
||||||
|
let mut config = state.config.write().await;
|
||||||
|
if let Some(watch) = req.watch {
|
||||||
|
config.scanning.watch = watch;
|
||||||
|
}
|
||||||
|
if let Some(interval) = req.poll_interval_secs {
|
||||||
|
config.scanning.poll_interval_secs = interval;
|
||||||
|
}
|
||||||
|
if let Some(patterns) = req.ignore_patterns {
|
||||||
|
config.scanning.ignore_patterns = patterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist to disk if we have a config path
|
||||||
|
if let Some(ref path) = state.config_path {
|
||||||
|
config.save_to_file(path).map_err(ApiError)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let roots = state.storage.list_root_dirs().await?;
|
||||||
|
|
||||||
|
let config_path = state
|
||||||
|
.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)
|
||||||
|
.map(|m| !m.permissions().readonly())
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
path.parent()
|
||||||
|
.map(|parent| {
|
||||||
|
std::fs::metadata(parent)
|
||||||
|
.map(|m| !m.permissions().readonly())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ConfigResponse {
|
||||||
|
backend: format!("{:?}", config.storage.backend).to_lowercase(),
|
||||||
|
database_path: config
|
||||||
|
.storage
|
||||||
|
.sqlite
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.path.to_string_lossy().to_string()),
|
||||||
|
roots: roots
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.collect(),
|
||||||
|
scanning: ScanningConfigResponse {
|
||||||
|
watch: config.scanning.watch,
|
||||||
|
poll_interval_secs: config.scanning.poll_interval_secs,
|
||||||
|
ignore_patterns: config.scanning.ignore_patterns.clone(),
|
||||||
|
},
|
||||||
|
server: ServerConfigResponse {
|
||||||
|
host: config.server.host.clone(),
|
||||||
|
port: config.server.port,
|
||||||
|
},
|
||||||
|
ui: UiConfigResponse::from(&config.ui),
|
||||||
|
config_path,
|
||||||
|
config_writable,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_root(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<RootDirRequest>,
|
||||||
|
) -> Result<Json<ConfigResponse>, ApiError> {
|
||||||
|
let path = std::path::PathBuf::from(&req.path);
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(ApiError(pinakes_core::error::PinakesError::FileNotFound(
|
||||||
|
path,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
state.storage.add_root_dir(path.clone()).await?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut config = state.config.write().await;
|
||||||
|
if !config.directories.roots.contains(&path) {
|
||||||
|
config.directories.roots.push(path);
|
||||||
|
}
|
||||||
|
if let Some(ref config_path) = state.config_path {
|
||||||
|
config.save_to_file(config_path).map_err(ApiError)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get_config(State(state)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_root(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<RootDirRequest>,
|
||||||
|
) -> Result<Json<ConfigResponse>, ApiError> {
|
||||||
|
let path = std::path::PathBuf::from(&req.path);
|
||||||
|
|
||||||
|
state.storage.remove_root_dir(&path).await?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut config = state.config.write().await;
|
||||||
|
config.directories.roots.retain(|r| r != &path);
|
||||||
|
if let Some(ref config_path) = state.config_path {
|
||||||
|
config.save_to_file(config_path).map_err(ApiError)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get_config(State(state)).await
|
||||||
|
}
|
||||||
34
crates/pinakes-server/src/routes/database.rs
Normal file
34
crates/pinakes-server/src/routes/database.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
|
||||||
|
use crate::dto::DatabaseStatsResponse;
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub async fn database_stats(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<DatabaseStatsResponse>, ApiError> {
|
||||||
|
let stats = state.storage.database_stats().await?;
|
||||||
|
Ok(Json(DatabaseStatsResponse {
|
||||||
|
media_count: stats.media_count,
|
||||||
|
tag_count: stats.tag_count,
|
||||||
|
collection_count: stats.collection_count,
|
||||||
|
audit_count: stats.audit_count,
|
||||||
|
database_size_bytes: stats.database_size_bytes,
|
||||||
|
backend_name: stats.backend_name,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn vacuum_database(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
state.storage.vacuum().await?;
|
||||||
|
Ok(Json(serde_json::json!({"status": "ok"})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear_database(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
state.storage.clear_all_data().await?;
|
||||||
|
Ok(Json(serde_json::json!({"status": "ok"})))
|
||||||
|
}
|
||||||
30
crates/pinakes-server/src/routes/duplicates.rs
Normal file
30
crates/pinakes-server/src/routes/duplicates.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
|
||||||
|
use crate::dto::{DuplicateGroupResponse, MediaResponse};
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub async fn list_duplicates(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Vec<DuplicateGroupResponse>>, ApiError> {
|
||||||
|
let groups = state.storage.find_duplicates().await?;
|
||||||
|
|
||||||
|
let response: Vec<DuplicateGroupResponse> = groups
|
||||||
|
.into_iter()
|
||||||
|
.map(|items| {
|
||||||
|
let content_hash = items
|
||||||
|
.first()
|
||||||
|
.map(|i| i.content_hash.0.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let media_items: Vec<MediaResponse> =
|
||||||
|
items.into_iter().map(MediaResponse::from).collect();
|
||||||
|
DuplicateGroupResponse {
|
||||||
|
content_hash,
|
||||||
|
items: media_items,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
42
crates/pinakes-server/src/routes/export.rs
Normal file
42
crates/pinakes-server/src/routes/export.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ExportRequest {
|
||||||
|
pub format: String,
|
||||||
|
pub destination: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn trigger_export(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
// Default export to JSON in data dir
|
||||||
|
let dest = pinakes_core::config::Config::default_data_dir().join("export.json");
|
||||||
|
let kind = pinakes_core::jobs::JobKind::Export {
|
||||||
|
format: pinakes_core::jobs::ExportFormat::Json,
|
||||||
|
destination: dest,
|
||||||
|
};
|
||||||
|
let job_id = state.job_queue.submit(kind).await;
|
||||||
|
Ok(Json(serde_json::json!({ "job_id": job_id.to_string() })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn trigger_export_with_options(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<ExportRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let format = match req.format.as_str() {
|
||||||
|
"csv" => pinakes_core::jobs::ExportFormat::Csv,
|
||||||
|
_ => pinakes_core::jobs::ExportFormat::Json,
|
||||||
|
};
|
||||||
|
let kind = pinakes_core::jobs::JobKind::Export {
|
||||||
|
format,
|
||||||
|
destination: req.destination,
|
||||||
|
};
|
||||||
|
let job_id = state.job_queue.submit(kind).await;
|
||||||
|
Ok(Json(serde_json::json!({ "job_id": job_id.to_string() })))
|
||||||
|
}
|
||||||
8
crates/pinakes-server/src/routes/health.rs
Normal file
8
crates/pinakes-server/src/routes/health.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
use axum::Json;
|
||||||
|
|
||||||
|
pub async fn health() -> Json<serde_json::Value> {
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
99
crates/pinakes-server/src/routes/integrity.rs
Normal file
99
crates/pinakes-server/src/routes/integrity.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct OrphanResolveRequest {
|
||||||
|
pub action: String,
|
||||||
|
pub ids: Vec<uuid::Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn trigger_orphan_detection(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let kind = pinakes_core::jobs::JobKind::OrphanDetection;
|
||||||
|
let job_id = state.job_queue.submit(kind).await;
|
||||||
|
Ok(Json(serde_json::json!({ "job_id": job_id.to_string() })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn trigger_verify_integrity(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<VerifyIntegrityRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let media_ids = req
|
||||||
|
.media_ids
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| pinakes_core::model::MediaId(id))
|
||||||
|
.collect();
|
||||||
|
let kind = pinakes_core::jobs::JobKind::VerifyIntegrity { media_ids };
|
||||||
|
let job_id = state.job_queue.submit(kind).await;
|
||||||
|
Ok(Json(serde_json::json!({ "job_id": job_id.to_string() })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct VerifyIntegrityRequest {
|
||||||
|
pub media_ids: Vec<uuid::Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn trigger_cleanup_thumbnails(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let kind = pinakes_core::jobs::JobKind::CleanupThumbnails;
|
||||||
|
let job_id = state.job_queue.submit(kind).await;
|
||||||
|
Ok(Json(serde_json::json!({ "job_id": job_id.to_string() })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct GenerateThumbnailsRequest {
|
||||||
|
/// When true, only generate thumbnails for items that don't have one yet.
|
||||||
|
/// When false (default), regenerate all thumbnails.
|
||||||
|
#[serde(default)]
|
||||||
|
pub only_missing: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn generate_all_thumbnails(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
body: Option<Json<GenerateThumbnailsRequest>>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let only_missing = body.map(|b| b.only_missing).unwrap_or(false);
|
||||||
|
let media_ids = state
|
||||||
|
.storage
|
||||||
|
.list_media_ids_for_thumbnails(only_missing)
|
||||||
|
.await?;
|
||||||
|
let count = media_ids.len();
|
||||||
|
if count == 0 {
|
||||||
|
return Ok(Json(serde_json::json!({
|
||||||
|
"job_id": null,
|
||||||
|
"media_count": 0,
|
||||||
|
"message": "no media items to process"
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
let kind = pinakes_core::jobs::JobKind::GenerateThumbnails { media_ids };
|
||||||
|
let job_id = state.job_queue.submit(kind).await;
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"job_id": job_id.to_string(),
|
||||||
|
"media_count": count
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolve_orphans(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<OrphanResolveRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let action = match req.action.as_str() {
|
||||||
|
"delete" => pinakes_core::integrity::OrphanAction::Delete,
|
||||||
|
_ => pinakes_core::integrity::OrphanAction::Ignore,
|
||||||
|
};
|
||||||
|
let ids: Vec<pinakes_core::model::MediaId> = req
|
||||||
|
.ids
|
||||||
|
.into_iter()
|
||||||
|
.map(pinakes_core::model::MediaId)
|
||||||
|
.collect();
|
||||||
|
let count = pinakes_core::integrity::resolve_orphans(&state.storage, action, &ids)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(e))?;
|
||||||
|
Ok(Json(serde_json::json!({ "resolved": count })))
|
||||||
|
}
|
||||||
34
crates/pinakes-server/src/routes/jobs.rs
Normal file
34
crates/pinakes-server/src/routes/jobs.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use pinakes_core::jobs::Job;
|
||||||
|
|
||||||
|
pub async fn list_jobs(State(state): State<AppState>) -> Json<Vec<Job>> {
|
||||||
|
Json(state.job_queue.list().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_job(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
) -> Result<Json<Job>, ApiError> {
|
||||||
|
state.job_queue.status(id).await.map(Json).ok_or_else(|| {
|
||||||
|
pinakes_core::error::PinakesError::NotFound(format!("job not found: {id}")).into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cancel_job(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let cancelled = state.job_queue.cancel(id).await;
|
||||||
|
if cancelled {
|
||||||
|
Ok(Json(serde_json::json!({ "cancelled": true })))
|
||||||
|
} else {
|
||||||
|
Err(pinakes_core::error::PinakesError::NotFound(format!(
|
||||||
|
"job not found or already finished: {id}"
|
||||||
|
))
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
795
crates/pinakes-server/src/routes/media.rs
Normal file
795
crates/pinakes-server/src/routes/media.rs
Normal file
|
|
@ -0,0 +1,795 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::*;
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use pinakes_core::model::{MediaId, Pagination};
|
||||||
|
use pinakes_core::storage::DynStorageBackend;
|
||||||
|
|
||||||
|
/// Apply tags and add to collection after a successful import.
|
||||||
|
/// Shared logic used by import_with_options, batch_import, and import_directory_endpoint.
|
||||||
|
async fn apply_import_post_processing(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
media_id: MediaId,
|
||||||
|
tag_ids: Option<&[Uuid]>,
|
||||||
|
new_tags: Option<&[String]>,
|
||||||
|
collection_id: Option<Uuid>,
|
||||||
|
) {
|
||||||
|
if let Some(tag_ids) = tag_ids {
|
||||||
|
for tid in tag_ids {
|
||||||
|
if let Err(e) = pinakes_core::tags::tag_media(storage, media_id, *tid).await {
|
||||||
|
tracing::warn!(error = %e, "failed to apply tag during import");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(new_tags) = new_tags {
|
||||||
|
for name in new_tags {
|
||||||
|
match pinakes_core::tags::create_tag(storage, name, None).await {
|
||||||
|
Ok(tag) => {
|
||||||
|
if let Err(e) = pinakes_core::tags::tag_media(storage, media_id, tag.id).await {
|
||||||
|
tracing::warn!(error = %e, "failed to apply new tag during import");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(tag_name = %name, error = %e, "failed to create tag during import");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(col_id) = collection_id
|
||||||
|
&& let Err(e) = pinakes_core::collections::add_member(storage, col_id, media_id, 0).await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "failed to add to collection during import");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn import_media(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<ImportRequest>,
|
||||||
|
) -> Result<Json<ImportResponse>, ApiError> {
|
||||||
|
let result = pinakes_core::import::import_file(&state.storage, &req.path).await?;
|
||||||
|
Ok(Json(ImportResponse {
|
||||||
|
media_id: result.media_id.0.to_string(),
|
||||||
|
was_duplicate: result.was_duplicate,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_media(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<PaginationParams>,
|
||||||
|
) -> Result<Json<Vec<MediaResponse>>, ApiError> {
|
||||||
|
let pagination = Pagination::new(
|
||||||
|
params.offset.unwrap_or(0),
|
||||||
|
params.limit.unwrap_or(50).min(1000),
|
||||||
|
params.sort,
|
||||||
|
);
|
||||||
|
let items = state.storage.list_media(&pagination).await?;
|
||||||
|
Ok(Json(items.into_iter().map(MediaResponse::from).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_media(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<MediaResponse>, ApiError> {
|
||||||
|
let item = state.storage.get_media(MediaId(id)).await?;
|
||||||
|
Ok(Json(MediaResponse::from(item)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum length for short text fields (title, artist, album, genre).
|
||||||
|
const MAX_SHORT_TEXT: usize = 500;
|
||||||
|
/// Maximum length for long text fields (description).
|
||||||
|
const MAX_LONG_TEXT: usize = 10_000;
|
||||||
|
|
||||||
|
fn validate_optional_text(field: &Option<String>, name: &str, max: usize) -> Result<(), ApiError> {
|
||||||
|
if let Some(v) = field
|
||||||
|
&& v.len() > max
|
||||||
|
{
|
||||||
|
return Err(ApiError(
|
||||||
|
pinakes_core::error::PinakesError::InvalidOperation(format!(
|
||||||
|
"{name} exceeds {max} characters"
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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)?;
|
||||||
|
|
||||||
|
let mut item = state.storage.get_media(MediaId(id)).await?;
|
||||||
|
|
||||||
|
if let Some(title) = req.title {
|
||||||
|
item.title = Some(title);
|
||||||
|
}
|
||||||
|
if let Some(artist) = req.artist {
|
||||||
|
item.artist = Some(artist);
|
||||||
|
}
|
||||||
|
if let Some(album) = req.album {
|
||||||
|
item.album = Some(album);
|
||||||
|
}
|
||||||
|
if let Some(genre) = req.genre {
|
||||||
|
item.genre = Some(genre);
|
||||||
|
}
|
||||||
|
if let Some(year) = req.year {
|
||||||
|
item.year = Some(year);
|
||||||
|
}
|
||||||
|
if let Some(description) = req.description {
|
||||||
|
item.description = Some(description);
|
||||||
|
}
|
||||||
|
item.updated_at = chrono::Utc::now();
|
||||||
|
|
||||||
|
state.storage.update_media(&item).await?;
|
||||||
|
pinakes_core::audit::record_action(
|
||||||
|
&state.storage,
|
||||||
|
Some(item.id),
|
||||||
|
pinakes_core::model::AuditAction::Updated,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(MediaResponse::from(item)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_media(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let media_id = MediaId(id);
|
||||||
|
// Fetch item first to get thumbnail path for cleanup
|
||||||
|
let item = state.storage.get_media(media_id).await?;
|
||||||
|
|
||||||
|
// Record audit BEFORE delete to avoid FK constraint violation
|
||||||
|
pinakes_core::audit::record_action(
|
||||||
|
&state.storage,
|
||||||
|
Some(media_id),
|
||||||
|
pinakes_core::model::AuditAction::Deleted,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
state.storage.delete_media(media_id).await?;
|
||||||
|
|
||||||
|
// Clean up thumbnail file if it exists
|
||||||
|
if let Some(ref thumb_path) = item.thumbnail_path
|
||||||
|
&& let Err(e) = tokio::fs::remove_file(thumb_path).await
|
||||||
|
&& e.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
tracing::warn!(path = %thumb_path.display(), error = %e, "failed to remove thumbnail");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({"deleted": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn open_media(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let item = state.storage.get_media(MediaId(id)).await?;
|
||||||
|
let opener = pinakes_core::opener::default_opener();
|
||||||
|
opener.open(&item.path)?;
|
||||||
|
pinakes_core::audit::record_action(
|
||||||
|
&state.storage,
|
||||||
|
Some(item.id),
|
||||||
|
pinakes_core::model::AuditAction::Opened,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({"opened": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stream_media(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
) -> Result<axum::response::Response, ApiError> {
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{StatusCode, header};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
|
let item = state.storage.get_media(MediaId(id)).await?;
|
||||||
|
|
||||||
|
let file = tokio::fs::File::open(&item.path).await.map_err(|_e| {
|
||||||
|
ApiError(pinakes_core::error::PinakesError::FileNotFound(
|
||||||
|
item.path.clone(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let metadata = file
|
||||||
|
.metadata()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(pinakes_core::error::PinakesError::Io(e)))?;
|
||||||
|
let total_size = metadata.len();
|
||||||
|
let content_type = item.media_type.mime_type();
|
||||||
|
|
||||||
|
// Parse Range header
|
||||||
|
if let Some(range_header) = headers.get(header::RANGE)
|
||||||
|
&& let Ok(range_str) = range_header.to_str()
|
||||||
|
&& let Some(range) = parse_range(range_str, total_size)
|
||||||
|
{
|
||||||
|
let (start, end) = range;
|
||||||
|
let content_length = end - start + 1;
|
||||||
|
|
||||||
|
let mut file = file;
|
||||||
|
file.seek(std::io::SeekFrom::Start(start))
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(pinakes_core::error::PinakesError::Io(e)))?;
|
||||||
|
|
||||||
|
let limited = file.take(content_length);
|
||||||
|
let stream = ReaderStream::new(limited);
|
||||||
|
let body = Body::from_stream(stream);
|
||||||
|
|
||||||
|
return axum::response::Response::builder()
|
||||||
|
.status(StatusCode::PARTIAL_CONTENT)
|
||||||
|
.header(header::CONTENT_TYPE, content_type)
|
||||||
|
.header(header::CONTENT_LENGTH, content_length)
|
||||||
|
.header(header::ACCEPT_RANGES, "bytes")
|
||||||
|
.header(
|
||||||
|
header::CONTENT_RANGE,
|
||||||
|
format!("bytes {start}-{end}/{total_size}"),
|
||||||
|
)
|
||||||
|
.header(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("inline; filename=\"{}\"", item.file_name),
|
||||||
|
)
|
||||||
|
.body(body)
|
||||||
|
.map_err(|e| {
|
||||||
|
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
|
format!("failed to build response: {e}"),
|
||||||
|
))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full response (no Range header)
|
||||||
|
let stream = ReaderStream::new(file);
|
||||||
|
let body = Body::from_stream(stream);
|
||||||
|
|
||||||
|
axum::response::Response::builder()
|
||||||
|
.header(header::CONTENT_TYPE, content_type)
|
||||||
|
.header(header::CONTENT_LENGTH, total_size)
|
||||||
|
.header(header::ACCEPT_RANGES, "bytes")
|
||||||
|
.header(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("inline; filename=\"{}\"", item.file_name),
|
||||||
|
)
|
||||||
|
.body(body)
|
||||||
|
.map_err(|e| {
|
||||||
|
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
|
format!("failed to build response: {e}"),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `Range: bytes=START-END` header value.
|
||||||
|
/// Returns `Some((start, end))` inclusive, or `None` if malformed.
|
||||||
|
fn parse_range(header: &str, total_size: u64) -> Option<(u64, u64)> {
|
||||||
|
let bytes_prefix = header.strip_prefix("bytes=")?;
|
||||||
|
let (start_str, end_str) = bytes_prefix.split_once('-')?;
|
||||||
|
|
||||||
|
if start_str.is_empty() {
|
||||||
|
// Suffix range: bytes=-500 means last 500 bytes
|
||||||
|
let suffix_len: u64 = end_str.parse().ok()?;
|
||||||
|
let start = total_size.saturating_sub(suffix_len);
|
||||||
|
Some((start, total_size - 1))
|
||||||
|
} else {
|
||||||
|
let start: u64 = start_str.parse().ok()?;
|
||||||
|
let end = if end_str.is_empty() {
|
||||||
|
total_size - 1
|
||||||
|
} else {
|
||||||
|
end_str.parse::<u64>().ok()?.min(total_size - 1)
|
||||||
|
};
|
||||||
|
if start > end || start >= total_size {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((start, end))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn import_with_options(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<ImportWithOptionsRequest>,
|
||||||
|
) -> Result<Json<ImportResponse>, ApiError> {
|
||||||
|
let result = pinakes_core::import::import_file(&state.storage, &req.path).await?;
|
||||||
|
|
||||||
|
if !result.was_duplicate {
|
||||||
|
apply_import_post_processing(
|
||||||
|
&state.storage,
|
||||||
|
result.media_id,
|
||||||
|
req.tag_ids.as_deref(),
|
||||||
|
req.new_tags.as_deref(),
|
||||||
|
req.collection_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(ImportResponse {
|
||||||
|
media_id: result.media_id.0.to_string(),
|
||||||
|
was_duplicate: result.was_duplicate,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn batch_import(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<BatchImportRequest>,
|
||||||
|
) -> Result<Json<BatchImportResponse>, ApiError> {
|
||||||
|
if req.paths.len() > 10_000 {
|
||||||
|
return Err(ApiError(
|
||||||
|
pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
|
"batch size exceeds limit of 10000".into(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let mut imported = 0usize;
|
||||||
|
let mut duplicates = 0usize;
|
||||||
|
let mut errors = 0usize;
|
||||||
|
|
||||||
|
for path in &req.paths {
|
||||||
|
match pinakes_core::import::import_file(&state.storage, path).await {
|
||||||
|
Ok(result) => {
|
||||||
|
if result.was_duplicate {
|
||||||
|
duplicates += 1;
|
||||||
|
} else {
|
||||||
|
imported += 1;
|
||||||
|
apply_import_post_processing(
|
||||||
|
&state.storage,
|
||||||
|
result.media_id,
|
||||||
|
req.tag_ids.as_deref(),
|
||||||
|
req.new_tags.as_deref(),
|
||||||
|
req.collection_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
results.push(BatchImportItemResult {
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
media_id: Some(result.media_id.0.to_string()),
|
||||||
|
was_duplicate: result.was_duplicate,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
errors += 1;
|
||||||
|
results.push(BatchImportItemResult {
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
media_id: None,
|
||||||
|
was_duplicate: false,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = results.len();
|
||||||
|
Ok(Json(BatchImportResponse {
|
||||||
|
results,
|
||||||
|
total,
|
||||||
|
imported,
|
||||||
|
duplicates,
|
||||||
|
errors,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn import_directory_endpoint(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<DirectoryImportRequest>,
|
||||||
|
) -> Result<Json<BatchImportResponse>, ApiError> {
|
||||||
|
let config = state.config.read().await;
|
||||||
|
let ignore_patterns = config.scanning.ignore_patterns.clone();
|
||||||
|
let concurrency = config.scanning.import_concurrency;
|
||||||
|
drop(config);
|
||||||
|
|
||||||
|
let import_results = pinakes_core::import::import_directory_with_concurrency(
|
||||||
|
&state.storage,
|
||||||
|
&req.path,
|
||||||
|
&ignore_patterns,
|
||||||
|
concurrency,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let mut imported = 0usize;
|
||||||
|
let mut duplicates = 0usize;
|
||||||
|
let mut errors = 0usize;
|
||||||
|
|
||||||
|
for r in import_results {
|
||||||
|
match r {
|
||||||
|
Ok(result) => {
|
||||||
|
if result.was_duplicate {
|
||||||
|
duplicates += 1;
|
||||||
|
} else {
|
||||||
|
imported += 1;
|
||||||
|
apply_import_post_processing(
|
||||||
|
&state.storage,
|
||||||
|
result.media_id,
|
||||||
|
req.tag_ids.as_deref(),
|
||||||
|
req.new_tags.as_deref(),
|
||||||
|
req.collection_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
results.push(BatchImportItemResult {
|
||||||
|
path: result.path.to_string_lossy().to_string(),
|
||||||
|
media_id: Some(result.media_id.0.to_string()),
|
||||||
|
was_duplicate: result.was_duplicate,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
errors += 1;
|
||||||
|
results.push(BatchImportItemResult {
|
||||||
|
path: String::new(),
|
||||||
|
media_id: None,
|
||||||
|
was_duplicate: false,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = results.len();
|
||||||
|
Ok(Json(BatchImportResponse {
|
||||||
|
results,
|
||||||
|
total,
|
||||||
|
imported,
|
||||||
|
duplicates,
|
||||||
|
errors,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn preview_directory(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<serde_json::Value>,
|
||||||
|
) -> Result<Json<DirectoryPreviewResponse>, ApiError> {
|
||||||
|
let path_str = req.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||||
|
pinakes_core::error::PinakesError::InvalidOperation("path required".into())
|
||||||
|
})?;
|
||||||
|
let recursive = req
|
||||||
|
.get("recursive")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
let dir = std::path::PathBuf::from(path_str);
|
||||||
|
if !dir.is_dir() {
|
||||||
|
return Err(pinakes_core::error::PinakesError::FileNotFound(dir).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the directory is under a configured root (if roots are configured)
|
||||||
|
let roots = state.storage.list_root_dirs().await?;
|
||||||
|
if !roots.is_empty() {
|
||||||
|
let canonical = dir.canonicalize().map_err(|_| {
|
||||||
|
pinakes_core::error::PinakesError::InvalidOperation("cannot resolve path".into())
|
||||||
|
})?;
|
||||||
|
let allowed = roots.iter().any(|root| canonical.starts_with(root));
|
||||||
|
if !allowed {
|
||||||
|
return Err(pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
|
"path is not under a configured root directory".into(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let files: Vec<DirectoryPreviewFile> = tokio::task::spawn_blocking(move || {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
fn walk_dir(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
recursive: bool,
|
||||||
|
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()
|
||||||
|
.map(|n| n.to_string_lossy().starts_with('.'))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if path.is_dir() {
|
||||||
|
if recursive {
|
||||||
|
walk_dir(&path, recursive, result);
|
||||||
|
}
|
||||||
|
} else if path.is_file()
|
||||||
|
&& let Some(mt) = pinakes_core::media_type::MediaType::from_path(&path)
|
||||||
|
{
|
||||||
|
let size = entry.metadata().ok().map(|m| m.len()).unwrap_or(0);
|
||||||
|
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: path.to_string_lossy().to_string(),
|
||||||
|
file_name,
|
||||||
|
media_type,
|
||||||
|
file_size: size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk_dir(&dir, recursive, &mut result);
|
||||||
|
result
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| pinakes_core::error::PinakesError::Io(std::io::Error::other(e)))?;
|
||||||
|
|
||||||
|
let total_count = files.len();
|
||||||
|
let total_size = files.iter().map(|f| f.file_size).sum();
|
||||||
|
|
||||||
|
Ok(Json(DirectoryPreviewResponse {
|
||||||
|
files,
|
||||||
|
total_count,
|
||||||
|
total_size,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_custom_field(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<SetCustomFieldRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
if req.name.is_empty() || req.name.len() > 255 {
|
||||||
|
return Err(ApiError(
|
||||||
|
pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
|
"field name must be 1-255 characters".into(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if req.value.len() > MAX_LONG_TEXT {
|
||||||
|
return Err(ApiError(
|
||||||
|
pinakes_core::error::PinakesError::InvalidOperation(format!(
|
||||||
|
"field value exceeds {} characters",
|
||||||
|
MAX_LONG_TEXT
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
use pinakes_core::model::{CustomField, CustomFieldType};
|
||||||
|
let field_type = match req.field_type.as_str() {
|
||||||
|
"number" => CustomFieldType::Number,
|
||||||
|
"date" => CustomFieldType::Date,
|
||||||
|
"boolean" => CustomFieldType::Boolean,
|
||||||
|
_ => CustomFieldType::Text,
|
||||||
|
};
|
||||||
|
let field = CustomField {
|
||||||
|
field_type,
|
||||||
|
value: req.value,
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.set_custom_field(MediaId(id), &req.name, &field)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({"set": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_custom_field(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((id, name)): Path<(Uuid, String)>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.delete_custom_field(MediaId(id), &name)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({"deleted": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn batch_tag(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<BatchTagRequest>,
|
||||||
|
) -> Result<Json<BatchOperationResponse>, ApiError> {
|
||||||
|
if req.media_ids.len() > 10_000 {
|
||||||
|
return Err(ApiError(
|
||||||
|
pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
|
"batch size exceeds limit of 10000".into(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let media_ids: Vec<MediaId> = req.media_ids.iter().map(|id| MediaId(*id)).collect();
|
||||||
|
match state
|
||||||
|
.storage
|
||||||
|
.batch_tag_media(&media_ids, &req.tag_ids)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(count) => Ok(Json(BatchOperationResponse {
|
||||||
|
processed: count as usize,
|
||||||
|
errors: Vec::new(),
|
||||||
|
})),
|
||||||
|
Err(e) => Ok(Json(BatchOperationResponse {
|
||||||
|
processed: 0,
|
||||||
|
errors: vec![e.to_string()],
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_all_media(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<BatchOperationResponse>, ApiError> {
|
||||||
|
// Record audit entry before deletion
|
||||||
|
if let Err(e) = pinakes_core::audit::record_action(
|
||||||
|
&state.storage,
|
||||||
|
None,
|
||||||
|
pinakes_core::model::AuditAction::Deleted,
|
||||||
|
Some("delete all media".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "failed to record audit entry");
|
||||||
|
}
|
||||||
|
|
||||||
|
match state.storage.delete_all_media().await {
|
||||||
|
Ok(count) => Ok(Json(BatchOperationResponse {
|
||||||
|
processed: count as usize,
|
||||||
|
errors: Vec::new(),
|
||||||
|
})),
|
||||||
|
Err(e) => Ok(Json(BatchOperationResponse {
|
||||||
|
processed: 0,
|
||||||
|
errors: vec![e.to_string()],
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn batch_delete(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<BatchDeleteRequest>,
|
||||||
|
) -> Result<Json<BatchOperationResponse>, ApiError> {
|
||||||
|
if req.media_ids.len() > 10_000 {
|
||||||
|
return Err(ApiError(
|
||||||
|
pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
|
"batch size exceeds limit of 10000".into(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let media_ids: Vec<MediaId> = req.media_ids.iter().map(|id| MediaId(*id)).collect();
|
||||||
|
|
||||||
|
// Record audit entries BEFORE delete to avoid FK constraint violation.
|
||||||
|
// Use None for media_id since they'll be deleted; include ID in details.
|
||||||
|
for id in &media_ids {
|
||||||
|
if let Err(e) = pinakes_core::audit::record_action(
|
||||||
|
&state.storage,
|
||||||
|
None,
|
||||||
|
pinakes_core::model::AuditAction::Deleted,
|
||||||
|
Some(format!("batch delete: media_id={}", id.0)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "failed to record audit entry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match state.storage.batch_delete_media(&media_ids).await {
|
||||||
|
Ok(count) => Ok(Json(BatchOperationResponse {
|
||||||
|
processed: count as usize,
|
||||||
|
errors: Vec::new(),
|
||||||
|
})),
|
||||||
|
Err(e) => Ok(Json(BatchOperationResponse {
|
||||||
|
processed: 0,
|
||||||
|
errors: vec![e.to_string()],
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn batch_add_to_collection(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<BatchCollectionRequest>,
|
||||||
|
) -> Result<Json<BatchOperationResponse>, ApiError> {
|
||||||
|
if req.media_ids.len() > 10_000 {
|
||||||
|
return Err(ApiError(
|
||||||
|
pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
|
"batch size exceeds limit of 10000".into(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut processed = 0;
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
for (i, media_id) in req.media_ids.iter().enumerate() {
|
||||||
|
match pinakes_core::collections::add_member(
|
||||||
|
&state.storage,
|
||||||
|
req.collection_id,
|
||||||
|
MediaId(*media_id),
|
||||||
|
i as i32,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => processed += 1,
|
||||||
|
Err(e) => errors.push(format!("{media_id}: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(BatchOperationResponse { processed, errors }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn batch_update(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<BatchUpdateRequest>,
|
||||||
|
) -> Result<Json<BatchOperationResponse>, ApiError> {
|
||||||
|
if req.media_ids.len() > 10_000 {
|
||||||
|
return Err(ApiError(
|
||||||
|
pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
|
"batch size exceeds limit of 10000".into(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let media_ids: Vec<MediaId> = req.media_ids.iter().map(|id| MediaId(*id)).collect();
|
||||||
|
match state
|
||||||
|
.storage
|
||||||
|
.batch_update_media(
|
||||||
|
&media_ids,
|
||||||
|
req.title.as_deref(),
|
||||||
|
req.artist.as_deref(),
|
||||||
|
req.album.as_deref(),
|
||||||
|
req.genre.as_deref(),
|
||||||
|
req.year,
|
||||||
|
req.description.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(count) => Ok(Json(BatchOperationResponse {
|
||||||
|
processed: count as usize,
|
||||||
|
errors: Vec::new(),
|
||||||
|
})),
|
||||||
|
Err(e) => Ok(Json(BatchOperationResponse {
|
||||||
|
processed: 0,
|
||||||
|
errors: vec![e.to_string()],
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_thumbnail(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<axum::response::Response, ApiError> {
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::header;
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
|
let item = state.storage.get_media(MediaId(id)).await?;
|
||||||
|
|
||||||
|
let thumb_path = item.thumbnail_path.ok_or_else(|| {
|
||||||
|
ApiError(pinakes_core::error::PinakesError::NotFound(
|
||||||
|
"no thumbnail available".into(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let file = tokio::fs::File::open(&thumb_path)
|
||||||
|
.await
|
||||||
|
.map_err(|_e| ApiError(pinakes_core::error::PinakesError::FileNotFound(thumb_path)))?;
|
||||||
|
|
||||||
|
let stream = ReaderStream::new(file);
|
||||||
|
let body = Body::from_stream(stream);
|
||||||
|
|
||||||
|
axum::response::Response::builder()
|
||||||
|
.header(header::CONTENT_TYPE, "image/jpeg")
|
||||||
|
.header(header::CACHE_CONTROL, "public, max-age=86400")
|
||||||
|
.body(body)
|
||||||
|
.map_err(|e| {
|
||||||
|
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
|
format!("failed to build response: {e}"),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_media_count(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<MediaCountResponse>, ApiError> {
|
||||||
|
let count = state.storage.count_media().await?;
|
||||||
|
Ok(Json(MediaCountResponse { count }))
|
||||||
|
}
|
||||||
18
crates/pinakes-server/src/routes/mod.rs
Normal file
18
crates/pinakes-server/src/routes/mod.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
pub mod audit;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod collections;
|
||||||
|
pub mod config;
|
||||||
|
pub mod database;
|
||||||
|
pub mod duplicates;
|
||||||
|
pub mod export;
|
||||||
|
pub mod health;
|
||||||
|
pub mod integrity;
|
||||||
|
pub mod jobs;
|
||||||
|
pub mod media;
|
||||||
|
pub mod saved_searches;
|
||||||
|
pub mod scan;
|
||||||
|
pub mod scheduled_tasks;
|
||||||
|
pub mod search;
|
||||||
|
pub mod statistics;
|
||||||
|
pub mod tags;
|
||||||
|
pub mod webhooks;
|
||||||
76
crates/pinakes-server/src/routes/saved_searches.rs
Normal file
76
crates/pinakes-server/src/routes/saved_searches.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateSavedSearchRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub query: String,
|
||||||
|
pub sort_order: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SavedSearchResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub query: String,
|
||||||
|
pub sort_order: Option<String>,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_saved_search(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<CreateSavedSearchRequest>,
|
||||||
|
) -> Result<Json<SavedSearchResponse>, ApiError> {
|
||||||
|
let id = uuid::Uuid::now_v7();
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.save_search(id, &req.name, &req.query, req.sort_order.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(ApiError)?;
|
||||||
|
|
||||||
|
Ok(Json(SavedSearchResponse {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: req.name,
|
||||||
|
query: req.query,
|
||||||
|
sort_order: req.sort_order,
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_saved_searches(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Vec<SavedSearchResponse>>, ApiError> {
|
||||||
|
let searches = state
|
||||||
|
.storage
|
||||||
|
.list_saved_searches()
|
||||||
|
.await
|
||||||
|
.map_err(ApiError)?;
|
||||||
|
Ok(Json(
|
||||||
|
searches
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| SavedSearchResponse {
|
||||||
|
id: s.id.to_string(),
|
||||||
|
name: s.name,
|
||||||
|
query: s.query,
|
||||||
|
sort_order: s.sort_order,
|
||||||
|
created_at: s.created_at,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_saved_search(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.delete_saved_search(id)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError)?;
|
||||||
|
Ok(Json(serde_json::json!({ "deleted": true })))
|
||||||
|
}
|
||||||
30
crates/pinakes-server/src/routes/scan.rs
Normal file
30
crates/pinakes-server/src/routes/scan.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
|
||||||
|
use crate::dto::*;
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Trigger a scan as a background job. Returns the job ID immediately.
|
||||||
|
pub async fn trigger_scan(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<ScanRequest>,
|
||||||
|
) -> Result<Json<ScanJobResponse>, ApiError> {
|
||||||
|
let kind = pinakes_core::jobs::JobKind::Scan { path: req.path };
|
||||||
|
let job_id = state.job_queue.submit(kind).await;
|
||||||
|
Ok(Json(ScanJobResponse {
|
||||||
|
job_id: job_id.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scan_status(State(state): State<AppState>) -> Json<ScanStatusResponse> {
|
||||||
|
let snapshot = state.scan_progress.snapshot();
|
||||||
|
let error_count = snapshot.errors.len();
|
||||||
|
Json(ScanStatusResponse {
|
||||||
|
scanning: snapshot.scanning,
|
||||||
|
files_found: snapshot.files_found,
|
||||||
|
files_processed: snapshot.files_processed,
|
||||||
|
error_count,
|
||||||
|
errors: snapshot.errors,
|
||||||
|
})
|
||||||
|
}
|
||||||
55
crates/pinakes-server/src/routes/scheduled_tasks.rs
Normal file
55
crates/pinakes-server/src/routes/scheduled_tasks.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
|
||||||
|
use crate::dto::ScheduledTaskResponse;
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub async fn list_scheduled_tasks(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Vec<ScheduledTaskResponse>>, ApiError> {
|
||||||
|
let tasks = state.scheduler.list_tasks().await;
|
||||||
|
let responses: Vec<ScheduledTaskResponse> = tasks
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| ScheduledTaskResponse {
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
schedule: t.schedule.display_string(),
|
||||||
|
enabled: t.enabled,
|
||||||
|
last_run: t.last_run.map(|dt| dt.to_rfc3339()),
|
||||||
|
next_run: t.next_run.map(|dt| dt.to_rfc3339()),
|
||||||
|
last_status: t.last_status,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(responses))
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => Ok(Json(serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"enabled": enabled,
|
||||||
|
}))),
|
||||||
|
None => Err(ApiError(pinakes_core::error::PinakesError::NotFound(
|
||||||
|
format!("scheduled task not found: {id}"),
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => 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}"),
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
87
crates/pinakes-server/src/routes/search.rs
Normal file
87
crates/pinakes-server/src/routes/search.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Query, State};
|
||||||
|
|
||||||
|
use crate::dto::*;
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use pinakes_core::model::Pagination;
|
||||||
|
use pinakes_core::search::{SearchRequest, SortOrder, parse_search_query};
|
||||||
|
|
||||||
|
fn resolve_sort(sort: Option<&str>) -> SortOrder {
|
||||||
|
match sort {
|
||||||
|
Some("date_asc") => SortOrder::DateAsc,
|
||||||
|
Some("date_desc") => SortOrder::DateDesc,
|
||||||
|
Some("name_asc") => SortOrder::NameAsc,
|
||||||
|
Some("name_desc") => SortOrder::NameDesc,
|
||||||
|
Some("size_asc") => SortOrder::SizeAsc,
|
||||||
|
Some("size_desc") => SortOrder::SizeDesc,
|
||||||
|
_ => SortOrder::Relevance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<SearchParams>,
|
||||||
|
) -> Result<Json<SearchResponse>, ApiError> {
|
||||||
|
if params.q.len() > 2048 {
|
||||||
|
return Err(ApiError(
|
||||||
|
pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
|
"search query exceeds maximum length of 2048 characters".into(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = parse_search_query(¶ms.q)?;
|
||||||
|
let sort = resolve_sort(params.sort.as_deref());
|
||||||
|
|
||||||
|
let request = SearchRequest {
|
||||||
|
query,
|
||||||
|
sort,
|
||||||
|
pagination: Pagination::new(
|
||||||
|
params.offset.unwrap_or(0),
|
||||||
|
params.limit.unwrap_or(50).min(1000),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let results = state.storage.search(&request).await?;
|
||||||
|
|
||||||
|
Ok(Json(SearchResponse {
|
||||||
|
items: results.items.into_iter().map(MediaResponse::from).collect(),
|
||||||
|
total_count: results.total_count,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<SearchRequestBody>,
|
||||||
|
) -> Result<Json<SearchResponse>, ApiError> {
|
||||||
|
if body.q.len() > 2048 {
|
||||||
|
return Err(ApiError(
|
||||||
|
pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
|
"search query exceeds maximum length of 2048 characters".into(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = parse_search_query(&body.q)?;
|
||||||
|
let sort = resolve_sort(body.sort.as_deref());
|
||||||
|
|
||||||
|
let request = SearchRequest {
|
||||||
|
query,
|
||||||
|
sort,
|
||||||
|
pagination: Pagination::new(
|
||||||
|
body.offset.unwrap_or(0),
|
||||||
|
body.limit.unwrap_or(50).min(1000),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let results = state.storage.search(&request).await?;
|
||||||
|
|
||||||
|
Ok(Json(SearchResponse {
|
||||||
|
items: results.items.into_iter().map(MediaResponse::from).collect(),
|
||||||
|
total_count: results.total_count,
|
||||||
|
}))
|
||||||
|
}
|
||||||
13
crates/pinakes-server/src/routes/statistics.rs
Normal file
13
crates/pinakes-server/src/routes/statistics.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
|
||||||
|
use crate::dto::LibraryStatisticsResponse;
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub async fn library_statistics(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<LibraryStatisticsResponse>, ApiError> {
|
||||||
|
let stats = state.storage.library_statistics().await?;
|
||||||
|
Ok(Json(LibraryStatisticsResponse::from(stats)))
|
||||||
|
}
|
||||||
70
crates/pinakes-server/src/routes/tags.rs
Normal file
70
crates/pinakes-server/src/routes/tags.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::*;
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use pinakes_core::model::MediaId;
|
||||||
|
|
||||||
|
pub async fn create_tag(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<CreateTagRequest>,
|
||||||
|
) -> Result<Json<TagResponse>, ApiError> {
|
||||||
|
if req.name.is_empty() || req.name.len() > 255 {
|
||||||
|
return Err(ApiError(
|
||||||
|
pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
|
"tag name must be 1-255 characters".into(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let tag = pinakes_core::tags::create_tag(&state.storage, &req.name, req.parent_id).await?;
|
||||||
|
Ok(Json(TagResponse::from(tag)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_tags(State(state): State<AppState>) -> Result<Json<Vec<TagResponse>>, ApiError> {
|
||||||
|
let tags = state.storage.list_tags().await?;
|
||||||
|
Ok(Json(tags.into_iter().map(TagResponse::from).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_tag(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<TagResponse>, ApiError> {
|
||||||
|
let tag = state.storage.get_tag(id).await?;
|
||||||
|
Ok(Json(TagResponse::from(tag)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_tag(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
state.storage.delete_tag(id).await?;
|
||||||
|
Ok(Json(serde_json::json!({"deleted": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn tag_media(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(media_id): Path<Uuid>,
|
||||||
|
Json(req): Json<TagMediaRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
pinakes_core::tags::tag_media(&state.storage, MediaId(media_id), req.tag_id).await?;
|
||||||
|
Ok(Json(serde_json::json!({"tagged": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn untag_media(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((media_id, tag_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
pinakes_core::tags::untag_media(&state.storage, MediaId(media_id), tag_id).await?;
|
||||||
|
Ok(Json(serde_json::json!({"untagged": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_media_tags(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(media_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Vec<TagResponse>>, ApiError> {
|
||||||
|
let tags = state.storage.get_media_tags(MediaId(media_id)).await?;
|
||||||
|
Ok(Json(tags.into_iter().map(TagResponse::from).collect()))
|
||||||
|
}
|
||||||
40
crates/pinakes-server/src/routes/webhooks.rs
Normal file
40
crates/pinakes-server/src/routes/webhooks.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct WebhookInfo {
|
||||||
|
pub url: String,
|
||||||
|
pub events: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_webhooks(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Vec<WebhookInfo>>, ApiError> {
|
||||||
|
let config = state.config.read().await;
|
||||||
|
let hooks: Vec<WebhookInfo> = config
|
||||||
|
.webhooks
|
||||||
|
.iter()
|
||||||
|
.map(|h| WebhookInfo {
|
||||||
|
url: h.url.clone(),
|
||||||
|
events: h.events.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(hooks))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn test_webhook(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let config = state.config.read().await;
|
||||||
|
let count = config.webhooks.len();
|
||||||
|
// Emit a test event to all configured webhooks
|
||||||
|
// In production, the event bus would handle delivery
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"webhooks_configured": count,
|
||||||
|
"test_sent": true
|
||||||
|
})))
|
||||||
|
}
|
||||||
50
crates/pinakes-server/src/state.rs
Normal file
50
crates/pinakes-server/src/state.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use pinakes_core::cache::CacheLayer;
|
||||||
|
use pinakes_core::config::{Config, UserRole};
|
||||||
|
use pinakes_core::jobs::JobQueue;
|
||||||
|
use pinakes_core::scan::ScanProgress;
|
||||||
|
use pinakes_core::scheduler::TaskScheduler;
|
||||||
|
use pinakes_core::storage::DynStorageBackend;
|
||||||
|
|
||||||
|
/// Default session TTL: 24 hours.
|
||||||
|
pub const SESSION_TTL_SECS: i64 = 24 * 60 * 60;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SessionInfo {
|
||||||
|
pub username: String,
|
||||||
|
pub role: UserRole,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionInfo {
|
||||||
|
/// Returns true if this session has exceeded its TTL.
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
let age = chrono::Utc::now() - self.created_at;
|
||||||
|
age.num_seconds() > SESSION_TTL_SECS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SessionStore = Arc<RwLock<HashMap<String, SessionInfo>>>;
|
||||||
|
|
||||||
|
/// Remove all expired sessions from the store.
|
||||||
|
pub async fn cleanup_expired_sessions(sessions: &SessionStore) {
|
||||||
|
let mut store = sessions.write().await;
|
||||||
|
store.retain(|_, info| !info.is_expired());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub storage: DynStorageBackend,
|
||||||
|
pub config: Arc<RwLock<Config>>,
|
||||||
|
pub config_path: Option<PathBuf>,
|
||||||
|
pub scan_progress: ScanProgress,
|
||||||
|
pub sessions: SessionStore,
|
||||||
|
pub job_queue: Arc<JobQueue>,
|
||||||
|
pub cache: Arc<CacheLayer>,
|
||||||
|
pub scheduler: Arc<TaskScheduler>,
|
||||||
|
}
|
||||||
212
crates/pinakes-server/tests/api_test.rs
Normal file
212
crates/pinakes-server/tests/api_test.rs
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::ConnectInfo;
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
use pinakes_core::cache::CacheLayer;
|
||||||
|
use pinakes_core::config::{
|
||||||
|
AccountsConfig, Config, DirectoryConfig, JobsConfig, ScanningConfig, ServerConfig,
|
||||||
|
SqliteConfig, StorageBackendType, StorageConfig, ThumbnailConfig, UiConfig, WebhookConfig,
|
||||||
|
};
|
||||||
|
use pinakes_core::jobs::JobQueue;
|
||||||
|
use pinakes_core::storage::StorageBackend;
|
||||||
|
use pinakes_core::storage::sqlite::SqliteBackend;
|
||||||
|
|
||||||
|
/// Fake socket address for tests (governor needs ConnectInfo<SocketAddr>)
|
||||||
|
fn test_addr() -> ConnectInfo<SocketAddr> {
|
||||||
|
ConnectInfo("127.0.0.1:9999".parse().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a GET request with ConnectInfo for rate limiter compatibility
|
||||||
|
fn get(uri: &str) -> Request<Body> {
|
||||||
|
let mut req = Request::builder().uri(uri).body(Body::empty()).unwrap();
|
||||||
|
req.extensions_mut().insert(test_addr());
|
||||||
|
req
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a POST request with ConnectInfo
|
||||||
|
fn post_json(uri: &str, body: &str) -> Request<Body> {
|
||||||
|
let mut req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(uri)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(body.to_string()))
|
||||||
|
.unwrap();
|
||||||
|
req.extensions_mut().insert(test_addr());
|
||||||
|
req
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup_app() -> axum::Router {
|
||||||
|
let backend = SqliteBackend::in_memory().expect("in-memory SQLite");
|
||||||
|
backend.run_migrations().await.expect("migrations");
|
||||||
|
let storage = Arc::new(backend) as pinakes_core::storage::DynStorageBackend;
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
storage: StorageConfig {
|
||||||
|
backend: StorageBackendType::Sqlite,
|
||||||
|
sqlite: Some(SqliteConfig {
|
||||||
|
path: ":memory:".into(),
|
||||||
|
}),
|
||||||
|
postgres: None,
|
||||||
|
},
|
||||||
|
directories: DirectoryConfig { roots: vec![] },
|
||||||
|
scanning: ScanningConfig {
|
||||||
|
watch: false,
|
||||||
|
poll_interval_secs: 300,
|
||||||
|
ignore_patterns: vec![],
|
||||||
|
import_concurrency: 8,
|
||||||
|
},
|
||||||
|
server: ServerConfig {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 3000,
|
||||||
|
api_key: None,
|
||||||
|
},
|
||||||
|
ui: UiConfig::default(),
|
||||||
|
accounts: AccountsConfig::default(),
|
||||||
|
jobs: JobsConfig::default(),
|
||||||
|
thumbnails: ThumbnailConfig::default(),
|
||||||
|
webhooks: Vec::<WebhookConfig>::new(),
|
||||||
|
scheduled_tasks: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let job_queue = JobQueue::new(1, |_id, _kind, _cancel, _jobs| tokio::spawn(async {}));
|
||||||
|
let config = Arc::new(RwLock::new(config));
|
||||||
|
let scheduler = pinakes_core::scheduler::TaskScheduler::new(
|
||||||
|
job_queue.clone(),
|
||||||
|
tokio_util::sync::CancellationToken::new(),
|
||||||
|
config.clone(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let state = pinakes_server::state::AppState {
|
||||||
|
storage,
|
||||||
|
config,
|
||||||
|
config_path: None,
|
||||||
|
scan_progress: pinakes_core::scan::ScanProgress::new(),
|
||||||
|
sessions: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||||
|
job_queue,
|
||||||
|
cache: Arc::new(CacheLayer::new(60)),
|
||||||
|
scheduler: Arc::new(scheduler),
|
||||||
|
};
|
||||||
|
|
||||||
|
pinakes_server::app::create_router(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_list_media_empty() {
|
||||||
|
let app = setup_app().await;
|
||||||
|
|
||||||
|
let response = app.oneshot(get("/api/v1/media")).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let items: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(items.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_and_list_tags() {
|
||||||
|
let app = setup_app().await;
|
||||||
|
|
||||||
|
// Create a tag
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(post_json("/api/v1/tags", r#"{"name":"Music"}"#))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// List tags
|
||||||
|
let response = app.oneshot(get("/api/v1/tags")).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let tags: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(tags.len(), 1);
|
||||||
|
assert_eq!(tags[0]["name"], "Music");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_search_empty() {
|
||||||
|
let app = setup_app().await;
|
||||||
|
|
||||||
|
let response = app.oneshot(get("/api/v1/search?q=test")).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(result["total_count"], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_media_not_found() {
|
||||||
|
let app = setup_app().await;
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(get("/api/v1/media/00000000-0000-0000-0000-000000000000"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_collections_crud() {
|
||||||
|
let app = setup_app().await;
|
||||||
|
|
||||||
|
// Create collection
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(post_json(
|
||||||
|
"/api/v1/collections",
|
||||||
|
r#"{"name":"Favorites","kind":"manual"}"#,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// List collections
|
||||||
|
let response = app.oneshot(get("/api/v1/collections")).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let cols: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(cols.len(), 1);
|
||||||
|
assert_eq!(cols[0]["name"], "Favorites");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_statistics_endpoint() {
|
||||||
|
let app = setup_app().await;
|
||||||
|
|
||||||
|
let response = app.oneshot(get("/api/v1/statistics")).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let stats: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(stats["total_media"], 0);
|
||||||
|
assert_eq!(stats["total_size_bytes"], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_scheduled_tasks_endpoint() {
|
||||||
|
let app = setup_app().await;
|
||||||
|
|
||||||
|
let response = app.oneshot(get("/api/v1/tasks/scheduled")).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let tasks: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(!tasks.is_empty(), "should have default scheduled tasks");
|
||||||
|
// Verify structure of first task
|
||||||
|
assert!(tasks[0]["id"].is_string());
|
||||||
|
assert!(tasks[0]["name"].is_string());
|
||||||
|
assert!(tasks[0]["schedule"].is_string());
|
||||||
|
}
|
||||||
20
crates/pinakes-tui/Cargo.toml
Normal file
20
crates/pinakes-tui/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
[package]
|
||||||
|
name = "pinakes-tui"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
ratatui = { workspace = true }
|
||||||
|
crossterm = { workspace = true }
|
||||||
1029
crates/pinakes-tui/src/app.rs
Normal file
1029
crates/pinakes-tui/src/app.rs
Normal file
File diff suppressed because it is too large
Load diff
455
crates/pinakes-tui/src/client.rs
Normal file
455
crates/pinakes-tui/src/client.rs
Normal file
|
|
@ -0,0 +1,455 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ApiClient {
|
||||||
|
client: Client,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response types (mirror server DTOs)
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct MediaResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub path: String,
|
||||||
|
pub file_name: String,
|
||||||
|
pub media_type: String,
|
||||||
|
pub content_hash: String,
|
||||||
|
pub file_size: u64,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub genre: Option<String>,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub duration_secs: Option<f64>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_thumbnail: bool,
|
||||||
|
pub custom_fields: HashMap<String, CustomFieldResponse>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct CustomFieldResponse {
|
||||||
|
pub field_type: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ImportResponse {
|
||||||
|
pub media_id: String,
|
||||||
|
pub was_duplicate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct TagResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub parent_id: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct CollectionResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub kind: String,
|
||||||
|
pub filter_query: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct SearchResponse {
|
||||||
|
pub items: Vec<MediaResponse>,
|
||||||
|
pub total_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct AuditEntryResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub media_id: Option<String>,
|
||||||
|
pub action: String,
|
||||||
|
pub details: Option<String>,
|
||||||
|
pub timestamp: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ScanResponse {
|
||||||
|
pub files_found: usize,
|
||||||
|
pub files_processed: usize,
|
||||||
|
pub errors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct DatabaseStatsResponse {
|
||||||
|
pub media_count: u64,
|
||||||
|
pub tag_count: u64,
|
||||||
|
pub collection_count: u64,
|
||||||
|
pub audit_count: u64,
|
||||||
|
pub database_size_bytes: u64,
|
||||||
|
pub backend_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct DuplicateGroupResponse {
|
||||||
|
pub content_hash: String,
|
||||||
|
pub items: Vec<MediaResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct JobResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub kind: serde_json::Value,
|
||||||
|
pub status: serde_json::Value,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ScheduledTaskResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub schedule: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub last_run: Option<String>,
|
||||||
|
pub next_run: Option<String>,
|
||||||
|
pub last_status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct LibraryStatisticsResponse {
|
||||||
|
pub total_media: u64,
|
||||||
|
pub total_size_bytes: u64,
|
||||||
|
pub avg_file_size_bytes: u64,
|
||||||
|
pub media_by_type: Vec<TypeCount>,
|
||||||
|
pub storage_by_type: Vec<TypeCount>,
|
||||||
|
pub newest_item: Option<String>,
|
||||||
|
pub oldest_item: Option<String>,
|
||||||
|
pub top_tags: Vec<TypeCount>,
|
||||||
|
pub top_collections: Vec<TypeCount>,
|
||||||
|
pub total_tags: u64,
|
||||||
|
pub total_collections: u64,
|
||||||
|
pub total_duplicates: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct TypeCount {
|
||||||
|
pub name: String,
|
||||||
|
pub count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiClient {
|
||||||
|
pub fn new(base_url: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
client: Client::new(),
|
||||||
|
base_url: base_url.trim_end_matches('/').to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self, path: &str) -> String {
|
||||||
|
format!("{}/api/v1{}", self.base_url, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_media(&self, offset: u64, limit: u64) -> Result<Vec<MediaResponse>> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(self.url("/media"))
|
||||||
|
.query(&[("offset", offset.to_string()), ("limit", limit.to_string())])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_media(&self, id: &str) -> Result<MediaResponse> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(self.url(&format!("/media/{id}")))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn import_file(&self, path: &str) -> Result<ImportResponse> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(self.url("/media/import"))
|
||||||
|
.json(&serde_json::json!({"path": path}))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_media(&self, id: &str) -> Result<()> {
|
||||||
|
self.client
|
||||||
|
.delete(self.url(&format!("/media/{id}")))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn open_media(&self, id: &str) -> Result<()> {
|
||||||
|
self.client
|
||||||
|
.post(self.url(&format!("/media/{id}/open")))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search(&self, query: &str, offset: u64, limit: u64) -> Result<SearchResponse> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(self.url("/search"))
|
||||||
|
.query(&[
|
||||||
|
("q", query.to_string()),
|
||||||
|
("offset", offset.to_string()),
|
||||||
|
("limit", limit.to_string()),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_tags(&self) -> Result<Vec<TagResponse>> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(self.url("/tags"))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_tag(&self, name: &str, parent_id: Option<&str>) -> Result<TagResponse> {
|
||||||
|
let mut body = serde_json::json!({"name": name});
|
||||||
|
if let Some(pid) = parent_id {
|
||||||
|
body["parent_id"] = serde_json::Value::String(pid.to_string());
|
||||||
|
}
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(self.url("/tags"))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_tag(&self, id: &str) -> Result<()> {
|
||||||
|
self.client
|
||||||
|
.delete(self.url(&format!("/tags/{id}")))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn tag_media(&self, media_id: &str, tag_id: &str) -> Result<()> {
|
||||||
|
self.client
|
||||||
|
.post(self.url(&format!("/media/{media_id}/tags")))
|
||||||
|
.json(&serde_json::json!({"tag_id": tag_id}))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn untag_media(&self, media_id: &str, tag_id: &str) -> Result<()> {
|
||||||
|
self.client
|
||||||
|
.delete(self.url(&format!("/media/{media_id}/tags/{tag_id}")))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_media_tags(&self, media_id: &str) -> Result<Vec<TagResponse>> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(self.url(&format!("/media/{media_id}/tags")))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_collections(&self) -> Result<Vec<CollectionResponse>> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(self.url("/collections"))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_collection(&self, id: &str) -> Result<()> {
|
||||||
|
self.client
|
||||||
|
.delete(self.url(&format!("/collections/{id}")))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn trigger_scan(&self, path: Option<&str>) -> Result<Vec<ScanResponse>> {
|
||||||
|
let body = match path {
|
||||||
|
Some(p) => serde_json::json!({"path": p}),
|
||||||
|
None => serde_json::json!({"path": null}),
|
||||||
|
};
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(self.url("/scan"))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_audit(&self, offset: u64, limit: u64) -> Result<Vec<AuditEntryResponse>> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(self.url("/audit"))
|
||||||
|
.query(&[("offset", offset.to_string()), ("limit", limit.to_string())])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_duplicates(&self) -> Result<Vec<DuplicateGroupResponse>> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(self.url("/duplicates"))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn database_stats(&self) -> Result<DatabaseStatsResponse> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(self.url("/database/stats"))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_jobs(&self) -> Result<Vec<JobResponse>> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(self.url("/jobs"))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn vacuum_database(&self) -> Result<()> {
|
||||||
|
self.client
|
||||||
|
.post(self.url("/database/vacuum"))
|
||||||
|
.json(&serde_json::json!({}))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_media(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
updates: serde_json::Value,
|
||||||
|
) -> Result<MediaResponse> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.patch(self.url(&format!("/media/{id}")))
|
||||||
|
.json(&updates)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn library_statistics(&self) -> Result<LibraryStatisticsResponse> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(self.url("/statistics"))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_scheduled_tasks(&self) -> Result<Vec<ScheduledTaskResponse>> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(self.url("/tasks/scheduled"))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn toggle_scheduled_task(&self, id: &str) -> Result<()> {
|
||||||
|
self.client
|
||||||
|
.post(self.url(&format!("/tasks/scheduled/{id}/toggle")))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_task_now(&self, id: &str) -> Result<()> {
|
||||||
|
self.client
|
||||||
|
.post(self.url(&format!("/tasks/scheduled/{id}/run-now")))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
74
crates/pinakes-tui/src/event.rs
Normal file
74
crates/pinakes-tui/src/event.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crossterm::event::{self, Event as CrosstermEvent, KeyEvent};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AppEvent {
|
||||||
|
Key(KeyEvent),
|
||||||
|
Tick,
|
||||||
|
ApiResult(ApiResult),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum ApiResult {
|
||||||
|
MediaList(Vec<crate::client::MediaResponse>),
|
||||||
|
SearchResults(crate::client::SearchResponse),
|
||||||
|
Tags(Vec<crate::client::TagResponse>),
|
||||||
|
AllTags(Vec<crate::client::TagResponse>),
|
||||||
|
Collections(Vec<crate::client::CollectionResponse>),
|
||||||
|
ImportDone(crate::client::ImportResponse),
|
||||||
|
ScanDone(Vec<crate::client::ScanResponse>),
|
||||||
|
AuditLog(Vec<crate::client::AuditEntryResponse>),
|
||||||
|
Duplicates(Vec<crate::client::DuplicateGroupResponse>),
|
||||||
|
DatabaseStats(crate::client::DatabaseStatsResponse),
|
||||||
|
Statistics(crate::client::LibraryStatisticsResponse),
|
||||||
|
ScheduledTasks(Vec<crate::client::ScheduledTaskResponse>),
|
||||||
|
MediaUpdated,
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EventHandler {
|
||||||
|
tx: mpsc::UnboundedSender<AppEvent>,
|
||||||
|
rx: mpsc::UnboundedReceiver<AppEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventHandler {
|
||||||
|
pub fn new(tick_rate: Duration) -> Self {
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
let event_tx = tx.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
match event::poll(tick_rate) {
|
||||||
|
Ok(true) => {
|
||||||
|
if let Ok(CrosstermEvent::Key(key)) = event::read()
|
||||||
|
&& event_tx.send(AppEvent::Key(key)).is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
if event_tx.send(AppEvent::Tick).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "event poll failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self { tx, rx }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sender(&self) -> mpsc::UnboundedSender<AppEvent> {
|
||||||
|
self.tx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn next(&mut self) -> Option<AppEvent> {
|
||||||
|
self.rx.recv().await
|
||||||
|
}
|
||||||
|
}
|
||||||
97
crates/pinakes-tui/src/input.rs
Normal file
97
crates/pinakes-tui/src/input.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
|
use crate::app::View;
|
||||||
|
|
||||||
|
pub enum Action {
|
||||||
|
Quit,
|
||||||
|
NavigateUp,
|
||||||
|
NavigateDown,
|
||||||
|
NavigateLeft,
|
||||||
|
NavigateRight,
|
||||||
|
Select,
|
||||||
|
Back,
|
||||||
|
Search,
|
||||||
|
Import,
|
||||||
|
Delete,
|
||||||
|
DeleteSelected,
|
||||||
|
Open,
|
||||||
|
TagView,
|
||||||
|
CollectionView,
|
||||||
|
AuditView,
|
||||||
|
SettingsView,
|
||||||
|
DuplicatesView,
|
||||||
|
DatabaseView,
|
||||||
|
QueueView,
|
||||||
|
StatisticsView,
|
||||||
|
TasksView,
|
||||||
|
ScanTrigger,
|
||||||
|
Refresh,
|
||||||
|
NextTab,
|
||||||
|
PrevTab,
|
||||||
|
PageUp,
|
||||||
|
PageDown,
|
||||||
|
GoTop,
|
||||||
|
GoBottom,
|
||||||
|
CreateTag,
|
||||||
|
TagMedia,
|
||||||
|
UntagMedia,
|
||||||
|
Help,
|
||||||
|
Char(char),
|
||||||
|
Backspace,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_key(key: KeyEvent, in_input_mode: bool, current_view: &View) -> Action {
|
||||||
|
if in_input_mode {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => Action::Back,
|
||||||
|
KeyCode::Enter => Action::Select,
|
||||||
|
KeyCode::Char(c) => Action::Char(c),
|
||||||
|
KeyCode::Backspace => Action::Backspace,
|
||||||
|
_ => Action::None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => Action::Quit,
|
||||||
|
(KeyCode::Up | KeyCode::Char('k'), _) => Action::NavigateUp,
|
||||||
|
(KeyCode::Down | KeyCode::Char('j'), _) => Action::NavigateDown,
|
||||||
|
(KeyCode::Left | KeyCode::Char('h'), _) => Action::NavigateLeft,
|
||||||
|
(KeyCode::Right | KeyCode::Char('l'), _) => Action::NavigateRight,
|
||||||
|
(KeyCode::Home, _) => Action::GoTop,
|
||||||
|
(KeyCode::End, _) => Action::GoBottom,
|
||||||
|
(KeyCode::Enter, _) => Action::Select,
|
||||||
|
(KeyCode::Esc, _) => Action::Back,
|
||||||
|
(KeyCode::Char('/'), _) => Action::Search,
|
||||||
|
(KeyCode::Char('?'), _) => Action::Help,
|
||||||
|
(KeyCode::Char('i'), _) => Action::Import,
|
||||||
|
(KeyCode::Char('d'), _) => match current_view {
|
||||||
|
View::Tags | View::Collections => Action::DeleteSelected,
|
||||||
|
_ => Action::Delete,
|
||||||
|
},
|
||||||
|
(KeyCode::Char('o'), _) => Action::Open,
|
||||||
|
(KeyCode::Char('e'), _) => match current_view {
|
||||||
|
View::Detail => Action::Select,
|
||||||
|
_ => Action::None,
|
||||||
|
},
|
||||||
|
(KeyCode::Char('t'), _) => Action::TagView,
|
||||||
|
(KeyCode::Char('c'), _) => Action::CollectionView,
|
||||||
|
(KeyCode::Char('a'), _) => Action::AuditView,
|
||||||
|
(KeyCode::Char('S'), _) => Action::SettingsView,
|
||||||
|
(KeyCode::Char('D'), _) => Action::DuplicatesView,
|
||||||
|
(KeyCode::Char('B'), _) => Action::DatabaseView,
|
||||||
|
(KeyCode::Char('Q'), _) => Action::QueueView,
|
||||||
|
(KeyCode::Char('X'), _) => Action::StatisticsView,
|
||||||
|
(KeyCode::Char('T'), _) => Action::TasksView,
|
||||||
|
(KeyCode::Char('s'), _) => Action::ScanTrigger,
|
||||||
|
(KeyCode::Char('r'), _) => Action::Refresh,
|
||||||
|
(KeyCode::Char('n'), _) => Action::CreateTag,
|
||||||
|
(KeyCode::Char('+'), _) => Action::TagMedia,
|
||||||
|
(KeyCode::Char('-'), _) => Action::UntagMedia,
|
||||||
|
(KeyCode::Tab, _) => Action::NextTab,
|
||||||
|
(KeyCode::BackTab, _) => Action::PrevTab,
|
||||||
|
(KeyCode::PageUp, _) => Action::PageUp,
|
||||||
|
(KeyCode::PageDown, _) => Action::PageDown,
|
||||||
|
_ => Action::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
crates/pinakes-tui/src/main.rs
Normal file
55
crates/pinakes-tui/src/main.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod client;
|
||||||
|
mod event;
|
||||||
|
mod input;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
/// Pinakes terminal UI client
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "pinakes-tui", version, about)]
|
||||||
|
struct Cli {
|
||||||
|
/// Server URL to connect to
|
||||||
|
#[arg(
|
||||||
|
short,
|
||||||
|
long,
|
||||||
|
env = "PINAKES_SERVER_URL",
|
||||||
|
default_value = "http://localhost:3000"
|
||||||
|
)]
|
||||||
|
server: String,
|
||||||
|
|
||||||
|
/// Set log level (trace, debug, info, warn, error)
|
||||||
|
#[arg(long, default_value = "warn")]
|
||||||
|
log_level: String,
|
||||||
|
|
||||||
|
/// Log to file instead of stderr (avoids corrupting TUI display)
|
||||||
|
#[arg(long)]
|
||||||
|
log_file: Option<std::path::PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
// Initialize logging - for TUI, must log to file to avoid corrupting the display
|
||||||
|
let env_filter = EnvFilter::try_new(&cli.log_level).unwrap_or_else(|_| EnvFilter::new("warn"));
|
||||||
|
|
||||||
|
if let Some(log_path) = &cli.log_file {
|
||||||
|
let file = std::fs::File::create(log_path)?;
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(env_filter)
|
||||||
|
.with_writer(file)
|
||||||
|
.with_ansi(false)
|
||||||
|
.init();
|
||||||
|
} else {
|
||||||
|
// When no log file specified, suppress all output to avoid TUI corruption
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(EnvFilter::new("off"))
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
app::run(&cli.server).await
|
||||||
|
}
|
||||||
85
crates/pinakes-tui/src/ui/audit.rs
Normal file
85
crates/pinakes-tui/src/ui/audit.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::Span;
|
||||||
|
use ratatui::widgets::{Block, Borders, Cell, Row, Table};
|
||||||
|
|
||||||
|
use super::format_date;
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
/// Return a color for an audit action string.
|
||||||
|
fn action_color(action: &str) -> Color {
|
||||||
|
match action {
|
||||||
|
"imported" | "import" | "created" => Color::Green,
|
||||||
|
"deleted" | "delete" | "removed" => Color::Red,
|
||||||
|
"tagged" | "tag_added" => Color::Cyan,
|
||||||
|
"untagged" | "tag_removed" => Color::Yellow,
|
||||||
|
"updated" | "modified" | "edited" => Color::Blue,
|
||||||
|
"scanned" | "scan" => Color::Magenta,
|
||||||
|
_ => Color::White,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
let header = Row::new(vec!["Action", "Media ID", "Details", "Date"]).style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows: Vec<Row> = state
|
||||||
|
.audit_log
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, entry)| {
|
||||||
|
let style = if Some(i) == state.audit_selected {
|
||||||
|
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let color = action_color(&entry.action);
|
||||||
|
let action_cell = Cell::from(Span::styled(
|
||||||
|
entry.action.clone(),
|
||||||
|
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Truncate media ID for display
|
||||||
|
let media_display = entry
|
||||||
|
.media_id
|
||||||
|
.as_deref()
|
||||||
|
.map(|id| {
|
||||||
|
if id.len() > 12 {
|
||||||
|
format!("{}...", &id[..12])
|
||||||
|
} else {
|
||||||
|
id.to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "-".into());
|
||||||
|
|
||||||
|
Row::new(vec![
|
||||||
|
action_cell,
|
||||||
|
Cell::from(media_display),
|
||||||
|
Cell::from(entry.details.clone().unwrap_or_else(|| "-".into())),
|
||||||
|
Cell::from(format_date(&entry.timestamp).to_string()),
|
||||||
|
])
|
||||||
|
.style(style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let title = format!(" Audit Log ({}) ", state.audit_log.len());
|
||||||
|
|
||||||
|
let table = Table::new(
|
||||||
|
rows,
|
||||||
|
[
|
||||||
|
ratatui::layout::Constraint::Percentage(18),
|
||||||
|
ratatui::layout::Constraint::Percentage(22),
|
||||||
|
ratatui::layout::Constraint::Percentage(40),
|
||||||
|
ratatui::layout::Constraint::Percentage(20),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.header(header)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(title));
|
||||||
|
|
||||||
|
f.render_widget(table, area);
|
||||||
|
}
|
||||||
64
crates/pinakes-tui/src/ui/collections.rs
Normal file
64
crates/pinakes-tui/src/ui/collections.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::widgets::{Block, Borders, Row, Table};
|
||||||
|
|
||||||
|
use super::format_date;
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
let header = Row::new(vec!["Name", "Kind", "Description", "Members", "Created"]).style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows: Vec<Row> = state
|
||||||
|
.collections
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, col)| {
|
||||||
|
let style = if Some(i) == state.collection_selected {
|
||||||
|
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// We show the filter_query as a proxy for member info when kind is "smart"
|
||||||
|
let members_display = if col.kind == "smart" {
|
||||||
|
col.filter_query
|
||||||
|
.as_deref()
|
||||||
|
.map(|q| format!("filter: {q}"))
|
||||||
|
.unwrap_or_else(|| "-".to_string())
|
||||||
|
} else {
|
||||||
|
"-".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
Row::new(vec![
|
||||||
|
col.name.clone(),
|
||||||
|
col.kind.clone(),
|
||||||
|
col.description.clone().unwrap_or_else(|| "-".into()),
|
||||||
|
members_display,
|
||||||
|
format_date(&col.created_at).to_string(),
|
||||||
|
])
|
||||||
|
.style(style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let title = format!(" Collections ({}) ", state.collections.len());
|
||||||
|
|
||||||
|
let table = Table::new(
|
||||||
|
rows,
|
||||||
|
[
|
||||||
|
ratatui::layout::Constraint::Percentage(25),
|
||||||
|
ratatui::layout::Constraint::Percentage(12),
|
||||||
|
ratatui::layout::Constraint::Percentage(28),
|
||||||
|
ratatui::layout::Constraint::Percentage(15),
|
||||||
|
ratatui::layout::Constraint::Percentage(20),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.header(header)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(title));
|
||||||
|
|
||||||
|
f.render_widget(table, area);
|
||||||
|
}
|
||||||
55
crates/pinakes-tui/src/ui/database.rs
Normal file
55
crates/pinakes-tui/src/ui/database.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
|
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
let label_style = Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let value_style = Style::default().fg(Color::White);
|
||||||
|
let section_style = Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
|
||||||
|
let pad = " ";
|
||||||
|
|
||||||
|
let mut lines = vec![
|
||||||
|
Line::default(),
|
||||||
|
Line::from(Span::styled("--- Database Statistics ---", section_style)),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(ref stats) = state.database_stats {
|
||||||
|
for (key, value) in stats {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(format!("{key:<20}"), label_style),
|
||||||
|
Span::styled(value.to_string(), value_style),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::raw("Press 'r' to load database statistics"),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::default());
|
||||||
|
lines.push(Line::from(Span::styled("--- Actions ---", section_style)));
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::raw("v: Vacuum database"),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::raw("Esc: Return to library"),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let paragraph =
|
||||||
|
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Database "));
|
||||||
|
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
223
crates/pinakes-tui/src/ui/detail.rs
Normal file
223
crates/pinakes-tui/src/ui/detail.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
|
|
||||||
|
use super::{format_date, format_duration, format_size, media_type_color};
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
let item = match &state.selected_media {
|
||||||
|
Some(item) => item,
|
||||||
|
None => {
|
||||||
|
let msg = Paragraph::new("No item selected")
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" Detail "));
|
||||||
|
f.render_widget(msg, area);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Min(0)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let label_style = Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let value_style = Style::default().fg(Color::White);
|
||||||
|
let dim_style = Style::default().fg(Color::DarkGray);
|
||||||
|
|
||||||
|
let pad = " ";
|
||||||
|
let label_width = 14;
|
||||||
|
let make_label = |name: &str| -> String { format!("{name:<label_width$}") };
|
||||||
|
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
// Section: File Info
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"--- File Info ---",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)));
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(make_label("Name"), label_style),
|
||||||
|
Span::styled(&item.file_name, value_style),
|
||||||
|
]));
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(make_label("Path"), label_style),
|
||||||
|
Span::styled(&item.path, dim_style),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let type_color = media_type_color(&item.media_type);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(make_label("Type"), label_style),
|
||||||
|
Span::styled(&item.media_type, Style::default().fg(type_color)),
|
||||||
|
]));
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(make_label("Size"), label_style),
|
||||||
|
Span::styled(format_size(item.file_size), value_style),
|
||||||
|
]));
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(make_label("Hash"), label_style),
|
||||||
|
Span::styled(&item.content_hash, dim_style),
|
||||||
|
]));
|
||||||
|
|
||||||
|
if item.has_thumbnail {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(make_label("Thumbnail"), label_style),
|
||||||
|
Span::styled("Yes", Style::default().fg(Color::Green)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::default()); // blank line
|
||||||
|
|
||||||
|
// Section: Metadata
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"--- Metadata ---",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)));
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(make_label("Title"), label_style),
|
||||||
|
Span::styled(item.title.as_deref().unwrap_or("-"), value_style),
|
||||||
|
]));
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(make_label("Artist"), label_style),
|
||||||
|
Span::styled(item.artist.as_deref().unwrap_or("-"), value_style),
|
||||||
|
]));
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(make_label("Album"), label_style),
|
||||||
|
Span::styled(item.album.as_deref().unwrap_or("-"), value_style),
|
||||||
|
]));
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(make_label("Genre"), label_style),
|
||||||
|
Span::styled(item.genre.as_deref().unwrap_or("-"), value_style),
|
||||||
|
]));
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(make_label("Year"), label_style),
|
||||||
|
Span::styled(
|
||||||
|
item.year
|
||||||
|
.map(|y| y.to_string())
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
value_style,
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(make_label("Duration"), label_style),
|
||||||
|
Span::styled(
|
||||||
|
item.duration_secs
|
||||||
|
.map(format_duration)
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
value_style,
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Description
|
||||||
|
if let Some(ref desc) = item.description
|
||||||
|
&& !desc.is_empty()
|
||||||
|
{
|
||||||
|
lines.push(Line::default());
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(make_label("Description"), label_style),
|
||||||
|
Span::styled(desc.as_str(), value_style),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom fields
|
||||||
|
if !item.custom_fields.is_empty() {
|
||||||
|
lines.push(Line::default());
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"--- Custom Fields ---",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)));
|
||||||
|
let mut fields: Vec<_> = item.custom_fields.iter().collect();
|
||||||
|
fields.sort_by_key(|(k, _)| k.as_str());
|
||||||
|
for (key, field) in fields {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(format!("{key:<label_width$}"), label_style),
|
||||||
|
Span::styled(
|
||||||
|
format!("{} ({})", field.value, field.field_type),
|
||||||
|
value_style,
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags section
|
||||||
|
if !state.tags.is_empty() {
|
||||||
|
lines.push(Line::default());
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"--- Tags ---",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)));
|
||||||
|
let tag_names: Vec<&str> = state.tags.iter().map(|t| t.name.as_str()).collect();
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(tag_names.join(", "), Style::default().fg(Color::Green)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::default());
|
||||||
|
|
||||||
|
// Section: Timestamps
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"--- Timestamps ---",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)));
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(make_label("Created"), label_style),
|
||||||
|
Span::styled(format_date(&item.created_at), dim_style),
|
||||||
|
]));
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(make_label("Updated"), label_style),
|
||||||
|
Span::styled(format_date(&item.updated_at), dim_style),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let title = if let Some(ref title_str) = item.title {
|
||||||
|
format!(" Detail: {} ", title_str)
|
||||||
|
} else {
|
||||||
|
format!(" Detail: {} ", item.file_name)
|
||||||
|
};
|
||||||
|
|
||||||
|
let detail = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(title));
|
||||||
|
|
||||||
|
f.render_widget(detail, chunks[0]);
|
||||||
|
}
|
||||||
56
crates/pinakes-tui/src/ui/duplicates.rs
Normal file
56
crates/pinakes-tui/src/ui/duplicates.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, List, ListItem};
|
||||||
|
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
let items: Vec<ListItem> = if state.duplicate_groups.is_empty() {
|
||||||
|
vec![ListItem::new(Line::from(Span::styled(
|
||||||
|
" No duplicates found. Press 'r' to refresh.",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)))]
|
||||||
|
} else {
|
||||||
|
let mut list_items = Vec::new();
|
||||||
|
for (i, group) in state.duplicate_groups.iter().enumerate() {
|
||||||
|
let header = format!(
|
||||||
|
"Group {} ({} items, hash: {})",
|
||||||
|
i + 1,
|
||||||
|
group.len(),
|
||||||
|
group
|
||||||
|
.first()
|
||||||
|
.map(|m| m.content_hash.as_str())
|
||||||
|
.unwrap_or("?")
|
||||||
|
);
|
||||||
|
list_items.push(ListItem::new(Line::from(Span::styled(
|
||||||
|
header,
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))));
|
||||||
|
for item in group {
|
||||||
|
let line = format!(" {} - {}", item.file_name, item.path);
|
||||||
|
let is_selected = state
|
||||||
|
.duplicates_selected
|
||||||
|
.map(|sel| sel == list_items.len())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let style = if is_selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
list_items.push(ListItem::new(Line::from(Span::styled(line, style))));
|
||||||
|
}
|
||||||
|
list_items.push(ListItem::new(Line::default()));
|
||||||
|
}
|
||||||
|
list_items
|
||||||
|
};
|
||||||
|
|
||||||
|
let list = List::new(items).block(Block::default().borders(Borders::ALL).title(" Duplicates "));
|
||||||
|
|
||||||
|
f.render_widget(list, area);
|
||||||
|
}
|
||||||
65
crates/pinakes-tui/src/ui/import.rs
Normal file
65
crates/pinakes-tui/src/ui/import.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
|
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(3), Constraint::Min(0)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let input = Paragraph::new(state.import_input.as_str())
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Import File (enter path and press Enter) "),
|
||||||
|
)
|
||||||
|
.style(if state.input_mode {
|
||||||
|
Style::default().fg(Color::Cyan)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
});
|
||||||
|
f.render_widget(input, chunks[0]);
|
||||||
|
|
||||||
|
let label_style = Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let key_style = Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
|
||||||
|
let help_lines = vec![
|
||||||
|
Line::default(),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" Import a file or trigger a library scan",
|
||||||
|
label_style,
|
||||||
|
)),
|
||||||
|
Line::default(),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Enter", key_style),
|
||||||
|
Span::raw(" Import the file at the entered path"),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Esc", key_style),
|
||||||
|
Span::raw(" Cancel and return to library"),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" s", key_style),
|
||||||
|
Span::raw(" Trigger a full library scan (scans all configured directories)"),
|
||||||
|
]),
|
||||||
|
Line::default(),
|
||||||
|
Line::from(Span::styled(" Tips:", label_style)),
|
||||||
|
Line::from(" - Enter an absolute path to a media file (e.g. /home/user/music/song.mp3)"),
|
||||||
|
Line::from(" - The file will be copied into the managed library"),
|
||||||
|
Line::from(" - Duplicates are detected by content hash and will be skipped"),
|
||||||
|
Line::from(" - Press 's' (without typing a path) to scan all library directories"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let help =
|
||||||
|
Paragraph::new(help_lines).block(Block::default().borders(Borders::ALL).title(" Help "));
|
||||||
|
f.render_widget(help, chunks[1]);
|
||||||
|
}
|
||||||
75
crates/pinakes-tui/src/ui/library.rs
Normal file
75
crates/pinakes-tui/src/ui/library.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::{Constraint, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::Span;
|
||||||
|
use ratatui::widgets::{Block, Borders, Cell, Row, Table};
|
||||||
|
|
||||||
|
use super::{format_duration, format_size, media_type_color};
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
let header = Row::new(vec!["Title / Name", "Type", "Duration", "Year", "Size"]).style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows: Vec<Row> = state
|
||||||
|
.media_list
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, item)| {
|
||||||
|
let style = if Some(i) == state.selected_index {
|
||||||
|
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let display_name = item.title.as_deref().unwrap_or(&item.file_name).to_string();
|
||||||
|
|
||||||
|
let type_color = media_type_color(&item.media_type);
|
||||||
|
let type_cell = Cell::from(Span::styled(
|
||||||
|
item.media_type.clone(),
|
||||||
|
Style::default().fg(type_color),
|
||||||
|
));
|
||||||
|
|
||||||
|
let duration = item
|
||||||
|
.duration_secs
|
||||||
|
.map(format_duration)
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
|
||||||
|
let year = item
|
||||||
|
.year
|
||||||
|
.map(|y| y.to_string())
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
|
||||||
|
Row::new(vec![
|
||||||
|
Cell::from(display_name),
|
||||||
|
type_cell,
|
||||||
|
Cell::from(duration),
|
||||||
|
Cell::from(year),
|
||||||
|
Cell::from(format_size(item.file_size)),
|
||||||
|
])
|
||||||
|
.style(style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let page = (state.page_offset / state.page_size) + 1;
|
||||||
|
let item_count = state.media_list.len();
|
||||||
|
let title = format!(" Library (page {page}, {item_count} items) ");
|
||||||
|
|
||||||
|
let table = Table::new(
|
||||||
|
rows,
|
||||||
|
[
|
||||||
|
Constraint::Percentage(35),
|
||||||
|
Constraint::Percentage(20),
|
||||||
|
Constraint::Percentage(15),
|
||||||
|
Constraint::Percentage(10),
|
||||||
|
Constraint::Percentage(20),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.header(header)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(title));
|
||||||
|
|
||||||
|
f.render_widget(table, area);
|
||||||
|
}
|
||||||
83
crates/pinakes-tui/src/ui/metadata_edit.rs
Normal file
83
crates/pinakes-tui/src/ui/metadata_edit.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
|
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(3), Constraint::Min(0)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
let title = if let Some(ref media) = state.selected_media {
|
||||||
|
format!(" Edit: {} ", media.file_name)
|
||||||
|
} else {
|
||||||
|
" Edit Metadata ".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = Paragraph::new(Line::from(Span::styled(
|
||||||
|
&title,
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)))
|
||||||
|
.block(Block::default().borders(Borders::ALL));
|
||||||
|
|
||||||
|
f.render_widget(header, chunks[0]);
|
||||||
|
|
||||||
|
// Edit fields
|
||||||
|
let label_style = Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let value_style = Style::default().fg(Color::White);
|
||||||
|
let active_style = Style::default()
|
||||||
|
.fg(Color::Green)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let pad = " ";
|
||||||
|
|
||||||
|
let fields = [
|
||||||
|
("Title", &state.edit_title),
|
||||||
|
("Artist", &state.edit_artist),
|
||||||
|
("Album", &state.edit_album),
|
||||||
|
("Genre", &state.edit_genre),
|
||||||
|
("Year", &state.edit_year),
|
||||||
|
("Description", &state.edit_description),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
lines.push(Line::default());
|
||||||
|
|
||||||
|
for (i, (label, value)) in fields.iter().enumerate() {
|
||||||
|
let is_active = state.edit_field_index == Some(i);
|
||||||
|
let style = if is_active { active_style } else { label_style };
|
||||||
|
let cursor = if is_active { "> " } else { pad };
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(cursor),
|
||||||
|
Span::styled(format!("{label:<14}"), style),
|
||||||
|
Span::styled(value.as_str(), value_style),
|
||||||
|
if is_active {
|
||||||
|
Span::styled("_", Style::default().fg(Color::Green))
|
||||||
|
} else {
|
||||||
|
Span::raw("")
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::default());
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled(
|
||||||
|
"Tab: Next field Enter: Save Esc: Cancel",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let editor =
|
||||||
|
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Fields "));
|
||||||
|
|
||||||
|
f.render_widget(editor, chunks[1]);
|
||||||
|
}
|
||||||
190
crates/pinakes-tui/src/ui/mod.rs
Normal file
190
crates/pinakes-tui/src/ui/mod.rs
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
pub mod audit;
|
||||||
|
pub mod collections;
|
||||||
|
pub mod database;
|
||||||
|
pub mod detail;
|
||||||
|
pub mod duplicates;
|
||||||
|
pub mod import;
|
||||||
|
pub mod library;
|
||||||
|
pub mod metadata_edit;
|
||||||
|
pub mod queue;
|
||||||
|
pub mod search;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod statistics;
|
||||||
|
pub mod tags;
|
||||||
|
pub mod tasks;
|
||||||
|
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, Paragraph, Tabs};
|
||||||
|
|
||||||
|
use crate::app::{AppState, View};
|
||||||
|
|
||||||
|
/// Format a file size in bytes into a human-readable string.
|
||||||
|
pub fn format_size(bytes: u64) -> String {
|
||||||
|
if bytes < 1024 {
|
||||||
|
format!("{bytes} B")
|
||||||
|
} else if bytes < 1024 * 1024 {
|
||||||
|
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||||
|
} else if bytes < 1024 * 1024 * 1024 {
|
||||||
|
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||||
|
} else {
|
||||||
|
format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format duration in seconds into hh:mm:ss format.
|
||||||
|
pub fn format_duration(secs: f64) -> String {
|
||||||
|
let total = secs as u64;
|
||||||
|
let h = total / 3600;
|
||||||
|
let m = (total % 3600) / 60;
|
||||||
|
let s = total % 60;
|
||||||
|
if h > 0 {
|
||||||
|
format!("{h:02}:{m:02}:{s:02}")
|
||||||
|
} else {
|
||||||
|
format!("{m:02}:{s:02}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trim a timestamp string to just the date portion (YYYY-MM-DD).
|
||||||
|
pub fn format_date(timestamp: &str) -> &str {
|
||||||
|
// Timestamps are typically "2024-01-15T10:30:00Z" or similar
|
||||||
|
if timestamp.len() >= 10 {
|
||||||
|
×tamp[..10]
|
||||||
|
} else {
|
||||||
|
timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a color based on media type string.
|
||||||
|
pub fn media_type_color(media_type: &str) -> Color {
|
||||||
|
match media_type {
|
||||||
|
t if t.starts_with("audio") => Color::Green,
|
||||||
|
t if t.starts_with("video") => Color::Magenta,
|
||||||
|
t if t.starts_with("image") => Color::Yellow,
|
||||||
|
t if t.starts_with("application/pdf") => Color::Red,
|
||||||
|
t if t.starts_with("text") => Color::Cyan,
|
||||||
|
_ => Color::White,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, state: &AppState) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(f.area());
|
||||||
|
|
||||||
|
render_tabs(f, state, chunks[0]);
|
||||||
|
|
||||||
|
match state.current_view {
|
||||||
|
View::Library => library::render(f, state, chunks[1]),
|
||||||
|
View::Search => search::render(f, state, chunks[1]),
|
||||||
|
View::Detail => detail::render(f, state, chunks[1]),
|
||||||
|
View::Tags => tags::render(f, state, chunks[1]),
|
||||||
|
View::Collections => collections::render(f, state, chunks[1]),
|
||||||
|
View::Audit => audit::render(f, state, chunks[1]),
|
||||||
|
View::Import => import::render(f, state, chunks[1]),
|
||||||
|
View::Settings => settings::render(f, state, chunks[1]),
|
||||||
|
View::Duplicates => duplicates::render(f, state, chunks[1]),
|
||||||
|
View::Database => database::render(f, state, chunks[1]),
|
||||||
|
View::MetadataEdit => metadata_edit::render(f, state, chunks[1]),
|
||||||
|
View::Queue => queue::render(f, state, chunks[1]),
|
||||||
|
View::Statistics => statistics::render(f, state, chunks[1]),
|
||||||
|
View::Tasks => tasks::render(f, state, chunks[1]),
|
||||||
|
}
|
||||||
|
|
||||||
|
render_status_bar(f, state, chunks[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
let titles: Vec<Line> = vec![
|
||||||
|
"Library",
|
||||||
|
"Search",
|
||||||
|
"Tags",
|
||||||
|
"Collections",
|
||||||
|
"Audit",
|
||||||
|
"Queue",
|
||||||
|
"Stats",
|
||||||
|
"Tasks",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White))))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let selected = match state.current_view {
|
||||||
|
View::Library | View::Detail | View::Import | View::Settings | View::MetadataEdit => 0,
|
||||||
|
View::Search => 1,
|
||||||
|
View::Tags => 2,
|
||||||
|
View::Collections => 3,
|
||||||
|
View::Audit | View::Duplicates | View::Database => 4,
|
||||||
|
View::Queue => 5,
|
||||||
|
View::Statistics => 6,
|
||||||
|
View::Tasks => 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
let tabs = Tabs::new(titles)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" Pinakes "))
|
||||||
|
.select(selected)
|
||||||
|
.style(Style::default().fg(Color::Gray))
|
||||||
|
.highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
|
||||||
|
f.render_widget(tabs, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_status_bar(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
let status = if let Some(ref msg) = state.status_message {
|
||||||
|
msg.clone()
|
||||||
|
} else {
|
||||||
|
match state.current_view {
|
||||||
|
View::Tags => {
|
||||||
|
" q:Quit j/k:Nav Home/End:Top/Bot n:New d:Delete r:Refresh Tab:Switch"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
View::Collections => {
|
||||||
|
" q:Quit j/k:Nav Home/End:Top/Bot d:Delete r:Refresh Tab:Switch".to_string()
|
||||||
|
}
|
||||||
|
View::Audit => {
|
||||||
|
" q:Quit j/k:Nav Home/End:Top/Bot r:Refresh Tab:Switch".to_string()
|
||||||
|
}
|
||||||
|
View::Detail => {
|
||||||
|
" q:Quit Esc:Back o:Open e:Edit +:Tag -:Untag r:Refresh ?:Help".to_string()
|
||||||
|
}
|
||||||
|
View::Import => {
|
||||||
|
" Enter:Import Esc:Cancel s:Scan libraries ?:Help".to_string()
|
||||||
|
}
|
||||||
|
View::Settings => " q:Quit Esc:Back ?:Help".to_string(),
|
||||||
|
View::Duplicates => " q:Quit j/k:Nav r:Refresh Esc:Back".to_string(),
|
||||||
|
View::Database => " q:Quit v:Vacuum r:Refresh Esc:Back".to_string(),
|
||||||
|
View::MetadataEdit => {
|
||||||
|
" Tab:Next field Enter:Save Esc:Cancel".to_string()
|
||||||
|
}
|
||||||
|
View::Queue => {
|
||||||
|
" q:Quit j/k:Nav Enter:Play d:Remove N:Next P:Prev R:Repeat S:Shuffle C:Clear"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
View::Statistics => " q:Quit r:Refresh Esc:Back ?:Help".to_string(),
|
||||||
|
View::Tasks => {
|
||||||
|
" q:Quit j/k:Nav Enter:Toggle R:Run Now r:Refresh Esc:Back".to_string()
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
" q:Quit /:Search i:Import o:Open t:Tags c:Coll a:Audit D:Dupes B:DB Q:Queue X:Stats T:Tasks ?:Help"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(Line::from(Span::styled(
|
||||||
|
status,
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)));
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
69
crates/pinakes-tui/src/ui/queue.rs
Normal file
69
crates/pinakes-tui/src/ui/queue.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, List, ListItem};
|
||||||
|
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
let items: Vec<ListItem> = if state.play_queue.is_empty() {
|
||||||
|
vec![ListItem::new(Line::from(Span::styled(
|
||||||
|
" Queue is empty. Select items in the library and press 'q' to add.",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)))]
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
.play_queue
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, item)| {
|
||||||
|
let is_current = state.queue_current_index == Some(i);
|
||||||
|
let is_selected = state.queue_selected == Some(i);
|
||||||
|
let prefix = if is_current { ">> " } else { " " };
|
||||||
|
let type_color = super::media_type_color(&item.media_type);
|
||||||
|
let id_suffix = if item.media_id.len() > 8 {
|
||||||
|
&item.media_id[item.media_id.len() - 8..]
|
||||||
|
} else {
|
||||||
|
&item.media_id
|
||||||
|
};
|
||||||
|
let text = if let Some(ref artist) = item.artist {
|
||||||
|
format!("{prefix}{} - {} [{}]", item.title, artist, id_suffix)
|
||||||
|
} else {
|
||||||
|
format!("{prefix}{} [{}]", item.title, id_suffix)
|
||||||
|
};
|
||||||
|
|
||||||
|
let style = if is_selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else if is_current {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Green)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(type_color)
|
||||||
|
};
|
||||||
|
|
||||||
|
ListItem::new(Line::from(Span::styled(text, style)))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let repeat_str = match state.queue_repeat {
|
||||||
|
0 => "Off",
|
||||||
|
1 => "One",
|
||||||
|
_ => "All",
|
||||||
|
};
|
||||||
|
let shuffle_str = if state.queue_shuffle { "On" } else { "Off" };
|
||||||
|
let title = format!(
|
||||||
|
" Queue ({}) | Repeat: {} | Shuffle: {} ",
|
||||||
|
state.play_queue.len(),
|
||||||
|
repeat_str,
|
||||||
|
shuffle_str,
|
||||||
|
);
|
||||||
|
|
||||||
|
let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title));
|
||||||
|
|
||||||
|
f.render_widget(list, area);
|
||||||
|
}
|
||||||
81
crates/pinakes-tui/src/ui/search.rs
Normal file
81
crates/pinakes-tui/src/ui/search.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::Span;
|
||||||
|
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table};
|
||||||
|
|
||||||
|
use super::{format_size, media_type_color};
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(3), Constraint::Min(0)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Search input
|
||||||
|
let input = Paragraph::new(state.search_input.as_str())
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Search (type and press Enter) "),
|
||||||
|
)
|
||||||
|
.style(if state.input_mode {
|
||||||
|
Style::default().fg(Color::Cyan)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
});
|
||||||
|
f.render_widget(input, chunks[0]);
|
||||||
|
|
||||||
|
// Results
|
||||||
|
let header = Row::new(vec!["Name", "Type", "Artist", "Size"]).style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows: Vec<Row> = state
|
||||||
|
.search_results
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, item)| {
|
||||||
|
let style = if Some(i) == state.search_selected {
|
||||||
|
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let type_color = media_type_color(&item.media_type);
|
||||||
|
let type_cell = Cell::from(Span::styled(
|
||||||
|
item.media_type.clone(),
|
||||||
|
Style::default().fg(type_color),
|
||||||
|
));
|
||||||
|
|
||||||
|
Row::new(vec![
|
||||||
|
Cell::from(item.file_name.clone()),
|
||||||
|
type_cell,
|
||||||
|
Cell::from(item.artist.clone().unwrap_or_default()),
|
||||||
|
Cell::from(format_size(item.file_size)),
|
||||||
|
])
|
||||||
|
.style(style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let shown = state.search_results.len();
|
||||||
|
let total = state.search_total_count;
|
||||||
|
let results_title = format!(" Results: {shown} shown, {total} total ");
|
||||||
|
|
||||||
|
let table = Table::new(
|
||||||
|
rows,
|
||||||
|
[
|
||||||
|
Constraint::Percentage(35),
|
||||||
|
Constraint::Percentage(20),
|
||||||
|
Constraint::Percentage(25),
|
||||||
|
Constraint::Percentage(20),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.header(header)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(results_title));
|
||||||
|
|
||||||
|
f.render_widget(table, chunks[1]);
|
||||||
|
}
|
||||||
82
crates/pinakes-tui/src/ui/settings.rs
Normal file
82
crates/pinakes-tui/src/ui/settings.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
|
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
let label_style = Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let value_style = Style::default().fg(Color::White);
|
||||||
|
let section_style = Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
|
||||||
|
let pad = " ";
|
||||||
|
|
||||||
|
let lines = vec![
|
||||||
|
Line::default(),
|
||||||
|
Line::from(Span::styled("--- Connection ---", section_style)),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled("Server URL: ", label_style),
|
||||||
|
Span::styled(&state.server_url, value_style),
|
||||||
|
]),
|
||||||
|
Line::default(),
|
||||||
|
Line::from(Span::styled("--- Library ---", section_style)),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled("Total items: ", label_style),
|
||||||
|
Span::styled(state.total_media_count.to_string(), value_style),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled("Page size: ", label_style),
|
||||||
|
Span::styled(state.page_size.to_string(), value_style),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled("Current page: ", label_style),
|
||||||
|
Span::styled(
|
||||||
|
((state.page_offset / state.page_size) + 1).to_string(),
|
||||||
|
value_style,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Line::default(),
|
||||||
|
Line::from(Span::styled("--- State ---", section_style)),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled("Tags loaded: ", label_style),
|
||||||
|
Span::styled(state.tags.len().to_string(), value_style),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled("All tags: ", label_style),
|
||||||
|
Span::styled(state.all_tags.len().to_string(), value_style),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled("Collections: ", label_style),
|
||||||
|
Span::styled(state.collections.len().to_string(), value_style),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::styled("Audit entries: ", label_style),
|
||||||
|
Span::styled(state.audit_log.len().to_string(), value_style),
|
||||||
|
]),
|
||||||
|
Line::default(),
|
||||||
|
Line::from(Span::styled("--- Shortcuts ---", section_style)),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw(pad),
|
||||||
|
Span::raw("Press Esc to return to the library view"),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let settings =
|
||||||
|
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Settings "));
|
||||||
|
|
||||||
|
f.render_widget(settings, area);
|
||||||
|
}
|
||||||
183
crates/pinakes-tui/src/ui/statistics.rs
Normal file
183
crates/pinakes-tui/src/ui/statistics.rs
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
|
||||||
|
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
let Some(ref stats) = state.library_stats else {
|
||||||
|
let msg = Paragraph::new("Loading statistics... (press X to refresh)")
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" Statistics "));
|
||||||
|
f.render_widget(msg, area);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(8), // Overview
|
||||||
|
Constraint::Length(10), // Media by type
|
||||||
|
Constraint::Min(6), // Top tags & collections
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Overview section
|
||||||
|
let overview_lines = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Total Media: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(
|
||||||
|
stats.total_media.to_string(),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("Total Size: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(
|
||||||
|
super::format_size(stats.total_size_bytes),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Avg Size: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(
|
||||||
|
super::format_size(stats.avg_file_size_bytes),
|
||||||
|
Style::default().fg(Color::White),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Tags: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(
|
||||||
|
stats.total_tags.to_string(),
|
||||||
|
Style::default().fg(Color::Green),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("Collections: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(
|
||||||
|
stats.total_collections.to_string(),
|
||||||
|
Style::default().fg(Color::Green),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("Duplicates: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(
|
||||||
|
stats.total_duplicates.to_string(),
|
||||||
|
Style::default().fg(Color::Yellow),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Newest: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(
|
||||||
|
stats
|
||||||
|
.newest_item
|
||||||
|
.as_deref()
|
||||||
|
.map(super::format_date)
|
||||||
|
.unwrap_or("-"),
|
||||||
|
Style::default().fg(Color::White),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("Oldest: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(
|
||||||
|
stats
|
||||||
|
.oldest_item
|
||||||
|
.as_deref()
|
||||||
|
.map(super::format_date)
|
||||||
|
.unwrap_or("-"),
|
||||||
|
Style::default().fg(Color::White),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let overview = Paragraph::new(overview_lines)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" Overview "));
|
||||||
|
f.render_widget(overview, chunks[0]);
|
||||||
|
|
||||||
|
// Media by Type table
|
||||||
|
let type_rows: Vec<Row> = stats
|
||||||
|
.media_by_type
|
||||||
|
.iter()
|
||||||
|
.map(|tc| {
|
||||||
|
let color = super::media_type_color(&tc.name);
|
||||||
|
Row::new(vec![
|
||||||
|
Span::styled(tc.name.clone(), Style::default().fg(color)),
|
||||||
|
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let storage_rows: Vec<Row> = stats
|
||||||
|
.storage_by_type
|
||||||
|
.iter()
|
||||||
|
.map(|tc| {
|
||||||
|
Row::new(vec![
|
||||||
|
Span::styled(tc.name.clone(), Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(
|
||||||
|
super::format_size(tc.count),
|
||||||
|
Style::default().fg(Color::White),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let type_cols = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
|
.split(chunks[1]);
|
||||||
|
|
||||||
|
let type_table = Table::new(type_rows, [Constraint::Min(20), Constraint::Length(10)]).block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Media by Type "),
|
||||||
|
);
|
||||||
|
f.render_widget(type_table, type_cols[0]);
|
||||||
|
|
||||||
|
let storage_table = Table::new(storage_rows, [Constraint::Min(20), Constraint::Length(12)])
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Storage by Type "),
|
||||||
|
);
|
||||||
|
f.render_widget(storage_table, type_cols[1]);
|
||||||
|
|
||||||
|
// Top tags and collections
|
||||||
|
let bottom_cols = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
|
.split(chunks[2]);
|
||||||
|
|
||||||
|
let tag_rows: Vec<Row> = stats
|
||||||
|
.top_tags
|
||||||
|
.iter()
|
||||||
|
.map(|tc| {
|
||||||
|
Row::new(vec![
|
||||||
|
Span::styled(tc.name.clone(), Style::default().fg(Color::Green)),
|
||||||
|
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let tags_table = Table::new(tag_rows, [Constraint::Min(20), Constraint::Length(10)])
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" Top Tags "));
|
||||||
|
f.render_widget(tags_table, bottom_cols[0]);
|
||||||
|
|
||||||
|
let col_rows: Vec<Row> = stats
|
||||||
|
.top_collections
|
||||||
|
.iter()
|
||||||
|
.map(|tc| {
|
||||||
|
Row::new(vec![
|
||||||
|
Span::styled(tc.name.clone(), Style::default().fg(Color::Magenta)),
|
||||||
|
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let cols_table = Table::new(col_rows, [Constraint::Min(20), Constraint::Length(10)]).block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Top Collections "),
|
||||||
|
);
|
||||||
|
f.render_widget(cols_table, bottom_cols[1]);
|
||||||
|
}
|
||||||
61
crates/pinakes-tui/src/ui/tags.rs
Normal file
61
crates/pinakes-tui/src/ui/tags.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::widgets::{Block, Borders, Row, Table};
|
||||||
|
|
||||||
|
use super::format_date;
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
let header = Row::new(vec!["Name", "Parent", "Created"]).style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows: Vec<Row> = state
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, tag)| {
|
||||||
|
let style = if Some(i) == state.tag_selected {
|
||||||
|
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve parent tag name from the tags list itself
|
||||||
|
let parent_display = match &tag.parent_id {
|
||||||
|
Some(pid) => state
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.id == *pid)
|
||||||
|
.map(|t| t.name.clone())
|
||||||
|
.unwrap_or_else(|| pid.chars().take(8).collect::<String>() + "..."),
|
||||||
|
None => "-".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Row::new(vec![
|
||||||
|
tag.name.clone(),
|
||||||
|
parent_display,
|
||||||
|
format_date(&tag.created_at).to_string(),
|
||||||
|
])
|
||||||
|
.style(style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let title = format!(" Tags ({}) ", state.tags.len());
|
||||||
|
|
||||||
|
let table = Table::new(
|
||||||
|
rows,
|
||||||
|
[
|
||||||
|
ratatui::layout::Constraint::Percentage(40),
|
||||||
|
ratatui::layout::Constraint::Percentage(30),
|
||||||
|
ratatui::layout::Constraint::Percentage(30),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.header(header)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(title));
|
||||||
|
|
||||||
|
f.render_widget(table, area);
|
||||||
|
}
|
||||||
63
crates/pinakes-tui/src/ui/tasks.rs
Normal file
63
crates/pinakes-tui/src/ui/tasks.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, List, ListItem};
|
||||||
|
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
let items: Vec<ListItem> = if state.scheduled_tasks.is_empty() {
|
||||||
|
vec![ListItem::new(Line::from(Span::styled(
|
||||||
|
" No scheduled tasks. Press T to refresh.",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)))]
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
.scheduled_tasks
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, task)| {
|
||||||
|
let is_selected = state.scheduled_tasks_selected == Some(i);
|
||||||
|
let enabled_marker = if task.enabled { "[ON] " } else { "[OFF]" };
|
||||||
|
let enabled_color = if task.enabled {
|
||||||
|
Color::Green
|
||||||
|
} else {
|
||||||
|
Color::DarkGray
|
||||||
|
};
|
||||||
|
|
||||||
|
let last_run = task
|
||||||
|
.last_run
|
||||||
|
.as_deref()
|
||||||
|
.map(super::format_date)
|
||||||
|
.unwrap_or("-");
|
||||||
|
let next_run = task
|
||||||
|
.next_run
|
||||||
|
.as_deref()
|
||||||
|
.map(super::format_date)
|
||||||
|
.unwrap_or("-");
|
||||||
|
let status = task.last_status.as_deref().unwrap_or("-");
|
||||||
|
|
||||||
|
let text = format!(
|
||||||
|
" {enabled_marker} {:<20} {:<16} Last: {:<12} Next: {:<12} Status: {}",
|
||||||
|
task.name, task.schedule, last_run, next_run, status
|
||||||
|
);
|
||||||
|
|
||||||
|
let style = if is_selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(enabled_color)
|
||||||
|
};
|
||||||
|
|
||||||
|
ListItem::new(Line::from(Span::styled(text, style)))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = format!(" Scheduled Tasks ({}) ", state.scheduled_tasks.len());
|
||||||
|
let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title));
|
||||||
|
|
||||||
|
f.render_widget(list, area);
|
||||||
|
}
|
||||||
21
crates/pinakes-ui/Cargo.toml
Normal file
21
crates/pinakes-ui/Cargo.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
[package]
|
||||||
|
name = "pinakes-ui"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
dioxus = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
rfd = "0.17"
|
||||||
|
pulldown-cmark = { workspace = true }
|
||||||
|
gray_matter = { workspace = true }
|
||||||
1577
crates/pinakes-ui/src/app.rs
Normal file
1577
crates/pinakes-ui/src/app.rs
Normal file
File diff suppressed because it is too large
Load diff
1066
crates/pinakes-ui/src/client.rs
Normal file
1066
crates/pinakes-ui/src/client.rs
Normal file
File diff suppressed because it is too large
Load diff
128
crates/pinakes-ui/src/components/audit.rs
Normal file
128
crates/pinakes-ui/src/components/audit.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use super::pagination::Pagination as PaginationControls;
|
||||||
|
use super::utils::format_timestamp;
|
||||||
|
use crate::client::AuditEntryResponse;
|
||||||
|
|
||||||
|
const ACTION_OPTIONS: &[&str] = &[
|
||||||
|
"All",
|
||||||
|
"imported",
|
||||||
|
"deleted",
|
||||||
|
"tagged",
|
||||||
|
"untagged",
|
||||||
|
"updated",
|
||||||
|
"added_to_collection",
|
||||||
|
"removed_from_collection",
|
||||||
|
"opened",
|
||||||
|
"scanned",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AuditLog(
|
||||||
|
entries: Vec<AuditEntryResponse>,
|
||||||
|
on_select: EventHandler<String>,
|
||||||
|
audit_page: u64,
|
||||||
|
total_pages: u64,
|
||||||
|
on_page_change: EventHandler<u64>,
|
||||||
|
audit_filter: String,
|
||||||
|
on_filter_change: EventHandler<String>,
|
||||||
|
) -> Element {
|
||||||
|
if entries.is_empty() {
|
||||||
|
return rsx! {
|
||||||
|
div { class: "empty-state",
|
||||||
|
h3 { class: "empty-title", "No audit entries" }
|
||||||
|
p { class: "empty-subtitle", "Activity will appear here as you use the application." }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "audit-controls",
|
||||||
|
select {
|
||||||
|
class: "filter-select",
|
||||||
|
value: "{audit_filter}",
|
||||||
|
onchange: move |evt: Event<FormData>| {
|
||||||
|
on_filter_change.call(evt.value().to_string());
|
||||||
|
},
|
||||||
|
for option in ACTION_OPTIONS.iter() {
|
||||||
|
option {
|
||||||
|
key: "{option}",
|
||||||
|
value: "{option}",
|
||||||
|
selected: audit_filter == *option,
|
||||||
|
"{option}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table { class: "data-table",
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Action" }
|
||||||
|
th { "Media ID" }
|
||||||
|
th { "Details" }
|
||||||
|
th { "Timestamp" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for entry in entries.iter() {
|
||||||
|
{
|
||||||
|
let media_id = entry.media_id.clone().unwrap_or_default();
|
||||||
|
let truncated_id = if media_id.len() > 8 {
|
||||||
|
format!("{}...", &media_id[..8])
|
||||||
|
} else {
|
||||||
|
media_id.clone()
|
||||||
|
};
|
||||||
|
let details = entry.details.clone().unwrap_or_default();
|
||||||
|
let action_class = action_badge_class(&entry.action);
|
||||||
|
let timestamp = format_timestamp(&entry.timestamp);
|
||||||
|
let click_id = media_id.clone();
|
||||||
|
let has_media_id = !media_id.is_empty();
|
||||||
|
rsx! {
|
||||||
|
tr { key: "{entry.id}",
|
||||||
|
td {
|
||||||
|
span { class: "type-badge {action_class}", "{entry.action}" }
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
if has_media_id {
|
||||||
|
span {
|
||||||
|
class: "mono clickable",
|
||||||
|
onclick: move |_| {
|
||||||
|
on_select.call(click_id.clone());
|
||||||
|
},
|
||||||
|
"{truncated_id}"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
span { class: "mono", "{truncated_id}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td { "{details}" }
|
||||||
|
td { "{timestamp}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PaginationControls {
|
||||||
|
current_page: audit_page,
|
||||||
|
total_pages: total_pages,
|
||||||
|
on_page_change: on_page_change,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action_badge_class(action: &str) -> &'static str {
|
||||||
|
match action {
|
||||||
|
"imported" => "type-image",
|
||||||
|
"deleted" => "action-danger",
|
||||||
|
"tagged" | "untagged" => "tag-badge",
|
||||||
|
"updated" => "action-updated",
|
||||||
|
"added_to_collection" => "action-collection",
|
||||||
|
"removed_from_collection" => "action-collection-remove",
|
||||||
|
"opened" => "action-opened",
|
||||||
|
"scanned" => "action-scanned",
|
||||||
|
_ => "type-other",
|
||||||
|
}
|
||||||
|
}
|
||||||
42
crates/pinakes-ui/src/components/breadcrumb.rs
Normal file
42
crates/pinakes-ui/src/components/breadcrumb.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct BreadcrumbItem {
|
||||||
|
pub label: String,
|
||||||
|
pub view: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Breadcrumb(
|
||||||
|
items: Vec<BreadcrumbItem>,
|
||||||
|
on_navigate: EventHandler<Option<String>>,
|
||||||
|
) -> Element {
|
||||||
|
rsx! {
|
||||||
|
nav { class: "breadcrumb",
|
||||||
|
for (i, item) in items.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
span { class: "breadcrumb-sep", " > " }
|
||||||
|
}
|
||||||
|
if i < items.len() - 1 {
|
||||||
|
{
|
||||||
|
let view = item.view.clone();
|
||||||
|
let label = item.label.clone();
|
||||||
|
rsx! {
|
||||||
|
a {
|
||||||
|
class: "breadcrumb-link",
|
||||||
|
href: "#",
|
||||||
|
onclick: move |e: Event<MouseData>| {
|
||||||
|
e.prevent_default();
|
||||||
|
on_navigate.call(view.clone());
|
||||||
|
},
|
||||||
|
"{label}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
span { class: "breadcrumb-current", "{item.label}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
334
crates/pinakes-ui/src/components/collections.rs
Normal file
334
crates/pinakes-ui/src/components/collections.rs
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use super::utils::{format_size, type_badge_class};
|
||||||
|
use crate::client::{CollectionResponse, MediaResponse};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Collections(
|
||||||
|
collections: Vec<CollectionResponse>,
|
||||||
|
collection_members: Vec<MediaResponse>,
|
||||||
|
viewing_collection: Option<String>,
|
||||||
|
on_create: EventHandler<(String, String, Option<String>, Option<String>)>,
|
||||||
|
on_delete: EventHandler<String>,
|
||||||
|
on_view_members: EventHandler<String>,
|
||||||
|
on_back_to_list: EventHandler<()>,
|
||||||
|
on_remove_member: EventHandler<(String, String)>,
|
||||||
|
on_select: EventHandler<String>,
|
||||||
|
on_add_member: EventHandler<(String, String)>,
|
||||||
|
all_media: Vec<MediaResponse>,
|
||||||
|
) -> Element {
|
||||||
|
let mut new_name = use_signal(String::new);
|
||||||
|
let mut new_kind = use_signal(|| String::from("manual"));
|
||||||
|
let mut new_description = use_signal(String::new);
|
||||||
|
let mut new_filter_query = use_signal(String::new);
|
||||||
|
let mut confirm_delete: Signal<Option<String>> = use_signal(|| None);
|
||||||
|
let mut show_add_modal = use_signal(|| false);
|
||||||
|
|
||||||
|
// Detail view: viewing a specific collection's members
|
||||||
|
if let Some(ref col_id) = viewing_collection {
|
||||||
|
let col_name = collections
|
||||||
|
.iter()
|
||||||
|
.find(|c| &c.id == col_id)
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
.unwrap_or_else(|| col_id.clone());
|
||||||
|
|
||||||
|
let back_click = move |_| on_back_to_list.call(());
|
||||||
|
|
||||||
|
// Collect IDs of current members to filter available media
|
||||||
|
let member_ids: Vec<String> = collection_members.iter().map(|m| m.id.clone()).collect();
|
||||||
|
let available_media: Vec<&MediaResponse> = all_media
|
||||||
|
.iter()
|
||||||
|
.filter(|m| !member_ids.contains(&m.id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let modal_col_id = col_id.clone();
|
||||||
|
|
||||||
|
return rsx! {
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost mb-16",
|
||||||
|
onclick: back_click,
|
||||||
|
"\u{2190} Back to Collections"
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 { class: "mb-16", "{col_name}" }
|
||||||
|
|
||||||
|
div { class: "form-row mb-16",
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
onclick: move |_| show_add_modal.set(true),
|
||||||
|
"Add Media"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if collection_members.is_empty() {
|
||||||
|
div { class: "empty-state",
|
||||||
|
p { class: "empty-subtitle", "This collection has no members." }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
table { class: "data-table",
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Name" }
|
||||||
|
th { "Type" }
|
||||||
|
th { "Artist" }
|
||||||
|
th { "Size" }
|
||||||
|
th { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for item in collection_members.iter() {
|
||||||
|
{
|
||||||
|
let artist = item.artist.clone().unwrap_or_default();
|
||||||
|
let size = format_size(item.file_size);
|
||||||
|
let badge_class = type_badge_class(&item.media_type);
|
||||||
|
let remove_cid = col_id.clone();
|
||||||
|
let remove_mid = item.id.clone();
|
||||||
|
let row_click = {
|
||||||
|
let mid = item.id.clone();
|
||||||
|
move |_| on_select.call(mid.clone())
|
||||||
|
};
|
||||||
|
rsx! {
|
||||||
|
tr {
|
||||||
|
key: "{item.id}",
|
||||||
|
class: "clickable-row",
|
||||||
|
onclick: row_click,
|
||||||
|
td { "{item.file_name}" }
|
||||||
|
td {
|
||||||
|
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||||
|
}
|
||||||
|
td { "{artist}" }
|
||||||
|
td { "{size}" }
|
||||||
|
td {
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger btn-sm",
|
||||||
|
onclick: move |e: Event<MouseData>| {
|
||||||
|
e.stop_propagation();
|
||||||
|
on_remove_member.call((remove_cid.clone(), remove_mid.clone()));
|
||||||
|
},
|
||||||
|
"Remove"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Media modal
|
||||||
|
if *show_add_modal.read() {
|
||||||
|
div { class: "modal-overlay",
|
||||||
|
onclick: move |_| show_add_modal.set(false),
|
||||||
|
div { class: "modal",
|
||||||
|
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||||
|
div { class: "modal-header",
|
||||||
|
h3 { "Add Media to Collection" }
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost",
|
||||||
|
onclick: move |_| show_add_modal.set(false),
|
||||||
|
"\u{2715}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "modal-body",
|
||||||
|
if available_media.is_empty() {
|
||||||
|
p { "No media available to add." }
|
||||||
|
} else {
|
||||||
|
table { class: "data-table",
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Name" }
|
||||||
|
th { "Type" }
|
||||||
|
th { "Artist" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for media in available_media.iter() {
|
||||||
|
{
|
||||||
|
let artist = media.artist.clone().unwrap_or_default();
|
||||||
|
let badge_class = type_badge_class(&media.media_type);
|
||||||
|
let add_click = {
|
||||||
|
let cid = modal_col_id.clone();
|
||||||
|
let mid = media.id.clone();
|
||||||
|
move |_| {
|
||||||
|
on_add_member.call((cid.clone(), mid.clone()));
|
||||||
|
show_add_modal.set(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rsx! {
|
||||||
|
tr {
|
||||||
|
key: "{media.id}",
|
||||||
|
class: "clickable-row",
|
||||||
|
onclick: add_click,
|
||||||
|
td { "{media.file_name}" }
|
||||||
|
td {
|
||||||
|
span { class: "type-badge {badge_class}", "{media.media_type}" }
|
||||||
|
}
|
||||||
|
td { "{artist}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// List view: show all collections with create form
|
||||||
|
let is_virtual = *new_kind.read() == "virtual";
|
||||||
|
|
||||||
|
let create_click = move |_| {
|
||||||
|
let name = new_name.read().clone();
|
||||||
|
if name.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let kind = new_kind.read().clone();
|
||||||
|
let desc = {
|
||||||
|
let d = new_description.read().clone();
|
||||||
|
if d.is_empty() { None } else { Some(d) }
|
||||||
|
};
|
||||||
|
let filter = {
|
||||||
|
let f = new_filter_query.read().clone();
|
||||||
|
if f.is_empty() { None } else { Some(f) }
|
||||||
|
};
|
||||||
|
on_create.call((name, kind, desc, filter));
|
||||||
|
new_name.set(String::new());
|
||||||
|
new_kind.set(String::from("manual"));
|
||||||
|
new_description.set(String::new());
|
||||||
|
new_filter_query.set(String::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "card",
|
||||||
|
div { class: "card-header",
|
||||||
|
h3 { class: "card-title", "Collections" }
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "form-row mb-16",
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Collection name...",
|
||||||
|
value: "{new_name}",
|
||||||
|
oninput: move |e| new_name.set(e.value()),
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
value: "{new_kind}",
|
||||||
|
onchange: move |e| new_kind.set(e.value()),
|
||||||
|
option { value: "manual", "Manual" }
|
||||||
|
option { value: "virtual", "Virtual" }
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Description (optional)...",
|
||||||
|
value: "{new_description}",
|
||||||
|
oninput: move |e| new_description.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_virtual {
|
||||||
|
div { class: "form-row mb-16",
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Filter query for virtual collection...",
|
||||||
|
value: "{new_filter_query}",
|
||||||
|
oninput: move |e| new_filter_query.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "form-row mb-16",
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
onclick: create_click,
|
||||||
|
"Create"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if collections.is_empty() {
|
||||||
|
div { class: "empty-state",
|
||||||
|
p { class: "empty-subtitle", "No collections yet. Create one above." }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
table { class: "data-table",
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Name" }
|
||||||
|
th { "Kind" }
|
||||||
|
th { "Description" }
|
||||||
|
th { "" }
|
||||||
|
th { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for col in collections.iter() {
|
||||||
|
{
|
||||||
|
let desc = col.description.clone().unwrap_or_default();
|
||||||
|
let kind_class = if col.kind == "virtual" { "type-document" } else { "type-other" };
|
||||||
|
let view_click = {
|
||||||
|
let id = col.id.clone();
|
||||||
|
move |_| on_view_members.call(id.clone())
|
||||||
|
};
|
||||||
|
let col_id_for_delete = col.id.clone();
|
||||||
|
let is_confirming = confirm_delete
|
||||||
|
.read()
|
||||||
|
.as_ref()
|
||||||
|
.map(|id| id == &col.id)
|
||||||
|
.unwrap_or(false);
|
||||||
|
rsx! {
|
||||||
|
tr { key: "{col.id}",
|
||||||
|
td { "{col.name}" }
|
||||||
|
td {
|
||||||
|
span { class: "type-badge {kind_class}", "{col.kind}" }
|
||||||
|
}
|
||||||
|
td { "{desc}" }
|
||||||
|
td {
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-secondary",
|
||||||
|
onclick: view_click,
|
||||||
|
"View"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
if is_confirming {
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger btn-sm",
|
||||||
|
onclick: {
|
||||||
|
let id = col_id_for_delete.clone();
|
||||||
|
move |_| {
|
||||||
|
on_delete.call(id.clone());
|
||||||
|
confirm_delete.set(None);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Confirm"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-sm",
|
||||||
|
onclick: move |_| confirm_delete.set(None),
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger btn-sm",
|
||||||
|
onclick: {
|
||||||
|
let id = col_id_for_delete.clone();
|
||||||
|
move |_| confirm_delete.set(Some(id.clone()))
|
||||||
|
},
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
193
crates/pinakes-ui/src/components/database.rs
Normal file
193
crates/pinakes-ui/src/components/database.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use super::utils::format_size;
|
||||||
|
use crate::client::DatabaseStatsResponse;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Database(
|
||||||
|
stats: Option<DatabaseStatsResponse>,
|
||||||
|
on_refresh: EventHandler<()>,
|
||||||
|
on_vacuum: EventHandler<()>,
|
||||||
|
on_clear: EventHandler<()>,
|
||||||
|
on_backup: EventHandler<String>,
|
||||||
|
) -> Element {
|
||||||
|
let mut confirm_clear = use_signal(|| false);
|
||||||
|
let mut confirm_vacuum = use_signal(|| false);
|
||||||
|
let mut backup_path = use_signal(String::new);
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "card mb-16",
|
||||||
|
div { class: "card-header",
|
||||||
|
h3 { class: "card-title", "Database Overview" }
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-secondary",
|
||||||
|
onclick: move |_| on_refresh.call(()),
|
||||||
|
"\u{21bb} Refresh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match stats.as_ref() {
|
||||||
|
Some(s) => {
|
||||||
|
let size_str = format_size(s.database_size_bytes);
|
||||||
|
rsx! {
|
||||||
|
div { class: "stats-grid",
|
||||||
|
div { class: "stat-card",
|
||||||
|
div { class: "stat-value", "{s.media_count}" }
|
||||||
|
div { class: "stat-label", "Media Items" }
|
||||||
|
}
|
||||||
|
div { class: "stat-card",
|
||||||
|
div { class: "stat-value", "{s.tag_count}" }
|
||||||
|
div { class: "stat-label", "Tags" }
|
||||||
|
}
|
||||||
|
div { class: "stat-card",
|
||||||
|
div { class: "stat-value", "{s.collection_count}" }
|
||||||
|
div { class: "stat-label", "Collections" }
|
||||||
|
}
|
||||||
|
div { class: "stat-card",
|
||||||
|
div { class: "stat-value", "{s.audit_count}" }
|
||||||
|
div { class: "stat-label", "Audit Entries" }
|
||||||
|
}
|
||||||
|
div { class: "stat-card",
|
||||||
|
div { class: "stat-value", "{size_str}" }
|
||||||
|
div { class: "stat-label", "Database Size" }
|
||||||
|
}
|
||||||
|
div { class: "stat-card",
|
||||||
|
div { class: "stat-value", "{s.backend_name}" }
|
||||||
|
div { class: "stat-label", "Backend" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => rsx! {
|
||||||
|
div { class: "empty-state",
|
||||||
|
p { class: "text-muted", "Loading database stats..." }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maintenance actions
|
||||||
|
div { class: "card mb-16",
|
||||||
|
div { class: "card-header",
|
||||||
|
h3 { class: "card-title", "Maintenance" }
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "db-actions",
|
||||||
|
// Vacuum
|
||||||
|
div { class: "db-action-row",
|
||||||
|
div { class: "db-action-info",
|
||||||
|
h4 { "Vacuum Database" }
|
||||||
|
p { class: "text-muted text-sm",
|
||||||
|
"Reclaim unused disk space and optimize the database. "
|
||||||
|
"This is safe to run at any time but may briefly lock the database."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *confirm_vacuum.read() {
|
||||||
|
div { class: "db-action-confirm",
|
||||||
|
span { class: "text-sm", "Run vacuum?" }
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-primary",
|
||||||
|
onclick: move |_| {
|
||||||
|
confirm_vacuum.set(false);
|
||||||
|
on_vacuum.call(());
|
||||||
|
},
|
||||||
|
"Confirm"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-ghost",
|
||||||
|
onclick: move |_| confirm_vacuum.set(false),
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
button {
|
||||||
|
class: "btn btn-secondary",
|
||||||
|
onclick: move |_| confirm_vacuum.set(true),
|
||||||
|
"Vacuum"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup
|
||||||
|
div { class: "db-action-row",
|
||||||
|
div { class: "db-action-info",
|
||||||
|
h4 { "Backup Database" }
|
||||||
|
p { class: "text-muted text-sm",
|
||||||
|
"Create a copy of the database at the specified path. "
|
||||||
|
"The backup is a full snapshot of the current state."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "form-row",
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "/path/to/backup.db",
|
||||||
|
value: "{backup_path}",
|
||||||
|
oninput: move |e| backup_path.set(e.value()),
|
||||||
|
style: "max-width: 300px;",
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-secondary",
|
||||||
|
disabled: backup_path.read().is_empty(),
|
||||||
|
onclick: {
|
||||||
|
let mut backup_path = backup_path;
|
||||||
|
move |_| {
|
||||||
|
let path = backup_path.read().clone();
|
||||||
|
if !path.is_empty() {
|
||||||
|
on_backup.call(path);
|
||||||
|
backup_path.set(String::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Backup"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Danger zone
|
||||||
|
div { class: "card mb-16 danger-card",
|
||||||
|
div { class: "card-header",
|
||||||
|
h3 { class: "card-title", style: "color: var(--danger);", "Danger Zone" }
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "db-actions",
|
||||||
|
div { class: "db-action-row",
|
||||||
|
div { class: "db-action-info",
|
||||||
|
h4 { "Clear All Data" }
|
||||||
|
p { class: "text-muted text-sm",
|
||||||
|
"Permanently delete all media records, tags, collections, and audit entries. "
|
||||||
|
"This cannot be undone. Files on disk are not affected."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *confirm_clear.read() {
|
||||||
|
div { class: "db-action-confirm",
|
||||||
|
span { class: "text-sm", style: "color: var(--danger);",
|
||||||
|
"This will delete everything. Are you sure?"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-danger",
|
||||||
|
onclick: move |_| {
|
||||||
|
confirm_clear.set(false);
|
||||||
|
on_clear.call(());
|
||||||
|
},
|
||||||
|
"Yes, Delete Everything"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-ghost",
|
||||||
|
onclick: move |_| confirm_clear.set(false),
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger",
|
||||||
|
onclick: move |_| confirm_clear.set(true),
|
||||||
|
"Clear All Data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
663
crates/pinakes-ui/src/components/detail.rs
Normal file
663
crates/pinakes-ui/src/components/detail.rs
Normal file
|
|
@ -0,0 +1,663 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use super::image_viewer::ImageViewer;
|
||||||
|
use super::markdown_viewer::MarkdownViewer;
|
||||||
|
use super::media_player::MediaPlayer;
|
||||||
|
use super::utils::{format_duration, format_size, media_category, type_badge_class};
|
||||||
|
use crate::client::{MediaResponse, MediaUpdateEvent, TagResponse};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Detail(
|
||||||
|
media: MediaResponse,
|
||||||
|
media_tags: Vec<TagResponse>,
|
||||||
|
all_tags: Vec<TagResponse>,
|
||||||
|
server_url: String,
|
||||||
|
#[props(default = false)] autoplay: bool,
|
||||||
|
on_back: EventHandler<()>,
|
||||||
|
on_open: EventHandler<String>,
|
||||||
|
on_update: EventHandler<MediaUpdateEvent>,
|
||||||
|
on_tag: EventHandler<(String, String)>,
|
||||||
|
on_untag: EventHandler<(String, String)>,
|
||||||
|
on_set_custom_field: EventHandler<(String, String, String, String)>,
|
||||||
|
on_delete_custom_field: EventHandler<(String, String)>,
|
||||||
|
on_delete: EventHandler<String>,
|
||||||
|
) -> Element {
|
||||||
|
let mut editing = use_signal(|| false);
|
||||||
|
let mut show_image_viewer = use_signal(|| false);
|
||||||
|
let mut edit_title = use_signal(String::new);
|
||||||
|
let mut edit_artist = use_signal(String::new);
|
||||||
|
let mut edit_album = use_signal(String::new);
|
||||||
|
let mut edit_genre = use_signal(String::new);
|
||||||
|
let mut edit_year = use_signal(String::new);
|
||||||
|
let mut edit_description = use_signal(String::new);
|
||||||
|
|
||||||
|
let mut add_tag_id = use_signal(String::new);
|
||||||
|
|
||||||
|
let mut new_field_name = use_signal(String::new);
|
||||||
|
let mut new_field_type = use_signal(|| "text".to_string());
|
||||||
|
let mut new_field_value = use_signal(String::new);
|
||||||
|
|
||||||
|
let mut confirm_delete = use_signal(|| false);
|
||||||
|
|
||||||
|
let id = media.id.clone();
|
||||||
|
let title = media.title.clone().unwrap_or_default();
|
||||||
|
let artist = media.artist.clone().unwrap_or_default();
|
||||||
|
let album = media.album.clone().unwrap_or_default();
|
||||||
|
let genre = media.genre.clone().unwrap_or_default();
|
||||||
|
let year_str = media.year.map(|y| y.to_string()).unwrap_or_default();
|
||||||
|
let duration_str = media.duration_secs.map(format_duration).unwrap_or_default();
|
||||||
|
let description = media.description.clone().unwrap_or_default();
|
||||||
|
let size = format_size(media.file_size);
|
||||||
|
let badge_class = type_badge_class(&media.media_type);
|
||||||
|
let custom_fields: Vec<(String, String, String)> = media
|
||||||
|
.custom_fields
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), v.field_type.clone(), v.value.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let is_editing = editing();
|
||||||
|
|
||||||
|
// Separate system-extracted metadata from user-defined custom fields.
|
||||||
|
// System fields are those set by extractors (camera info, dimensions, etc.)
|
||||||
|
let system_field_names: &[&str] = &[
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"camera_make",
|
||||||
|
"camera_model",
|
||||||
|
"date_taken",
|
||||||
|
"gps_latitude",
|
||||||
|
"gps_longitude",
|
||||||
|
"iso",
|
||||||
|
"exposure_time",
|
||||||
|
"f_number",
|
||||||
|
"focal_length",
|
||||||
|
"software",
|
||||||
|
"lens_model",
|
||||||
|
"flash",
|
||||||
|
"orientation",
|
||||||
|
"track_number",
|
||||||
|
"disc_number",
|
||||||
|
"comment",
|
||||||
|
"bitrate",
|
||||||
|
"sample_rate",
|
||||||
|
"channels",
|
||||||
|
"resolution",
|
||||||
|
"video_codec",
|
||||||
|
"audio_codec",
|
||||||
|
"audio_bitrate",
|
||||||
|
];
|
||||||
|
let system_fields: Vec<(String, String, String)> = custom_fields
|
||||||
|
.iter()
|
||||||
|
.filter(|(k, _, _)| system_field_names.contains(&k.as_str()))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let user_fields: Vec<(String, String, String)> = custom_fields
|
||||||
|
.iter()
|
||||||
|
.filter(|(k, _, _)| !system_field_names.contains(&k.as_str()))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let has_system_fields = !system_fields.is_empty();
|
||||||
|
let has_user_fields = !user_fields.is_empty();
|
||||||
|
|
||||||
|
// Media preview URLs
|
||||||
|
let stream_url = format!("{}/api/v1/media/{}/stream", server_url, media.id);
|
||||||
|
let thumbnail_url = format!("{}/api/v1/media/{}/thumbnail", server_url, media.id);
|
||||||
|
let category = media_category(&media.media_type);
|
||||||
|
let has_thumbnail = media.has_thumbnail;
|
||||||
|
|
||||||
|
// Compute available tags (all_tags minus media_tags)
|
||||||
|
let media_tag_ids: Vec<String> = media_tags.iter().map(|t| t.id.clone()).collect();
|
||||||
|
let available_tags: Vec<TagResponse> = all_tags
|
||||||
|
.iter()
|
||||||
|
.filter(|t| !media_tag_ids.contains(&t.id))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Clone values needed for closures
|
||||||
|
let id_for_open = id.clone();
|
||||||
|
let id_for_save = id.clone();
|
||||||
|
let id_for_tag = id.clone();
|
||||||
|
let id_for_field = id.clone();
|
||||||
|
let id_for_delete = id.clone();
|
||||||
|
|
||||||
|
// Clone media field values for the edit button
|
||||||
|
let title_for_edit = media.title.clone().unwrap_or_default();
|
||||||
|
let artist_for_edit = media.artist.clone().unwrap_or_default();
|
||||||
|
let album_for_edit = media.album.clone().unwrap_or_default();
|
||||||
|
let genre_for_edit = media.genre.clone().unwrap_or_default();
|
||||||
|
let year_for_edit = media.year.map(|y| y.to_string()).unwrap_or_default();
|
||||||
|
let description_for_edit = media.description.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
let on_edit_click = move |_| {
|
||||||
|
edit_title.set(title_for_edit.clone());
|
||||||
|
edit_artist.set(artist_for_edit.clone());
|
||||||
|
edit_album.set(album_for_edit.clone());
|
||||||
|
edit_genre.set(genre_for_edit.clone());
|
||||||
|
edit_year.set(year_for_edit.clone());
|
||||||
|
edit_description.set(description_for_edit.clone());
|
||||||
|
editing.set(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_save_click = {
|
||||||
|
let id_save = id_for_save.clone();
|
||||||
|
move |_| {
|
||||||
|
let t = edit_title();
|
||||||
|
let ar = edit_artist();
|
||||||
|
let al = edit_album();
|
||||||
|
let g = edit_genre();
|
||||||
|
let y_str = edit_year();
|
||||||
|
let d = edit_description();
|
||||||
|
|
||||||
|
let title_opt = if t.is_empty() { None } else { Some(t) };
|
||||||
|
let artist_opt = if ar.is_empty() { None } else { Some(ar) };
|
||||||
|
let album_opt = if al.is_empty() { None } else { Some(al) };
|
||||||
|
let genre_opt = if g.is_empty() { None } else { Some(g) };
|
||||||
|
let year_opt = if y_str.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
y_str.parse::<i32>().ok()
|
||||||
|
};
|
||||||
|
let desc_opt = if d.is_empty() { None } else { Some(d) };
|
||||||
|
|
||||||
|
on_update.call(MediaUpdateEvent {
|
||||||
|
id: id_save.clone(),
|
||||||
|
title: title_opt,
|
||||||
|
artist: artist_opt,
|
||||||
|
album: album_opt,
|
||||||
|
genre: genre_opt,
|
||||||
|
year: year_opt,
|
||||||
|
description: desc_opt,
|
||||||
|
});
|
||||||
|
editing.set(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_cancel_click = move |_| {
|
||||||
|
editing.set(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_tag_add_click = {
|
||||||
|
let id_tag = id_for_tag.clone();
|
||||||
|
move |_| {
|
||||||
|
let tag_id = add_tag_id();
|
||||||
|
if !tag_id.is_empty() {
|
||||||
|
on_tag.call((id_tag.clone(), tag_id));
|
||||||
|
add_tag_id.set(String::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_add_field_click = {
|
||||||
|
let id_field = id_for_field.clone();
|
||||||
|
move |_| {
|
||||||
|
let name = new_field_name();
|
||||||
|
let ft = new_field_type();
|
||||||
|
let val = new_field_value();
|
||||||
|
if !name.is_empty() && !val.is_empty() {
|
||||||
|
on_set_custom_field.call((id_field.clone(), name, ft, val));
|
||||||
|
new_field_name.set(String::new());
|
||||||
|
new_field_type.set("text".to_string());
|
||||||
|
new_field_value.set(String::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_delete_click = move |_| {
|
||||||
|
confirm_delete.set(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_confirm_delete = {
|
||||||
|
let id_del = id_for_delete.clone();
|
||||||
|
move |_| {
|
||||||
|
on_delete.call(id_del.clone());
|
||||||
|
confirm_delete.set(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_cancel_delete = move |_| {
|
||||||
|
confirm_delete.set(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let stream_url_for_viewer = stream_url.clone();
|
||||||
|
let thumb_for_player = thumbnail_url.clone();
|
||||||
|
let file_name_for_viewer = media.file_name.clone();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
// Media preview
|
||||||
|
div { class: "detail-preview",
|
||||||
|
if category == "audio" {
|
||||||
|
MediaPlayer {
|
||||||
|
src: stream_url.clone(),
|
||||||
|
media_type: "audio".to_string(),
|
||||||
|
title: media.title.clone(),
|
||||||
|
thumbnail_url: if has_thumbnail { Some(thumb_for_player.clone()) } else { None },
|
||||||
|
autoplay: autoplay,
|
||||||
|
}
|
||||||
|
} else if category == "video" {
|
||||||
|
MediaPlayer {
|
||||||
|
src: stream_url.clone(),
|
||||||
|
media_type: "video".to_string(),
|
||||||
|
title: media.title.clone(),
|
||||||
|
autoplay: autoplay,
|
||||||
|
}
|
||||||
|
} else if category == "image" {
|
||||||
|
if has_thumbnail {
|
||||||
|
img {
|
||||||
|
src: "{thumbnail_url}",
|
||||||
|
alt: "{media.file_name}",
|
||||||
|
class: "detail-preview-image clickable",
|
||||||
|
onclick: move |_| show_image_viewer.set(true),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
img {
|
||||||
|
src: "{stream_url}",
|
||||||
|
alt: "{media.file_name}",
|
||||||
|
class: "detail-preview-image clickable",
|
||||||
|
onclick: move |_| show_image_viewer.set(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if category == "text" {
|
||||||
|
MarkdownViewer {
|
||||||
|
content_url: stream_url.clone(),
|
||||||
|
media_type: media.media_type.clone(),
|
||||||
|
}
|
||||||
|
} else if category == "document" {
|
||||||
|
div { class: "detail-no-preview",
|
||||||
|
p { class: "text-muted", "Preview not available for this document type." }
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
onclick: {
|
||||||
|
let id_open = id.clone();
|
||||||
|
move |_| on_open.call(id_open.clone())
|
||||||
|
},
|
||||||
|
"Open Externally"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if has_thumbnail {
|
||||||
|
img {
|
||||||
|
src: "{thumbnail_url}",
|
||||||
|
alt: "Thumbnail",
|
||||||
|
class: "detail-thumbnail",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action bar
|
||||||
|
div { class: "detail-actions",
|
||||||
|
button {
|
||||||
|
class: "btn btn-secondary",
|
||||||
|
onclick: move |_| on_back.call(()),
|
||||||
|
"Back"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
onclick: {
|
||||||
|
let id_open = id_for_open.clone();
|
||||||
|
move |_| on_open.call(id_open.clone())
|
||||||
|
},
|
||||||
|
"Open"
|
||||||
|
}
|
||||||
|
if is_editing {
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
onclick: on_save_click,
|
||||||
|
"Save"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost",
|
||||||
|
onclick: on_cancel_click,
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
button {
|
||||||
|
class: "btn btn-secondary",
|
||||||
|
onclick: on_edit_click,
|
||||||
|
"Edit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if confirm_delete() {
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger",
|
||||||
|
onclick: on_confirm_delete,
|
||||||
|
"Confirm Delete"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost",
|
||||||
|
onclick: on_cancel_delete,
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger",
|
||||||
|
onclick: on_delete_click,
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info / Edit section
|
||||||
|
if is_editing {
|
||||||
|
div { class: "detail-grid",
|
||||||
|
// Read-only file info
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label", "File Name" }
|
||||||
|
span { class: "detail-value", "{media.file_name}" }
|
||||||
|
}
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label", "Path" }
|
||||||
|
span { class: "detail-value mono", "{media.path}" }
|
||||||
|
}
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label", "Type" }
|
||||||
|
span { class: "detail-value",
|
||||||
|
span { class: "type-badge {badge_class}", "{media.media_type}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label", "Size" }
|
||||||
|
span { class: "detail-value", "{size}" }
|
||||||
|
}
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label", "Hash" }
|
||||||
|
span { class: "detail-value mono", "{media.content_hash}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editable fields — conditional by media category
|
||||||
|
div { class: "detail-field",
|
||||||
|
label { class: "detail-label", "Title" }
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
value: "{edit_title}",
|
||||||
|
oninput: move |e: Event<FormData>| edit_title.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "detail-field",
|
||||||
|
label { class: "detail-label",
|
||||||
|
{match category {
|
||||||
|
"image" => "Photographer",
|
||||||
|
"document" | "text" => "Author",
|
||||||
|
_ => "Artist",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
value: "{edit_artist}",
|
||||||
|
oninput: move |e: Event<FormData>| edit_artist.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if category == "audio" {
|
||||||
|
div { class: "detail-field",
|
||||||
|
label { class: "detail-label", "Album" }
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
value: "{edit_album}",
|
||||||
|
oninput: move |e: Event<FormData>| edit_album.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if category == "audio" || category == "video" {
|
||||||
|
div { class: "detail-field",
|
||||||
|
label { class: "detail-label", "Genre" }
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
value: "{edit_genre}",
|
||||||
|
oninput: move |e: Event<FormData>| edit_genre.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if category == "audio" || category == "video" || category == "document" {
|
||||||
|
div { class: "detail-field",
|
||||||
|
label { class: "detail-label", "Year" }
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
value: "{edit_year}",
|
||||||
|
oninput: move |e: Event<FormData>| edit_year.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "detail-field full-width",
|
||||||
|
label { class: "detail-label", "Description" }
|
||||||
|
textarea {
|
||||||
|
value: "{edit_description}",
|
||||||
|
oninput: move |e: Event<FormData>| edit_description.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
div { class: "detail-grid",
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label", "File Name" }
|
||||||
|
span { class: "detail-value", "{media.file_name}" }
|
||||||
|
}
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label", "Path" }
|
||||||
|
span { class: "detail-value mono", "{media.path}" }
|
||||||
|
}
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label", "Type" }
|
||||||
|
span { class: "detail-value",
|
||||||
|
span { class: "type-badge {badge_class}", "{media.media_type}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label", "Size" }
|
||||||
|
span { class: "detail-value", "{size}" }
|
||||||
|
}
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label", "Hash" }
|
||||||
|
span { class: "detail-value mono", "{media.content_hash}" }
|
||||||
|
}
|
||||||
|
// Title: only shown when non-empty
|
||||||
|
if !title.is_empty() {
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label", "Title" }
|
||||||
|
span { class: "detail-value", "{title}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Artist/Author/Photographer: only shown when non-empty
|
||||||
|
if !artist.is_empty() {
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label",
|
||||||
|
{match category {
|
||||||
|
"image" => "Photographer",
|
||||||
|
"document" | "text" => "Author",
|
||||||
|
_ => "Artist",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
span { class: "detail-value", "{artist}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Album: audio only, when non-empty
|
||||||
|
if category == "audio" && !album.is_empty() {
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label", "Album" }
|
||||||
|
span { class: "detail-value", "{album}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Genre: audio and video, when non-empty
|
||||||
|
if (category == "audio" || category == "video") && !genre.is_empty() {
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label", "Genre" }
|
||||||
|
span { class: "detail-value", "{genre}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Year: audio, video, document, when non-empty
|
||||||
|
if (category == "audio" || category == "video" || category == "document") && !year_str.is_empty() {
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label", "Year" }
|
||||||
|
span { class: "detail-value", "{year_str}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Duration: audio and video
|
||||||
|
if (category == "audio" || category == "video") && media.duration_secs.is_some() {
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label", "Duration" }
|
||||||
|
span { class: "detail-value", "{duration_str}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Description: only shown when non-empty
|
||||||
|
if !description.is_empty() {
|
||||||
|
div { class: "detail-field full-width",
|
||||||
|
span { class: "detail-label", "Description" }
|
||||||
|
span { class: "detail-value", "{description}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label", "Created" }
|
||||||
|
span { class: "detail-value", "{media.created_at}" }
|
||||||
|
}
|
||||||
|
div { class: "detail-field",
|
||||||
|
span { class: "detail-label", "Updated" }
|
||||||
|
span { class: "detail-value", "{media.updated_at}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags section
|
||||||
|
div { class: "card mb-16",
|
||||||
|
div { class: "card-header",
|
||||||
|
h4 { class: "card-title", "Tags" }
|
||||||
|
}
|
||||||
|
div { class: "tag-list mb-8",
|
||||||
|
for tag in media_tags.iter() {
|
||||||
|
{
|
||||||
|
let tag_id = tag.id.clone();
|
||||||
|
let media_id_untag = id.clone();
|
||||||
|
rsx! {
|
||||||
|
span {
|
||||||
|
class: "tag-badge",
|
||||||
|
key: "{tag_id}",
|
||||||
|
"{tag.name}"
|
||||||
|
span {
|
||||||
|
class: "tag-remove",
|
||||||
|
onclick: {
|
||||||
|
let tid = tag_id.clone();
|
||||||
|
let mid = media_id_untag.clone();
|
||||||
|
move |_| on_untag.call((mid.clone(), tid.clone()))
|
||||||
|
},
|
||||||
|
"x"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "form-row",
|
||||||
|
select {
|
||||||
|
value: "{add_tag_id}",
|
||||||
|
onchange: move |e: Event<FormData>| add_tag_id.set(e.value()),
|
||||||
|
option { value: "", "Add tag..." }
|
||||||
|
for tag in available_tags.iter() {
|
||||||
|
{
|
||||||
|
let tid = tag.id.clone();
|
||||||
|
let tname = tag.name.clone();
|
||||||
|
rsx! {
|
||||||
|
option {
|
||||||
|
key: "{tid}",
|
||||||
|
value: "{tid}",
|
||||||
|
"{tname}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-primary",
|
||||||
|
onclick: on_tag_add_click,
|
||||||
|
"Add"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Technical Metadata section (system-extracted fields)
|
||||||
|
if has_system_fields {
|
||||||
|
div { class: "card mb-16",
|
||||||
|
div { class: "card-header",
|
||||||
|
h4 { class: "card-title", "Technical Metadata" }
|
||||||
|
}
|
||||||
|
div { class: "detail-grid",
|
||||||
|
for (key, _field_type, value) in system_fields.iter() {
|
||||||
|
div {
|
||||||
|
class: "detail-field",
|
||||||
|
key: "{key}",
|
||||||
|
span { class: "detail-label", "{key}" }
|
||||||
|
span { class: "detail-value", "{value}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom Fields section (user-defined)
|
||||||
|
div { class: "card",
|
||||||
|
div { class: "card-header",
|
||||||
|
h4 { class: "card-title", "Custom Fields" }
|
||||||
|
}
|
||||||
|
if has_user_fields {
|
||||||
|
div { class: "detail-grid",
|
||||||
|
for (key, field_type, value) in user_fields.iter() {
|
||||||
|
{
|
||||||
|
let field_name = key.clone();
|
||||||
|
let media_id_del = id.clone();
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
class: "detail-field",
|
||||||
|
key: "{field_name}",
|
||||||
|
span { class: "detail-label", "{key} ({field_type})" }
|
||||||
|
div { class: "flex-row",
|
||||||
|
span { class: "detail-value", "{value}" }
|
||||||
|
button {
|
||||||
|
class: "btn-icon",
|
||||||
|
onclick: {
|
||||||
|
let fname = field_name.clone();
|
||||||
|
let mid = media_id_del.clone();
|
||||||
|
move |_| on_delete_custom_field.call((mid.clone(), fname.clone()))
|
||||||
|
},
|
||||||
|
"x"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "form-row",
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Field name",
|
||||||
|
value: "{new_field_name}",
|
||||||
|
oninput: move |e: Event<FormData>| new_field_name.set(e.value()),
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
value: "{new_field_type}",
|
||||||
|
onchange: move |e: Event<FormData>| new_field_type.set(e.value()),
|
||||||
|
option { value: "text", "text" }
|
||||||
|
option { value: "number", "number" }
|
||||||
|
option { value: "date", "date" }
|
||||||
|
option { value: "boolean", "boolean" }
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Value",
|
||||||
|
value: "{new_field_value}",
|
||||||
|
oninput: move |e: Event<FormData>| new_field_value.set(e.value()),
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-primary",
|
||||||
|
onclick: on_add_field_click,
|
||||||
|
"Add"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image viewer overlay
|
||||||
|
if *show_image_viewer.read() {
|
||||||
|
ImageViewer {
|
||||||
|
src: stream_url_for_viewer.clone(),
|
||||||
|
alt: file_name_for_viewer.clone(),
|
||||||
|
on_close: move |_| show_image_viewer.set(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
170
crates/pinakes-ui/src/components/duplicates.rs
Normal file
170
crates/pinakes-ui/src/components/duplicates.rs
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use super::utils::{format_size, format_timestamp};
|
||||||
|
use crate::client::DuplicateGroupResponse;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Duplicates(
|
||||||
|
groups: Vec<DuplicateGroupResponse>,
|
||||||
|
server_url: String,
|
||||||
|
on_delete: EventHandler<String>,
|
||||||
|
on_refresh: EventHandler<()>,
|
||||||
|
) -> Element {
|
||||||
|
let mut expanded_group = use_signal(|| Option::<String>::None);
|
||||||
|
let mut confirm_delete = use_signal(|| Option::<String>::None);
|
||||||
|
|
||||||
|
let total_groups = groups.len();
|
||||||
|
let total_duplicates: usize = groups.iter().map(|g| g.items.len().saturating_sub(1)).sum();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "duplicates-view",
|
||||||
|
div { class: "duplicates-header",
|
||||||
|
h3 { "Duplicates" }
|
||||||
|
div { class: "duplicates-summary",
|
||||||
|
span { class: "text-muted",
|
||||||
|
"{total_groups} group(s), {total_duplicates} duplicate(s)"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-secondary",
|
||||||
|
onclick: move |_| on_refresh.call(()),
|
||||||
|
"Refresh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if groups.is_empty() {
|
||||||
|
div { class: "empty-state",
|
||||||
|
p { class: "text-muted", "No duplicate files found." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for group in groups.iter() {
|
||||||
|
{
|
||||||
|
let hash = group.content_hash.clone();
|
||||||
|
let is_expanded = expanded_group.read().as_ref() == Some(&hash);
|
||||||
|
let hash_for_toggle = hash.clone();
|
||||||
|
let item_count = group.items.len();
|
||||||
|
let first_name = group.items.first()
|
||||||
|
.map(|i| i.file_name.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let total_size: u64 = group.items.iter().map(|i| i.file_size).sum();
|
||||||
|
let short_hash = if hash.len() > 12 {
|
||||||
|
format!("{}...", &hash[..12])
|
||||||
|
} else {
|
||||||
|
hash.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
class: "duplicate-group",
|
||||||
|
key: "{hash}",
|
||||||
|
|
||||||
|
// Group header
|
||||||
|
button {
|
||||||
|
class: "duplicate-group-header",
|
||||||
|
onclick: move |_| {
|
||||||
|
let current = expanded_group.read().clone();
|
||||||
|
if current.as_ref() == Some(&hash_for_toggle) {
|
||||||
|
expanded_group.set(None);
|
||||||
|
} else {
|
||||||
|
expanded_group.set(Some(hash_for_toggle.clone()));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
span { class: "expand-icon",
|
||||||
|
if is_expanded { "\u{25bc}" } else { "\u{25b6}" }
|
||||||
|
}
|
||||||
|
span { class: "group-name", "{first_name}" }
|
||||||
|
span { class: "group-badge", "{item_count} files" }
|
||||||
|
span { class: "group-size text-muted", "{format_size(total_size)}" }
|
||||||
|
span { class: "group-hash mono text-muted",
|
||||||
|
"{short_hash}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded: show items
|
||||||
|
if is_expanded {
|
||||||
|
div { class: "duplicate-items",
|
||||||
|
for (idx, item) in group.items.iter().enumerate() {
|
||||||
|
{
|
||||||
|
let item_id = item.id.clone();
|
||||||
|
let is_first = idx == 0;
|
||||||
|
let is_confirming = confirm_delete.read().as_ref() == Some(&item_id);
|
||||||
|
let thumb_url = format!("{}/api/v1/media/{}/thumbnail", server_url, item.id);
|
||||||
|
let has_thumb = item.has_thumbnail;
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
class: if is_first { "duplicate-item duplicate-item-keep" } else { "duplicate-item" },
|
||||||
|
key: "{item_id}",
|
||||||
|
|
||||||
|
// Thumbnail
|
||||||
|
div { class: "dup-thumb",
|
||||||
|
if has_thumb {
|
||||||
|
img {
|
||||||
|
src: "{thumb_url}",
|
||||||
|
alt: "{item.file_name}",
|
||||||
|
class: "dup-thumb-img",
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
div { class: "dup-thumb-placeholder", "\u{1f5bc}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info
|
||||||
|
div { class: "dup-info",
|
||||||
|
div { class: "dup-filename", "{item.file_name}" }
|
||||||
|
div { class: "dup-path mono text-muted", "{item.path}" }
|
||||||
|
div { class: "dup-meta",
|
||||||
|
span { "{format_size(item.file_size)}" }
|
||||||
|
span { class: "text-muted", " | " }
|
||||||
|
span { "{format_timestamp(&item.created_at)}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
div { class: "dup-actions",
|
||||||
|
if is_first {
|
||||||
|
span { class: "keep-badge", "Keep" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_confirming {
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-danger",
|
||||||
|
onclick: {
|
||||||
|
let id = item_id.clone();
|
||||||
|
move |_| {
|
||||||
|
confirm_delete.set(None);
|
||||||
|
on_delete.call(id.clone());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Confirm"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-ghost",
|
||||||
|
onclick: move |_| confirm_delete.set(None),
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
} else if !is_first {
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-danger",
|
||||||
|
onclick: {
|
||||||
|
let id = item_id.clone();
|
||||||
|
move |_| confirm_delete.set(Some(id.clone()))
|
||||||
|
},
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
236
crates/pinakes-ui/src/components/image_viewer.rs
Normal file
236
crates/pinakes-ui/src/components/image_viewer.rs
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
enum FitMode {
|
||||||
|
FitScreen,
|
||||||
|
FitWidth,
|
||||||
|
Actual,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FitMode {
|
||||||
|
fn next(self) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::FitScreen => Self::FitWidth,
|
||||||
|
Self::FitWidth => Self::Actual,
|
||||||
|
Self::Actual => Self::FitScreen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::FitScreen => "Fit",
|
||||||
|
Self::FitWidth => "Width",
|
||||||
|
Self::Actual => "100%",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ImageViewer(
|
||||||
|
src: String,
|
||||||
|
alt: String,
|
||||||
|
on_close: EventHandler<()>,
|
||||||
|
#[props(default)] on_prev: Option<EventHandler<()>>,
|
||||||
|
#[props(default)] on_next: Option<EventHandler<()>>,
|
||||||
|
) -> Element {
|
||||||
|
let mut zoom = use_signal(|| 1.0f64);
|
||||||
|
let mut offset_x = use_signal(|| 0.0f64);
|
||||||
|
let mut offset_y = use_signal(|| 0.0f64);
|
||||||
|
let mut dragging = use_signal(|| false);
|
||||||
|
let mut drag_start_x = use_signal(|| 0.0f64);
|
||||||
|
let mut drag_start_y = use_signal(|| 0.0f64);
|
||||||
|
let mut fit_mode = use_signal(|| FitMode::FitScreen);
|
||||||
|
|
||||||
|
let z = *zoom.read();
|
||||||
|
let ox = *offset_x.read();
|
||||||
|
let oy = *offset_y.read();
|
||||||
|
let is_dragging = *dragging.read();
|
||||||
|
let zoom_pct = (z * 100.0) as u32;
|
||||||
|
let current_fit = *fit_mode.read();
|
||||||
|
|
||||||
|
let transform = format!("translate({ox}px, {oy}px) scale({z})");
|
||||||
|
let cursor = if z > 1.0 {
|
||||||
|
if is_dragging { "grabbing" } else { "grab" }
|
||||||
|
} else {
|
||||||
|
"default"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute image style based on fit mode
|
||||||
|
let img_style = match current_fit {
|
||||||
|
FitMode::FitScreen => format!(
|
||||||
|
"transform: {transform}; cursor: {cursor}; max-width: 100%; max-height: 100%; object-fit: contain;"
|
||||||
|
),
|
||||||
|
FitMode::FitWidth => {
|
||||||
|
format!("transform: {transform}; cursor: {cursor}; width: 100%; object-fit: contain;")
|
||||||
|
}
|
||||||
|
FitMode::Actual => format!("transform: {transform}; cursor: {cursor};"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_wheel = move |e: WheelEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
let delta = e.delta().strip_units();
|
||||||
|
let factor = if delta.y < 0.0 { 1.1 } else { 1.0 / 1.1 };
|
||||||
|
let new_zoom = (*zoom.read() * factor).clamp(0.1, 20.0);
|
||||||
|
zoom.set(new_zoom);
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_mouse_down = move |e: MouseEvent| {
|
||||||
|
if *zoom.read() > 1.0 {
|
||||||
|
dragging.set(true);
|
||||||
|
let coords = e.client_coordinates();
|
||||||
|
drag_start_x.set(coords.x - *offset_x.read());
|
||||||
|
drag_start_y.set(coords.y - *offset_y.read());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_mouse_move = move |e: MouseEvent| {
|
||||||
|
if *dragging.read() {
|
||||||
|
let coords = e.client_coordinates();
|
||||||
|
offset_x.set(coords.x - *drag_start_x.read());
|
||||||
|
offset_y.set(coords.y - *drag_start_y.read());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_mouse_up = move |_: MouseEvent| {
|
||||||
|
dragging.set(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_keydown = {
|
||||||
|
move |evt: KeyboardEvent| match evt.key() {
|
||||||
|
Key::Escape => on_close.call(()),
|
||||||
|
Key::Character(ref c) if c == "+" || c == "=" => {
|
||||||
|
let new_zoom = (*zoom.read() * 1.2).min(20.0);
|
||||||
|
zoom.set(new_zoom);
|
||||||
|
}
|
||||||
|
Key::Character(ref c) if c == "-" => {
|
||||||
|
let new_zoom = (*zoom.read() / 1.2).max(0.1);
|
||||||
|
zoom.set(new_zoom);
|
||||||
|
}
|
||||||
|
Key::Character(ref c) if c == "0" => {
|
||||||
|
zoom.set(1.0);
|
||||||
|
offset_x.set(0.0);
|
||||||
|
offset_y.set(0.0);
|
||||||
|
fit_mode.set(FitMode::FitScreen);
|
||||||
|
}
|
||||||
|
Key::ArrowLeft => {
|
||||||
|
if let Some(ref prev) = on_prev {
|
||||||
|
prev.call(());
|
||||||
|
zoom.set(1.0);
|
||||||
|
offset_x.set(0.0);
|
||||||
|
offset_y.set(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Key::ArrowRight => {
|
||||||
|
if let Some(ref next) = on_next {
|
||||||
|
next.call(());
|
||||||
|
zoom.set(1.0);
|
||||||
|
offset_x.set(0.0);
|
||||||
|
offset_y.set(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let zoom_in = move |_| {
|
||||||
|
let new_zoom = (*zoom.read() * 1.2).min(20.0);
|
||||||
|
zoom.set(new_zoom);
|
||||||
|
};
|
||||||
|
|
||||||
|
let zoom_out = move |_| {
|
||||||
|
let new_zoom = (*zoom.read() / 1.2).max(0.1);
|
||||||
|
zoom.set(new_zoom);
|
||||||
|
};
|
||||||
|
|
||||||
|
let cycle_fit = move |_| {
|
||||||
|
let next = fit_mode.read().next();
|
||||||
|
fit_mode.set(next);
|
||||||
|
zoom.set(1.0);
|
||||||
|
offset_x.set(0.0);
|
||||||
|
offset_y.set(0.0);
|
||||||
|
};
|
||||||
|
|
||||||
|
let has_prev = on_prev.is_some();
|
||||||
|
let has_next = on_next.is_some();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
class: "image-viewer-overlay",
|
||||||
|
tabindex: "0",
|
||||||
|
onkeydown: on_keydown,
|
||||||
|
|
||||||
|
// Toolbar
|
||||||
|
div { class: "image-viewer-toolbar",
|
||||||
|
div { class: "image-viewer-toolbar-left",
|
||||||
|
if has_prev {
|
||||||
|
button {
|
||||||
|
class: "iv-btn",
|
||||||
|
onclick: move |_| {
|
||||||
|
if let Some(ref prev) = on_prev {
|
||||||
|
prev.call(());
|
||||||
|
zoom.set(1.0);
|
||||||
|
offset_x.set(0.0);
|
||||||
|
offset_y.set(0.0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: "Previous",
|
||||||
|
"\u{25c0}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has_next {
|
||||||
|
button {
|
||||||
|
class: "iv-btn",
|
||||||
|
onclick: move |_| {
|
||||||
|
if let Some(ref next) = on_next {
|
||||||
|
next.call(());
|
||||||
|
zoom.set(1.0);
|
||||||
|
offset_x.set(0.0);
|
||||||
|
offset_y.set(0.0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: "Next",
|
||||||
|
"\u{25b6}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "image-viewer-toolbar-center",
|
||||||
|
button { class: "iv-btn", onclick: cycle_fit, title: "Cycle fit mode",
|
||||||
|
"{current_fit.label()}"
|
||||||
|
}
|
||||||
|
button { class: "iv-btn", onclick: zoom_out, title: "Zoom out", "\u{2212}" }
|
||||||
|
span { class: "iv-zoom-label", "{zoom_pct}%" }
|
||||||
|
button { class: "iv-btn", onclick: zoom_in, title: "Zoom in", "+" }
|
||||||
|
}
|
||||||
|
div { class: "image-viewer-toolbar-right",
|
||||||
|
button {
|
||||||
|
class: "iv-btn iv-close",
|
||||||
|
onclick: move |_| on_close.call(()),
|
||||||
|
title: "Close",
|
||||||
|
"\u{2715}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image canvas
|
||||||
|
div {
|
||||||
|
class: "image-viewer-canvas",
|
||||||
|
onwheel: on_wheel,
|
||||||
|
onmousedown: on_mouse_down,
|
||||||
|
onmousemove: on_mouse_move,
|
||||||
|
onmouseup: on_mouse_up,
|
||||||
|
onclick: move |e: MouseEvent| {
|
||||||
|
// Close on background click (not on image)
|
||||||
|
e.stop_propagation();
|
||||||
|
},
|
||||||
|
|
||||||
|
img {
|
||||||
|
src: "{src}",
|
||||||
|
alt: "{alt}",
|
||||||
|
style: "{img_style}",
|
||||||
|
draggable: "false",
|
||||||
|
onclick: move |e: MouseEvent| e.stop_propagation(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
717
crates/pinakes-ui/src/components/import.rs
Normal file
717
crates/pinakes-ui/src/components/import.rs
Normal file
|
|
@ -0,0 +1,717 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use super::utils::{format_size, type_badge_class};
|
||||||
|
use crate::client::{
|
||||||
|
CollectionResponse, DirectoryPreviewFile, ImportEvent, ScanStatusResponse, TagResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Import event for batch: (paths, tag_ids, new_tags, collection_id)
|
||||||
|
pub type BatchImportEvent = (Vec<String>, Vec<String>, Vec<String>, Option<String>);
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Import(
|
||||||
|
tags: Vec<TagResponse>,
|
||||||
|
collections: Vec<CollectionResponse>,
|
||||||
|
on_import_file: EventHandler<ImportEvent>,
|
||||||
|
on_import_directory: EventHandler<ImportEvent>,
|
||||||
|
on_import_batch: EventHandler<BatchImportEvent>,
|
||||||
|
on_scan: EventHandler<()>,
|
||||||
|
on_preview_directory: EventHandler<(String, bool)>,
|
||||||
|
preview_files: Vec<DirectoryPreviewFile>,
|
||||||
|
preview_total_size: u64,
|
||||||
|
scan_progress: Option<ScanStatusResponse>,
|
||||||
|
) -> Element {
|
||||||
|
let mut import_mode = use_signal(|| 0usize);
|
||||||
|
let mut file_path = use_signal(String::new);
|
||||||
|
let mut dir_path = use_signal(String::new);
|
||||||
|
let selected_tags = use_signal(Vec::<String>::new);
|
||||||
|
let new_tags_input = use_signal(String::new);
|
||||||
|
let selected_collection = use_signal(|| Option::<String>::None);
|
||||||
|
|
||||||
|
// Recursive toggle for directory preview
|
||||||
|
let mut recursive = use_signal(|| true);
|
||||||
|
|
||||||
|
// Filter state for directory preview
|
||||||
|
let mut filter_types = use_signal(|| vec![true, true, true, true, true, true]); // audio, video, image, document, text, other
|
||||||
|
let mut filter_min_size = use_signal(|| 0u64);
|
||||||
|
let mut filter_max_size = use_signal(|| 0u64); // 0 means no limit
|
||||||
|
|
||||||
|
// File selection state
|
||||||
|
let mut selected_file_paths = use_signal(HashSet::<String>::new);
|
||||||
|
|
||||||
|
let current_mode = *import_mode.read();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
// Tab bar
|
||||||
|
div { class: "import-tabs",
|
||||||
|
button {
|
||||||
|
class: if current_mode == 0 { "import-tab active" } else { "import-tab" },
|
||||||
|
onclick: move |_| import_mode.set(0),
|
||||||
|
"Single File"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: if current_mode == 1 { "import-tab active" } else { "import-tab" },
|
||||||
|
onclick: move |_| import_mode.set(1),
|
||||||
|
"Directory"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: if current_mode == 2 { "import-tab active" } else { "import-tab" },
|
||||||
|
onclick: move |_| import_mode.set(2),
|
||||||
|
"Scan Roots"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode 0: Single File
|
||||||
|
if current_mode == 0 {
|
||||||
|
div { class: "card mb-16",
|
||||||
|
div { class: "card-header",
|
||||||
|
h3 { class: "card-title", "Import Single File" }
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "form-group",
|
||||||
|
label { class: "form-label", "File Path" }
|
||||||
|
div { class: "form-row",
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "/path/to/file...",
|
||||||
|
value: "{file_path}",
|
||||||
|
oninput: move |e| file_path.set(e.value()),
|
||||||
|
onkeypress: {
|
||||||
|
let mut file_path = file_path;
|
||||||
|
let mut selected_tags = selected_tags;
|
||||||
|
let mut new_tags_input = new_tags_input;
|
||||||
|
let mut selected_collection = selected_collection;
|
||||||
|
move |e: KeyboardEvent| {
|
||||||
|
if e.key() == Key::Enter {
|
||||||
|
let path = file_path.read().clone();
|
||||||
|
if !path.is_empty() {
|
||||||
|
let tag_ids = selected_tags.read().clone();
|
||||||
|
let new_tags = parse_new_tags(&new_tags_input.read());
|
||||||
|
let col_id = selected_collection.read().clone();
|
||||||
|
on_import_file.call((path, tag_ids, new_tags, col_id));
|
||||||
|
file_path.set(String::new());
|
||||||
|
selected_tags.set(Vec::new());
|
||||||
|
new_tags_input.set(String::new());
|
||||||
|
selected_collection.set(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-secondary",
|
||||||
|
onclick: move |_| {
|
||||||
|
let mut file_path = file_path;
|
||||||
|
spawn(async move {
|
||||||
|
if let Some(handle) = rfd::AsyncFileDialog::new().pick_file().await {
|
||||||
|
file_path.set(handle.path().to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"Browse..."
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
onclick: {
|
||||||
|
let mut file_path = file_path;
|
||||||
|
let mut selected_tags = selected_tags;
|
||||||
|
let mut new_tags_input = new_tags_input;
|
||||||
|
let mut selected_collection = selected_collection;
|
||||||
|
move |_| {
|
||||||
|
let path = file_path.read().clone();
|
||||||
|
if !path.is_empty() {
|
||||||
|
let tag_ids = selected_tags.read().clone();
|
||||||
|
let new_tags = parse_new_tags(&new_tags_input.read());
|
||||||
|
let col_id = selected_collection.read().clone();
|
||||||
|
on_import_file.call((path, tag_ids, new_tags, col_id));
|
||||||
|
file_path.set(String::new());
|
||||||
|
selected_tags.set(Vec::new());
|
||||||
|
new_tags_input.set(String::new());
|
||||||
|
selected_collection.set(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Import"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportOptions {
|
||||||
|
tags: tags.clone(),
|
||||||
|
collections: collections.clone(),
|
||||||
|
selected_tags: selected_tags,
|
||||||
|
new_tags_input: new_tags_input,
|
||||||
|
selected_collection: selected_collection,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode 1: Directory
|
||||||
|
if current_mode == 1 {
|
||||||
|
div { class: "card mb-16",
|
||||||
|
div { class: "card-header",
|
||||||
|
h3 { class: "card-title", "Import Directory" }
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "form-group",
|
||||||
|
label { class: "form-label", "Directory Path" }
|
||||||
|
div { class: "form-row",
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "/path/to/directory...",
|
||||||
|
value: "{dir_path}",
|
||||||
|
oninput: move |e| dir_path.set(e.value()),
|
||||||
|
onkeypress: {
|
||||||
|
let dir_path = dir_path;
|
||||||
|
let recursive = recursive;
|
||||||
|
move |e: KeyboardEvent| {
|
||||||
|
if e.key() == Key::Enter {
|
||||||
|
let path = dir_path.read().clone();
|
||||||
|
if !path.is_empty() {
|
||||||
|
on_preview_directory.call((path, *recursive.read()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-secondary",
|
||||||
|
onclick: move |_| {
|
||||||
|
let mut dir_path = dir_path;
|
||||||
|
let recursive = recursive;
|
||||||
|
spawn(async move {
|
||||||
|
if let Some(handle) = rfd::AsyncFileDialog::new().pick_folder().await {
|
||||||
|
let path = handle.path().to_string_lossy().to_string();
|
||||||
|
dir_path.set(path.clone());
|
||||||
|
on_preview_directory.call((path, *recursive.read()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"Browse..."
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-secondary",
|
||||||
|
onclick: {
|
||||||
|
let dir_path = dir_path;
|
||||||
|
let recursive = recursive;
|
||||||
|
move |_| {
|
||||||
|
let path = dir_path.read().clone();
|
||||||
|
if !path.is_empty() {
|
||||||
|
on_preview_directory.call((path, *recursive.read()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Preview"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursive toggle
|
||||||
|
div { class: "form-group",
|
||||||
|
label { class: "form-row",
|
||||||
|
input {
|
||||||
|
r#type: "checkbox",
|
||||||
|
checked: *recursive.read(),
|
||||||
|
onchange: move |_| recursive.toggle(),
|
||||||
|
}
|
||||||
|
span { style: "margin-left: 6px;", "Recursive (include subdirectories)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview results
|
||||||
|
if !preview_files.is_empty() {
|
||||||
|
{
|
||||||
|
// Read filter signals once before the loop to avoid per-item reads
|
||||||
|
let types_snapshot = filter_types.read().clone();
|
||||||
|
let min = *filter_min_size.read();
|
||||||
|
let max = *filter_max_size.read();
|
||||||
|
|
||||||
|
let filtered: Vec<&DirectoryPreviewFile> = preview_files.iter().filter(|f| {
|
||||||
|
let type_idx = match type_badge_class(&f.media_type) {
|
||||||
|
"type-audio" => 0,
|
||||||
|
"type-video" => 1,
|
||||||
|
"type-image" => 2,
|
||||||
|
"type-document" => 3,
|
||||||
|
"type-text" => 4,
|
||||||
|
_ => 5,
|
||||||
|
};
|
||||||
|
if !types_snapshot[type_idx] { return false; }
|
||||||
|
if min > 0 && f.file_size < min { return false; }
|
||||||
|
if max > 0 && f.file_size > max { return false; }
|
||||||
|
true
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
let filtered_count = filtered.len();
|
||||||
|
let total_count = preview_files.len();
|
||||||
|
|
||||||
|
// Read selection once for display
|
||||||
|
let selection = selected_file_paths.read().clone();
|
||||||
|
let selected_count = selection.len();
|
||||||
|
let all_filtered_selected = !filtered.is_empty()
|
||||||
|
&& filtered.iter().all(|f| selection.contains(&f.path));
|
||||||
|
|
||||||
|
let filtered_paths: Vec<String> = filtered.iter().map(|f| f.path.clone()).collect();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "card mb-16",
|
||||||
|
div { class: "card-header",
|
||||||
|
h3 { class: "card-title", "Preview" }
|
||||||
|
p { class: "text-muted text-sm",
|
||||||
|
"{filtered_count} of {total_count} files shown, {format_size(preview_total_size)} total"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter bar
|
||||||
|
div { class: "filter-bar",
|
||||||
|
div { class: "flex-row mb-8",
|
||||||
|
label {
|
||||||
|
input {
|
||||||
|
r#type: "checkbox",
|
||||||
|
checked: types_snapshot[0],
|
||||||
|
onchange: move |_| {
|
||||||
|
let mut types = filter_types.read().clone();
|
||||||
|
types[0] = !types[0];
|
||||||
|
filter_types.set(types);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
" Audio"
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
input {
|
||||||
|
r#type: "checkbox",
|
||||||
|
checked: types_snapshot[1],
|
||||||
|
onchange: move |_| {
|
||||||
|
let mut types = filter_types.read().clone();
|
||||||
|
types[1] = !types[1];
|
||||||
|
filter_types.set(types);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
" Video"
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
input {
|
||||||
|
r#type: "checkbox",
|
||||||
|
checked: types_snapshot[2],
|
||||||
|
onchange: move |_| {
|
||||||
|
let mut types = filter_types.read().clone();
|
||||||
|
types[2] = !types[2];
|
||||||
|
filter_types.set(types);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
" Image"
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
input {
|
||||||
|
r#type: "checkbox",
|
||||||
|
checked: types_snapshot[3],
|
||||||
|
onchange: move |_| {
|
||||||
|
let mut types = filter_types.read().clone();
|
||||||
|
types[3] = !types[3];
|
||||||
|
filter_types.set(types);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
" Document"
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
input {
|
||||||
|
r#type: "checkbox",
|
||||||
|
checked: types_snapshot[4],
|
||||||
|
onchange: move |_| {
|
||||||
|
let mut types = filter_types.read().clone();
|
||||||
|
types[4] = !types[4];
|
||||||
|
filter_types.set(types);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
" Text"
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
input {
|
||||||
|
r#type: "checkbox",
|
||||||
|
checked: types_snapshot[5],
|
||||||
|
onchange: move |_| {
|
||||||
|
let mut types = filter_types.read().clone();
|
||||||
|
types[5] = !types[5];
|
||||||
|
filter_types.set(types);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
" Other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "flex-row",
|
||||||
|
label { class: "form-label", "Min size (MB): " }
|
||||||
|
input {
|
||||||
|
r#type: "number",
|
||||||
|
value: "{min / (1024 * 1024)}",
|
||||||
|
oninput: move |e| {
|
||||||
|
if let Ok(mb) = e.value().parse::<u64>() {
|
||||||
|
filter_min_size.set(mb * 1024 * 1024);
|
||||||
|
} else {
|
||||||
|
filter_min_size.set(0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
label { class: "form-label", "Max size (MB): " }
|
||||||
|
input {
|
||||||
|
r#type: "number",
|
||||||
|
value: "{max / (1024 * 1024)}",
|
||||||
|
oninput: move |e| {
|
||||||
|
if let Ok(mb) = e.value().parse::<u64>() {
|
||||||
|
filter_max_size.set(mb * 1024 * 1024);
|
||||||
|
} else {
|
||||||
|
filter_max_size.set(0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection toolbar
|
||||||
|
div { class: "flex-row mb-8", style: "gap: 8px; align-items: center; padding: 0 8px;",
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-secondary",
|
||||||
|
onclick: {
|
||||||
|
let filtered_paths = filtered_paths.clone();
|
||||||
|
move |_| {
|
||||||
|
let mut sel = selected_file_paths.read().clone();
|
||||||
|
for p in &filtered_paths {
|
||||||
|
sel.insert(p.clone());
|
||||||
|
}
|
||||||
|
selected_file_paths.set(sel);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Select All ({filtered_count})"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-ghost",
|
||||||
|
onclick: move |_| {
|
||||||
|
selected_file_paths.set(HashSet::new());
|
||||||
|
},
|
||||||
|
"Deselect All"
|
||||||
|
}
|
||||||
|
if selected_count > 0 {
|
||||||
|
span { class: "text-muted text-sm",
|
||||||
|
"{selected_count} files selected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div { style: "max-height: 400px; overflow-y: auto;",
|
||||||
|
table { class: "data-table",
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { style: "width: 32px;",
|
||||||
|
input {
|
||||||
|
r#type: "checkbox",
|
||||||
|
checked: all_filtered_selected,
|
||||||
|
onclick: {
|
||||||
|
let filtered_paths = filtered_paths.clone();
|
||||||
|
move |_| {
|
||||||
|
if all_filtered_selected {
|
||||||
|
// Deselect all filtered
|
||||||
|
let filtered_set: HashSet<String> = filtered_paths.iter().cloned().collect();
|
||||||
|
let sel = selected_file_paths.read().clone();
|
||||||
|
let new_sel: HashSet<String> = sel.difference(&filtered_set).cloned().collect();
|
||||||
|
selected_file_paths.set(new_sel);
|
||||||
|
} else {
|
||||||
|
// Select all filtered
|
||||||
|
let mut sel = selected_file_paths.read().clone();
|
||||||
|
for p in &filtered_paths {
|
||||||
|
sel.insert(p.clone());
|
||||||
|
}
|
||||||
|
selected_file_paths.set(sel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
th { "File Name" }
|
||||||
|
th { "Type" }
|
||||||
|
th { "Size" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for file in filtered.iter() {
|
||||||
|
{
|
||||||
|
let size = format_size(file.file_size);
|
||||||
|
let badge_class = type_badge_class(&file.media_type);
|
||||||
|
let is_selected = selection.contains(&file.path);
|
||||||
|
let file_path_clone = file.path.clone();
|
||||||
|
rsx! {
|
||||||
|
tr {
|
||||||
|
key: "{file.path}",
|
||||||
|
class: if is_selected { "row-selected" } else { "" },
|
||||||
|
td {
|
||||||
|
input {
|
||||||
|
r#type: "checkbox",
|
||||||
|
checked: is_selected,
|
||||||
|
onclick: {
|
||||||
|
let path = file_path_clone.clone();
|
||||||
|
move |_| {
|
||||||
|
let mut sel = selected_file_paths.read().clone();
|
||||||
|
if sel.contains(&path) {
|
||||||
|
sel.remove(&path);
|
||||||
|
} else {
|
||||||
|
sel.insert(path.clone());
|
||||||
|
}
|
||||||
|
selected_file_paths.set(sel);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td { "{file.file_name}" }
|
||||||
|
td {
|
||||||
|
span { class: "type-badge {badge_class}", "{file.media_type}" }
|
||||||
|
}
|
||||||
|
td { "{size}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportOptions {
|
||||||
|
tags: tags.clone(),
|
||||||
|
collections: collections.clone(),
|
||||||
|
selected_tags: selected_tags,
|
||||||
|
new_tags_input: new_tags_input,
|
||||||
|
selected_collection: selected_collection,
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "flex-row mb-16", style: "gap: 8px;",
|
||||||
|
// Import selected files only (batch import)
|
||||||
|
{
|
||||||
|
let sel_count = selected_file_paths.read().len();
|
||||||
|
let has_selected = sel_count > 0;
|
||||||
|
rsx! {
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
disabled: !has_selected,
|
||||||
|
onclick: {
|
||||||
|
let mut selected_file_paths = selected_file_paths;
|
||||||
|
let mut selected_tags = selected_tags;
|
||||||
|
let mut new_tags_input = new_tags_input;
|
||||||
|
let mut selected_collection = selected_collection;
|
||||||
|
move |_| {
|
||||||
|
let paths: Vec<String> = selected_file_paths.read().iter().cloned().collect();
|
||||||
|
if !paths.is_empty() {
|
||||||
|
let tag_ids = selected_tags.read().clone();
|
||||||
|
let new_tags = parse_new_tags(&new_tags_input.read());
|
||||||
|
let col_id = selected_collection.read().clone();
|
||||||
|
on_import_batch.call((paths, tag_ids, new_tags, col_id));
|
||||||
|
selected_file_paths.set(HashSet::new());
|
||||||
|
selected_tags.set(Vec::new());
|
||||||
|
new_tags_input.set(String::new());
|
||||||
|
selected_collection.set(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
if has_selected {
|
||||||
|
"Import Selected ({sel_count})"
|
||||||
|
} else {
|
||||||
|
"Import Selected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import entire directory
|
||||||
|
button {
|
||||||
|
class: "btn btn-secondary",
|
||||||
|
onclick: {
|
||||||
|
let mut dir_path = dir_path;
|
||||||
|
let mut selected_tags = selected_tags;
|
||||||
|
let mut new_tags_input = new_tags_input;
|
||||||
|
let mut selected_collection = selected_collection;
|
||||||
|
let mut selected_file_paths = selected_file_paths;
|
||||||
|
move |_| {
|
||||||
|
let path = dir_path.read().clone();
|
||||||
|
if !path.is_empty() {
|
||||||
|
let tag_ids = selected_tags.read().clone();
|
||||||
|
let new_tags = parse_new_tags(&new_tags_input.read());
|
||||||
|
let col_id = selected_collection.read().clone();
|
||||||
|
on_import_directory.call((path, tag_ids, new_tags, col_id));
|
||||||
|
dir_path.set(String::new());
|
||||||
|
selected_tags.set(Vec::new());
|
||||||
|
new_tags_input.set(String::new());
|
||||||
|
selected_collection.set(None);
|
||||||
|
selected_file_paths.set(HashSet::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Import Entire Directory"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode 2: Scan Roots
|
||||||
|
if current_mode == 2 {
|
||||||
|
div { class: "card mb-16",
|
||||||
|
div { class: "card-header",
|
||||||
|
h3 { class: "card-title", "Scan Root Directories" }
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "empty-state",
|
||||||
|
p { class: "empty-subtitle",
|
||||||
|
"Scan all configured root directories for media files. "
|
||||||
|
"This will discover and import any new files found in your root paths."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "mb-16", style: "text-align: center;",
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
onclick: move |_| on_scan.call(()),
|
||||||
|
"Scan All Roots"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref progress) = scan_progress {
|
||||||
|
{
|
||||||
|
let pct = (progress.files_processed * 100).checked_div(progress.files_found).unwrap_or(0);
|
||||||
|
rsx! {
|
||||||
|
div { class: "mb-16",
|
||||||
|
div { class: "progress-bar",
|
||||||
|
div {
|
||||||
|
class: "progress-fill",
|
||||||
|
style: "width: {pct}%;",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p { class: "text-muted text-sm",
|
||||||
|
"{progress.files_processed} / {progress.files_found} files processed"
|
||||||
|
}
|
||||||
|
if progress.error_count > 0 {
|
||||||
|
p { class: "text-muted text-sm",
|
||||||
|
"{progress.error_count} errors"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if progress.scanning {
|
||||||
|
p { class: "text-muted text-sm", "Scanning..." }
|
||||||
|
} else {
|
||||||
|
p { class: "text-muted text-sm", "Scan complete" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn ImportOptions(
|
||||||
|
tags: Vec<TagResponse>,
|
||||||
|
collections: Vec<CollectionResponse>,
|
||||||
|
selected_tags: Signal<Vec<String>>,
|
||||||
|
new_tags_input: Signal<String>,
|
||||||
|
selected_collection: Signal<Option<String>>,
|
||||||
|
) -> Element {
|
||||||
|
let selected_tags = selected_tags;
|
||||||
|
let mut new_tags_input = new_tags_input;
|
||||||
|
let selected_collection = selected_collection;
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "card mb-16",
|
||||||
|
div { class: "card-header",
|
||||||
|
h4 { class: "card-title", "Import Options" }
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "form-group",
|
||||||
|
label { class: "form-label", "Tags" }
|
||||||
|
if tags.is_empty() {
|
||||||
|
p { class: "text-muted text-sm", "No tags available. Create tags from the Tags page." }
|
||||||
|
} else {
|
||||||
|
div { class: "tag-list",
|
||||||
|
for tag in tags.iter() {
|
||||||
|
{
|
||||||
|
let tag_id = tag.id.clone();
|
||||||
|
let tag_name = tag.name.clone();
|
||||||
|
let is_selected = selected_tags.read().contains(&tag_id);
|
||||||
|
let badge_class = if is_selected {
|
||||||
|
"tag-badge selected"
|
||||||
|
} else {
|
||||||
|
"tag-badge"
|
||||||
|
};
|
||||||
|
rsx! {
|
||||||
|
span {
|
||||||
|
class: "{badge_class}",
|
||||||
|
onclick: {
|
||||||
|
let tag_id = tag_id.clone();
|
||||||
|
let mut selected_tags = selected_tags;
|
||||||
|
move |_| {
|
||||||
|
let mut current = selected_tags.read().clone();
|
||||||
|
if let Some(pos) = current.iter().position(|t| t == &tag_id) {
|
||||||
|
current.remove(pos);
|
||||||
|
} else {
|
||||||
|
current.push(tag_id.clone());
|
||||||
|
}
|
||||||
|
selected_tags.set(current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"{tag_name}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "form-group",
|
||||||
|
label { class: "form-label", "Create New Tags" }
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "tag1, tag2, tag3...",
|
||||||
|
value: "{new_tags_input}",
|
||||||
|
oninput: move |e| new_tags_input.set(e.value()),
|
||||||
|
}
|
||||||
|
p { class: "text-muted text-sm", "Comma-separated. Will be created if they don't exist." }
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "form-group",
|
||||||
|
label { class: "form-label", "Add to Collection" }
|
||||||
|
select {
|
||||||
|
value: "{selected_collection.read().clone().unwrap_or_default()}",
|
||||||
|
onchange: {
|
||||||
|
let mut selected_collection = selected_collection;
|
||||||
|
move |e: Event<FormData>| {
|
||||||
|
let val = e.value();
|
||||||
|
if val.is_empty() {
|
||||||
|
selected_collection.set(None);
|
||||||
|
} else {
|
||||||
|
selected_collection.set(Some(val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
option { value: "", "None" }
|
||||||
|
for col in collections.iter() {
|
||||||
|
{
|
||||||
|
let col_id = col.id.clone();
|
||||||
|
let col_name = col.name.clone();
|
||||||
|
rsx! {
|
||||||
|
option { value: "{col_id}", "{col_name}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_new_tags(input: &str) -> Vec<String> {
|
||||||
|
input
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
874
crates/pinakes-ui/src/components/library.rs
Normal file
874
crates/pinakes-ui/src/components/library.rs
Normal file
|
|
@ -0,0 +1,874 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use super::pagination::Pagination as PaginationControls;
|
||||||
|
use super::utils::{format_size, media_category, type_badge_class, type_icon};
|
||||||
|
use crate::client::{CollectionResponse, MediaResponse, TagResponse};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum ViewMode {
|
||||||
|
Grid,
|
||||||
|
Table,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The set of type filter categories available to the user.
|
||||||
|
const TYPE_FILTERS: &[&str] = &["all", "audio", "video", "image", "document", "text"];
|
||||||
|
|
||||||
|
/// Human-readable label for a type filter value.
|
||||||
|
fn filter_label(f: &str) -> &str {
|
||||||
|
match f {
|
||||||
|
"all" => "All",
|
||||||
|
"audio" => "Audio",
|
||||||
|
"video" => "Video",
|
||||||
|
"image" => "Image",
|
||||||
|
"document" => "Document",
|
||||||
|
"text" => "Text",
|
||||||
|
_ => f,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the current sort field string into (column, direction) so table
|
||||||
|
/// headers can show the correct arrow indicator.
|
||||||
|
fn parse_sort(sort: &str) -> (&str, &str) {
|
||||||
|
if let Some(col) = sort.strip_suffix("_asc") {
|
||||||
|
(col, "asc")
|
||||||
|
} else if let Some(col) = sort.strip_suffix("_desc") {
|
||||||
|
(col, "desc")
|
||||||
|
} else {
|
||||||
|
(sort, "asc")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the sort arrow indicator for a table column header. Returns an empty
|
||||||
|
/// string when the column is not the active sort column.
|
||||||
|
fn sort_arrow(current_sort: &str, column: &str) -> &'static str {
|
||||||
|
let (col, dir) = parse_sort(current_sort);
|
||||||
|
if col == column {
|
||||||
|
if dir == "asc" {
|
||||||
|
" \u{25b2}"
|
||||||
|
} else {
|
||||||
|
" \u{25bc}"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the next sort value when a table column header is clicked. If the
|
||||||
|
/// column is already sorted ascending, flip to descending and vice-versa.
|
||||||
|
/// Otherwise default to ascending.
|
||||||
|
fn next_sort(current_sort: &str, column: &str) -> String {
|
||||||
|
let (col, dir) = parse_sort(current_sort);
|
||||||
|
if col == column {
|
||||||
|
let new_dir = if dir == "asc" { "desc" } else { "asc" };
|
||||||
|
format!("{column}_{new_dir}")
|
||||||
|
} else {
|
||||||
|
format!("{column}_asc")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Library(
|
||||||
|
media: Vec<MediaResponse>,
|
||||||
|
tags: Vec<TagResponse>,
|
||||||
|
collections: Vec<CollectionResponse>,
|
||||||
|
total_count: u64,
|
||||||
|
current_page: u64,
|
||||||
|
page_size: u64,
|
||||||
|
server_url: String,
|
||||||
|
on_select: EventHandler<String>,
|
||||||
|
on_delete: EventHandler<String>,
|
||||||
|
on_batch_delete: EventHandler<Vec<String>>,
|
||||||
|
on_batch_tag: EventHandler<(Vec<String>, Vec<String>)>,
|
||||||
|
on_batch_collection: EventHandler<(Vec<String>, String)>,
|
||||||
|
on_page_change: EventHandler<u64>,
|
||||||
|
on_page_size_change: EventHandler<u64>,
|
||||||
|
on_sort_change: EventHandler<String>,
|
||||||
|
#[props(default)] on_select_all_global: Option<EventHandler<EventHandler<Vec<String>>>>,
|
||||||
|
#[props(default)] on_delete_all: Option<EventHandler<()>>,
|
||||||
|
) -> Element {
|
||||||
|
let mut selected_ids = use_signal(Vec::<String>::new);
|
||||||
|
let mut select_all = use_signal(|| false);
|
||||||
|
let mut confirm_delete = use_signal(|| Option::<String>::None);
|
||||||
|
let mut confirm_batch_delete = use_signal(|| false);
|
||||||
|
let mut confirm_delete_all = use_signal(|| false);
|
||||||
|
let mut show_batch_tag = use_signal(|| false);
|
||||||
|
let mut batch_tag_selection = use_signal(Vec::<String>::new);
|
||||||
|
let mut show_batch_collection = use_signal(|| false);
|
||||||
|
let mut batch_collection_id = use_signal(String::new);
|
||||||
|
let mut view_mode = use_signal(|| ViewMode::Grid);
|
||||||
|
let mut sort_field = use_signal(|| "created_at_desc".to_string());
|
||||||
|
let mut type_filter = use_signal(|| "all".to_string());
|
||||||
|
// Track the last-clicked index for shift+click range selection.
|
||||||
|
let mut last_click_index = use_signal(|| Option::<usize>::None);
|
||||||
|
// True when all items across all pages have been selected.
|
||||||
|
let mut global_all_selected = use_signal(|| false);
|
||||||
|
|
||||||
|
if media.is_empty() && total_count == 0 {
|
||||||
|
return rsx! {
|
||||||
|
div { class: "empty-state",
|
||||||
|
h3 { class: "empty-title", "No media found" }
|
||||||
|
p { class: "empty-subtitle", "Import files or scan your root directories to get started." }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply client-side type filter.
|
||||||
|
let active_filter = type_filter.read().clone();
|
||||||
|
let filtered_media: Vec<MediaResponse> = if active_filter == "all" {
|
||||||
|
media.clone()
|
||||||
|
} else {
|
||||||
|
media
|
||||||
|
.iter()
|
||||||
|
.filter(|m| media_category(&m.media_type) == active_filter.as_str())
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let filtered_count = filtered_media.len();
|
||||||
|
|
||||||
|
let all_ids: Vec<String> = filtered_media.iter().map(|m| m.id.clone()).collect();
|
||||||
|
// Read selection once to avoid repeated signal reads in loops
|
||||||
|
let current_selection: Vec<String> = selected_ids.read().clone();
|
||||||
|
let selection_count = current_selection.len();
|
||||||
|
let has_selection = selection_count > 0;
|
||||||
|
let total_pages = total_count.div_ceil(page_size);
|
||||||
|
|
||||||
|
let toggle_select_all = {
|
||||||
|
let all_ids = all_ids.clone();
|
||||||
|
move |_| {
|
||||||
|
let new_val = !*select_all.read();
|
||||||
|
select_all.set(new_val);
|
||||||
|
global_all_selected.set(false);
|
||||||
|
if new_val {
|
||||||
|
selected_ids.set(all_ids.clone());
|
||||||
|
} else {
|
||||||
|
selected_ids.set(Vec::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_all_selected = *select_all.read();
|
||||||
|
let current_mode = *view_mode.read();
|
||||||
|
let current_sort = sort_field.read().clone();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
// Confirmation dialog for single delete
|
||||||
|
if confirm_delete.read().is_some() {
|
||||||
|
div { class: "modal-overlay",
|
||||||
|
onclick: move |_| confirm_delete.set(None),
|
||||||
|
div { class: "modal",
|
||||||
|
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||||
|
h3 { class: "modal-title", "Confirm Delete" }
|
||||||
|
p { class: "modal-body", "Are you sure you want to delete this media item? This cannot be undone." }
|
||||||
|
div { class: "modal-actions",
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost",
|
||||||
|
onclick: move |_| confirm_delete.set(None),
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger",
|
||||||
|
onclick: move |_| {
|
||||||
|
if let Some(id) = confirm_delete.read().clone() {
|
||||||
|
on_delete.call(id);
|
||||||
|
}
|
||||||
|
confirm_delete.set(None);
|
||||||
|
},
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmation dialog for batch delete
|
||||||
|
if *confirm_batch_delete.read() {
|
||||||
|
div { class: "modal-overlay",
|
||||||
|
onclick: move |_| confirm_batch_delete.set(false),
|
||||||
|
div { class: "modal",
|
||||||
|
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||||
|
h3 { class: "modal-title", "Confirm Batch Delete" }
|
||||||
|
p { class: "modal-body",
|
||||||
|
"Are you sure you want to delete {selection_count} selected items? This cannot be undone."
|
||||||
|
}
|
||||||
|
div { class: "modal-actions",
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost",
|
||||||
|
onclick: move |_| confirm_batch_delete.set(false),
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger",
|
||||||
|
onclick: move |_| {
|
||||||
|
let ids = selected_ids.read().clone();
|
||||||
|
on_batch_delete.call(ids);
|
||||||
|
selected_ids.set(Vec::new());
|
||||||
|
select_all.set(false);
|
||||||
|
confirm_batch_delete.set(false);
|
||||||
|
},
|
||||||
|
"Delete All"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmation dialog for delete all
|
||||||
|
if *confirm_delete_all.read() {
|
||||||
|
div { class: "modal-overlay",
|
||||||
|
onclick: move |_| confirm_delete_all.set(false),
|
||||||
|
div { class: "modal",
|
||||||
|
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||||
|
h3 { class: "modal-title", "Delete All Media" }
|
||||||
|
p { class: "modal-body",
|
||||||
|
"Are you sure you want to delete ALL {total_count} items? This cannot be undone."
|
||||||
|
}
|
||||||
|
div { class: "modal-actions",
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost",
|
||||||
|
onclick: move |_| confirm_delete_all.set(false),
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger",
|
||||||
|
onclick: move |_| {
|
||||||
|
if let Some(handler) = on_delete_all {
|
||||||
|
handler.call(());
|
||||||
|
}
|
||||||
|
selected_ids.set(Vec::new());
|
||||||
|
select_all.set(false);
|
||||||
|
global_all_selected.set(false);
|
||||||
|
confirm_delete_all.set(false);
|
||||||
|
},
|
||||||
|
"Delete Everything"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch tag dialog
|
||||||
|
if *show_batch_tag.read() {
|
||||||
|
div { class: "modal-overlay",
|
||||||
|
onclick: move |_| {
|
||||||
|
show_batch_tag.set(false);
|
||||||
|
batch_tag_selection.set(Vec::new());
|
||||||
|
},
|
||||||
|
div { class: "modal",
|
||||||
|
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||||
|
h3 { class: "modal-title", "Tag Selected Items" }
|
||||||
|
p { class: "modal-body text-muted text-sm",
|
||||||
|
"Select tags to apply to {selection_count} items:"
|
||||||
|
}
|
||||||
|
if tags.is_empty() {
|
||||||
|
p { class: "text-muted", "No tags available. Create tags first." }
|
||||||
|
} else {
|
||||||
|
div { class: "tag-list", style: "margin: 12px 0;",
|
||||||
|
for tag in tags.iter() {
|
||||||
|
{
|
||||||
|
let tag_id = tag.id.clone();
|
||||||
|
let tag_name = tag.name.clone();
|
||||||
|
let is_selected = batch_tag_selection.read().contains(&tag_id);
|
||||||
|
let badge_class = if is_selected { "tag-badge selected" } else { "tag-badge" };
|
||||||
|
rsx! {
|
||||||
|
span {
|
||||||
|
class: "{badge_class}",
|
||||||
|
onclick: {
|
||||||
|
let tag_id = tag_id.clone();
|
||||||
|
move |_| {
|
||||||
|
let mut current = batch_tag_selection.read().clone();
|
||||||
|
if let Some(pos) = current.iter().position(|t| t == &tag_id) {
|
||||||
|
current.remove(pos);
|
||||||
|
} else {
|
||||||
|
current.push(tag_id.clone());
|
||||||
|
}
|
||||||
|
batch_tag_selection.set(current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"{tag_name}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "modal-actions",
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost",
|
||||||
|
onclick: move |_| {
|
||||||
|
show_batch_tag.set(false);
|
||||||
|
batch_tag_selection.set(Vec::new());
|
||||||
|
},
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
onclick: move |_| {
|
||||||
|
let ids = selected_ids.read().clone();
|
||||||
|
let tag_ids = batch_tag_selection.read().clone();
|
||||||
|
if !tag_ids.is_empty() {
|
||||||
|
on_batch_tag.call((ids, tag_ids));
|
||||||
|
selected_ids.set(Vec::new());
|
||||||
|
select_all.set(false);
|
||||||
|
}
|
||||||
|
show_batch_tag.set(false);
|
||||||
|
batch_tag_selection.set(Vec::new());
|
||||||
|
},
|
||||||
|
"Apply Tags"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch collection dialog
|
||||||
|
if *show_batch_collection.read() {
|
||||||
|
div { class: "modal-overlay",
|
||||||
|
onclick: move |_| {
|
||||||
|
show_batch_collection.set(false);
|
||||||
|
batch_collection_id.set(String::new());
|
||||||
|
},
|
||||||
|
div { class: "modal",
|
||||||
|
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||||
|
h3 { class: "modal-title", "Add to Collection" }
|
||||||
|
p { class: "modal-body text-muted text-sm",
|
||||||
|
"Choose a collection for {selection_count} items:"
|
||||||
|
}
|
||||||
|
if collections.is_empty() {
|
||||||
|
p { class: "text-muted", "No collections available. Create one first." }
|
||||||
|
} else {
|
||||||
|
select {
|
||||||
|
style: "width: 100%; margin: 12px 0;",
|
||||||
|
value: "{batch_collection_id}",
|
||||||
|
onchange: move |e: Event<FormData>| batch_collection_id.set(e.value()),
|
||||||
|
option { value: "", "Select a collection..." }
|
||||||
|
for col in collections.iter() {
|
||||||
|
option {
|
||||||
|
key: "{col.id}",
|
||||||
|
value: "{col.id}",
|
||||||
|
"{col.name}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "modal-actions",
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost",
|
||||||
|
onclick: move |_| {
|
||||||
|
show_batch_collection.set(false);
|
||||||
|
batch_collection_id.set(String::new());
|
||||||
|
},
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
onclick: move |_| {
|
||||||
|
let ids = selected_ids.read().clone();
|
||||||
|
let col_id = batch_collection_id.read().clone();
|
||||||
|
if !col_id.is_empty() {
|
||||||
|
on_batch_collection.call((ids, col_id));
|
||||||
|
selected_ids.set(Vec::new());
|
||||||
|
select_all.set(false);
|
||||||
|
}
|
||||||
|
show_batch_collection.set(false);
|
||||||
|
batch_collection_id.set(String::new());
|
||||||
|
},
|
||||||
|
"Add to Collection"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toolbar: view toggle, sort, batch actions
|
||||||
|
div { class: "library-toolbar",
|
||||||
|
div { class: "toolbar-left",
|
||||||
|
// View mode toggle
|
||||||
|
div { class: "view-toggle",
|
||||||
|
button {
|
||||||
|
class: if current_mode == ViewMode::Grid { "view-btn active" } else { "view-btn" },
|
||||||
|
onclick: move |_| view_mode.set(ViewMode::Grid),
|
||||||
|
title: "Grid view",
|
||||||
|
"\u{25a6}"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: if current_mode == ViewMode::Table { "view-btn active" } else { "view-btn" },
|
||||||
|
onclick: move |_| view_mode.set(ViewMode::Table),
|
||||||
|
title: "Table view",
|
||||||
|
"\u{2630}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort selector
|
||||||
|
div { class: "sort-control",
|
||||||
|
select {
|
||||||
|
value: "{sort_field}",
|
||||||
|
onchange: move |e: Event<FormData>| {
|
||||||
|
let val = e.value();
|
||||||
|
sort_field.set(val.clone());
|
||||||
|
on_sort_change.call(val);
|
||||||
|
},
|
||||||
|
option { value: "created_at_desc", "Newest first" }
|
||||||
|
option { value: "created_at_asc", "Oldest first" }
|
||||||
|
option { value: "file_name_asc", "Name A-Z" }
|
||||||
|
option { value: "file_name_desc", "Name Z-A" }
|
||||||
|
option { value: "file_size_desc", "Largest first" }
|
||||||
|
option { value: "file_size_asc", "Smallest first" }
|
||||||
|
option { value: "media_type_asc", "Type" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page size
|
||||||
|
div { class: "page-size-control",
|
||||||
|
span { class: "text-muted text-sm", "Show:" }
|
||||||
|
select {
|
||||||
|
value: "{page_size}",
|
||||||
|
onchange: move |e: Event<FormData>| {
|
||||||
|
if let Ok(size) = e.value().parse::<u64>() {
|
||||||
|
on_page_size_change.call(size);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
option { value: "24", "24" }
|
||||||
|
option { value: "48", "48" }
|
||||||
|
option { value: "96", "96" }
|
||||||
|
option { value: "200", "200" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "toolbar-right",
|
||||||
|
// Select All / Deselect All toggle (works in both grid and table)
|
||||||
|
{
|
||||||
|
let all_ids2 = all_ids.clone();
|
||||||
|
rsx! {
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-ghost",
|
||||||
|
onclick: move |_| {
|
||||||
|
if is_all_selected {
|
||||||
|
selected_ids.set(Vec::new());
|
||||||
|
select_all.set(false);
|
||||||
|
global_all_selected.set(false);
|
||||||
|
} else {
|
||||||
|
selected_ids.set(all_ids2.clone());
|
||||||
|
select_all.set(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
if is_all_selected {
|
||||||
|
"Deselect All"
|
||||||
|
} else {
|
||||||
|
"Select All"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_selection {
|
||||||
|
div { class: "batch-actions",
|
||||||
|
span { "{selection_count} selected" }
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-secondary",
|
||||||
|
onclick: move |_| show_batch_tag.set(true),
|
||||||
|
"Tag"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-secondary",
|
||||||
|
onclick: move |_| show_batch_collection.set(true),
|
||||||
|
"Collection"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-danger",
|
||||||
|
onclick: move |_| confirm_batch_delete.set(true),
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-ghost",
|
||||||
|
onclick: move |_| {
|
||||||
|
selected_ids.set(Vec::new());
|
||||||
|
select_all.set(false);
|
||||||
|
global_all_selected.set(false);
|
||||||
|
},
|
||||||
|
"Clear"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if on_delete_all.is_some() && total_count > 0 {
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-danger",
|
||||||
|
onclick: move |_| confirm_delete_all.set(true),
|
||||||
|
"Delete All"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span { class: "text-muted text-sm",
|
||||||
|
"{total_count} items"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type filter chips
|
||||||
|
div { class: "type-filter-row",
|
||||||
|
for filter in TYPE_FILTERS.iter() {
|
||||||
|
{
|
||||||
|
let f = (*filter).to_string();
|
||||||
|
let is_active = active_filter == f;
|
||||||
|
let chip_class = if is_active { "filter-chip active" } else { "filter-chip" };
|
||||||
|
let label = filter_label(filter);
|
||||||
|
rsx! {
|
||||||
|
button {
|
||||||
|
key: "{f}",
|
||||||
|
class: "{chip_class}",
|
||||||
|
onclick: {
|
||||||
|
let f = f.clone();
|
||||||
|
move |_| {
|
||||||
|
type_filter.set(f.clone());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"{label}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats summary row
|
||||||
|
div { class: "library-stats",
|
||||||
|
span { class: "text-muted text-sm",
|
||||||
|
if active_filter != "all" {
|
||||||
|
"Showing {filtered_count} of {total_count} items (filtered: {active_filter})"
|
||||||
|
} else {
|
||||||
|
"Showing {filtered_count} items"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span { class: "text-muted text-sm",
|
||||||
|
"Page {current_page + 1} of {total_pages}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select-all banner: when all items on this page are selected and there
|
||||||
|
// are more pages, offer to select everything across all pages.
|
||||||
|
if is_all_selected && total_count > page_size && !*global_all_selected.read() {
|
||||||
|
div { class: "select-all-banner",
|
||||||
|
"All {filtered_count} items on this page are selected."
|
||||||
|
if on_select_all_global.is_some() {
|
||||||
|
button {
|
||||||
|
onclick: move |_| {
|
||||||
|
if let Some(handler) = on_select_all_global {
|
||||||
|
handler.call(EventHandler::new(move |all_ids: Vec<String>| {
|
||||||
|
selected_ids.set(all_ids);
|
||||||
|
global_all_selected.set(true);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Select all {total_count} items"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *global_all_selected.read() {
|
||||||
|
div { class: "select-all-banner",
|
||||||
|
"All {selection_count} items across all pages are selected."
|
||||||
|
button {
|
||||||
|
onclick: move |_| {
|
||||||
|
selected_ids.set(Vec::new());
|
||||||
|
select_all.set(false);
|
||||||
|
global_all_selected.set(false);
|
||||||
|
},
|
||||||
|
"Clear selection"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content: grid or table
|
||||||
|
match current_mode {
|
||||||
|
ViewMode::Grid => rsx! {
|
||||||
|
div { class: "media-grid",
|
||||||
|
for (idx, item) in filtered_media.iter().enumerate() {
|
||||||
|
{
|
||||||
|
let id = item.id.clone();
|
||||||
|
let badge_class = type_badge_class(&item.media_type);
|
||||||
|
let is_checked = current_selection.contains(&id);
|
||||||
|
|
||||||
|
let card_click = {
|
||||||
|
let id = item.id.clone();
|
||||||
|
move |_| on_select.call(id.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a list of all visible IDs for shift+click range selection.
|
||||||
|
let visible_ids: Vec<String> = filtered_media.iter().map(|m| m.id.clone()).collect();
|
||||||
|
|
||||||
|
let toggle_id = {
|
||||||
|
let id = id.clone();
|
||||||
|
move |e: Event<MouseData>| {
|
||||||
|
e.stop_propagation();
|
||||||
|
let shift = e.modifiers().shift();
|
||||||
|
let mut ids = selected_ids.read().clone();
|
||||||
|
|
||||||
|
if shift {
|
||||||
|
// Shift+click: select range from last_click_index to current idx.
|
||||||
|
if let Some(last) = *last_click_index.read() {
|
||||||
|
let start = last.min(idx);
|
||||||
|
let end = last.max(idx);
|
||||||
|
for i in start..=end {
|
||||||
|
if let Some(range_id) = visible_ids.get(i)
|
||||||
|
&& !ids.contains(range_id)
|
||||||
|
{
|
||||||
|
ids.push(range_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No previous click, just toggle this one.
|
||||||
|
if !ids.contains(&id) {
|
||||||
|
ids.push(id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if ids.contains(&id) {
|
||||||
|
ids.retain(|x| x != &id);
|
||||||
|
} else {
|
||||||
|
ids.push(id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
last_click_index.set(Some(idx));
|
||||||
|
selected_ids.set(ids);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let thumb_url = if item.has_thumbnail {
|
||||||
|
format!("{}/api/v1/media/{}/thumbnail", server_url, item.id)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let has_thumb = item.has_thumbnail;
|
||||||
|
let media_type = item.media_type.clone();
|
||||||
|
let card_class = if is_checked { "media-card selected" } else { "media-card" };
|
||||||
|
let title_text = item.title.clone().unwrap_or_default();
|
||||||
|
let artist_text = item.artist.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
key: "{item.id}",
|
||||||
|
class: "{card_class}",
|
||||||
|
onclick: card_click,
|
||||||
|
|
||||||
|
div { class: "card-checkbox",
|
||||||
|
input {
|
||||||
|
r#type: "checkbox",
|
||||||
|
checked: is_checked,
|
||||||
|
onclick: toggle_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail with CSS fallback: both the icon and img
|
||||||
|
// are rendered. The img is absolutely positioned on
|
||||||
|
// top. If the image fails to load, the icon beneath
|
||||||
|
// shows through.
|
||||||
|
div { class: "card-thumbnail",
|
||||||
|
div { class: "card-type-icon {badge_class}",
|
||||||
|
"{type_icon(&media_type)}"
|
||||||
|
}
|
||||||
|
if has_thumb {
|
||||||
|
img {
|
||||||
|
class: "card-thumb-img",
|
||||||
|
src: "{thumb_url}",
|
||||||
|
alt: "{item.file_name}",
|
||||||
|
loading: "lazy",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "card-info",
|
||||||
|
div { class: "card-name", title: "{item.file_name}",
|
||||||
|
"{item.file_name}"
|
||||||
|
}
|
||||||
|
if !title_text.is_empty() {
|
||||||
|
div { class: "card-title text-muted text-xs",
|
||||||
|
"{title_text}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !artist_text.is_empty() {
|
||||||
|
div { class: "card-artist text-muted text-xs",
|
||||||
|
"{artist_text}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "card-meta",
|
||||||
|
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||||
|
span { class: "card-size", "{format_size(item.file_size)}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ViewMode::Table => rsx! {
|
||||||
|
table { class: "data-table",
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th {
|
||||||
|
input {
|
||||||
|
r#type: "checkbox",
|
||||||
|
checked: is_all_selected,
|
||||||
|
onclick: toggle_select_all,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
th { "" }
|
||||||
|
th {
|
||||||
|
class: "sortable-header",
|
||||||
|
onclick: {
|
||||||
|
let cs = current_sort.clone();
|
||||||
|
move |_| {
|
||||||
|
let val = next_sort(&cs, "file_name");
|
||||||
|
sort_field.set(val.clone());
|
||||||
|
on_sort_change.call(val);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Name{sort_arrow(¤t_sort, \"file_name\")}"
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
class: "sortable-header",
|
||||||
|
onclick: {
|
||||||
|
let cs = current_sort.clone();
|
||||||
|
move |_| {
|
||||||
|
let val = next_sort(&cs, "media_type");
|
||||||
|
sort_field.set(val.clone());
|
||||||
|
on_sort_change.call(val);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Type{sort_arrow(¤t_sort, \"media_type\")}"
|
||||||
|
}
|
||||||
|
th { "Artist" }
|
||||||
|
th {
|
||||||
|
class: "sortable-header",
|
||||||
|
onclick: {
|
||||||
|
let cs = current_sort.clone();
|
||||||
|
move |_| {
|
||||||
|
let val = next_sort(&cs, "file_size");
|
||||||
|
sort_field.set(val.clone());
|
||||||
|
on_sort_change.call(val);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Size{sort_arrow(¤t_sort, \"file_size\")}"
|
||||||
|
}
|
||||||
|
th { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for (idx, item) in filtered_media.iter().enumerate() {
|
||||||
|
{
|
||||||
|
let id = item.id.clone();
|
||||||
|
let artist = item.artist.clone().unwrap_or_default();
|
||||||
|
let size = format_size(item.file_size);
|
||||||
|
let badge_class = type_badge_class(&item.media_type);
|
||||||
|
let is_checked = current_selection.contains(&id);
|
||||||
|
|
||||||
|
let visible_ids: Vec<String> = filtered_media.iter().map(|m| m.id.clone()).collect();
|
||||||
|
|
||||||
|
let toggle_id = {
|
||||||
|
let id = id.clone();
|
||||||
|
move |e: Event<MouseData>| {
|
||||||
|
e.stop_propagation();
|
||||||
|
let shift = e.modifiers().shift();
|
||||||
|
let mut ids = selected_ids.read().clone();
|
||||||
|
|
||||||
|
if shift {
|
||||||
|
if let Some(last) = *last_click_index.read() {
|
||||||
|
let start = last.min(idx);
|
||||||
|
let end = last.max(idx);
|
||||||
|
for i in start..=end {
|
||||||
|
if let Some(range_id) = visible_ids.get(i)
|
||||||
|
&& !ids.contains(range_id)
|
||||||
|
{
|
||||||
|
ids.push(range_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !ids.contains(&id) {
|
||||||
|
ids.push(id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if ids.contains(&id) {
|
||||||
|
ids.retain(|x| x != &id);
|
||||||
|
} else {
|
||||||
|
ids.push(id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
last_click_index.set(Some(idx));
|
||||||
|
selected_ids.set(ids);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let row_click = {
|
||||||
|
let id = item.id.clone();
|
||||||
|
move |_| on_select.call(id.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
let delete_click = {
|
||||||
|
let id = item.id.clone();
|
||||||
|
move |e: Event<MouseData>| {
|
||||||
|
e.stop_propagation();
|
||||||
|
confirm_delete.set(Some(id.clone()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let thumb_url = if item.has_thumbnail {
|
||||||
|
format!("{}/api/v1/media/{}/thumbnail", server_url, item.id)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let has_thumb = item.has_thumbnail;
|
||||||
|
let media_type_str = item.media_type.clone();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
tr {
|
||||||
|
key: "{item.id}",
|
||||||
|
onclick: row_click,
|
||||||
|
td {
|
||||||
|
input {
|
||||||
|
r#type: "checkbox",
|
||||||
|
checked: is_checked,
|
||||||
|
onclick: toggle_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td { class: "table-thumb-cell",
|
||||||
|
// Thumbnail with CSS fallback: icon always
|
||||||
|
// rendered, img overlays when available.
|
||||||
|
span { class: "table-type-icon {badge_class}",
|
||||||
|
"{type_icon(&media_type_str)}"
|
||||||
|
}
|
||||||
|
if has_thumb {
|
||||||
|
img {
|
||||||
|
class: "table-thumb table-thumb-overlay",
|
||||||
|
src: "{thumb_url}",
|
||||||
|
alt: "",
|
||||||
|
loading: "lazy",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td { "{item.file_name}" }
|
||||||
|
td {
|
||||||
|
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||||
|
}
|
||||||
|
td { "{artist}" }
|
||||||
|
td { "{size}" }
|
||||||
|
td {
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger btn-sm",
|
||||||
|
onclick: delete_click,
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination controls
|
||||||
|
PaginationControls {
|
||||||
|
current_page,
|
||||||
|
total_pages,
|
||||||
|
on_page_change: move |page: u64| on_page_change.call(page),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
crates/pinakes-ui/src/components/loading.rs
Normal file
59
crates/pinakes-ui/src/components/loading.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SkeletonCard() -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "skeleton-card",
|
||||||
|
div { class: "skeleton-thumb skeleton-pulse" }
|
||||||
|
div { class: "skeleton-text skeleton-pulse" }
|
||||||
|
div { class: "skeleton-text skeleton-text-short skeleton-pulse" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SkeletonRow() -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "skeleton-row",
|
||||||
|
div { class: "skeleton-cell skeleton-cell-icon skeleton-pulse" }
|
||||||
|
div { class: "skeleton-cell skeleton-cell-wide skeleton-pulse" }
|
||||||
|
div { class: "skeleton-cell skeleton-pulse" }
|
||||||
|
div { class: "skeleton-cell skeleton-pulse" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn LoadingOverlay(message: Option<String>) -> Element {
|
||||||
|
let msg = message.unwrap_or_else(|| "Loading...".to_string());
|
||||||
|
rsx! {
|
||||||
|
div { class: "loading-overlay",
|
||||||
|
div { class: "loading-spinner" }
|
||||||
|
span { class: "loading-message", "{msg}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SkeletonGrid(count: Option<usize>) -> Element {
|
||||||
|
let n = count.unwrap_or(12);
|
||||||
|
rsx! {
|
||||||
|
div { class: "media-grid",
|
||||||
|
for i in 0..n {
|
||||||
|
SkeletonCard { key: "skel-{i}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SkeletonList(count: Option<usize>) -> Element {
|
||||||
|
let n = count.unwrap_or(10);
|
||||||
|
rsx! {
|
||||||
|
div { class: "media-list",
|
||||||
|
for i in 0..n {
|
||||||
|
SkeletonRow { key: "skel-row-{i}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
crates/pinakes-ui/src/components/login.rs
Normal file
75
crates/pinakes-ui/src/components/login.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Login(
|
||||||
|
on_login: EventHandler<(String, String)>,
|
||||||
|
#[props(default)] error: Option<String>,
|
||||||
|
#[props(default = false)] loading: bool,
|
||||||
|
) -> Element {
|
||||||
|
let mut username = use_signal(String::new);
|
||||||
|
let mut password = use_signal(String::new);
|
||||||
|
|
||||||
|
let on_submit = {
|
||||||
|
move |_| {
|
||||||
|
let u = username.read().clone();
|
||||||
|
let p = password.read().clone();
|
||||||
|
if !u.is_empty() && !p.is_empty() {
|
||||||
|
on_login.call((u, p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_key = move |e: KeyboardEvent| {
|
||||||
|
if e.key() == Key::Enter {
|
||||||
|
let u = username.read().clone();
|
||||||
|
let p = password.read().clone();
|
||||||
|
if !u.is_empty() && !p.is_empty() {
|
||||||
|
on_login.call((u, p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "login-container",
|
||||||
|
div { class: "login-card",
|
||||||
|
h2 { class: "login-title", "Pinakes" }
|
||||||
|
p { class: "login-subtitle", "Sign in to continue" }
|
||||||
|
|
||||||
|
if let Some(ref err) = error {
|
||||||
|
div { class: "login-error", "{err}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "login-form",
|
||||||
|
div { class: "form-group",
|
||||||
|
label { class: "form-label", "Username" }
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Enter username",
|
||||||
|
value: "{username}",
|
||||||
|
disabled: loading,
|
||||||
|
oninput: move |e: Event<FormData>| username.set(e.value()),
|
||||||
|
onkeypress: on_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "form-group",
|
||||||
|
label { class: "form-label", "Password" }
|
||||||
|
input {
|
||||||
|
r#type: "password",
|
||||||
|
placeholder: "Enter password",
|
||||||
|
value: "{password}",
|
||||||
|
disabled: loading,
|
||||||
|
oninput: move |e: Event<FormData>| password.set(e.value()),
|
||||||
|
onkeypress: on_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary login-btn",
|
||||||
|
disabled: loading,
|
||||||
|
onclick: on_submit,
|
||||||
|
if loading { "Signing in..." } else { "Sign In" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
180
crates/pinakes-ui/src/components/markdown_viewer.rs
Normal file
180
crates/pinakes-ui/src/components/markdown_viewer.rs
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn MarkdownViewer(content_url: String, media_type: String) -> Element {
|
||||||
|
let mut rendered_html = use_signal(String::new);
|
||||||
|
let mut frontmatter_html = use_signal(|| Option::<String>::None);
|
||||||
|
let mut loading = use_signal(|| true);
|
||||||
|
let mut error = use_signal(|| Option::<String>::None);
|
||||||
|
|
||||||
|
// Fetch content on mount
|
||||||
|
let url = content_url.clone();
|
||||||
|
let mtype = media_type.clone();
|
||||||
|
use_effect(move || {
|
||||||
|
let url = url.clone();
|
||||||
|
let mtype = mtype.clone();
|
||||||
|
spawn(async move {
|
||||||
|
loading.set(true);
|
||||||
|
error.set(None);
|
||||||
|
match reqwest::get(&url).await {
|
||||||
|
Ok(resp) => match resp.text().await {
|
||||||
|
Ok(text) => {
|
||||||
|
if mtype == "md" || mtype == "markdown" {
|
||||||
|
let (fm_html, body_html) = render_markdown_with_frontmatter(&text);
|
||||||
|
frontmatter_html.set(fm_html);
|
||||||
|
rendered_html.set(body_html);
|
||||||
|
} else {
|
||||||
|
frontmatter_html.set(None);
|
||||||
|
rendered_html.set(render_plaintext(&text));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(format!("Failed to read content: {e}"))),
|
||||||
|
},
|
||||||
|
Err(e) => error.set(Some(format!("Failed to fetch: {e}"))),
|
||||||
|
}
|
||||||
|
loading.set(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let is_loading = *loading.read();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "markdown-viewer",
|
||||||
|
if is_loading {
|
||||||
|
div { class: "loading-overlay",
|
||||||
|
div { class: "spinner" }
|
||||||
|
"Loading content..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref err) = *error.read() {
|
||||||
|
div { class: "error-banner",
|
||||||
|
span { class: "error-icon", "\u{26a0}" }
|
||||||
|
"{err}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !is_loading && error.read().is_none() {
|
||||||
|
if let Some(ref fm) = *frontmatter_html.read() {
|
||||||
|
div {
|
||||||
|
class: "frontmatter-card",
|
||||||
|
dangerous_inner_html: "{fm}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
class: "markdown-content",
|
||||||
|
dangerous_inner_html: "{rendered_html}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse frontmatter and render markdown body. Returns (frontmatter_html, body_html).
|
||||||
|
fn render_markdown_with_frontmatter(text: &str) -> (Option<String>, String) {
|
||||||
|
use gray_matter::Matter;
|
||||||
|
use gray_matter::engine::YAML;
|
||||||
|
|
||||||
|
let matter = Matter::<YAML>::new();
|
||||||
|
let Ok(result) = matter.parse(text) else {
|
||||||
|
// If frontmatter parsing fails, just render the whole text as markdown
|
||||||
|
return (None, render_markdown(text));
|
||||||
|
};
|
||||||
|
|
||||||
|
let fm_html = result.data.and_then(|data| render_frontmatter_card(&data));
|
||||||
|
|
||||||
|
let body_html = render_markdown(&result.content);
|
||||||
|
(fm_html, body_html)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render frontmatter fields as an HTML card.
|
||||||
|
fn render_frontmatter_card(data: &gray_matter::Pod) -> Option<String> {
|
||||||
|
let gray_matter::Pod::Hash(map) = data else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
if map.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut html = String::from("<dl class=\"frontmatter-fields\">");
|
||||||
|
|
||||||
|
for (key, value) in map {
|
||||||
|
let display_value = pod_to_display(value);
|
||||||
|
let escaped_key = escape_html(key);
|
||||||
|
html.push_str(&format!("<dt>{escaped_key}</dt><dd>{display_value}</dd>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.push_str("</dl>");
|
||||||
|
Some(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pod_to_display(pod: &gray_matter::Pod) -> String {
|
||||||
|
match pod {
|
||||||
|
gray_matter::Pod::String(s) => escape_html(s),
|
||||||
|
gray_matter::Pod::Integer(n) => n.to_string(),
|
||||||
|
gray_matter::Pod::Float(f) => f.to_string(),
|
||||||
|
gray_matter::Pod::Boolean(b) => b.to_string(),
|
||||||
|
gray_matter::Pod::Array(arr) => {
|
||||||
|
let items: Vec<String> = arr.iter().map(pod_to_display).collect();
|
||||||
|
items.join(", ")
|
||||||
|
}
|
||||||
|
gray_matter::Pod::Hash(map) => {
|
||||||
|
let items: Vec<String> = map
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("{}: {}", escape_html(k), pod_to_display(v)))
|
||||||
|
.collect();
|
||||||
|
items.join("; ")
|
||||||
|
}
|
||||||
|
gray_matter::Pod::Null => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_markdown(text: &str) -> String {
|
||||||
|
use pulldown_cmark::{Options, Parser, html};
|
||||||
|
|
||||||
|
let mut options = Options::empty();
|
||||||
|
options.insert(Options::ENABLE_TABLES);
|
||||||
|
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||||
|
options.insert(Options::ENABLE_TASKLISTS);
|
||||||
|
options.insert(Options::ENABLE_FOOTNOTES);
|
||||||
|
options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
|
||||||
|
|
||||||
|
let parser = Parser::new_ext(text, options);
|
||||||
|
let mut html_output = String::new();
|
||||||
|
html::push_html(&mut html_output, parser);
|
||||||
|
|
||||||
|
// Strip script tags for safety
|
||||||
|
strip_script_tags(&html_output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_plaintext(text: &str) -> String {
|
||||||
|
let escaped = escape_html(text);
|
||||||
|
format!("<pre><code>{escaped}</code></pre>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_html(text: &str) -> String {
|
||||||
|
text.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_script_tags(html: &str) -> String {
|
||||||
|
// Simple removal of <script> tags
|
||||||
|
let mut result = html.to_string();
|
||||||
|
while let Some(start) = result.to_lowercase().find("<script") {
|
||||||
|
if let Some(end) = result.to_lowercase()[start..].find("</script>") {
|
||||||
|
result = format!(
|
||||||
|
"{}{}",
|
||||||
|
&result[..start],
|
||||||
|
&result[start + end + "</script>".len()..]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Malformed script tag - remove to end
|
||||||
|
result = result[..start].to_string();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue