feat: add supports for netease music driver (#6423 close #5364)

pull/6451/head
liuycy 2024-05-09 14:29:35 +08:00 committed by GitHub
parent 7e7b9b9b48
commit f261ef50cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 851 additions and 0 deletions

View File

@ -32,6 +32,7 @@ import (
_ "github.com/alist-org/alist/v3/drivers/mediatrack"
_ "github.com/alist-org/alist/v3/drivers/mega"
_ "github.com/alist-org/alist/v3/drivers/mopan"
_ "github.com/alist-org/alist/v3/drivers/netease_music"
_ "github.com/alist-org/alist/v3/drivers/onedrive"
_ "github.com/alist-org/alist/v3/drivers/onedrive_app"
_ "github.com/alist-org/alist/v3/drivers/pikpak"

View File

@ -0,0 +1,135 @@
package netease_music
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"math/big"
"strings"
"github.com/alist-org/alist/v3/pkg/utils"
"github.com/alist-org/alist/v3/pkg/utils/random"
)
var (
linuxapiKey = []byte("rFgB&h#%2?^eDg:Q")
eapiKey = []byte("e82ckenh8dichen8")
iv = []byte("0102030405060708")
presetKey = []byte("0CoJUm6Qyw8W8jud")
publicKey = []byte("-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDgtQn2JZ34ZC28NWYpAUd98iZ37BUrX/aKzmFbt7clFSs6sXqHauqKWqdtLkF2KexO40H1YTX8z2lSgBBOAxLsvaklV8k4cBFK9snQXE9/DDaFt6Rr7iVZMldczhC0JNgTz+SHXT6CBHuX3e9SdB1Ua44oncaTWz7OBGLbCiK45wIDAQAB\n-----END PUBLIC KEY-----")
stdChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
)
func aesKeyPending(key []byte) []byte {
k := len(key)
count := 0
switch true {
case k <= 16:
count = 16 - k
case k <= 24:
count = 24 - k
case k <= 32:
count = 32 - k
default:
return key[:32]
}
if count == 0 {
return key
}
return append(key, bytes.Repeat([]byte{0}, count)...)
}
func pkcs7Padding(src []byte, blockSize int) []byte {
padding := blockSize - len(src)%blockSize
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
return append(src, padtext...)
}
func aesCBCEncrypt(src, key, iv []byte) []byte {
block, _ := aes.NewCipher(aesKeyPending(key))
src = pkcs7Padding(src, block.BlockSize())
dst := make([]byte, len(src))
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(dst, src)
return dst
}
func aesECBEncrypt(src, key []byte) []byte {
block, _ := aes.NewCipher(aesKeyPending(key))
src = pkcs7Padding(src, block.BlockSize())
dst := make([]byte, len(src))
ecbCryptBlocks(block, dst, src)
return dst
}
func ecbCryptBlocks(block cipher.Block, dst, src []byte) {
bs := block.BlockSize()
for len(src) > 0 {
block.Encrypt(dst, src[:bs])
src = src[bs:]
dst = dst[bs:]
}
}
func rsaEncrypt(buffer, key []byte) []byte {
buffers := make([]byte, 128-16, 128)
buffers = append(buffers, buffer...)
block, _ := pem.Decode(key)
pubInterface, _ := x509.ParsePKIXPublicKey(block.Bytes)
pub := pubInterface.(*rsa.PublicKey)
c := new(big.Int).SetBytes([]byte(buffers))
return c.Exp(c, big.NewInt(int64(pub.E)), pub.N).Bytes()
}
func getSecretKey() ([]byte, []byte) {
key := make([]byte, 16)
reversed := make([]byte, 16)
for i := 0; i < 16; i++ {
result := stdChars[random.RangeInt64(0, 62)]
key[i] = result
reversed[15-i] = result
}
return key, reversed
}
func weapi(data map[string]string) map[string]string {
text, _ := utils.Json.Marshal(data)
secretKey, reversedKey := getSecretKey()
params := []byte(base64.StdEncoding.EncodeToString(aesCBCEncrypt(text, presetKey, iv)))
return map[string]string{
"params": base64.StdEncoding.EncodeToString(aesCBCEncrypt(params, reversedKey, iv)),
"encSecKey": hex.EncodeToString(rsaEncrypt(secretKey, publicKey)),
}
}
func eapi(url string, data map[string]interface{}) map[string]string {
text, _ := utils.Json.Marshal(data)
msg := "nobody" + url + "use" + string(text) + "md5forencrypt"
h := md5.New()
h.Write([]byte(msg))
digest := hex.EncodeToString(h.Sum(nil))
params := []byte(url + "-36cd479b6b5-" + string(text) + "-36cd479b6b5-" + digest)
return map[string]string{
"params": hex.EncodeToString(aesECBEncrypt(params, eapiKey)),
}
}
func linuxapi(data map[string]interface{}) map[string]string {
text, _ := utils.Json.Marshal(data)
return map[string]string{
"eparams": strings.ToUpper(hex.EncodeToString(aesECBEncrypt(text, linuxapiKey))),
}
}

View File

@ -0,0 +1,110 @@
package netease_music
import (
"context"
"strings"
"github.com/alist-org/alist/v3/internal/driver"
"github.com/alist-org/alist/v3/internal/errs"
"github.com/alist-org/alist/v3/internal/model"
_ "golang.org/x/image/webp"
)
type NeteaseMusic struct {
model.Storage
Addition
csrfToken string
musicU string
fileMapByName map[string]model.Obj
}
func (d *NeteaseMusic) Config() driver.Config {
return config
}
func (d *NeteaseMusic) GetAddition() driver.Additional {
return &d.Addition
}
func (d *NeteaseMusic) Init(ctx context.Context) error {
d.csrfToken = d.Addition.getCookie("__csrf")
d.musicU = d.Addition.getCookie("MUSIC_U")
if d.csrfToken == "" || d.musicU == "" {
return errs.EmptyToken
}
return nil
}
func (d *NeteaseMusic) Drop(ctx context.Context) error {
return nil
}
func (d *NeteaseMusic) Get(ctx context.Context, path string) (model.Obj, error) {
if path == "/" {
return &model.Object{
IsFolder: true,
Path: path,
}, nil
}
fragments := strings.Split(path, "/")
if len(fragments) > 1 {
fileName := fragments[1]
if strings.HasSuffix(fileName, ".lrc") {
lrc := d.fileMapByName[fileName]
return d.getLyricObj(lrc)
}
if song, ok := d.fileMapByName[fileName]; ok {
return song, nil
} else {
return nil, errs.ObjectNotFound
}
}
return nil, errs.ObjectNotFound
}
func (d *NeteaseMusic) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
return d.getSongObjs(args)
}
func (d *NeteaseMusic) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
if lrc, ok := file.(*LyricObj); ok {
if args.Type == "parsed" {
return lrc.getLyricLink(), nil
} else {
return lrc.getProxyLink(args), nil
}
}
return d.getSongLink(file)
}
func (d *NeteaseMusic) Remove(ctx context.Context, obj model.Obj) error {
return d.removeSongObj(obj)
}
func (d *NeteaseMusic) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error {
return d.putSongStream(stream)
}
func (d *NeteaseMusic) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
return errs.NotSupport
}
func (d *NeteaseMusic) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
return errs.NotSupport
}
func (d *NeteaseMusic) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
return errs.NotSupport
}
func (d *NeteaseMusic) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
return errs.NotSupport
}
var _ driver.Driver = (*NeteaseMusic)(nil)

