diff --git a/algo/qmc/key_mmkv.go b/algo/qmc/key_mmkv.go index 24b51cb..5bd0d9c 100644 --- a/algo/qmc/key_mmkv.go +++ b/algo/qmc/key_mmkv.go @@ -80,14 +80,16 @@ func readKeyFromMMKV(file string, logger *zap.Logger) ([]byte, error) { return deriveKey(buf) } -func OpenMMKV(vaultPath string, vaultKey string, logger *zap.Logger) error { - filePath, fileName := filepath.Split(vaultPath) +func OpenMMKV(mmkvPath string, key string, logger *zap.Logger) error { + filePath, fileName := filepath.Split(mmkvPath) mgr, err := mmkv.NewManager(filepath.Dir(filePath)) if err != nil { return fmt.Errorf("init mmkv manager: %w", err) } - streamKeyVault, err = mgr.OpenVaultCrypto(fileName, vaultKey) + // If `vaultKey` is empty, the key is ignored. + streamKeyVault, err = mgr.OpenVaultCrypto(fileName, key) + if err != nil { return fmt.Errorf("open mmkv vault: %w", err) } @@ -96,6 +98,7 @@ func OpenMMKV(vaultPath string, vaultKey string, logger *zap.Logger) error { return nil } +// / func readKeyFromMMKVCustom(mid string) ([]byte, error) { if streamKeyVault == nil { return nil, fmt.Errorf("mmkv vault not loaded") @@ -109,6 +112,7 @@ func readKeyFromMMKVCustom(mid string) ([]byte, error) { return deriveKey(eKey) } +// / getRelativeMMKVDir get mmkv dir relative to file (legacy QQMusic for macOS behaviour) func getRelativeMMKVDir(file string) (string, error) { mmkvDir := filepath.Join(filepath.Dir(file), "../mmkv") if _, err := os.Stat(mmkvDir); err != nil { @@ -131,7 +135,7 @@ func getDefaultMMKVDir() (string, error) { mmkvDir := filepath.Join( homeDir, - "Library/Containers/com.tencent.QQMusicMac/Data", // todo: make configurable + "Library/Containers/com.tencent.QQMusicMac/Data", "Library/Application Support/QQMusicMac/mmkv", ) if _, err := os.Stat(mmkvDir); err != nil { diff --git a/algo/qmc/qmc.go b/algo/qmc/qmc.go index 310e642..651519a 100644 --- a/algo/qmc/qmc.go +++ b/algo/qmc/qmc.go @@ -145,13 +145,12 @@ func (d *Decoder) searchKey() (err error) { case "STag": return errors.New("qmc: file with 'STag' suffix doesn't contains media key") case "cex\x00": - footer := qqMusicTagMusicEx{} - audioLen, err := footer.Read(d.raw) + footer, err := NewMusicExTag(d.raw) if err != nil { return err } - d.audioLen = int(audioLen) - d.decodedKey, err = readKeyFromMMKVCustom(footer.mediafile) + d.audioLen = fileSize - int(footer.TagSize) + d.decodedKey, err = readKeyFromMMKVCustom(footer.MediaFileName) if err != nil { return err } diff --git a/algo/qmc/qmc_footer_musicex.go b/algo/qmc/qmc_footer_musicex.go index 444c2aa..80449d0 100644 --- a/algo/qmc/qmc_footer_musicex.go +++ b/algo/qmc/qmc_footer_musicex.go @@ -1,65 +1,93 @@ package qmc import ( + bytes "bytes" "encoding/binary" + "errors" "fmt" "io" + "strings" ) -type qqMusicTagMusicEx struct { - songid uint32 // Song ID - unknown_1 uint32 // unused & unknown - unknown_2 uint32 // unused & unknown - mid string // Media ID - mediafile string // real file name - unknown_3 uint32 // unused; uninitialized memory? - sizeof_struct uint32 // 19.57: fixed value: 0xC0 - version uint32 // 19.57: fixed value: 0x01 - tag_magic []byte // fixed value "musicex\0" (8 bytes) +type MusicExTagV1 struct { + SongID uint32 // Song ID + Unknown1 uint32 // unused & unknown + Unknown2 uint32 // unused & unknown + MediaID string // Media ID + MediaFileName string // real file name + Unknown3 uint32 // unused; uninitialized memory? + + // 16 byte at the end of tag. + // TagSize should be respected when parsing. + TagSize uint32 // 19.57: fixed value: 0xC0 + TagVersion uint32 // 19.57: fixed value: 0x01 + TagMagic []byte // fixed value "musicex\0" (8 bytes) } -func (tag *qqMusicTagMusicEx) Read(raw io.ReadSeeker) (int64, error) { - _, err := raw.Seek(-16, io.SeekEnd) +func NewMusicExTag(f io.ReadSeeker) (*MusicExTagV1, error) { + _, err := f.Seek(-16, io.SeekEnd) if err != nil { - return 0, fmt.Errorf("musicex seek error: %w", err) + return nil, fmt.Errorf("musicex seek error: %w", err) } - footerBuf := make([]byte, 4) - footerBuf, err = io.ReadAll(io.LimitReader(raw, 4)) + buffer := make([]byte, 16) + bytesRead, err := f.Read(buffer) if err != nil { - return 0, fmt.Errorf("get musicex error: %w", err) + return nil, fmt.Errorf("get musicex error: %w", err) + } + if bytesRead != 16 { + return nil, fmt.Errorf("MusicExV1: read %d bytes (expected %d)", bytesRead, 16) } - footerLen := int64(binary.LittleEndian.Uint32(footerBuf)) - audioLen, err := raw.Seek(-footerLen, io.SeekEnd) - buf, err := io.ReadAll(io.LimitReader(raw, audioLen)) + tag := &MusicExTagV1{ + TagSize: binary.LittleEndian.Uint32(buffer[0x00:0x04]), + TagVersion: binary.LittleEndian.Uint32(buffer[0x04:0x08]), + TagMagic: buffer[0x04:0x0C], + } + + if !bytes.Equal(tag.TagMagic, []byte("musicex\x00")) { + return nil, errors.New("MusicEx magic mismatch") + } + if tag.TagVersion != 1 { + return nil, errors.New(fmt.Sprintf("unsupported musicex tag version. expecting 1, got %d", tag.TagVersion)) + } + + if tag.TagSize < 0xC0 { + return nil, errors.New(fmt.Sprintf("unsupported musicex tag size. expecting at least 0xC0, got 0x%02x", tag.TagSize)) + } + + buffer = make([]byte, tag.TagSize) + bytesRead, err = f.Read(buffer) if err != nil { - return 0, err + return nil, err + } + if uint32(bytesRead) != tag.TagSize { + return nil, fmt.Errorf("MusicExV1: read %d bytes (expected %d)", bytesRead, tag.TagSize) } - tag.songid = binary.LittleEndian.Uint32(buf[0:4]) - tag.unknown_1 = binary.LittleEndian.Uint32(buf[4:8]) - tag.unknown_2 = binary.LittleEndian.Uint32(buf[8:12]) - - for i := 0; i < 30; i++ { - u := binary.LittleEndian.Uint16(buf[12+i*2 : 12+(i+1)*2]) - if u == 0 { - break - } - tag.mid += string(u) - } - for i := 0; i < 50; i++ { - u := binary.LittleEndian.Uint16(buf[72+i*2 : 72+(i+1)*2]) - if u == 0 { - break - } - tag.mediafile += string(u) - } - - tag.unknown_3 = binary.LittleEndian.Uint32(buf[173:177]) - tag.sizeof_struct = binary.LittleEndian.Uint32(buf[177:181]) - tag.version = binary.LittleEndian.Uint32(buf[181:185]) - tag.tag_magic = buf[185:193] - - return audioLen, nil + tag.SongID = binary.LittleEndian.Uint32(buffer[0x00:0x04]) + tag.Unknown1 = binary.LittleEndian.Uint32(buffer[0x04:0x08]) + tag.Unknown2 = binary.LittleEndian.Uint32(buffer[0x08:0x0C]) + tag.MediaID = readUnicodeTagName(buffer[0x0C:], 30*2) + tag.MediaFileName = readUnicodeTagName(buffer[0x48:], 50*2) + tag.Unknown3 = binary.LittleEndian.Uint32(buffer[0xAC:0xB0]) + return tag, nil +} + +// readUnicodeTagName reads a buffer to maxLen. +// reconstruct text by skipping alternate char (ascii chars encoded in UTF-16-LE), +// until finding a zero or reaching maxLen. +func readUnicodeTagName(buffer []byte, maxLen int) string { + builder := strings.Builder{} + + for i := 0; i < maxLen; i += 2 { + chr := buffer[i] + if chr != 0 { + builder.WriteByte(chr) + } else { + break + } + } + + return builder.String() } diff --git a/cmd/um/main.go b/cmd/um/main.go index 4b74ce7..cb48457 100644 --- a/cmd/um/main.go +++ b/cmd/um/main.go @@ -5,9 +5,6 @@ import ( "context" "errors" "fmt" - "github.com/fsnotify/fsnotify" - "github.com/urfave/cli/v2" - "go.uber.org/zap" "io" "os" "os/signal" @@ -18,6 +15,10 @@ import ( "strings" "time" + "github.com/fsnotify/fsnotify" + "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" @@ -49,8 +50,8 @@ func main() { 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.StringFlag{Name: "vault-file", Aliases: []string{"db"}, Usage: "数据库文件位置 (请确保crc文件在同目录下)", Required: false}, - &cli.StringFlag{Name: "vault-key", Aliases: []string{"key"}, Usage: "数据库密钥 (length 32)", Required: false}, + &cli.StringFlag{Name: "qmc-mmkv", Aliases: []string{"db"}, Usage: "path to qmc mmkv (`.crc` file also required)", Required: false}, + &cli.StringFlag{Name: "qmc-mmkv-key", Aliases: []string{"key"}, Usage: "mmkv password (16 ascii chars)", 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}, @@ -131,10 +132,10 @@ func appMain(c *cli.Context) (err error) { return errors.New("output should be a writable directory") } - vaultPath := c.String("vault-file") - vaultKey := c.String("vault-key") - if vaultPath != "" && vaultKey != "" { - err := qmc.OpenMMKV(vaultPath, vaultKey, logger) + if mmkv := c.String("qmc-mmkv"); mmkv != "" { + // If key is not set, the mmkv vault will be treated as unencrypted. + key := c.String("qmc-mmkv-key") + err := qmc.OpenMMKV(mmkv, key, logger) if err != nil { return err }