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/cmd/um/main.go b/cmd/um/main.go index 1dcfd14..8652d82 100644 --- a/cmd/um/main.go +++ b/cmd/um/main.go @@ -53,6 +53,7 @@ func main() { &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: "player_process_db"}, &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}, @@ -185,7 +186,11 @@ func appMain(c *cli.Context) (err error) { if err != nil { return err } - qmcKeys = mmkv.Merge(qmcKeys, qmc.LoadUserMMKV(c.String("qmc-mmkv"), c.String("qmc-mmkv-key"), logger)) + qmcKeys = mmkv.Merge( + qmcKeys, + qmc.LoadUserMMKV(c.String("qmc-mmkv"), c.String("qmc-mmkv-key"), logger), + qmc.LoadAndroidDB(c.String("qmc-android-db"), logger), + ) kggDbPath := c.String("kgg-db") if kggDbPath == "" {