mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-04-23 16:07:15 -04:00
678 lines
17 KiB
Rust
678 lines
17 KiB
Rust
use crate::{
|
|
api::CoreEvent,
|
|
invalidate_query,
|
|
library::{update_library_statistics, Library, LibraryConfig, LibraryName},
|
|
location::{scan_location, LocationCreateArgs, ScanState},
|
|
util::MaybeUndefined,
|
|
Node,
|
|
};
|
|
|
|
use sd_core_heavy_lifting::JobId;
|
|
|
|
use sd_file_ext::kind::ObjectKind;
|
|
use sd_p2p::RemoteIdentity;
|
|
use sd_prisma::prisma::{file_path, indexer_rule, object, object_kind_statistics, statistics};
|
|
use sd_utils::{db::size_in_bytes_from_db, u64_to_frontend};
|
|
|
|
use std::{
|
|
collections::{hash_map::Entry, HashMap},
|
|
convert::identity,
|
|
pin::pin,
|
|
sync::{
|
|
atomic::{AtomicBool, Ordering},
|
|
Arc,
|
|
},
|
|
time::Duration,
|
|
};
|
|
|
|
use async_channel as chan;
|
|
use directories::UserDirs;
|
|
use futures::{FutureExt, StreamExt};
|
|
use futures_concurrency::{
|
|
future::{Join, TryJoin},
|
|
stream::Merge,
|
|
};
|
|
use once_cell::sync::Lazy;
|
|
use prisma_client_rust::{raw, QueryError};
|
|
use rspc::{alpha::AlphaRouter, ErrorCode};
|
|
use serde::{Deserialize, Serialize};
|
|
use specta::Type;
|
|
use strum::IntoEnumIterator;
|
|
use tokio::{
|
|
spawn,
|
|
sync::{Mutex, RwLock},
|
|
time::{interval, Instant},
|
|
};
|
|
use tokio_stream::wrappers::IntervalStream;
|
|
use tracing::{debug, error, info, warn};
|
|
use uuid::Uuid;
|
|
|
|
use super::{utils::library, Ctx, R};
|
|
|
|
const ONE_MINUTE: Duration = Duration::from_secs(60);
|
|
const TWO_MINUTES: Duration = Duration::from_secs(60 * 2);
|
|
const FIVE_MINUTES: Duration = Duration::from_secs(60 * 5);
|
|
|
|
static IS_COMPUTING_KIND_STATISTICS: AtomicBool = AtomicBool::new(false);
|
|
static LAST_KIND_STATISTICS_UPDATE: Lazy<RwLock<TotalFilesStatistics>> = Lazy::new(RwLock::default);
|
|
|
|
static STATISTICS_UPDATERS: Lazy<Mutex<HashMap<Uuid, chan::Sender<Instant>>>> =
|
|
Lazy::new(|| Mutex::new(HashMap::new()));
|
|
|
|
struct TotalFilesStatistics {
|
|
updated_at: Instant,
|
|
total_identified_files: u64,
|
|
total_unidentified_files: u64,
|
|
}
|
|
|
|
impl Default for TotalFilesStatistics {
|
|
fn default() -> Self {
|
|
Self {
|
|
updated_at: Instant::now() - FIVE_MINUTES,
|
|
total_identified_files: 0,
|
|
total_unidentified_files: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO(@Oscar): Replace with `specta::json`
|
|
#[derive(Serialize, Type)]
|
|
pub struct LibraryConfigWrapped {
|
|
pub uuid: Uuid,
|
|
pub instance_id: Uuid,
|
|
pub instance_public_key: RemoteIdentity,
|
|
pub config: LibraryConfig,
|
|
}
|
|
|
|
impl LibraryConfigWrapped {
|
|
pub async fn from_library(library: &Library) -> Self {
|
|
Self {
|
|
uuid: library.id,
|
|
instance_id: library.instance_uuid,
|
|
instance_public_key: library.identity.to_remote_identity(),
|
|
config: library.config().await,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Type, Default, Clone)]
|
|
pub struct KindStatistic {
|
|
kind: i32,
|
|
name: String,
|
|
count: (u32, u32),
|
|
total_bytes: (u32, u32),
|
|
}
|
|
|
|
pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
|
R.router()
|
|
.procedure("list", {
|
|
R.query(|node, _: ()| async move {
|
|
Ok(node
|
|
.libraries
|
|
.get_all()
|
|
.await
|
|
.into_iter()
|
|
.map(|lib| async move {
|
|
LibraryConfigWrapped {
|
|
uuid: lib.id,
|
|
instance_id: lib.instance_uuid,
|
|
instance_public_key: lib.identity.to_remote_identity(),
|
|
config: lib.config().await,
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join()
|
|
.await)
|
|
})
|
|
})
|
|
.procedure("statistics", {
|
|
#[derive(Serialize, Deserialize, Type)]
|
|
pub struct StatisticsResponse {
|
|
statistics: Option<statistics::Data>,
|
|
}
|
|
R.with2(library())
|
|
.query(|(node, library), _: ()| async move {
|
|
let statistics = library
|
|
.db
|
|
.statistics()
|
|
.find_unique(statistics::id::equals(1))
|
|
.exec()
|
|
.await?;
|
|
|
|
match STATISTICS_UPDATERS.lock().await.entry(library.id) {
|
|
Entry::Occupied(entry) => {
|
|
if entry.get().send(Instant::now()).await.is_err() {
|
|
error!("Failed to send statistics update request;");
|
|
}
|
|
}
|
|
Entry::Vacant(entry) => {
|
|
let (tx, rx) = chan::bounded(1);
|
|
entry.insert(tx);
|
|
|
|
spawn(update_statistics_loop(node, library, rx));
|
|
}
|
|
}
|
|
|
|
Ok(StatisticsResponse { statistics })
|
|
})
|
|
})
|
|
.procedure("kindStatistics", {
|
|
#[derive(Debug, Serialize, Deserialize, Type, Default)]
|
|
pub struct KindStatistics {
|
|
statistics: HashMap<i32, KindStatistic>,
|
|
total_identified_files: i32,
|
|
total_unidentified_files: i32,
|
|
}
|
|
|
|
R.with2(library())
|
|
.query(|(node, library), _: ()| async move {
|
|
let last_kind_statistics = LAST_KIND_STATISTICS_UPDATE.read().await;
|
|
|
|
let (total_unidentified_files, total_identified_files) = if last_kind_statistics
|
|
.updated_at
|
|
.elapsed()
|
|
< ONE_MINUTE
|
|
{
|
|
(
|
|
last_kind_statistics.total_unidentified_files,
|
|
last_kind_statistics.total_identified_files,
|
|
)
|
|
} else {
|
|
drop(last_kind_statistics);
|
|
let mut last_kind_statistics = LAST_KIND_STATISTICS_UPDATE.write().await;
|
|
|
|
if let Ok(false) = IS_COMPUTING_KIND_STATISTICS.compare_exchange(
|
|
false,
|
|
true,
|
|
Ordering::Release,
|
|
Ordering::Relaxed,
|
|
) {
|
|
spawn({
|
|
let library = Arc::clone(&library);
|
|
async move {
|
|
if let Err(e) = update_kind_statistics(node, library).await {
|
|
error!(?e, "Failed to update kind statistics;");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
let (total_unidentified_files, total_identified_files) = (
|
|
library
|
|
.db
|
|
.file_path()
|
|
.count(vec![file_path::object_id::equals(None)])
|
|
.exec()
|
|
.map(|count_res| count_res.map(|count| count as u64)),
|
|
library
|
|
.db
|
|
.file_path()
|
|
.count(vec![file_path::object_id::not(None)])
|
|
.exec()
|
|
.map(|count_res| count_res.map(|count| count as u64)),
|
|
)
|
|
.try_join()
|
|
.await?;
|
|
|
|
last_kind_statistics.total_unidentified_files = total_unidentified_files;
|
|
last_kind_statistics.total_identified_files = total_identified_files;
|
|
last_kind_statistics.updated_at = Instant::now();
|
|
|
|
(total_unidentified_files, total_identified_files)
|
|
};
|
|
|
|
let statistics = ObjectKind::iter()
|
|
.map(|kind| {
|
|
let library = Arc::clone(&library);
|
|
async move {
|
|
let int_kind = kind as i32;
|
|
library
|
|
.db
|
|
.object_kind_statistics()
|
|
.find_unique(object_kind_statistics::kind::equals(int_kind))
|
|
.select(
|
|
object_kind_statistics::select!({ total_bytes files_count }),
|
|
)
|
|
.exec()
|
|
.await
|
|
.map(|maybe_data| {
|
|
let (files_count, total_bytes) =
|
|
maybe_data.map_or((0, 0), |data| {
|
|
(data.files_count as u64, data.total_bytes as u64)
|
|
});
|
|
|
|
(
|
|
int_kind,
|
|
KindStatistic {
|
|
kind: int_kind,
|
|
name: kind.to_string(),
|
|
count: u64_to_frontend(files_count),
|
|
total_bytes: u64_to_frontend(total_bytes),
|
|
},
|
|
)
|
|
})
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.try_join()
|
|
.await?
|
|
.into_iter()
|
|
.collect();
|
|
|
|
Ok(KindStatistics {
|
|
statistics,
|
|
total_identified_files: total_identified_files as i32,
|
|
total_unidentified_files: total_unidentified_files as i32,
|
|
})
|
|
})
|
|
})
|
|
.procedure("updatedKindStatistic", {
|
|
R.with2(library())
|
|
.subscription(|(node, library), _: ()| async move {
|
|
let mut event_bus_rx = node.event_bus.0.subscribe();
|
|
async_stream::stream! {
|
|
while let Ok(event) = event_bus_rx.recv().await {
|
|
match event {
|
|
CoreEvent::UpdatedKindStatistic(stat, library_id) if library_id == library.id => {
|
|
yield stat
|
|
},
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
.procedure("create", {
|
|
#[derive(Deserialize, Type, Default)]
|
|
pub struct DefaultLocations {
|
|
desktop: bool,
|
|
documents: bool,
|
|
downloads: bool,
|
|
pictures: bool,
|
|
music: bool,
|
|
videos: bool,
|
|
}
|
|
|
|
#[derive(Deserialize, Type)]
|
|
pub struct CreateLibraryArgs {
|
|
name: LibraryName,
|
|
default_locations: Option<DefaultLocations>,
|
|
}
|
|
|
|
async fn create_default_locations_on_library_creation(
|
|
DefaultLocations {
|
|
desktop,
|
|
documents,
|
|
downloads,
|
|
pictures,
|
|
music,
|
|
videos,
|
|
}: DefaultLocations,
|
|
node: Arc<Node>,
|
|
library: Arc<Library>,
|
|
) -> Result<Option<JobId>, rspc::Error> {
|
|
// If all of them are false, we skip
|
|
if [!desktop, !documents, !downloads, !pictures, !music, !videos]
|
|
.into_iter()
|
|
.all(identity)
|
|
{
|
|
return Ok(None);
|
|
}
|
|
|
|
let Some(default_locations_paths) = UserDirs::new() else {
|
|
return Err(rspc::Error::new(
|
|
ErrorCode::NotFound,
|
|
"Didn't find any system locations".to_string(),
|
|
));
|
|
};
|
|
|
|
let default_rules_ids = library
|
|
.db
|
|
.indexer_rule()
|
|
.find_many(vec![indexer_rule::default::equals(Some(true))])
|
|
.select(indexer_rule::select!({ id }))
|
|
.exec()
|
|
.await
|
|
.map_err(|e| {
|
|
rspc::Error::with_cause(
|
|
ErrorCode::InternalServerError,
|
|
"Failed to get default indexer rules for default locations".to_string(),
|
|
e,
|
|
)
|
|
})?
|
|
.into_iter()
|
|
.map(|rule| rule.id)
|
|
.collect::<Vec<_>>();
|
|
|
|
let mut maybe_error = None;
|
|
|
|
[
|
|
(desktop, default_locations_paths.desktop_dir()),
|
|
(documents, default_locations_paths.document_dir()),
|
|
(downloads, default_locations_paths.download_dir()),
|
|
(pictures, default_locations_paths.picture_dir()),
|
|
(music, default_locations_paths.audio_dir()),
|
|
(videos, default_locations_paths.video_dir()),
|
|
]
|
|
.into_iter()
|
|
.filter_map(|entry| {
|
|
if let (true, Some(path)) = entry {
|
|
let node = Arc::clone(&node);
|
|
let library = Arc::clone(&library);
|
|
let indexer_rules_ids = default_rules_ids.clone();
|
|
let path = path.to_path_buf();
|
|
Some(spawn(async move {
|
|
let Some(location) = LocationCreateArgs {
|
|
path,
|
|
dry_run: false,
|
|
indexer_rules_ids,
|
|
}
|
|
.create(&node, &library)
|
|
.await
|
|
.map_err(rspc::Error::from)?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let scan_state = ScanState::try_from(location.scan_state)?;
|
|
|
|
scan_location(&node, &library, location, scan_state)
|
|
.await
|
|
.map_err(rspc::Error::from)
|
|
}))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join()
|
|
.await
|
|
.into_iter()
|
|
.map(|spawn_res| {
|
|
spawn_res
|
|
.map_err(|_| {
|
|
rspc::Error::new(
|
|
ErrorCode::InternalServerError,
|
|
"A task to create a default location failed".to_string(),
|
|
)
|
|
})
|
|
.and_then(identity)
|
|
})
|
|
.fold(&mut maybe_error, |maybe_error, res| {
|
|
if let Err(e) = res {
|
|
error!(?e, "Failed to create default location;");
|
|
*maybe_error = Some(e);
|
|
}
|
|
maybe_error
|
|
});
|
|
|
|
if let Some(e) = maybe_error {
|
|
return Err(e);
|
|
}
|
|
|
|
debug!("Created default locations");
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
R.mutation(
|
|
|node,
|
|
CreateLibraryArgs {
|
|
name,
|
|
default_locations,
|
|
}: CreateLibraryArgs| async move {
|
|
debug!("Creating library");
|
|
|
|
let library = node.libraries.create(name, None, &node).await?;
|
|
|
|
debug!(%library.id, "Created library;");
|
|
|
|
if let Some(locations) = default_locations {
|
|
create_default_locations_on_library_creation(
|
|
locations,
|
|
node,
|
|
Arc::clone(&library),
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
Ok(LibraryConfigWrapped::from_library(&library).await)
|
|
},
|
|
)
|
|
})
|
|
.procedure("edit", {
|
|
#[derive(Type, Deserialize)]
|
|
pub struct EditLibraryArgs {
|
|
pub id: Uuid,
|
|
pub name: Option<LibraryName>,
|
|
pub description: MaybeUndefined<String>,
|
|
}
|
|
|
|
R.mutation(
|
|
|node,
|
|
EditLibraryArgs {
|
|
id,
|
|
name,
|
|
description,
|
|
}: EditLibraryArgs| async move {
|
|
Ok(node
|
|
.libraries
|
|
.edit(id, name, description, MaybeUndefined::Undefined, None)
|
|
.await?)
|
|
},
|
|
)
|
|
})
|
|
.procedure(
|
|
"delete",
|
|
R.mutation(|node, id: Uuid| async move {
|
|
node.libraries.delete(&id).await.map_err(Into::into)
|
|
}),
|
|
)
|
|
.procedure(
|
|
"actors",
|
|
R.with2(library()).subscription(|(_, library), _: ()| {
|
|
let mut rx = library.actors.invalidate_rx.resubscribe();
|
|
|
|
async_stream::stream! {
|
|
let actors = library.actors.get_state().await;
|
|
yield actors;
|
|
|
|
while let Ok(()) = rx.recv().await {
|
|
let actors = library.actors.get_state().await;
|
|
yield actors;
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.procedure(
|
|
"startActor",
|
|
R.with2(library())
|
|
.mutation(|(_, library), name: String| async move {
|
|
library.actors.start(&name).await;
|
|
|
|
Ok(())
|
|
}),
|
|
)
|
|
.procedure(
|
|
"stopActor",
|
|
R.with2(library())
|
|
.mutation(|(_, library), name: String| async move {
|
|
library.actors.stop(&name).await;
|
|
|
|
Ok(())
|
|
}),
|
|
)
|
|
.procedure(
|
|
"vacuumDb",
|
|
R.with2(library())
|
|
.mutation(|(_, library), _: ()| async move {
|
|
// We retry a few times because if the DB is being actively used, the vacuum will fail
|
|
for _ in 0..5 {
|
|
match library.db._execute_raw(raw!("VACUUM;")).exec().await {
|
|
Ok(_) => break,
|
|
Err(e) => {
|
|
warn!(
|
|
%library.id,
|
|
?e,
|
|
"Failed to vacuum DB for library, retrying...;",
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
info!(%library.id, "Successfully vacuumed DB;");
|
|
|
|
Ok(())
|
|
}),
|
|
)
|
|
}
|
|
|
|
async fn update_kind_statistics(node: Arc<Node>, library: Arc<Library>) -> Result<(), QueryError> {
|
|
async fn inner(node: Arc<Node>, library: Arc<Library>) -> Result<(), QueryError> {
|
|
// We're processing each kind in a sequentially manner, so we can update the frontend step by step
|
|
for kind in ObjectKind::iter() {
|
|
let int_kind = kind as i32;
|
|
let mut last_object_id = 0;
|
|
|
|
let mut total_size = 0;
|
|
let mut files_count = 0;
|
|
|
|
loop {
|
|
let objects = library
|
|
.db
|
|
.object()
|
|
.find_many(vec![
|
|
object::id::gt(last_object_id),
|
|
object::kind::equals(Some(int_kind)),
|
|
])
|
|
.take(10_000)
|
|
.select(object::select!({ id file_paths: select { size_in_bytes_bytes } }))
|
|
.exec()
|
|
.await?;
|
|
|
|
if let Some(last) = objects.last() {
|
|
last_object_id = last.id;
|
|
} else {
|
|
break; // No more objects
|
|
}
|
|
|
|
for object in objects {
|
|
files_count += object.file_paths.len() as u64;
|
|
total_size += object
|
|
.file_paths
|
|
.into_iter()
|
|
.map(|file_path| {
|
|
file_path
|
|
.size_in_bytes_bytes
|
|
.map(|size| size_in_bytes_from_db(&size))
|
|
.unwrap_or(0)
|
|
})
|
|
.sum::<u64>();
|
|
}
|
|
}
|
|
|
|
let (old_files_count, old_total_bytes) = library
|
|
.db
|
|
.object_kind_statistics()
|
|
.find_unique(object_kind_statistics::kind::equals(int_kind))
|
|
.select(object_kind_statistics::select!({ files_count total_bytes }))
|
|
.exec()
|
|
.await?
|
|
.map_or((0, 0), |stats| {
|
|
(stats.files_count as u64, stats.total_bytes as u64)
|
|
});
|
|
|
|
// Only update if the statistics changed
|
|
if files_count != old_files_count || total_size != old_total_bytes {
|
|
let update_params = {
|
|
#[allow(clippy::cast_possible_wrap)]
|
|
{
|
|
// SAFETY: we had to store using i64 due to SQLite limitations
|
|
vec![
|
|
object_kind_statistics::files_count::set(files_count as i64),
|
|
object_kind_statistics::total_bytes::set(total_size as i64),
|
|
]
|
|
}
|
|
};
|
|
|
|
library
|
|
.db
|
|
.object_kind_statistics()
|
|
.upsert(
|
|
object_kind_statistics::kind::equals(int_kind),
|
|
object_kind_statistics::Create {
|
|
kind: int_kind,
|
|
_params: update_params.clone(),
|
|
},
|
|
update_params,
|
|
)
|
|
.select(object_kind_statistics::select!({ kind }))
|
|
.exec()
|
|
.await?;
|
|
|
|
// Sending back the updated statistics to the frontend
|
|
node.emit(CoreEvent::UpdatedKindStatistic(
|
|
KindStatistic {
|
|
kind: int_kind,
|
|
name: kind.to_string(),
|
|
count: u64_to_frontend(files_count),
|
|
total_bytes: u64_to_frontend(total_size),
|
|
},
|
|
library.id,
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
let res = inner(node, library).await;
|
|
|
|
// We finished and we can reset the flag
|
|
IS_COMPUTING_KIND_STATISTICS.store(false, Ordering::Release);
|
|
|
|
res
|
|
}
|
|
|
|
async fn update_statistics_loop(
|
|
node: Arc<Node>,
|
|
library: Arc<Library>,
|
|
last_requested_rx: chan::Receiver<Instant>,
|
|
) {
|
|
let mut last_received_at = Instant::now();
|
|
|
|
let tick = interval(ONE_MINUTE);
|
|
|
|
enum Message {
|
|
Tick,
|
|
Requested(Instant),
|
|
}
|
|
|
|
let mut msg_stream = pin!((
|
|
IntervalStream::new(tick).map(|_| Message::Tick),
|
|
last_requested_rx.map(Message::Requested)
|
|
)
|
|
.merge());
|
|
|
|
while let Some(msg) = msg_stream.next().await {
|
|
match msg {
|
|
Message::Tick => {
|
|
if last_received_at.elapsed() < FIVE_MINUTES {
|
|
if let Err(e) = update_library_statistics(&node, &library).await {
|
|
error!(?e, "Failed to update library statistics;");
|
|
} else {
|
|
invalidate_query!(&library, "library.statistics");
|
|
}
|
|
}
|
|
}
|
|
Message::Requested(instant) => {
|
|
if instant - last_received_at > TWO_MINUTES {
|
|
debug!("Updating last received at");
|
|
last_received_at = instant;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|