feat: frontend MVP — auth, timeline, upload, albums, admin, image viewer
Backend: - user roles (DB + JWT + first-user-is-admin) - volume-aware file resolver (multi-volume asset serving) - directory scanner uses volume URI directly - date-summary endpoint (capture date from EXIF) - timeline ordered by capture date - list endpoints: volumes, plugins, pipelines, library paths - delete endpoints: volumes, library paths - configurable upload body limit (MAX_UPLOAD_BYTES) Frontend: - auth: login/register, token refresh, role-based admin gate - timeline: date-grouped grid, infinite scroll, date scrubber - image viewer: fullscreen zoom/pan/pinch, metadata sidebar - upload: drag-drop, sequential upload, progress tracking - albums: create, add/remove photos, asset picker dialog - admin: storage (import library), jobs (pagination, error details), plugins (list + toggle), pipelines, sidecars, duplicates - multi-select mode with add-to-album action - TanStack Query for all data fetching
This commit is contained in:
@@ -3,6 +3,7 @@ use crate::plugins::{
|
||||
DirectoryScannerPlugin, MetadataExtractorPlugin, NoOpPlugin, SidecarSyncPlugin,
|
||||
ThumbnailGeneratorPlugin,
|
||||
};
|
||||
use adapters_storage::LocalVolumeFileResolver;
|
||||
use application::catalog::RegisterAssetHandler;
|
||||
use domain::ports::{
|
||||
EventPublisher, MetadataExtractorPort, SidecarWriterPort, ThumbnailGeneratorPort,
|
||||
@@ -21,15 +22,18 @@ pub fn build_plugin_registry(
|
||||
) -> InMemoryPluginRegistry {
|
||||
let mut registry = InMemoryPluginRegistry::new();
|
||||
|
||||
let volume_resolver = Arc::new(LocalVolumeFileResolver::new(repos.volume.clone()));
|
||||
|
||||
registry.register(Arc::new(NoOpPlugin));
|
||||
registry.register(Arc::new(MetadataExtractorPlugin::new(
|
||||
repos.asset.clone(),
|
||||
file_storage.clone(),
|
||||
volume_resolver.clone(),
|
||||
repos.metadata.clone(),
|
||||
extractor,
|
||||
)));
|
||||
registry.register(Arc::new(ThumbnailGeneratorPlugin::new(
|
||||
repos.asset.clone(),
|
||||
volume_resolver,
|
||||
file_storage.clone(),
|
||||
repos.derivative.clone(),
|
||||
thumbnail_gen,
|
||||
@@ -43,7 +47,6 @@ pub fn build_plugin_registry(
|
||||
registry.register(Arc::new(DirectoryScannerPlugin::new(
|
||||
repos.volume.clone(),
|
||||
repos.library_path.clone(),
|
||||
file_storage.clone(),
|
||||
register_handler,
|
||||
)));
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ mod sweep;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env().add_directive("worker=info".parse()?),
|
||||
|
||||
@@ -3,17 +3,17 @@ use async_trait::async_trait;
|
||||
use domain::{
|
||||
catalog::entities::AssetType,
|
||||
errors::DomainError,
|
||||
ports::{FileStoragePort, LibraryPathRepository, PluginExecutor, StorageVolumeRepository},
|
||||
ports::{LibraryPathRepository, PluginExecutor, StorageVolumeRepository},
|
||||
value_objects::{MetadataValue, StructuredData, SystemId},
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub struct DirectoryScannerPlugin {
|
||||
volume_repo: Arc<dyn StorageVolumeRepository>,
|
||||
path_repo: Arc<dyn LibraryPathRepository>,
|
||||
file_storage: Arc<dyn FileStoragePort>,
|
||||
register_handler: Arc<RegisterAssetHandler>,
|
||||
}
|
||||
|
||||
@@ -21,13 +21,11 @@ impl DirectoryScannerPlugin {
|
||||
pub fn new(
|
||||
volume_repo: Arc<dyn StorageVolumeRepository>,
|
||||
path_repo: Arc<dyn LibraryPathRepository>,
|
||||
file_storage: Arc<dyn FileStoragePort>,
|
||||
register_handler: Arc<RegisterAssetHandler>,
|
||||
) -> Self {
|
||||
Self {
|
||||
volume_repo,
|
||||
path_repo,
|
||||
file_storage,
|
||||
register_handler,
|
||||
}
|
||||
}
|
||||
@@ -55,7 +53,7 @@ fn classify(filename: &str) -> Option<(AssetType, &'static str)> {
|
||||
#[async_trait]
|
||||
impl PluginExecutor for DirectoryScannerPlugin {
|
||||
fn plugin_name(&self) -> &str {
|
||||
"directory_scanner"
|
||||
"scan_directory"
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
@@ -92,8 +90,14 @@ impl PluginExecutor for DirectoryScannerPlugin {
|
||||
DomainError::Validation(format!("LibraryPath {} has no designated owner", path_id))
|
||||
})?;
|
||||
|
||||
let volume_base = volume
|
||||
.uri_prefix
|
||||
.strip_prefix("file://")
|
||||
.unwrap_or(&volume.uri_prefix);
|
||||
let volume_root = PathBuf::from(volume_base);
|
||||
|
||||
let scan_root = &library_path.relative_path;
|
||||
info!(path = scan_root, volume = %volume.volume_name, "scanning directory");
|
||||
info!(path = scan_root, volume = %volume.volume_name, base = %volume_root.display(), "scanning directory");
|
||||
|
||||
let mut found = 0u64;
|
||||
let mut registered = 0u64;
|
||||
@@ -101,29 +105,40 @@ impl PluginExecutor for DirectoryScannerPlugin {
|
||||
let mut dirs_to_scan = vec![scan_root.to_string()];
|
||||
|
||||
while let Some(dir) = dirs_to_scan.pop() {
|
||||
let entries = match self.file_storage.list_directory(&dir).await {
|
||||
Ok(e) => e,
|
||||
let abs_dir = if dir.is_empty() {
|
||||
volume_root.clone()
|
||||
} else {
|
||||
volume_root.join(&dir)
|
||||
};
|
||||
|
||||
let mut read_dir = match tokio::fs::read_dir(&abs_dir).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!(dir = dir, error = %e, "failed to list directory, skipping");
|
||||
warn!(dir = %abs_dir.display(), error = %e, "failed to list directory, skipping");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let full_path = if dir.is_empty() {
|
||||
entry.path.clone()
|
||||
while let Ok(Some(entry)) = read_dir.next_entry().await {
|
||||
let meta = match entry.metadata().await {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let relative = if dir.is_empty() {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("{}/{}", dir, entry.path)
|
||||
format!("{}/{}", dir, name)
|
||||
};
|
||||
|
||||
if entry.is_directory {
|
||||
dirs_to_scan.push(full_path);
|
||||
if meta.is_dir() {
|
||||
dirs_to_scan.push(relative);
|
||||
continue;
|
||||
}
|
||||
|
||||
found += 1;
|
||||
|
||||
let (asset_type, mime_type) = match classify(&entry.path) {
|
||||
let (asset_type, mime_type) = match classify(&name) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
skipped += 1;
|
||||
@@ -131,10 +146,11 @@ impl PluginExecutor for DirectoryScannerPlugin {
|
||||
}
|
||||
};
|
||||
|
||||
let data = match self.file_storage.read_file(&full_path).await {
|
||||
let abs_path = volume_root.join(&relative);
|
||||
let data = match tokio::fs::read(&abs_path).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
warn!(path = full_path, error = %e, "failed to read file, skipping");
|
||||
warn!(path = %abs_path.display(), error = %e, "failed to read file, skipping");
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -144,7 +160,7 @@ impl PluginExecutor for DirectoryScannerPlugin {
|
||||
|
||||
let cmd = RegisterAssetCommand {
|
||||
volume_id: library_path.volume_id,
|
||||
relative_path: full_path.clone(),
|
||||
relative_path: relative.clone(),
|
||||
checksum,
|
||||
asset_type,
|
||||
mime_type: mime_type.to_string(),
|
||||
@@ -156,11 +172,11 @@ impl PluginExecutor for DirectoryScannerPlugin {
|
||||
Ok((asset, dup)) => {
|
||||
registered += 1;
|
||||
if dup.is_some() {
|
||||
info!(path = full_path, asset_id = %asset.asset_id, "registered (duplicate detected)");
|
||||
info!(path = relative, asset_id = %asset.asset_id, "registered (duplicate detected)");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(path = full_path, error = %e, "failed to register asset");
|
||||
warn!(path = relative, error = %e, "failed to register asset");
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ use domain::{
|
||||
entities::{AssetMetadata, MetadataSource},
|
||||
errors::DomainError,
|
||||
ports::{
|
||||
AssetMetadataRepository, AssetRepository, FileStoragePort, MetadataExtractorPort,
|
||||
PluginExecutor,
|
||||
AssetMetadataRepository, AssetRepository, MetadataExtractorPort, PluginExecutor,
|
||||
VolumeFileResolver,
|
||||
},
|
||||
value_objects::{MetadataValue, StructuredData, SystemId},
|
||||
};
|
||||
@@ -13,7 +13,7 @@ use tracing::info;
|
||||
|
||||
pub struct MetadataExtractorPlugin {
|
||||
asset_repo: Arc<dyn AssetRepository>,
|
||||
file_storage: Arc<dyn FileStoragePort>,
|
||||
volume_resolver: Arc<dyn VolumeFileResolver>,
|
||||
metadata_repo: Arc<dyn AssetMetadataRepository>,
|
||||
extractor: Arc<dyn MetadataExtractorPort>,
|
||||
}
|
||||
@@ -21,13 +21,13 @@ pub struct MetadataExtractorPlugin {
|
||||
impl MetadataExtractorPlugin {
|
||||
pub fn new(
|
||||
asset_repo: Arc<dyn AssetRepository>,
|
||||
file_storage: Arc<dyn FileStoragePort>,
|
||||
volume_resolver: Arc<dyn VolumeFileResolver>,
|
||||
metadata_repo: Arc<dyn AssetMetadataRepository>,
|
||||
extractor: Arc<dyn MetadataExtractorPort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
asset_repo,
|
||||
file_storage,
|
||||
volume_resolver,
|
||||
metadata_repo,
|
||||
extractor,
|
||||
}
|
||||
@@ -56,8 +56,13 @@ impl PluginExecutor for MetadataExtractorPlugin {
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Asset {} not found", asset_id)))?;
|
||||
|
||||
let path = &asset.source_reference.relative_path;
|
||||
let data = self.file_storage.read_file(path).await?;
|
||||
let data = self
|
||||
.volume_resolver
|
||||
.read_by_volume(
|
||||
&asset.source_reference.volume_id,
|
||||
&asset.source_reference.relative_path,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut extracted = self.extractor.extract(&data)?;
|
||||
extracted.insert("file_size_bytes", MetadataValue::Integer(data.len() as i64));
|
||||
|
||||
@@ -4,7 +4,7 @@ use domain::{
|
||||
errors::DomainError,
|
||||
ports::{
|
||||
AssetRepository, DerivativeRepository, FileStoragePort, PluginExecutor,
|
||||
ThumbnailGeneratorPort,
|
||||
ThumbnailGeneratorPort, VolumeFileResolver,
|
||||
},
|
||||
value_objects::{MetadataValue, StructuredData, SystemId},
|
||||
};
|
||||
@@ -13,6 +13,7 @@ use tracing::info;
|
||||
|
||||
pub struct ThumbnailGeneratorPlugin {
|
||||
asset_repo: Arc<dyn AssetRepository>,
|
||||
volume_resolver: Arc<dyn VolumeFileResolver>,
|
||||
file_storage: Arc<dyn FileStoragePort>,
|
||||
derivative_repo: Arc<dyn DerivativeRepository>,
|
||||
thumbnail_gen: Arc<dyn ThumbnailGeneratorPort>,
|
||||
@@ -21,12 +22,14 @@ pub struct ThumbnailGeneratorPlugin {
|
||||
impl ThumbnailGeneratorPlugin {
|
||||
pub fn new(
|
||||
asset_repo: Arc<dyn AssetRepository>,
|
||||
volume_resolver: Arc<dyn VolumeFileResolver>,
|
||||
file_storage: Arc<dyn FileStoragePort>,
|
||||
derivative_repo: Arc<dyn DerivativeRepository>,
|
||||
thumbnail_gen: Arc<dyn ThumbnailGeneratorPort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
asset_repo,
|
||||
volume_resolver,
|
||||
file_storage,
|
||||
derivative_repo,
|
||||
thumbnail_gen,
|
||||
@@ -92,8 +95,11 @@ impl PluginExecutor for ThumbnailGeneratorPlugin {
|
||||
}
|
||||
|
||||
let source_bytes = self
|
||||
.file_storage
|
||||
.read_file(&asset.source_reference.relative_path)
|
||||
.volume_resolver
|
||||
.read_by_volume(
|
||||
&asset.source_reference.volume_id,
|
||||
&asset.source_reference.relative_path,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let output = self
|
||||
|
||||
Reference in New Issue
Block a user