feat: content upload session impl (#6992)

* feat: content upload session impl

* fix: fixes

* fix: prepr
This commit is contained in:
Calum H.
2026-08-14 13:43:09 +00:00
committed by GitHub
parent 17f13097f2
commit 4ca48e4589
11 changed files with 285 additions and 216 deletions
+10 -3
View File
@@ -123,6 +123,7 @@ const {
effectiveServerWorldId, effectiveServerWorldId,
serverContextServerData, serverContextServerData,
serverContentProjectIds, serverContentProjectIds,
queuedServerInstallRootProjectIds,
queuedServerInstallProjectIds, queuedServerInstallProjectIds,
queuedServerInstallCount, queuedServerInstallCount,
selectedServerInstallProjects, selectedServerInstallProjects,
@@ -143,6 +144,7 @@ const {
enforceSetupModpackRoute, enforceSetupModpackRoute,
getQueuedServerInstallPlans, getQueuedServerInstallPlans,
setQueuedServerInstallPlans, setQueuedServerInstallPlans,
resolveQueuedServerInstallPlan,
openServerModpackInstallFlow, openServerModpackInstallFlow,
onServerFlowBack, onServerFlowBack,
handleServerModpackFlowCreate, handleServerModpackFlowCreate,
@@ -870,6 +872,7 @@ function getCardActions(
['modpack', 'mod', 'plugin', 'datapack'].includes(currentProjectType) ['modpack', 'mod', 'plugin', 'datapack'].includes(currentProjectType)
) { ) {
const isQueued = queuedServerInstallProjectIds.value.has(projectResult.project_id) const isQueued = queuedServerInstallProjectIds.value.has(projectResult.project_id)
const isQueuedRoot = queuedServerInstallRootProjectIds.value.has(projectResult.project_id)
const isInstallingSelection = isInstallingQueuedServerInstalls.value const isInstallingSelection = isInstallingQueuedServerInstalls.value
const validatingInstall = const validatingInstall =
isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection
@@ -897,14 +900,16 @@ function getCardActions(
? CheckIcon ? CheckIcon
: PlusIcon, : PlusIcon,
iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined, iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
disabled: showAsInstalled || isInstalling || isInstallingSelection, disabled:
showAsInstalled || isInstalling || isInstallingSelection || (isQueued && !isQueuedRoot),
color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand', color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
type: 'outlined', type: 'outlined',
onClick: async () => { onClick: async () => {
if (isQueued) { if (isQueuedRoot) {
removeQueuedServerInstall(projectResult.project_id) removeQueuedServerInstall(projectResult.project_id)
return return
} }
if (isQueued) return
const contentType = currentProjectType as BrowseInstallContentType const contentType = currentProjectType as BrowseInstallContentType
const isModpack = contentType === 'modpack' const isModpack = contentType === 'modpack'
@@ -913,7 +918,7 @@ function getCardActions(
setProjectInstalling(projectResult.project_id, true) setProjectInstalling(projectResult.project_id, true)
} }
try { try {
await requestInstall({ const plan = await requestInstall({
project: projectResult, project: projectResult,
contentType, contentType,
mode: isModpack ? 'immediate' : 'queue', mode: isModpack ? 'immediate' : 'queue',
@@ -937,7 +942,9 @@ function getCardActions(
iconUrl: plan.project.icon_url ?? undefined, iconUrl: plan.project.icon_url ?? undefined,
}), }),
}) })
if (!isModpack) await resolveQueuedServerInstallPlan(plan)
} catch (err) { } catch (err) {
if (!isModpack) removeQueuedServerInstall(projectResult.project_id)
handleError(err as Error) handleError(err as Error)
} finally { } finally {
if (shouldShowInstalling) { if (shouldShowInstalling) {
@@ -1,4 +1,4 @@
import type { AbstractModrinthClient, Archon, Labrinth } from '@modrinth/api-client' import type { Archon, Labrinth } from '@modrinth/api-client'
import { import {
addPendingServerContentInstalls, addPendingServerContentInstalls,
type BrowseInstallPlan, type BrowseInstallPlan,
@@ -6,15 +6,17 @@ import {
createContext, createContext,
type CreationFlowContextValue, type CreationFlowContextValue,
flushStoredServerAddonInstallQueue, flushStoredServerAddonInstallQueue,
getServerAddonInstallPlanProjectIds,
getStoredServerAddonInstallQueue, getStoredServerAddonInstallQueue,
getTargetInstallPreferences,
injectModrinthClient, injectModrinthClient,
injectNotificationManager, injectNotificationManager,
type ModpackSearchResult, type ModpackSearchResult,
type PendingServerContentInstall, type PendingServerContentInstall,
type PendingServerContentInstallType, type PendingServerContentInstallType,
readPendingServerContentInstalls,
readStoredServerInstallQueue, readStoredServerInstallQueue,
removePendingServerContentInstall, removePendingServerContentInstall,
resolveServerAddonInstallPlans,
writePendingServerContentInstallBaseline, writePendingServerContentInstallBaseline,
writeStoredServerInstallQueue, writeStoredServerInstallQueue,
} from '@modrinth/ui' } from '@modrinth/ui'
@@ -54,6 +56,7 @@ export interface ServerInstallContentContext {
effectiveServerWorldId: ComputedRef<string | null> effectiveServerWorldId: ComputedRef<string | null>
serverContextServerData: Ref<Archon.Servers.v0.Server | null> serverContextServerData: Ref<Archon.Servers.v0.Server | null>
serverContentProjectIds: Ref<Set<string>> serverContentProjectIds: Ref<Set<string>>
queuedServerInstallRootProjectIds: ComputedRef<Set<string>>
queuedServerInstallProjectIds: ComputedRef<Set<string>> queuedServerInstallProjectIds: ComputedRef<Set<string>>
queuedServerInstallCount: ComputedRef<number> queuedServerInstallCount: ComputedRef<number>
selectedServerInstallProjects: ComputedRef<BrowseSelectedProject[]> selectedServerInstallProjects: ComputedRef<BrowseSelectedProject[]>
@@ -76,6 +79,9 @@ export interface ServerInstallContentContext {
setQueuedServerInstallPlans: ( setQueuedServerInstallPlans: (
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>, plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) => void ) => void
resolveQueuedServerInstallPlan: (
plan: BrowseInstallPlan<InstallableSearchResult>,
) => Promise<void>
openServerModpackInstallFlow: (request: ServerModpackSelectionRequest) => Promise<void> openServerModpackInstallFlow: (request: ServerModpackSelectionRequest) => Promise<void>
onServerFlowBack: () => void onServerFlowBack: () => void
handleServerModpackFlowCreate: (config: CreationFlowContextValue) => Promise<void> handleServerModpackFlowCreate: (config: CreationFlowContextValue) => Promise<void>
@@ -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( function getQueuedAddonInstallPlans(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>, plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) { ) {
@@ -187,17 +151,6 @@ function getQueuedInstallPlaceholderFallbacks(
) )
} }
async function getQueuedInstallPlaceholders(
client: AbstractModrinthClient,
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return Promise.all(
getQueuedAddonInstallPlans(plans).map(async (plan) =>
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(client, plan.project)),
),
)
}
export function createServerInstallContent(opts: { export function createServerInstallContent(opts: {
serverSetupModalRef: Ref<ServerSetupModalHandle | null> serverSetupModalRef: Ref<ServerSetupModalHandle | null>
}) { }) {
@@ -250,7 +203,12 @@ export function createServerInstallContent(opts: {
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>( const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(
new Map(), 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 queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
const selectedServerInstallProjects = computed<BrowseSelectedProject[]>(() => const selectedServerInstallProjects = computed<BrowseSelectedProject[]>(() =>
Array.from(queuedServerInstalls.value.values()).map((plan) => ({ Array.from(queuedServerInstalls.value.values()).map((plan) => ({
@@ -462,6 +420,59 @@ export function createServerInstallContent(opts: {
writeStoredServerInstallQueue(serverId, worldId, plans) writeStoredServerInstallQueue(serverId, worldId, plans)
} }
function toResolvePreferences(
preferences?: BrowseInstallPlan<InstallableSearchResult>['preferences'],
): Labrinth.Content.v3.ResolutionPreferences {
return {
game_versions: preferences?.gameVersions,
loaders: preferences?.loaders,
}
}
async function resolveAddonPlan(
plan: BrowseInstallPlan<InstallableSearchResult>,
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<InstallableSearchResult>) {
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<InstallableSearchResult>[]) {
return await resolveServerAddonInstallPlans({
plans,
existingProjectIds: serverContentProjectIds.value,
resolvePlan: resolveAddonPlan,
})
}
async function flushQueuedServerInstalls( async function flushQueuedServerInstalls(
serverId: string | null = serverIdQuery.value, serverId: string | null = serverIdQuery.value,
worldId: string | null = effectiveServerWorldId.value, worldId: string | null = effectiveServerWorldId.value,
@@ -486,15 +497,12 @@ export function createServerInstallContent(opts: {
const result = await flushStoredServerAddonInstallQueue({ const result = await flushStoredServerAddonInstallQueue({
serverId, serverId,
worldId, worldId,
install: (plans) => install: async (plans) => {
client.archon.content_v1.addAddons( const addons = await resolveQueuedAddonPlans(plans)
serverId, if (addons.length > 0) {
worldId, await client.archon.content_v1.addAddons(serverId, worldId, addons)
plans.map((plan) => ({ }
project_id: plan.projectId, },
version_id: plan.versionId,
})),
),
onQueueChange: (plans) => setStoredServerInstallPlans(serverId, worldId, plans), onQueueChange: (plans) => setStoredServerInstallPlans(serverId, worldId, plans),
}) })
@@ -544,21 +552,10 @@ export function createServerInstallContent(opts: {
writeStoredServerInstallQueue(sid, wid, plans) writeStoredServerInstallQueue(sid, wid, plans)
writePendingServerContentInstallBaseline(sid, wid, serverContentInstallKeys.value) writePendingServerContentInstallBaseline(sid, wid, serverContentInstallKeys.value)
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans)) 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) await router.push(backUrl)
void flushQueuedServerInstalls(sid, wid)
return true return true
} }
@@ -627,6 +624,7 @@ export function createServerInstallContent(opts: {
effectiveServerWorldId, effectiveServerWorldId,
serverContextServerData, serverContextServerData,
serverContentProjectIds, serverContentProjectIds,
queuedServerInstallRootProjectIds,
queuedServerInstallProjectIds, queuedServerInstallProjectIds,
queuedServerInstallCount, queuedServerInstallCount,
selectedServerInstallProjects, selectedServerInstallProjects,
@@ -647,6 +645,7 @@ export function createServerInstallContent(opts: {
enforceSetupModpackRoute, enforceSetupModpackRoute,
getQueuedServerInstallPlans, getQueuedServerInstallPlans,
setQueuedServerInstallPlans, setQueuedServerInstallPlans,
resolveQueuedServerInstallPlan,
openServerModpackInstallFlow, openServerModpackInstallFlow,
onServerFlowBack, onServerFlowBack,
handleServerModpackFlowCreate, handleServerModpackFlowCreate,
@@ -14,14 +14,15 @@ import {
commonMessages, commonMessages,
defineMessages, defineMessages,
flushStoredServerAddonInstallQueue, flushStoredServerAddonInstallQueue,
getServerAddonInstallPlanProjectIds,
getStoredServerAddonInstallQueue, getStoredServerAddonInstallQueue,
getTargetInstallPreferences, getTargetInstallPreferences,
injectModrinthClient, injectModrinthClient,
injectNotificationManager, injectNotificationManager,
readPendingServerContentInstalls,
readStoredServerInstallQueue, readStoredServerInstallQueue,
removePendingServerContentInstall, removePendingServerContentInstall,
requestInstall, requestInstall,
resolveServerAddonInstallPlans,
stripServerRuntimeInstallFilters, stripServerRuntimeInstallFilters,
stripServerRuntimeInstallOverrides, stripServerRuntimeInstallOverrides,
useVIntl, useVIntl,
@@ -177,7 +178,12 @@ export function useServerInstallContent({
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<ServerInstallSearchResult>>>( const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<ServerInstallSearchResult>>>(
readStoredServerInstallQueue(currentServerId.value, currentWorldId.value), 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 queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
const selectedServerInstallProjects = computed(() => const selectedServerInstallProjects = computed(() =>
Array.from(queuedServerInstalls.value.values()).map((plan) => ({ Array.from(queuedServerInstalls.value.values()).map((plan) => ({
@@ -219,45 +225,6 @@ export function useServerInstallContent({
writeStoredServerInstallQueue(serverId, worldId, plans) 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( function getQueuedInstallPlaceholder(
plan: BrowseInstallPlan<ServerInstallSearchResult>, plan: BrowseInstallPlan<ServerInstallSearchResult>,
owner: PendingServerContentInstallInput['owner'], owner: PendingServerContentInstallInput['owner'],
@@ -284,16 +251,6 @@ export function useServerInstallContent({
) )
} }
async function getQueuedInstallPlaceholders(
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
) {
return Promise.all(
getQueuedAddonInstallPlans(plans).map(async (plan) =>
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(plan.project)),
),
)
}
function setProjectInstalling(projectId: string, installing: boolean) { function setProjectInstalling(projectId: string, installing: boolean) {
const next = new Set(installingProjectIds.value) const next = new Set(installingProjectIds.value)
if (installing) { if (installing) {
@@ -437,32 +394,43 @@ export function useServerInstallContent({
} }
} }
async function resolveAddonPlan(
plan: BrowseInstallPlan<ServerInstallSearchResult>,
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 [resolved.primary, ...resolved.dependencies].map((item) => ({
projectId: item.project_id,
versionId: item.version_id,
}))
}
async function resolveAndStoreQueuedAddonPlan(
plan: BrowseInstallPlan<ServerInstallSearchResult>,
) {
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<ServerInstallSearchResult>[]) { async function resolveQueuedAddonPlans(plans: BrowseInstallPlan<ServerInstallSearchResult>[]) {
const existingProjectIds = getServerInstalledProjectIds() return await resolveServerAddonInstallPlans({
const resolvedAddons: Array<{ project_id: string; version_id: string }> = [] plans,
existingProjectIds: getServerInstalledProjectIds(),
for (const plan of plans) { resolvePlan: resolveAddonPlan,
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,
})
}
}
return resolvedAddons
} }
function getInstallProjectVersions(projectId: string) { function getInstallProjectVersions(projectId: string) {
@@ -533,10 +501,9 @@ export function useServerInstallContent({
total: result.flushedPlans.length, total: result.flushedPlans.length,
} }
if (result.flushedPlans.length > 0) { if (result.flushedPlans.length > 0) {
await Promise.all([ await queryClient.invalidateQueries({
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }), queryKey: ['content', 'list', 'v1', serverId],
queryClient.invalidateQueries({ queryKey: ['content', 'list'] }), })
])
} }
return true return true
@@ -564,21 +531,10 @@ export function useServerInstallContent({
...optimisticallyInstalledProjectIds.value, ...optimisticallyInstalledProjectIds.value,
]) ])
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans)) 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) await navigateTo(backUrl)
void flushQueuedServerInstalls(sid, wid)
return true return true
} }
@@ -598,16 +554,17 @@ export function useServerInstallContent({
const isModpack = contentType === 'modpack' const isModpack = contentType === 'modpack'
try { try {
if (!isModpack && queuedServerInstallProjectIds.value.has(project.project_id)) { if (!isModpack && queuedServerInstallRootProjectIds.value.has(project.project_id)) {
removeQueuedServerInstall(project.project_id) removeQueuedServerInstall(project.project_id)
return return
} }
if (!isModpack && queuedServerInstallProjectIds.value.has(project.project_id)) return
if (isModpack || !queuedServerInstallProjectIds.value.has(project.project_id)) { if (isModpack || !queuedServerInstallProjectIds.value.has(project.project_id)) {
setProjectInstalling(project.project_id, true) setProjectInstalling(project.project_id, true)
} }
await requestInstall({ const plan = await requestInstall({
project, project,
contentType, contentType,
mode: isModpack ? 'immediate' : 'queue', mode: isModpack ? 'immediate' : 'queue',
@@ -646,10 +603,13 @@ export function useServerInstallContent({
ctx.modal.value?.setStage('final-config') ctx.modal.value?.setStage('final-config')
}, },
}) })
if (!isModpack) await resolveAndStoreQueuedAddonPlan(plan)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
if (isModpack) { if (isModpack) {
setProjectInstalling(project.project_id, false) setProjectInstalling(project.project_id, false)
} else {
removeQueuedServerInstall(project.project_id)
} }
handleError(e instanceof Error ? e : new Error(`Error installing content ${e}`)) handleError(e instanceof Error ? e : new Error(`Error installing content ${e}`))
} finally { } finally {
@@ -804,6 +764,7 @@ export function useServerInstallContent({
hideSelectedServerInstalls, hideSelectedServerInstalls,
installingProjectIds, installingProjectIds,
optimisticallyInstalledProjectIds, optimisticallyInstalledProjectIds,
queuedServerInstallRootProjectIds,
queuedServerInstallProjectIds, queuedServerInstallProjectIds,
queuedServerInstallCount, queuedServerInstallCount,
isInstallingQueuedServerInstalls, isInstallingQueuedServerInstalls,
@@ -163,6 +163,7 @@ const {
hideSelectedServerInstalls, hideSelectedServerInstalls,
installingProjectIds, installingProjectIds,
optimisticallyInstalledProjectIds, optimisticallyInstalledProjectIds,
queuedServerInstallRootProjectIds,
queuedServerInstallProjectIds, queuedServerInstallProjectIds,
queuedServerInstallCount, queuedServerInstallCount,
isInstallingQueuedServerInstalls, isInstallingQueuedServerInstalls,
@@ -327,6 +328,7 @@ function getCardActions(
if (serverData.value) { if (serverData.value) {
const isQueued = queuedServerInstallProjectIds.value.has(result.project_id) const isQueued = queuedServerInstallProjectIds.value.has(result.project_id)
const isQueuedRoot = queuedServerInstallRootProjectIds.value.has(result.project_id)
const isInstalled = const isInstalled =
projectResult.installed || projectResult.installed ||
optimisticallyInstalledProjectIds.value.has(result.project_id) || optimisticallyInstalledProjectIds.value.has(result.project_id) ||
@@ -362,7 +364,8 @@ function getCardActions(
? CheckIcon ? CheckIcon
: DownloadIcon, : DownloadIcon,
iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined, iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
disabled: !!isInstalled || isInstalling || isInstallingSelection, disabled:
!!isInstalled || isInstalling || isInstallingSelection || (isQueued && !isQueuedRoot),
color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand', color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
type: 'outlined', type: 'outlined',
onClick: () => serverInstall(projectResult), onClick: () => serverInstall(projectResult),
@@ -7,6 +7,10 @@ export type UploadSessionFile = {
filename: string filename: string
} }
function getUploadSessionPath(scope: Kyros.UploadSessions.v1.Scope, worldId: string) {
return `/worlds/${worldId}/${scope}/upload-session`
}
export class KyrosUploadSessionsV1Module extends AbstractModule { export class KyrosUploadSessionsV1Module extends AbstractModule {
public getModuleID(): string { public getModuleID(): string {
return 'kyros_upload_sessions_v1' return 'kyros_upload_sessions_v1'
@@ -17,7 +21,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
worldId: string, worldId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> { ): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>( return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session`, getUploadSessionPath(scope, worldId),
{ {
api: '', api: '',
version: 'v1', version: 'v1',
@@ -32,7 +36,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
worldId: string, worldId: string,
): Promise<Kyros.UploadSessions.v1.GetUploadSessionResponse> { ): Promise<Kyros.UploadSessions.v1.GetUploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.GetUploadSessionResponse>( return this.client.request<Kyros.UploadSessions.v1.GetUploadSessionResponse>(
`/worlds/${worldId}/files/upload-session`, getUploadSessionPath(scope, worldId),
{ {
api: '', api: '',
version: 'v1', version: 'v1',
@@ -58,7 +62,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
} }
return this.client.upload<Kyros.UploadSessions.v1.UploadSessionResponse>( return this.client.upload<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}/files`, `${getUploadSessionPath(scope, worldId)}/${uploadId}/files`,
{ {
api: '', api: '',
version: 'v1', version: 'v1',
@@ -76,7 +80,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
uploadId: string, uploadId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> { ): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>( return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}/finalize`, `${getUploadSessionPath(scope, worldId)}/${uploadId}/finalize`,
{ {
api: '', api: '',
version: 'v1', version: 'v1',
@@ -92,7 +96,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
uploadId: string, uploadId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> { ): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>( return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}`, `${getUploadSessionPath(scope, worldId)}/${uploadId}`,
{ {
api: '', api: '',
version: 'v1', version: 'v1',
@@ -68,6 +68,7 @@ useQuery({
from_modpack: false, from_modpack: false,
}), }),
enabled: computed(() => !!worldId.value), enabled: computed(() => !!worldId.value),
staleTime: 30_000,
}) })
const serverSettingsTabComponentMap = { const serverSettingsTabComponentMap = {
@@ -199,7 +199,6 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
} }
: current, : current,
) )
void queryClient.invalidateQueries({ queryKey: contentListKey(serverId) })
} }
function handleWorldContentBaseUpdate( function handleWorldContentBaseUpdate(
@@ -31,6 +31,11 @@ export interface BrowseInstallTarget {
loader?: string | null loader?: string | null
} }
export interface BrowseResolvedInstallContent {
projectId: string
versionId: string
}
/** /**
* Minimal project shape needed by shared install resolution. * Minimal project shape needed by shared install resolution.
*/ */
@@ -59,6 +64,7 @@ export interface BrowseInstallPlan<TProject extends BrowseInstallProject = Brows
contentType: BrowseInstallContentType contentType: BrowseInstallContentType
preferences: BrowseInstallPreferences preferences: BrowseInstallPreferences
source: BrowseInstallPlanSource source: BrowseInstallPlanSource
resolvedContent?: BrowseResolvedInstallContent[]
} }
/** /**
@@ -335,6 +341,15 @@ export interface FlushStoredServerAddonInstallQueueOptions<TProject extends Brow
onQueueChange?: (plans: Map<string, BrowseInstallPlan<TProject>>) => void onQueueChange?: (plans: Map<string, BrowseInstallPlan<TProject>>) => void
} }
export interface ResolveServerAddonInstallPlansOptions<TProject extends BrowseInstallProject> {
plans: readonly BrowseInstallPlan<TProject>[]
existingProjectIds: Iterable<string>
resolvePlan: (
plan: BrowseInstallPlan<TProject>,
existingProjectIds: string[],
) => Promise<BrowseResolvedInstallContent[]>
}
/** /**
* Result of a queue flush. Failed plans are also written back to the queue. * Result of a queue flush. Failed plans are also written back to the queue.
*/ */
@@ -646,6 +661,62 @@ export function getStoredServerAddonInstallQueue<
return addonPlans return addonPlans
} }
export function getServerAddonInstallPlanProjectIds<TProject extends BrowseInstallProject>(
plans: Iterable<BrowseInstallPlan<TProject>>,
) {
const projectIds = new Set<string>()
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<TProject extends BrowseInstallProject>({
plans,
existingProjectIds,
resolvePlan,
}: ResolveServerAddonInstallPlansOptions<TProject>) {
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<string, { project_id: string; version_id: string }>()
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<TProject extends BrowseInstallProject>({ export async function flushStoredServerAddonInstallQueue<TProject extends BrowseInstallProject>({
serverId, serverId,
worldId, worldId,
@@ -869,7 +940,22 @@ function isStoredBrowseInstallPlan(
typeof record.versionId === 'string' && typeof record.versionId === 'string' &&
isStoredBrowseInstallContentType(record.contentType) && isStoredBrowseInstallContentType(record.contentType) &&
isStoredBrowseInstallPreferences(record.preferences) && 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<string, unknown>).projectId === 'string' &&
typeof (item as Record<string, unknown>).versionId === 'string',
))
) )
} }
@@ -245,6 +245,7 @@ const addonsQuery = useQuery({
queryFn: () => queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }), client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
enabled: computed(() => worldId.value !== null), enabled: computed(() => worldId.value !== null),
staleTime: 30_000,
}) })
const modpack = computed(() => addonsQuery.data.value?.modpack ?? null) const modpack = computed(() => addonsQuery.data.value?.modpack ?? null)
@@ -411,6 +412,9 @@ async function uploadLocalModpackWithSoftOverride() {
provideInstallationSettings({ provideInstallationSettings({
closeSettings: serverSettings.closeModal, closeSettings: serverSettings.closeModal,
afterSave: async () => {
serverSettings.closeModal?.()
},
onGameVersionHover: handleGameVersionHover, onGameVersionHover: handleGameVersionHover,
loading: computed(() => !server.value || addonsQuery.isLoading.value), loading: computed(() => !server.value || addonsQuery.isLoading.value),
installationInfo: computed(() => { installationInfo: computed(() => {
@@ -32,6 +32,7 @@ import {
flushStoredServerAddonInstallQueue, flushStoredServerAddonInstallQueue,
getStoredServerAddonInstallQueue, getStoredServerAddonInstallQueue,
getTargetInstallPreferences, getTargetInstallPreferences,
resolveServerAddonInstallPlans,
} from '../../../shared/browse-tab/composables/install-logic' } from '../../../shared/browse-tab/composables/install-logic'
import ManagedContentModal from '../../../shared/content-tab/components/managed-content-modal/index.vue' import ManagedContentModal from '../../../shared/content-tab/components/managed-content-modal/index.vue'
import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue' import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue'
@@ -157,7 +158,7 @@ const contentQuery = useQuery({
queryFn: () => queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }), client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
enabled: computed(() => worldId.value !== null), enabled: computed(() => worldId.value !== null),
staleTime: 0, staleTime: 30_000,
}) })
const isModpackContentModalOpen = ref(false) const isModpackContentModalOpen = ref(false)
@@ -168,7 +169,7 @@ const modpackContentQuery = useQuery({
from_modpack: true, from_modpack: true,
}), }),
enabled: computed(() => worldId.value !== null && !!contentQuery.data.value?.modpack), enabled: computed(() => worldId.value !== null && !!contentQuery.data.value?.modpack),
staleTime: 0, staleTime: 30_000,
}) })
const setupActionDisabled = computed(() => !canSetup.value || busyReasons.value.length > 0) const setupActionDisabled = computed(() => !canSetup.value || busyReasons.value.length > 0)
@@ -395,37 +396,32 @@ function toResolvePreferences(
} }
async function resolveStoredServerAddonPlans(plans: BrowseInstallPlan[]) { async function resolveStoredServerAddonPlans(plans: BrowseInstallPlan[]) {
const existingProjectIds = getInstalledProjectIds() return await resolveServerAddonInstallPlans({
const resolvedAddons: Array<{ project_id: string; version_id: string }> = [] plans,
existingProjectIds: getInstalledProjectIds(),
for (const plan of plans) { resolvePlan: async (plan, existingProjectIds) => {
const target = getTargetInstallPreferences( const target = getTargetInstallPreferences(
{ {
gameVersion: server.value?.mc_version, gameVersion: server.value?.mc_version,
loader: server.value?.loader, loader: server.value?.loader,
}, },
plan.contentType, plan.contentType,
) )
const resolved = await client.labrinth.content_v3.resolve({ const resolved = await client.labrinth.content_v3.resolve({
project_id: plan.projectId, project_id: plan.projectId,
version_id: plan.versionId, version_id: plan.versionId,
content_type: plan.contentType as Labrinth.Content.v3.ContentType, content_type: plan.contentType as Labrinth.Content.v3.ContentType,
selected: toResolvePreferences(plan.preferences), selected: toResolvePreferences(plan.preferences),
target: toResolvePreferences(target), target: toResolvePreferences(target),
existing_project_ids: Array.from(existingProjectIds), existing_project_ids: 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 resolvedAddons return [resolved.primary, ...resolved.dependencies].map((item) => ({
projectId: item.project_id,
versionId: item.version_id,
}))
},
})
} }
function addonMatchesPendingInstall( function addonMatchesPendingInstall(
@@ -1124,8 +1124,13 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) =>
) )
} }
let newModInvalidateTimer: ReturnType<typeof setTimeout> | null = null
const handleNewMod = () => { 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) => { const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => {
@@ -1520,6 +1525,10 @@ function initializeServer() {
const cleanup = () => { const cleanup = () => {
isMounted.value = false isMounted.value = false
if (newModInvalidateTimer) {
clearTimeout(newModInvalidateTimer)
newModInvalidateTimer = null
}
saveWsStateToCache() saveWsStateToCache()