feat: 移植截图中心后端(索引/分组/编辑保存)
Sync source to CNB / Sync Git ref (push) Has been cancelled

This commit is contained in:
AxTps committed 2026-10-01 21:08:57 +08:00
1 parent e646598078
commit ead5d96b78
13 files changed
+1829 -1

No files matched your search

+1 -1
View File
@@ -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)
}
+1
View File
@@ -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?)
}
+15
View File
@@ -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),