8 Commits

Author SHA1 Message Date
鲁树人
8c1f40bfe1 docs: update changelog 2025-09-15 21:48:20 +09:00
鲁树人
aea3bd5714 fix: Handle musicex tag correctly 2025-09-15 21:35:12 +09:00
鲁树人
e4bfefd0a6 fix: use proper regex 2025-09-09 22:32:43 +09:00
鲁树人
b1aa3bacdf fix: relax regex to extract udid from plist 2025-09-09 22:24:16 +09:00
鲁树人
6ec434f6b1 docs: update v0.2.15 changelog 2025-09-09 21:54:16 +09:00
鲁树人
a46428e07c feat: support QQMusic from AppStore 2025-09-09 21:49:22 +09:00
鲁树人
57dd4b80b3 docs: add changelog 2025-09-08 23:42:08 +09:00
鲁树人
e06a21123f feat: QQMusic Mac v10 mmkv support 2025-09-08 23:30:27 +09:00
9 changed files with 302 additions and 88 deletions

73
CHANGELOG.md Normal file
View File

@@ -0,0 +1,73 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [v0.2.18] - 2025-09-15
### Changed
- Fix `musicex\0` tag parsing.
## [v0.2.17] - 2025-09-09 ⚠️ **(Broken Release)**
### Changed
- Update RegEx used to extract UDID in plist.
## [v0.2.16] - 2025-09-09 ⚠️ **(Broken Release)**
### Changed
- Update RegEx used to extract UDID in plist.
## [v0.2.15] - 2025-09-09 ⚠️ **(Broken Release)**
### Added
- Support MMKV dump in QQMusic Mac 10.x (AppStore version).
## [v0.2.14] - 2025-09-08 ⚠️ **(Broken Release)**
### Added
- Support MMKV dump in QQMusic Mac 10.x.
## [v0.2.13] - 2025-09-06 ⚠️ **(Broken Release)**
### Changed
- Updated project namespace and repository URLs to new url
- Upgraded Go version requirement to 1.25
- Restricted KGG database support to Windows platform only
- Enhanced MMKV key extraction logic with improved reliability
### Fixed
- Fixed NCM metadata parsing to properly handle mixed-type artist arrays
- Drop i386 targets in CI build
## [v0.2.12] - 2025-05-07
### Added
- KGG (KGMv5) file format support
- Support for `.mflacm` file extension
### Changed
- Updated default version identifier to "custom" for development builds
- Upgraded GoLang version
## [v0.2.11] - 2024-11-05
### Fixed
- Resolved relative path resolution issues on Windows platforms (#108)
- Improved cross-platform compatibility for file path handling
---
## Historical Versions
**Note**: This changelog was created starting from v0.2.11. For changes in earlier versions (v0.2.10 and below), please refer to the project's git commit history:
```bash
git log --oneline --before="2024-11-05"
```
Or view the complete commit history on the project repository for detailed information about features, fixes, and improvements in previous releases.

View File

@@ -1,22 +1,20 @@
package qmc package qmc
import ( import (
"fmt"
"os"
"path/filepath"
"git.um-react.app/um/cli/algo/common" "git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/algo/qmc/qmmac"
"git.um-react.app/um/cli/internal/mmkv"
"go.uber.org/zap" "go.uber.org/zap"
) )
func LoadMMKVOrDefault(path string, key string, logger *zap.Logger) (result common.QMCKeys, err error) { func LoadMMKVOrDefault(path string, key string, logger *zap.Logger) (result common.QMCKeys, err error) {
key1, err := loadMacKeysV8(logger) key1, err := qmmac.LoadMacKeysV8(logger)
if err != nil { if err != nil {
key1 = nil key1 = nil
logger.Warn("LoadMMKVOrDefault: could not read QQMusic v8.8.0 keys", zap.Error(err)) logger.Warn("LoadMMKVOrDefault: could not read QQMusic v8.8.0 keys", zap.Error(err))
} }
key2, err := loadMacKeysV10(logger) key2, err := qmmac.LoadMacKeysV10(logger)
if err != nil { if err != nil {
key2 = nil key2 = nil
logger.Warn("LoadMMKVOrDefault: could not read QQMusic v10.x keys", zap.Error(err)) logger.Warn("LoadMMKVOrDefault: could not read QQMusic v10.x keys", zap.Error(err))
@@ -25,41 +23,16 @@ func LoadMMKVOrDefault(path string, key string, logger *zap.Logger) (result comm
userKeys := make(common.QMCKeys) userKeys := make(common.QMCKeys)
if path != "" { if path != "" {
logger.Info("Using user mmkv") logger.Info("Using user mmkv")
userKeys, err = LoadMMKV(path, key, logger) userKeys, err = mmkv.LoadFromPath(path, key, logger)
if err != nil { if err != nil {
userKeys = nil userKeys = nil
logger.Warn("LoadMMKVOrDefault: could not read user keys", zap.Error(err)) logger.Warn("LoadMMKVOrDefault: could not read user keys", zap.Error(err))
} }
} }
allKeys := mergeMMKVKeys(key1, key2, userKeys) allKeys := mmkv.Merge(key1, key2, userKeys)
logger.Debug("Keys loaded", zap.Any("keys", allKeys), zap.Int("len", len(allKeys))) logger.Debug("Keys loaded", zap.Any("keys", allKeys), zap.Int("len", len(allKeys)))
return allKeys, nil return allKeys, nil
} }
func loadMacKeysV8(logger *zap.Logger) (keys common.QMCKeys, err error) {
homeDir, err := os.UserHomeDir()
if err != nil {
logger.Warn("Failed to get home dir")
return nil, fmt.Errorf("loadMacKeysV8: failed to get home: %w", err)
}
p := filepath.Join(
homeDir,
"Library/Containers/com.tencent.QQMusicMac/Data",
"Library/Application Support/QQMusicMac/mmkv",
"MMKVStreamEncryptId",
)
if f, err := os.Stat(p); err == nil && !f.IsDir() {
logger.Info("Using QQMusic 8.x mmkv", zap.String("mmkv", p))
return LoadMMKV(p, "", logger)
}
return nil, nil
}
func loadMacKeysV10(logger *zap.Logger) (common.QMCKeys, error) {
// TODO: stub only
var _ = logger
return nil, nil
}

View File

@@ -137,11 +137,11 @@ func (d *Decoder) searchKey() (err error) {
d.decodedKey, err = deriveKey([]byte(key)) d.decodedKey, err = deriveKey([]byte(key))
if err == nil { if err == nil {
d.audioLen = fileSize d.audioLen = fileSize
return nil } else {
}
d.decodedKey = nil d.decodedKey = nil
d.logger.Warn("could not derive key, skip", zap.Error(err)) d.logger.Warn("could not derive key, skip", zap.Error(err))
} }
}
suffixBuf := make([]byte, 4) suffixBuf := make([]byte, 4)
if _, err := io.ReadFull(d.raw, suffixBuf); err != nil { if _, err := io.ReadFull(d.raw, suffixBuf); err != nil {
@@ -153,7 +153,7 @@ func (d *Decoder) searchKey() (err error) {
return d.readRawMetaQTag() return d.readRawMetaQTag()
case "STag": case "STag":
return errors.New("qmc: file with 'STag' suffix doesn't contains media key") return errors.New("qmc: file with 'STag' suffix doesn't contains media key")
// MusicEx\0 // speculative guess for "musicex\0"
case "cex\x00": case "cex\x00":
footer, err := NewMusicExTag(d.raw) footer, err := NewMusicExTag(d.raw)
if err != nil { if err != nil {
@@ -161,17 +161,26 @@ func (d *Decoder) searchKey() (err error) {
} }
d.audioLen = fileSize - int(footer.TagSize) d.audioLen = fileSize - int(footer.TagSize)
if key, ok := d.params.CryptoParams.QmcKeys.Get(footer.MediaFileName); ok { if key, ok := d.params.CryptoParams.QmcKeys.Get(footer.MediaFileName); ok {
d.logger.Debug("searchKey: using key from MediaFileName", zap.String("MediaFileName", footer.MediaFileName), zap.String("key", key))
d.decodedKey, err = deriveKey([]byte(key)) d.decodedKey, err = deriveKey([]byte(key))
} else if d.decodedKey == nil {
return errors.New("searchKey: no key found for musicex tag")
} }
return err return err
default: default:
size := binary.LittleEndian.Uint32(suffixBuf) // if we already have a key from legacy mmkv, use it
if d.decodedKey != nil {
return nil
}
// try to use suffix as key length
size := binary.LittleEndian.Uint32(suffixBuf)
if size <= 0xFFFF && size != 0 { // assume size is key len if size <= 0xFFFF && size != 0 { // assume size is key len
return d.readRawKey(int64(size)) return d.readRawKey(int64(size))
} }
// try to use default static cipher // try to use default static cipher,
// or the key read from the legacy mmkv
d.audioLen = fileSize d.audioLen = fileSize
return nil return nil
} }

View File

@@ -42,24 +42,25 @@ func NewMusicExTag(f io.ReadSeeker) (*MusicExTagV1, error) {
tag := &MusicExTagV1{ tag := &MusicExTagV1{
TagSize: binary.LittleEndian.Uint32(buffer[0x00:0x04]), TagSize: binary.LittleEndian.Uint32(buffer[0x00:0x04]),
TagVersion: binary.LittleEndian.Uint32(buffer[0x04:0x08]), TagVersion: binary.LittleEndian.Uint32(buffer[0x04:0x08]),
TagMagic: buffer[0x04:0x0C], TagMagic: buffer[0x08:],
} }
if !bytes.Equal(tag.TagMagic, []byte("musicex\x00")) { if !bytes.Equal(tag.TagMagic, []byte("musicex\x00")) {
return nil, errors.New("MusicEx magic mismatch") return nil, errors.New("MusicEx magic mismatch")
} }
if tag.TagVersion != 1 { if tag.TagVersion != 1 {
return nil, errors.New(fmt.Sprintf("unsupported musicex tag version. expecting 1, got %d", tag.TagVersion)) return nil, fmt.Errorf("unsupported musicex tag version. expecting 1, got %d", tag.TagVersion)
} }
if tag.TagSize < 0xC0 { if tag.TagSize < 0xC0 {
return nil, errors.New(fmt.Sprintf("unsupported musicex tag size. expecting at least 0xC0, got 0x%02x", tag.TagSize)) return nil, fmt.Errorf("unsupported musicex tag size. expecting at least 0xC0, got 0x%02x", tag.TagSize)
} }
buffer = make([]byte, tag.TagSize) buffer = make([]byte, tag.TagSize)
f.Seek(-int64(tag.TagSize), io.SeekEnd)
bytesRead, err = f.Read(buffer) bytesRead, err = f.Read(buffer)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("MusicExV1: Read error %w", err)
} }
if uint32(bytesRead) != tag.TagSize { if uint32(bytesRead) != tag.TagSize {
return nil, fmt.Errorf("MusicExV1: read %d bytes (expected %d)", bytesRead, tag.TagSize) return nil, fmt.Errorf("MusicExV1: read %d bytes (expected %d)", bytesRead, tag.TagSize)

View File

@@ -0,0 +1,163 @@
package qmmac
import (
"crypto/md5"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/internal/mmkv"
"go.uber.org/zap"
)
var _RE_UDID_V10 = regexp.MustCompile(`_\x10\(([0-9a-f]{40})`)
type QQMusicMacV10 struct {
logger *zap.Logger
mmkv_dir string
}
func (q *QQMusicMacV10) extractUdids(data []byte) ([]string, error) {
var result []string
for _, match := range _RE_UDID_V10.FindAllSubmatch(data, -1) {
udid := string(match[1])
q.logger.Debug("extractUdids: found udid", zap.String("udid", udid))
result = append(result, udid)
}
return result, nil
}
func (q *QQMusicMacV10) caesar(text string, shift int) string {
var result strings.Builder
for _, char := range []byte(text) {
var transformed byte
if 'A' <= char && char <= 'Z' {
transformed = (char-'A'+byte(shift))%26 + 'A'
} else if 'a' <= char && char <= 'z' {
transformed = (char-'a'+byte(shift))%26 + 'a'
} else if '0' <= char && char <= '9' {
transformed = (char-'0'+byte(shift))%10 + '0'
} else {
transformed = char
}
result.WriteByte(transformed)
}
return result.String()
}
func (q *QQMusicMacV10) mmkv(udid string, id int) (path string, key string, err error) {
str1 := q.caesar(udid, id+3)
int1, err := strconv.ParseInt(udid[5:7], 16, 32)
if err != nil {
return "", "", fmt.Errorf("getMmkv: could not parse udid: %w", err)
}
int2 := 5 + (int(int1)+id)%4
name := str1[:int2]
path = filepath.Join(q.mmkv_dir, name)
int3 := id + 0xa546
str3 := fmt.Sprintf("%s%04x", udid, int3)
hash := md5.Sum([]byte(str3))
key = fmt.Sprintf("%x", hash)[:16]
return path, key, nil
}
func (q *QQMusicMacV10) loadByPList(plist_path string) ([]common.QMCKeys, error) {
logger := q.logger.With(zap.String("plist", plist_path))
logger.Debug("loadMacKeysV10: load key from plist")
if f, err := os.Stat(plist_path); err != nil || f.IsDir() {
logger.Debug("loadMacKeysV10: plist not found")
return nil, nil
}
plist_data, err := os.ReadFile(plist_path)
if err != nil {
logger.Warn("loadMacKeysV10: could not read plist", zap.Error(err))
return nil, fmt.Errorf("loadMacKeysV10: could not read plist: %w", err)
}
udids, err := q.extractUdids(plist_data)
if err != nil {
logger.Warn("loadMacKeysV10: could not extract udid", zap.Error(err))
return nil, fmt.Errorf("loadMacKeysV10: could not extract udid: %w", err)
}
logger.Debug("loadMacKeysV10: read udid", zap.Strings("udids", udids))
var keysList []common.QMCKeys
for _, udid := range udids {
keys, err := q.loadByUDID(udid, logger)
if err != nil {
logger.Warn("loadMacKeysV10: could not load by udid", zap.String("udid", udid), zap.Error(err))
continue
}
keysList = append(keysList, keys)
}
return keysList, nil
}
func (q *QQMusicMacV10) loadByUDID(udid string, logger *zap.Logger) (common.QMCKeys, error) {
mmkv_path, mmkv_key, err := q.mmkv(udid, 1)
if err != nil {
logger.Warn("loadMacKeysV10: could not get mmkv name/key", zap.Error(err))
return nil, fmt.Errorf("loadMacKeysV10: could not get mmkv name/key: %w", err)
}
logger.Info("Using QQMusic 10.x mmkv", zap.String("mmkv", mmkv_path))
keys, err := mmkv.LoadFromPath(mmkv_path, mmkv_key, logger)
if err != nil {
logger.Warn("loadMacKeysV10: could not load mmkv", zap.String("mmkv", mmkv_path), zap.Error(err))
return nil, fmt.Errorf("loadMacKeysV10: could not load mmkv: %w", err)
}
return keys, nil
}
func LoadMacKeysV10(logger *zap.Logger) (common.QMCKeys, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
logger.Warn("Failed to get home dir")
return nil, fmt.Errorf("loadMacKeysV10: failed to get home: %w", err)
}
// MMKV dir is always inside the sandbox container
mmkv_dir := filepath.Join(
homeDir,
"Library/Containers/com.tencent.QQMusicMac/Data/",
"Library/Application Support/QQMusicMac/iData",
)
if f, err := os.Stat(mmkv_dir); err != nil || !f.IsDir() {
logger.Debug("loadMacKeysV10: mmkv dir not found", zap.String("mmkv_dir", mmkv_dir))
return nil, nil
}
// without sandbox
plist_without_sandbox := filepath.Join(
homeDir,
"Library/Preferences/com.tencent.QQMusicMac.plist",
)
// with sandbox (e.g. from App Store)
plist_sandboxed := filepath.Join(
homeDir,
"Library/Containers/com.tencent.QQMusicMac/Data/",
"Library/Preferences/com.tencent.QQMusicMac.plist",
)
q := QQMusicMacV10{
logger: logger,
mmkv_dir: mmkv_dir,
}
keys1, err := q.loadByPList(plist_without_sandbox)
if err != nil {
return nil, err
}
keys2, err := q.loadByPList(plist_sandboxed)
if err != nil {
return nil, err
}
return mmkv.Merge(append(keys1, keys2...)...), nil
}

View File

@@ -0,0 +1,30 @@
package qmmac
import (
"fmt"
"os"
"path/filepath"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/internal/mmkv"
"go.uber.org/zap"
)
func LoadMacKeysV8(logger *zap.Logger) (keys common.QMCKeys, err error) {
homeDir, err := os.UserHomeDir()
if err != nil {
logger.Warn("Failed to get home dir")
return nil, fmt.Errorf("loadMacKeysV8: failed to get home: %w", err)
}
p := filepath.Join(
homeDir,
"Library/Containers/com.tencent.QQMusicMac/Data",
"Library/Application Support/QQMusicMac/mmkv",
"MMKVStreamEncryptId",
)
if f, err := os.Stat(p); err == nil && !f.IsDir() {
logger.Info("Using QQMusic 8.x mmkv", zap.String("mmkv", p))
return mmkv.LoadFromPath(p, "", logger)
}
return nil, nil
}

2
go.mod
View File

@@ -8,7 +8,7 @@ require (
github.com/go-flac/flacvorbis v0.2.0 github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/go-flac v1.0.0 github.com/go-flac/go-flac v1.0.0
github.com/samber/lo v1.47.0 github.com/samber/lo v1.47.0
github.com/unlock-music/go-mmkv v0.1.1 github.com/unlock-music/go-mmkv v0.1.2
github.com/urfave/cli/v2 v2.27.5 github.com/urfave/cli/v2 v2.27.5
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/crypto v0.29.0 golang.org/x/crypto v0.29.0

2
go.sum
View File

@@ -32,6 +32,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/unlock-music/go-mmkv v0.1.1 h1:1w3shjaBMp58jRrRkxPghnMpvyUoP4ZkA++W9ueFpE8= github.com/unlock-music/go-mmkv v0.1.1 h1:1w3shjaBMp58jRrRkxPghnMpvyUoP4ZkA++W9ueFpE8=
github.com/unlock-music/go-mmkv v0.1.1/go.mod h1:E1h/HBpWPOKnCQw6LSQZkMpzxQojGDe+T5f/DRDXBsc= github.com/unlock-music/go-mmkv v0.1.1/go.mod h1:E1h/HBpWPOKnCQw6LSQZkMpzxQojGDe+T5f/DRDXBsc=
github.com/unlock-music/go-mmkv v0.1.2 h1:nZvYoyO/LO4ycXHYmObgHVNXxqMjfINeCYwKFmiyOQc=
github.com/unlock-music/go-mmkv v0.1.2/go.mod h1:E1h/HBpWPOKnCQw6LSQZkMpzxQojGDe+T5f/DRDXBsc=
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=

View File

@@ -1,9 +1,8 @@
package qmc package mmkv
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"git.um-react.app/um/cli/algo/common" "git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/internal/utils" "git.um-react.app/um/cli/internal/utils"
@@ -11,7 +10,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
func mergeMMKVKeys(keys ...common.QMCKeys) common.QMCKeys { func Merge(keys ...common.QMCKeys) common.QMCKeys {
result := make(common.QMCKeys) result := make(common.QMCKeys)
for _, k := range keys { for _, k := range keys {
for key, value := range k { for key, value := range k {
@@ -21,7 +20,7 @@ func mergeMMKVKeys(keys ...common.QMCKeys) common.QMCKeys {
return result return result
} }
func LoadMMKV(path string, key string, logger *zap.Logger) (result common.QMCKeys, err error) { func LoadFromPath(path string, key string, logger *zap.Logger) (result common.QMCKeys, err error) {
mmkv_path := path mmkv_path := path
mmkv_crc := path + ".crc" mmkv_crc := path + ".crc"
@@ -33,9 +32,11 @@ func LoadMMKV(path string, key string, logger *zap.Logger) (result common.QMCKey
defer mr.Close() defer mr.Close()
cr, err := os.Open(mmkv_crc) cr, err := os.Open(mmkv_crc)
if err == nil { if err != nil {
// crc is optional // crc is optional
cr = nil logger.Warn("LoadMMKV: Failed to open crc file, assuming no encryption", zap.Error(err))
key = ""
} else {
defer cr.Close() defer cr.Close()
} }
@@ -68,41 +69,3 @@ func LoadMMKV(path string, key string, logger *zap.Logger) (result common.QMCKey
return result, nil return result, nil
} }
// 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 {
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",
"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
}