This commit is contained in:
1 parent
e646598078
commit
ead5d96b78
13 files changed
+1829
-1
No files matched your search
@@ -9,7 +9,7 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::{Emitter, Runtime};
|
use tauri::{Emitter, Runtime};
|
||||||
use tauri_plugin_dialog::DialogExt;
|
use tauri_plugin_dialog::DialogExt;
|
||||||
use theseus::instance::get_full_path;
|
use theseus::instance::{get_full_path, list};
|
||||||
|
|
||||||
const STUDIO_FILES_CHANGED_EVENT: &str = "studio-files-changed";
|
const STUDIO_FILES_CHANGED_EVENT: &str = "studio-files-changed";
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use chrono::NaiveDate;
|
|||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use path_util::SafeRelativeUtf8UnixPathBuf;
|
use path_util::SafeRelativeUtf8UnixPathBuf;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::Manager;
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use theseus::data::{
|
use theseus::data::{
|
||||||
@@ -103,6 +105,20 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
instance_edit_icon,
|
instance_edit_icon,
|
||||||
instance_export_mrpack,
|
instance_export_mrpack,
|
||||||
instance_get_pack_export_candidates,
|
instance_get_pack_export_candidates,
|
||||||
|
instance_list_screenshots,
|
||||||
|
instance_list_all_screenshots,
|
||||||
|
instance_list_synced_screenshots,
|
||||||
|
instance_save_edited_screenshot,
|
||||||
|
instance_list_screenshot_groups,
|
||||||
|
instance_create_screenshot_group,
|
||||||
|
instance_rename_screenshot_group,
|
||||||
|
instance_delete_screenshot_group,
|
||||||
|
instance_set_screenshot_group_memberships,
|
||||||
|
instance_import_screenshot_groups,
|
||||||
|
instance_delete_screenshots,
|
||||||
|
instance_export_screenshots,
|
||||||
|
instance_move_screenshots,
|
||||||
|
instance_open_screenshot,
|
||||||
])
|
])
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
@@ -1490,3 +1506,171 @@ pub async fn instance_edit_icon(
|
|||||||
theseus::instance::edit_icon(instance_id, icon_path).await?;
|
theseus::instance::edit_icon(instance_id, icon_path).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug, Clone)]
|
||||||
|
pub struct InstanceScreenshot {
|
||||||
|
pub id: String,
|
||||||
|
pub instance_id: String,
|
||||||
|
pub instance_name: String,
|
||||||
|
pub file_name: String,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub modified_at: i64,
|
||||||
|
pub group_id: Option<String>,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_screenshot<R: tauri::Runtime>(
|
||||||
|
app: &tauri::AppHandle<R>,
|
||||||
|
s: theseus::instance::InstanceScreenshot,
|
||||||
|
) -> Result<InstanceScreenshot> {
|
||||||
|
if let Some(parent) = s.path.parent() {
|
||||||
|
crate::api::files::ensure_browsable(app, parent);
|
||||||
|
}
|
||||||
|
let mut url = super::utils::tauri_convert_file_src(&s.path)?;
|
||||||
|
app.asset_protocol_scope().allow_file(&s.path)?;
|
||||||
|
app.fs_scope().allow_file(&s.path)?;
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("revision", &s.modified_at.to_string());
|
||||||
|
Ok(InstanceScreenshot {
|
||||||
|
id: s.id,
|
||||||
|
instance_id: s.instance_id,
|
||||||
|
instance_name: s.instance_name,
|
||||||
|
file_name: s.file_name,
|
||||||
|
created_at: s.created_at,
|
||||||
|
modified_at: s.modified_at,
|
||||||
|
group_id: s.group_id,
|
||||||
|
path: s.path,
|
||||||
|
url: url.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_screenshots<R: tauri::Runtime>(
|
||||||
|
app: &tauri::AppHandle<R>,
|
||||||
|
items: Vec<theseus::instance::InstanceScreenshot>,
|
||||||
|
) -> Result<Vec<InstanceScreenshot>> {
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| serialize_screenshot(app, s))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_list_screenshots<R: tauri::Runtime>(
|
||||||
|
app: tauri::AppHandle<R>,
|
||||||
|
instance_id: &str,
|
||||||
|
) -> Result<Vec<InstanceScreenshot>> {
|
||||||
|
serialize_screenshots(
|
||||||
|
&app,
|
||||||
|
theseus::instance::list_screenshots(instance_id).await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_list_all_screenshots<R: tauri::Runtime>(
|
||||||
|
app: tauri::AppHandle<R>,
|
||||||
|
) -> Result<Vec<InstanceScreenshot>> {
|
||||||
|
serialize_screenshots(
|
||||||
|
&app,
|
||||||
|
theseus::instance::list_all_screenshots().await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_list_synced_screenshots<R: tauri::Runtime>(
|
||||||
|
app: tauri::AppHandle<R>,
|
||||||
|
) -> Result<Vec<InstanceScreenshot>> {
|
||||||
|
serialize_screenshots(
|
||||||
|
&app,
|
||||||
|
theseus::instance::list_synced_screenshots().await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_save_edited_screenshot<R: tauri::Runtime>(
|
||||||
|
app: tauri::AppHandle<R>,
|
||||||
|
key: theseus::instance::ScreenshotKey,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
mode: theseus::instance::ScreenshotEditSaveMode,
|
||||||
|
) -> Result<InstanceScreenshot> {
|
||||||
|
serialize_screenshot(
|
||||||
|
&app,
|
||||||
|
theseus::instance::save_edited_screenshot(key, bytes, mode).await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_list_screenshot_groups()
|
||||||
|
-> Result<Vec<theseus::instance::ScreenshotGroup>> {
|
||||||
|
Ok(theseus::instance::list_screenshot_groups().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_create_screenshot_group(
|
||||||
|
name: String,
|
||||||
|
ids: Vec<String>,
|
||||||
|
) -> Result<theseus::instance::ScreenshotGroup> {
|
||||||
|
Ok(theseus::instance::create_screenshot_group(name, ids).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_rename_screenshot_group(
|
||||||
|
id: String,
|
||||||
|
new_name: String,
|
||||||
|
) -> Result<theseus::instance::ScreenshotGroup> {
|
||||||
|
Ok(theseus::instance::rename_screenshot_group(id, new_name).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_delete_screenshot_group(id: String) -> Result<()> {
|
||||||
|
Ok(theseus::instance::delete_screenshot_group(id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_set_screenshot_group_memberships(
|
||||||
|
updates: Vec<theseus::instance::ScreenshotGroupMembershipUpdate>,
|
||||||
|
) -> Result<()> {
|
||||||
|
Ok(theseus::instance::set_screenshot_group_memberships(updates).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_import_screenshot_groups(
|
||||||
|
groups: Vec<theseus::instance::ScreenshotGroupImport>,
|
||||||
|
) -> Result<()> {
|
||||||
|
Ok(theseus::instance::import_screenshot_groups(groups).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_delete_screenshots(
|
||||||
|
keys: Vec<theseus::instance::ScreenshotKey>,
|
||||||
|
) -> Result<()> {
|
||||||
|
Ok(theseus::instance::delete_screenshots(&keys).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_export_screenshots(
|
||||||
|
keys: Vec<theseus::instance::ScreenshotKey>,
|
||||||
|
path: PathBuf,
|
||||||
|
) -> Result<()> {
|
||||||
|
Ok(theseus::instance::export_screenshots(&keys, path).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_move_screenshots(
|
||||||
|
keys: Vec<theseus::instance::ScreenshotKey>,
|
||||||
|
target: &str,
|
||||||
|
) -> Result<Vec<theseus::instance::ScreenshotKey>> {
|
||||||
|
Ok(theseus::instance::move_screenshots(&keys, target).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_open_screenshot<R: tauri::Runtime>(
|
||||||
|
app: tauri::AppHandle<R>,
|
||||||
|
key: theseus::instance::ScreenshotKey,
|
||||||
|
) -> Result<()> {
|
||||||
|
let path = theseus::instance::get_screenshot_path(&key).await?;
|
||||||
|
app.opener()
|
||||||
|
.reveal_item_in_dir(path)
|
||||||
|
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- Screenshot center: unified index of per-instance screenshots plus
|
||||||
|
-- user-defined groups. Ported from the Axolotl implementation, trimmed of
|
||||||
|
-- the instance-sync / game-options tables that Starlight does not use.
|
||||||
|
|
||||||
|
CREATE TABLE screenshots (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
instance_id TEXT NOT NULL,
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
content_hash TEXT NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
modified_at INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
editor_state TEXT,
|
||||||
|
UNIQUE (instance_id, file_name),
|
||||||
|
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX screenshots_instance_id ON screenshots(instance_id);
|
||||||
|
CREATE INDEX screenshots_instance_hash ON screenshots(instance_id, content_hash, file_size);
|
||||||
|
|
||||||
|
CREATE TABLE screenshot_groups (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
display_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CHECK (length(trim(name)) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX screenshot_groups_display_order ON screenshot_groups(display_order);
|
||||||
|
|
||||||
|
CREATE TABLE screenshot_group_memberships (
|
||||||
|
screenshot_id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
group_id TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (screenshot_id) REFERENCES screenshots(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (group_id) REFERENCES screenshot_groups(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX screenshot_group_memberships_group_id ON screenshot_group_memberships(group_id);
|
||||||
|
ALTER TABLE settings ADD COLUMN show_screenshots_tab_in_instances INTEGER NOT NULL DEFAULT FALSE CHECK (show_screenshots_tab_in_instances IN (0, 1));
|
||||||
@@ -12,6 +12,8 @@ mod paths;
|
|||||||
mod planet_minecraft;
|
mod planet_minecraft;
|
||||||
mod projects;
|
mod projects;
|
||||||
mod run;
|
mod run;
|
||||||
|
mod screenshot_groups;
|
||||||
|
mod screenshots;
|
||||||
mod upgrade;
|
mod upgrade;
|
||||||
|
|
||||||
pub use self::content::{
|
pub use self::content::{
|
||||||
@@ -77,4 +79,17 @@ pub use self::upgrade::{
|
|||||||
resolve_custom_instance_upgrade_solution, select_instance_upgrade_solution,
|
resolve_custom_instance_upgrade_solution, select_instance_upgrade_solution,
|
||||||
update_instance_upgrade_resolution, update_instance_upgrade_resolutions,
|
update_instance_upgrade_resolution, update_instance_upgrade_resolutions,
|
||||||
};
|
};
|
||||||
|
pub use self::screenshot_groups::{
|
||||||
|
ScreenshotGroup, ScreenshotGroupImport, ScreenshotGroupMembershipUpdate,
|
||||||
|
create_screenshot_group, delete_screenshot_group, import_screenshot_groups,
|
||||||
|
list_screenshot_groups, rename_screenshot_group,
|
||||||
|
set_screenshot_group_memberships,
|
||||||
|
};
|
||||||
|
pub(crate) use self::screenshots::reconcile_screenshots;
|
||||||
|
pub use self::screenshots::{
|
||||||
|
InstanceScreenshot, ScreenshotEditSaveMode, ScreenshotKey,
|
||||||
|
delete_screenshots, export_screenshots, get_screenshot_path,
|
||||||
|
list_all_screenshots, list_screenshots, list_synced_screenshots,
|
||||||
|
move_screenshots, save_edited_screenshot,
|
||||||
|
};
|
||||||
pub use crate::state::{DailyPlaytime, DailyPlaytimeEntry};
|
pub use crate::state::{DailyPlaytime, DailyPlaytimeEntry};
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
use crate::State;
|
||||||
|
use crate::event::InstancePayloadType;
|
||||||
|
use crate::event::emit::emit_instance;
|
||||||
|
use crate::state::instances::adapters::sqlite::screenshot_rows;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{Sqlite, Transaction};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const MAX_GROUP_NAME_LENGTH: usize = 256;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ScreenshotGroup {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ScreenshotGroupMembershipUpdate {
|
||||||
|
pub screenshot_id: String,
|
||||||
|
pub group_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ScreenshotGroupImport {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub screenshot_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_group_name(name: &str) -> crate::Result<&str> {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(crate::ErrorKind::InputError(
|
||||||
|
"Group name cannot be empty".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
if name.chars().count() > MAX_GROUP_NAME_LENGTH {
|
||||||
|
return Err(crate::ErrorKind::InputError(format!(
|
||||||
|
"Group name cannot exceed {MAX_GROUP_NAME_LENGTH} characters"
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
Ok(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_screenshot_groups() -> crate::Result<Vec<ScreenshotGroup>> {
|
||||||
|
let state = State::get().await?;
|
||||||
|
Ok(screenshot_rows::list_groups(&state.pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|group| ScreenshotGroup {
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_screenshot_group(
|
||||||
|
name: String,
|
||||||
|
screenshot_ids: Vec<String>,
|
||||||
|
) -> crate::Result<ScreenshotGroup> {
|
||||||
|
let name = validate_group_name(&name)?;
|
||||||
|
let state = State::get().await?;
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
let mut tx = state.pool.begin().await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE screenshot_groups SET display_order = display_order + 1",
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO screenshot_groups (id, name, display_order) VALUES (?, ?, 0)",
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let instance_ids = set_group_members(
|
||||||
|
&id,
|
||||||
|
normalize_screenshot_ids(screenshot_ids)?,
|
||||||
|
&mut tx,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
emit_screenshot_updates(instance_ids).await;
|
||||||
|
|
||||||
|
Ok(ScreenshotGroup {
|
||||||
|
id,
|
||||||
|
name: name.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rename_screenshot_group(
|
||||||
|
id: String,
|
||||||
|
new_name: String,
|
||||||
|
) -> crate::Result<ScreenshotGroup> {
|
||||||
|
let new_name = validate_group_name(&new_name)?;
|
||||||
|
let state = State::get().await?;
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"UPDATE screenshot_groups SET name = ? WHERE id = ?",
|
||||||
|
new_name,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(crate::ErrorKind::InputError(format!(
|
||||||
|
"Unknown screenshot group {id}"
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ScreenshotGroup {
|
||||||
|
id,
|
||||||
|
name: new_name.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_screenshot_group(id: String) -> crate::Result<()> {
|
||||||
|
let state = State::get().await?;
|
||||||
|
let instance_ids = sqlx::query_scalar!(
|
||||||
|
"
|
||||||
|
SELECT DISTINCT screenshots.instance_id
|
||||||
|
FROM screenshot_group_memberships memberships
|
||||||
|
INNER JOIN screenshots ON screenshots.id = memberships.screenshot_id
|
||||||
|
WHERE memberships.group_id = ?
|
||||||
|
",
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
let result =
|
||||||
|
sqlx::query!("DELETE FROM screenshot_groups WHERE id = ?", id,)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(crate::ErrorKind::InputError(format!(
|
||||||
|
"Unknown screenshot group {id}"
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
emit_screenshot_updates(instance_ids).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub async fn set_screenshot_group_memberships(
|
||||||
|
updates: Vec<ScreenshotGroupMembershipUpdate>,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
let updates = normalize_membership_updates(updates)?;
|
||||||
|
if updates.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = State::get().await?;
|
||||||
|
let mut tx = state.pool.begin().await?;
|
||||||
|
let group_ids = updates
|
||||||
|
.iter()
|
||||||
|
.filter_map(|update| update.group_id.as_deref())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
for group_id in group_ids {
|
||||||
|
ensure_group_exists(group_id, &mut tx).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut instance_ids = HashSet::new();
|
||||||
|
for update in updates {
|
||||||
|
let instance_id =
|
||||||
|
screenshot_instance_id(&update.screenshot_id, &mut tx).await?;
|
||||||
|
instance_ids.insert(instance_id);
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM screenshot_group_memberships WHERE screenshot_id = ?",
|
||||||
|
update.screenshot_id,
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if let Some(group_id) = update.group_id {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO screenshot_group_memberships (screenshot_id, group_id) VALUES (?, ?)",
|
||||||
|
update.screenshot_id,
|
||||||
|
group_id,
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
emit_screenshot_updates(instance_ids).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn import_screenshot_groups(
|
||||||
|
groups: Vec<ScreenshotGroupImport>,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
if groups.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = State::get().await?;
|
||||||
|
let mut tx = state.pool.begin().await?;
|
||||||
|
let base_order = sqlx::query_scalar!(
|
||||||
|
"SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' FROM screenshot_groups",
|
||||||
|
)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let mut group_ids = HashSet::new();
|
||||||
|
let mut affected_instance_ids = HashSet::new();
|
||||||
|
|
||||||
|
for (index, group) in groups.into_iter().enumerate() {
|
||||||
|
if group.id.trim().is_empty() || !group_ids.insert(group.id.clone()) {
|
||||||
|
return Err(crate::ErrorKind::InputError(
|
||||||
|
"Screenshot group import contains an invalid group ID"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let name = validate_group_name(&group.name)?;
|
||||||
|
let display_order = base_order + index as i64;
|
||||||
|
sqlx::query!(
|
||||||
|
"
|
||||||
|
INSERT INTO screenshot_groups (id, name, display_order)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET name = excluded.name
|
||||||
|
",
|
||||||
|
group.id,
|
||||||
|
name,
|
||||||
|
display_order,
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
affected_instance_ids.extend(
|
||||||
|
set_group_members(
|
||||||
|
&group.id,
|
||||||
|
normalize_screenshot_ids(group.screenshot_ids)?,
|
||||||
|
&mut tx,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
emit_screenshot_updates(affected_instance_ids).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_screenshot_ids(
|
||||||
|
screenshot_ids: Vec<String>,
|
||||||
|
) -> crate::Result<Vec<String>> {
|
||||||
|
let mut unique = HashSet::new();
|
||||||
|
if screenshot_ids
|
||||||
|
.iter()
|
||||||
|
.all(|screenshot_id| unique.insert(screenshot_id.clone()))
|
||||||
|
{
|
||||||
|
return Ok(screenshot_ids);
|
||||||
|
}
|
||||||
|
Err(crate::ErrorKind::InputError(
|
||||||
|
"Screenshot group contains duplicate screenshots".to_string(),
|
||||||
|
)
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_membership_updates(
|
||||||
|
updates: Vec<ScreenshotGroupMembershipUpdate>,
|
||||||
|
) -> crate::Result<Vec<ScreenshotGroupMembershipUpdate>> {
|
||||||
|
let mut screenshot_ids = HashSet::new();
|
||||||
|
if updates
|
||||||
|
.iter()
|
||||||
|
.all(|update| screenshot_ids.insert(update.screenshot_id.clone()))
|
||||||
|
{
|
||||||
|
return Ok(updates);
|
||||||
|
}
|
||||||
|
Err(crate::ErrorKind::InputError(
|
||||||
|
"Screenshot membership update contains duplicate screenshots".to_string(),
|
||||||
|
)
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_group_members(
|
||||||
|
group_id: &str,
|
||||||
|
screenshot_ids: Vec<String>,
|
||||||
|
tx: &mut Transaction<'_, Sqlite>,
|
||||||
|
) -> crate::Result<HashSet<String>> {
|
||||||
|
let mut instance_ids = HashSet::new();
|
||||||
|
for screenshot_id in screenshot_ids {
|
||||||
|
instance_ids.insert(screenshot_instance_id(&screenshot_id, tx).await?);
|
||||||
|
sqlx::query!(
|
||||||
|
"
|
||||||
|
INSERT INTO screenshot_group_memberships (screenshot_id, group_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(screenshot_id) DO UPDATE SET group_id = excluded.group_id
|
||||||
|
",
|
||||||
|
screenshot_id,
|
||||||
|
group_id,
|
||||||
|
)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(instance_ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_group_exists(
|
||||||
|
group_id: &str,
|
||||||
|
tx: &mut Transaction<'_, Sqlite>,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
let exists = sqlx::query_scalar!(
|
||||||
|
r#"SELECT EXISTS(SELECT 1 FROM screenshot_groups WHERE id = ?) AS "exists!: bool""#,
|
||||||
|
group_id,
|
||||||
|
)
|
||||||
|
.fetch_one(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
if !exists {
|
||||||
|
return Err(crate::ErrorKind::InputError(format!(
|
||||||
|
"Unknown screenshot group {group_id}"
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn screenshot_instance_id(
|
||||||
|
screenshot_id: &str,
|
||||||
|
tx: &mut Transaction<'_, Sqlite>,
|
||||||
|
) -> crate::Result<String> {
|
||||||
|
sqlx::query_scalar!(
|
||||||
|
"SELECT instance_id FROM screenshots WHERE id = ?",
|
||||||
|
screenshot_id,
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut **tx)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::ErrorKind::InputError(format!(
|
||||||
|
"Unknown screenshot {screenshot_id}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn emit_screenshot_updates(
|
||||||
|
instance_ids: impl IntoIterator<Item = String>,
|
||||||
|
) {
|
||||||
|
for instance_id in instance_ids {
|
||||||
|
let _ = emit_instance(
|
||||||
|
&instance_id,
|
||||||
|
InstancePayloadType::ScreenshotsUpdated,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
mod operations;
|
||||||
|
mod reconciliation;
|
||||||
|
|
||||||
|
pub use self::operations::{
|
||||||
|
InstanceScreenshot, ScreenshotEditSaveMode, ScreenshotKey,
|
||||||
|
delete_screenshots, export_screenshots, get_screenshot_path,
|
||||||
|
list_all_screenshots, list_screenshots, list_synced_screenshots,
|
||||||
|
move_screenshots, save_edited_screenshot,
|
||||||
|
};
|
||||||
|
pub(crate) use self::reconciliation::reconcile_screenshots;
|
||||||
@@ -0,0 +1,650 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use futures::stream::{self, StreamExt, TryStreamExt};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::io::Cursor;
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
use tokio::sync::OwnedMutexGuard;
|
||||||
|
use zip::write::SimpleFileOptions;
|
||||||
|
use zip::{CompressionMethod, ZipWriter};
|
||||||
|
|
||||||
|
use super::reconciliation::{
|
||||||
|
list_source_screenshots, reconcile_source_screenshots,
|
||||||
|
scan_source_screenshots,
|
||||||
|
};
|
||||||
|
use crate::State;
|
||||||
|
use crate::event::InstancePayloadType;
|
||||||
|
use crate::event::emit::emit_instance;
|
||||||
|
use crate::state::instances::adapters::sqlite::{
|
||||||
|
instance_rows::{self, InstanceScreenshotSource},
|
||||||
|
screenshot_rows,
|
||||||
|
};
|
||||||
|
use crate::util::fetch::sha1_file_async;
|
||||||
|
use crate::util::io::{self, IOError};
|
||||||
|
|
||||||
|
const SCREENSHOTS_DIRECTORY: &str = "screenshots";
|
||||||
|
const SCREENSHOT_SCAN_CONCURRENCY: usize = 8;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||||
|
pub struct ScreenshotKey {
|
||||||
|
pub instance_id: String,
|
||||||
|
pub file_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct InstanceScreenshot {
|
||||||
|
pub id: String,
|
||||||
|
pub instance_id: String,
|
||||||
|
pub instance_name: String,
|
||||||
|
pub file_name: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub modified_at: i64,
|
||||||
|
pub group_id: Option<String>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ScreenshotEditSaveMode {
|
||||||
|
CreateCopy,
|
||||||
|
ReplaceEdit,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_screenshots(
|
||||||
|
instance_id: &str,
|
||||||
|
) -> crate::Result<Vec<InstanceScreenshot>> {
|
||||||
|
let state = State::get().await?;
|
||||||
|
let source =
|
||||||
|
instance_rows::get_instance_screenshot_source(instance_id, &state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
list_source_screenshots(&state, source).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_synced_screenshots() -> crate::Result<Vec<InstanceScreenshot>>
|
||||||
|
{
|
||||||
|
list_all_screenshots().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_all_screenshots() -> crate::Result<Vec<InstanceScreenshot>> {
|
||||||
|
let state = State::get().await?;
|
||||||
|
let sources = instance_rows::list_screenshot_sources(&state.pool).await?;
|
||||||
|
list_source_screenshot_sets(&state, sources).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_source_screenshot_sets(
|
||||||
|
state: &State,
|
||||||
|
sources: Vec<InstanceScreenshotSource>,
|
||||||
|
) -> crate::Result<Vec<InstanceScreenshot>> {
|
||||||
|
let mut screenshots =
|
||||||
|
stream::iter(sources.into_iter().map(|source| async move {
|
||||||
|
list_source_screenshots(state, source).await
|
||||||
|
}))
|
||||||
|
.buffer_unordered(SCREENSHOT_SCAN_CONCURRENCY)
|
||||||
|
.try_collect::<Vec<Vec<InstanceScreenshot>>>()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
sort_screenshots(&mut screenshots);
|
||||||
|
Ok(screenshots)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn lock_instance_screenshots<'a>(
|
||||||
|
state: &State,
|
||||||
|
instance_ids: impl IntoIterator<Item = &'a str>,
|
||||||
|
) -> Vec<OwnedMutexGuard<()>> {
|
||||||
|
let mut locks = Vec::new();
|
||||||
|
for instance_id in instance_ids {
|
||||||
|
locks.push(state.lock_instance_screenshots(instance_id).await);
|
||||||
|
}
|
||||||
|
locks
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_screenshots(keys: &[ScreenshotKey]) -> crate::Result<()> {
|
||||||
|
ensure_unique_keys(keys)?;
|
||||||
|
let state = State::get().await?;
|
||||||
|
let mut instance_ids = keys
|
||||||
|
.iter()
|
||||||
|
.map(|key| key.instance_id.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
instance_ids.sort_unstable();
|
||||||
|
instance_ids.dedup();
|
||||||
|
let _locks = lock_instance_screenshots(&state, instance_ids).await;
|
||||||
|
|
||||||
|
for key in keys {
|
||||||
|
io::remove_file(get_screenshot_path(key).await?).await?;
|
||||||
|
screenshot_rows::delete_screenshot_by_key(
|
||||||
|
&key.instance_id,
|
||||||
|
&key.file_name,
|
||||||
|
&state.pool,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn export_screenshots(
|
||||||
|
keys: &[ScreenshotKey],
|
||||||
|
export_path: PathBuf,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
ensure_unique_keys(keys)?;
|
||||||
|
if keys.is_empty() {
|
||||||
|
return Err(crate::ErrorKind::InputError(
|
||||||
|
"At least one screenshot must be selected".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = State::get().await?;
|
||||||
|
let mut sources = HashMap::new();
|
||||||
|
let mut archive_folders = HashMap::<String, String>::new();
|
||||||
|
let mut used_archive_folders = HashSet::new();
|
||||||
|
let mut screenshots = Vec::with_capacity(keys.len());
|
||||||
|
|
||||||
|
for key in keys {
|
||||||
|
let source = match sources.get(&key.instance_id) {
|
||||||
|
Some(source) => source,
|
||||||
|
None => {
|
||||||
|
let source = instance_rows::get_instance_screenshot_source(
|
||||||
|
&key.instance_id,
|
||||||
|
&state.pool,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||||
|
})?;
|
||||||
|
sources.insert(key.instance_id.clone(), source);
|
||||||
|
&sources[&key.instance_id]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let archive_folder = match archive_folders.get(&key.instance_id) {
|
||||||
|
Some(folder) => folder.clone(),
|
||||||
|
None => {
|
||||||
|
let folder = unique_archive_folder(
|
||||||
|
&sanitize_archive_component(&source.name),
|
||||||
|
&mut used_archive_folders,
|
||||||
|
);
|
||||||
|
archive_folders.insert(key.instance_id.clone(), folder.clone());
|
||||||
|
folder
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let archive_name = format!("{archive_folder}/{}", key.file_name);
|
||||||
|
screenshots.push((archive_name, get_screenshot_path(key).await?));
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || -> crate::Result<()> {
|
||||||
|
let file = std::fs::File::create(&export_path)
|
||||||
|
.map_err(|error| IOError::with_path(error, &export_path))?;
|
||||||
|
let mut writer = ZipWriter::new(file);
|
||||||
|
let options = SimpleFileOptions::default()
|
||||||
|
.compression_method(CompressionMethod::Stored);
|
||||||
|
|
||||||
|
for (archive_name, path) in screenshots {
|
||||||
|
writer
|
||||||
|
.start_file(archive_name, options)
|
||||||
|
.map_err(std::io::Error::from)?;
|
||||||
|
let mut source = std::fs::File::open(&path)
|
||||||
|
.map_err(|error| IOError::with_path(error, &path))?;
|
||||||
|
std::io::copy(&mut source, &mut writer).map_err(IOError::from)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.finish().map_err(std::io::Error::from)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub async fn move_screenshots(
|
||||||
|
keys: &[ScreenshotKey],
|
||||||
|
target_instance_id: &str,
|
||||||
|
) -> crate::Result<Vec<ScreenshotKey>> {
|
||||||
|
ensure_unique_keys(keys)?;
|
||||||
|
if keys.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = State::get().await?;
|
||||||
|
let mut instance_ids = keys
|
||||||
|
.iter()
|
||||||
|
.map(|key| key.instance_id.as_str())
|
||||||
|
.chain(std::iter::once(target_instance_id))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
instance_ids.sort_unstable();
|
||||||
|
instance_ids.dedup();
|
||||||
|
let _locks = lock_instance_screenshots(&state, instance_ids).await;
|
||||||
|
let target_source = instance_rows::get_instance_screenshot_source(
|
||||||
|
target_instance_id,
|
||||||
|
&state.pool,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::ErrorKind::InputError("Unknown target instance".to_string())
|
||||||
|
})?;
|
||||||
|
let target_dir = source_screenshots_dir(&state, &target_source).await?;
|
||||||
|
io::create_dir_all(&target_dir).await?;
|
||||||
|
ensure_directory_is_not_symlink(&target_dir).await?;
|
||||||
|
|
||||||
|
let mut moved_keys = Vec::with_capacity(keys.len());
|
||||||
|
for key in keys {
|
||||||
|
if key.instance_id == target_instance_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let source_path = get_screenshot_path(key).await?;
|
||||||
|
let target_path =
|
||||||
|
available_target_path(&target_dir, &key.file_name).await?;
|
||||||
|
io::rename_or_move(&source_path, &target_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
crate::ErrorKind::InputError(format!(
|
||||||
|
"Could not move screenshot: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let file_name = target_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|file_name| file_name.to_str())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::ErrorKind::InputError(
|
||||||
|
"Could not determine moved screenshot file name"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
screenshot_rows::move_screenshot(
|
||||||
|
&key.instance_id,
|
||||||
|
&key.file_name,
|
||||||
|
target_instance_id,
|
||||||
|
&file_name,
|
||||||
|
&state.pool,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
moved_keys.push(ScreenshotKey {
|
||||||
|
instance_id: target_instance_id.to_string(),
|
||||||
|
file_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(moved_keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_screenshot_path(
|
||||||
|
key: &ScreenshotKey,
|
||||||
|
) -> crate::Result<PathBuf> {
|
||||||
|
validate_file_name(&key.file_name)?;
|
||||||
|
|
||||||
|
let state = State::get().await?;
|
||||||
|
let source = instance_rows::get_instance_screenshot_source(
|
||||||
|
&key.instance_id,
|
||||||
|
&state.pool,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||||
|
})?;
|
||||||
|
let screenshots_dir = source_screenshots_dir(&state, &source).await?;
|
||||||
|
let canonical_dir = tokio::fs::canonicalize(&screenshots_dir)
|
||||||
|
.await
|
||||||
|
.map_err(|error| IOError::with_path(error, &screenshots_dir))?;
|
||||||
|
let requested_path = screenshots_dir.join(&key.file_name);
|
||||||
|
let metadata = tokio::fs::symlink_metadata(&requested_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| IOError::with_path(error, &requested_path))?;
|
||||||
|
|
||||||
|
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||||
|
return Err(crate::ErrorKind::InputError(
|
||||||
|
"Screenshot must be a regular file".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let canonical_path = tokio::fs::canonicalize(&requested_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| IOError::with_path(error, &requested_path))?;
|
||||||
|
if !canonical_path.starts_with(&canonical_dir) {
|
||||||
|
return Err(crate::ErrorKind::InputError(
|
||||||
|
"Screenshot path is outside the instance screenshots directory"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(canonical_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_edited_screenshot(
|
||||||
|
key: ScreenshotKey,
|
||||||
|
png_bytes: Vec<u8>,
|
||||||
|
mode: ScreenshotEditSaveMode,
|
||||||
|
) -> crate::Result<InstanceScreenshot> {
|
||||||
|
validate_file_name(&key.file_name)?;
|
||||||
|
|
||||||
|
let state = State::get().await?;
|
||||||
|
let source = instance_rows::get_instance_screenshot_source(
|
||||||
|
&key.instance_id,
|
||||||
|
&state.pool,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||||
|
})?;
|
||||||
|
let _lock = state.lock_instance_screenshots(&source.id).await;
|
||||||
|
|
||||||
|
let scanned = scan_source_screenshots(&state, &source).await?;
|
||||||
|
let current =
|
||||||
|
reconcile_source_screenshots(&state, &source, scanned).await?;
|
||||||
|
let source_screenshot = current
|
||||||
|
.iter()
|
||||||
|
.find(|screenshot| screenshot.file_name == key.file_name)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::ErrorKind::InputError("Unknown screenshot".to_string())
|
||||||
|
})?;
|
||||||
|
let source_row = screenshot_rows::get_screenshot_by_key(
|
||||||
|
&key.instance_id,
|
||||||
|
&key.file_name,
|
||||||
|
&state.pool,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::ErrorKind::InputError("Unknown screenshot".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let png_bytes = tokio::task::spawn_blocking(move || {
|
||||||
|
validate_png_dimensions(&png_bytes)?;
|
||||||
|
Ok::<_, crate::Error>(png_bytes)
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
|
||||||
|
let screenshots_dir = source_screenshots_dir(&state, &source).await?;
|
||||||
|
let (target_path, copy_group) = match mode {
|
||||||
|
ScreenshotEditSaveMode::CreateCopy => (
|
||||||
|
available_target_path(&screenshots_dir, &source_row.file_name)
|
||||||
|
.await?,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
ScreenshotEditSaveMode::ReplaceEdit => {
|
||||||
|
(source_screenshot.path.clone(), false)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let target_file_name = target_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|file_name| file_name.to_str())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::ErrorKind::InputError(
|
||||||
|
"Could not determine edited screenshot file name".to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
io::write(&target_path, png_bytes).await?;
|
||||||
|
if !copy_group {
|
||||||
|
let (file_size, content_hash) = sha1_file_async(&target_path).await?;
|
||||||
|
let file_size = i64::try_from(file_size).map_err(|_| {
|
||||||
|
crate::ErrorKind::InputError(
|
||||||
|
"Screenshot is too large to index".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mut source_row = source_row.clone();
|
||||||
|
source_row.content_hash = content_hash;
|
||||||
|
source_row.file_size = file_size;
|
||||||
|
let mut tx = state.pool.begin().await?;
|
||||||
|
screenshot_rows::update_screenshot(&source_row, &mut tx).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
}
|
||||||
|
let scanned = scan_source_screenshots(&state, &source).await?;
|
||||||
|
let reconciled =
|
||||||
|
reconcile_source_screenshots(&state, &source, scanned).await?;
|
||||||
|
let mut saved = reconciled
|
||||||
|
.into_iter()
|
||||||
|
.find(|screenshot| screenshot.file_name == target_file_name)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::ErrorKind::InputError(
|
||||||
|
"Could not index edited screenshot".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if copy_group {
|
||||||
|
let result: crate::Result<()> = async {
|
||||||
|
let mut tx = state.pool.begin().await?;
|
||||||
|
screenshot_rows::copy_group_membership(
|
||||||
|
&source_row.id,
|
||||||
|
&saved.id,
|
||||||
|
&mut tx,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
if let Err(error) = result {
|
||||||
|
let _ = io::remove_file(&target_path).await;
|
||||||
|
let _ = screenshot_rows::delete_screenshot_by_key(
|
||||||
|
&source.id,
|
||||||
|
&target_file_name,
|
||||||
|
&state.pool,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut saved_row = screenshot_rows::get_screenshot_by_key(
|
||||||
|
&saved.instance_id,
|
||||||
|
&saved.file_name,
|
||||||
|
&state.pool,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::ErrorKind::InputError(
|
||||||
|
"Could not load edited screenshot".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !copy_group {
|
||||||
|
let mut tx = state.pool.begin().await?;
|
||||||
|
if saved_row.created_at != source_row.created_at {
|
||||||
|
saved_row.created_at = source_row.created_at;
|
||||||
|
screenshot_rows::update_screenshot(&saved_row, &mut tx).await?;
|
||||||
|
saved.created_at =
|
||||||
|
DateTime::from_timestamp_millis(source_row.created_at)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::ErrorKind::InputError(
|
||||||
|
"Screenshot creation time is out of range"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
}
|
||||||
|
saved.modified_at = saved_row.modified_at;
|
||||||
|
saved.group_id = saved_row.group_id;
|
||||||
|
|
||||||
|
let _ = emit_instance(
|
||||||
|
&saved.instance_id,
|
||||||
|
InstancePayloadType::ScreenshotsUpdated,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn source_screenshots_dir(
|
||||||
|
state: &State,
|
||||||
|
source: &InstanceScreenshotSource,
|
||||||
|
) -> crate::Result<PathBuf> {
|
||||||
|
// Directly associated instances keep their files in the external launcher's
|
||||||
|
// resolved game directory (which may be the shared `.minecraft` root or an
|
||||||
|
// isolated version directory). Use the same runtime resolver as launching
|
||||||
|
// and content browsing so screenshots are read from the directory the game
|
||||||
|
// actually uses.
|
||||||
|
let instance_dir =
|
||||||
|
match instance_rows::get_instance_by_id(&source.id, &state.pool).await?
|
||||||
|
{
|
||||||
|
Some(instance) => crate::launcher::linked_game_dir(&instance)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
state.directories.instances_dir().join(&source.path)
|
||||||
|
}),
|
||||||
|
None => state.directories.instances_dir().join(&source.path),
|
||||||
|
};
|
||||||
|
let canonical_instance_dir =
|
||||||
|
tokio::fs::canonicalize(&instance_dir)
|
||||||
|
.await
|
||||||
|
.map_err(|error| IOError::with_path(error, &instance_dir))?;
|
||||||
|
let screenshots_dir = canonical_instance_dir.join(SCREENSHOTS_DIRECTORY);
|
||||||
|
|
||||||
|
ensure_directory_is_not_symlink(&screenshots_dir).await?;
|
||||||
|
Ok(screenshots_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_directory_is_not_symlink(path: &Path) -> crate::Result<()> {
|
||||||
|
match tokio::fs::symlink_metadata(path).await {
|
||||||
|
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||||
|
Err(crate::ErrorKind::InputError(
|
||||||
|
"Instance screenshots directory cannot be a symbolic link"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
Ok(metadata) if !metadata.is_dir() => {
|
||||||
|
Err(crate::ErrorKind::InputError(
|
||||||
|
"Instance screenshots path must be a directory".to_string(),
|
||||||
|
)
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(error) => Err(IOError::with_path(error, path).into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn available_target_path(
|
||||||
|
target_dir: &Path,
|
||||||
|
file_name: &str,
|
||||||
|
) -> crate::Result<PathBuf> {
|
||||||
|
validate_file_name(file_name)?;
|
||||||
|
let original = target_dir.join(file_name);
|
||||||
|
if !tokio::fs::try_exists(&original)
|
||||||
|
.await
|
||||||
|
.map_err(|error| IOError::with_path(error, &original))?
|
||||||
|
{
|
||||||
|
return Ok(original);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = Path::new(file_name);
|
||||||
|
let stem = path.file_stem().and_then(|value| value.to_str()).unwrap();
|
||||||
|
let extension = path.extension().and_then(|value| value.to_str()).unwrap();
|
||||||
|
|
||||||
|
for suffix in 1_u32.. {
|
||||||
|
let candidate =
|
||||||
|
target_dir.join(format!("{stem} ({suffix}).{extension}"));
|
||||||
|
if !tokio::fs::try_exists(&candidate)
|
||||||
|
.await
|
||||||
|
.map_err(|error| IOError::with_path(error, &candidate))?
|
||||||
|
{
|
||||||
|
return Ok(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_png_dimensions(bytes: &[u8]) -> crate::Result<(u32, u32)> {
|
||||||
|
let validate = || -> Result<_, png::DecodingError> {
|
||||||
|
let mut reader = png::Decoder::new(Cursor::new(bytes)).read_info()?;
|
||||||
|
let dimensions = (reader.info().width, reader.info().height);
|
||||||
|
while reader.next_row()?.is_some() {}
|
||||||
|
reader.finish()?;
|
||||||
|
Ok(dimensions)
|
||||||
|
};
|
||||||
|
validate().map_err(|error| {
|
||||||
|
crate::ErrorKind::InputError(format!(
|
||||||
|
"Could not decode screenshot as PNG: {error}"
|
||||||
|
))
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_unique_keys(keys: &[ScreenshotKey]) -> crate::Result<()> {
|
||||||
|
let mut unique = HashSet::new();
|
||||||
|
if keys.iter().all(|key| unique.insert(key)) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(crate::ErrorKind::InputError(
|
||||||
|
"Screenshot selection contains duplicates".to_string(),
|
||||||
|
)
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_file_name(file_name: &str) -> crate::Result<()> {
|
||||||
|
let path = Path::new(file_name);
|
||||||
|
let mut components = path.components();
|
||||||
|
let is_single_file =
|
||||||
|
matches!(components.next(), Some(Component::Normal(_)))
|
||||||
|
&& components.next().is_none();
|
||||||
|
|
||||||
|
if !is_single_file || !has_png_extension(path) {
|
||||||
|
return Err(crate::ErrorKind::InputError(
|
||||||
|
"Invalid screenshot file name".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn has_png_extension(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
.is_some_and(|extension| extension.eq_ignore_ascii_case("png"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn sort_screenshots(screenshots: &mut [InstanceScreenshot]) {
|
||||||
|
screenshots.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.created_at
|
||||||
|
.cmp(&left.created_at)
|
||||||
|
.then_with(|| left.instance_name.cmp(&right.instance_name))
|
||||||
|
.then_with(|| left.file_name.cmp(&right.file_name))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_archive_component(value: &str) -> String {
|
||||||
|
let sanitized = value
|
||||||
|
.chars()
|
||||||
|
.map(|character| match character {
|
||||||
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||||
|
_ => character,
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
if sanitized.is_empty() || sanitized == "." || sanitized == ".." {
|
||||||
|
"Instance".to_string()
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_archive_folder(
|
||||||
|
base: &str,
|
||||||
|
used_folders: &mut HashSet<String>,
|
||||||
|
) -> String {
|
||||||
|
if used_folders.insert(base.to_string()) {
|
||||||
|
return base.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
for suffix in 2_u32.. {
|
||||||
|
let candidate = format!("{base} ({suffix})");
|
||||||
|
if used_folders.insert(candidate.clone()) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
use super::operations::{
|
||||||
|
InstanceScreenshot, has_png_extension, sort_screenshots,
|
||||||
|
source_screenshots_dir,
|
||||||
|
};
|
||||||
|
use crate::State;
|
||||||
|
use crate::state::instances::adapters::sqlite::{
|
||||||
|
instance_rows::{self, InstanceScreenshotSource},
|
||||||
|
screenshot_rows::{self, ScreenshotRow},
|
||||||
|
};
|
||||||
|
use crate::util::fetch::sha1_file_async;
|
||||||
|
use crate::util::io::IOError;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use futures::stream::{self, StreamExt, TryStreamExt};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const SCREENSHOT_HASH_CONCURRENCY: usize = 8;
|
||||||
|
|
||||||
|
pub(super) struct ScannedScreenshot {
|
||||||
|
file_name: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
modified_at: i64,
|
||||||
|
file_size: i64,
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ResolvedScreenshot {
|
||||||
|
scanned: ScannedScreenshot,
|
||||||
|
row: ScreenshotRow,
|
||||||
|
is_new: bool,
|
||||||
|
changed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_scanned_screenshot(
|
||||||
|
source: &InstanceScreenshotSource,
|
||||||
|
scanned: ScannedScreenshot,
|
||||||
|
content_hash: String,
|
||||||
|
matched: Option<ScreenshotRow>,
|
||||||
|
) -> ResolvedScreenshot {
|
||||||
|
let (mut row, is_new) = match matched {
|
||||||
|
Some(row) => (row, false),
|
||||||
|
None => (
|
||||||
|
ScreenshotRow {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
instance_id: source.id.clone(),
|
||||||
|
file_name: scanned.file_name.clone(),
|
||||||
|
content_hash: content_hash.clone(),
|
||||||
|
file_size: scanned.file_size,
|
||||||
|
modified_at: scanned.modified_at,
|
||||||
|
created_at: scanned.created_at.timestamp_millis(),
|
||||||
|
group_id: None,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let changed = !is_new
|
||||||
|
&& (row.file_name != scanned.file_name
|
||||||
|
|| row.content_hash != content_hash
|
||||||
|
|| row.file_size != scanned.file_size
|
||||||
|
|| row.modified_at != scanned.modified_at
|
||||||
|
|| row.created_at != scanned.created_at.timestamp_millis());
|
||||||
|
row.instance_id.clone_from(&source.id);
|
||||||
|
row.file_name.clone_from(&scanned.file_name);
|
||||||
|
row.content_hash = content_hash;
|
||||||
|
row.file_size = scanned.file_size;
|
||||||
|
row.modified_at = scanned.modified_at;
|
||||||
|
row.created_at = scanned.created_at.timestamp_millis();
|
||||||
|
|
||||||
|
ResolvedScreenshot {
|
||||||
|
scanned,
|
||||||
|
row,
|
||||||
|
is_new,
|
||||||
|
changed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn list_source_screenshots(
|
||||||
|
state: &State,
|
||||||
|
source: InstanceScreenshotSource,
|
||||||
|
) -> crate::Result<Vec<InstanceScreenshot>> {
|
||||||
|
let _lock = state.lock_instance_screenshots(&source.id).await;
|
||||||
|
let scanned = scan_source_screenshots(state, &source).await?;
|
||||||
|
reconcile_source_screenshots(state, &source, scanned).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn reconcile_screenshots(
|
||||||
|
instance_id: &str,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
let state = State::get().await?;
|
||||||
|
let Some(source) =
|
||||||
|
instance_rows::get_instance_screenshot_source(instance_id, &state.pool)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
list_source_screenshots(&state, source).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn scan_source_screenshots(
|
||||||
|
state: &State,
|
||||||
|
source: &InstanceScreenshotSource,
|
||||||
|
) -> crate::Result<Vec<ScannedScreenshot>> {
|
||||||
|
let screenshots_dir = source_screenshots_dir(state, source).await?;
|
||||||
|
tokio::task::spawn_blocking(move || scan_screenshots_dir(&screenshots_dir))
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_screenshots_dir(
|
||||||
|
screenshots_dir: &Path,
|
||||||
|
) -> crate::Result<Vec<ScannedScreenshot>> {
|
||||||
|
let entries = match std::fs::read_dir(screenshots_dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
let mut screenshots = Vec::new();
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry
|
||||||
|
.map_err(|error| IOError::with_path(error, screenshots_dir))?;
|
||||||
|
let file_type = entry
|
||||||
|
.file_type()
|
||||||
|
.map_err(|error| IOError::with_path(error, entry.path()))?;
|
||||||
|
if !file_type.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = entry.path();
|
||||||
|
if !has_png_extension(&path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(file_name) = entry.file_name().to_str().map(str::to_owned)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let metadata = entry
|
||||||
|
.metadata()
|
||||||
|
.map_err(|error| IOError::with_path(error, &path))?;
|
||||||
|
let created_at = metadata
|
||||||
|
.created()
|
||||||
|
.or_else(|_| metadata.modified())
|
||||||
|
.map(DateTime::<Utc>::from)
|
||||||
|
.map_err(|error| IOError::with_path(error, &path))?;
|
||||||
|
let modified_at = metadata
|
||||||
|
.modified()
|
||||||
|
.map(DateTime::<Utc>::from)
|
||||||
|
.map(|value| value.timestamp_millis())
|
||||||
|
.map_err(|error| IOError::with_path(error, &path))?;
|
||||||
|
let file_size = i64::try_from(metadata.len()).map_err(|_| {
|
||||||
|
crate::ErrorKind::InputError(
|
||||||
|
"Screenshot is too large to index".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
screenshots.push(ScannedScreenshot {
|
||||||
|
file_name,
|
||||||
|
created_at,
|
||||||
|
modified_at,
|
||||||
|
file_size,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(screenshots)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn reconcile_source_screenshots(
|
||||||
|
state: &State,
|
||||||
|
source: &InstanceScreenshotSource,
|
||||||
|
scanned: Vec<ScannedScreenshot>,
|
||||||
|
) -> crate::Result<Vec<InstanceScreenshot>> {
|
||||||
|
let existing =
|
||||||
|
screenshot_rows::list_screenshots(&source.id, &state.pool).await?;
|
||||||
|
let mut unmatched_by_name = existing
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| (row.file_name.clone(), row))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let mut resolved = Vec::with_capacity(scanned.len());
|
||||||
|
let mut needs_hash = Vec::new();
|
||||||
|
|
||||||
|
for scanned in scanned {
|
||||||
|
match unmatched_by_name.remove(&scanned.file_name) {
|
||||||
|
Some(row)
|
||||||
|
if row.file_size == scanned.file_size
|
||||||
|
&& row.modified_at == scanned.modified_at
|
||||||
|
&& row.created_at
|
||||||
|
== scanned.created_at.timestamp_millis() =>
|
||||||
|
{
|
||||||
|
resolved.push(ResolvedScreenshot {
|
||||||
|
scanned,
|
||||||
|
row,
|
||||||
|
is_new: false,
|
||||||
|
changed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
matched => needs_hash.push((scanned, matched)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hashed = stream::iter(needs_hash.into_iter().map(
|
||||||
|
|(mut scanned, matched)| async move {
|
||||||
|
let (file_size, content_hash) =
|
||||||
|
sha1_file_async(&scanned.path).await?;
|
||||||
|
scanned.file_size = i64::try_from(file_size).map_err(|_| {
|
||||||
|
crate::ErrorKind::InputError(
|
||||||
|
"Screenshot is too large to index".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok::<_, crate::Error>((scanned, content_hash, matched))
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.buffer_unordered(SCREENSHOT_HASH_CONCURRENCY)
|
||||||
|
.try_collect::<Vec<_>>()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut unmatched_by_hash =
|
||||||
|
HashMap::<(String, i64), Vec<ScreenshotRow>>::new();
|
||||||
|
for row in unmatched_by_name.into_values() {
|
||||||
|
unmatched_by_hash
|
||||||
|
.entry((row.content_hash.clone(), row.file_size))
|
||||||
|
.or_default()
|
||||||
|
.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (scanned, content_hash, matched_by_name) in hashed {
|
||||||
|
if let Some(row) = matched_by_name {
|
||||||
|
resolved.push(resolve_scanned_screenshot(
|
||||||
|
source,
|
||||||
|
scanned,
|
||||||
|
content_hash,
|
||||||
|
Some(row),
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash_key = (content_hash.clone(), scanned.file_size);
|
||||||
|
let matched = unmatched_by_hash.get_mut(&hash_key).and_then(|rows| {
|
||||||
|
if rows.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let created_at = scanned.created_at.timestamp_millis();
|
||||||
|
let index = rows
|
||||||
|
.iter()
|
||||||
|
.position(|row| {
|
||||||
|
row.modified_at == scanned.modified_at
|
||||||
|
&& row.created_at == created_at
|
||||||
|
})
|
||||||
|
.unwrap_or(rows.len() - 1);
|
||||||
|
Some(rows.swap_remove(index))
|
||||||
|
});
|
||||||
|
resolved.push(resolve_scanned_screenshot(
|
||||||
|
source,
|
||||||
|
scanned,
|
||||||
|
content_hash,
|
||||||
|
matched,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = state.pool.begin().await?;
|
||||||
|
for row in unmatched_by_hash.into_values().flatten() {
|
||||||
|
screenshot_rows::delete_screenshot(&row.id, &mut tx).await?;
|
||||||
|
}
|
||||||
|
for screenshot in &resolved {
|
||||||
|
if !screenshot.is_new && screenshot.changed {
|
||||||
|
let mut row = screenshot.row.clone();
|
||||||
|
row.file_name = Uuid::new_v4().to_string();
|
||||||
|
screenshot_rows::update_screenshot(&row, &mut tx).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for screenshot in &resolved {
|
||||||
|
if screenshot.is_new {
|
||||||
|
screenshot_rows::insert_screenshot(&screenshot.row, &mut tx)
|
||||||
|
.await?;
|
||||||
|
} else if screenshot.changed {
|
||||||
|
screenshot_rows::update_screenshot(&screenshot.row, &mut tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
let mut screenshots = resolved
|
||||||
|
.into_iter()
|
||||||
|
.map(|screenshot| InstanceScreenshot {
|
||||||
|
id: screenshot.row.id,
|
||||||
|
instance_id: source.id.clone(),
|
||||||
|
instance_name: source.name.clone(),
|
||||||
|
file_name: screenshot.scanned.file_name,
|
||||||
|
created_at: screenshot.scanned.created_at,
|
||||||
|
modified_at: screenshot.row.modified_at,
|
||||||
|
group_id: screenshot.row.group_id,
|
||||||
|
path: screenshot.scanned.path,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
sort_screenshots(&mut screenshots);
|
||||||
|
Ok(screenshots)
|
||||||
|
}
|
||||||
@@ -360,6 +360,7 @@ pub enum InstancePayloadType {
|
|||||||
revision: u64,
|
revision: u64,
|
||||||
},
|
},
|
||||||
ServersUpdated,
|
ServersUpdated,
|
||||||
|
ScreenshotsUpdated,
|
||||||
WorldUpdated {
|
WorldUpdated {
|
||||||
world: String,
|
world: String,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,34 @@ use chrono::{DateTime, TimeZone, Utc};
|
|||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use sqlx::{Executor, Sqlite, SqlitePool, Transaction};
|
use sqlx::{Executor, Sqlite, SqlitePool, Transaction};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||||
|
pub(crate) struct InstanceScreenshotSource {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_instance_screenshot_source(
|
||||||
|
instance_id: &str,
|
||||||
|
pool: &SqlitePool,
|
||||||
|
) -> crate::Result<Option<InstanceScreenshotSource>> {
|
||||||
|
Ok(sqlx::query_as::<_, InstanceScreenshotSource>(
|
||||||
|
"SELECT id, name, path FROM instances WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(instance_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list_screenshot_sources(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
) -> crate::Result<Vec<InstanceScreenshotSource>> {
|
||||||
|
Ok(sqlx::query_as::<_, InstanceScreenshotSource>(
|
||||||
|
"SELECT id, name, path FROM instances ORDER BY name, id",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
pub(crate) struct InstanceRow {
|
pub(crate) struct InstanceRow {
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ pub(crate) mod config_sync_rows;
|
|||||||
pub(crate) mod content_rows;
|
pub(crate) mod content_rows;
|
||||||
pub(crate) mod instance_rows;
|
pub(crate) mod instance_rows;
|
||||||
pub(crate) mod loader_component_rows;
|
pub(crate) mod loader_component_rows;
|
||||||
|
pub(crate) mod screenshot_rows;
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
use sqlx::{Sqlite, SqlitePool, Transaction};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||||
|
pub(crate) struct ScreenshotRow {
|
||||||
|
pub id: String,
|
||||||
|
pub instance_id: String,
|
||||||
|
pub file_name: String,
|
||||||
|
pub content_hash: String,
|
||||||
|
pub file_size: i64,
|
||||||
|
pub modified_at: i64,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub group_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||||
|
pub(crate) struct ScreenshotGroupRow {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list_screenshots(
|
||||||
|
instance_id: &str,
|
||||||
|
pool: &SqlitePool,
|
||||||
|
) -> crate::Result<Vec<ScreenshotRow>> {
|
||||||
|
Ok(sqlx::query_as!(
|
||||||
|
ScreenshotRow,
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
screenshots.id,
|
||||||
|
screenshots.instance_id,
|
||||||
|
screenshots.file_name,
|
||||||
|
screenshots.content_hash,
|
||||||
|
screenshots.file_size,
|
||||||
|
screenshots.modified_at,
|
||||||
|
screenshots.created_at,
|
||||||
|
memberships.group_id AS "group_id?"
|
||||||
|
FROM screenshots
|
||||||
|
LEFT JOIN screenshot_group_memberships memberships
|
||||||
|
ON memberships.screenshot_id = screenshots.id
|
||||||
|
WHERE screenshots.instance_id = ?
|
||||||
|
ORDER BY screenshots.file_name, screenshots.id
|
||||||
|
"#,
|
||||||
|
instance_id,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_screenshot_by_key(
|
||||||
|
instance_id: &str,
|
||||||
|
file_name: &str,
|
||||||
|
pool: &SqlitePool,
|
||||||
|
) -> crate::Result<Option<ScreenshotRow>> {
|
||||||
|
Ok(sqlx::query_as!(
|
||||||
|
ScreenshotRow,
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
screenshots.id,
|
||||||
|
screenshots.instance_id,
|
||||||
|
screenshots.file_name,
|
||||||
|
screenshots.content_hash,
|
||||||
|
screenshots.file_size,
|
||||||
|
screenshots.modified_at,
|
||||||
|
screenshots.created_at,
|
||||||
|
memberships.group_id AS "group_id?"
|
||||||
|
FROM screenshots
|
||||||
|
LEFT JOIN screenshot_group_memberships memberships
|
||||||
|
ON memberships.screenshot_id = screenshots.id
|
||||||
|
WHERE screenshots.instance_id = ? AND screenshots.file_name = ?
|
||||||
|
"#,
|
||||||
|
instance_id,
|
||||||
|
file_name,
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn copy_group_membership(
|
||||||
|
source_id: &str,
|
||||||
|
edited_id: &str,
|
||||||
|
tx: &mut Transaction<'_, Sqlite>,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO screenshot_group_memberships (screenshot_id, group_id)
|
||||||
|
SELECT ?, group_id
|
||||||
|
FROM screenshot_group_memberships
|
||||||
|
WHERE screenshot_id = ?
|
||||||
|
ON CONFLICT (screenshot_id) DO UPDATE SET group_id = excluded.group_id
|
||||||
|
"#,
|
||||||
|
edited_id,
|
||||||
|
source_id,
|
||||||
|
)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn insert_screenshot(
|
||||||
|
row: &ScreenshotRow,
|
||||||
|
tx: &mut Transaction<'_, Sqlite>,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
"
|
||||||
|
INSERT INTO screenshots (
|
||||||
|
id,
|
||||||
|
instance_id,
|
||||||
|
file_name,
|
||||||
|
content_hash,
|
||||||
|
file_size,
|
||||||
|
modified_at,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
",
|
||||||
|
row.id,
|
||||||
|
row.instance_id,
|
||||||
|
row.file_name,
|
||||||
|
row.content_hash,
|
||||||
|
row.file_size,
|
||||||
|
row.modified_at,
|
||||||
|
row.created_at,
|
||||||
|
)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn update_screenshot(
|
||||||
|
row: &ScreenshotRow,
|
||||||
|
tx: &mut Transaction<'_, Sqlite>,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
"
|
||||||
|
UPDATE screenshots
|
||||||
|
SET
|
||||||
|
instance_id = ?,
|
||||||
|
file_name = ?,
|
||||||
|
content_hash = ?,
|
||||||
|
file_size = ?,
|
||||||
|
modified_at = ?,
|
||||||
|
created_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
",
|
||||||
|
row.instance_id,
|
||||||
|
row.file_name,
|
||||||
|
row.content_hash,
|
||||||
|
row.file_size,
|
||||||
|
row.modified_at,
|
||||||
|
row.created_at,
|
||||||
|
row.id,
|
||||||
|
)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn delete_screenshot(
|
||||||
|
id: &str,
|
||||||
|
tx: &mut Transaction<'_, Sqlite>,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
sqlx::query!("DELETE FROM screenshots WHERE id = ?", id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn move_screenshot(
|
||||||
|
source_instance_id: &str,
|
||||||
|
source_file_name: &str,
|
||||||
|
target_instance_id: &str,
|
||||||
|
target_file_name: &str,
|
||||||
|
pool: &SqlitePool,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM screenshots WHERE instance_id = ? AND file_name = ?",
|
||||||
|
target_instance_id,
|
||||||
|
target_file_name,
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"
|
||||||
|
UPDATE screenshots
|
||||||
|
SET instance_id = ?, file_name = ?
|
||||||
|
WHERE instance_id = ? AND file_name = ?
|
||||||
|
",
|
||||||
|
target_instance_id,
|
||||||
|
target_file_name,
|
||||||
|
source_instance_id,
|
||||||
|
source_file_name,
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn delete_screenshot_by_key(
|
||||||
|
instance_id: &str,
|
||||||
|
file_name: &str,
|
||||||
|
pool: &SqlitePool,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM screenshots WHERE instance_id = ? AND file_name = ?",
|
||||||
|
instance_id,
|
||||||
|
file_name,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list_groups(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
) -> crate::Result<Vec<ScreenshotGroupRow>> {
|
||||||
|
Ok(sqlx::query_as!(
|
||||||
|
ScreenshotGroupRow,
|
||||||
|
"
|
||||||
|
SELECT id, name
|
||||||
|
FROM screenshot_groups
|
||||||
|
ORDER BY display_order, name, id
|
||||||
|
",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
@@ -120,6 +120,7 @@ pub struct State {
|
|||||||
pub(crate) install_job_cancellations: DashMap<Uuid, CancellationToken>,
|
pub(crate) install_job_cancellations: DashMap<Uuid, CancellationToken>,
|
||||||
pub(crate) install_job_operation_locks:
|
pub(crate) install_job_operation_locks:
|
||||||
DashMap<Uuid, Arc<AsyncMutex<InstallJobOperationState>>>,
|
DashMap<Uuid, Arc<AsyncMutex<InstallJobOperationState>>>,
|
||||||
|
pub(crate) screenshot_locks: DashMap<String, Arc<AsyncMutex<()>>>,
|
||||||
|
|
||||||
/// Process manager
|
/// Process manager
|
||||||
pub process_manager: ProcessManager,
|
pub process_manager: ProcessManager,
|
||||||
@@ -904,6 +905,7 @@ impl State {
|
|||||||
install_db_semaphore: Semaphore::new(1),
|
install_db_semaphore: Semaphore::new(1),
|
||||||
install_job_cancellations: DashMap::new(),
|
install_job_cancellations: DashMap::new(),
|
||||||
install_job_operation_locks: DashMap::new(),
|
install_job_operation_locks: DashMap::new(),
|
||||||
|
screenshot_locks: DashMap::new(),
|
||||||
process_manager,
|
process_manager,
|
||||||
friends_socket,
|
friends_socket,
|
||||||
restart_after_pending_update: AtomicBool::new(false),
|
restart_after_pending_update: AtomicBool::new(false),
|
||||||
@@ -931,6 +933,18 @@ impl State {
|
|||||||
) -> InstanceLockGuard {
|
) -> InstanceLockGuard {
|
||||||
self.instance_locks.lock_exclusive(instance_id).await
|
self.instance_locks.lock_exclusive(instance_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn lock_instance_screenshots(
|
||||||
|
&self,
|
||||||
|
instance_id: &str,
|
||||||
|
) -> tokio::sync::OwnedMutexGuard<()> {
|
||||||
|
self.screenshot_locks
|
||||||
|
.entry(instance_id.to_string())
|
||||||
|
.or_insert_with(|| Arc::new(AsyncMutex::new(())))
|
||||||
|
.clone()
|
||||||
|
.lock_owned()
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -971,6 +985,7 @@ pub(crate) async fn test_state(
|
|||||||
install_db_semaphore: Semaphore::new(1),
|
install_db_semaphore: Semaphore::new(1),
|
||||||
install_job_cancellations: DashMap::new(),
|
install_job_cancellations: DashMap::new(),
|
||||||
install_job_operation_locks: DashMap::new(),
|
install_job_operation_locks: DashMap::new(),
|
||||||
|
screenshot_locks: DashMap::new(),
|
||||||
process_manager: ProcessManager::new(),
|
process_manager: ProcessManager::new(),
|
||||||
friends_socket: FriendsSocket::new(),
|
friends_socket: FriendsSocket::new(),
|
||||||
restart_after_pending_update: AtomicBool::new(false),
|
restart_after_pending_update: AtomicBool::new(false),
|
||||||
|
|||||||
Reference in new issue
Block a user