export interface CurseForgeManualDownloadItem { projectId: number fileId: number fileName: string websiteUrl?: string projectType?: string projectSlug?: string targetFolder?: string hashes?: Array<{ value: string; algo: number }> fileLength?: number fileFingerprint?: number ownershipKind?: 'pack_managed' | 'user_added' operationKind?: 'pack_install' | 'pack_update' | 'content_install' | 'content_update' } export interface InstalledCurseForgeContentItem { file_name: string provider_refs?: Array< | { provider: 'modrinth'; project_id: string; version_id?: string | null } | { provider: 'curseforge'; project_id: number; file_id?: number | null } > } const manualDownloadsByInstance = new Map() function modFileFamily(fileName: string) { const baseName = fileName.replace(/\.disabled$/i, '') const extension = baseName.match(/\.([^.]+)$/)?.[1]?.toLowerCase() if (!extension) return undefined const stem = baseName .toLowerCase() .replace(/\.(?:jar|zip|litemod|mrpack)$/i, '') .replace(/\s*\(\d+\)$/, '') const versionStart = stem.search(/[-_. ]+v?\d/) if (versionStart <= 0) return undefined const family = stem.slice(0, versionStart).replace(/[^a-z0-9]+/g, '') return family.length >= 3 ? `${extension}:${family}` : undefined } export function getCurseForgeManualDownloads(instanceId: string): CurseForgeManualDownloadItem[] { return manualDownloadsByInstance.get(instanceId) ?? [] } export function setCurseForgeManualDownloads( instanceId: string, items: CurseForgeManualDownloadItem[], ) { if (!items.length) { manualDownloadsByInstance.delete(instanceId) return } const existing = new Map( (manualDownloadsByInstance.get(instanceId) ?? []).map((item) => [ `${item.projectId}:${item.fileId}`, item, ]), ) const deduped = new Map() for (const item of items) { const key = `${item.projectId}:${item.fileId}` const previous = existing.get(key) deduped.set(key, { ...previous, ...item, projectType: item.projectType ?? previous?.projectType, projectSlug: item.projectSlug ?? previous?.projectSlug, targetFolder: item.targetFolder ?? previous?.targetFolder, hashes: item.hashes?.length ? item.hashes : previous?.hashes, fileLength: item.fileLength ?? previous?.fileLength, fileFingerprint: item.fileFingerprint ?? previous?.fileFingerprint, ownershipKind: item.ownershipKind ?? previous?.ownershipKind, operationKind: item.operationKind ?? previous?.operationKind, }) } manualDownloadsByInstance.set(instanceId, [...deduped.values()]) } export function getCurseForgeManualDownloadUrl(item: CurseForgeManualDownloadItem) { const projectTypePath = { mod: 'mc-mods', modpack: 'modpacks', datapack: 'data-packs', resourcepack: 'texture-packs', shader: 'shaders', shaderpack: 'shaders', world: 'worlds', }[item.projectType ?? ''] const fallback = item.projectSlug && projectTypePath ? `https://www.curseforge.com/minecraft/${projectTypePath}/${item.projectSlug}/download/${item.fileId}` : `https://www.curseforge.com/minecraft/search?search=${encodeURIComponent(item.fileName)}` if (!item.websiteUrl) return fallback try { const url = new URL(item.websiteUrl) if (!['curseforge.com', 'www.curseforge.com', 'legacy.curseforge.com'].includes(url.hostname)) { return item.websiteUrl } const projectPath = url.pathname.match( /^\/minecraft\/(?:mc-mods|modpacks|data-packs|texture-packs|shaders|worlds)\/([^/]+)/i, ) if (projectPath?.[1] && /^\d+$/.test(projectPath[1])) return fallback url.pathname = url.pathname.replace( /\/(?:files|download)\/\d+\/?$/i, `/download/${item.fileId}`, ) if (!url.pathname.endsWith(`/download/${item.fileId}`)) { url.pathname = `${url.pathname.replace(/\/$/, '')}/download/${item.fileId}` } url.search = '' url.hash = '' return url.toString() } catch { return fallback } } export function clearCurseForgeManualDownloads(instanceId: string) { setCurseForgeManualDownloads(instanceId, []) } export function filterInstalledCurseForgeManualDownloads( manualDownloads: CurseForgeManualDownloadItem[], installedItems: InstalledCurseForgeContentItem[], ) { const installedFileNames = new Set( installedItems.flatMap((item) => { const lower = item.file_name.toLowerCase() const base = lower.replace(/\.disabled$/i, '') return [lower, base].filter(Boolean) }), ) const installedFileFamilies = new Set( installedItems .map((item) => modFileFamily(item.file_name)) .filter((family): family is string => !!family), ) const installedCurseForgeProjects = new Set( installedItems.flatMap((item) => (item.provider_refs ?? []) .filter((reference) => reference.provider === 'curseforge') .map((reference) => reference.project_id), ), ) const installedCurseForgeFiles = new Set( installedItems.flatMap((item) => (item.provider_refs ?? []) .filter((reference) => reference.provider === 'curseforge' && reference.file_id != null) .map((reference) => `${reference.project_id}:${reference.file_id}`), ), ) return manualDownloads.filter((item) => { const fileFamily = modFileFamily(item.fileName) return ( !installedCurseForgeProjects.has(item.projectId) && !installedCurseForgeFiles.has(`${item.projectId}:${item.fileId}`) && !installedFileNames.has(item.fileName.toLowerCase()) && (!fileFamily || !installedFileFamilies.has(fileFamily)) ) }) } export function removeInstalledCurseForgeManualDownloads( instanceId: string, manualDownloads: CurseForgeManualDownloadItem[], installedItems: InstalledCurseForgeContentItem[], ) { const remaining = filterInstalledCurseForgeManualDownloads(manualDownloads, installedItems) setCurseForgeManualDownloads(instanceId, remaining) return remaining }