feat: content tab rewrite for worlds (#5136)

* feat: base content card component

* fix: tooltips + colors

* feat: fix orgs

* feat: base content tab internals rewrite

* feat: fix invalidmodal

* feat: add ContentModpackCard

* fix: extract types

* draft: layout

* feat: unlink modal

* feat: impl content tab

* fix: lint

* fix: toggling

* temp: disable updating stuff

* feat: selection v-model

* feat: bulk selection

* feat: mods tab rough draft

* feat: use fuse.js

* feat: add project combobox

* clean up project combobox

* feat: start install to play modal

* fix: events

* feat: use v-on

* feat: bulk actions + fix floating action bar width

* feat: figma alignments

* feat: migrate toggle to tailwind

* fix: row borders

* feat: disabled state

* feat: virtual list impl for card table based on window scroll

* fix: lint

* feat: virtualization + smaller contentcard items

* feat: use ContentCardTable + ContentCardItems

* feat: fix gap + border issues on last elm

* feat: cleanup + use proper searching

* fix: use TeleportOverflowMenu

* fix: fallback to svg if src is invalid on avatar component

* fix: storybook

* feat: start on updater modal

* feat: finish content updater modal

* feat: i18n pass

* feat: impl modal

* feat(app): backend changes for content tab refactor (#5237)

* feat: include_changelog=false for updater modal

* fix: hash overrides

* feat: update checking for modpack

* feat: qa

* feat: modpack content modal

* fix: padding in table to match modals + tightness

* fix: lint

* feat: delete modal

* feat: fix toggle bugs

* fix: prepr

* fix: duplicate messages

* qa: full width search

* qa: use bg-surface-1.5

* qa: animation for filter pills

* qa: standardize hover colors

* fix: border-[1px] is border

* qa: mass de-select actually mass selecting

* qa: match figma designs for floating action bar

* qa: modal fixes

* q: modal fixes x2

* fix: table border

* qa: confirm modals

* qa: modal alignment

* qa: re-add stuck heading + dedupe logic

* qa: dedupe virtual scrolling + remove dead components

* qa: responsiveness for content table + link fixes

* qa: version column link, tooltips + lint fixes

* qa: instance busy protections

* fix: installation freeze bug

* chore: remove old mods page

* refactor: deduplicate layout

* chore: delete old content page(s)

* qa

* qa

* qa

* feat: sort btn - to iterate

* fix: ml

* feat: date added

* fix: lint

* fix: formatting.ts removal

* feat: get_dependencies_as_content_items

* qa: final QA changes

* refactor: deduplicate + polish content.rs

* feat: hook up content.vue with v1

* feat: hide v1 content api behind frontend feature flag

* fix: query keys + copy on empty state

* chore: i18n pass

* feat: reimpl unlink + upload endpoint

* feat: use bulk endpoints v1

* fix: lint

* fix: flags

* fix: responsiveness via container queries

* fix: lint

* qa: 1

* qa: fixes

* qa: fix ssr issues with browse content

* qa: header page divider

* qa: modals

* fix: prepr

* fix: issues

* fix: lint

* fix: toggle v1 ff

* qa: 5

* qa: delete modal copy

* feat: creation flow modals (#5383)

* refactor: delete content v0 usages + impl

* feat: qa + fixes

* feat: installing banner using state event

* feat: fix modpack card bugs + filtering issues

* refactor: delete backups v0 api module

* feat: v1 servers GET endpoint

* fix: backups

* feat: swap to kyros upload v1 addon

* fix: use tanstack for loader.vue

* feat: finish install from discovery modal

* qa: bug fixes

* feat: set up installation settings

* fix: lint

* fix: typos

* fix: bugs

* fix: disable inline content

* feat: content tab improvements — upload UX, installation settings, and client-only indicators

   Upload cancellation and navigation guard:
   - Add ConfirmLeaveModal that prompts when navigating away during upload
   - Cancel in-flight XHR uploads when user confirms leaving the page
   - Add beforeunload handler to warn on browser/tab close during upload
   - Track uploadedBytes/totalBytes in UploadState for progress display
   - Replace Collapsible with Transition for upload progress admonition
   - Show byte progress and percentage in upload banner
   - Clamp upload progress to prevent exceeding 100%

   Installation settings (server.properties):
   - Add KnownPropertiesFields and PropertiesFields types to Archon types
   - Add buildProperties() to creation flow context to collect gamemode,
     difficulty, seed, world type, structures, and generator settings
   - Pass properties through installContent on onboarding, discovery, and
     ServerSetupModal flows

   Server setup and discovery flow improvements:
   - Migrate ServerSetupModal from servers_v0.reinstall to content_v1.installContent
   - Replace loaderApiNames lookup with toApiLoader() helper
   - Remove eraseDataOnInstall toggle — always use soft_override: false
   - Simplify modpack install on discovery page to use first available version
     and route through creation flow modal for both onboarding and non-onboarding
   - Differentiate post-install navigation: content page for onboarding,
     loader options for existing servers

   Modpack update flow:
   - Replace updateModpack() call with installContent() using soft_override: true
     to support version selection in the content updater modal

   Client-only mod indicators:
   - Add environment field to AddonVersion (reuses Labrinth.Projects.v3.Environment)
   - Add environment to ContentItem and isClientOnly to ContentCardTableItem
   - Show orange TriangleAlertIcon with tooltip on client-only mods in content table
   - Add "Client-only" filter pill to content filtering (controlled via
     showClientOnlyFilter on ContentManagerContext)
   - Apply client-only indicators in both ContentPageLayout and ModpackContentModal

   Misc:
   - Add CLAUDE.md note about using prepr commands for lint checks
   - Export ConfirmLeaveModal from instances barrel

* fix: piping

* fix: switch content disable for linked server instances

* feat: client only filter

* fix: prepr

* feat: hasUpdate shape update

* feat: bulk update endpoint impl for content in panel

* feat: websocket state impl again with new phases

* fix: ws

* fix: use timeout fn for sync admon + fix content card layout scroll for browsers with overflow anchor bug

* fix: qa bugs

* fix: lint, a11y and i18n

* refactor: set up layouts folder properly

* fix: linked data cache stuff + lint

* feat: move installationsettings to shared layout

* fix: lint

* fix: issues

* feat: temp fuck staging up

* fix: lockfile

* fix: data sync issues on loader.vue

* fix: lint

* Hide shader configuration files from content list (#5499)

* feat: workaround search problem + split out reset

* fix: qa

* fix: changelog not showing on first open

* fix: qa + optimistic updating improvements

* fix: prepr+lint

* fix: qa

* feat: qa

* fix: lint

* fix: lint

* fix: build

* fix: build

* fix: type errors

* fix: fade and JAVA_HOME passthrough

* feat: qa

* feat: impl diff shit

* fix: qa

* fix: app qa

* feat: update diff modal

* fix: endpoint

* fix: qa

* fix: qa

* fix: use bulk in modpack modal

* feat: abort signal impl + fix issues

* fix: diff modal trunc

* feat: qa

* fix: qa

* feat: tooltip content tab

* fix: prepr

* fix: dismiss on settings btn

* feat: qa

* feat: dont clear handlers on disconnect

* fix: lint

* fix: wrangler + introduce staging-archon env file

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Artyom Ezri <61311568+Artezon@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-03-12 20:24:32 +00:00
committed by GitHub
parent f0224dfff7
commit 7d92e4ec7f
302 changed files with 20016 additions and 12142 deletions

View File

@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "\n SELECT data as \"data?: sqlx::types::Json<CacheValue>\"\n FROM cache\n WHERE data_type = $1 AND id = $2\n ",
"describe": {
"columns": [
{
"name": "data?: sqlx::types::Json<CacheValue>",
"ordinal": 0,
"type_info": "Null"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true
]
},
"hash": "4d66e2bfedb7a31244d24858acf9b73a6289c1a90fb0988c4f5f5f64be01c4ec"
}

View File

@@ -53,3 +53,20 @@ pub async fn purge_cache_types(
Ok(())
}
/// Get versions for a project (without changelogs for fast loading).
/// Uses the cache system with the ProjectVersions cache type.
#[tracing::instrument]
pub async fn get_project_versions(
project_id: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Option<Vec<Version>>> {
let state = crate::State::get().await?;
CachedEntry::get_project_versions(
project_id,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await
}

View File

@@ -18,12 +18,13 @@ pub mod worlds;
pub mod data {
pub use crate::state::{
CacheBehaviour, CacheValueType, Credentials, Dependency, DirectoryInfo,
Hooks, JavaVersion, LinkedData, MemorySettings, ModLoader,
ModrinthCredentials, Organization, ProcessMetadata, ProfileFile,
Project, ProjectType, ProjectV3, SearchResult, SearchResults,
SearchResultsV3, Settings, TeamMember, Theme, User, UserFriend,
Version, WindowSize,
CacheBehaviour, CacheValueType, ContentItem, ContentItemOwner,
ContentItemProject, ContentItemVersion, Credentials, Dependency,
DirectoryInfo, Hooks, JavaVersion, LinkedData, LinkedModpackInfo,
MemorySettings, ModLoader, ModrinthCredentials, Organization,
OwnerType, ProcessMetadata, ProfileFile, Project, ProjectType,
ProjectV3, SearchResult, SearchResults, SearchResultsV3, Settings,
TeamMember, Theme, User, UserFriend, Version, WindowSize,
};
pub use ariadne::users::UserStatus;
}

View File

@@ -182,7 +182,12 @@ pub fn get_default_launcher_path(
Some(dirs::data_dir()?.join("gdlauncher_next"))
}
ImportLauncherType::Curseforge => {
Some(dirs::home_dir()?.join("curseforge").join("minecraft"))
let home = dirs::home_dir()?;
let primary = home.join("curseforge").join("minecraft");
if primary.exists() {
return Some(primary);
}
Some(dirs::document_dir()?.join("curseforge").join("minecraft"))
}
ImportLauncherType::Unknown => None,
};

View File

@@ -1,4 +1,5 @@
use crate::State;
use crate::api::profile;
use crate::data::ModLoader;
use crate::event::emit::{emit_loading, init_loading};
use crate::event::{LoadingBarId, LoadingBarType};
@@ -226,6 +227,24 @@ pub async fn generate_pack_from_version_id(
})?;
emit_loading(&loading_bar, 10.0, None)?;
// Update profile with correct loader and game version from the API version metadata,
// so the UI shows accurate info while the pack file is still downloading.
if let Some(game_version) = version.game_versions.first() {
let loader = version
.loaders
.first()
.map(|l| ModLoader::from_string(l))
.unwrap_or(ModLoader::Vanilla);
let game_version = game_version.clone();
let profile_path_clone = profile_path.clone();
profile::edit(&profile_path_clone, |prof| {
prof.game_version.clone_from(&game_version);
prof.loader = loader;
async { Ok(()) }
})
.await?;
}
let (url, hash) =
if let Some(file) = version.files.iter().find(|x| x.primary) {
Some((file.url.clone(), file.hashes.get("sha1")))
@@ -303,6 +322,12 @@ pub async fn generate_pack_from_version_id(
None
};
// Set the icon immediately so the UI shows it during download.
if let Some(ref icon_path) = icon {
let _ =
profile::edit_icon(&profile_path, Some(icon_path.as_path())).await;
}
Ok(CreatePack {
file,
description: CreatePackDescription {

View File

@@ -8,7 +8,7 @@ use crate::pack::install_from::{
use crate::state::{
CacheBehaviour, CachedEntry, ProfileInstallStage, SideType, cache_file_hash,
};
use crate::util::fetch::{fetch_mirrors, write};
use crate::util::fetch::{fetch_mirrors, sha1_async, write};
use crate::util::io;
use crate::{State, profile};
use async_zip::base::read::seek::ZipFileReader;
@@ -115,6 +115,53 @@ pub async fn install_zipped_mrpack_files(
.into());
}
// Cache the modpack file hashes for later filtering of user-added content
// Includes both manifest file hashes and computed hashes for override files
if let Some(ref version_id) = version_id {
let mut file_hashes: Vec<String> = pack
.files
.iter()
.filter_map(|f| f.hashes.get(&PackFileHash::Sha1).cloned())
.collect();
// Also hash files from overrides folders (these aren't in modrinth.index.json)
let override_entries: Vec<usize> = zip_reader
.file()
.entries()
.iter()
.enumerate()
.filter_map(|(index, entry)| {
let filename = entry.filename().as_str().ok()?;
let is_override = (filename.starts_with("overrides/")
|| filename.starts_with("client-overrides/")
|| filename.starts_with("server-overrides/"))
&& !filename.ends_with('/');
is_override.then_some(index)
})
.collect();
for index in override_entries {
let mut file_bytes = Vec::new();
let mut entry_reader = zip_reader.reader_with_entry(index).await?;
entry_reader.read_to_end_checked(&mut file_bytes).await?;
let hash = sha1_async(bytes::Bytes::from(file_bytes)).await?;
file_hashes.push(hash);
}
tracing::info!(
"Caching {} modpack file hashes for version {}",
file_hashes.len(),
version_id
);
CachedEntry::cache_modpack_files(version_id, file_hashes, &state.pool)
.await?;
} else {
tracing::warn!(
"No version_id available, skipping modpack file hash caching"
);
}
// Sets generated profile attributes to the pack ones (using profile::edit)
set_profile_information(
profile_path.clone(),

View File

@@ -8,8 +8,9 @@ use crate::pack::install_from::{
EnvType, PackDependency, PackFile, PackFileHash, PackFormat,
};
use crate::state::{
CacheBehaviour, CachedEntry, Credentials, JavaVersion, ProcessMetadata,
ProfileFile, ProfileInstallStage, ProjectType, SideType,
CacheBehaviour, CachedEntry, ContentItem, Credentials, Dependency,
JavaVersion, LinkedModpackInfo, ProcessMetadata, ProfileFile,
ProfileInstallStage, ProjectType, SideType,
};
use crate::event::{ProfilePayloadType, emit::emit_profile};
@@ -93,6 +94,119 @@ pub async fn get_projects(
}
}
#[tracing::instrument]
pub async fn get_installed_project_ids(
path: &str,
) -> crate::Result<Vec<String>> {
let state = State::get().await?;
if let Some(profile) = get(path).await? {
let ids = profile
.get_installed_project_ids(&state.pool, &state.api_semaphore)
.await?;
Ok(ids)
} else {
Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
.as_error())
}
}
/// Get content items with rich metadata for a profile
///
/// Returns content items filtered to exclude modpack files (if linked),
/// sorted alphabetically by project name.
#[tracing::instrument]
pub async fn get_content_items(
path: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Vec<ContentItem>> {
let state = State::get().await?;
if let Some(profile) = get(path).await? {
let items = crate::state::get_content_items(
&profile,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await?;
Ok(items)
} else {
Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
.as_error())
}
}
/// Get content items that are part of the linked modpack
///
/// Returns the modpack's dependencies as ContentItem list.
/// Returns empty vec if the profile is not linked to a modpack.
#[tracing::instrument]
pub async fn get_linked_modpack_content(
path: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Vec<ContentItem>> {
let state = State::get().await?;
if let Some(profile) = get(path).await? {
let items = crate::state::get_linked_modpack_content(
&profile,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await?;
Ok(items)
} else {
Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
.as_error())
}
}
/// Convert a list of dependencies into ContentItems with rich metadata
#[tracing::instrument]
pub async fn get_dependencies_as_content_items(
dependencies: Vec<Dependency>,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Vec<ContentItem>> {
let state = State::get().await?;
let items = crate::state::dependencies_to_content_items(
&dependencies,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await?;
Ok(items)
}
/// Get linked modpack info for a profile
///
/// Returns project, version, and owner information for the linked modpack,
/// or None if the profile is not linked to a modpack.
#[tracing::instrument]
pub async fn get_linked_modpack_info(
path: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Option<LinkedModpackInfo>> {
let state = State::get().await?;
if let Some(profile) = get(path).await? {
let info = crate::state::get_linked_modpack_info(
&profile,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await?;
Ok(info)
} else {
Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
.as_error())
}
}
/// Get profile's full path in the filesystem
#[tracing::instrument]
pub async fn get_full_path(path: &str) -> crate::Result<PathBuf> {

View File

@@ -36,6 +36,9 @@ pub enum CacheValueType {
FileUpdate,
SearchResults,
SearchResultsV3,
ModpackFiles,
/// Cached list of versions for a project (without changelogs for fast loading)
ProjectVersions,
}
impl CacheValueType {
@@ -59,6 +62,8 @@ impl CacheValueType {
CacheValueType::FileUpdate => "file_update",
CacheValueType::SearchResults => "search_results",
CacheValueType::SearchResultsV3 => "search_results_v3",
CacheValueType::ModpackFiles => "modpack_files",
CacheValueType::ProjectVersions => "project_versions",
}
}
@@ -82,6 +87,8 @@ impl CacheValueType {
"file_update" => CacheValueType::FileUpdate,
"search_results" => CacheValueType::SearchResults,
"search_results_v3" => CacheValueType::SearchResultsV3,
"modpack_files" => CacheValueType::ModpackFiles,
"project_versions" => CacheValueType::ProjectVersions,
_ => CacheValueType::Project,
}
}
@@ -91,7 +98,10 @@ impl CacheValueType {
match self {
CacheValueType::File => 30 * 24 * 60 * 60, // 30 days
CacheValueType::FileHash => 30 * 24 * 60 * 60, // 30 days
_ => 30 * 60, // 30 minutes
// ModpackFiles never expire - version_id is immutable so hashes never change
// TODO: There has to be a way to exclude this from the "Purge cache" stuff?
CacheValueType::ModpackFiles => 100 * 365 * 24 * 60 * 60, // 100 years (effectively never)
_ => 30 * 60, // 30 minutes
}
}
@@ -126,11 +136,27 @@ impl CacheValueType {
| CacheValueType::LoaderManifest
| CacheValueType::FileUpdate
| CacheValueType::SearchResults
| CacheValueType::SearchResultsV3 => None,
| CacheValueType::SearchResultsV3
| CacheValueType::ModpackFiles
| CacheValueType::ProjectVersions => None,
}
}
}
/// Cached modpack file hashes for filtering content
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CachedModpackFiles {
pub version_id: String,
pub file_hashes: Vec<String>,
}
/// Cached list of versions for a project (without changelogs for fast loading)
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CachedProjectVersions {
pub project_id: String,
pub versions: Vec<Version>,
}
// De/serialization strategy:
// - on serialize:
// - in the `cache` table, save the `data_type` (variant of this value) alongside
@@ -165,6 +191,8 @@ pub enum CacheValue {
FileUpdate(CachedFileUpdate),
SearchResults(SearchResults),
SearchResultsV3(SearchResultsV3),
ModpackFiles(CachedModpackFiles),
ProjectVersions(CachedProjectVersions),
ProjectV3(ProjectV3),
}
@@ -349,7 +377,8 @@ pub struct Version {
pub name: String,
pub version_number: String,
pub changelog: String,
#[serde(default)]
pub changelog: Option<String>,
pub changelog_url: Option<String>,
pub date_published: DateTime<Utc>,
@@ -499,6 +528,8 @@ impl CacheValue {
CacheValue::FileUpdate(_) => CacheValueType::FileUpdate,
CacheValue::SearchResults(_) => CacheValueType::SearchResults,
CacheValue::SearchResultsV3(_) => CacheValueType::SearchResultsV3,
CacheValue::ModpackFiles(_) => CacheValueType::ModpackFiles,
CacheValue::ProjectVersions(_) => CacheValueType::ProjectVersions,
}
}
@@ -541,6 +572,8 @@ impl CacheValue {
}
CacheValue::SearchResults(search) => search.search.clone(),
CacheValue::SearchResultsV3(search) => search.search.clone(),
CacheValue::ModpackFiles(files) => files.version_id.clone(),
CacheValue::ProjectVersions(pv) => pv.project_id.clone(),
}
}
@@ -567,7 +600,9 @@ impl CacheValue {
| CacheValue::LoaderManifest { .. }
| CacheValue::FileUpdate(_)
| CacheValue::SearchResults(_)
| CacheValue::SearchResultsV3(_) => None,
| CacheValue::SearchResultsV3(_)
| CacheValue::ModpackFiles(_)
| CacheValue::ProjectVersions(_) => None,
}
}
@@ -601,6 +636,8 @@ impl CacheValue {
CacheValue::FileUpdate(update) => serde_json::to_value(update),
CacheValue::SearchResults(search) => serde_json::to_value(search),
CacheValue::SearchResultsV3(search) => serde_json::to_value(search),
CacheValue::ModpackFiles(files) => serde_json::to_value(files),
CacheValue::ProjectVersions(pv) => serde_json::to_value(pv),
}
.map_err(|err| {
crate::ErrorKind::OtherError(format!(
@@ -1515,6 +1552,56 @@ impl CachedEntry {
})
.collect()
}
CacheValueType::ModpackFiles => {
// ModpackFiles are only stored locally during modpack installation,
// not fetched from an external API
vec![]
}
CacheValueType::ProjectVersions => {
let mut values = vec![];
for key in keys {
let project_id = key.to_string();
let url = format!(
"{}project/{}/version?include_changelog=false",
env!("MODRINTH_API_URL"),
project_id
);
match fetch_json::<Vec<Version>>(
Method::GET,
&url,
None,
None,
fetch_semaphore,
pool,
)
.await
{
Ok(versions) => {
values.push((
CacheValue::ProjectVersions(
CachedProjectVersions {
project_id,
versions,
},
)
.get_entry(),
true,
));
}
Err(e) => {
tracing::warn!(
"Failed to fetch versions for project {}: {:?}",
project_id,
e
);
}
}
}
values
}
CacheValueType::SearchResultsV3 => {
let fetch_urls = keys
.iter()
@@ -1628,6 +1715,12 @@ impl CachedEntry {
CacheValueType::SearchResultsV3 => CacheValue::SearchResultsV3(
parse(data, id, "search_results_v3")?,
),
CacheValueType::ModpackFiles => {
CacheValue::ModpackFiles(parse(data, id, "modpack_files")?)
}
CacheValueType::ProjectVersions => CacheValue::ProjectVersions(
parse(data, id, "project_versions")?,
),
};
Ok(value)
@@ -1700,6 +1793,83 @@ impl CachedEntry {
Ok(())
}
/// Store modpack file hashes in cache
pub async fn cache_modpack_files(
version_id: &str,
file_hashes: Vec<String>,
pool: &SqlitePool,
) -> crate::Result<()> {
let data = CachedModpackFiles {
version_id: version_id.to_string(),
file_hashes,
};
let entry = CachedEntry {
id: version_id.to_string(),
alias: None,
expires: Utc::now().timestamp()
+ CacheValueType::ModpackFiles.expiry(),
type_: CacheValueType::ModpackFiles,
data: Some(CacheValue::ModpackFiles(data)),
};
Self::upsert_many(&[entry], pool).await
}
/// Get modpack file hashes from cache
pub async fn get_modpack_files(
version_id: &str,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Option<CachedModpackFiles>> {
let entry = Self::get(
CacheValueType::ModpackFiles,
version_id,
None,
pool,
fetch_semaphore,
)
.await?;
if let Some(CachedEntry {
data: Some(CacheValue::ModpackFiles(files)),
..
}) = entry
{
return Ok(Some(files));
}
Ok(None)
}
/// Get versions for a project (without changelogs for fast loading)
#[tracing::instrument(skip(pool, fetch_semaphore))]
pub async fn get_project_versions(
project_id: &str,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Option<Vec<Version>>> {
let entry = Self::get(
CacheValueType::ProjectVersions,
project_id,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
if let Some(CachedEntry {
data: Some(CacheValue::ProjectVersions(pv)),
..
}) = entry
{
return Ok(Some(pv.versions));
}
Ok(None)
}
}
pub async fn cache_file_hash(

View File

@@ -0,0 +1,887 @@
//! # Content API
//!
//! ## Data Flow
//!
//! 1. Frontend calls `get_content_items(profile_path)`
//! 2. Backend fetches all installed files via `Profile::get_projects()`
//! 3. If profile is linked to a modpack:
//! - Fetch modpack file hashes from cache (populated during installation)
//! - Fallback: re-download .mrpack if cache miss (cleared/expired)
//! - Filter out files that belong to the modpack
//! 4. For remaining files, fetch project/version/owner metadata in parallel
//! 5. Return sorted `ContentItem` list
//!
//! ## Caching
//!
//! Modpack file hashes are cached in `CacheValueType::ModpackFiles`
//! during modpack installation. The cache never expires (version_id is
//! immutable), so re-download is only needed if cache was cleared or
//! profile predates this caching mechanism.
use crate::pack::install_from::{PackFileHash, PackFormat};
use crate::state::profiles::{Profile, ProfileFile, ProjectType};
use crate::state::{CacheBehaviour, CachedEntry};
use crate::util::fetch::{FetchSemaphore, fetch_mirrors, sha1_async};
use async_zip::base::read::seek::ZipFileReader;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::collections::HashSet;
use std::io::Cursor;
/// Content item with rich metadata for frontend display
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ContentItem {
/// Unique identifier (the file name)
pub file_name: String,
/// Relative path to the file within the profile
pub file_path: String,
/// SHA1 hash of the file
pub hash: String,
/// File size in bytes
pub size: u64,
/// Whether the file is enabled (not .disabled)
pub enabled: bool,
/// Type of project (mod, resourcepack, etc.)
pub project_type: ProjectType,
/// Modrinth project info if recognized
pub project: Option<ContentItemProject>,
/// Version info if recognized
pub version: Option<ContentItemVersion>,
/// Owner info (organization or user)
pub owner: Option<ContentItemOwner>,
/// Whether an update is available
pub has_update: bool,
/// The recommended version ID to update to (if has_update is true)
pub update_version_id: Option<String>,
/// When the file was added to the instance (file modification time)
pub date_added: Option<String>,
}
/// Project information for content item display
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ContentItemProject {
pub id: String,
pub slug: Option<String>,
pub title: String,
pub icon_url: Option<String>,
}
/// Version information for content item display
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ContentItemVersion {
pub id: String,
pub version_number: String,
pub file_name: String,
pub date_published: Option<String>,
}
/// Owner information for content item display
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ContentItemOwner {
pub id: String,
pub name: String,
pub avatar_url: Option<String>,
#[serde(rename = "type")]
pub owner_type: OwnerType,
}
/// Type of content owner
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum OwnerType {
User,
Organization,
}
use crate::state::cache::{Dependency, Organization, TeamMember};
use crate::state::{Project, Version};
/// Full linked modpack information including owner and update status
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LinkedModpackInfo {
pub project: Project,
pub version: Version,
pub owner: Option<ContentItemOwner>,
/// Whether an update is available for this modpack
pub has_update: bool,
/// The version ID to update to (if has_update is true)
pub update_version_id: Option<String>,
/// The full version info for the update (if has_update is true)
pub update_version: Option<Version>,
}
/// Get linked modpack info including project, version, owner, and update status.
/// Returns None if the profile is not linked to a modpack.
pub async fn get_linked_modpack_info(
profile: &Profile,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Option<LinkedModpackInfo>> {
let Some(linked_data) = &profile.linked_data else {
return Ok(None);
};
// Vanilla server projects have linked_data with an empty version_id
if linked_data.version_id.is_empty() {
return Ok(None);
}
// Fetch project, version, and all project versions in parallel
let (project, version, all_versions) = tokio::try_join!(
CachedEntry::get_project(
&linked_data.project_id,
cache_behaviour,
pool,
fetch_semaphore,
),
CachedEntry::get_version(
&linked_data.version_id,
cache_behaviour,
pool,
fetch_semaphore,
),
CachedEntry::get_project_versions(
&linked_data.project_id,
cache_behaviour,
pool,
fetch_semaphore,
),
)?;
let version = version.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Linked modpack version {} not found",
linked_data.version_id
))
})?;
// For server instances, linked_data.project_id is the server project,
// but the version may belong to a different (modpack) project.
// If so, fetch the actual modpack project for display and update checking.
let (project, all_versions) =
if version.project_id != linked_data.project_id {
let (modpack_project, modpack_versions) = tokio::try_join!(
CachedEntry::get_project(
&version.project_id,
cache_behaviour,
pool,
fetch_semaphore,
),
CachedEntry::get_project_versions(
&version.project_id,
cache_behaviour,
pool,
fetch_semaphore,
),
)?;
(modpack_project.or(project), modpack_versions)
} else {
(project, all_versions)
};
let project = project.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Linked modpack project {} not found",
linked_data.project_id
))
})?;
// Resolve owner - prefer organization, fall back to team owner
let owner = if let Some(org_id) = &project.organization {
let org = CachedEntry::get_organization(
org_id,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
org.map(|o| ContentItemOwner {
id: o.id,
name: o.name,
avatar_url: o.icon_url,
owner_type: OwnerType::Organization,
})
} else {
let team = CachedEntry::get_team(
&project.team,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
team.and_then(|t| {
t.into_iter()
.find(|m| m.is_owner)
.map(|m| ContentItemOwner {
id: m.user.id,
name: m.user.username,
avatar_url: m.user.avatar_url,
owner_type: OwnerType::User,
})
})
};
// Check for updates
let (has_update, update_version_id, update_version) = check_modpack_update(
profile,
&linked_data.version_id,
&version,
all_versions,
);
Ok(Some(LinkedModpackInfo {
project,
version,
owner,
has_update,
update_version_id,
update_version,
}))
}
/// Check if a newer compatible version exists for the linked modpack.
/// Returns (has_update, update_version_id, update_version).
fn check_modpack_update(
profile: &Profile,
installed_version_id: &str,
installed_version: &Version,
all_versions: Option<Vec<Version>>,
) -> (bool, Option<String>, Option<Version>) {
let Some(versions) = all_versions else {
return (false, None, None);
};
// Get the loader as a string for comparison
let loader_str = profile.loader.as_str().to_lowercase();
let game_version = &profile.game_version;
// Filter to compatible versions
let mut compatible_versions: Vec<&Version> = versions
.iter()
.filter(|v| {
// Must support the profile's game version
let supports_game = v.game_versions.contains(game_version);
// Must support the profile's loader
// The v2 API replaces "mrpack" with actual loaders from mrpack_loaders,
// but if mrpack_loaders is missing, loaders may be just ["mrpack"].
// In that case we can't filter by loader, so accept the version.
let real_loaders: Vec<_> = v
.loaders
.iter()
.filter(|l| l.to_lowercase() != "mrpack")
.collect();
let supports_loader = real_loaders.is_empty()
|| real_loaders.iter().any(|l| l.to_lowercase() == loader_str);
supports_game && supports_loader
})
.collect();
// Sort by date_published descending (newest first)
compatible_versions.sort_by(|a, b| b.date_published.cmp(&a.date_published));
// Find the newest compatible version
if let Some(newest) = compatible_versions.first() {
// Check if the newest version is different and newer than installed
if newest.id != installed_version_id
&& newest.date_published > installed_version.date_published
{
return (true, Some(newest.id.clone()), Some((*newest).clone()));
}
}
(false, None, None)
}
/// Get content items with rich metadata, filtered to exclude modpack content.
/// Returns only user-added content (not part of the linked modpack).
pub async fn get_content_items(
profile: &Profile,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Vec<ContentItem>> {
let all_files = profile
.get_projects(cache_behaviour, pool, fetch_semaphore)
.await?;
let modpack_hashes: HashSet<String> = if let Some(ref linked_data) =
profile.linked_data
{
if linked_data.version_id.is_empty() {
HashSet::new()
} else {
tracing::info!(
"Fetching modpack file hashes for version_id={}, project_id={}",
linked_data.version_id,
linked_data.project_id
);
match get_modpack_file_hashes(
&linked_data.version_id,
pool,
fetch_semaphore,
)
.await
{
Ok(hashes) => {
tracing::info!(
"Got {} modpack file hashes for version {}",
hashes.len(),
linked_data.version_id
);
hashes
}
Err(e) => {
tracing::error!(
"Failed to fetch modpack file hashes for version {}: {}",
linked_data.version_id,
e
);
HashSet::new()
}
}
}
} else {
HashSet::new()
};
let user_files: Vec<(String, ProfileFile)> = all_files
.into_iter()
.filter(|(_, file)| !modpack_hashes.contains(&file.hash))
.collect();
profile_files_to_content_items(
&profile.path,
&user_files,
cache_behaviour,
pool,
fetch_semaphore,
)
.await
}
/// Pre-fetched metadata for projects, versions, teams, and organizations.
struct ResolvedMetadata {
projects: Vec<Project>,
versions: Vec<Version>,
teams: Vec<Vec<TeamMember>>,
organizations: Vec<Organization>,
}
/// Fetch project, version, team, and organization metadata in parallel batches.
async fn resolve_metadata(
project_ids: &HashSet<String>,
version_ids: &HashSet<String>,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<ResolvedMetadata> {
let project_ids_vec: Vec<&str> =
project_ids.iter().map(|s| s.as_str()).collect();
let version_ids_vec: Vec<&str> =
version_ids.iter().map(|s| s.as_str()).collect();
let (projects, versions) =
if !project_ids.is_empty() || !version_ids.is_empty() {
tokio::try_join!(
async {
if project_ids.is_empty() {
Ok(Vec::new())
} else {
CachedEntry::get_project_many(
&project_ids_vec,
cache_behaviour,
pool,
fetch_semaphore,
)
.await
}
},
async {
if version_ids.is_empty() {
Ok(Vec::new())
} else {
CachedEntry::get_version_many(
&version_ids_vec,
cache_behaviour,
pool,
fetch_semaphore,
)
.await
}
}
)?
} else {
(Vec::new(), Vec::new())
};
let team_ids: HashSet<String> =
projects.iter().map(|p| p.team.clone()).collect();
let org_ids: HashSet<String> = projects
.iter()
.filter_map(|p| p.organization.clone())
.collect();
let team_ids_vec: Vec<&str> = team_ids.iter().map(|s| s.as_str()).collect();
let org_ids_vec: Vec<&str> = org_ids.iter().map(|s| s.as_str()).collect();
let (teams, organizations) = if !team_ids.is_empty() || !org_ids.is_empty()
{
tokio::try_join!(
async {
if team_ids.is_empty() {
Ok(Vec::new())
} else {
CachedEntry::get_team_many(
&team_ids_vec,
cache_behaviour,
pool,
fetch_semaphore,
)
.await
}
},
async {
if org_ids.is_empty() {
Ok(Vec::new())
} else {
CachedEntry::get_organization_many(
&org_ids_vec,
cache_behaviour,
pool,
fetch_semaphore,
)
.await
}
}
)?
} else {
(Vec::new(), Vec::new())
};
Ok(ResolvedMetadata {
projects,
versions,
teams,
organizations,
})
}
/// Shared helper: convert profile files to ContentItems with rich metadata.
/// Used by both `get_content_items` (user-added files) and
/// `get_linked_modpack_content` (modpack-bundled files).
async fn profile_files_to_content_items(
profile_path: &str,
files: &[(String, ProfileFile)],
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Vec<ContentItem>> {
let project_ids: HashSet<String> = files
.iter()
.filter_map(|(_, f)| f.metadata.as_ref().map(|m| m.project_id.clone()))
.collect();
let version_ids: HashSet<String> = files
.iter()
.filter_map(|(_, f)| f.metadata.as_ref().map(|m| m.version_id.clone()))
.collect();
let meta = resolve_metadata(
&project_ids,
&version_ids,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
let profile_base_path =
crate::api::profile::get_full_path(profile_path).await?;
// Batch-read file modification times off the main async runtime
let paths: Vec<std::path::PathBuf> = files
.iter()
.map(|(path, _)| profile_base_path.join(path))
.collect();
let modification_times: Vec<Option<String>> =
tokio::task::spawn_blocking(move || {
paths
.iter()
.map(|path| {
std::fs::metadata(path).and_then(|m| m.modified()).ok().map(
|t| {
chrono::DateTime::<chrono::Utc>::from(t)
.to_rfc3339()
},
)
})
.collect()
})
.await?;
let mut items: Vec<ContentItem> = files
.iter()
.enumerate()
.map(|(i, (path, file))| {
let project = file.metadata.as_ref().and_then(|m| {
meta.projects.iter().find(|p| p.id == m.project_id)
});
let version = file.metadata.as_ref().and_then(|m| {
meta.versions.iter().find(|v| v.id == m.version_id)
});
let owner = project.and_then(|p| {
resolve_owner(p, &meta.teams, &meta.organizations)
});
ContentItem {
file_name: file.file_name.clone(),
file_path: path.clone(),
hash: file.hash.clone(),
size: file.size,
enabled: !file.file_name.ends_with(".disabled"),
project_type: file.project_type,
project: project.map(|p| ContentItemProject {
id: p.id.clone(),
slug: p.slug.clone(),
title: p.title.clone(),
icon_url: p.icon_url.clone(),
}),
version: version.map(|v| ContentItemVersion {
id: v.id.clone(),
version_number: v.version_number.clone(),
file_name: file.file_name.clone(),
date_published: Some(v.date_published.to_rfc3339()),
}),
owner,
has_update: file.update_version_id.is_some(),
update_version_id: file.update_version_id.clone(),
date_added: modification_times[i].clone(),
}
})
.collect();
items.sort_by(|a, b| {
let name_a = a
.project
.as_ref()
.map(|p| p.title.as_str())
.unwrap_or(&a.file_name);
let name_b = b
.project
.as_ref()
.map(|p| p.title.as_str())
.unwrap_or(&b.file_name);
name_a.to_lowercase().cmp(&name_b.to_lowercase())
});
Ok(items)
}
/// Resolve the owner of a project from pre-fetched teams and organizations.
fn resolve_owner(
project: &Project,
teams: &[Vec<TeamMember>],
organizations: &[Organization],
) -> Option<ContentItemOwner> {
if let Some(org_id) = &project.organization {
organizations.iter().find(|o| &o.id == org_id).map(|o| {
ContentItemOwner {
id: o.id.clone(),
name: o.name.clone(),
avatar_url: o.icon_url.clone(),
owner_type: OwnerType::Organization,
}
})
} else {
teams
.iter()
.find(|t| t.first().is_some_and(|m| m.team_id == project.team))
.and_then(|t| t.iter().find(|m| m.is_owner))
.map(|m| ContentItemOwner {
id: m.user.id.clone(),
name: m.user.username.clone(),
avatar_url: m.user.avatar_url.clone(),
owner_type: OwnerType::User,
})
}
}
/// Get content items that are part of the linked modpack (not user-added).
/// Returns modpack-bundled files with full on-disk metadata (file_path, enabled, etc).
/// Returns empty vec if the profile is not linked to a modpack.
pub async fn get_linked_modpack_content(
profile: &Profile,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Vec<ContentItem>> {
let Some(linked_data) = &profile.linked_data else {
return Ok(Vec::new());
};
let all_files = profile
.get_projects(cache_behaviour, pool, fetch_semaphore)
.await?;
let modpack_hashes: HashSet<String> = match get_modpack_file_hashes(
&linked_data.version_id,
pool,
fetch_semaphore,
)
.await
{
Ok(hashes) => hashes,
Err(e) => {
tracing::warn!("Failed to fetch modpack file hashes: {}", e);
return Ok(Vec::new());
}
};
// Inverse of get_content_items: keep only modpack-bundled files
let modpack_files: Vec<(String, ProfileFile)> = all_files
.into_iter()
.filter(|(_, file)| modpack_hashes.contains(&file.hash))
.collect();
profile_files_to_content_items(
&profile.path,
&modpack_files,
cache_behaviour,
pool,
fetch_semaphore,
)
.await
}
/// Convert a list of dependencies into ContentItems with rich metadata.
/// Fetches project, version, and owner info for each dependency.
pub async fn dependencies_to_content_items(
dependencies: &[Dependency],
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Vec<ContentItem>> {
let project_ids: HashSet<String> = dependencies
.iter()
.filter_map(|d| d.project_id.clone())
.collect();
if project_ids.is_empty() {
return Ok(Vec::new());
}
let version_ids: HashSet<String> = dependencies
.iter()
.filter_map(|d| d.version_id.clone())
.collect();
let meta = resolve_metadata(
&project_ids,
&version_ids,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
let mut items: Vec<ContentItem> = dependencies
.iter()
.filter_map(|dep| {
let project_id = dep.project_id.as_ref()?;
let project = meta.projects.iter().find(|p| &p.id == project_id)?;
let version = dep
.version_id
.as_ref()
.and_then(|vid| meta.versions.iter().find(|v| &v.id == vid));
let owner =
resolve_owner(project, &meta.teams, &meta.organizations);
let project_type = match project.project_type.as_str() {
"mod" => ProjectType::Mod,
"resourcepack" => ProjectType::ResourcePack,
"shader" => ProjectType::ShaderPack,
"datapack" => ProjectType::DataPack,
_ => ProjectType::Mod,
};
Some(ContentItem {
file_name: version
.and_then(|v| v.files.first())
.map(|f| f.filename.clone())
.unwrap_or_else(|| {
format!(
"{}.jar",
project.slug.as_deref().unwrap_or(&project.id)
)
}),
file_path: String::new(),
hash: String::new(),
size: version
.and_then(|v| v.files.first())
.map(|f| f.size as u64)
.unwrap_or(0),
enabled: true,
project_type,
project: Some(ContentItemProject {
id: project.id.clone(),
slug: project.slug.clone(),
title: project.title.clone(),
icon_url: project.icon_url.clone(),
}),
version: version.map(|v| ContentItemVersion {
id: v.id.clone(),
version_number: v.version_number.clone(),
file_name: v
.files
.first()
.map(|f| f.filename.clone())
.unwrap_or_default(),
date_published: Some(v.date_published.to_rfc3339()),
}),
owner,
has_update: false,
update_version_id: None,
date_added: None,
})
})
.collect();
items.sort_by(|a, b| {
let name_a = a
.project
.as_ref()
.map(|p| p.title.as_str())
.unwrap_or(&a.file_name);
let name_b = b
.project
.as_ref()
.map(|p| p.title.as_str())
.unwrap_or(&b.file_name);
name_a.to_lowercase().cmp(&name_b.to_lowercase())
});
Ok(items)
}
/// Gets SHA1 hashes of all files in a modpack version.
/// Checks cache first, falls back to downloading mrpack if not cached.
async fn get_modpack_file_hashes(
version_id: &str,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<HashSet<String>> {
if let Some(cached) =
CachedEntry::get_modpack_files(version_id, pool, fetch_semaphore)
.await?
{
tracing::info!(
"Cache hit: {} modpack file hashes for version {}",
cached.file_hashes.len(),
version_id
);
return Ok(cached.file_hashes.into_iter().collect());
}
tracing::warn!(
"Cache miss: modpack files not cached, downloading mrpack for version {}",
version_id
);
let version =
CachedEntry::get_version(version_id, None, pool, fetch_semaphore)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Modpack version {version_id} not found"
))
})?;
let primary_file = version
.files
.iter()
.find(|f| f.primary)
.or_else(|| version.files.first())
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"No files found for modpack version {version_id}"
))
})?;
let mrpack_bytes = fetch_mirrors(
&[&primary_file.url],
primary_file.hashes.get("sha1").map(|s| s.as_str()),
fetch_semaphore,
pool,
)
.await?;
let reader = Cursor::new(&mrpack_bytes);
let mut zip_reader =
ZipFileReader::with_tokio(reader).await.map_err(|_| {
crate::ErrorKind::InputError(
"Failed to read modpack zip".to_string(),
)
})?;
let manifest_idx = zip_reader
.file()
.entries()
.iter()
.position(|f| {
matches!(f.filename().as_str(), Ok("modrinth.index.json"))
})
.ok_or_else(|| {
crate::ErrorKind::InputError(
"No modrinth.index.json found in mrpack".to_string(),
)
})?;
let mut manifest = String::new();
let mut entry_reader = zip_reader.reader_with_entry(manifest_idx).await?;
entry_reader.read_to_string_checked(&mut manifest).await?;
let pack: PackFormat = serde_json::from_str(&manifest)?;
let mut hashes: Vec<String> = pack
.files
.iter()
.filter_map(|f| f.hashes.get(&PackFileHash::Sha1).cloned())
.collect();
// Also hash files from overrides folders (these aren't in modrinth.index.json)
let override_entries: Vec<usize> = zip_reader
.file()
.entries()
.iter()
.enumerate()
.filter_map(|(index, entry)| {
let filename = entry.filename().as_str().ok()?;
let is_override = (filename.starts_with("overrides/")
|| filename.starts_with("client-overrides/")
|| filename.starts_with("server-overrides/"))
&& !filename.ends_with('/');
is_override.then_some(index)
})
.collect();
for index in override_entries {
let mut file_bytes = Vec::new();
let mut entry_reader = zip_reader.reader_with_entry(index).await?;
entry_reader.read_to_end_checked(&mut file_bytes).await?;
let hash = sha1_async(bytes::Bytes::from(file_bytes)).await?;
hashes.push(hash);
}
CachedEntry::cache_modpack_files(version_id, hashes.clone(), pool).await?;
Ok(hashes.into_iter().collect())
}

View File

@@ -0,0 +1,4 @@
//! Instance-related modules for profile/instance management.
mod content;
pub use self::content::*;

View File

@@ -622,7 +622,7 @@ impl From<LegacyModrinthVersion> for Version {
featured: value.featured,
name: value.name,
version_number: value.version_number,
changelog: value.changelog,
changelog: Some(value.changelog),
changelog_url: value.changelog_url,
date_published: value.date_published,
downloads: value.downloads,

View File

@@ -13,6 +13,9 @@ pub use self::dirs::*;
mod profiles;
pub use self::profiles::*;
mod instances;
pub use self::instances::*;
mod settings;
pub use self::settings::*;

View File

@@ -2,7 +2,7 @@ use super::settings::{Hooks, MemorySettings, WindowSize};
use crate::profile::get_full_path;
use crate::state::server_join_log::JoinLogEntry;
use crate::state::{
CacheBehaviour, CachedEntry, CachedFileHash, cache_file_hash,
CacheBehaviour, CachedEntry, CachedFile, CachedFileHash, cache_file_hash,
};
use crate::util;
use crate::util::fetch::{FetchSemaphore, IoSemaphore, write_cached_icon};
@@ -409,6 +409,14 @@ macro_rules! select_profiles_with_predicate {
};
}
struct InitialScanFile {
path: String,
file_name: String,
project_type: ProjectType,
size: u64,
cache_key: String,
}
impl Profile {
pub async fn get(
path: &str,
@@ -640,6 +648,8 @@ impl Profile {
&& let Some(file_name) = subdirectory
.file_name()
.and_then(|x| x.to_str())
&& !(project_type == ProjectType::ShaderPack
&& file_name.ends_with(".txt"))
{
let file_size = subdirectory
.metadata()
@@ -909,63 +919,8 @@ impl Profile {
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<DashMap<String, ProfileFile>> {
let path = crate::api::profile::get_full_path(&self.path).await?;
struct InitialScanFile {
path: String,
file_name: String,
project_type: ProjectType,
size: u64,
cache_key: String,
}
let mut keys = vec![];
for project_type in ProjectType::iterator() {
let folder = project_type.get_folder();
let path = path.join(folder);
if path.exists() {
for subdirectory in std::fs::read_dir(&path)
.map_err(|e| io::IOError::with_path(e, &path))?
{
let subdirectory =
subdirectory.map_err(io::IOError::from)?.path();
if subdirectory.is_file()
&& let Some(file_name) =
subdirectory.file_name().and_then(|x| x.to_str())
{
let file_size = subdirectory
.metadata()
.map_err(io::IOError::from)?
.len();
keys.push(InitialScanFile {
path: format!(
"{}/{folder}/{}",
self.path,
file_name.trim_end_matches(".disabled")
),
file_name: file_name.to_string(),
project_type,
size: file_size,
cache_key: format!(
"{file_size}-{}/{folder}/{file_name}",
self.path
),
});
}
}
}
}
let file_hashes = CachedEntry::get_file_hash_many(
&keys.iter().map(|s| &*s.cache_key).collect::<Vec<_>>(),
None,
pool,
fetch_semaphore,
)
.await?;
let (keys, file_hashes) =
self.scan_and_hash(pool, fetch_semaphore).await?;
let file_updates = file_hashes
.iter()
@@ -976,7 +931,7 @@ impl Profile {
file_hashes.iter().map(|x| &*x.hash).collect::<Vec<_>>();
let file_updates_ref =
file_updates.iter().map(|x| &**x).collect::<Vec<_>>();
let (mut file_info, file_updates) = tokio::try_join!(
let (file_info, file_updates) = tokio::try_join!(
CachedEntry::get_file_many(
&file_hashes_ref,
cache_behaviour,
@@ -991,18 +946,23 @@ impl Profile {
)
)?;
let mut keys_by_path: std::collections::HashMap<
String,
InitialScanFile,
> = keys.into_iter().map(|k| (k.path.clone(), k)).collect();
let mut file_info_by_hash: std::collections::HashMap<
String,
CachedFile,
> = file_info.into_iter().map(|f| (f.hash.clone(), f)).collect();
let files = DashMap::new();
for hash in file_hashes {
let info_index = file_info.iter().position(|x| x.hash == hash.hash);
let file = info_index.map(|x| file_info.remove(x));
if let Some(initial_file_index) = keys
.iter()
.position(|x| x.path == hash.path.trim_end_matches(".disabled"))
{
let initial_file = keys.remove(initial_file_index);
let file = file_info_by_hash.remove(&hash.hash);
let trimmed = hash.path.trim_end_matches(".disabled");
if let Some(initial_file) = keys_by_path.remove(trimmed) {
let path = format!(
"{}/{}",
initial_file.project_type.get_folder(),
@@ -1043,6 +1003,95 @@ impl Profile {
Ok(files)
}
pub async fn get_installed_project_ids(
&self,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Vec<String>> {
let (_keys, file_hashes) =
self.scan_and_hash(pool, fetch_semaphore).await?;
let file_hashes_ref =
file_hashes.iter().map(|x| &*x.hash).collect::<Vec<_>>();
let file_info = CachedEntry::get_file_many(
&file_hashes_ref,
None,
pool,
fetch_semaphore,
)
.await?;
let project_ids: Vec<String> = file_info
.into_iter()
.map(|f| f.project_id)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
Ok(project_ids)
}
async fn scan_and_hash(
&self,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<(Vec<InitialScanFile>, Vec<CachedFileHash>)> {
let path = crate::api::profile::get_full_path(&self.path).await?;
let mut keys = vec![];
for project_type in ProjectType::iterator() {
let folder = project_type.get_folder();
let path = path.join(folder);
if path.exists() {
for subdirectory in std::fs::read_dir(&path)
.map_err(|e| io::IOError::with_path(e, &path))?
{
let subdirectory =
subdirectory.map_err(io::IOError::from)?.path();
if subdirectory.is_file()
&& let Some(file_name) =
subdirectory.file_name().and_then(|x| x.to_str())
&& !(project_type == ProjectType::ShaderPack
&& file_name.ends_with(".txt"))
{
let file_size = subdirectory
.metadata()
.map_err(io::IOError::from)?
.len();
keys.push(InitialScanFile {
path: format!(
"{}/{folder}/{}",
self.path,
file_name.trim_end_matches(".disabled")
),
file_name: file_name.to_string(),
project_type,
size: file_size,
cache_key: format!(
"{file_size}-{}/{folder}/{file_name}",
self.path
),
});
}
}
}
}
let file_hashes = CachedEntry::get_file_hash_many(
&keys.iter().map(|s| &*s.cache_key).collect::<Vec<_>>(),
None,
pool,
fetch_semaphore,
)
.await?;
Ok((keys, file_hashes))
}
fn get_cache_key(file: &CachedFileHash, profile: &Profile) -> String {
format!(
"{}-{}-{}",