View File

@ -0,0 +1,32 @@
package netease_music
import (
"regexp"
"github.com/alist-org/alist/v3/internal/driver"
"github.com/alist-org/alist/v3/internal/op"
)
type Addition struct {
Cookie string `json:"cookie" type:"text" required:"true" help:""`
SongLimit uint64 `json:"song_limit" default:"200" type:"number" help:"only get 200 songs by default"`
}
func (ad *Addition) getCookie(name string) string {
re := regexp.MustCompile(name + "=([^(;|$)]+)")
matches := re.FindStringSubmatch(ad.Cookie)
if len(matches) < 2 {
return ""
}
return matches[1]
}
var config = driver.Config{
Name: "NeteaseMusic",
}
func init() {
op.RegisterDriver(func() driver.Driver {
return &NeteaseMusic{}
})
}

View File

@ -0,0 +1,116 @@
package netease_music
import (
"context"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/alist-org/alist/v3/internal/model"
"github.com/alist-org/alist/v3/internal/sign"
"github.com/alist-org/alist/v3/pkg/http_range"
"github.com/alist-org/alist/v3/pkg/utils"
"github.com/alist-org/alist/v3/pkg/utils/random"
"github.com/alist-org/alist/v3/server/common"
)
type HostsResp struct {
Upload []string `json:"upload"`
}
type SongResp struct {
Data []struct {
Url string `json:"url"`
} `json:"data"`
}
type ListResp struct {
Size string `json:"size"`
MaxSize string `json:"maxSize"`
Data []struct {
AddTime int64 `json:"addTime"`
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
SongId int64 `json:"songId"`
SimpleSong struct {
Al struct {
PicUrl string `json:"picUrl"`
} `json:"al"`
} `json:"simpleSong"`
} `json:"data"`
}
type LyricObj struct {
model.Object
lyric string
}
func (lrc *LyricObj) getProxyLink(args model.LinkArgs) *model.Link {
rawURL := common.GetApiUrl(args.HttpReq) + "/p" + lrc.Path
rawURL = utils.EncodePath(rawURL, true) + "?type=parsed&sign=" + sign.Sign(lrc.Path)
return &model.Link{URL: rawURL}
}
func (lrc *LyricObj) getLyricLink() *model.Link {
reader := strings.NewReader(lrc.lyric)
return &model.Link{
RangeReadCloser: &model.RangeReadCloser{
RangeReader: func(ctx context.Context, httpRange http_range.Range) (io.ReadCloser, error) {
if httpRange.Length < 0 {
return io.NopCloser(reader), nil
}
sr := io.NewSectionReader(reader, httpRange.Start, httpRange.Length)
return io.NopCloser(sr), nil
},
Closers: utils.EmptyClosers(),
},
}
}
type ReqOption struct {
crypto string
stream model.FileStreamer
data map[string]string
headers map[string]string
cookies []*http.Cookie
url string
}
type Characteristic map[string]string
func (ch *Characteristic) fromDriver(d *NeteaseMusic) *Characteristic {
*ch = map[string]string{
"osver": "",
"deviceId": "",
"mobilename": "",
"appver": "6.1.1",
"versioncode": "140",
"buildver": strconv.FormatInt(time.Now().Unix(), 10),
"resolution": "1920x1080",
"os": "android",
"channel": "",
"requestId": strconv.FormatInt(time.Now().Unix()*1000, 10) + strconv.Itoa(int(random.RangeInt64(0, 1000))),
"MUSIC_U": d.musicU,
}
return ch
}
func (ch Characteristic) toCookies() []*http.Cookie {
cookies := make([]*http.Cookie, 0)
for k, v := range ch {
cookies = append(cookies, &http.Cookie{Name: k, Value: v})
}
return cookies
}
func (ch *Characteristic) merge(data map[string]string) map[string]interface{} {
body := map[string]interface{}{
"header": ch,
}
for k, v := range data {
body[k] = v
}
return body
}

