diff --git a/.cursor/rules/um-cli.mdc b/.cursor/rules/um-cli.mdc new file mode 100644 index 0000000..34d0c25 --- /dev/null +++ b/.cursor/rules/um-cli.mdc @@ -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//`, 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. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..50ad9de --- /dev/null +++ b/AGENTS.md @@ -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/` 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4337323..495d57a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [v0.2.20] - 2026-10-20 + +### 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`). +- [#5] `--ignore-known-non-music-ext` / `--no-ignore-known-non-music-ext` (default on): skip files with no extension or known non-music file. + + +### Changed + +- [#8] User MMKV (`--qmc-mmkv`) now merges with platform default keys on all OSes. + ## [v0.2.19] - 2025-11-26 ### Fixed @@ -78,3 +92,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. + +[#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 diff --git a/README.md b/README.md index 0c83a02..168d9ca 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,11 @@ Original: Web Edition https://git.um-react.app/um/web ## Features -- [x] All Algorithm Supported By `unlock-music/web` -- [x] Complete Metadata & Cover Image +- Decrypt formats supported by `unlock-music/web` (NCM, QMC/MGG/MFLAC, KGM/KGG, KWM, XM, Ximalaya, TM, …) +- 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 @@ -20,19 +23,43 @@ Original: Web Edition https://git.um-react.app/um/web ## 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 -1. Pull repo source. -2. Build with `go build ./cmd/um`. +```bash +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 -- Drag the encrypted file to `um.exe` (Tested on Windows) -- Run: `./um [-o ] [-i] ` -- Use `./um -h` to show help menu +```bash +# Single file or directory (default input = cwd, default output = input dir) +./um [-o ] [-i] + +# 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. diff --git a/algo/common/dispatch.go b/algo/common/dispatch.go index fc86df6..ce139e1 100644 --- a/algo/common/dispatch.go +++ b/algo/common/dispatch.go @@ -11,6 +11,16 @@ import ( type QMCKeys map[string]string +func MergeQMCKeys(keys ...QMCKeys) QMCKeys { + result := make(QMCKeys) + for _, k := range keys { + for key, value := range k { + result[utils.NormalizeUnicode(key)] = utils.NormalizeUnicode(value) + } + } + return result +} + type CryptoParams struct { // KuGou kgg database path KggDbPath string diff --git a/algo/qmc/key_android_db_loader.go b/algo/qmc/key_android_db_loader.go new file mode 100644 index 0000000..535452e --- /dev/null +++ b/algo/qmc/key_android_db_loader.go @@ -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 +} diff --git a/algo/qmc/key_android_db_loader_test.go b/algo/qmc/key_android_db_loader_test.go new file mode 100644 index 0000000..5a4fe6e --- /dev/null +++ b/algo/qmc/key_android_db_loader_test.go @@ -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") + } +} diff --git a/algo/qmc/key_mmkv_loader.go b/algo/qmc/key_mmkv_loader.go new file mode 100644 index 0000000..a9fc990 --- /dev/null +++ b/algo/qmc/key_mmkv_loader.go @@ -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("parseQmpcMMKVCliOutput: error reading qmpc-mmkv-cli output", zap.Error(err)) + } + if nameRead { + logger.Warn("parseQmpcMMKVCliOutput: qmpc-mmkv-cli output has odd number of lines, ignoring last line", + zap.String("line", name), + ) + } + + return result +} diff --git a/algo/qmc/key_mmkv_loader_darwin.go b/algo/qmc/key_mmkv_loader_darwin.go index e406c2c..28ef9f1 100644 --- a/algo/qmc/key_mmkv_loader_darwin.go +++ b/algo/qmc/key_mmkv_loader_darwin.go @@ -3,11 +3,10 @@ package qmc import ( "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" ) -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) if err != nil { key1 = nil @@ -20,19 +19,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)) } - userKeys := make(common.QMCKeys) - 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) - + allKeys := common.MergeQMCKeys(key1, key2) logger.Debug("Keys loaded", zap.Any("keys", allKeys), zap.Int("len", len(allKeys))) - return allKeys, nil } diff --git a/algo/qmc/key_mmkv_loader_default.go b/algo/qmc/key_mmkv_loader_default.go index dff3be3..edcb9a9 100644 --- a/algo/qmc/key_mmkv_loader_default.go +++ b/algo/qmc/key_mmkv_loader_default.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin && !windows package qmc @@ -7,7 +7,7 @@ import ( "go.uber.org/zap" ) -func LoadMMKVOrDefault(path string, key string, logger *zap.Logger) (result common.QMCKeys, err error) { - // Stub: do nothing +func LoadMMKVOrDefault(opts MMKVLoaderOptions, logger *zap.Logger) (result common.QMCKeys, err error) { + // No platform-default QMC keys on this OS. return nil, nil } diff --git a/algo/qmc/key_mmkv_loader_test.go b/algo/qmc/key_mmkv_loader_test.go new file mode 100644 index 0000000..e6e8d68 --- /dev/null +++ b/algo/qmc/key_mmkv_loader_test.go @@ -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) + } + }) +} diff --git a/algo/qmc/key_mmkv_loader_windows.go b/algo/qmc/key_mmkv_loader_windows.go new file mode 100644 index 0000000..1e894d3 --- /dev/null +++ b/algo/qmc/key_mmkv_loader_windows.go @@ -0,0 +1,35 @@ +package qmc + +import ( + "bytes" + "os/exec" + + "git.um-react.app/um/cli/algo/common" + "go.uber.org/zap" +) + +func runQmpcMMKVCLi(exe string) (stdout, stderr bytes.Buffer, err error) { + cmd := exec.Command(exe, "-f", "plaintext", "-o", "-") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err = cmd.Run() + return stdout, stderr, err +} + +func LoadMMKVOrDefault(opts MMKVLoaderOptions, logger *zap.Logger) (result common.QMCKeys, err error) { + exe := opts.CliExe + + stdout, stderr, err := runQmpcMMKVCLi(exe) + if err != nil { + logger.Warn("LoadMMKVOrDefault: qmpc-mmkv-cli exited with error", + zap.String("exe", exe), + zap.Error(err), + zap.String("stderr", stderr.String()), + ) + return nil, nil + } + + result = parseQmpcMMKVCliOutput(&stdout, logger) + logger.Debug("LoadMMKVOrDefault: Keys loaded from qmpc-mmkv-cli", zap.Int("count", len(result))) + return result, nil +} diff --git a/algo/qmc/qmc.go b/algo/qmc/qmc.go index b58ad0b..9f8cc17 100644 --- a/algo/qmc/qmc.go +++ b/algo/qmc/qmc.go @@ -276,8 +276,8 @@ func init() { // New ogg/flac: extraExtsCanHaveSuffix := []string{"mgg", "mflac"} - // Mac also adds some extra suffix to ext: - extraExtSuffix := []string{"0", "1", "a", "h", "l", "m"} + // Mac/Android also adds some extra suffix to ext: + extraExtSuffix := []string{"0", "1", "2", "a", "h", "l", "m"} for _, ext := range extraExtsCanHaveSuffix { common.RegisterDecoder(ext, false, NewDecoder) for _, suffix := range extraExtSuffix { diff --git a/algo/qmc/qmmac/v10_darwin.go b/algo/qmc/qmmac/v10_darwin.go index d3ddd2d..d33838f 100644 --- a/algo/qmc/qmmac/v10_darwin.go +++ b/algo/qmc/qmmac/v10_darwin.go @@ -159,5 +159,5 @@ func LoadMacKeysV10(logger *zap.Logger) (common.QMCKeys, error) { return nil, err } - return mmkv.Merge(append(keys1, keys2...)...), nil + return common.MergeQMCKeys(append(keys1, keys2...)...), nil } diff --git a/cmd/um/main.go b/cmd/um/main.go index 721a624..3e5bf8d 100644 --- a/cmd/um/main.go +++ b/cmd/um/main.go @@ -51,9 +51,13 @@ func main() { &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-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.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: "ignore-known-non-music-ext", Usage: "skip files with no extension or known non-music extensions (txt, mmkv, exe, dll)", Required: false, Value: true}, + &cli.BoolFlag{Name: "no-ignore-known-non-music-ext", Usage: "do not skip known non-music extensions", Required: false, Value: false}, &cli.BoolFlag{Name: "verbose", Aliases: []string{"V"}, Usage: "verbose logging", Required: false, Value: false}, &cli.BoolFlag{Name: "update-metadata", Usage: "update metadata & album art from network", Required: false, Value: false}, &cli.BoolFlag{Name: "overwrite", Usage: "overwrite output file without asking", Required: false, Value: false}, @@ -176,25 +180,46 @@ func appMain(c *cli.Context) (err error) { return errors.New("output should be a writable directory") } - // QMC: Load keys - qmcKeys, err := qmc.LoadMMKVOrDefault(c.String("qmc-mmkv"), c.String("qmc-mmkv-key"), logger) + // QMC: Load platform keys, then merge user mmkv (--qmc-mmkv) on all platforms. + qmcKeys, err := qmc.LoadMMKVOrDefault(qmc.MMKVLoaderOptions{ + CliExe: c.String("qmpc-mmkv-cli-exe"), + }, logger) if err != nil { 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 = common.MergeQMCKeys( + qmcKeys, + qmc.LoadUserMMKV(c.String("qmc-mmkv"), c.String("qmc-mmkv-key"), logger), + qmc.LoadAndroidDB(qmcAndroidKeysPath, logger), + ) kggDbPath := c.String("kgg-db") if kggDbPath == "" { kggDbPath = filepath.Join(os.Getenv("APPDATA"), "Kugou8", "KGMusicV3.db") } + ignoreKnownNonMusicExt := c.Bool("ignore-known-non-music-ext") + if c.Bool("no-ignore-known-non-music-ext") { + ignoreKnownNonMusicExt = false + } + proc := &processor{ - logger: logger, - inputDir: inputDir, - outputDir: output, - skipNoopDecoder: c.Bool("skip-noop"), - removeSource: c.Bool("remove-source"), - updateMetadata: c.Bool("update-metadata"), - overwriteOutput: c.Bool("overwrite"), + logger: logger, + inputDir: inputDir, + outputDir: output, + skipNoopDecoder: c.Bool("skip-noop"), + ignoreKnownNonMusicExt: ignoreKnownNonMusicExt, + removeSource: c.Bool("remove-source"), + updateMetadata: c.Bool("update-metadata"), + overwriteOutput: c.Bool("overwrite"), crypto: common.CryptoParams{ // KuGou @@ -223,10 +248,11 @@ type processor struct { inputDir string outputDir string - skipNoopDecoder bool - removeSource bool - updateMetadata bool - overwriteOutput bool + skipNoopDecoder bool + ignoreKnownNonMusicExt bool + removeSource bool + updateMetadata bool + overwriteOutput bool crypto common.CryptoParams } @@ -313,6 +339,11 @@ func (p *processor) processDir(inputDir string) error { } func (p *processor) processFile(filePath string) error { + if p.ignoreKnownNonMusicExt && isKnownNonMusicExt(filePath) { + p.logger.Debug("skip known non-music extension", zap.String("file", filePath)) + return nil + } + p.logger.Debug("processFile", zap.String("file", filePath), zap.String("inputDir", p.inputDir)) allDec := common.GetDecoder(filePath, p.skipNoopDecoder) diff --git a/cmd/um/non_music_ext.go b/cmd/um/non_music_ext.go new file mode 100644 index 0000000..bd48cae --- /dev/null +++ b/cmd/um/non_music_ext.go @@ -0,0 +1,21 @@ +package main + +import ( + "path/filepath" + "slices" + "strings" +) + +// knownNonMusicExts are common non-audio suffixes to skip when walking dirs. +var knownNonMusicExts = []string{ + "", + ".txt", + ".mmkv", + ".exe", + ".dll", +} + +func isKnownNonMusicExt(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return slices.Contains(knownNonMusicExts, ext) +} diff --git a/go.mod b/go.mod index a17ebee..e00b909 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/go-flac/flacvorbis v0.2.0 github.com/go-flac/go-flac v1.0.0 github.com/samber/lo v1.52.0 - github.com/unlock-music/go-mmkv v0.1.4 + github.com/unlock-music/go-mmkv v0.1.6 github.com/urfave/cli/v2 v2.27.7 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.44.0 diff --git a/go.sum b/go.sum index 462b60a..1219931 100644 --- a/go.sum +++ b/go.sum @@ -30,10 +30,8 @@ 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/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/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/go.mod h1:u1fYHzYOO7et1E55Q86lTvjXf6p14CIELszAHI8T9zQ= +github.com/unlock-music/go-mmkv v0.1.6 h1:6WW5LcMp1d6Bb8/5zdXEtuMbPta/jFcYMqW2EA8d4BY= +github.com/unlock-music/go-mmkv v0.1.6/go.mod h1:u1fYHzYOO7et1E55Q86lTvjXf6p14CIELszAHI8T9zQ= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= diff --git a/internal/mmkv/mmkv.go b/internal/mmkv/mmkv.go index 0570f8c..5ab040f 100644 --- a/internal/mmkv/mmkv.go +++ b/internal/mmkv/mmkv.go @@ -2,6 +2,7 @@ package mmkv import ( "fmt" + "io" "os" "git.um-react.app/um/cli/algo/common" @@ -10,16 +11,6 @@ import ( "go.uber.org/zap" ) -func Merge(keys ...common.QMCKeys) common.QMCKeys { - result := make(common.QMCKeys) - for _, k := range keys { - for key, value := range k { - result[utils.NormalizeUnicode(key)] = utils.NormalizeUnicode(value) - } - } - return result -} - func LoadFromPath(path string, key string, logger *zap.Logger) (result common.QMCKeys, err error) { mmkv_path := path mmkv_crc := path + ".crc" @@ -31,6 +22,7 @@ func LoadFromPath(path string, key string, logger *zap.Logger) (result common.QM } defer mr.Close() + var crc io.Reader cr, err := os.Open(mmkv_crc) if err != nil { // crc is optional @@ -38,14 +30,22 @@ func LoadFromPath(path string, key string, logger *zap.Logger) (result common.QM key = "" } else { defer cr.Close() + crc = cr } + stat, err := os.Stat(path) + if err != nil { + logger.Error("LoadMMKV: Could not stat mmkv file", zap.Error(err)) + return nil, fmt.Errorf("LoadMMKV: stat error: %w", err) + } + mmkv_size := stat.Size() + var password []byte = nil if key != "" { password = make([]byte, 16) copy(password, []byte(key)) } - mmkv, err := go_mmkv.NewMMKVReader(mr, password, cr) + mmkv, err := go_mmkv.NewMMKVReader(mr, mmkv_size, password, crc) if err != nil { logger.Error("LoadMMKV: failed to create reader", zap.Error(err)) return nil, fmt.Errorf("LoadMMKV: NewMMKVReader error: %w", err)