From 061876f9bf849f562ac4b4ac47abdc0a72fe7aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=81=E6=A0=91=E4=BA=BA?= Date: Sun, 18 Oct 2026 08:03:38 +0900 Subject: [PATCH] refactor(#8): update LoadMMKVOrDefault function to use MMKVLoaderOptions and implement platform-specific loading for Windows --- algo/qmc/key_mmkv_loader.go | 64 +++++++++++++++++++++++ algo/qmc/key_mmkv_loader_darwin.go | 16 +----- algo/qmc/key_mmkv_loader_default.go | 6 +-- algo/qmc/key_mmkv_loader_test.go | 79 +++++++++++++++++++++++++++++ algo/qmc/key_mmkv_loader_windows.go | 45 ++++++++++++++++ cmd/um/main.go | 9 +++- 6 files changed, 200 insertions(+), 19 deletions(-) create mode 100644 algo/qmc/key_mmkv_loader.go create mode 100644 algo/qmc/key_mmkv_loader_test.go create mode 100644 algo/qmc/key_mmkv_loader_windows.go diff --git a/algo/qmc/key_mmkv_loader.go b/algo/qmc/key_mmkv_loader.go new file mode 100644 index 0000000..479e338 --- /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("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 +} diff --git a/algo/qmc/key_mmkv_loader_darwin.go b/algo/qmc/key_mmkv_loader_darwin.go index e406c2c..23a8db4 100644 --- a/algo/qmc/key_mmkv_loader_darwin.go +++ b/algo/qmc/key_mmkv_loader_darwin.go @@ -7,7 +7,7 @@ import ( "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 +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)) } - 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 := mmkv.Merge(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..3ed7788 --- /dev/null +++ b/algo/qmc/key_mmkv_loader_windows.go @@ -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 +} diff --git a/cmd/um/main.go b/cmd/um/main.go index 721a624..1dcfd14 100644 --- a/cmd/um/main.go +++ b/cmd/um/main.go @@ -24,6 +24,7 @@ import ( _ "git.um-react.app/um/cli/algo/xiami" _ "git.um-react.app/um/cli/algo/ximalaya" "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/utils" "github.com/fsnotify/fsnotify" @@ -51,6 +52,7 @@ 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: "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}, @@ -176,11 +178,14 @@ 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 } + qmcKeys = mmkv.Merge(qmcKeys, qmc.LoadUserMMKV(c.String("qmc-mmkv"), c.String("qmc-mmkv-key"), logger)) kggDbPath := c.String("kgg-db") if kggDbPath == "" {