加密音乐格式转换器
—-内核源码—–
Go语言编写
文件结构导图
程序截图
程序内核源码(不含界面)
一、algo
① common
1.dispatch.go
package common import ( "io" "path/filepath" "strings" "go.uber.org/zap" ) type DecoderParams struct { Reader io.ReadSeeker // required Extension string // required, source extension, eg. ".mp3" FilePath string // optional, source file path Logger *zap.Logger // required } type NewDecoderFunc func(p *DecoderParams) Decoder type decoderItem struct { noop bool decoder NewDecoderFunc } var DecoderRegistry = make(map[string][]decoderItem) func RegisterDecoder(ext string, noop bool, dispatchFunc NewDecoderFunc) { DecoderRegistry[ext] = append(DecoderRegistry[ext], decoderItem{noop: noop, decoder: dispatchFunc}) } func GetDecoder(filename string, skipNoop bool) (rs []NewDecoderFunc) { ext := strings.ToLower(strings.TrimLeft(filepath.Ext(filename), ".")) for _, dec := range DecoderRegistry[ext] { if skipNoop && dec.noop { continue } rs = append(rs, dec.decoder) } return }
2.interface.go
package common import ( "context" "io" ) type StreamDecoder interface { Decrypt(buf []byte, offset int) } type Decoder interface { Validate() error io.Reader } type CoverImageGetter interface { GetCoverImage(ctx context.Context) ([]byte, error) } type AudioMeta interface { GetArtists() []string GetTitle() string GetAlbum() string } type AudioMetaGetter interface { GetAudioMeta(ctx context.Context) (AudioMeta, error) }
3.meta.go
package common import ( "path" "strings" ) type filenameMeta struct { artists []string title string album string } func (f *filenameMeta) GetArtists() []string { return f.artists } func (f *filenameMeta) GetTitle() string { return f.title } func (f *filenameMeta) GetAlbum() string { return f.album } func ParseFilenameMeta(filename string) (meta AudioMeta) { partName := strings.TrimSuffix(filename, path.Ext(filename)) items := strings.Split(partName, "-") ret := &filenameMeta{} switch len(items) { case 0: // no-op case 1: ret.title = strings.TrimSpace(items[0]) default: ret.title = strings.TrimSpace(items[len(items)-1]) for _, v := range items[:len(items)-1] { artists := strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == '_' }) for _, artist := range artists { ret.artists = append(ret.artists, strings.TrimSpace(artist)) } } } return ret }
4.meta_test.go
package common import ( "reflect" "testing" ) func TestParseFilenameMeta(t *testing.T) { tests := []struct { name string wantMeta AudioMeta }{ { name: "test1", wantMeta: &filenameMeta{title: "test1"}, }, { name: "周杰伦 - 晴天.flac", wantMeta: &filenameMeta{artists: []string{"周杰伦"}, title: "晴天"}, }, { name: "Alan Walker _ Iselin Solheim - Sing Me to Sleep.flac", wantMeta: &filenameMeta{artists: []string{"Alan Walker", "Iselin Solheim"}, title: "Sing Me to Sleep"}, }, { name: "Christopher,Madcon - Limousine.flac", wantMeta: &filenameMeta{artists: []string{"Christopher", "Madcon"}, title: "Limousine"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if gotMeta := ParseFilenameMeta(tt.name); !reflect.DeepEqual(gotMeta, tt.wantMeta) { t.Errorf("ParseFilenameMeta() = %v, want %v", gotMeta, tt.wantMeta) } }) } }
5.raw.go
package common import ( "errors" "fmt" "io" "unlock-music.dev/cli/internal/sniff" ) type RawDecoder struct { rd io.ReadSeeker audioExt string } func NewRawDecoder(p *DecoderParams) Decoder { return &RawDecoder{rd: p.Reader} } func (d *RawDecoder) Validate() error { header := make([]byte, 16) if _, err := io.ReadFull(d.rd, header); err != nil { return fmt.Errorf("read file header failed: %v", err) } if _, err := d.rd.Seek(0, io.SeekStart); err != nil { return fmt.Errorf("seek file failed: %v", err) } var ok bool d.audioExt, ok = sniff.AudioExtension(header) if !ok { return errors.New("raw: sniff audio type failed") } return nil } func (d *RawDecoder) Read(p []byte) (n int, err error) { return d.rd.Read(p) } func init() { RegisterDecoder("mp3", true, NewRawDecoder) RegisterDecoder("flac", true, NewRawDecoder) RegisterDecoder("ogg", true, NewRawDecoder) RegisterDecoder("m4a", true, NewRawDecoder) RegisterDecoder("wav", true, NewRawDecoder) RegisterDecoder("wma", true, NewRawDecoder) RegisterDecoder("aac", true, NewRawDecoder) }
②.kgm
1.kgm.go
package kgm import ( "fmt" "io" "unlock-music.dev/cli/algo/common" ) type Decoder struct { rd io.ReadSeeker cipher common.StreamDecoder offset int header header } func NewDecoder(p *common.DecoderParams) common.Decoder { return &Decoder{rd: p.Reader} } // Validate checks if the file is a valid Kugou (.kgm, .vpr, .kgma) file. // rd will be seeked to the beginning of the encrypted audio. func (d *Decoder) Validate() (err error) { if err := d.header.FromFile(d.rd); err != nil { return err } // TODO; validate crypto version switch d.header.CryptoVersion { case 3: d.cipher, err = newKgmCryptoV3(&d.header) if err != nil { return fmt.Errorf("kgm init crypto v3: %w", err) } default: return fmt.Errorf("kgm: unsupported crypto version %d", d.header.CryptoVersion) } // prepare for read if _, err := d.rd.Seek(int64(d.header.AudioOffset), io.SeekStart); err != nil { return fmt.Errorf("kgm seek to audio: %w", err) } return nil } func (d *Decoder) Read(buf []byte) (int, error) { n, err := d.rd.Read(buf) if n > 0 { d.cipher.Decrypt(buf[:n], d.offset) d.offset += n } return n, err } func init() { // Kugou common.RegisterDecoder("kgm", false, NewDecoder) common.RegisterDecoder("kgma", false, NewDecoder) // Viper common.RegisterDecoder("vpr", false, NewDecoder) }
2.kgm_header.go
package kgm import ( "bytes" "encoding/binary" "errors" "fmt" "io" ) var ( vprHeader = []byte{ 0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43, 0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31, } kgmHeader = []byte{ 0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B, 0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14, } ErrKgmMagicHeader = errors.New("kgm magic header not matched") ) // header is the header of a KGM file. type header struct { MagicHeader []byte // 0x00-0x0f: magic header AudioOffset uint32 // 0x10-0x13: offset of audio data CryptoVersion uint32 // 0x14-0x17: crypto version CryptoSlot uint32 // 0x18-0x1b: crypto key slot CryptoTestData []byte // 0x1c-0x2b: crypto test data CryptoKey []byte // 0x2c-0x3b: crypto key } func (h *header) FromFile(rd io.ReadSeeker) error { if _, err := rd.Seek(0, io.SeekStart); err != nil { return fmt.Errorf("kgm seek start: %w", err) } buf := make([]byte, 0x3c) if _, err := io.ReadFull(rd, buf); err != nil { return fmt.Errorf("kgm read header: %w", err) } return h.FromBytes(buf) } func (h *header) FromBytes(buf []byte) error { if len(buf) < 0x3c { return errors.New("invalid kgm header length") } h.MagicHeader = buf[:0x10] if !bytes.Equal(kgmHeader, h.MagicHeader) && !bytes.Equal(vprHeader, h.MagicHeader) { return ErrKgmMagicHeader } h.AudioOffset = binary.LittleEndian.Uint32(buf[0x10:0x14]) h.CryptoVersion = binary.LittleEndian.Uint32(buf[0x14:0x18]) h.CryptoSlot = binary.LittleEndian.Uint32(buf[0x18:0x1c]) h.CryptoTestData = buf[0x1c:0x2c] h.CryptoKey = buf[0x2c:0x3c] return nil }
3.kgm_v3.go
package kgm import ( "crypto/md5" "fmt" "unlock-music.dev/cli/algo/common" ) // kgmCryptoV3 is kgm file crypto v3 type kgmCryptoV3 struct { slotBox []byte fileBox []byte } var kgmV3Slot2Key = map[uint32][]byte{ 1: {0x6C, 0x2C, 0x2F, 0x27}, } func newKgmCryptoV3(header *header) (common.StreamDecoder, error) { c := &kgmCryptoV3{} slotKey, ok := kgmV3Slot2Key[header.CryptoSlot] if !ok { return nil, fmt.Errorf("kgm3: unknown crypto slot %d", header.CryptoSlot) } c.slotBox = kugouMD5(slotKey) c.fileBox = append(kugouMD5(header.CryptoKey), 0x6b) return c, nil } func (d *kgmCryptoV3) Decrypt(b []byte, offset int) { for i := 0; i < len(b); i++ { b[i] ^= d.fileBox[(offset+i)%len(d.fileBox)] b[i] ^= b[i] << 4 b[i] ^= d.slotBox[(offset+i)%len(d.slotBox)] b[i] ^= xorCollapseUint32(uint32(offset + i)) } } func xorCollapseUint32(i uint32) byte { return byte(i) ^ byte(i>>8) ^ byte(i>>16) ^ byte(i>>24) } func kugouMD5(b []byte) []byte { digest := md5.Sum(b) ret := make([]byte, 16) for i := 0; i < md5.Size; i += 2 { ret[i] = digest[14-i] ret[i+1] = digest[14-i+1] } return ret }
③.kwm
1.kwm.go
package kwm import ( "bytes" "errors" "fmt" "io" "strconv" "strings" "unicode" "unlock-music.dev/cli/algo/common" ) const magicHeader1 = "yeelion-kuwo-tme" const magicHeader2 = "yeelion-kuwo\x00\x00\x00\x00" const keyPreDefined = "MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk" type Decoder struct { rd io.ReadSeeker cipher common.StreamDecoder offset int outputExt string bitrate int } func (d *Decoder) GetAudioExt() string { return "." + d.outputExt } func NewDecoder(p *common.DecoderParams) common.Decoder { return &Decoder{rd: p.Reader} } // Validate checks if the file is a valid Kuwo .kw file. // rd will be seeked to the beginning of the encrypted audio. func (d *Decoder) Validate() error { header := make([]byte, 0x400) // kwm header is fixed to 1024 bytes _, err := io.ReadFull(d.rd, header) if err != nil { return fmt.Errorf("kwm read header: %w", err) } // check magic header, 0x00 - 0x0F magicHeader := header[:0x10] if !bytes.Equal([]byte(magicHeader1), magicHeader) && !bytes.Equal([]byte(magicHeader2), magicHeader) { return errors.New("kwm magic header not matched") } d.cipher = newKwmCipher(header[0x18:0x20]) // Crypto Key, 0x18 - 0x1F d.bitrate, d.outputExt = parseBitrateAndType(header[0x30:0x38]) // Bitrate & File Extension, 0x30 - 0x38 return nil } func parseBitrateAndType(header []byte) (int, string) { tmp := strings.TrimRight(string(header), "\x00") sep := strings.IndexFunc(tmp, func(r rune) bool { return !unicode.IsDigit(r) }) bitrate, _ := strconv.Atoi(tmp[:sep]) // just ignore the error outputExt := strings.ToLower(tmp[sep:]) return bitrate, outputExt } func (d *Decoder) Read(b []byte) (int, error) { n, err := d.rd.Read(b) if n > 0 { d.cipher.Decrypt(b[:n], d.offset) d.offset += n } return n, err } func padOrTruncate(raw string, length int) string { lenRaw := len(raw) out := raw if lenRaw == 0 { out = string(make([]byte, length)) } else if lenRaw > length { out = raw[:length] } else if lenRaw < length { _tmp := make([]byte, 32) for i := 0; i < 32; i++ { _tmp[i] = raw[i%lenRaw] } out = string(_tmp) } return out } func init() { // Kuwo Mp3/Flac common.RegisterDecoder("kwm", false, NewDecoder) common.RegisterDecoder("kwm", false, common.NewRawDecoder) }
2.kwm_cipher.go
package kwm import ( "encoding/binary" "strconv" ) type kwmCipher struct { mask []byte } func newKwmCipher(key []byte) *kwmCipher { return &kwmCipher{mask: generateMask(key)} } func generateMask(key []byte) []byte { keyInt := binary.LittleEndian.Uint64(key) keyStr := strconv.FormatUint(keyInt, 10) keyStrTrim := padOrTruncate(keyStr, 32) mask := make([]byte, 32) for i := 0; i < 32; i++ { mask[i] = keyPreDefined[i] ^ keyStrTrim[i] } return mask } func (c kwmCipher) Decrypt(buf []byte, offset int) { for i := range buf { buf[i] ^= c.mask[(offset+i)&0x1F] // equivalent: [i % 32] } }
④.ncm
1.meta.go
package ncm import ( "strings" "unlock-music.dev/cli/algo/common" ) type ncmMeta interface { common.AudioMeta // GetFormat return the audio format, e.g. mp3, flac GetFormat() string // GetAlbumImageURL return the album image url GetAlbumImageURL() string } type ncmMetaMusic struct { Format string `json:"format"` MusicID int `json:"musicId"` MusicName string `json:"musicName"` Artist [][]interface{} `json:"artist"` Album string `json:"album"` AlbumID int `json:"albumId"` AlbumPicDocID interface{} `json:"albumPicDocId"` AlbumPic string `json:"albumPic"` MvID int `json:"mvId"` Flag int `json:"flag"` Bitrate int `json:"bitrate"` Duration int `json:"duration"` Alias []interface{} `json:"alias"` TransNames []interface{} `json:"transNames"` } func (m *ncmMetaMusic) GetAlbumImageURL() string { return m.AlbumPic } func (m *ncmMetaMusic) GetArtists() (artists []string) { for _, artist := range m.Artist { for _, item := range artist { name, ok := item.(string) if ok { artists = append(artists, name) } } } return } func (m *ncmMetaMusic) GetTitle() string { return m.MusicName } func (m *ncmMetaMusic) GetAlbum() string { return m.Album } func (m *ncmMetaMusic) GetFormat() string { return m.Format } //goland:noinspection SpellCheckingInspection type ncmMetaDJ struct { ProgramID int `json:"programId"` ProgramName string `json:"programName"` MainMusic ncmMetaMusic `json:"mainMusic"` DjID int `json:"djId"` DjName string `json:"djName"` DjAvatarURL string `json:"djAvatarUrl"` CreateTime int64 `json:"createTime"` Brand string `json:"brand"` Serial int `json:"serial"` ProgramDesc string `json:"programDesc"` ProgramFeeType int `json:"programFeeType"` ProgramBuyed bool `json:"programBuyed"` RadioID int `json:"radioId"` RadioName string `json:"radioName"` RadioCategory string `json:"radioCategory"` RadioCategoryID int `json:"radioCategoryId"` RadioDesc string `json:"radioDesc"` RadioFeeType int `json:"radioFeeType"` RadioFeeScope int `json:"radioFeeScope"` RadioBuyed bool `json:"radioBuyed"` RadioPrice int `json:"radioPrice"` RadioPurchaseCount int `json:"radioPurchaseCount"` } func (m *ncmMetaDJ) GetArtists() []string { if m.DjName != "" { return []string{m.DjName} } return m.MainMusic.GetArtists() } func (m *ncmMetaDJ) GetTitle() string { if m.ProgramName != "" { return m.ProgramName } return m.MainMusic.GetTitle() } func (m *ncmMetaDJ) GetAlbum() string { if m.Brand != "" { return m.Brand } return m.MainMusic.GetAlbum() } func (m *ncmMetaDJ) GetFormat() string { return m.MainMusic.GetFormat() } func (m *ncmMetaDJ) GetAlbumImageURL() string { if strings.HasPrefix(m.MainMusic.GetAlbumImageURL(), "http") { return m.MainMusic.GetAlbumImageURL() } return m.DjAvatarURL }
2.ncm.go
package ncm import ( "bytes" "context" "encoding/base64" "encoding/binary" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "unlock-music.dev/cli/algo/common" "unlock-music.dev/cli/internal/utils" ) const magicHeader = "CTENFDAM" var ( keyCore = []byte{ 0x68, 0x7a, 0x48, 0x52, 0x41, 0x6d, 0x73, 0x6f, 0x35, 0x6b, 0x49, 0x6e, 0x62, 0x61, 0x78, 0x57, } keyMeta = []byte{ 0x23, 0x31, 0x34, 0x6C, 0x6A, 0x6B, 0x5F, 0x21, 0x5C, 0x5D, 0x26, 0x30, 0x55, 0x3C, 0x27, 0x28, } ) func NewDecoder(p *common.DecoderParams) common.Decoder { return &Decoder{rd: p.Reader} } type Decoder struct { rd io.ReadSeeker // rd is the original file reader offset int cipher common.StreamDecoder metaRaw []byte metaType string meta ncmMeta cover []byte } // Validate checks if the file is a valid Netease .ncm file. // rd will be seeked to the beginning of the encrypted audio. func (d *Decoder) Validate() error { if err := d.validateMagicHeader(); err != nil { return err } if _, err := d.rd.Seek(2, io.SeekCurrent); err != nil { // 2 bytes gap return fmt.Errorf("ncm seek file: %w", err) } keyData, err := d.readKeyData() if err != nil { return err } if err := d.readMetaData(); err != nil { return fmt.Errorf("read meta date failed: %w", err) } if _, err := d.rd.Seek(5, io.SeekCurrent); err != nil { // 5 bytes gap return fmt.Errorf("ncm seek gap: %w", err) } if err := d.readCoverData(); err != nil { return fmt.Errorf("parse ncm cover file failed: %w", err) } if err := d.parseMeta(); err != nil { return fmt.Errorf("parse meta failed: %w", err) } d.cipher = newNcmCipher(keyData) return nil } func (d *Decoder) validateMagicHeader() error { header := make([]byte, len(magicHeader)) // 0x00 - 0x07 if _, err := d.rd.Read(header); err != nil { return fmt.Errorf("ncm read magic header: %w", err) } if !bytes.Equal([]byte(magicHeader), header) { return errors.New("ncm magic header not match") } return nil } func (d *Decoder) readKeyData() ([]byte, error) { bKeyLen := make([]byte, 4) // if _, err := io.ReadFull(d.rd, bKeyLen); err != nil { return nil, fmt.Errorf("ncm read key length: %w", err) } iKeyLen := binary.LittleEndian.Uint32(bKeyLen) bKeyRaw := make([]byte, iKeyLen) if _, err := io.ReadFull(d.rd, bKeyRaw); err != nil { return nil, fmt.Errorf("ncm read key data: %w", err) } for i := uint32(0); i < iKeyLen; i++ { bKeyRaw[i] ^= 0x64 } return utils.PKCS7UnPadding(utils.DecryptAES128ECB(bKeyRaw, keyCore))[17:], nil } func (d *Decoder) readMetaData() error { bMetaLen := make([]byte, 4) // if _, err := io.ReadFull(d.rd, bMetaLen); err != nil { return fmt.Errorf("ncm read key length: %w", err) } iMetaLen := binary.LittleEndian.Uint32(bMetaLen) if iMetaLen == 0 { return nil // no meta data } bMetaRaw := make([]byte, iMetaLen) if _, err := io.ReadFull(d.rd, bMetaRaw); err != nil { return fmt.Errorf("ncm read meta data: %w", err) } bMetaRaw = bMetaRaw[22:] // skip "163 key(Don't modify):" for i := 0; i < len(bMetaRaw); i++ { bMetaRaw[i] ^= 0x63 } cipherText, err := base64.StdEncoding.DecodeString(string(bMetaRaw)) if err != nil { return errors.New("decode ncm meta failed: " + err.Error()) } metaRaw := utils.PKCS7UnPadding(utils.DecryptAES128ECB(cipherText, keyMeta)) sep := bytes.IndexByte(metaRaw, ':') if sep == -1 { return errors.New("invalid ncm meta file") } d.metaType = string(metaRaw[:sep]) d.metaRaw = metaRaw[sep+1:] return nil } func (d *Decoder) readCoverData() error { bCoverCRC := make([]byte, 4) if _, err := io.ReadFull(d.rd, bCoverCRC); err != nil { return fmt.Errorf("ncm read cover crc: %w", err) } bCoverLen := make([]byte, 4) // if _, err := io.ReadFull(d.rd, bCoverLen); err != nil { return fmt.Errorf("ncm read cover length: %w", err) } iCoverLen := binary.LittleEndian.Uint32(bCoverLen) coverBuf := make([]byte, iCoverLen) if _, err := io.ReadFull(d.rd, coverBuf); err != nil { return fmt.Errorf("ncm read cover data: %w", err) } d.cover = coverBuf return nil } func (d *Decoder) parseMeta() error { switch d.metaType { case "music": d.meta = new(ncmMetaMusic) return json.Unmarshal(d.metaRaw, d.meta) case "dj": d.meta = new(ncmMetaDJ) return json.Unmarshal(d.metaRaw, d.meta) default: return errors.New("unknown ncm meta type: " + d.metaType) } } func (d *Decoder) Read(buf []byte) (int, error) { n, err := d.rd.Read(buf) if n > 0 { d.cipher.Decrypt(buf[:n], d.offset) d.offset += n } return n, err } func (d *Decoder) GetAudioExt() string { if d.meta != nil { if format := d.meta.GetFormat(); format != "" { return "." + d.meta.GetFormat() } } return "" } func (d *Decoder) GetCoverImage(ctx context.Context) ([]byte, error) { if d.cover != nil { return d.cover, nil } if d.meta == nil { return nil, errors.New("ncm meta not found") } imgURL := d.meta.GetAlbumImageURL() if !strings.HasPrefix(imgURL, "http") { return nil, nil // no cover image } // fetch cover image req, err := http.NewRequestWithContext(ctx, http.MethodGet, imgURL, nil) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("ncm download image failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("ncm download image failed: unexpected http status %s", resp.Status) } d.cover, err = io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("ncm download image failed: %w", err) } return d.cover, nil } func (d *Decoder) GetAudioMeta(_ context.Context) (common.AudioMeta, error) { return d.meta, nil } func init() { // Netease Mp3/Flac common.RegisterDecoder("ncm", false, NewDecoder) }
3.ncm_cipher.go
package ncm type ncmCipher struct { key []byte box []byte } func newNcmCipher(key []byte) *ncmCipher { return &ncmCipher{ key: key, box: buildKeyBox(key), } } func (c *ncmCipher) Decrypt(buf []byte, offset int) { for i := 0; i < len(buf); i++ { buf[i] ^= c.box[(i+offset)&0xff] } } func buildKeyBox(key []byte) []byte { box := make([]byte, 256) for i := 0; i < 256; i++ { box[i] = byte(i) } var j byte for i := 0; i < 256; i++ { j = box[i] + j + key[i%len(key)] box[i], box[j] = box[j], box[i] } ret := make([]byte, 256) var _i byte for i := 0; i < 256; i++ { _i = byte(i + 1) si := box[_i] sj := box[_i+si] ret[i] = box[si+sj] } return ret }
⑤.qmc
client
1.base.go
package client import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" ) type QQMusic struct { http *http.Client } func (c *QQMusic) rpcDoRequest(ctx context.Context, reqBody any) ([]byte, error) { reqBodyBuf, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("qqMusicClient[rpcDoRequest] marshal request: %w", err) } const endpointURL = "https://u.y.qq.com/cgi-bin/musicu.fcg" req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL+fmt.Sprintf("?pcachetime=%d", time.Now().Unix()), bytes.NewReader(reqBodyBuf), ) if err != nil { return nil, fmt.Errorf("qqMusicClient[rpcDoRequest] create request: %w", err) } req.Header.Set("Accept", "*/*") req.Header.Set("Accept-Language", "zh-CN") req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)") req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // req.Header.Set("Accept-Encoding", "gzip, deflate") reqp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("qqMusicClient[rpcDoRequest] send request: %w", err) } defer reqp.Body.Close() respBodyBuf, err := io.ReadAll(reqp.Body) if err != nil { return nil, fmt.Errorf("qqMusicClient[rpcDoRequest] read response: %w", err) } return respBodyBuf, nil } type rpcRequest struct { Method string `json:"method"` Module string `json:"module"` Param any `json:"param"` } type rpcResponse struct { Code int `json:"code"` Ts int64 `json:"ts"` StartTs int64 `json:"start_ts"` TraceID string `json:"traceid"` } type rpcSubResponse struct { Code int `json:"code"` Data json.RawMessage `json:"data"` } func (c *QQMusic) rpcCall(ctx context.Context, protocol string, method string, module string, param any, ) (json.RawMessage, error) { reqBody := map[string]any{protocol: rpcRequest{ Method: method, Module: module, Param: param, }} respBodyBuf, err := c.rpcDoRequest(ctx, reqBody) if err != nil { return nil, fmt.Errorf("qqMusicClient[rpcCall] do request: %w", err) } // check rpc response status respStatus := rpcResponse{} if err := json.Unmarshal(respBodyBuf, &respStatus); err != nil { return nil, fmt.Errorf("qqMusicClient[rpcCall] unmarshal response: %w", err) } if respStatus.Code != 0 { return nil, fmt.Errorf("qqMusicClient[rpcCall] rpc error: %d", respStatus.Code) } // parse response data var respBody map[string]json.RawMessage if err := json.Unmarshal(respBodyBuf, &respBody); err != nil { return nil, fmt.Errorf("qqMusicClient[rpcCall] unmarshal response: %w", err) } subRespBuf, ok := respBody[protocol] if !ok { return nil, fmt.Errorf("qqMusicClient[rpcCall] sub-response not found") } subResp := rpcSubResponse{} if err := json.Unmarshal(subRespBuf, &subResp); err != nil { return nil, fmt.Errorf("qqMusicClient[rpcCall] unmarshal sub-response: %w", err) } if subResp.Code != 0 { return nil, fmt.Errorf("qqMusicClient[rpcCall] sub-response error: %d", subResp.Code) } return subResp.Data, nil } func (c *QQMusic) downloadFile(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("qmc[downloadFile] init request: %w", err) } //req.Header.Set("Accept", "image/webp,image/*,*/*;q=0.8") // jpeg is preferred to embed in audio req.Header.Set("Accept-Language", "zh-CN,zh;q=0.8,en-US;q=0.6,en;q=0.5;q=0.4") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.47.134 Safari/537.36 QBCore/3.53.47.400 QQBrowser/9.0.2524.400") resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("qmc[downloadFile] send request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("qmc[downloadFile] unexpected http status %s", resp.Status) } return io.ReadAll(resp.Body) } func NewQQMusicClient() *QQMusic { return &QQMusic{ http: &http.Client{ Timeout: 10 * time.Second, }, } }
2.cover.go
package client import ( "context" "fmt" "strconv" ) func (c *QQMusic) AlbumCoverByID(ctx context.Context, albumID int) ([]byte, error) { u := fmt.Sprintf("https://imgcache.qq.com/music/photo/album/%s/albumpic_%s_0.jpg", strconv.Itoa(albumID%100), strconv.Itoa(albumID), ) return c.downloadFile(ctx, u) } func (c *QQMusic) AlbumCoverByMediaID(ctx context.Context, mediaID string) ([]byte, error) { // original: https://y.gtimg.cn/music/photo_new/T002M000%s.jpg u := fmt.Sprintf("https://y.gtimg.cn/music/photo_new/T002R500x500M000%s.jpg", mediaID) return c.downloadFile(ctx, u) }
3.search.go
package client import ( "context" "encoding/json" "fmt" ) type searchParams struct { Grp int `json:"grp"` NumPerPage int `json:"num_per_page"` PageNum int `json:"page_num"` Query string `json:"query"` RemotePlace string `json:"remoteplace"` SearchType int `json:"search_type"` //SearchID string `json:"searchid"` // todo: it seems generated randomly } type searchResponse struct { Body struct { Song struct { List []*TrackInfo `json:"list"` } `json:"song"` } `json:"body"` Code int `json:"code"` } func (c *QQMusic) Search(ctx context.Context, keyword string) ([]*TrackInfo, error) { resp, err := c.rpcCall(ctx, "music.search.SearchCgiService", "DoSearchForQQMusicDesktop", "music.search.SearchCgiService", &searchParams{ SearchType: 0, Query: keyword, PageNum: 1, NumPerPage: 40, // static values Grp: 1, RemotePlace: "sizer.newclient.song", }) if err != nil { return nil, fmt.Errorf("qqMusicClient[Search] rpc call: %w", err) } respData := searchResponse{} if err := json.Unmarshal(resp, &respData); err != nil { return nil, fmt.Errorf("qqMusicClient[Search] unmarshal response: %w", err) } return respData.Body.Song.List, nil }
4.track.go
package client import ( "context" "encoding/json" "fmt" "github.com/samber/lo" ) type getTrackInfoParams struct { Ctx int `json:"ctx"` Ids []int `json:"ids"` Types []int `json:"types"` } type getTrackInfoResponse struct { Tracks []*TrackInfo `json:"tracks"` } func (c *QQMusic) GetTracksInfo(ctx context.Context, songIDs []int) ([]*TrackInfo, error) { resp, err := c.rpcCall(ctx, "Protocol_UpdateSongInfo", "CgiGetTrackInfo", "music.trackInfo.UniformRuleCtrl", &getTrackInfoParams{Ctx: 0, Ids: songIDs, Types: []int{0}}, ) if err != nil { return nil, fmt.Errorf("qqMusicClient[GetTrackInfo] rpc call: %w", err) } respData := getTrackInfoResponse{} if err := json.Unmarshal(resp, &respData); err != nil { return nil, fmt.Errorf("qqMusicClient[GetTrackInfo] unmarshal response: %w", err) } return respData.Tracks, nil } func (c *QQMusic) GetTrackInfo(ctx context.Context, songID int) (*TrackInfo, error) { tracks, err := c.GetTracksInfo(ctx, []int{songID}) if err != nil { return nil, fmt.Errorf("qqMusicClient[GetTrackInfo] get tracks info: %w", err) } if len(tracks) == 0 { return nil, fmt.Errorf("qqMusicClient[GetTrackInfo] track not found") } return tracks[0], nil } type TrackSinger struct { Id int `json:"id"` Mid string `json:"mid"` Name string `json:"name"` Title string `json:"title"` Type int `json:"type"` Uin int `json:"uin"` Pmid string `json:"pmid"` } type TrackAlbum struct { Id int `json:"id"` Mid string `json:"mid"` Name string `json:"name"` Title string `json:"title"` Subtitle string `json:"subtitle"` Pmid string `json:"pmid"` } type TrackInfo struct { Id int `json:"id"` Type int `json:"type"` Mid string `json:"mid"` Name string `json:"name"` Title string `json:"title"` Subtitle string `json:"subtitle"` Singer []TrackSinger `json:"singer"` Album TrackAlbum `json:"album"` Mv struct { Id int `json:"id"` Vid string `json:"vid"` Name string `json:"name"` Title string `json:"title"` Vt int `json:"vt"` } `json:"mv"` Interval int `json:"interval"` Isonly int `json:"isonly"` Language int `json:"language"` Genre int `json:"genre"` IndexCd int `json:"index_cd"` IndexAlbum int `json:"index_album"` TimePublic string `json:"time_public"` Status int `json:"status"` Fnote int `json:"fnote"` File struct { MediaMid string `json:"media_mid"` Size24Aac int `json:"size_24aac"` Size48Aac int `json:"size_48aac"` Size96Aac int `json:"size_96aac"` Size192Ogg int `json:"size_192ogg"` Size192Aac int `json:"size_192aac"` Size128Mp3 int `json:"size_128mp3"` Size320Mp3 int `json:"size_320mp3"` SizeApe int `json:"size_ape"` SizeFlac int `json:"size_flac"` SizeDts int `json:"size_dts"` SizeTry int `json:"size_try"` TryBegin int `json:"try_begin"` TryEnd int `json:"try_end"` Url string `json:"url"` SizeHires int `json:"size_hires"` HiresSample int `json:"hires_sample"` HiresBitdepth int `json:"hires_bitdepth"` B30S int `json:"b_30s"` E30S int `json:"e_30s"` Size96Ogg int `json:"size_96ogg"` Size360Ra []interface{} `json:"size_360ra"` SizeDolby int `json:"size_dolby"` SizeNew []interface{} `json:"size_new"` } `json:"file"` Pay struct { PayMonth int `json:"pay_month"` PriceTrack int `json:"price_track"` PriceAlbum int `json:"price_album"` PayPlay int `json:"pay_play"` PayDown int `json:"pay_down"` PayStatus int `json:"pay_status"` TimeFree int `json:"time_free"` } `json:"pay"` Action struct { Switch int `json:"switch"` Msgid int `json:"msgid"` Alert int `json:"alert"` Icons int `json:"icons"` Msgshare int `json:"msgshare"` Msgfav int `json:"msgfav"` Msgdown int `json:"msgdown"` Msgpay int `json:"msgpay"` Switch2 int `json:"switch2"` Icon2 int `json:"icon2"` } `json:"action"` Ksong struct { Id int `json:"id"` Mid string `json:"mid"` } `json:"ksong"` Volume struct { Gain float64 `json:"gain"` Peak float64 `json:"peak"` Lra float64 `json:"lra"` } `json:"volume"` Label string `json:"label"` Url string `json:"url"` Ppurl string `json:"ppurl"` Bpm int `json:"bpm"` Version int `json:"version"` Trace string `json:"trace"` DataType int `json:"data_type"` ModifyStamp int `json:"modify_stamp"` Aid int `json:"aid"` Tid int `json:"tid"` Ov int `json:"ov"` Sa int `json:"sa"` Es string `json:"es"` Vs []string `json:"vs"` } func (t *TrackInfo) GetArtists() []string { return lo.Map(t.Singer, func(v TrackSinger, i int) string { return v.Name }) } func (t *TrackInfo) GetTitle() string { return t.Title } func (t *TrackInfo) GetAlbum() string { return t.Album.Name }
1.cipher_map.go
package qmc import "errors" type mapCipher struct { key []byte box []byte size int } func newMapCipher(key []byte) (*mapCipher, error) { if len(key) == 0 { return nil, errors.New("qmc/cipher_map: invalid key size") } c := &mapCipher{key: key, size: len(key)} c.box = make([]byte, c.size) return c, nil } func (c *mapCipher) getMask(offset int) byte { if offset > 0x7FFF { offset %= 0x7FFF } idx := (offset*offset + 71214) % c.size return c.rotate(c.key[idx], byte(idx)&0x7) } func (c *mapCipher) rotate(value byte, bits byte) byte { rotate := (bits + 4) % 8 left := value << rotate right := value >> rotate return left | right } func (c *mapCipher) Decrypt(buf []byte, offset int) { for i := 0; i < len(buf); i++ { buf[i] ^= c.getMask(offset + i) } }
2.cipher_map_test.go
package qmc import ( "fmt" "os" "reflect" "testing" ) func loadTestDataMapCipher(name string) ([]byte, []byte, []byte, error) { key, err := os.ReadFile(fmt.Sprintf("./testdata/%s_key.bin", name)) if err != nil { return nil, nil, nil, err } raw, err := os.ReadFile(fmt.Sprintf("./testdata/%s_raw.bin", name)) if err != nil { return nil, nil, nil, err } target, err := os.ReadFile(fmt.Sprintf("./testdata/%s_target.bin", name)) if err != nil { return nil, nil, nil, err } return key, raw, target, nil } func Test_mapCipher_Decrypt(t *testing.T) { tests := []struct { name string wantErr bool }{ {"mflac_map", false}, {"mgg_map", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { key, raw, target, err := loadTestDataMapCipher(tt.name) if err != nil { t.Fatalf("load testing data failed: %s", err) } c, err := newMapCipher(key) if err != nil { t.Errorf("init mapCipher failed: %s", err) return } c.Decrypt(raw, 0) if !reflect.DeepEqual(raw, target) { t.Error("overall") } }) } }
3.cipher_rc4.go
package qmc import ( "errors" ) // A rc4Cipher is an instance of RC4 using a particular key. type rc4Cipher struct { box []byte key []byte hash uint32 n int } // newRC4Cipher creates and returns a new rc4Cipher. The key argument should be the // RC4 key, at least 1 byte and at most 256 bytes. func newRC4Cipher(key []byte) (*rc4Cipher, error) { n := len(key) if n == 0 { return nil, errors.New("qmc/cipher_rc4: invalid key size") } var c = rc4Cipher{key: key, n: n} c.box = make([]byte, n) for i := 0; i < n; i++ { c.box[i] = byte(i) } var j = 0 for i := 0; i < n; i++ { j = (j + int(c.box[i]) + int(key[i%n])) % n c.box[i], c.box[j] = c.box[j], c.box[i] } c.getHashBase() return &c, nil } func (c *rc4Cipher) getHashBase() { c.hash = 1 for i := 0; i < c.n; i++ { v := uint32(c.key[i]) if v == 0 { continue } nextHash := c.hash * v if nextHash == 0 || nextHash <= c.hash { break } c.hash = nextHash } } const ( rc4SegmentSize = 5120 rc4FirstSegmentSize = 128 ) func (c *rc4Cipher) Decrypt(src []byte, offset int) { toProcess := len(src) processed := 0 markProcess := func(p int) (finished bool) { offset += p toProcess -= p processed += p return toProcess == 0 } if offset < rc4FirstSegmentSize { blockSize := toProcess if blockSize > rc4FirstSegmentSize-offset { blockSize = rc4FirstSegmentSize - offset } c.encFirstSegment(src[:blockSize], offset) if markProcess(blockSize) { return } } if offset%rc4SegmentSize != 0 { blockSize := toProcess if blockSize > rc4SegmentSize-offset%rc4SegmentSize { blockSize = rc4SegmentSize - offset%rc4SegmentSize } c.encASegment(src[processed:processed+blockSize], offset) if markProcess(blockSize) { return } } for toProcess > rc4SegmentSize { c.encASegment(src[processed:processed+rc4SegmentSize], offset) markProcess(rc4SegmentSize) } if toProcess > 0 { c.encASegment(src[processed:], offset) } } func (c *rc4Cipher) encFirstSegment(buf []byte, offset int) { for i := 0; i < len(buf); i++ { buf[i] ^= c.key[c.getSegmentSkip(offset+i)] } } func (c *rc4Cipher) encASegment(buf []byte, offset int) { box := make([]byte, c.n) copy(box, c.box) j, k := 0, 0 skipLen := (offset % rc4SegmentSize) + c.getSegmentSkip(offset/rc4SegmentSize) for i := -skipLen; i < len(buf); i++ { j = (j + 1) % c.n k = (int(box[j]) + k) % c.n box[j], box[k] = box[k], box[j] if i >= 0 { buf[i] ^= box[(int(box[j])+int(box[k]))%c.n] } } } func (c *rc4Cipher) getSegmentSkip(id int) int { seed := int(c.key[id%c.n]) idx := int64(float64(c.hash) / float64((id+1)*seed) * 100.0) return int(idx % int64(c.n)) }
4.cipher_rc4_test.go
package qmc import ( "os" "reflect" "testing" ) func loadTestRC4CipherData(name string) ([]byte, []byte, []byte, error) { prefix := "./testdata/" + name key, err := os.ReadFile(prefix + "_key.bin") if err != nil { return nil, nil, nil, err } raw, err := os.ReadFile(prefix + "_raw.bin") if err != nil { return nil, nil, nil, err } target, err := os.ReadFile(prefix + "_target.bin") if err != nil { return nil, nil, nil, err } return key, raw, target, nil } func Test_rc4Cipher_Decrypt(t *testing.T) { tests := []struct { name string wantErr bool }{ {"mflac0_rc4", false}, {"mflac_rc4", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { key, raw, target, err := loadTestRC4CipherData(tt.name) if err != nil { t.Fatalf("load testing data failed: %s", err) } c, err := newRC4Cipher(key) if err != nil { t.Errorf("init rc4Cipher failed: %s", err) return } c.Decrypt(raw, 0) if !reflect.DeepEqual(raw, target) { t.Error("overall") } }) } } func BenchmarkRc4Cipher_Decrypt(b *testing.B) { key, raw, _, err := loadTestRC4CipherData("mflac0_rc4") if err != nil { b.Fatalf("load testing data failed: %s", err) } c, err := newRC4Cipher(key) if err != nil { b.Errorf("init rc4Cipher failed: %s", err) return } b.ResetTimer() for i := 0; i < b.N; i++ { c.Decrypt(raw, 0) } } func Test_rc4Cipher_encFirstSegment(t *testing.T) { key, raw, target, err := loadTestRC4CipherData("mflac0_rc4") if err != nil { t.Fatalf("load testing data failed: %s", err) } t.Run("first-block(0~128)", func(t *testing.T) { c, err := newRC4Cipher(key) if err != nil { t.Errorf("init rc4Cipher failed: %s", err) return } c.Decrypt(raw[:128], 0) if !reflect.DeepEqual(raw[:128], target[:128]) { t.Error("first-block(0~128)") } }) } func Test_rc4Cipher_encASegment(t *testing.T) { key, raw, target, err := loadTestRC4CipherData("mflac0_rc4") if err != nil { t.Fatalf("load testing data failed: %s", err) } t.Run("align-block(128~5120)", func(t *testing.T) { c, err := newRC4Cipher(key) if err != nil { t.Errorf("init rc4Cipher failed: %s", err) return } c.Decrypt(raw[128:5120], 128) if !reflect.DeepEqual(raw[128:5120], target[128:5120]) { t.Error("align-block(128~5120)") } }) t.Run("simple-block(5120~10240)", func(t *testing.T) { c, err := newRC4Cipher(key) if err != nil { t.Errorf("init rc4Cipher failed: %s", err) return } c.Decrypt(raw[5120:10240], 5120) if !reflect.DeepEqual(raw[5120:10240], target[5120:10240]) { t.Error("align-block(128~5120)") } }) }
5.cipher_static.go
package qmc func newStaticCipher() *staticCipher { return &defaultStaticCipher } var defaultStaticCipher = staticCipher{} type staticCipher struct{} func (c *staticCipher) Decrypt(buf []byte, offset int) { for i := 0; i < len(buf); i++ { buf[i] ^= c.getMask(offset + i) } } func (c *staticCipher) getMask(offset int) byte { if offset > 0x7FFF { offset %= 0x7FFF } idx := (offset*offset + 27) & 0xff return staticCipherBox[idx] } var staticCipherBox = [...]byte{ 0x77, 0x48, 0x32, 0x73, 0xDE, 0xF2, 0xC0, 0xC8, //0x00 0x95, 0xEC, 0x30, 0xB2, 0x51, 0xC3, 0xE1, 0xA0, //0x08 0x9E, 0xE6, 0x9D, 0xCF, 0xFA, 0x7F, 0x14, 0xD1, //0x10 0xCE, 0xB8, 0xDC, 0xC3, 0x4A, 0x67, 0x93, 0xD6, //0x18 0x28, 0xC2, 0x91, 0x70, 0xCA, 0x8D, 0xA2, 0xA4, //0x20 0xF0, 0x08, 0x61, 0x90, 0x7E, 0x6F, 0xA2, 0xE0, //0x28 0xEB, 0xAE, 0x3E, 0xB6, 0x67, 0xC7, 0x92, 0xF4, //0x30 0x91, 0xB5, 0xF6, 0x6C, 0x5E, 0x84, 0x40, 0xF7, //0x38 0xF3, 0x1B, 0x02, 0x7F, 0xD5, 0xAB, 0x41, 0x89, //0x40 0x28, 0xF4, 0x25, 0xCC, 0x52, 0x11, 0xAD, 0x43, //0x48 0x68, 0xA6, 0x41, 0x8B, 0x84, 0xB5, 0xFF, 0x2C, //0x50 0x92, 0x4A, 0x26, 0xD8, 0x47, 0x6A, 0x7C, 0x95, //0x58 0x61, 0xCC, 0xE6, 0xCB, 0xBB, 0x3F, 0x47, 0x58, //0x60 0x89, 0x75, 0xC3, 0x75, 0xA1, 0xD9, 0xAF, 0xCC, //0x68 0x08, 0x73, 0x17, 0xDC, 0xAA, 0x9A, 0xA2, 0x16, //0x70 0x41, 0xD8, 0xA2, 0x06, 0xC6, 0x8B, 0xFC, 0x66, //0x78 0x34, 0x9F, 0xCF, 0x18, 0x23, 0xA0, 0x0A, 0x74, //0x80 0xE7, 0x2B, 0x27, 0x70, 0x92, 0xE9, 0xAF, 0x37, //0x88 0xE6, 0x8C, 0xA7, 0xBC, 0x62, 0x65, 0x9C, 0xC2, //0x90 0x08, 0xC9, 0x88, 0xB3, 0xF3, 0x43, 0xAC, 0x74, //0x98 0x2C, 0x0F, 0xD4, 0xAF, 0xA1, 0xC3, 0x01, 0x64, //0xA0 0x95, 0x4E, 0x48, 0x9F, 0xF4, 0x35, 0x78, 0x95, //0xA8 0x7A, 0x39, 0xD6, 0x6A, 0xA0, 0x6D, 0x40, 0xE8, //0xB0 0x4F, 0xA8, 0xEF, 0x11, 0x1D, 0xF3, 0x1B, 0x3F, //0xB8 0x3F, 0x07, 0xDD, 0x6F, 0x5B, 0x19, 0x30, 0x19, //0xC0 0xFB, 0xEF, 0x0E, 0x37, 0xF0, 0x0E, 0xCD, 0x16, //0xC8 0x49, 0xFE, 0x53, 0x47, 0x13, 0x1A, 0xBD, 0xA4, //0xD0 0xF1, 0x40, 0x19, 0x60, 0x0E, 0xED, 0x68, 0x09, //0xD8 0x06, 0x5F, 0x4D, 0xCF, 0x3D, 0x1A, 0xFE, 0x20, //0xE0 0x77, 0xE4, 0xD9, 0xDA, 0xF9, 0xA4, 0x2B, 0x76, //0xE8 0x1C, 0x71, 0xDB, 0x00, 0xBC, 0xFD, 0x0C, 0x6C, //0xF0 0xA5, 0x47, 0xF7, 0xF6, 0x00, 0x79, 0x4A, 0x11, //0xF8 }
6.key_derive.go
package qmc import ( "bytes" "encoding/base64" "errors" "fmt" "math" "golang.org/x/crypto/tea" ) func simpleMakeKey(salt byte, length int) []byte { keyBuf := make([]byte, length) for i := 0; i < length; i++ { tmp := math.Tan(float64(salt) + float64(i)*0.1) keyBuf[i] = byte(math.Abs(tmp) * 100.0) } return keyBuf } const rawKeyPrefixV2 = "QQMusic EncV2,Key:" func deriveKey(rawKey []byte) ([]byte, error) { rawKeyDec := make([]byte, base64.StdEncoding.DecodedLen(len(rawKey))) n, err := base64.StdEncoding.Decode(rawKeyDec, rawKey) if err != nil { return nil, err } rawKeyDec = rawKeyDec[:n] if bytes.HasPrefix(rawKeyDec, []byte(rawKeyPrefixV2)) { rawKeyDec, err = deriveKeyV2(bytes.TrimPrefix(rawKeyDec, []byte(rawKeyPrefixV2))) if err != nil { return nil, fmt.Errorf("deriveKeyV2 failed: %w", err) } } return deriveKeyV1(rawKeyDec) } func deriveKeyV1(rawKeyDec []byte) ([]byte, error) { if len(rawKeyDec) < 16 { return nil, errors.New("key length is too short") } simpleKey := simpleMakeKey(106, 8) teaKey := make([]byte, 16) for i := 0; i < 8; i++ { teaKey[i<<1] = simpleKey[i] teaKey[i<<1+1] = rawKeyDec[i] } rs, err := decryptTencentTea(rawKeyDec[8:], teaKey) if err != nil { return nil, err } return append(rawKeyDec[:8], rs...), nil } var ( deriveV2Key1 = []byte{ 0x33, 0x38, 0x36, 0x5A, 0x4A, 0x59, 0x21, 0x40, 0x23, 0x2A, 0x24, 0x25, 0x5E, 0x26, 0x29, 0x28, } deriveV2Key2 = []byte{ 0x2A, 0x2A, 0x23, 0x21, 0x28, 0x23, 0x24, 0x25, 0x26, 0x5E, 0x61, 0x31, 0x63, 0x5A, 0x2C, 0x54, } ) func deriveKeyV2(raw []byte) ([]byte, error) { buf, err := decryptTencentTea(raw, deriveV2Key1) if err != nil { return nil, err } buf, err = decryptTencentTea(buf, deriveV2Key2) if err != nil { return nil, err } n, err := base64.StdEncoding.Decode(buf, buf) if err != nil { return nil, err } return buf[:n], nil } func decryptTencentTea(inBuf []byte, key []byte) ([]byte, error) { const saltLen = 2 const zeroLen = 7 if len(inBuf)%8 != 0 { return nil, errors.New("inBuf size not a multiple of the block size") } if len(inBuf) < 16 { return nil, errors.New("inBuf size too small") } blk, err := tea.NewCipherWithRounds(key, 32) if err != nil { return nil, err } destBuf := make([]byte, 8) blk.Decrypt(destBuf, inBuf) padLen := int(destBuf[0] & 0x7) outLen := len(inBuf) - 1 - padLen - saltLen - zeroLen out := make([]byte, outLen) ivPrev := make([]byte, 8) ivCur := inBuf[:8] inBufPos := 8 destIdx := 1 + padLen cryptBlock := func() { ivPrev = ivCur ivCur = inBuf[inBufPos : inBufPos+8] xor8Bytes(destBuf, destBuf, inBuf[inBufPos:inBufPos+8]) blk.Decrypt(destBuf, destBuf) inBufPos += 8 destIdx = 0 } for i := 1; i <= saltLen; { if destIdx < 8 { destIdx++ i++ } else if destIdx == 8 { cryptBlock() } } outPos := 0 for outPos < outLen { if destIdx < 8 { out[outPos] = destBuf[destIdx] ^ ivPrev[destIdx] destIdx++ outPos++ } else if destIdx == 8 { cryptBlock() } } for i := 1; i <= zeroLen; i++ { if destBuf[destIdx] != ivPrev[destIdx] { return nil, errors.New("zero check failed") } } return out, nil } func xor8Bytes(dst, a, b []byte) { for i := 0; i < 8; i++ { dst[i] = a[i] ^ b[i] } }
7.key_derive_test.go
package qmc import ( "fmt" "os" "reflect" "testing" ) func TestSimpleMakeKey(t *testing.T) { expect := []byte{0x69, 0x56, 0x46, 0x38, 0x2b, 0x20, 0x15, 0x0b} t.Run("106,8", func(t *testing.T) { if got := simpleMakeKey(106, 8); !reflect.DeepEqual(got, expect) { t.Errorf("simpleMakeKey() = %v, want %v", got, expect) } }) } func loadDecryptKeyData(name string) ([]byte, []byte, error) { keyRaw, err := os.ReadFile(fmt.Sprintf("./testdata/%s_key_raw.bin", name)) if err != nil { return nil, nil, err } keyDec, err := os.ReadFile(fmt.Sprintf("./testdata/%s_key.bin", name)) if err != nil { return nil, nil, err } return keyRaw, keyDec, nil } func TestDecryptKey(t *testing.T) { tests := []struct { name string filename string wantErr bool }{ {"mflac0_rc4(512)", "mflac0_rc4", false}, {"mflac_map(256)", "mflac_map", false}, {"mflac_rc4(256)", "mflac_rc4", false}, {"mgg_map(256)", "mgg_map", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { raw, want, err := loadDecryptKeyData(tt.filename) if err != nil { t.Fatalf("load test data failed: %s", err) } got, err := deriveKey(raw) if (err != nil) != tt.wantErr { t.Errorf("deriveKey() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, want) { t.Errorf("deriveKey() got = %v..., want %v...", string(got[:32]), string(want[:32])) } }) } }
8.key_mmkv.go
package qmc import ( "errors" "fmt" "os" "path/filepath" "runtime" "github.com/samber/lo" "go.uber.org/zap" "golang.org/x/exp/slices" "golang.org/x/text/unicode/norm" "unlock-music.dev/mmkv" ) var streamKeyVault mmkv.Vault // TODO: move to factory func readKeyFromMMKV(file string, logger *zap.Logger) ([]byte, error) { if file == "" { return nil, errors.New("file path is required while reading key from mmkv") } //goland:noinspection GoBoolExpressions if runtime.GOOS != "darwin" { return nil, errors.New("mmkv vault not supported on this platform") } if streamKeyVault == nil { mmkvDir, err := getRelativeMMKVDir(file) if err != nil { mmkvDir, err = getDefaultMMKVDir() if err != nil { return nil, fmt.Errorf("mmkv key valut not found: %w", err) } } mgr, err := mmkv.NewManager(mmkvDir) if err != nil { return nil, fmt.Errorf("init mmkv manager: %w", err) } streamKeyVault, err = mgr.OpenVault("MMKVStreamEncryptId") if err != nil { return nil, fmt.Errorf("open mmkv vault: %w", err) } logger.Debug("mmkv vault opened", zap.Strings("keys", streamKeyVault.Keys())) } _, partName := filepath.Split(file) partName = normalizeUnicode(partName) buf, err := streamKeyVault.GetBytes(file) if buf == nil { filePaths := streamKeyVault.Keys() fileNames := lo.Map(filePaths, func(filePath string, _ int) string { _, name := filepath.Split(filePath) return normalizeUnicode(name) }) for _, key := range fileNames { // fallback: match filename only if key != partName { continue } idx := slices.Index(fileNames, key) buf, err = streamKeyVault.GetBytes(filePaths[idx]) if err != nil { logger.Warn("read key from mmkv", zap.String("key", filePaths[idx]), zap.Error(err)) } } } if len(buf) == 0 { return nil, errors.New("key not found in mmkv vault") } return deriveKey(buf) } func getRelativeMMKVDir(file string) (string, error) { mmkvDir := filepath.Join(filepath.Dir(file), "../mmkv") if _, err := os.Stat(mmkvDir); err != nil { return "", fmt.Errorf("stat default mmkv dir: %w", err) } keyFile := filepath.Join(mmkvDir, "MMKVStreamEncryptId") if _, err := os.Stat(keyFile); err != nil { return "", fmt.Errorf("stat default mmkv file: %w", err) } return mmkvDir, nil } func getDefaultMMKVDir() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("get user home dir: %w", err) } mmkvDir := filepath.Join( homeDir, "Library/Containers/com.tencent.QQMusicMac/Data", // todo: make configurable "Library/Application Support/QQMusicMac/mmkv", ) if _, err := os.Stat(mmkvDir); err != nil { return "", fmt.Errorf("stat default mmkv dir: %w", err) } keyFile := filepath.Join(mmkvDir, "MMKVStreamEncryptId") if _, err := os.Stat(keyFile); err != nil { return "", fmt.Errorf("stat default mmkv file: %w", err) } return mmkvDir, nil } // normalizeUnicode normalizes unicode string to NFC. // since macOS may change some characters in the file name. // e.g. "ぜ"(e3 81 9c) -> "ぜ"(e3 81 9b e3 82 99) func normalizeUnicode(str string) string { return norm.NFC.String(str) }
9.qmc.go
package qmc import ( "bytes" "encoding/binary" "errors" "fmt" "io" "runtime" "strconv" "strings" "go.uber.org/zap" "unlock-music.dev/cli/algo/common" "unlock-music.dev/cli/internal/sniff" ) type Decoder struct { raw io.ReadSeeker // raw is the original file reader params *common.DecoderParams audio io.Reader // audio is the encrypted audio data audioLen int // audioLen is the audio data length offset int // offset is the current audio read position decodedKey []byte // decodedKey is the decoded key for cipher cipher common.StreamDecoder songID int rawMetaExtra2 int albumID int albumMediaID string // cache meta common.AudioMeta cover []byte embeddedCover bool // embeddedCover is true if the cover is embedded in the file probeBuf *bytes.Buffer // probeBuf is the buffer for sniffing metadata, TODO: consider pipe? // provider logger *zap.Logger } // Read implements io.Reader, offer the decrypted audio data. // Validate should call before Read to check if the file is valid. func (d *Decoder) Read(p []byte) (int, error) { n, err := d.audio.Read(p) if n > 0 { d.cipher.Decrypt(p[:n], d.offset) d.offset += n _, _ = d.probeBuf.Write(p[:n]) // bytes.Buffer.Write never return error } return n, err } func NewDecoder(p *common.DecoderParams) common.Decoder { return &Decoder{raw: p.Reader, params: p, logger: p.Logger} } func (d *Decoder) Validate() error { // search & derive key err := d.searchKey() if err != nil { return err } // check cipher type and init decode cipher if len(d.decodedKey) > 300 { d.cipher, err = newRC4Cipher(d.decodedKey) if err != nil { return err } } else if len(d.decodedKey) != 0 { d.cipher, err = newMapCipher(d.decodedKey) if err != nil { return err } } else { d.cipher = newStaticCipher() } // test with first 16 bytes if err := d.validateDecode(); err != nil { return err } // reset position, limit to audio, prepare for Read if _, err := d.raw.Seek(0, io.SeekStart); err != nil { return err } d.audio = io.LimitReader(d.raw, int64(d.audioLen)) // prepare for sniffing metadata d.probeBuf = bytes.NewBuffer(make([]byte, 0, d.audioLen)) return nil } func (d *Decoder) validateDecode() error { _, err := d.raw.Seek(0, io.SeekStart) if err != nil { return fmt.Errorf("qmc seek to start: %w", err) } buf := make([]byte, 64) if _, err := io.ReadFull(d.raw, buf); err != nil { return fmt.Errorf("qmc read header: %w", err) } d.cipher.Decrypt(buf, 0) _, ok := sniff.AudioExtension(buf) if !ok { return errors.New("qmc: detect file type failed") } return nil } func (d *Decoder) searchKey() (err error) { fileSizeM4, err := d.raw.Seek(-4, io.SeekEnd) if err != nil { return err } fileSize := int(fileSizeM4) + 4 //goland:noinspection GoBoolExpressions if runtime.GOOS == "darwin" && !strings.HasPrefix(d.params.Extension, ".qmc") { d.decodedKey, err = readKeyFromMMKV(d.params.FilePath, d.logger) if err == nil { d.audioLen = fileSize return } d.logger.Warn("read key from mmkv failed", zap.Error(err)) } suffixBuf := make([]byte, 4) if _, err := io.ReadFull(d.raw, suffixBuf); err != nil { return err } switch string(suffixBuf) { case "QTag": return d.readRawMetaQTag() case "STag": return errors.New("qmc: file with 'STag' suffix doesn't contains media key") default: size := binary.LittleEndian.Uint32(suffixBuf) if size <= 0xFFFF && size != 0 { // assume size is key len return d.readRawKey(int64(size)) } // try to use default static cipher d.audioLen = fileSize return nil } } func (d *Decoder) readRawKey(rawKeyLen int64) error { audioLen, err := d.raw.Seek(-(4 + rawKeyLen), io.SeekEnd) if err != nil { return err } d.audioLen = int(audioLen) rawKeyData, err := io.ReadAll(io.LimitReader(d.raw, rawKeyLen)) if err != nil { return err } // clean suffix NULs rawKeyData = bytes.TrimRight(rawKeyData, "\x00") d.decodedKey, err = deriveKey(rawKeyData) if err != nil { return err } return nil } func (d *Decoder) readRawMetaQTag() error { // get raw meta data len if _, err := d.raw.Seek(-8, io.SeekEnd); err != nil { return err } buf, err := io.ReadAll(io.LimitReader(d.raw, 4)) if err != nil { return err } rawMetaLen := int64(binary.BigEndian.Uint32(buf)) // read raw meta data audioLen, err := d.raw.Seek(-(8 + rawMetaLen), io.SeekEnd) if err != nil { return err } d.audioLen = int(audioLen) rawMetaData, err := io.ReadAll(io.LimitReader(d.raw, rawMetaLen)) if err != nil { return err } items := strings.Split(string(rawMetaData), ",") if len(items) != 3 { return errors.New("invalid raw meta data") } d.decodedKey, err = deriveKey([]byte(items[0])) if err != nil { return err } d.songID, err = strconv.Atoi(items[1]) if err != nil { return err } d.rawMetaExtra2, err = strconv.Atoi(items[2]) if err != nil { return err } return nil } //goland:noinspection SpellCheckingInspection func init() { supportedExts := []string{ "qmc0", "qmc3", //QQ Music MP3 "qmc2", "qmc4", "qmc6", "qmc8", //QQ Music M4A "qmcflac", //QQ Music FLAC "qmcogg", //QQ Music OGG "tkm", //QQ Music Accompaniment M4A "bkcmp3", "bkcm4a", "bkcflac", "bkcwav", "bkcape", "bkcogg", "bkcwma", //Moo Music "666c6163", //QQ Music Weiyun Flac "6d7033", //QQ Music Weiyun Mp3 "6f6767", //QQ Music Weiyun Ogg "6d3461", //QQ Music Weiyun M4a "776176", //QQ Music Weiyun Wav "mgg", "mgg1", "mggl", //QQ Music New Ogg "mflac", "mflac0", "mflach", //QQ Music New Flac "mmp4", // QQ Music MP4 Container, tipically used for Dolby EAC3 stream } for _, ext := range supportedExts { common.RegisterDecoder(ext, false, NewDecoder) } }
10.qmc_meta.go
package qmc import ( "context" "errors" "fmt" "strings" "github.com/samber/lo" "unlock-music.dev/cli/algo/common" "unlock-music.dev/cli/algo/qmc/client" "unlock-music.dev/cli/internal/ffmpeg" ) func (d *Decoder) GetAudioMeta(ctx context.Context) (common.AudioMeta, error) { if d.meta != nil { return d.meta, nil } if d.songID != 0 { if err := d.getMetaBySongID(ctx); err != nil { return nil, err } return d.meta, nil } embedMeta, err := ffmpeg.ProbeReader(ctx, d.probeBuf) if err != nil { return nil, fmt.Errorf("qmc[GetAudioMeta] probe reader: %w", err) } d.meta = embedMeta d.embeddedCover = embedMeta.HasAttachedPic() if !d.embeddedCover && embedMeta.HasMetadata() { if err := d.searchMetaOnline(ctx, embedMeta); err != nil { return nil, err } return d.meta, nil } return d.meta, nil } func (d *Decoder) getMetaBySongID(ctx context.Context) error { c := client.NewQQMusicClient() // todo: use global client trackInfo, err := c.GetTrackInfo(ctx, d.songID) if err != nil { return fmt.Errorf("qmc[GetAudioMeta] get track info: %w", err) } d.meta = trackInfo d.albumID = trackInfo.Album.Id if trackInfo.Album.Pmid == "" { d.albumMediaID = trackInfo.Album.Pmid } else { d.albumMediaID = trackInfo.Album.Mid } return nil } func (d *Decoder) searchMetaOnline(ctx context.Context, original common.AudioMeta) error { c := client.NewQQMusicClient() // todo: use global client keyword := lo.WithoutEmpty(append( []string{original.GetTitle(), original.GetAlbum()}, original.GetArtists()...), ) if len(keyword) == 0 { return errors.New("qmc[searchMetaOnline] no keyword") } trackList, err := c.Search(ctx, strings.Join(keyword, " ")) if err != nil { return fmt.Errorf("qmc[searchMetaOnline] search: %w", err) } if len(trackList) == 0 { return errors.New("qmc[searchMetaOnline] no result") } meta := trackList[0] d.meta = meta d.albumID = meta.Album.Id if meta.Album.Pmid == "" { d.albumMediaID = meta.Album.Pmid } else { d.albumMediaID = meta.Album.Mid } return nil } func (d *Decoder) GetCoverImage(ctx context.Context) ([]byte, error) { if d.cover != nil { return d.cover, nil } if d.embeddedCover { img, err := ffmpeg.ExtractAlbumArt(ctx, d.probeBuf) if err != nil { return nil, fmt.Errorf("qmc[GetCoverImage] extract album art: %w", err) } d.cover = img.Bytes() return d.cover, nil } c := client.NewQQMusicClient() // todo: use global client var err error if d.albumMediaID != "" { d.cover, err = c.AlbumCoverByMediaID(ctx, d.albumMediaID) if err != nil { return nil, fmt.Errorf("qmc[GetCoverImage] get cover by media id: %w", err) } } else if d.albumID != 0 { d.cover, err = c.AlbumCoverByID(ctx, d.albumID) if err != nil { return nil, fmt.Errorf("qmc[GetCoverImage] get cover by id: %w", err) } } else { return nil, errors.New("qmc[GetAudioMeta] album (or media) id is empty") } return d.cover, nil }
11.qmc_test.go
package qmc import ( "bytes" "fmt" "io" "os" "reflect" "testing" "unlock-music.dev/cli/algo/common" ) func loadTestDataQmcDecoder(filename string) ([]byte, []byte, error) { encBody, err := os.ReadFile(fmt.Sprintf("./testdata/%s_raw.bin", filename)) if err != nil { return nil, nil, err } encSuffix, err := os.ReadFile(fmt.Sprintf("./testdata/%s_suffix.bin", filename)) if err != nil { return nil, nil, err } target, err := os.ReadFile(fmt.Sprintf("./testdata/%s_target.bin", filename)) if err != nil { return nil, nil, err } return bytes.Join([][]byte{encBody, encSuffix}, nil), target, nil } func TestMflac0Decoder_Read(t *testing.T) { tests := []struct { name string fileExt string wantErr bool }{ {"mflac0_rc4", ".mflac0", false}, {"mflac_rc4", ".mflac", false}, {"mflac_map", ".mflac", false}, {"mgg_map", ".mgg", false}, {"qmc0_static", ".qmc0", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { raw, target, err := loadTestDataQmcDecoder(tt.name) if err != nil { t.Fatal(err) } d := NewDecoder(&common.DecoderParams{ Reader: bytes.NewReader(raw), Extension: tt.fileExt, }) if err := d.Validate(); err != nil { t.Errorf("validate file error = %v", err) } buf := make([]byte, len(target)) if _, err := io.ReadFull(d, buf); err != nil { t.Errorf("read bytes from decoder error = %v", err) return } if !reflect.DeepEqual(buf, target) { t.Errorf("Decrypt() got = %v, want %v", buf[:32], target[:32]) } }) } } func TestMflac0Decoder_Validate(t *testing.T) { tests := []struct { name string fileExt string wantErr bool }{ {"mflac0_rc4", ".flac", false}, {"mflac_map", ".flac", false}, {"mgg_map", ".ogg", false}, {"qmc0_static", ".mp3", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { raw, _, err := loadTestDataQmcDecoder(tt.name) if err != nil { t.Fatal(err) } d := NewDecoder(&common.DecoderParams{ Reader: bytes.NewReader(raw), Extension: tt.fileExt, }) if err := d.Validate(); err != nil { t.Errorf("read bytes from decoder error = %v", err) return } }) } }
⑥.tm
1.tm.go
package tm import ( "bytes" "errors" "fmt" "io" "unlock-music.dev/cli/algo/common" "unlock-music.dev/cli/internal/sniff" ) var replaceHeader = []byte{0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70} var magicHeader = []byte{0x51, 0x51, 0x4D, 0x55} //0x15, 0x1D, 0x1A, 0x21 type Decoder struct { raw io.ReadSeeker // raw is the original file reader offset int audio io.Reader // audio is the decrypted audio data } func (d *Decoder) Validate() error { header := make([]byte, 8) if _, err := io.ReadFull(d.raw, header); err != nil { return fmt.Errorf("tm read header: %w", err) } if bytes.Equal(magicHeader, header[:len(magicHeader)]) { // replace m4a header d.audio = io.MultiReader(bytes.NewReader(replaceHeader), d.raw) return nil } if _, ok := sniff.AudioExtension(header); ok { // not encrypted d.audio = io.MultiReader(bytes.NewReader(header), d.raw) return nil } return errors.New("tm: valid magic header") } func (d *Decoder) Read(buf []byte) (int, error) { return d.audio.Read(buf) } func NewTmDecoder(p *common.DecoderParams) common.Decoder { return &Decoder{raw: p.Reader} } func init() { // QQ Music IOS M4a (replace header) common.RegisterDecoder("tm2", false, NewTmDecoder) common.RegisterDecoder("tm6", false, NewTmDecoder) // QQ Music IOS Mp3 (not encrypted) common.RegisterDecoder("tm0", false, NewTmDecoder) common.RegisterDecoder("tm3", false, NewTmDecoder) }
⑦.xiami
1.xm.go
package xiami import ( "bytes" "errors" "fmt" "io" "unlock-music.dev/cli/algo/common" ) var ( magicHeader = []byte{'i', 'f', 'm', 't'} magicHeader2 = []byte{0xfe, 0xfe, 0xfe, 0xfe} typeMapping = map[string]string{ " WAV": "wav", "FLAC": "flac", " MP3": "mp3", " A4M": "m4a", } ErrMagicHeader = errors.New("xm magic header not matched") ) type Decoder struct { rd io.ReadSeeker // rd is the original file reader offset int cipher common.StreamDecoder outputExt string } func (d *Decoder) GetAudioExt() string { if d.outputExt != "" { return "." + d.outputExt } return "" } func NewDecoder(p *common.DecoderParams) common.Decoder { return &Decoder{rd: p.Reader} } // Validate checks if the file is a valid xiami .xm file. // rd will set to the beginning of the encrypted audio data. func (d *Decoder) Validate() error { header := make([]byte, 16) // xm header is fixed to 16 bytes if _, err := io.ReadFull(d.rd, header); err != nil { return fmt.Errorf("xm read header: %w", err) } // 0x00 - 0x03 and 0x08 - 0x0B: magic header if !bytes.Equal(magicHeader, header[:4]) || !bytes.Equal(magicHeader2, header[8:12]) { return ErrMagicHeader } // 0x04 - 0x07: Audio File Type var ok bool d.outputExt, ok = typeMapping[string(header[4:8])] if !ok { return fmt.Errorf("xm detect unknown audio type: %s", string(header[4:8])) } // 0x0C - 0x0E, Encrypt Start At, LittleEndian Unit24 encStartAt := uint32(header[12]) | uint32(header[13])<<8 | uint32(header[14])<<16 // 0x0F, XOR Mask d.cipher = newXmCipher(header[15], int(encStartAt)) return nil } func (d *Decoder) Read(p []byte) (int, error) { n, err := d.rd.Read(p) if n > 0 { d.cipher.Decrypt(p[:n], d.offset) d.offset += n } return n, err } func init() { // Xiami Wav/M4a/Mp3/Flac common.RegisterDecoder("xm", false, NewDecoder) // Xiami Typed Format common.RegisterDecoder("wav", false, NewDecoder) common.RegisterDecoder("mp3", false, NewDecoder) common.RegisterDecoder("flac", false, NewDecoder) common.RegisterDecoder("m4a", false, NewDecoder) }
2.xm_cipher.go
package xiami type xmCipher struct { mask byte encryptStartAt int } func newXmCipher(mask byte, encryptStartAt int) *xmCipher { return &xmCipher{ mask: mask, encryptStartAt: encryptStartAt, } } func (c *xmCipher) Decrypt(buf []byte, offset int) { for i := 0; i < len(buf); i++ { if offset+i >= c.encryptStartAt { buf[i] ^= c.mask } } }
⑧.ximalaya
1.x2m_crypto.go
package ximalaya import ( _ "embed" "encoding/binary" ) const x2mHeaderSize = 1024 var x2mKey = [...]byte{'x', 'm', 'l', 'y'} var x2mScrambleTable = [x2mHeaderSize]uint16{} //go:embed x2m_scramble_table.bin var x2mScrambleTableBytes []byte func init() { if len(x2mScrambleTableBytes) != 2*x2mHeaderSize { panic("invalid x2m scramble table") } for i := range x2mScrambleTable { x2mScrambleTable[i] = binary.LittleEndian.Uint16(x2mScrambleTableBytes[i*2:]) } } // decryptX2MHeader decrypts the header of ximalaya .x2m file. // make sure input src is 1024(x2mHeaderSize) bytes long. func decryptX2MHeader(src []byte) []byte { dst := make([]byte, len(src)) for dstIdx := range src { srcIdx := x2mScrambleTable[dstIdx] dst[dstIdx] = src[srcIdx] ^ x2mKey[dstIdx%len(x2mKey)] } return dst }
2.x3m_crypto.go
package ximalaya import ( _ "embed" "encoding/binary" ) var x3mKey = [...]byte{ '3', '9', '8', '9', 'd', '1', '1', '1', 'a', 'a', 'd', '5', '6', '1', '3', '9', '4', '0', 'f', '4', 'f', 'c', '4', '4', 'b', '6', '3', '9', 'b', '2', '9', '2', } const x3mHeaderSize = 1024 var x3mScrambleTable = [x3mHeaderSize]uint16{} //go:embed x3m_scramble_table.bin var x3mScrambleTableBytes []byte func init() { if len(x3mScrambleTableBytes) != 2*x3mHeaderSize { panic("invalid x3m scramble table") } for i := range x3mScrambleTable { x3mScrambleTable[i] = binary.LittleEndian.Uint16(x3mScrambleTableBytes[i*2:]) } } // decryptX3MHeader decrypts the header of ximalaya .x3m file. // make sure input src is 1024 (x3mHeaderSize) bytes long. func decryptX3MHeader(src []byte) []byte { dst := make([]byte, len(src)) for dstIdx := range src { srcIdx := x3mScrambleTable[dstIdx] dst[dstIdx] = src[srcIdx] ^ x3mKey[dstIdx%len(x3mKey)] } return dst }
3.ximalaya.go
package ximalaya import ( "bytes" "fmt" "io" "unlock-music.dev/cli/algo/common" "unlock-music.dev/cli/internal/sniff" ) type Decoder struct { rd io.ReadSeeker offset int audio io.Reader } func NewDecoder(p *common.DecoderParams) common.Decoder { return &Decoder{rd: p.Reader} } func (d *Decoder) Validate() error { encryptedHeader := make([]byte, x2mHeaderSize) if _, err := io.ReadFull(d.rd, encryptedHeader); err != nil { return fmt.Errorf("ximalaya read header: %w", err) } { // try to decode with x2m header := decryptX2MHeader(encryptedHeader) if _, ok := sniff.AudioExtension(header); ok { d.audio = io.MultiReader(bytes.NewReader(header), d.rd) return nil } } { // try to decode with x3m // not read file again, since x2m and x3m have the same header size header := decryptX3MHeader(encryptedHeader) if _, ok := sniff.AudioExtension(header); ok { d.audio = io.MultiReader(bytes.NewReader(header), d.rd) return nil } } return fmt.Errorf("ximalaya: unknown format") } func (d *Decoder) Read(p []byte) (n int, err error) { return d.audio.Read(p) } func init() { common.RegisterDecoder("x2m", false, NewDecoder) common.RegisterDecoder("x3m", false, NewDecoder) common.RegisterDecoder("xm", false, NewDecoder) }
二、cmd
①.um
1.main.go
package main import ( "bytes" "context" "errors" "fmt" "io" "os" "path/filepath" "runtime" "runtime/debug" "sort" "strings" "time" "github.com/urfave/cli/v2" "go.uber.org/zap" "unlock-music.dev/cli/algo/common" _ "unlock-music.dev/cli/algo/kgm" _ "unlock-music.dev/cli/algo/kwm" _ "unlock-music.dev/cli/algo/ncm" _ "unlock-music.dev/cli/algo/qmc" _ "unlock-music.dev/cli/algo/tm" _ "unlock-music.dev/cli/algo/xiami" _ "unlock-music.dev/cli/algo/ximalaya" "unlock-music.dev/cli/internal/ffmpeg" "unlock-music.dev/cli/internal/logging" "unlock-music.dev/cli/internal/sniff" "unlock-music.dev/cli/internal/utils" ) var AppVersion = "v0.0.6" var logger, _ = logging.NewZapLogger() // TODO: inject logger to application, instead of using global logger func main() { module, ok := debug.ReadBuildInfo() if ok && module.Main.Version != "(devel)" { AppVersion = module.Main.Version } app := cli.App{ Name: "Unlock Music CLI", HelpName: "um", Usage: "Unlock your encrypted music file https://git.unlock-music.dev/um/cli", Version: fmt.Sprintf("%s (%s,%s/%s)", AppVersion, runtime.Version(), runtime.GOOS, runtime.GOARCH), Flags: []cli.Flag{ &cli.StringFlag{Name: "input", Aliases: []string{"i"}, Usage: "path to input file or dir", Required: false}, &cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "path to output dir", Required: false}, &cli.BoolFlag{Name: "remove-source", Aliases: []string{"rs"}, Usage: "remove source file", Required: false, Value: false}, &cli.BoolFlag{Name: "skip-noop", Aliases: []string{"n"}, Usage: "skip noop decoder", Required: false, Value: true}, &cli.BoolFlag{Name: "update-metadata", Usage: "update metadata & album art from network", Required: false, Value: false}, &cli.BoolFlag{Name: "supported-ext", Usage: "show supported file extensions and exit", Required: false, Value: false}, }, Action: appMain, Copyright: fmt.Sprintf("Copyright (c) 2020 - %d Unlock Music https://git.unlock-music.dev/um/cli/src/branch/master/LICENSE", time.Now().Year()), HideHelpCommand: true, UsageText: "um [-o /path/to/output/dir] [--extra-flags] [-i] /path/to/input", } err := app.Run(os.Args) if err != nil { logger.Fatal("run app failed", zap.Error(err)) } } func printSupportedExtensions() { var exts []string for ext := range common.DecoderRegistry { exts = append(exts, ext) } sort.Strings(exts) for _, ext := range exts { fmt.Printf("%s: %d\n", ext, len(common.DecoderRegistry[ext])) } } func appMain(c *cli.Context) (err error) { if c.Bool("supported-ext") { printSupportedExtensions() return nil } input := c.String("input") if input == "" { switch c.Args().Len() { case 0: input, err = os.Getwd() if err != nil { return err } case 1: input = c.Args().Get(0) default: return errors.New("please specify input file (or directory)") } } output := c.String("output") if output == "" { var err error output, err = os.Getwd() if err != nil { return err } if input == output { return errors.New("input and output path are same") } } inputStat, err := os.Stat(input) if err != nil { return err } outputStat, err := os.Stat(output) if err != nil { if errors.Is(err, os.ErrNotExist) { err = os.MkdirAll(output, 0755) } if err != nil { return err } } else if !outputStat.IsDir() { return errors.New("output should be a writable directory") } proc := &processor{ outputDir: output, skipNoopDecoder: c.Bool("skip-noop"), removeSource: c.Bool("remove-source"), updateMetadata: c.Bool("update-metadata"), } if inputStat.IsDir() { return proc.processDir(input) } else { return proc.processFile(input) } } type processor struct { outputDir string skipNoopDecoder bool removeSource bool updateMetadata bool } func (p *processor) processDir(inputDir string) error { items, err := os.ReadDir(inputDir) if err != nil { return err } for _, item := range items { if item.IsDir() { continue } filePath := filepath.Join(inputDir, item.Name()) allDec := common.GetDecoder(filePath, p.skipNoopDecoder) if len(allDec) == 0 { logger.Info("skipping while no suitable decoder", zap.String("source", item.Name())) continue } if err := p.process(filePath, allDec); err != nil { logger.Error("conversion failed", zap.String("source", item.Name()), zap.Error(err)) } } return nil } func (p *processor) processFile(filePath string) error { allDec := common.GetDecoder(filePath, p.skipNoopDecoder) if len(allDec) == 0 { logger.Fatal("skipping while no suitable decoder") } return p.process(filePath, allDec) } func (p *processor) process(inputFile string, allDec []common.NewDecoderFunc) error { file, err := os.Open(inputFile) if err != nil { return err } defer file.Close() logger := logger.With(zap.String("source", inputFile)) decParams := &common.DecoderParams{ Reader: file, Extension: filepath.Ext(inputFile), FilePath: inputFile, Logger: logger, } var dec common.Decoder for _, decFunc := range allDec { dec = decFunc(decParams) if err := dec.Validate(); err == nil { break } else { logger.Warn("try decode failed", zap.Error(err)) dec = nil } } if dec == nil { return errors.New("no any decoder can resolve the file") } params := &ffmpeg.UpdateMetadataParams{} header := bytes.NewBuffer(nil) _, err = io.CopyN(header, dec, 64) if err != nil { return fmt.Errorf("read header failed: %w", err) } audio := io.MultiReader(header, dec) params.AudioExt = sniff.AudioExtensionWithFallback(header.Bytes(), ".mp3") if p.updateMetadata { if audioMetaGetter, ok := dec.(common.AudioMetaGetter); ok { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // since ffmpeg doesn't support multiple input streams, // we need to write the audio to a temp file. // since qmc decoder doesn't support seeking & relying on ffmpeg probe, we need to read the whole file. // TODO: support seeking or using pipe for qmc decoder. params.Audio, err = utils.WriteTempFile(audio, params.AudioExt) if err != nil { return fmt.Errorf("updateAudioMeta write temp file: %w", err) } defer os.Remove(params.Audio) params.Meta, err = audioMetaGetter.GetAudioMeta(ctx) if err != nil { logger.Warn("get audio meta failed", zap.Error(err)) } if params.Meta == nil { // reset audio meta if failed audio, err = os.Open(params.Audio) if err != nil { return fmt.Errorf("updateAudioMeta open temp file: %w", err) } } } } if p.updateMetadata && params.Meta != nil { if coverGetter, ok := dec.(common.CoverImageGetter); ok { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if cover, err := coverGetter.GetCoverImage(ctx); err != nil { logger.Warn("get cover image failed", zap.Error(err)) } else if imgExt, ok := sniff.ImageExtension(cover); !ok { logger.Warn("sniff cover image type failed", zap.Error(err)) } else { params.AlbumArtExt = imgExt params.AlbumArt = cover } } } inFilename := strings.TrimSuffix(filepath.Base(inputFile), filepath.Ext(inputFile)) outPath := filepath.Join(p.outputDir, inFilename+params.AudioExt) if params.Meta == nil { outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { return err } defer outFile.Close() if _, err := io.Copy(outFile, audio); err != nil { return err } outFile.Close() } else { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() if err := ffmpeg.UpdateMeta(ctx, outPath, params); err != nil { return err } } // if source file need to be removed if p.removeSource { err := os.RemoveAll(inputFile) if err != nil { return err } logger.Info("successfully converted, and source file is removed", zap.String("source", inputFile), zap.String("destination", outPath)) } else { logger.Info("successfully converted", zap.String("source", inputFile), zap.String("destination", outPath)) } return nil }
三、internal
①.ffmpeg
1.ffmpeg.go
package ffmpeg import ( "bytes" "context" "fmt" "io" "os" "os/exec" "strings" "unlock-music.dev/cli/algo/common" "unlock-music.dev/cli/internal/utils" ) func ExtractAlbumArt(ctx context.Context, rd io.Reader) (*bytes.Buffer, error) { cmd := exec.CommandContext(ctx, "ffmpeg", "-i", "pipe:0", // input from stdin "-an", // disable audio "-codec:v", "copy", // copy video(image) codec "-f", "image2", // use image2 muxer "pipe:1", // output to stdout ) cmd.Stdin = rd stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} cmd.Stdout, cmd.Stderr = stdout, stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("ffmpeg run: %w", err) } return stdout, nil } type UpdateMetadataParams struct { Audio string // required AudioExt string // required Meta common.AudioMeta // required AlbumArt []byte // optional AlbumArtExt string // required if AlbumArt is not nil } func UpdateMeta(ctx context.Context, outPath string, params *UpdateMetadataParams) error { if params.AudioExt == ".flac" { return updateMetaFlac(ctx, outPath, params) } else { return updateMetaFFmpeg(ctx, outPath, params) } } func updateMetaFFmpeg(ctx context.Context, outPath string, params *UpdateMetadataParams) error { builder := newFFmpegBuilder() out := newOutputBuilder(outPath) // output to file builder.SetFlag("y") // overwrite output file builder.AddOutput(out) // input audio -> output audio builder.AddInput(newInputBuilder(params.Audio)) // input 0: audio out.AddOption("map", "0:a") out.AddOption("codec:a", "copy") // input cover -> output cover if params.AlbumArt != nil && params.AudioExt != ".wav" /* wav doesn't support attached image */ { // write cover to temp file artPath, err := utils.WriteTempFile(bytes.NewReader(params.AlbumArt), params.AlbumArtExt) if err != nil { return fmt.Errorf("updateAudioMeta write temp file: %w", err) } defer os.Remove(artPath) builder.AddInput(newInputBuilder(artPath)) // input 1: cover out.AddOption("map", "1:v") switch params.AudioExt { case ".ogg": // ogg only supports theora codec out.AddOption("codec:v", "libtheora") case ".m4a": // .m4a(mp4) requires set codec, disposition, stream metadata out.AddOption("codec:v", "mjpeg") out.AddOption("disposition:v", "attached_pic") out.AddMetadata("s:v", "title", "Album cover") out.AddMetadata("s:v", "comment", "Cover (front)") case ".mp3": out.AddOption("codec:v", "mjpeg") out.AddMetadata("s:v", "title", "Album cover") out.AddMetadata("s:v", "comment", "Cover (front)") default: // other formats use default behavior } } // set file metadata album := params.Meta.GetAlbum() if album != "" { out.AddMetadata("", "album", album) } title := params.Meta.GetTitle() if album != "" { out.AddMetadata("", "title", title) } artists := params.Meta.GetArtists() if len(artists) != 0 { // TODO: it seems that ffmpeg doesn't support multiple artists out.AddMetadata("", "artist", strings.Join(artists, " / ")) } if params.AudioExt == ".mp3" { out.AddOption("write_id3v1", "true") out.AddOption("id3v2_version", "3") } // execute ffmpeg cmd := builder.Command(ctx) if stdout, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("ffmpeg run: %w, %s", err, string(stdout)) } return nil }
2.ffprobe.go
package ffmpeg import ( "bytes" "context" "encoding/json" "io" "os/exec" "strings" "github.com/samber/lo" ) type Result struct { Format *Format `json:"format"` Streams []*Stream `json:"streams"` } func (r *Result) HasAttachedPic() bool { return lo.ContainsBy(r.Streams, func(s *Stream) bool { return s.CodecType == "video" }) } func (r *Result) getTagByKey(key string) string { for k, v := range r.Format.Tags { if key == strings.ToLower(k) { return v } } for _, stream := range r.Streams { // try to find in streams if stream.CodecType != "audio" { continue } for k, v := range stream.Tags { if key == strings.ToLower(k) { return v } } } return "" } func (r *Result) GetTitle() string { return r.getTagByKey("title") } func (r *Result) GetAlbum() string { return r.getTagByKey("album") } func (r *Result) GetArtists() []string { artists := strings.Split(r.getTagByKey("artist"), "/") for i := range artists { artists[i] = strings.TrimSpace(artists[i]) } return artists } func (r *Result) HasMetadata() bool { return r.GetTitle() != "" || r.GetAlbum() != "" || len(r.GetArtists()) > 0 } type Format struct { Filename string `json:"filename"` NbStreams int `json:"nb_streams"` NbPrograms int `json:"nb_programs"` FormatName string `json:"format_name"` FormatLongName string `json:"format_long_name"` StartTime string `json:"start_time"` Duration string `json:"duration"` BitRate string `json:"bit_rate"` ProbeScore int `json:"probe_score"` Tags map[string]string `json:"tags"` } type Stream struct { Index int `json:"index"` CodecName string `json:"codec_name"` CodecLongName string `json:"codec_long_name"` CodecType string `json:"codec_type"` CodecTagString string `json:"codec_tag_string"` CodecTag string `json:"codec_tag"` SampleFmt string `json:"sample_fmt"` SampleRate string `json:"sample_rate"` Channels int `json:"channels"` ChannelLayout string `json:"channel_layout"` BitsPerSample int `json:"bits_per_sample"` RFrameRate string `json:"r_frame_rate"` AvgFrameRate string `json:"avg_frame_rate"` TimeBase string `json:"time_base"` StartPts int `json:"start_pts"` StartTime string `json:"start_time"` BitRate string `json:"bit_rate"` Disposition *ProbeDisposition `json:"disposition"` Tags map[string]string `json:"tags"` } type ProbeDisposition struct { Default int `json:"default"` Dub int `json:"dub"` Original int `json:"original"` Comment int `json:"comment"` Lyrics int `json:"lyrics"` Karaoke int `json:"karaoke"` Forced int `json:"forced"` HearingImpaired int `json:"hearing_impaired"` VisualImpaired int `json:"visual_impaired"` CleanEffects int `json:"clean_effects"` AttachedPic int `json:"attached_pic"` TimedThumbnails int `json:"timed_thumbnails"` Captions int `json:"captions"` Descriptions int `json:"descriptions"` Metadata int `json:"metadata"` Dependent int `json:"dependent"` StillImage int `json:"still_image"` } func ProbeReader(ctx context.Context, rd io.Reader) (*Result, error) { cmd := exec.CommandContext(ctx, "ffprobe", "-v", "quiet", // disable logging "-print_format", "json", // use json format "-show_format", "-show_streams", "-show_error", // retrieve format and streams "pipe:0", // input from stdin ) cmd.Stdin = rd stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} cmd.Stdout, cmd.Stderr = stdout, stderr if err := cmd.Run(); err != nil { return nil, err } ret := new(Result) if err := json.Unmarshal(stdout.Bytes(), ret); err != nil { return nil, err } return ret, nil }
3.meta_flac.go
package ffmpeg import ( "context" "mime" "strings" "github.com/go-flac/flacpicture" "github.com/go-flac/flacvorbis" "github.com/go-flac/go-flac" "golang.org/x/exp/slices" ) func updateMetaFlac(_ context.Context, outPath string, m *UpdateMetadataParams) error { f, err := flac.ParseFile(m.Audio) if err != nil { return err } // generate comment block comment := flacvorbis.MetaDataBlockVorbisComment{Vendor: "unlock-music.dev"} // add metadata title := m.Meta.GetTitle() if title != "" { _ = comment.Add(flacvorbis.FIELD_TITLE, title) } album := m.Meta.GetAlbum() if album != "" { _ = comment.Add(flacvorbis.FIELD_ALBUM, album) } artists := m.Meta.GetArtists() for _, artist := range artists { _ = comment.Add(flacvorbis.FIELD_ARTIST, artist) } existCommentIdx := slices.IndexFunc(f.Meta, func(b *flac.MetaDataBlock) bool { return b.Type == flac.VorbisComment }) if existCommentIdx >= 0 { // copy existing comment fields exist, err := flacvorbis.ParseFromMetaDataBlock(*f.Meta[existCommentIdx]) if err != nil { for _, s := range exist.Comments { if strings.HasPrefix(s, flacvorbis.FIELD_TITLE+"=") && title != "" || strings.HasPrefix(s, flacvorbis.FIELD_ALBUM+"=") && album != "" || strings.HasPrefix(s, flacvorbis.FIELD_ARTIST+"=") && len(artists) != 0 { continue } comment.Comments = append(comment.Comments, s) } } } // add / replace flac comment cmtBlock := comment.Marshal() if existCommentIdx < 0 { f.Meta = append(f.Meta, &cmtBlock) } else { f.Meta[existCommentIdx] = &cmtBlock } if m.AlbumArt != nil { cover, err := flacpicture.NewFromImageData( flacpicture.PictureTypeFrontCover, "Front cover", m.AlbumArt, mime.TypeByExtension(m.AlbumArtExt), ) if err != nil { return err } coverBlock := cover.Marshal() f.Meta = append(f.Meta, &coverBlock) // add / replace flac cover coverIdx := slices.IndexFunc(f.Meta, func(b *flac.MetaDataBlock) bool { return b.Type == flac.Picture }) if coverIdx < 0 { f.Meta = append(f.Meta, &coverBlock) } else { f.Meta[coverIdx] = &coverBlock } } return f.Save(outPath) }
4.options.go
package ffmpeg import ( "context" "os/exec" "strings" ) type ffmpegBuilder struct { binary string // ffmpeg binary path options map[string]string // global options inputs []*inputBuilder // input options outputs []*outputBuilder // output options } func newFFmpegBuilder() *ffmpegBuilder { return &ffmpegBuilder{ binary: "ffmpeg", options: make(map[string]string), } } func (b *ffmpegBuilder) AddInput(src *inputBuilder) { b.inputs = append(b.inputs, src) } func (b *ffmpegBuilder) AddOutput(dst *outputBuilder) { b.outputs = append(b.outputs, dst) } func (b *ffmpegBuilder) SetBinary(bin string) { b.binary = bin } func (b *ffmpegBuilder) SetFlag(flag string) { b.options[flag] = "" } func (b *ffmpegBuilder) SetOption(name, value string) { b.options[name] = value } func (b *ffmpegBuilder) Args() (args []string) { for name, val := range b.options { args = append(args, "-"+name) if val != "" { args = append(args, val) } } for _, input := range b.inputs { args = append(args, input.Args()...) } for _, output := range b.outputs { args = append(args, output.Args()...) } return } func (b *ffmpegBuilder) Command(ctx context.Context) *exec.Cmd { bin := "ffmpeg" if b.binary != "" { bin = b.binary } return exec.CommandContext(ctx, bin, b.Args()...) } // inputBuilder is the builder for ffmpeg input options type inputBuilder struct { path string options map[string][]string } func newInputBuilder(path string) *inputBuilder { return &inputBuilder{ path: path, options: make(map[string][]string), } } func (b *inputBuilder) AddOption(name, value string) { b.options[name] = append(b.options[name], value) } func (b *inputBuilder) Args() (args []string) { for name, values := range b.options { for _, val := range values { args = append(args, "-"+name, val) } } return append(args, "-i", b.path) } // outputBuilder is the builder for ffmpeg output options type outputBuilder struct { path string options map[string][]string } func newOutputBuilder(path string) *outputBuilder { return &outputBuilder{ path: path, options: make(map[string][]string), } } func (b *outputBuilder) AddOption(name, value string) { b.options[name] = append(b.options[name], value) } func (b *outputBuilder) Args() (args []string) { for name, values := range b.options { for _, val := range values { args = append(args, "-"+name, val) } } return append(args, b.path) } // AddMetadata is the shortcut for adding "metadata" option func (b *outputBuilder) AddMetadata(stream, key, value string) { optVal := strings.TrimSpace(key) + "=" + strings.TrimSpace(value) if stream != "" { b.AddOption("metadata:"+stream, optVal) } else { b.AddOption("metadata", optVal) } }
②.logging
1.zap.go
package logging import ( "go.uber.org/zap" "go.uber.org/zap/zapcore" ) func NewZapLogger() (*zap.Logger, error) { zapCfg := zap.NewDevelopmentConfig() zapCfg.DisableStacktrace = true zapCfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder zapCfg.EncoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout("2006/01/02 15:04:05.000") return zapCfg.Build() }
③.sniff
1.audio.go
package sniff import ( "bytes" "encoding/binary" "golang.org/x/exp/slices" ) type Sniffer interface { Sniff(header []byte) bool } var audioExtensions = map[string]Sniffer{ // ref: https://mimesniff.spec.whatwg.org ".mp3": prefixSniffer("ID3"), // todo: check mp3 without ID3v2 tag ".ogg": prefixSniffer("OggS"), ".wav": prefixSniffer("RIFF"), // ref: https://www.loc.gov/preservation/digital/formats/fdd/fdd000027.shtml ".wma": prefixSniffer{ 0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c, }, // ref: https://www.garykessler.net/library/file_sigs.html ".m4a": m4aSniffer{}, // MPEG-4 container, Apple Lossless Audio Codec ".mp4": &mpeg4Sniffer{}, // MPEG-4 container, other fallback ".flac": prefixSniffer("fLaC"), // ref: https://xiph.org/flac/format.html ".dff": prefixSniffer("FRM8"), // DSDIFF, ref: https://www.sonicstudio.com/pdf/dsd/DSDIFF_1.5_Spec.pdf } // AudioExtension sniffs the known audio types, and returns the file extension. // header is recommended to at least 16 bytes. func AudioExtension(header []byte) (string, bool) { for ext, sniffer := range audioExtensions { if sniffer.Sniff(header) { return ext, true } } return "", false } // AudioExtensionWithFallback is equivalent to AudioExtension, but returns fallback // most likely to use .mp3 as fallback, because mp3 files may not have ID3v2 tag. func AudioExtensionWithFallback(header []byte, fallback string) string { ext, ok := AudioExtension(header) if !ok { return fallback } return ext } type prefixSniffer []byte func (s prefixSniffer) Sniff(header []byte) bool { return bytes.HasPrefix(header, s) } type m4aSniffer struct{} func (m4aSniffer) Sniff(header []byte) bool { box := readMpeg4FtypBox(header) if box == nil { return false } return box.majorBrand == "M4A " || slices.Contains(box.compatibleBrands, "M4A ") } type mpeg4Sniffer struct{} func (s *mpeg4Sniffer) Sniff(header []byte) bool { return readMpeg4FtypBox(header) != nil } type mpeg4FtpyBox struct { majorBrand string minorVersion uint32 compatibleBrands []string } func readMpeg4FtypBox(header []byte) *mpeg4FtpyBox { if (len(header) < 8) || !bytes.Equal([]byte("ftyp"), header[4:8]) { return nil // not a valid ftyp box } size := binary.BigEndian.Uint32(header[0:4]) // size if size < 16 || size%4 != 0 { return nil // invalid ftyp box } box := mpeg4FtpyBox{ majorBrand: string(header[8:12]), minorVersion: binary.BigEndian.Uint32(header[12:16]), } // compatible brands for i := 16; i < int(size) && i+4 < len(header); i += 4 { box.compatibleBrands = append(box.compatibleBrands, string(header[i:i+4])) } return &box }
2.image.go
package sniff // ref: https://mimesniff.spec.whatwg.org var imageMIMEs = map[string]Sniffer{ "image/jpeg": prefixSniffer{0xFF, 0xD8, 0xFF}, "image/png": prefixSniffer{'P', 'N', 'G', '\r', '\n', 0x1A, '\n'}, "image/bmp": prefixSniffer("BM"), "image/webp": prefixSniffer("RIFF"), "image/gif": prefixSniffer("GIF8"), } // ImageMIME sniffs the well-known image types, and returns its MIME. func ImageMIME(header []byte) (string, bool) { for ext, sniffer := range imageMIMEs { if sniffer.Sniff(header) { return ext, true } } return "", false } // ImageExtension is equivalent to ImageMIME, but returns file extension func ImageExtension(header []byte) (string, bool) { ext, ok := ImageMIME(header) if !ok { return "", false } // todo: use mime.ExtensionsByType return "." + ext[6:], true // "image/" is 6 bytes }
④.utils
1.crypto.go
package utils import "crypto/aes" func PKCS7UnPadding(encrypt []byte) []byte { length := len(encrypt) unPadding := int(encrypt[length-1]) return encrypt[:(length - unPadding)] } func DecryptAES128ECB(data, key []byte) []byte { cipher, _ := aes.NewCipher(key) decrypted := make([]byte, len(data)) size := 16 for bs, be := 0, size; bs < len(data); bs, be = bs+size, be+size { cipher.Decrypt(decrypted[bs:be], data[bs:be]) } return decrypted }
2.temp.go
package utils import ( "fmt" "io" "os" ) func WriteTempFile(rd io.Reader, ext string) (string, error) { audioFile, err := os.CreateTemp("", "*"+ext) if err != nil { return "", fmt.Errorf("ffmpeg create temp file: %w", err) } if _, err := io.Copy(audioFile, rd); err != nil { return "", fmt.Errorf("ffmpeg write temp file: %w", err) } if err := audioFile.Close(); err != nil { return "", fmt.Errorf("ffmpeg close temp file: %w", err) } return audioFile.Name(), nil }