View File

@ -0,0 +1,208 @@
package netease_music
import (
"crypto/md5"
"encoding/hex"
"io"
"net/http"
"strconv"
"strings"
"github.com/alist-org/alist/v3/internal/model"
"github.com/alist-org/alist/v3/pkg/utils"
"github.com/dhowden/tag"
)
type token struct {
resourceId string
objectKey string
token string
}
type songmeta struct {
needUpload bool
songId string
name string
artist string
album string
}
type uploader struct {
driver *NeteaseMusic
file model.File
meta songmeta
md5 string
ext string
size string
filename string
}
func (u *uploader) init(stream model.FileStreamer) error {
u.filename = stream.GetName()
u.size = strconv.FormatInt(stream.GetSize(), 10)
u.ext = "mp3"
if strings.HasSuffix(stream.GetMimetype(), "flac") {
u.ext = "flac"
}
h := md5.New()
io.Copy(h, stream)
u.md5 = hex.EncodeToString(h.Sum(nil))
_, err := u.file.Seek(0, io.SeekStart)
if err != nil {
return err
}
if m, err := tag.ReadFrom(u.file); err != nil {
u.meta = songmeta{}
} else {
u.meta = songmeta{
name: m.Title(),
artist: m.Artist(),
album: m.Album(),
}
}
if u.meta.name == "" {
u.meta.name = u.filename
}
if u.meta.album == "" {
u.meta.album = "未知专辑"
}
if u.meta.artist == "" {
u.meta.artist = "未知艺术家"
}
_, err = u.file.Seek(0, io.SeekStart)
if err != nil {
return err
}
return nil
}
func (u *uploader) checkIfExisted() error {
body, err := u.driver.request("https://interface.music.163.com/api/cloud/upload/check", http.MethodPost,
ReqOption{
crypto: "weapi",
data: map[string]string{
"ext": "",
"songId": "0",
"version": "1",
"bitrate": "999000",
"length": u.size,
"md5": u.md5,
},
cookies: []*http.Cookie{
{Name: "os", Value: "pc"},
{Name: "appver", Value: "2.9.7"},
},
},
)
if err != nil {
return err
}
u.meta.songId = utils.Json.Get(body, "songId").ToString()
u.meta.needUpload = utils.Json.Get(body, "needUpload").ToBool()
return nil
}
func (u *uploader) allocToken(bucket ...string) (token, error) {
if len(bucket) == 0 {
bucket = []string{""}
}
body, err := u.driver.request("https://music.163.com/weapi/nos/token/alloc", http.MethodPost, ReqOption{
crypto: "weapi",
data: map[string]string{
"bucket": bucket[0],
"local": "false",
"type": "audio",
"nos_product": "3",
"filename": u.filename,
"md5": u.md5,
"ext": u.ext,
},
})
if err != nil {
return token{}, err
}
return token{
resourceId: utils.Json.Get(body, "result", "resourceId").ToString(),
objectKey: utils.Json.Get(body, "result", "objectKey").ToString(),
token: utils.Json.Get(body, "result", "token").ToString(),
}, nil
}
func (u *uploader) publishInfo(resourceId string) error {
body, err := u.driver.request("https://music.163.com/api/upload/cloud/info/v2", http.MethodPost, ReqOption{
crypto: "weapi",
data: map[string]string{
"md5": u.md5,
"filename": u.filename,
"song": u.meta.name,
"album": u.meta.album,
"artist": u.meta.artist,
"songid": u.meta.songId,
"resourceId": resourceId,
"bitrate": "999000",
},
})
if err != nil {
return err
}
_, err = u.driver.request("https://interface.music.163.com/api/cloud/pub/v2", http.MethodPost, ReqOption{
crypto: "weapi",
data: map[string]string{
"songid": utils.Json.Get(body, "songId").ToString(),
},
})
if err != nil {
return err
}
return nil
}
func (u *uploader) upload(stream model.FileStreamer) error {
bucket := "jd-musicrep-privatecloud-audio-public"
token, err := u.allocToken(bucket)
if err != nil {
return err
}
body, err := u.driver.request("https://wanproxy.127.net/lbs?version=1.0&bucketname="+bucket, http.MethodGet,
ReqOption{},
)
if err != nil {
return err
}
var resp HostsResp
err = utils.Json.Unmarshal(body, &resp)
if err != nil {
return err
}
objectKey := strings.ReplaceAll(token.objectKey, "/", "%2F")
_, err = u.driver.request(
resp.Upload[0]+"/"+bucket+"/"+objectKey+"?offset=0&complete=true&version=1.0",
http.MethodPost,
ReqOption{
stream: stream,
headers: map[string]string{
"x-nos-token": token.token,
"Content-Type": "audio/mpeg",
"Content-Length": u.size,
"Content-MD5": u.md5,
},
},
)
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,246 @@
package netease_music
import (
"io"
"net/http"
"path"
"regexp"
"strconv"
"strings"
"time"
"github.com/alist-org/alist/v3/drivers/base"
"github.com/alist-org/alist/v3/internal/errs"
"github.com/alist-org/alist/v3/internal/model"
"github.com/alist-org/alist/v3/pkg/utils"
)
func (d *NeteaseMusic) request(url, method string, opt ReqOption) ([]byte, error) {
req := base.RestyClient.R()
req.SetHeader("Cookie", d.Addition.Cookie)
if strings.Contains(url, "music.163.com") {
req.SetHeader("Referer", "https://music.163.com")
}
if opt.cookies != nil {
for _, cookie := range opt.cookies {
req.SetCookie(cookie)
}
}
if opt.headers != nil {
for header, value := range opt.headers {
req.SetHeader(header, value)
}
}
data := opt.data
if opt.crypto == "weapi" {
data = weapi(data)
re, _ := regexp.Compile(`/\w*api/`)
url = re.ReplaceAllString(url, "/weapi/")
} else if opt.crypto == "eapi" {
ch := new(Characteristic).fromDriver(d)
req.SetCookies(ch.toCookies())
data = eapi(opt.url, ch.merge(data))
re, _ := regexp.Compile(`/\w*api/`)
url = re.ReplaceAllString(url, "/eapi/")
} else if opt.crypto == "linuxapi" {
re, _ := regexp.Compile(`/\w*api/`)
data = linuxapi(map[string]interface{}{
"url": re.ReplaceAllString(url, "/api/"),
"method": method,
"params": data,
})
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36")
url = "https://music.163.com/api/linux/forward"
}
if method == http.MethodPost {
if opt.stream != nil {
req.SetContentLength(true)
req.SetBody(io.ReadCloser(opt.stream))
} else {
req.SetFormData(data)
}
res, err := req.Post(url)
return res.Body(), err
}
if method == http.MethodGet {
res, err := req.Get(url)
return res.Body(), err
}
return nil, errs.NotImplement
}
func (d *NeteaseMusic) getSongObjs(args model.ListArgs) ([]model.Obj, error) {
body, err := d.request("https://music.163.com/weapi/v1/cloud/get", http.MethodPost, ReqOption{
crypto: "weapi",
data: map[string]string{
"limit": strconv.FormatUint(d.Addition.SongLimit, 10),
"offset": "0",
},
cookies: []*http.Cookie{
{Name: "os", Value: "pc"},
},
})
if err != nil {
return nil, err
}
var resp ListResp
err = utils.Json.Unmarshal(body, &resp)
if err != nil {
return nil, err
}
d.fileMapByName = make(map[string]model.Obj)
files := make([]model.Obj, 0, len(resp.Data))
for _, f := range resp.Data {
song := &model.ObjThumb{
Object: model.Object{
IsFolder: false,
Size: f.FileSize,
Name: f.FileName,
Modified: time.UnixMilli(f.AddTime),
ID: strconv.FormatInt(f.SongId, 10),
},
Thumbnail: model.Thumbnail{Thumbnail: f.SimpleSong.Al.PicUrl},
}
d.fileMapByName[song.Name] = song
files = append(files, song)
// map song id for lyric
lrcName := strings.Split(f.FileName, ".")[0] + ".lrc"
lrc := &model.Object{
IsFolder: false,
Name: lrcName,
Path: path.Join(args.ReqPath, lrcName),
ID: strconv.FormatInt(f.SongId, 10),
}
d.fileMapByName[lrc.Name] = lrc
}
return files, nil
}
func (d *NeteaseMusic) getSongLink(file model.Obj) (*model.Link, error) {
body, err := d.request(
"https://music.163.com/api/song/enhance/player/url", http.MethodPost, ReqOption{
crypto: "linuxapi",
data: map[string]string{
"ids": "[" + file.GetID() + "]",
"br": "999000",
},
cookies: []*http.Cookie{
{Name: "os", Value: "pc"},
},
},
)
if err != nil {
return nil, err
}
var resp SongResp
err = utils.Json.Unmarshal(body, &resp)
if err != nil {
return nil, err
}
if len(resp.Data) < 1 {
return nil, errs.ObjectNotFound
}
return &model.Link{URL: resp.Data[0].Url}, nil
}
func (d *NeteaseMusic) getLyricObj(file model.Obj) (model.Obj, error) {
if lrc, ok := file.(*LyricObj); ok {
return lrc, nil
}
body, err := d.request(
"https://music.163.com/api/song/lyric?_nmclfl=1", http.MethodPost, ReqOption{
data: map[string]string{
"id": file.GetID(),
"tv": "-1",
"lv": "-1",
"rv": "-1",
"kv": "-1",
},
cookies: []*http.Cookie{
{Name: "os", Value: "ios"},
},
},
)
if err != nil {
return nil, err
}
lyric := utils.Json.Get(body, "lrc", "lyric").ToString()
return &LyricObj{
lyric: lyric,
Object: model.Object{
IsFolder: false,
ID: file.GetID(),
Name: file.GetName(),
Path: file.GetPath(),
Size: int64(len(lyric)),
},
}, nil
}
func (d *NeteaseMusic) removeSongObj(file model.Obj) error {
_, err := d.request("http://music.163.com/weapi/cloud/del", http.MethodPost, ReqOption{
crypto: "weapi",
data: map[string]string{
"songIds": "[" + file.GetID() + "]",
},
})
return err
}
func (d *NeteaseMusic) putSongStream(stream model.FileStreamer) error {
tmp, err := stream.CacheFullInTempFile()
if err != nil {
return err
}
defer tmp.Close()
u := uploader{driver: d, file: tmp}
err = u.init(stream)
if err != nil {
return err
}
err = u.checkIfExisted()
if err != nil {
return err
}
token, err := u.allocToken()
if err != nil {
return err
}
if u.meta.needUpload {
err = u.upload(stream)
if err != nil {
return err
}
}
err = u.publishInfo(token.resourceId)
if err != nil {
return err
}
return nil
}

1
go.mod
View File

@ -108,6 +108,7 @@ require (
github.com/crackcomm/go-gitignore v0.0.0-20170627025303-887ab5e44cc3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 // indirect
github.com/fxamacker/cbor/v2 v2.5.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/geoffgarside/ber v1.1.0 // indirect

2
go.sum
View File

@ -123,6 +123,8 @@ github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=