mirror of
https://github.com/packwiz/packwiz.git
synced 2025-04-19 21:16:30 +02:00
Both CurseForge and Modrinth preferences were not being done in a specific order - so a file with a newer Minecraft version would not necessarily take priority over a file with a more preferred loader (e.g. Quilt over Fabric) or a file with a newer release date / CurseForge ID. Also fixed a loop variable reference in the CurseForge loop, which caused eagerly resolved (included in API response) file info to be inconsistent with the chosen version, and added filtering for duplicate values in the acceptable-game-versions/primary version list to ensure game versions are always compared properly (so the same index == same version).
580 lines
17 KiB
Go
580 lines
17 KiB
Go
package curseforge
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"github.com/spf13/viper"
|
|
"golang.org/x/exp/slices"
|
|
"io"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/mitchellh/mapstructure"
|
|
"github.com/packwiz/packwiz/cmd"
|
|
"github.com/packwiz/packwiz/core"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var curseforgeCmd = &cobra.Command{
|
|
Use: "curseforge",
|
|
Aliases: []string{"cf", "curse"},
|
|
Short: "Manage curseforge-based mods",
|
|
}
|
|
|
|
func init() {
|
|
cmd.Add(curseforgeCmd)
|
|
core.Updaters["curseforge"] = cfUpdater{}
|
|
core.MetaDownloaders["curseforge"] = cfDownloader{}
|
|
}
|
|
|
|
var snapshotVersionRegex = regexp.MustCompile("(?:Snapshot )?(\\d+)w0?(0|[1-9]\\d*)([a-z])")
|
|
|
|
var snapshotNames = [...]string{"-pre", " Pre-Release ", " Pre-release ", "-rc"}
|
|
|
|
func getCurseforgeVersion(mcVersion string) string {
|
|
for _, name := range snapshotNames {
|
|
index := strings.Index(mcVersion, name)
|
|
if index > -1 {
|
|
return mcVersion[:index] + "-Snapshot"
|
|
}
|
|
}
|
|
|
|
matches := snapshotVersionRegex.FindStringSubmatch(mcVersion)
|
|
if matches == nil {
|
|
return mcVersion
|
|
}
|
|
year, err := strconv.Atoi(matches[1])
|
|
if err != nil {
|
|
return mcVersion
|
|
}
|
|
week, err := strconv.Atoi(matches[2])
|
|
if err != nil {
|
|
return mcVersion
|
|
}
|
|
|
|
if year >= 22 && week >= 11 {
|
|
return "1.19-Snapshot"
|
|
} else if year == 21 && week >= 37 || year >= 22 {
|
|
return "1.18-Snapshot"
|
|
} else if year == 20 && week >= 45 || year == 21 && week <= 20 {
|
|
return "1.17-Snapshot"
|
|
} else if year == 20 && week >= 6 {
|
|
return "1.16-Snapshot"
|
|
} else if year == 19 && week >= 34 {
|
|
return "1.15-Snapshot"
|
|
} else if year == 18 && week >= 43 || year == 19 && week <= 14 {
|
|
return "1.14-Snapshot"
|
|
} else if year == 18 && week >= 30 && week <= 33 {
|
|
return "1.13.1-Snapshot"
|
|
} else if year == 17 && week >= 43 || year == 18 && week <= 22 {
|
|
return "1.13-Snapshot"
|
|
} else if year == 17 && week == 31 {
|
|
return "1.12.1-Snapshot"
|
|
} else if year == 17 && week >= 6 && week <= 18 {
|
|
return "1.12-Snapshot"
|
|
} else if year == 16 && week == 50 {
|
|
return "1.11.1-Snapshot"
|
|
} else if year == 16 && week >= 32 && week <= 44 {
|
|
return "1.11-Snapshot"
|
|
} else if year == 16 && week >= 20 && week <= 21 {
|
|
return "1.10-Snapshot"
|
|
} else if year == 16 && week >= 14 && week <= 15 {
|
|
return "1.9.3-Snapshot"
|
|
} else if year == 15 && week >= 31 || year == 16 && week <= 7 {
|
|
return "1.9-Snapshot"
|
|
} else if year == 14 && week >= 2 && week <= 34 {
|
|
return "1.8-Snapshot"
|
|
} else if year == 13 && week >= 47 && week <= 49 {
|
|
return "1.7.4-Snapshot"
|
|
} else if year == 13 && week >= 36 && week <= 43 {
|
|
return "1.7.2-Snapshot"
|
|
} else if year == 13 && week >= 16 && week <= 26 {
|
|
return "1.6-Snapshot"
|
|
} else if year == 13 && week >= 11 && week <= 12 {
|
|
return "1.5.1-Snapshot"
|
|
} else if year == 13 && week >= 1 && week <= 10 {
|
|
return "1.5-Snapshot"
|
|
} else if year == 12 && week >= 49 && week <= 50 {
|
|
return "1.4.6-Snapshot"
|
|
} else if year == 12 && week >= 32 && week <= 42 {
|
|
return "1.4.2-Snapshot"
|
|
} else if year == 12 && week >= 15 && week <= 30 {
|
|
return "1.3.1-Snapshot"
|
|
} else if year == 12 && week >= 3 && week <= 8 {
|
|
return "1.2.1-Snapshot"
|
|
} else if year == 11 && week >= 47 || year == 12 && week <= 1 {
|
|
return "1.1-Snapshot"
|
|
}
|
|
return mcVersion
|
|
}
|
|
|
|
func getCurseforgeVersions(mcVersions []string) []string {
|
|
out := make([]string, len(mcVersions))
|
|
for i, v := range mcVersions {
|
|
out[i] = getCurseforgeVersion(v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
var urlRegexes = [...]*regexp.Regexp{
|
|
regexp.MustCompile("^https?://(?P<game>minecraft)\\.curseforge\\.com/projects/(?P<slug>[^/]+)(?:/(?:files|download)/(?P<fileID>\\d+))?"),
|
|
regexp.MustCompile("^https?://(?:www\\.|beta\\.)?curseforge\\.com/(?P<game>[^/]+)/(?P<category>[^/]+)/(?P<slug>[^/]+)(?:/(?:files|download)/(?P<fileID>\\d+))?"),
|
|
regexp.MustCompile("^(?P<slug>[a-z][\\da-z\\-_]{0,127})$"),
|
|
}
|
|
|
|
func parseSlugOrUrl(url string) (game string, category string, slug string, fileID uint32, err error) {
|
|
for _, r := range urlRegexes {
|
|
matches := r.FindStringSubmatch(url)
|
|
if matches != nil {
|
|
if i := r.SubexpIndex("game"); i >= 0 {
|
|
game = matches[i]
|
|
}
|
|
if i := r.SubexpIndex("category"); i >= 0 {
|
|
category = matches[i]
|
|
}
|
|
if i := r.SubexpIndex("slug"); i >= 0 {
|
|
slug = matches[i]
|
|
}
|
|
if i := r.SubexpIndex("fileID"); i >= 0 {
|
|
if matches[i] != "" {
|
|
var f uint64
|
|
f, err = strconv.ParseUint(matches[i], 10, 32)
|
|
fileID = uint32(f)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
var defaultFolders = map[uint32]map[uint32]string{
|
|
432: { // Minecraft
|
|
5: "plugins", // Bukkit Plugins
|
|
12: "resourcepacks",
|
|
6: "mods",
|
|
17: "saves",
|
|
},
|
|
}
|
|
|
|
func getPathForFile(gameID uint32, classID uint32, categoryID uint32, slug string) string {
|
|
metaFolder := viper.GetString("meta-folder")
|
|
if metaFolder == "" {
|
|
if m, ok := defaultFolders[gameID]; ok {
|
|
if folder, ok := m[classID]; ok {
|
|
return filepath.Join(viper.GetString("meta-folder-base"), folder, slug+core.MetaExtension)
|
|
} else if folder, ok := m[categoryID]; ok {
|
|
return filepath.Join(viper.GetString("meta-folder-base"), folder, slug+core.MetaExtension)
|
|
}
|
|
}
|
|
metaFolder = "."
|
|
}
|
|
return filepath.Join(viper.GetString("meta-folder-base"), metaFolder, slug+core.MetaExtension)
|
|
}
|
|
|
|
func createModFile(modInfo modInfo, fileInfo modFileInfo, index *core.Index, optionalDisabled bool) error {
|
|
updateMap := make(map[string]map[string]interface{})
|
|
var err error
|
|
|
|
updateMap["curseforge"], err = cfUpdateData{
|
|
ProjectID: modInfo.ID,
|
|
FileID: fileInfo.ID,
|
|
}.ToMap()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
hash, hashFormat := fileInfo.getBestHash()
|
|
|
|
var optional *core.ModOption
|
|
if optionalDisabled {
|
|
optional = &core.ModOption{
|
|
Optional: true,
|
|
Default: false,
|
|
}
|
|
}
|
|
|
|
modMeta := core.Mod{
|
|
Name: modInfo.Name,
|
|
FileName: fileInfo.FileName,
|
|
Side: core.UniversalSide,
|
|
Download: core.ModDownload{
|
|
HashFormat: hashFormat,
|
|
Hash: hash,
|
|
Mode: "metadata:curseforge",
|
|
},
|
|
Option: optional,
|
|
Update: updateMap,
|
|
}
|
|
path := modMeta.SetMetaPath(getPathForFile(modInfo.GameID, modInfo.ClassID, modInfo.PrimaryCategoryID, modInfo.Slug))
|
|
|
|
// If the file already exists, this will overwrite it!!!
|
|
// TODO: Should this be improved?
|
|
// Current strategy is to go ahead and do stuff without asking, with the assumption that you are using
|
|
// VCS anyway.
|
|
|
|
format, hash, err := modMeta.Write()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return index.RefreshFileWithHash(path, format, hash, true)
|
|
}
|
|
|
|
func getSearchLoaderType(pack core.Pack) modloaderType {
|
|
dependencies := pack.Versions
|
|
|
|
_, hasFabric := dependencies["fabric"]
|
|
_, hasQuilt := dependencies["quilt"]
|
|
_, hasForge := dependencies["forge"]
|
|
if (hasFabric || hasQuilt) && hasForge {
|
|
return modloaderTypeAny
|
|
} else if hasFabric || hasQuilt {
|
|
return modloaderTypeFabric
|
|
} else if hasQuilt {
|
|
// Backwards-compatible with Fabric for now (could be configurable later)
|
|
// since we can't filter by more than one loader, just accept any and filter the response
|
|
return modloaderTypeAny
|
|
} else if hasForge {
|
|
return modloaderTypeForge
|
|
} else {
|
|
return modloaderTypeAny
|
|
}
|
|
}
|
|
|
|
// Crude way of preferring Quilt to Fabric: larger types are preferred
|
|
// so Quilt > Fabric > Forge > Any
|
|
|
|
func filterLoaderTypeIndex(packLoaders []string, modLoaderType modloaderType) (modloaderType, bool) {
|
|
if len(packLoaders) == 0 || modLoaderType == modloaderTypeAny {
|
|
// No loaders are specified: allow all files
|
|
return modloaderTypeAny, true
|
|
} else {
|
|
if slices.Contains(packLoaders, modloaderIds[modLoaderType]) {
|
|
// Pack contains this loader, pass through
|
|
return modLoaderType, true
|
|
} else {
|
|
// Pack does not contain this loader, report unsupported
|
|
return modloaderTypeAny, false
|
|
}
|
|
}
|
|
}
|
|
|
|
func filterFileInfoLoaderIndex(packLoaders []string, fileInfoData modFileInfo) (modloaderType, bool) {
|
|
if len(packLoaders) == 0 {
|
|
// No loaders are specified: allow all files
|
|
return modloaderTypeAny, true
|
|
} else {
|
|
bestLoaderId := -1
|
|
for i, name := range modloaderNames {
|
|
// Check if packLoaders and the file both contain this loader type
|
|
if slices.Contains(packLoaders, modloaderIds[i]) && slices.Contains(fileInfoData.GameVersions, name) {
|
|
if i > bestLoaderId {
|
|
// First loader found, or a loader preferred over the previous one (later IDs are preferred)
|
|
bestLoaderId = i
|
|
}
|
|
}
|
|
}
|
|
if bestLoaderId > -1 {
|
|
// Found a supported loader
|
|
return modloaderType(bestLoaderId), true
|
|
} else {
|
|
// Failed to find a supported version
|
|
return modloaderTypeAny, false
|
|
}
|
|
}
|
|
}
|
|
|
|
// findLatestFile looks at mod info, and finds the latest file ID (and potentially the file info for it - may be null)
|
|
func findLatestFile(modInfoData modInfo, mcVersions []string, packLoaders []string) (fileID uint32, fileInfoData *modFileInfo, fileName string) {
|
|
cfMcVersions := getCurseforgeVersions(mcVersions)
|
|
bestMcVer := -1
|
|
bestLoaderType := modloaderTypeAny
|
|
|
|
// For snapshots, curseforge doesn't put them in GameVersionLatestFiles
|
|
for _, v := range modInfoData.LatestFiles {
|
|
mcVerIdx := core.HighestSliceIndex(mcVersions, v.GameVersions)
|
|
loaderIdx, loaderValid := filterFileInfoLoaderIndex(packLoaders, v)
|
|
|
|
if mcVerIdx < 0 || !loaderValid {
|
|
continue
|
|
}
|
|
// Compare first by Minecraft version (prefer higher indexes of mcVersions)
|
|
compare := int32(mcVerIdx - bestMcVer)
|
|
if compare == 0 {
|
|
// Prefer higher loader indexes
|
|
compare = int32(loaderIdx - bestLoaderType)
|
|
}
|
|
if compare == 0 {
|
|
// Other comparisons are equal, compare by ID instead
|
|
compare = int32(v.ID - fileID)
|
|
}
|
|
if compare > 0 {
|
|
fileID = v.ID
|
|
fileInfoDataCopy := v // Fix for loop variable reference (which gets reassigned on every iteration!)
|
|
fileInfoData = &fileInfoDataCopy
|
|
fileName = v.FileName
|
|
bestMcVer = mcVerIdx
|
|
bestLoaderType = loaderIdx
|
|
}
|
|
}
|
|
// TODO: manage alpha/beta/release correctly, check update channel?
|
|
for _, v := range modInfoData.GameVersionLatestFiles {
|
|
mcVerIdx := slices.Index(cfMcVersions, v.GameVersion)
|
|
loaderIdx, loaderValid := filterLoaderTypeIndex(packLoaders, v.Modloader)
|
|
|
|
if mcVerIdx < 0 || !loaderValid {
|
|
continue
|
|
}
|
|
// Compare first by Minecraft version (prefer higher indexes of mcVersions)
|
|
compare := int32(mcVerIdx - bestMcVer)
|
|
if compare == 0 {
|
|
// Prefer higher loader indexes
|
|
compare = int32(loaderIdx - bestLoaderType)
|
|
}
|
|
if compare == 0 {
|
|
// Other comparisons are equal, compare by ID instead
|
|
compare = int32(v.ID - fileID)
|
|
}
|
|
if compare > 0 {
|
|
fileID = v.ID
|
|
fileInfoData = nil // (no file info in GameVersionLatestFiles)
|
|
fileName = v.Name
|
|
bestMcVer = mcVerIdx
|
|
bestLoaderType = loaderIdx
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
type cfUpdateData struct {
|
|
ProjectID uint32 `mapstructure:"project-id"`
|
|
FileID uint32 `mapstructure:"file-id"`
|
|
}
|
|
|
|
func (u cfUpdateData) ToMap() (map[string]interface{}, error) {
|
|
newMap := make(map[string]interface{})
|
|
err := mapstructure.Decode(u, &newMap)
|
|
return newMap, err
|
|
}
|
|
|
|
type cfUpdater struct{}
|
|
|
|
func (u cfUpdater) ParseUpdate(updateUnparsed map[string]interface{}) (interface{}, error) {
|
|
var updateData cfUpdateData
|
|
err := mapstructure.Decode(updateUnparsed, &updateData)
|
|
return updateData, err
|
|
}
|
|
|
|
type cachedStateStore struct {
|
|
modInfo
|
|
fileID uint32
|
|
fileInfo *modFileInfo
|
|
}
|
|
|
|
func (u cfUpdater) CheckUpdate(mods []core.Mod, pack core.Pack) ([]core.UpdateCheck, error) {
|
|
results := make([]core.UpdateCheck, len(mods))
|
|
modIDs := make([]uint32, len(mods))
|
|
modInfos := make([]modInfo, len(mods))
|
|
|
|
mcVersions, err := pack.GetSupportedMCVersions()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for i, v := range mods {
|
|
projectRaw, ok := v.GetParsedUpdateData("curseforge")
|
|
if !ok {
|
|
results[i] = core.UpdateCheck{Error: errors.New("failed to parse update metadata")}
|
|
continue
|
|
}
|
|
project := projectRaw.(cfUpdateData)
|
|
modIDs[i] = project.ProjectID
|
|
}
|
|
|
|
modInfosUnsorted, err := cfDefaultClient.getModInfoMultiple(modIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, v := range modInfosUnsorted {
|
|
for i, id := range modIDs {
|
|
if id == v.ID {
|
|
modInfos[i] = v
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
packLoaders := pack.GetLoaders()
|
|
|
|
for i, v := range mods {
|
|
projectRaw, ok := v.GetParsedUpdateData("curseforge")
|
|
if !ok {
|
|
results[i] = core.UpdateCheck{Error: errors.New("failed to parse update metadata")}
|
|
continue
|
|
}
|
|
project := projectRaw.(cfUpdateData)
|
|
|
|
fileID, fileInfoData, fileName := findLatestFile(modInfos[i], mcVersions, packLoaders)
|
|
if fileID > project.FileID && fileID != 0 {
|
|
// Update available!
|
|
results[i] = core.UpdateCheck{
|
|
UpdateAvailable: true,
|
|
UpdateString: v.FileName + " -> " + fileName,
|
|
CachedState: cachedStateStore{modInfos[i], fileID, fileInfoData},
|
|
}
|
|
} else {
|
|
// Could not find a file, too old, or up to date: no update available
|
|
results[i] = core.UpdateCheck{UpdateAvailable: false}
|
|
continue
|
|
}
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func (u cfUpdater) DoUpdate(mods []*core.Mod, cachedState []interface{}) error {
|
|
// "Do" isn't really that accurate, more like "Apply", because all the work is done in CheckUpdate!
|
|
for i, v := range mods {
|
|
modState := cachedState[i].(cachedStateStore)
|
|
|
|
var fileInfoData modFileInfo
|
|
if modState.fileInfo != nil {
|
|
fileInfoData = *modState.fileInfo
|
|
} else {
|
|
var err error
|
|
fileInfoData, err = cfDefaultClient.getFileInfo(modState.ID, modState.fileID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
v.FileName = fileInfoData.FileName
|
|
v.Name = modState.Name
|
|
hash, hashFormat := fileInfoData.getBestHash()
|
|
v.Download = core.ModDownload{
|
|
HashFormat: hashFormat,
|
|
Hash: hash,
|
|
Mode: "metadata:curseforge",
|
|
}
|
|
|
|
v.Update["curseforge"]["project-id"] = modState.ID
|
|
v.Update["curseforge"]["file-id"] = fileInfoData.ID
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type cfExportData struct {
|
|
ProjectID uint32 `mapstructure:"project-id"`
|
|
}
|
|
|
|
func (e cfExportData) ToMap() (map[string]interface{}, error) {
|
|
newMap := make(map[string]interface{})
|
|
err := mapstructure.Decode(e, &newMap)
|
|
return newMap, err
|
|
}
|
|
|
|
func parseExportData(from map[string]interface{}) (cfExportData, error) {
|
|
var exportData cfExportData
|
|
err := mapstructure.Decode(from, &exportData)
|
|
return exportData, err
|
|
}
|
|
|
|
type cfDownloader struct{}
|
|
|
|
func (c cfDownloader) GetFilesMetadata(mods []*core.Mod) ([]core.MetaDownloaderData, error) {
|
|
if len(mods) == 0 {
|
|
return []core.MetaDownloaderData{}, nil
|
|
}
|
|
|
|
downloaderData := make([]core.MetaDownloaderData, len(mods))
|
|
indexMap := make(map[uint32]int)
|
|
projectMetadata := make([]cfUpdateData, len(mods))
|
|
fileIDs := make([]uint32, len(mods))
|
|
for i, v := range mods {
|
|
updateData, ok := v.GetParsedUpdateData("curseforge")
|
|
if !ok {
|
|
return nil, fmt.Errorf("failed to read CurseForge update metadata from %s", v.Name)
|
|
}
|
|
project := updateData.(cfUpdateData)
|
|
indexMap[project.ProjectID] = i
|
|
projectMetadata[i] = project
|
|
fileIDs[i] = project.FileID
|
|
}
|
|
|
|
fileData, err := cfDefaultClient.getFileInfoMultiple(fileIDs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get CurseForge file metadata: %w", err)
|
|
}
|
|
|
|
modIDsToLookup := make([]uint32, 0)
|
|
fileNames := make([]string, len(mods))
|
|
for _, file := range fileData {
|
|
if _, ok := indexMap[file.ModID]; !ok {
|
|
return nil, fmt.Errorf("unknown project ID in response: %v (file %v, name %v)", file.ModID, file.ID, file.FileName)
|
|
}
|
|
// Opted-out mods don't provide their download URLs
|
|
if file.DownloadURL == "" {
|
|
modIDsToLookup = append(modIDsToLookup, file.ModID)
|
|
fileNames[indexMap[file.ModID]] = file.FileName
|
|
} else {
|
|
downloaderData[indexMap[file.ModID]] = &cfDownloadMetadata{
|
|
url: file.DownloadURL,
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(modIDsToLookup) > 0 {
|
|
modData, err := cfDefaultClient.getModInfoMultiple(modIDsToLookup)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get CurseForge project metadata: %w", err)
|
|
}
|
|
for _, mod := range modData {
|
|
if _, ok := indexMap[mod.ID]; !ok {
|
|
return nil, fmt.Errorf("unknown project ID in response: %v (for %v)", mod.ID, mod.Name)
|
|
}
|
|
downloaderData[indexMap[mod.ID]] = &cfDownloadMetadata{
|
|
noDistribution: true, // Inverted so the default value is not this (probably doesn't matter)
|
|
name: mod.Name,
|
|
websiteUrl: mod.Links.WebsiteURL + "/files/" + strconv.FormatUint(uint64(fileIDs[indexMap[mod.ID]]), 10),
|
|
fileName: fileNames[indexMap[mod.ID]],
|
|
}
|
|
}
|
|
}
|
|
|
|
return downloaderData, nil
|
|
}
|
|
|
|
type cfDownloadMetadata struct {
|
|
url string
|
|
noDistribution bool
|
|
name string
|
|
fileName string
|
|
websiteUrl string
|
|
}
|
|
|
|
func (m *cfDownloadMetadata) GetManualDownload() (bool, core.ManualDownload) {
|
|
if !m.noDistribution {
|
|
return false, core.ManualDownload{}
|
|
}
|
|
return true, core.ManualDownload{
|
|
Name: m.name,
|
|
FileName: m.fileName,
|
|
URL: m.websiteUrl,
|
|
}
|
|
}
|
|
|
|
func (m *cfDownloadMetadata) DownloadFile() (io.ReadCloser, error) {
|
|
resp, err := core.GetWithUA(m.url, "application/octet-stream")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to download %s: %w", m.url, err)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
_ = resp.Body.Close()
|
|
return nil, fmt.Errorf("failed to download %s: invalid status code %v", m.url, resp.StatusCode)
|
|
}
|
|
return resp.Body, nil
|
|
}
|