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,
serverContextServerData,
serverContentProjectIds,
queuedServerInstallRootProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
selectedServerInstallProjects,
@@ -143,6 +144,7 @@ const {
enforceSetupModpackRoute,
getQueuedServerInstallPlans,
setQueuedServerInstallPlans,
resolveQueuedServerInstallPlan,
openServerModpackInstallFlow,
onServerFlowBack,
handleServerModpackFlowCreate,
@@ -870,6 +872,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
@@ -897,14 +900,16 @@ 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'
@@ -913,7 +918,7 @@ function getCardActions(
setProjectInstalling(projectResult.project_id, true)
}
try {
await requestInstall({
const plan = await requestInstall({
project: projectResult,
contentType,
mode: isModpack ? 'immediate' : 'queue',
@@ -937,7 +942,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) {
@@ -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<string | null>
serverContextServerData: Ref<Archon.Servers.v0.Server | null>
serverContentProjectIds: Ref<Set<string>>
queuedServerInstallRootProjectIds: ComputedRef<Set<string>>
queuedServerInstallProjectIds: ComputedRef<Set<string>>
queuedServerInstallCount: ComputedRef<number>
selectedServerInstallProjects: ComputedRef<BrowseSelectedProject[]>
@@ -76,6 +79,9 @@ export interface ServerInstallContentContext {
setQueuedServerInstallPlans: (
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) => void
resolveQueuedServerInstallPlan: (
plan: BrowseInstallPlan<InstallableSearchResult>,
) => Promise<void>
openServerModpackInstallFlow: (request: ServerModpackSelectionRequest) => Promise<void>
onServerFlowBack: () => 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(
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: {
serverSetupModalRef: Ref<ServerSetupModalHandle | null>
}) {
@@ -250,7 +203,12 @@ export function createServerInstallContent(opts: {
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(
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<BrowseSelectedProject[]>(() =>
Array.from(queuedServerInstalls.value.values()).map((plan) => ({
@@ -462,6 +420,59 @@ export function createServerInstallContent(opts: {
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(
serverId: string | null = serverIdQuery.value,
worldId: string | null = effectiveServerWorldId.value,
@@ -486,15 +497,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),
})
@@ -544,21 +552,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
}
@@ -627,6 +624,7 @@ export function createServerInstallContent(opts: {
effectiveServerWorldId,
serverContextServerData,
serverContentProjectIds,
queuedServerInstallRootProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
selectedServerInstallProjects,
@@ -647,6 +645,7 @@ export function createServerInstallContent(opts: {
enforceSetupModpackRoute,
getQueuedServerInstallPlans,
setQueuedServerInstallPlans,
resolveQueuedServerInstallPlan,
openServerModpackInstallFlow,
onServerFlowBack,
handleServerModpackFlowCreate,
@@ -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<Map<string, BrowseInstallPlan<ServerInstallSearchResult>>>(
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<ServerInstallSearchResult>,
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) {
const next = new Set(installingProjectIds.value)
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>[]) {
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,
})
}
}
return resolvedAddons
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,
@@ -163,6 +163,7 @@ const {
hideSelectedServerInstalls,
installingProjectIds,
optimisticallyInstalledProjectIds,
queuedServerInstallRootProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
isInstallingQueuedServerInstalls,
@@ -327,6 +328,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) ||
@@ -362,7 +364,8 @@ 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),
@@ -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<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session`,
getUploadSessionPath(scope, worldId),
{
api: '',
version: 'v1',
@@ -32,7 +36,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
worldId: string,
): Promise<Kyros.UploadSessions.v1.GetUploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.GetUploadSessionResponse>(
`/worlds/${worldId}/files/upload-session`,
getUploadSessionPath(scope, worldId),
{
api: '',
version: 'v1',
@@ -58,7 +62,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
}
return this.client.upload<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/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<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: '',
version: 'v1',
@@ -92,7 +96,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
uploadId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}`,
`${getUploadSessionPath(scope, worldId)}/${uploadId}`,
{
api: '',
version: 'v1',
@@ -68,6 +68,7 @@ useQuery({
from_modpack: false,
}),
enabled: computed(() => !!worldId.value),
staleTime: 30_000,
})
const serverSettingsTabComponentMap = {
@@ -199,7 +199,6 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
}
: current,
)
void queryClient.invalidateQueries({ queryKey: contentListKey(serverId) })
}
function handleWorldContentBaseUpdate(
@@ -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<TProject extends BrowseInstallProject = Brows
contentType: BrowseInstallContentType
preferences: BrowseInstallPreferences
source: BrowseInstallPlanSource
resolvedContent?: BrowseResolvedInstallContent[]
}
/**
@@ -335,6 +341,15 @@ export interface FlushStoredServerAddonInstallQueueOptions<TProject extends Brow
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.
*/
@@ -646,6 +661,62 @@ export function getStoredServerAddonInstallQueue<
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>({
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<string, unknown>).projectId === 'string' &&
typeof (item as Record<string, unknown>).versionId === 'string',
))
)
}
@@ -245,6 +245,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)
@@ -411,6 +412,9 @@ async function uploadLocalModpackWithSoftOverride() {
provideInstallationSettings({
closeSettings: serverSettings.closeModal,
afterSave: async () => {
serverSettings.closeModal?.()
},
onGameVersionHover: handleGameVersionHover,
loading: computed(() => !server.value || addonsQuery.isLoading.value),
installationInfo: computed(() => {
@@ -32,6 +32,7 @@ import {
flushStoredServerAddonInstallQueue,
getStoredServerAddonInstallQueue,
getTargetInstallPreferences,
resolveServerAddonInstallPlans,
} from '../../../shared/browse-tab/composables/install-logic'
import ManagedContentModal from '../../../shared/content-tab/components/managed-content-modal/index.vue'
import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.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)
@@ -395,37 +396,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(
@@ -1124,8 +1124,13 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) =>
)
}
let newModInvalidateTimer: ReturnType<typeof setTimeout> | 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) => {
@@ -1520,6 +1525,10 @@ function initializeServer() {
const cleanup = () => {
isMounted.value = false
if (newModInvalidateTimer) {
clearTimeout(newModInvalidateTimer)
newModInvalidateTimer = null
}
saveWsStateToCache()