Compare commits

...
7 Commits
14 changed files with 678 additions and 33 deletions
+13
View File
@@ -0,0 +1,13 @@
---
description: um-cli (Unlock Music CLI) project conventions for agents
alwaysApply: true
---
# um-cli
- Module: `git.um-react.app/um/cli`. Entry: `cmd/um/main.go`. Read `AGENTS.md` before structural changes.
- Decoders live under `algo/<vendor>/`, register via `common.RegisterDecoder` in `init()`, blank-import new packages from `cmd/um`.
- Shared non-algo code goes in `internal/`. Pass secrets through `common.CryptoParams`, not globals.
- Soft-fail optional key loaders (`Warn`); hard-fail only when decrypt cannot proceed. Normalize QMC map keys with `utils.NormalizeUnicode`.
- OS-specific code: `*_windows.go` / `*_darwin.go` or explicit `//go:build` stubs (see QMC MMKV loaders, `pc_kugou_db`).
- After flag or Go-version changes, update `README.md`. Keep changes minimal; run `go test ./...` for touched packages.
+141
View File
@@ -0,0 +1,141 @@
# Agent guide — um-cli
Unlock Music CLI (`um`): decrypt vendor-locked audio files to raw streams, optionally rewrite tags/cover with ffmpeg.
Module path: `git.um-react.app/um/cli`
Entry point: `cmd/um/main.go`
## Quick commands
```bash
go test ./...
go build -o um ./cmd/um
./um -h
./um --supported-ext
```
CI (`.gitea/workflows/build.yml`): `go test ./...` then cross-build `linux|windows|darwin` × `amd64|arm64`, `CGO_ENABLED=0`, Go `^1.25`.
Optional runtime deps (not needed to compile):
- `ffmpeg` / `ffprobe` — only when `--update-metadata` is set
- Windows: `qmpc-mmkv-cli.exe` for automatic QMC keys
- Windows: KuGou `KGMusicV3.db` for `.kgg` (KGMv5)
## Layout
```
cmd/um/ CLI flags, directory walk / watch, decode → write pipeline
algo/
common/ Decoder interfaces, registry, raw “noop” passthrough, CryptoParams
ncm/ kgm/ kwm/ qmc/ tm/ xiami/ ximalaya/ one package per format family
internal/
ffmpeg/ metadata + album art via ffmpeg/ffprobe
mmkv/ MMKV file read + QMCKeys merge/normalize
sniff/ audio/image magic → extension
utils/ crypto helpers, temp files, unicode normalize
logging/ zap helpers (mostly unused; logger still global in main)
misc/ release/repack scripts
```
Do not invent new top-level packages; put shared non-algo code under `internal/`.
## Architecture (decode path)
1. `main` resolves input/output, loads crypto side-data into `common.CryptoParams`.
2. `common.GetDecoder(path, skipNoop)` matches **filename suffix** (lowercased base name; multi-dot suffixes like `.kgm.flac` supported).
3. For each matching `DecoderFactory`, `Create` → `Validate()`. First success wins.
4. Decrypted bytes are read via `io.Reader`; first 64 bytes sniffed for output extension.
5. Without `--update-metadata`: stream copy to output. With it: optional `AudioMetaGetter` / `CoverImageGetter`, then `ffmpeg.UpdateMeta`.
Key types (`algo/common/interface.go`):
- `Decoder` — `Validate() error` + `io.Reader`
- `StreamDecoder` — `Decrypt(buf []byte, offset int)` for stream ciphers
- `AudioMetaGetter` / `CoverImageGetter` — optional, used only with `--update-metadata`
Registration (`algo/common/dispatch.go`):
```go
func init() {
common.RegisterDecoder("ext", false /* noop */, NewDecoder)
}
```
Format packages are blank-imported from `cmd/um/main.go` so `init()` runs. New format packages **must** be blank-imported there.
`noop == true` means “looks like plaintext audio” (see `algo/common/raw.go`). Default CLI skips them (`--skip-noop` defaults true).
## CryptoParams / keys
```go
type CryptoParams struct {
KggDbPath string // KuGou KGMv5
QmcKeys QMCKeys // map[normalizedName]ekey
}
```
QMC key merge order in `appMain` (later maps overwrite earlier on same key):
1. `qmc.LoadMMKVOrDefault` — **platform build tags**
- `key_mmkv_loader_darwin.go` — QQ Music Mac v8 / v10
- `key_mmkv_loader_windows.go` — runs `qmpc-mmkv-cli`
- `key_mmkv_loader_default.go` — other OS: empty
2. `qmc.LoadUserMMKV(--qmc-mmkv, --qmc-mmkv-key)` — soft-fail
3. `qmc.LoadAndroidDB(--qmc-android-db)` — soft-fail; tables `audio_file_ekey_table` or `EKeyFileInfo`
Always normalize map keys/values with `internal/utils.NormalizeUnicode` (NFC) when adding loaders.
KGG: `algo/kgm/pc_kugou_db` — real decrypt in `cipher_windows.go` (filename build constraint); stub on `!windows`.
## Adding or changing a decoder
1. Prefer extending an existing `algo/<vendor>` package over a new top-level package.
2. Implement `Validate` (seek/parse headers, init cipher) then `Read` (decrypt in place with absolute offset).
3. Register every real-world extension in `init()`; QMC already lists many aliases (Weiyun hex names, `mflac*` / `mgg*` suffixes).
4. Blank-import the package from `cmd/um` if new.
5. Pass secrets only via `DecoderParams.CryptoParams`, never globals.
6. Soft-fail optional key sources with `logger.Warn`; hard-fail only when decrypt cannot proceed.
7. Add `_test.go` next to the cipher/loader you touch; keep fixtures small.
Optional metadata: implement `AudioMetaGetter` / `CoverImageGetter` on the same decoder type.
## Platform / build tags
| Pattern | Purpose |
|---------|---------|
| `*_darwin.go` / `*_windows.go` | OS-specific implementations |
| `//go:build !windows` etc. | Explicit stubs |
| `algo/qmc/qmmac/` | macOS QQ Music MMKV helpers |
Do not call Windows-only or Darwin-only APIs from untagged files. Prefer stub + real file pairs over runtime `GOOS` switches when the whole API is unavailable.
## Conventions
- Errors: wrap with `%w` and a short package prefix (`qmc init cipher:`, `kgm v5:`).
- Logging: `*zap.Logger` from `DecoderParams.Logger` inside algo code; CLI still uses a package-level logger in `main` (do not expand that pattern).
- No new dependencies without need; prefer stdlib + existing modules (`urfave/cli/v2`, `zap`, `go-mmkv`, `modernc.org/sqlite`).
- Do not commit built binaries (`/um`, `*.exe` — see `.gitignore`).
- User-facing docs: keep `README.md` accurate when changing flags or Go version; keep `CHANGELOG.md` under `[Unreleased]` for notable behavior changes.
- Scope: this repo is the CLI only; crypto shared with web may live in sibling `um` projects — do not assume web TS sources are present here.
## Useful file map
| Task | Start here |
|------|------------|
| CLI flags / pipeline | `cmd/um/main.go` |
| Register / dispatch | `algo/common/dispatch.go`, `interface.go` |
| QMC decrypt / footer | `algo/qmc/qmc.go`, `key_derive.go`, `cipher_*.go` |
| QMC keys | `algo/qmc/key_mmkv_loader*.go`, `key_android_db_loader.go` |
| KGM / KGG | `algo/kgm/`, `algo/kgm/pc_kugou_db/` |
| NCM | `algo/ncm/` |
| Metadata rewrite | `internal/ffmpeg/` |
## Tests
```bash
go test ./algo/qmc/ -count=1
go test ./... -count=1
```
Prefer table-driven tests; avoid network in unit tests. Platform-specific tests may no-op or skip off-target OS.
+17
View File
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [v0.2.20] - 2026-10-18
### Added
- [#2] Support `.mflac2` / `.mgg2` extensions.
- [#7] Load QMC ekeys from QQ Music Android `player_process_db` (`--qmc-android-db`).
- [#8] Windows: auto-fetch QMC keys via `qmpc-mmkv-cli` (`--qmpc-mmkv-cli-exe`).
### Changed
- [#8] User MMKV (`--qmc-mmkv`) now merges with platform default keys on all OSes.
## [v0.2.19] - 2025-11-26 ## [v0.2.19] - 2025-11-26
### Fixed ### Fixed
@@ -78,3 +90,8 @@ 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. Or view the complete commit history on the project repository for detailed information about features, fixes, and improvements in previous releases.
[#2]: https://git.um-react.app/um/cli/pulls/2
[#7]: https://git.um-react.app/um/cli/pulls/7
[#8]: https://git.um-react.app/um/cli/pulls/8
[v0.2.20]: https://git.um-react.app/um/cli/releases/tag/v0.2.20
+37 -10
View File
@@ -11,8 +11,11 @@ Original: Web Edition https://git.um-react.app/um/web
## Features ## Features
- [x] All Algorithm Supported By `unlock-music/web` - Decrypt formats supported by `unlock-music/web` (NCM, QMC/MGG/MFLAC, KGM/KGG, KWM, XM, Ximalaya, TM, …)
- [x] Complete Metadata & Cover Image - Optional network metadata & cover update via `--update-metadata` (requires `ffmpeg` / `ffprobe` on `PATH`)
- Directory watch mode (`--watch`), overwrite / remove-source helpers
- QMC ekey loaders: platform defaults (macOS / Windows), user MMKV, Android `player_process_db`
- KGG (KGMv5) on Windows via KuGou `KGMusicV3.db`
## Release ## Release
@@ -20,19 +23,43 @@ Original: Web Edition https://git.um-react.app/um/web
## Install from source ## Install from source
- Requirements: **Golang 1.23.3** - Requirements: **Go 1.25+** (see `go.mod`)
1. run `go install git.um-react.app/um/cli/cmd/um@main` ```bash
go install git.um-react.app/um/cli/cmd/um@main
```
### Build from repo source ### Build from repo source
1. Pull repo source. ```bash
2. Build with `go build ./cmd/um`. git clone https://git.um-react.app/um/cli.git
cd cli
go build -o um ./cmd/um
```
It will produce `um` or `um.exe` (Windows). Produces `um` (or `um.exe` on Windows).
## How to use ## How to use
- Drag the encrypted file to `um.exe` (Tested on Windows) ```bash
- Run: `./um [-o <output dir>] [-i] <input dir/file>` # Single file or directory (default input = cwd, default output = input dir)
- Use `./um -h` to show help menu ./um [-o <output dir>] [-i] <input dir/file>
# Common flags
./um -o ./out --overwrite ./encrypted/
./um --watch -o ./out ./inbox/ # process existing + watch for new files
./um --update-metadata -o ./out song.mflac # needs ffmpeg/ffprobe
./um --supported-ext # list registered extensions
./um -h
```
Drag-and-drop onto `um.exe` also works on Windows (passes the path as argv).
## Development
See [AGENTS.md](./AGENTS.md) for repository layout, how to add a decoder, and agent-oriented conventions.
## License
This project is licensed under the MIT License.
See the [LICENSE](./LICENSE) file for details.
+110
View File
@@ -0,0 +1,110 @@
package qmc
import (
"database/sql"
"fmt"
"path/filepath"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/internal/utils"
"go.uber.org/zap"
_ "modernc.org/sqlite"
)
// LoadAndroidDB loads QMC ekeys from a QQ Music Android SQLite DB
// (player_process_db). Soft-fails with a warning.
func LoadAndroidDB(path string, logger *zap.Logger) common.QMCKeys {
if path == "" {
return nil
}
logger.Debug("LoadAndroidDB: Loading Android DB", zap.String("path", path))
keys, err := loadAndroidDB(path)
if err != nil {
logger.Warn("LoadAndroidDB: could not read Android DB keys", zap.Error(err))
return nil
}
logger.Info("Using Android DB", zap.String("path", path), zap.Int("count", len(keys)))
return keys
}
func loadAndroidDB(path string) (common.QMCKeys, error) {
// mode=ro: refuse writes and do not create a missing database file.
dsn := "file:" + filepath.ToSlash(path) + "?mode=ro"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open: %w", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping: %w", err)
}
query, err := androidEKeyQuery(db)
if err != nil {
return nil, err
}
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("query: %w", err)
}
defer rows.Close()
result := make(common.QMCKeys)
for rows.Next() {
var filePath, ekey string
if err := rows.Scan(&filePath, &ekey); err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
filePath = utils.NormalizeUnicode(filePath)
ekey = utils.NormalizeUnicode(ekey)
if filePath == "" || ekey == "" {
continue
}
result[filePath] = ekey
if base := filepath.Base(filePath); base != filePath {
result[utils.NormalizeUnicode(base)] = ekey
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows: %w", err)
}
return result, nil
}
func androidEKeyQuery(db *sql.DB) (string, error) {
has, err := hasTable(db, "audio_file_ekey_table")
if err != nil {
return "", err
}
if has {
return "select file_path, ekey from audio_file_ekey_table", nil
}
has, err = hasTable(db, "EKeyFileInfo")
if err != nil {
return "", err
}
if has {
return "select filePath, eKey from EKeyFileInfo", nil
}
return "", fmt.Errorf("no known ekey table found (audio_file_ekey_table or EKeyFileInfo)")
}
func hasTable(db *sql.DB, name string) (bool, error) {
var found string
err := db.QueryRow(
`select name from sqlite_master where type='table' and name=?`,
name,
).Scan(&found)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, fmt.Errorf("hasTable(%s): %w", name, err)
}
return true, nil
}
+135
View File
@@ -0,0 +1,135 @@
package qmc
import (
"database/sql"
"os"
"path/filepath"
"testing"
"go.uber.org/zap"
)
func TestLoadAndroidDB_audioFileEkeyTable(t *testing.T) {
path := filepath.Join(t.TempDir(), "db")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`
create table audio_file_ekey_table (file_path text, ekey text);
insert into audio_file_ekey_table values
('/storage/emulated/0/Music/song.mflac', 'ekey1'),
('plain.mgg', 'ekey2'),
('', 'skip'),
('empty-ekey.mflac', '');
`)
if err != nil {
t.Fatal(err)
}
db.Close()
got := LoadAndroidDB(path, zap.NewNop())
if got["/storage/emulated/0/Music/song.mflac"] != "ekey1" {
t.Errorf("full path = %q", got["/storage/emulated/0/Music/song.mflac"])
}
if got["song.mflac"] != "ekey1" {
t.Errorf("basename = %q, want ekey1", got["song.mflac"])
}
if got["plain.mgg"] != "ekey2" {
t.Errorf("plain.mgg = %q", got["plain.mgg"])
}
if _, ok := got[""]; ok {
t.Error("empty path should not be stored")
}
if _, ok := got["empty-ekey.mflac"]; ok {
t.Error("empty ekey should not be stored")
}
}
func TestLoadAndroidDB_EKeyFileInfo(t *testing.T) {
path := filepath.Join(t.TempDir(), "db")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`
create table EKeyFileInfo (filePath text, eKey text);
insert into EKeyFileInfo values
('/data/data/com.tencent.qqmusic/files/a.mflac', 'KEYA');
`)
if err != nil {
t.Fatal(err)
}
db.Close()
got := LoadAndroidDB(path, zap.NewNop())
if got["/data/data/com.tencent.qqmusic/files/a.mflac"] != "KEYA" {
t.Errorf("full path = %q", got["/data/data/com.tencent.qqmusic/files/a.mflac"])
}
if got["a.mflac"] != "KEYA" {
t.Errorf("basename = %q, want KEYA", got["a.mflac"])
}
}
func TestLoadAndroidDB_emptyPath(t *testing.T) {
got := LoadAndroidDB("", zap.NewNop())
if got != nil {
t.Errorf("got %#v, want nil", got)
}
}
func TestLoadAndroidDB_missingFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "no-such-db")
got := LoadAndroidDB(path, zap.NewNop())
if got != nil {
t.Errorf("got %#v, want nil", got)
}
if _, err := os.Stat(path); err == nil {
t.Error("missing db path should not be created")
} else if !os.IsNotExist(err) {
t.Fatalf("stat: %v", err)
}
}
func TestLoadAndroidDB_noKnownTable(t *testing.T) {
path := filepath.Join(t.TempDir(), "db")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`create table other (x text)`); err != nil {
t.Fatal(err)
}
db.Close()
got := LoadAndroidDB(path, zap.NewNop())
if got != nil {
t.Errorf("got %#v, want nil", got)
}
}
func TestLoadAndroidDB_prefersAudioFileEkeyTable(t *testing.T) {
path := filepath.Join(t.TempDir(), "db")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`
create table audio_file_ekey_table (file_path text, ekey text);
create table EKeyFileInfo (filePath text, eKey text);
insert into audio_file_ekey_table values ('from-new.mflac', 'new');
insert into EKeyFileInfo values ('from-old.mflac', 'old');
`)
if err != nil {
t.Fatal(err)
}
db.Close()
got := LoadAndroidDB(path, zap.NewNop())
if got["from-new.mflac"] != "new" {
t.Errorf("got %#v, want new table", got)
}
if _, ok := got["from-old.mflac"]; ok {
t.Error("should prefer audio_file_ekey_table over EKeyFileInfo")
}
}
+64
View File
@@ -0,0 +1,64 @@
package qmc
import (
"bufio"
"io"
"strings"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/internal/mmkv"
"git.um-react.app/um/cli/internal/utils"
"go.uber.org/zap"
)
// MMKVLoaderOptions holds platform-specific inputs for LoadMMKVOrDefault.
type MMKVLoaderOptions struct {
CliExe string // qmpc-mmkv-cli path (--qmpc-mmkv-cli-exe); Windows only
}
// LoadUserMMKV loads keys from a user-specified mmkv path. Soft-fails with a warning.
func LoadUserMMKV(path string, key string, logger *zap.Logger) common.QMCKeys {
if path == "" {
return nil
}
logger.Debug("LoadUserMMKV: Loading user mmkv", zap.String("path", path), zap.String("key", key))
userKeys, err := mmkv.LoadFromPath(path, key, logger)
if err != nil {
logger.Warn("LoadUserMMKV: could not read user keys", zap.Error(err))
return nil
}
logger.Info("Using user mmkv", zap.String("path", path), zap.Int("count", len(userKeys)))
return userKeys
}
// parseQmpcMMKVCliOutput reads qmpc-mmkv-cli plaintext output as alternating
// filename / ekey lines and builds a QMCKeys map.
func parseQmpcMMKVCliOutput(r io.Reader, logger *zap.Logger) common.QMCKeys {
sc := bufio.NewScanner(r)
result := make(common.QMCKeys)
var name string
nameRead := false
for sc.Scan() {
line := utils.NormalizeUnicode(strings.TrimSpace(sc.Text()))
if !nameRead {
name = line
} else {
if name != "" && line != "" {
result[name] = line
}
}
nameRead = !nameRead
}
if err := sc.Err(); err != nil {
logger.Warn("LoadMMKVOrDefault: error reading qmpc-mmkv-cli output", zap.Error(err))
}
if nameRead {
logger.Warn("LoadMMKVOrDefault: qmpc-mmkv-cli output has odd number of lines, ignoring last line",
zap.String("line", name),
)
}
return result
}
+2 -14
View File
@@ -7,7 +7,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
func LoadMMKVOrDefault(path string, key string, logger *zap.Logger) (result common.QMCKeys, err error) { func LoadMMKVOrDefault(opts MMKVLoaderOptions, logger *zap.Logger) (result common.QMCKeys, err error) {
key1, err := qmmac.LoadMacKeysV8(logger) key1, err := qmmac.LoadMacKeysV8(logger)
if err != nil { if err != nil {
key1 = nil key1 = nil
@@ -20,19 +20,7 @@ func LoadMMKVOrDefault(path string, key string, logger *zap.Logger) (result comm
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))
} }
userKeys := make(common.QMCKeys) allKeys := mmkv.Merge(key1, key2)
if path != "" {
logger.Info("Using user mmkv")
userKeys, err = mmkv.LoadFromPath(path, key, logger)
if err != nil {
userKeys = nil
logger.Warn("LoadMMKVOrDefault: could not read user keys", zap.Error(err))
}
}
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
} }
+3 -3
View File
@@ -1,4 +1,4 @@
//go:build !darwin //go:build !darwin && !windows
package qmc package qmc
@@ -7,7 +7,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
func LoadMMKVOrDefault(path string, key string, logger *zap.Logger) (result common.QMCKeys, err error) { func LoadMMKVOrDefault(opts MMKVLoaderOptions, logger *zap.Logger) (result common.QMCKeys, err error) {
// Stub: do nothing // No platform-default QMC keys on this OS.
return nil, nil return nil, nil
} }
+79
View File
@@ -0,0 +1,79 @@
package qmc
import (
"strings"
"testing"
"go.uber.org/zap"
)
func TestParseQmpcMMKVCliOutput(t *testing.T) {
logger := zap.NewNop()
t.Run("pairs", func(t *testing.T) {
in := "file1.mflac\nkey1\nfile2.mgg\nkey2\n"
got := parseQmpcMMKVCliOutput(strings.NewReader(in), logger)
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got["file1.mflac"] != "key1" {
t.Errorf("file1.mflac = %q, want key1", got["file1.mflac"])
}
if got["file2.mgg"] != "key2" {
t.Errorf("file2.mgg = %q, want key2", got["file2.mgg"])
}
})
t.Run("crlf", func(t *testing.T) {
in := "a.mflac\r\nKEYA\r\nb.mflac\r\nKEYB\r\n"
got := parseQmpcMMKVCliOutput(strings.NewReader(in), logger)
if got["a.mflac"] != "KEYA" || got["b.mflac"] != "KEYB" {
t.Errorf("got %#v", got)
}
})
t.Run("odd line ignored", func(t *testing.T) {
in := "file1.mflac\nkey1\norphan\n"
got := parseQmpcMMKVCliOutput(strings.NewReader(in), logger)
if len(got) != 1 || got["file1.mflac"] != "key1" {
t.Errorf("got %#v, want single pair", got)
}
})
t.Run("empty name skipped", func(t *testing.T) {
in := "\nkey-for-empty\nfile.mflac\nrealkey\n"
got := parseQmpcMMKVCliOutput(strings.NewReader(in), logger)
if len(got) != 1 || got["file.mflac"] != "realkey" {
t.Errorf("got %#v", got)
}
if _, ok := got[""]; ok {
t.Error("empty name should not be stored")
}
})
t.Run("empty key skipped", func(t *testing.T) {
in := "empty-key.mflac\n\nfile.mflac\nrealkey\n"
got := parseQmpcMMKVCliOutput(strings.NewReader(in), logger)
if len(got) != 1 || got["file.mflac"] != "realkey" {
t.Errorf("got %#v", got)
}
if _, ok := got["empty-key.mflac"]; ok {
t.Error("empty key should not be stored")
}
})
t.Run("empty input", func(t *testing.T) {
got := parseQmpcMMKVCliOutput(strings.NewReader(""), logger)
if len(got) != 0 {
t.Errorf("got %#v, want empty", got)
}
})
t.Run("no trailing newline", func(t *testing.T) {
in := "file.mflac\nkey"
got := parseQmpcMMKVCliOutput(strings.NewReader(in), logger)
if got["file.mflac"] != "key" {
t.Errorf("got %#v", got)
}
})
}
+45
View File
@@ -0,0 +1,45 @@
package qmc
import (
"bytes"
"errors"
"os/exec"
"git.um-react.app/um/cli/algo/common"
"go.uber.org/zap"
)
func LoadMMKVOrDefault(opts MMKVLoaderOptions, logger *zap.Logger) (result common.QMCKeys, err error) {
cliExe := opts.CliExe
if cliExe == "" {
cliExe = "qmpc-mmkv-cli.exe"
}
cmd := exec.Command(cliExe, "-f", "plaintext", "-o", "-")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
logger.Warn("LoadMMKVOrDefault: qmpc-mmkv-cli exited with non-zero status",
zap.String("exe", cliExe),
zap.Int("exit_code", exitErr.ExitCode()),
zap.String("stderr", stderr.String()),
)
} else {
logger.Warn("LoadMMKVOrDefault: failed to run qmpc-mmkv-cli",
zap.String("exe", cliExe),
zap.Error(err),
zap.String("stderr", stderr.String()),
)
}
return nil, nil
}
result = parseQmpcMMKVCliOutput(&stdout, logger)
logger.Debug("Keys loaded from qmpc-mmkv-cli", zap.Int("len", len(result)))
return result, nil
}
+2 -2
View File
@@ -276,8 +276,8 @@ func init() {
// New ogg/flac: // New ogg/flac:
extraExtsCanHaveSuffix := []string{"mgg", "mflac"} extraExtsCanHaveSuffix := []string{"mgg", "mflac"}
// Mac also adds some extra suffix to ext: // Mac/Android also adds some extra suffix to ext:
extraExtSuffix := []string{"0", "1", "a", "h", "l", "m"} extraExtSuffix := []string{"0", "1", "2", "a", "h", "l", "m"}
for _, ext := range extraExtsCanHaveSuffix { for _, ext := range extraExtsCanHaveSuffix {
common.RegisterDecoder(ext, false, NewDecoder) common.RegisterDecoder(ext, false, NewDecoder)
for _, suffix := range extraExtSuffix { for _, suffix := range extraExtSuffix {
+20 -2
View File
@@ -24,6 +24,7 @@ import (
_ "git.um-react.app/um/cli/algo/xiami" _ "git.um-react.app/um/cli/algo/xiami"
_ "git.um-react.app/um/cli/algo/ximalaya" _ "git.um-react.app/um/cli/algo/ximalaya"
"git.um-react.app/um/cli/internal/ffmpeg" "git.um-react.app/um/cli/internal/ffmpeg"
"git.um-react.app/um/cli/internal/mmkv"
"git.um-react.app/um/cli/internal/sniff" "git.um-react.app/um/cli/internal/sniff"
"git.um-react.app/um/cli/internal/utils" "git.um-react.app/um/cli/internal/utils"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
@@ -51,6 +52,8 @@ func main() {
&cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "path to output dir", Required: false}, &cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "path to output dir", Required: false},
&cli.StringFlag{Name: "qmc-mmkv", Aliases: []string{"db"}, Usage: "path to QQMusic mmkv path", Required: false}, &cli.StringFlag{Name: "qmc-mmkv", Aliases: []string{"db"}, Usage: "path to QQMusic mmkv path", Required: false},
&cli.StringFlag{Name: "qmc-mmkv-key", Aliases: []string{"key"}, Usage: "QQMusic mmkv password (16 ascii chars)", Required: false}, &cli.StringFlag{Name: "qmc-mmkv-key", Aliases: []string{"key"}, Usage: "QQMusic mmkv password (16 ascii chars)", Required: false},
&cli.StringFlag{Name: "qmpc-mmkv-cli-exe", Usage: "path to QMPC MMKV Cli by LSR (v0.1.1+, auto-fetch QMC keys; Windows only)", Required: false, Value: "qmpc-mmkv-cli.exe"},
&cli.StringFlag{Name: "qmc-android-db", Usage: "path to QQMusic Android player_process_db (dump from android: /data/data/com.tencent.qqmusic/databases/)", Required: false, Value: ""},
&cli.StringFlag{Name: "kgg-db", Usage: "path to kgg db (win32 kugou v11)", Required: false}, &cli.StringFlag{Name: "kgg-db", Usage: "path to kgg db (win32 kugou v11)", Required: false},
&cli.BoolFlag{Name: "remove-source", Aliases: []string{"rs"}, Usage: "remove source file", Required: false, Value: 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: "skip-noop", Aliases: []string{"n"}, Usage: "skip noop decoder", Required: false, Value: true},
@@ -176,11 +179,26 @@ func appMain(c *cli.Context) (err error) {
return errors.New("output should be a writable directory") return errors.New("output should be a writable directory")
} }
// QMC: Load keys // QMC: Load platform keys, then merge user mmkv (--qmc-mmkv) on all platforms.
qmcKeys, err := qmc.LoadMMKVOrDefault(c.String("qmc-mmkv"), c.String("qmc-mmkv-key"), logger) qmcKeys, err := qmc.LoadMMKVOrDefault(qmc.MMKVLoaderOptions{
CliExe: c.String("qmpc-mmkv-cli-exe"),
}, logger)
if err != nil { if err != nil {
return err return err
} }
qmcAndroidKeysPath := c.String("qmc-android-db")
if qmcAndroidKeysPath == "" {
// By default, use the current directory's `player_process_db` file if exists.
// Do not complain if this file does not exist.
if db, err := os.Stat("player_process_db"); err == nil && !db.IsDir() {
qmcAndroidKeysPath = "player_process_db"
}
}
qmcKeys = mmkv.Merge(
qmcKeys,
qmc.LoadUserMMKV(c.String("qmc-mmkv"), c.String("qmc-mmkv-key"), logger),
qmc.LoadAndroidDB(qmcAndroidKeysPath, logger),
)
kggDbPath := c.String("kgg-db") kggDbPath := c.String("kgg-db")
if kggDbPath == "" { if kggDbPath == "" {
+10 -2
View File
@@ -1,3 +1,5 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -12,10 +14,14 @@ github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGO
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI= github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY= github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8= github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@@ -30,8 +36,6 @@ github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
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.3 h1:BtO77wjmypCeaX3n8VRDX5ZWHlYdJ9xEfKGui9s4PM0=
github.com/unlock-music/go-mmkv v0.1.3/go.mod h1:u1fYHzYOO7et1E55Q86lTvjXf6p14CIELszAHI8T9zQ=
github.com/unlock-music/go-mmkv v0.1.4 h1:onjxxlNd9Fcp4RYL8khOgp/DRY0n+Iuw958TdRoB/FA= github.com/unlock-music/go-mmkv v0.1.4 h1:onjxxlNd9Fcp4RYL8khOgp/DRY0n+Iuw958TdRoB/FA=
github.com/unlock-music/go-mmkv v0.1.4/go.mod h1:u1fYHzYOO7et1E55Q86lTvjXf6p14CIELszAHI8T9zQ= github.com/unlock-music/go-mmkv v0.1.4/go.mod h1:u1fYHzYOO7et1E55Q86lTvjXf6p14CIELszAHI8T9zQ=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
@@ -50,11 +54,15 @@ golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/y
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=