diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue index 8f166430fd..ed0a650326 100644 --- a/apps/app-frontend/src/pages/Browse.vue +++ b/apps/app-frontend/src/pages/Browse.vue @@ -128,6 +128,7 @@ const { effectiveServerWorldId, serverContextServerData, serverContentProjectIds, + queuedServerInstallRootProjectIds, queuedServerInstallProjectIds, queuedServerInstallCount, selectedServerInstallProjects, @@ -148,6 +149,7 @@ const { enforceSetupModpackRoute, getQueuedServerInstallPlans, setQueuedServerInstallPlans, + resolveQueuedServerInstallPlan, openServerModpackInstallFlow, onServerFlowBack, handleServerModpackFlowCreate, @@ -845,6 +847,7 @@ function getCardActions( ['modpack', 'mod', 'plugin', 'datapack'].includes(currentProjectType) ) { const isQueued = queuedServerInstallProjectIds.value.has(projectResult.project_id) + const isQueuedRoot = queuedServerInstallRootProjectIds.value.has(projectResult.project_id) const isInstallingSelection = isInstallingQueuedServerInstalls.value const validatingInstall = isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection @@ -872,14 +875,19 @@ function getCardActions( ? CheckIcon : PlusIcon, iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined, - disabled: showAsInstalled || isInstalling || isInstallingSelection, + disabled: + showAsInstalled || + isInstalling || + isInstallingSelection || + (isQueued && !isQueuedRoot), color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand', type: 'outlined', onClick: async () => { - if (isQueued) { + if (isQueuedRoot) { removeQueuedServerInstall(projectResult.project_id) return } + if (isQueued) return const contentType = currentProjectType as BrowseInstallContentType const isModpack = contentType === 'modpack' @@ -888,7 +896,7 @@ function getCardActions( setProjectInstalling(projectResult.project_id, true) } try { - await requestInstall({ + const plan = await requestInstall({ project: projectResult, contentType, mode: isModpack ? 'immediate' : 'queue', @@ -912,7 +920,9 @@ function getCardActions( iconUrl: plan.project.icon_url ?? undefined, }), }) + if (!isModpack) await resolveQueuedServerInstallPlan(plan) } catch (err) { + if (!isModpack) removeQueuedServerInstall(projectResult.project_id) handleError(err as Error) } finally { if (shouldShowInstalling) { diff --git a/apps/app-frontend/src/providers/setup/server-install-content.ts b/apps/app-frontend/src/providers/setup/server-install-content.ts index c48b90f2c1..c53ace39c1 100644 --- a/apps/app-frontend/src/providers/setup/server-install-content.ts +++ b/apps/app-frontend/src/providers/setup/server-install-content.ts @@ -1,4 +1,4 @@ -import type { AbstractModrinthClient, Archon, Labrinth } from '@modrinth/api-client' +import type { Archon, Labrinth } from '@modrinth/api-client' import { addPendingServerContentInstalls, type BrowseInstallPlan, @@ -6,15 +6,17 @@ import { createContext, type CreationFlowContextValue, flushStoredServerAddonInstallQueue, + getServerAddonInstallPlanProjectIds, getStoredServerAddonInstallQueue, + getTargetInstallPreferences, injectModrinthClient, injectNotificationManager, type ModpackSearchResult, type PendingServerContentInstall, type PendingServerContentInstallType, - readPendingServerContentInstalls, readStoredServerInstallQueue, removePendingServerContentInstall, + resolveServerAddonInstallPlans, writePendingServerContentInstallBaseline, writeStoredServerInstallQueue, } from '@modrinth/ui' @@ -54,6 +56,7 @@ export interface ServerInstallContentContext { effectiveServerWorldId: ComputedRef serverContextServerData: Ref serverContentProjectIds: Ref> + queuedServerInstallRootProjectIds: ComputedRef> queuedServerInstallProjectIds: ComputedRef> queuedServerInstallCount: ComputedRef selectedServerInstallProjects: ComputedRef @@ -76,6 +79,9 @@ export interface ServerInstallContentContext { setQueuedServerInstallPlans: ( plans: Map>, ) => void + resolveQueuedServerInstallPlan: ( + plan: BrowseInstallPlan, + ) => Promise openServerModpackInstallFlow: (request: ServerModpackSelectionRequest) => Promise onServerFlowBack: () => void handleServerModpackFlowCreate: (config: CreationFlowContextValue) => Promise @@ -112,48 +118,6 @@ function getQueuedInstallOwnerFallback(project: InstallableSearchResult) { } } -async function getQueuedInstallOwner( - client: AbstractModrinthClient, - project: InstallableSearchResult, -) { - const fallback = getQueuedInstallOwnerFallback(project) - - try { - if (project.organization) { - const organization = await client.labrinth.projects_v3.getOrganization(project.project_id) - if (organization) { - return { - id: organization.id, - name: organization.name, - type: 'organization' as const, - avatar_url: organization.icon_url ?? undefined, - link: `https://modrinth.com/organization/${organization.slug}`, - } - } - } - - const members = await client.labrinth.projects_v3.getMembers(project.project_id) - const owner = - members.find((member) => member.user.id === project.author_id)?.user ?? - members.find((member) => member.is_owner || member.role === 'Owner')?.user ?? - members[0]?.user - - if (owner) { - return { - id: owner.id, - name: owner.username, - type: 'user' as const, - avatar_url: owner.avatar_url, - link: `/user/${encodeURIComponent(owner.username)}`, - } - } - } catch { - return fallback - } - - return fallback -} - function getQueuedAddonInstallPlans( plans: Map>, ) { @@ -187,17 +151,6 @@ function getQueuedInstallPlaceholderFallbacks( ) } -async function getQueuedInstallPlaceholders( - client: AbstractModrinthClient, - plans: Map>, -) { - return Promise.all( - getQueuedAddonInstallPlans(plans).map(async (plan) => - getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(client, plan.project)), - ), - ) -} - export function createServerInstallContent(opts: { serverSetupModalRef: Ref }) { @@ -228,7 +181,12 @@ export function createServerInstallContent(opts: { const queuedServerInstalls = ref>>( new Map(), ) - const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys())) + const queuedServerInstallRootProjectIds = computed( + () => new Set(queuedServerInstalls.value.keys()), + ) + const queuedServerInstallProjectIds = computed(() => + getServerAddonInstallPlanProjectIds(queuedServerInstalls.value.values()), + ) const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size) const selectedServerInstallProjects = computed(() => Array.from(queuedServerInstalls.value.values()).map((plan) => ({ @@ -426,6 +384,61 @@ export function createServerInstallContent(opts: { writeStoredServerInstallQueue(serverId, worldId, plans) } + function toResolvePreferences( + preferences?: BrowseInstallPlan['preferences'], + ): Labrinth.Content.v3.ResolutionPreferences { + return { + game_versions: preferences?.gameVersions, + loaders: preferences?.loaders, + } + } + + async function resolveAddonPlan( + plan: BrowseInstallPlan, + existingProjectIds: string[], + ) { + const target = getTargetInstallPreferences( + { + gameVersion: serverContextServerData.value?.mc_version, + loader: serverContextServerData.value?.loader, + }, + plan.contentType, + ) + const resolved = await client.labrinth.content_v3.resolve({ + project_id: plan.projectId, + version_id: plan.versionId, + content_type: plan.contentType as Labrinth.Content.v3.ContentType, + selected: toResolvePreferences(plan.preferences), + target: toResolvePreferences(target), + existing_project_ids: existingProjectIds, + }) + + return [resolved.primary, ...resolved.dependencies].map((item) => ({ + projectId: item.project_id, + versionId: item.version_id, + })) + } + + async function resolveQueuedServerInstallPlan( + plan: BrowseInstallPlan, + ) { + const resolvedContent = await resolveAddonPlan(plan, Array.from(serverContentProjectIds.value)) + const storedPlan = queuedServerInstalls.value.get(plan.projectId) + if (!storedPlan || storedPlan.versionId !== plan.versionId) return + + const nextPlans = new Map(queuedServerInstalls.value) + nextPlans.set(plan.projectId, { ...storedPlan, resolvedContent }) + setQueuedServerInstallPlans(nextPlans) + } + + async function resolveQueuedAddonPlans(plans: BrowseInstallPlan[]) { + return await resolveServerAddonInstallPlans({ + plans, + existingProjectIds: serverContentProjectIds.value, + resolvePlan: resolveAddonPlan, + }) + } + async function flushQueuedServerInstalls( serverId: string | null = serverIdQuery.value, worldId: string | null = effectiveServerWorldId.value, @@ -450,15 +463,12 @@ export function createServerInstallContent(opts: { const result = await flushStoredServerAddonInstallQueue({ serverId, worldId, - install: (plans) => - client.archon.content_v1.addAddons( - serverId, - worldId, - plans.map((plan) => ({ - project_id: plan.projectId, - version_id: plan.versionId, - })), - ), + install: async (plans) => { + const addons = await resolveQueuedAddonPlans(plans) + if (addons.length > 0) { + await client.archon.content_v1.addAddons(serverId, worldId, addons) + } + }, onQueueChange: (plans) => setStoredServerInstallPlans(serverId, worldId, plans), }) @@ -508,21 +518,10 @@ export function createServerInstallContent(opts: { writeStoredServerInstallQueue(sid, wid, plans) writePendingServerContentInstallBaseline(sid, wid, serverContentInstallKeys.value) addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans)) - void getQueuedInstallPlaceholders(client, plans) - .then((items) => { - const pendingProjectIds = new Set( - readPendingServerContentInstalls(sid, wid).map((item) => item.projectId), - ) - addPendingServerContentInstalls( - sid, - wid, - items.filter((item) => pendingProjectIds.has(item.projectId)), - ) - }) - .catch((err) => handleError(err as Error)) } + const installed = await flushQueuedServerInstalls(sid, wid) + if (!installed) return false await router.push(backUrl) - void flushQueuedServerInstalls(sid, wid) return true } @@ -591,6 +590,7 @@ export function createServerInstallContent(opts: { effectiveServerWorldId, serverContextServerData, serverContentProjectIds, + queuedServerInstallRootProjectIds, queuedServerInstallProjectIds, queuedServerInstallCount, selectedServerInstallProjects, @@ -611,6 +611,7 @@ export function createServerInstallContent(opts: { enforceSetupModpackRoute, getQueuedServerInstallPlans, setQueuedServerInstallPlans, + resolveQueuedServerInstallPlan, openServerModpackInstallFlow, onServerFlowBack, handleServerModpackFlowCreate, diff --git a/apps/frontend/src/composables/use-server-install-content.ts b/apps/frontend/src/composables/use-server-install-content.ts index 53ab528596..ca08cc7221 100644 --- a/apps/frontend/src/composables/use-server-install-content.ts +++ b/apps/frontend/src/composables/use-server-install-content.ts @@ -14,14 +14,15 @@ import { commonMessages, defineMessages, flushStoredServerAddonInstallQueue, + getServerAddonInstallPlanProjectIds, getStoredServerAddonInstallQueue, getTargetInstallPreferences, injectModrinthClient, injectNotificationManager, - readPendingServerContentInstalls, readStoredServerInstallQueue, removePendingServerContentInstall, requestInstall, + resolveServerAddonInstallPlans, stripServerRuntimeInstallFilters, stripServerRuntimeInstallOverrides, useVIntl, @@ -177,7 +178,12 @@ export function useServerInstallContent({ const queuedServerInstalls = ref>>( readStoredServerInstallQueue(currentServerId.value, currentWorldId.value), ) - const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys())) + const queuedServerInstallRootProjectIds = computed( + () => new Set(queuedServerInstalls.value.keys()), + ) + const queuedServerInstallProjectIds = computed(() => + getServerAddonInstallPlanProjectIds(queuedServerInstalls.value.values()), + ) const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size) const selectedServerInstallProjects = computed(() => Array.from(queuedServerInstalls.value.values()).map((plan) => ({ @@ -219,45 +225,6 @@ export function useServerInstallContent({ writeStoredServerInstallQueue(serverId, worldId, plans) } - async function getQueuedInstallOwner(project: ServerInstallSearchResult) { - const fallback = getQueuedInstallOwnerFallback(project) - - try { - if (project.organization) { - const organization = await client.labrinth.projects_v3.getOrganization(project.project_id) - if (organization) { - return { - id: organization.id, - name: organization.name, - type: 'organization' as const, - avatar_url: organization.icon_url ?? undefined, - link: `/organization/${organization.slug}`, - } - } - } - - const members = await client.labrinth.projects_v3.getMembers(project.project_id) - const owner = - members.find((member) => member.user.id === project.author_id)?.user ?? - members.find((member) => member.is_owner || member.role === 'Owner')?.user ?? - members[0]?.user - - if (owner) { - return { - id: owner.id, - name: owner.username, - type: 'user' as const, - avatar_url: owner.avatar_url, - link: `/user/${owner.username}`, - } - } - } catch { - return fallback - } - - return fallback - } - function getQueuedInstallPlaceholder( plan: BrowseInstallPlan, owner: PendingServerContentInstallInput['owner'], @@ -284,16 +251,6 @@ export function useServerInstallContent({ ) } - async function getQueuedInstallPlaceholders( - plans: Map>, - ) { - return Promise.all( - getQueuedAddonInstallPlans(plans).map(async (plan) => - getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(plan.project)), - ), - ) - } - function setProjectInstalling(projectId: string, installing: boolean) { const next = new Set(installingProjectIds.value) if (installing) { @@ -437,32 +394,43 @@ export function useServerInstallContent({ } } - async function resolveQueuedAddonPlans(plans: BrowseInstallPlan[]) { - const existingProjectIds = getServerInstalledProjectIds() - const resolvedAddons: Array<{ project_id: string; version_id: string }> = [] - - for (const plan of plans) { - const resolved = await client.labrinth.content_v3.resolve({ - project_id: plan.projectId, - version_id: plan.versionId, - content_type: plan.contentType as Labrinth.Content.v3.ContentType, - selected: toResolvePreferences(plan.preferences), - target: toResolvePreferences(getServerInstallTargetPreferences(plan.contentType)), - existing_project_ids: Array.from(existingProjectIds), - }) - const content = [resolved.primary, ...resolved.dependencies] - - for (const item of content) { - if (existingProjectIds.has(item.project_id)) continue - existingProjectIds.add(item.project_id) - resolvedAddons.push({ - project_id: item.project_id, - version_id: item.version_id, - }) - } - } + async function resolveAddonPlan( + plan: BrowseInstallPlan, + existingProjectIds: string[], + ) { + const resolved = await client.labrinth.content_v3.resolve({ + project_id: plan.projectId, + version_id: plan.versionId, + content_type: plan.contentType as Labrinth.Content.v3.ContentType, + selected: toResolvePreferences(plan.preferences), + target: toResolvePreferences(getServerInstallTargetPreferences(plan.contentType)), + existing_project_ids: existingProjectIds, + }) - return resolvedAddons + return [resolved.primary, ...resolved.dependencies].map((item) => ({ + projectId: item.project_id, + versionId: item.version_id, + })) + } + + async function resolveAndStoreQueuedAddonPlan( + plan: BrowseInstallPlan, + ) { + const resolvedContent = await resolveAddonPlan(plan, Array.from(getServerInstalledProjectIds())) + const storedPlan = queuedServerInstalls.value.get(plan.projectId) + if (!storedPlan || storedPlan.versionId !== plan.versionId) return + + const nextPlans = new Map(queuedServerInstalls.value) + nextPlans.set(plan.projectId, { ...storedPlan, resolvedContent }) + serverInstallQueue.set(nextPlans) + } + + async function resolveQueuedAddonPlans(plans: BrowseInstallPlan[]) { + return await resolveServerAddonInstallPlans({ + plans, + existingProjectIds: getServerInstalledProjectIds(), + resolvePlan: resolveAddonPlan, + }) } function getInstallProjectVersions(projectId: string) { @@ -533,10 +501,9 @@ export function useServerInstallContent({ total: result.flushedPlans.length, } if (result.flushedPlans.length > 0) { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }), - queryClient.invalidateQueries({ queryKey: ['content', 'list'] }), - ]) + await queryClient.invalidateQueries({ + queryKey: ['content', 'list', 'v1', serverId], + }) } return true @@ -564,21 +531,10 @@ export function useServerInstallContent({ ...optimisticallyInstalledProjectIds.value, ]) addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans)) - void getQueuedInstallPlaceholders(plans) - .then((items) => { - const pendingProjectIds = new Set( - readPendingServerContentInstalls(sid, wid).map((item) => item.projectId), - ) - addPendingServerContentInstalls( - sid, - wid, - items.filter((item) => pendingProjectIds.has(item.projectId)), - ) - }) - .catch((err) => handleError(err as Error)) } + const installed = await flushQueuedServerInstalls(sid, wid) + if (!installed) return false await navigateTo(backUrl) - void flushQueuedServerInstalls(sid, wid) return true } @@ -598,16 +554,17 @@ export function useServerInstallContent({ const isModpack = contentType === 'modpack' try { - if (!isModpack && queuedServerInstallProjectIds.value.has(project.project_id)) { + if (!isModpack && queuedServerInstallRootProjectIds.value.has(project.project_id)) { removeQueuedServerInstall(project.project_id) return } + if (!isModpack && queuedServerInstallProjectIds.value.has(project.project_id)) return if (isModpack || !queuedServerInstallProjectIds.value.has(project.project_id)) { setProjectInstalling(project.project_id, true) } - await requestInstall({ + const plan = await requestInstall({ project, contentType, mode: isModpack ? 'immediate' : 'queue', @@ -646,10 +603,13 @@ export function useServerInstallContent({ ctx.modal.value?.setStage('final-config') }, }) + if (!isModpack) await resolveAndStoreQueuedAddonPlan(plan) } catch (e) { console.error(e) if (isModpack) { setProjectInstalling(project.project_id, false) + } else { + removeQueuedServerInstall(project.project_id) } handleError(e instanceof Error ? e : new Error(`Error installing content ${e}`)) } finally { @@ -804,6 +764,7 @@ export function useServerInstallContent({ hideSelectedServerInstalls, installingProjectIds, optimisticallyInstalledProjectIds, + queuedServerInstallRootProjectIds, queuedServerInstallProjectIds, queuedServerInstallCount, isInstallingQueuedServerInstalls, diff --git a/apps/frontend/src/pages/discover/[type]/index.vue b/apps/frontend/src/pages/discover/[type]/index.vue index 8b24b6f5f2..dcda5c47a6 100644 --- a/apps/frontend/src/pages/discover/[type]/index.vue +++ b/apps/frontend/src/pages/discover/[type]/index.vue @@ -161,6 +161,7 @@ const { hideSelectedServerInstalls, installingProjectIds, optimisticallyInstalledProjectIds, + queuedServerInstallRootProjectIds, queuedServerInstallProjectIds, queuedServerInstallCount, isInstallingQueuedServerInstalls, @@ -310,6 +311,7 @@ function getCardActions( if (serverData.value) { const isQueued = queuedServerInstallProjectIds.value.has(result.project_id) + const isQueuedRoot = queuedServerInstallRootProjectIds.value.has(result.project_id) const isInstalled = projectResult.installed || optimisticallyInstalledProjectIds.value.has(result.project_id) || @@ -345,7 +347,11 @@ function getCardActions( ? CheckIcon : DownloadIcon, iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined, - disabled: !!isInstalled || isInstalling || isInstallingSelection, + disabled: + !!isInstalled || + isInstalling || + isInstallingSelection || + (isQueued && !isQueuedRoot), color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand', type: 'outlined', onClick: () => serverInstall(projectResult), diff --git a/packages/api-client/src/modules/kyros/upload-sessions/v1.ts b/packages/api-client/src/modules/kyros/upload-sessions/v1.ts index d88d6dacc0..999eafbdca 100644 --- a/packages/api-client/src/modules/kyros/upload-sessions/v1.ts +++ b/packages/api-client/src/modules/kyros/upload-sessions/v1.ts @@ -7,6 +7,10 @@ export type UploadSessionFile = { filename: string } +function getUploadSessionPath(scope: Kyros.UploadSessions.v1.Scope, worldId: string) { + return `/worlds/${worldId}/${scope}/upload-session` +} + export class KyrosUploadSessionsV1Module extends AbstractModule { public getModuleID(): string { return 'kyros_upload_sessions_v1' @@ -17,7 +21,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule { worldId: string, ): Promise { return this.client.request( - `/worlds/${worldId}/files/upload-session`, + getUploadSessionPath(scope, worldId), { api: '', version: 'v1', @@ -32,7 +36,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule { worldId: string, ): Promise { return this.client.request( - `/worlds/${worldId}/files/upload-session`, + getUploadSessionPath(scope, worldId), { api: '', version: 'v1', @@ -58,7 +62,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule { } return this.client.upload( - `/worlds/${worldId}/files/upload-session/${uploadId}/files`, + `${getUploadSessionPath(scope, worldId)}/${uploadId}/files`, { api: '', version: 'v1', @@ -76,7 +80,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule { uploadId: string, ): Promise { return this.client.request( - `/worlds/${worldId}/files/upload-session/${uploadId}/finalize`, + `${getUploadSessionPath(scope, worldId)}/${uploadId}/finalize`, { api: '', version: 'v1', @@ -92,7 +96,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule { uploadId: string, ): Promise { return this.client.request( - `/worlds/${worldId}/files/upload-session/${uploadId}`, + `${getUploadSessionPath(scope, worldId)}/${uploadId}`, { api: '', version: 'v1', diff --git a/packages/ui/src/components/servers/ServerSettingsModal.vue b/packages/ui/src/components/servers/ServerSettingsModal.vue index d63706a963..6798efeca1 100644 --- a/packages/ui/src/components/servers/ServerSettingsModal.vue +++ b/packages/ui/src/components/servers/ServerSettingsModal.vue @@ -68,6 +68,7 @@ useQuery({ from_modpack: false, }), enabled: computed(() => !!worldId.value), + staleTime: 30_000, }) const serverSettingsTabComponentMap = { diff --git a/packages/ui/src/composables/server-panel-sync.ts b/packages/ui/src/composables/server-panel-sync.ts index fa9d7f9ef3..5fa8d39c22 100644 --- a/packages/ui/src/composables/server-panel-sync.ts +++ b/packages/ui/src/composables/server-panel-sync.ts @@ -199,7 +199,6 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) { } : current, ) - void queryClient.invalidateQueries({ queryKey: contentListKey(serverId) }) } function handleWorldContentBaseUpdate( diff --git a/packages/ui/src/layouts/shared/browse-tab/composables/install-logic.ts b/packages/ui/src/layouts/shared/browse-tab/composables/install-logic.ts index fe39fb2e2b..c85bd48706 100644 --- a/packages/ui/src/layouts/shared/browse-tab/composables/install-logic.ts +++ b/packages/ui/src/layouts/shared/browse-tab/composables/install-logic.ts @@ -31,6 +31,11 @@ export interface BrowseInstallTarget { loader?: string | null } +export interface BrowseResolvedInstallContent { + projectId: string + versionId: string +} + /** * Minimal project shape needed by shared install resolution. */ @@ -59,6 +64,7 @@ export interface BrowseInstallPlan>) => void } +export interface ResolveServerAddonInstallPlansOptions { + plans: readonly BrowseInstallPlan[] + existingProjectIds: Iterable + resolvePlan: ( + plan: BrowseInstallPlan, + existingProjectIds: string[], + ) => Promise +} + /** * Result of a queue flush. Failed plans are also written back to the queue. */ @@ -646,6 +661,62 @@ export function getStoredServerAddonInstallQueue< return addonPlans } +export function getServerAddonInstallPlanProjectIds( + plans: Iterable>, +) { + const projectIds = new Set() + for (const plan of plans) { + projectIds.add(plan.projectId) + for (const item of plan.resolvedContent ?? []) { + projectIds.add(item.projectId) + } + } + return projectIds +} + +export async function resolveServerAddonInstallPlans({ + plans, + existingProjectIds, + resolvePlan, +}: ResolveServerAddonInstallPlansOptions) { + const installedProjectIds = new Set(existingProjectIds) + const resolutionExistingProjectIds = Array.from(installedProjectIds) + const resolvedPlans = await Promise.all( + plans.map( + async (plan) => + plan.resolvedContent ?? (await resolvePlan(plan, resolutionExistingProjectIds)), + ), + ) + const explicitProjectIds = new Set(plans.map((plan) => plan.projectId)) + const addons = new Map() + + for (const plan of plans) { + if (installedProjectIds.has(plan.projectId)) continue + addons.set(plan.projectId, { + project_id: plan.projectId, + version_id: plan.versionId, + }) + } + + for (const content of resolvedPlans) { + for (const item of content) { + if ( + installedProjectIds.has(item.projectId) || + explicitProjectIds.has(item.projectId) || + addons.has(item.projectId) + ) { + continue + } + addons.set(item.projectId, { + project_id: item.projectId, + version_id: item.versionId, + }) + } + } + + return Array.from(addons.values()) +} + export async function flushStoredServerAddonInstallQueue({ serverId, worldId, @@ -869,7 +940,22 @@ function isStoredBrowseInstallPlan( typeof record.versionId === 'string' && isStoredBrowseInstallContentType(record.contentType) && isStoredBrowseInstallPreferences(record.preferences) && - (record.source === 'filtered' || record.source === 'target') + (record.source === 'filtered' || record.source === 'target') && + isOptionalStoredResolvedContent(record.resolvedContent) + ) +} + +function isOptionalStoredResolvedContent(value: unknown) { + return ( + value === undefined || + (Array.isArray(value) && + value.every( + (item) => + !!item && + typeof item === 'object' && + typeof (item as Record).projectId === 'string' && + typeof (item as Record).versionId === 'string', + )) ) } diff --git a/packages/ui/src/layouts/shared/server-settings/pages/installation.vue b/packages/ui/src/layouts/shared/server-settings/pages/installation.vue index 3d943bd823..0455d95d8d 100644 --- a/packages/ui/src/layouts/shared/server-settings/pages/installation.vue +++ b/packages/ui/src/layouts/shared/server-settings/pages/installation.vue @@ -247,6 +247,7 @@ const addonsQuery = useQuery({ queryFn: () => client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }), enabled: computed(() => worldId.value !== null), + staleTime: 30_000, }) const modpack = computed(() => addonsQuery.data.value?.modpack ?? null) @@ -413,6 +414,9 @@ async function uploadLocalModpackWithSoftOverride() { provideInstallationSettings({ closeSettings: serverSettings.closeModal, + afterSave: async () => { + serverSettings.closeModal?.() + }, onGameVersionHover: handleGameVersionHover, loading: computed(() => !server.value || addonsQuery.isLoading.value), installationInfo: computed(() => { diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/content.vue b/packages/ui/src/layouts/wrapped/hosting/manage/content.vue index bcfd4afc38..5472a2b5db 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/content.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/content.vue @@ -32,6 +32,7 @@ import { flushStoredServerAddonInstallQueue, getStoredServerAddonInstallQueue, getTargetInstallPreferences, + resolveServerAddonInstallPlans, } from '../../../shared/browse-tab/composables/install-logic' import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue' import ConfirmUnlinkModal from '../../../shared/content-tab/components/modals/ConfirmUnlinkModal.vue' @@ -157,7 +158,7 @@ const contentQuery = useQuery({ queryFn: () => client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }), enabled: computed(() => worldId.value !== null), - staleTime: 0, + staleTime: 30_000, }) const isModpackContentModalOpen = ref(false) @@ -168,7 +169,7 @@ const modpackContentQuery = useQuery({ from_modpack: true, }), enabled: computed(() => worldId.value !== null && !!contentQuery.data.value?.modpack), - staleTime: 0, + staleTime: 30_000, }) const setupActionDisabled = computed(() => !canSetup.value || busyReasons.value.length > 0) @@ -384,37 +385,32 @@ function toResolvePreferences( } async function resolveStoredServerAddonPlans(plans: BrowseInstallPlan[]) { - const existingProjectIds = getInstalledProjectIds() - const resolvedAddons: Array<{ project_id: string; version_id: string }> = [] - - for (const plan of plans) { - const target = getTargetInstallPreferences( - { - gameVersion: server.value?.mc_version, - loader: server.value?.loader, - }, - plan.contentType, - ) - const resolved = await client.labrinth.content_v3.resolve({ - project_id: plan.projectId, - version_id: plan.versionId, - content_type: plan.contentType as Labrinth.Content.v3.ContentType, - selected: toResolvePreferences(plan.preferences), - target: toResolvePreferences(target), - existing_project_ids: Array.from(existingProjectIds), - }) - - for (const item of [resolved.primary, ...resolved.dependencies]) { - if (existingProjectIds.has(item.project_id)) continue - existingProjectIds.add(item.project_id) - resolvedAddons.push({ - project_id: item.project_id, - version_id: item.version_id, + return await resolveServerAddonInstallPlans({ + plans, + existingProjectIds: getInstalledProjectIds(), + resolvePlan: async (plan, existingProjectIds) => { + const target = getTargetInstallPreferences( + { + gameVersion: server.value?.mc_version, + loader: server.value?.loader, + }, + plan.contentType, + ) + const resolved = await client.labrinth.content_v3.resolve({ + project_id: plan.projectId, + version_id: plan.versionId, + content_type: plan.contentType as Labrinth.Content.v3.ContentType, + selected: toResolvePreferences(plan.preferences), + target: toResolvePreferences(target), + existing_project_ids: existingProjectIds, }) - } - } - return resolvedAddons + return [resolved.primary, ...resolved.dependencies].map((item) => ({ + projectId: item.project_id, + versionId: item.version_id, + })) + }, + }) } function addonMatchesPendingInstall( diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue index c781570242..97604af9af 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue @@ -1130,8 +1130,13 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) => ) } +let newModInvalidateTimer: ReturnType | null = null const handleNewMod = () => { - queryClient.invalidateQueries({ queryKey: ['content', 'list'] }) + if (newModInvalidateTimer) clearTimeout(newModInvalidateTimer) + newModInvalidateTimer = setTimeout(() => { + newModInvalidateTimer = null + void queryClient.invalidateQueries({ queryKey: ['content', 'list'] }) + }, 500) } const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => { @@ -1526,6 +1531,10 @@ function initializeServer() { const cleanup = () => { isMounted.value = false + if (newModInvalidateTimer) { + clearTimeout(newModInvalidateTimer) + newModInvalidateTimer = null + } saveWsStateToCache()