mirror of
https://git.um-react.app/um/cli.git
synced 2026-10-18 20:48:34 +00:00
feat(#7): implement LoadAndroidDB function and add tests for loading ekeys from QQ Music Android SQLite database
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-1
@@ -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", 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: "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.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},
|
||||||
@@ -185,7 +186,11 @@ func appMain(c *cli.Context) (err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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")
|
kggDbPath := c.String("kgg-db")
|
||||||
if kggDbPath == "" {
|
if kggDbPath == "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user