189 Commits

Author SHA1 Message Date
鲁树人
107aa2c3a8 chore: update go-mmkv dependency to v0.1.4 and document MMKV parsing fix in changelog 2025-11-26 22:26:05 +09:00
鲁树人
a2190e0ff5 docs: update changelog 2025-11-17 08:46:23 +09:00
鲁树人
c104a0bc6e chore: add missing libc dep and cleanup 2025-11-17 08:31:34 +09:00
鲁树人
a163d40fb2 chore: upgrade deps 2025-11-12 20:48:38 +09:00
鲁树人
40f93b4dd2 chore: upgrade mmkv lib 2025-11-12 20:32:06 +09:00
鲁树人
8c1f40bfe1 docs: update changelog 2025-09-15 21:48:20 +09:00
鲁树人
aea3bd5714 fix: Handle musicex tag correctly 2025-09-15 21:35:12 +09:00
鲁树人
e4bfefd0a6 fix: use proper regex 2025-09-09 22:32:43 +09:00
鲁树人
b1aa3bacdf fix: relax regex to extract udid from plist 2025-09-09 22:24:16 +09:00
鲁树人
6ec434f6b1 docs: update v0.2.15 changelog 2025-09-09 21:54:16 +09:00
鲁树人
a46428e07c feat: support QQMusic from AppStore 2025-09-09 21:49:22 +09:00
鲁树人
57dd4b80b3 docs: add changelog 2025-09-08 23:42:08 +09:00
鲁树人
e06a21123f feat: QQMusic Mac v10 mmkv support 2025-09-08 23:30:27 +09:00
鲁树人
1b62887619 ci: bump go version to 1.25 2025-09-06 23:59:55 +09:00
鲁树人
35dbfdc5d1 chore: update url to new git host 2025-09-06 23:58:58 +09:00
鲁树人
f6614973ee ci: print artifact information at end 2025-09-06 23:54:56 +09:00
鲁树人
00fefc1297 chore: rename project namespace 2025-09-06 23:50:08 +09:00
鲁树人
4671143030 refactor: restrict kgg db support to Windows only 2025-09-06 23:48:21 +09:00
鲁树人
9b0455b0fd refactor: improve mmkv logic 2025-09-06 23:44:07 +09:00
鲁树人
92ad51402e fix: use mmkv from github 2025-09-03 00:43:40 +09:00
鲁树人
589e573b55 Merge pull request 'Fix: Artist metadata is lost for NCM files with mixed-type artist arrays' (#131) from solitudechn/cli:fix/handle-mixed-type-artist into main
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/131
2025-08-11 17:09:24 +00:00
solitudechn
20b746bee5 fix(ncm): Handle mixed-type artist field in metadata parsing
The current implementation for parsing artist metadata in `ncmMetaMusic.GetArtists` does not handle cases where the "artist" JSON field is a list containing mixed-type inner lists.

For certain NCM files (often those with FLAC format), the artist data is structured as `[["Artist Name", 12345]]`. The existing type assertion for `[][]string` fails for this structure because the inner slice is of type `[]interface{}`, not `[]string`. This results in the artist metadata being dropped during the conversion process.

This commit enhances the `GetArtists` method by adding a new case to the `type switch` to specifically handle the `[]interface{}` type. The new logic iterates through the nested structure and correctly extracts the artist name, which is assumed to be the first string element in each inner slice.

This fix improves compatibility with a wider range of NCM files and ensures artist metadata is reliably parsed, preventing data loss for affected files.
2025-08-09 23:58:03 +00:00
鲁树人
0a94383ba3 ci: make zip work 2025-05-09 11:21:22 +09:00
鲁树人
369112af01 ci: fix repack script 2025-05-09 05:06:40 +09:00
鲁树人
111952199f ci: allow manual trigger of build job 2025-05-09 05:03:00 +09:00
鲁树人
a217170077 ci: fix archive name and zip package 2025-05-09 05:00:21 +09:00
鲁树人
1a943309fa ci: simplify packing script logic 2025-05-09 04:47:18 +09:00
鲁树人
c0649d1246 ci: remove i386 target 2025-05-09 04:47:06 +09:00
鲁树人
3344db1645 docs: update reference to gitea actions 2025-05-08 07:11:55 +09:00
鲁树人
791f9c0621 ci: strip archives, reduce verbosity 2025-05-08 07:02:42 +09:00
鲁树人
896ace49fd ci: cleanup build job, follow old release naming 2025-05-08 06:36:33 +09:00
鲁树人
17cde2a1a5 refactor: rename functions to follow go pattern 2025-05-08 06:10:17 +09:00
鲁树人
61fba401c7 chore: mark default version as "custom" 2025-05-07 09:27:34 +09:00
鲁树人
a968be6063 Merge pull request 'kgg 支持' (#122) from feat/kgg into main
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/122
2025-05-07 00:26:25 +00:00
鲁树人
000ef4ac13 build: upgrade setup-go version 2025-05-07 09:18:00 +09:00
鲁树人
05e5783336 build: get ci working again 2025-05-07 09:13:47 +09:00
鲁树人
006bad8c48 feat: first version of kgg support 2025-05-07 08:12:57 +09:00
鲁树人
380ed78b6b Merge pull request 'fix: .mflacm support' (#117) from ccforeverd/cli:main into main
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/117
2025-02-18 11:07:38 +00:00
Zhang
208aceb1b5 fix: .mflacm support 2025-02-18 18:46:37 +08:00
鲁树人
28ca8cef60 docs: update readme about go version and how to build from source 2024-11-22 00:57:09 +09:00
鲁树人
2b0bd2985e chore: bump golang and dependency version 2024-11-22 00:50:52 +09:00
鲁树人
72ace9fc62 chore: bump version to v0.2.11 2024-11-05 16:56:40 +09:00
鲁树人
074e4f874f fix #108: rel path resolution in windows 2024-11-05 16:27:14 +09:00
鲁树人
2bfb5ffddf chore: bump version to v0.2.10 2024-11-04 14:25:02 +09:00
鲁树人
2c9de7c56c fix #107: windows dnd path error 2024-11-04 14:24:32 +09:00
鲁树人
b374c11c86 chore: bump app version to v0.2.9 2024-11-02 13:59:22 +09:00
鲁树人
6493b2c5fc fix #78: skip parsing cover art if image is unsupported 2024-11-02 13:49:40 +09:00
鲁树人
f753b9c67d fix #78 #106: app crash due to imcompatible ncm metadata json 2024-11-02 13:44:29 +09:00
鲁树人
8829a3b3ba refactor: rework on logging 2024-11-02 13:43:56 +09:00
鲁树人
b2ef27761f chore: bump version to v0.2.8 2024-10-25 23:03:34 +09:00
鲁树人
7f7cb66fe5 fix #103: incorrect output path when input is a single file 2024-10-25 23:02:32 +09:00
鲁树人
77729cf653 chore: bump version to v0.2.7 2024-10-21 08:45:09 +09:00
鲁树人
b317b89ae9 ci: append version number to file name, fix tar.gz.gz archive. 2024-10-21 08:45:09 +09:00
鲁树人
89b629304e chore: bump version to v0.2.6 2024-10-21 07:30:22 +09:00
鲁树人
c0c3bda9ce ci: add windows arm64 build 2024-10-21 07:29:52 +09:00
鲁树人
fa8f7a1565 chore: bump version to v0.2.5 2024-10-21 06:23:17 +09:00
鲁树人
d4d5e5ddf4 ci: upload zip for windows build 2024-10-21 06:10:36 +09:00
鲁树人
57c1aa3e54 ci: produce zip for windows build 2024-10-21 06:04:54 +09:00
鲁树人
b0998d8c8a docs: document steps to update CI pipeline 2024-10-21 06:02:31 +09:00
鲁树人
f819726f3e chore: bump version to v0.2.4 2024-10-21 03:59:52 +09:00
鲁树人
fbad7ec450 fix #102: support multi-part kgm extensions (kgm.flac/vpr.flac) 2024-10-21 03:58:21 +09:00
鲁树人
19bc6c466e refactor: allow multi-part extensions (#102) 2024-10-21 03:57:56 +09:00
鲁树人
b9e2a38f82 fix: do not throw error when file already exists 2024-10-21 03:56:52 +09:00
鲁树人
673ea8491f docs: add do not fork notice 2024-10-13 05:45:25 +09:00
鲁树人
0d071a82be feat #99: support recursive processDir 2024-10-08 22:20:12 +01:00
鲁树人
b8e6196248 chore: bump version to 0.2.3 2024-10-08 22:10:15 +01:00
鲁树人
1323fb9e1a Merge pull request '修正 #59 #99 转换后移除原始文件处理' (#100) from fix-59-99-remove-source into main
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/100
2024-10-08 21:06:44 +00:00
鲁树人
36df203bdd fix: record last error when calling processDir 2024-10-08 22:03:29 +01:00
鲁树人
2afc232eb1 fix: don't force exit when processFile fails. 2024-10-08 21:59:47 +01:00
鲁树人
2abdd47c9c fix: typo 2024-10-08 21:59:27 +01:00
鲁树人
8b59bc026d chore: ignore exe files 2024-10-08 21:59:19 +01:00
鲁树人
91855f8f5b fix #99: default output dir to where the input file is 2024-10-08 21:52:23 +01:00
鲁树人
7edd326b95 fix #59: processDir should call processFile instead. 2024-10-08 21:47:10 +01:00
鲁树人
0b3ad0d97c chore: bump version 2024-09-12 15:08:56 +01:00
鲁树人
c87204c78a fix #96: ncm file parsing when image cover 2 is not empty 2024-09-12 15:08:04 +01:00
鲁树人
3630fc0c78 Merge pull request '修正问题' (#95) from CLI-fix-59-71 into main
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/95
2024-09-04 22:14:02 +00:00
鲁树人
b3f7cd33df chore: update vulnerable deps 2024-09-04 23:09:43 +01:00
鲁树人
c6fa777db1 chore: bump version to v0.2.1 2024-09-04 23:06:24 +01:00
鲁树人
cab705a130 fix: typo in qmc-mmkv flag description 2024-09-04 23:03:35 +01:00
鲁树人
f258e3e8f2 fix: remove file after completion #59 2024-09-04 22:58:11 +01:00
鲁树人
59f3759d48 fix: add more extensions (Mac) #71 2024-09-04 22:47:02 +01:00
鲁树人
db547002f9 chore: update go-mmkv module hash 2024-09-01 19:09:34 +01:00
鲁树人
05a1affa03 Merge pull request '小更改 - mmkv 相关' (#93) from qmc-mmkv-cipher into master
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/93
2024-07-06 19:35:06 +00:00
鲁树人
68e0c06246 refactor: rework on MusicEx footer parser 2024-07-06 20:32:26 +01:00
鲁树人
e010d1b308 refactor: make mmkv key optional; change cli params name 2024-07-06 19:55:31 +01:00
鲁树人
9b4e3b87ef Merge remote-tracking branch 'awalol/master' 2024-07-06 19:45:10 +01:00
鲁树人
1ccc507f61 Merge pull request 'fix: wrong png header' (#91) from awalol/cli:fix-png into master
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/91
2024-05-03 08:47:13 +00:00
awalol
119a8cf380 fix: wrong png header 2024-05-03 16:17:32 +08:00
Unlock Music Dev
6179bcf0c7 Merge pull request 'Build: fix CI building' (#90) from build/fix-ci into master
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/90
2024-04-24 09:28:29 +00:00
Unlock Music Dev
c9ed772ce0 chore: update deps 2024-04-24 17:14:06 +08:00
Unlock Music Dev
9c0acb794e build: update ci settings 2024-04-24 17:11:40 +08:00
Unlock Music Dev
bda32bc53e chore: update deps 2024-04-24 17:05:03 +08:00
Unlock Music Dev
3f6566f7a9 build: fix ci 2024-04-24 16:27:51 +08:00
awalol
1c5310ffae 一些修改 2024-02-14 16:59:05 +08:00
awalol
2941a9ac76 refactor: qmc musicex footer parser 2024-02-13 02:41:42 +08:00
awalol
1835f9852a 修改 shuffixBuf 匹配方法 2024-02-13 01:17:52 +08:00
awalol
4f4d8db3d5 refactor: load mmkv on startup 2024-02-13 00:58:13 +08:00
awalol
877f37f01e feat: mmkv 加密数据库解析 2024-02-05 08:41:40 +08:00
鲁树人
7b12f61a97 Merge pull request 'FIX: another solution to PR #86' (#87) from NestorRay/cli_fix:fix into master
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/87
2024-01-30 22:50:46 +00:00
鲁树人
f6bc911125 Merge pull request 'FIX: CHANGE metadata INT into interface{}' (#86) from NestorRay/cli_fix:master into master
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/86
2024-01-28 11:07:53 +00:00
NestorRay
9ddbd5f702 DELETE not necessary MusicID,AlbumID etc. 2024-01-28 16:24:58 +08:00
NestorRay
d480c9a2f8 CHANGE metadata INT into interface{} 2024-01-28 13:06:30 +08:00
鲁树人
907fe4143b Merge pull request 'NCM: MusicID 动态类型支持' (#85) from fix/ncm-metadata-musicid into master
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/85
2024-01-28 04:03:05 +00:00
鲁树人
4ad9a721f0 fix: dynamic music id type 2024-01-28 03:59:17 +00:00
Unlock Music Dev
1bbc707dcc Merge pull request 'Feat: add watch & overwrite flag' (#55) from feat/cli-args into master
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/55
2022-12-25 12:30:38 +00:00
Unlock Music Dev
dfbb807e41 feat: add watch flag 2022-12-25 20:23:59 +08:00
Unlock Music Dev
a928611a8d feat: add overwrite flag 2022-12-25 19:26:11 +08:00
Unlock Music Dev
2754c14fa6 Merge pull request 'fix: don't fatal while process dir' (#52) from fix/fatal-dir into master
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/52
2022-12-09 10:17:59 +00:00
Unlock Music Dev
8858ff37ad fix: don't fatal while process dir 2022-12-09 18:15:40 +08:00
Unlock Music Dev
d0d9836990 doc: update go version [skip ci] 2022-12-07 02:02:50 +08:00
Unlock Music Dev
1a508fc2b6 doc: update readme 2022-12-07 01:57:51 +08:00
Unlock Music Dev
0acf905d08 feat(meta): separate flac meta process 2022-12-07 01:37:53 +08:00
Unlock Music Dev
f3cbd2f3b4 feat(meta): add main options 2022-12-07 00:40:12 +08:00
Unlock Music Dev
fd6f830916 feat(meta): write album art & metadata into destination file 2022-12-06 23:55:43 +08:00
Unlock Music Dev
02e065aac4 feat(meta): add writing metadata by ffmpeg 2022-12-06 21:52:19 +08:00
Unlock Music Dev
8319df6ca3 chore: fix logging 2022-12-06 21:52:19 +08:00
Unlock Music Dev
3857ba91a6 feat(meta): allow read tags from streams 2022-12-06 21:52:18 +08:00
Unlock Music Dev
9856f52070 feat: add meta parser from filename 2022-12-06 21:52:18 +08:00
Unlock Music Dev
112d9ab28e feat(qmc): allow retrieve metadata online 2022-12-06 21:52:18 +08:00
Unlock Music Dev
f60f0b3d07 feat(qmc): add search interface 2022-12-06 21:52:17 +08:00
Unlock Music Dev
3cf542c84c feat(meta): use ffmpeg to retrieve album art & metadata 2022-12-06 21:52:17 +08:00
Unlock Music Dev
9494a535a9 feat(qmc): support audio meta getter 2022-12-06 21:52:16 +08:00
Unlock Music Dev
138adbf846 feat(ncm): support audio meta getter 2022-12-06 21:52:16 +08:00
Unlock Music Dev
c878bb8ca4 feat: add image sniffer 2022-12-06 21:52:16 +08:00
Unlock Music Dev
e9e63ce175 feat(qmc): improve m4a & mp4 sniff 2022-12-06 21:47:33 +08:00
um-dev
2c378d4d46 Merge pull request 'feat(qmc): add mmp4 format' (#48) from feat/qmc-mmp4 into master
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/48
2022-12-06 10:57:46 +00:00
Unlock Music Dev
21eab14e6c feat(qmc): add mmp4 format 2022-12-06 18:42:58 +08:00
um-dev
0a0179c614 Merge pull request 'fix(qmc): use unicode normalize to match filename' (#47) from fix/unicode-norm into master
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/47
2022-12-05 03:24:40 +00:00
Unlock Music Dev
12be881d42 chore: add accident removed comment 2022-12-05 11:10:40 +08:00
Unlock Music Dev
6f033af336 chore: remove unused debug log 2022-12-05 11:08:54 +08:00
Unlock Music Dev
79d00b356f feat(qmc): use unicode normalize to match filename 2022-12-05 11:04:57 +08:00
um-dev
f6149c9109 Merge pull request 'feat(qmc): add support for .mflach' (#46) from qmc/mflach into master
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/46
2022-12-05 02:03:42 +00:00
Unlock Music Dev
3739638ddf feat(qmc): use editorial distance to find the key 2022-12-05 09:43:33 +08:00
Unlock Music Dev
423767ba63 fix(qmc): fix key from mmkv 2022-12-05 08:54:40 +08:00
Unlock Music Dev
a9c976f47d Revert "fix(ci): add zlib for cgo"
This reverts commit 743c672c44.
2022-12-05 07:24:21 +08:00
Unlock Music Dev
5fbcdb77d4 fix(qmc): use pure go mmkv 2022-12-05 07:24:03 +08:00
Unlock Music Dev
743c672c44 fix(ci): add zlib for cgo 2022-12-05 01:00:00 +08:00
Unlock Music Dev
9241512f2d feat(qmc): update go.mod 2022-12-05 00:09:48 +08:00
Unlock Music Dev
52e986e644 feat(qmc): support .mflach on darwin 2022-12-05 00:06:38 +08:00
Unlock Music Dev
d2019b04ec fix: qmc test 2022-12-04 23:14:06 +08:00
Unlock Music Dev
ea3236e14b refactor: change decoder init parameter 2022-12-04 23:05:38 +08:00
um-dev
ad64a0f91d Merge pull request 'update readme [CI SKIP]' (#45) from update-readme into master
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/45
2022-11-27 02:31:32 +00:00
Unlock Music Dev
921f9b2ae6 update readme [CI SKIP] 2022-11-27 10:30:40 +08:00
um-dev
cb948e74df Merge pull request 'Init Drone CI Build & Release' (#44) from ci/init-drone into master
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/44
2022-11-26 23:54:20 +00:00
Unlock Music Dev
7637a91f71 ci: fix file exist 2022-11-27 07:50:04 +08:00
Unlock Music Dev
e7d360362e ci: fix duplicated name 2022-11-27 07:47:42 +08:00
Unlock Music Dev
04320bd45a ci: add release pipeline 2022-11-27 07:45:44 +08:00
Unlock Music Dev
26b580a4b8 ci: init with go build 2022-11-27 07:30:41 +08:00
Unlock Music Dev
6c168ee536 refactor: move audio sniffer to internal package 2022-11-22 06:16:40 +08:00
Unlock Music Dev
62a38d5ab4 fix(ximalaya): x2m scramble table loading 2022-11-21 13:30:48 +08:00
Unlock Music Dev
62548955dc chore: add release script 2022-11-20 12:42:03 +08:00
um-dev
3794ff3154 Merge pull request 'feat(ximalaya): initial support' (#42) from feat/ximalaya into master
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/42
2022-11-20 01:38:13 +00:00
Unlock Music Dev
b22453215f revert: go mod changes 2022-11-20 08:10:56 +08:00
Unlock Music Dev
81862b26c9 feat(ximalaya): initial support 2022-11-20 08:04:39 +08:00
um-dev
110a78433a Merge pull request 'fix(kwm): allow new magic header' (#41) from fix/kwm-magic into master
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/41
2022-11-20 00:03:42 +00:00
Unlock Music Dev
d896925dff fix(kwm): allow new magic header 2022-11-20 03:12:30 +08:00
Unlock Music Dev
bd95fdb53b fix: unit tests 2022-11-20 02:53:04 +08:00
Unlock Music Dev
f6748d644d refactor: code cleaning 2022-11-20 02:47:28 +08:00
Unlock Music Dev
8e068b9c8d refactor: rename xm -> xiami 2022-11-20 02:18:50 +08:00
Unlock Music Dev
14c9d49d46 refactor: change module path 2022-11-19 07:44:44 +08:00
Unlock Music Dev
6f1cdc5b4f fix: raw don't access registry 2022-11-19 07:25:45 +08:00
Unlock Music Dev
8fd6763b29 fix: allow unencrypted tm0 tm3 2022-11-19 07:25:44 +08:00
Unlock Music Dev
2880c63bf7 fix: seek to audio start after validate 2022-11-19 07:25:44 +08:00
Unlock Music Dev
67ff0c44cd refactor: use io.Reader instead of custom method 2022-11-19 07:25:43 +08:00
Unlock Music Dev
4365628bff refactor: qmc don't export internal functions 2022-11-19 07:25:42 +08:00
Unlock Music Dev
b275b552ed refactor: use kgm crypto from parakeet-rs
chore: update deps
2022-11-19 07:25:42 +08:00
Unlock Music Dev
4ecaef1ff9 refactor: remove global logger 2022-11-19 07:25:41 +08:00
Unlock Music Dev
e2fc56ddb2 refactor: remove logging in algorithms 2022-11-19 07:25:40 +08:00
Unlock Music Dev
a2c55721cc feat: simplify vpr decode 2022-11-19 07:25:39 +08:00
Unlock Music Dev
e6ba9f0bfa fix: links & errors 2022-11-19 07:25:39 +08:00
Unlock Music Dev
21bd246d6c feat: use table to generate kgm mask 2022-11-19 07:25:38 +08:00
Unlock Music Dev
f05ae61aff feat: adapt for qmc key v2 2022-11-19 07:25:37 +08:00
Unlock Music Dev
7b37e4dd3c feat: add tips for QMC with STag suffix 2022-11-19 07:25:36 +08:00
MengYX
9647ca15fd Merge pull request #33 from Sloaix/master
feat: support removing source files
2022-03-05 17:56:09 +08:00
Sloaix
f27b73a64c feat: support removing source files 2022-02-25 07:50:00 +08:00
MengYX
2058504384 feat(QMCv2): support .mggl and .bkc*
`.mggl`: QQ Music MacOS Ogg (fix https://github.com/unlock-music/cli/issues/25)
`.bkc*` (".bkc" + {"mp3", "m4a", "flac", "wav", "ape", "ogg", "wma"}): Moo Music
2022-01-09 06:30:53 +08:00
MengYX
76df8e6c1a fix(QMCv2): unlock error on 32bit platform 2022-01-09 06:24:58 +08:00
MengYX
42d658625f test(QMCv2): add case mflac_rc4 2022-01-09 06:21:35 +08:00
MengYX
def2925900 feat(QMCv2): better rc4 cipher 2021-12-17 04:31:58 +08:00
MengYX
62f7991a64 ci: add go test 2021-12-14 04:12:45 +08:00
MengYX
9390b45207 Merge pull request #23 from unlock-music/feature/qmc-v2
Feature: QMC Decoder v2
2021-12-14 04:09:17 +08:00
MengYX
deaa58e91f feat(QMCv2): replace with old decoder 2021-12-14 04:01:04 +08:00
MengYX
7755d47f7a test(QMCv2): add test data & case "mgg_map" 2021-12-14 02:56:33 +08:00
MengYX
7cc7aa19fd feat(QMCv2): support static cipher (legacy) 2021-12-14 02:19:32 +08:00
MengYX
1025010395 fix(QMCv2): add missing test data 2021-12-13 22:58:45 +08:00
MengYX
8cc7f66640 chore(QMCv2): Rename old files 2021-12-13 22:55:31 +08:00
MengYX
38648d57e6 feat(QMCv2): Add mapCipher & mflac/mgg key discovery 2021-12-13 22:54:08 +08:00
MengYX
629c0c36f0 ci: allow all branches 2021-12-13 20:55:05 +08:00
MengYX
1552a667f6 init QMCv2: RC4 (512 Byte Key) 2021-12-13 20:42:07 +08:00
97 changed files with 4654 additions and 1375 deletions

View File

@@ -0,0 +1,97 @@
name: Build
on:
workflow_dispatch:
push:
paths:
- "**/*.go"
- "go.mod"
- "go.sum"
- ".gitea/workflows/*.yml"
pull_request:
branches: [ main ]
types: [ opened, synchronize, reopened ]
paths:
- "**/*.go"
- "go.mod"
- "go.sum"
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
GOOS:
- linux
- windows
- darwin
GOARCH:
- "amd64"
- "arm64"
include:
- GOOS: windows
BIN_SUFFIX: ".exe"
steps:
- name: Checkout codebase
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up Go 1.25
uses: actions/setup-go@v5
with:
go-version: ^1.25
- name: Setup vars
id: vars
run: |
echo "::set-output name=short_sha::$(git rev-parse --short HEAD)"
echo "::set-output name=git_tag::$(git describe --tags --always)"
- name: Test
run: go test -v ./...
- name: Build
env:
GOOS: ${{ matrix.GOOS }}
GOARCH: ${{ matrix.GOARCH }}
CGO_ENABLED: 0
run: go build -v -trimpath -ldflags="-w -s -X main.AppVersion=${{ steps.vars.outputs.git_tag }}" -o um-${{ matrix.GOOS }}-${{ matrix.GOARCH }}${{ matrix.BIN_SUFFIX }} ./cmd/um
- name: Publish artifact
uses: christopherhx/gitea-upload-artifact@v4
with:
name: um-${{ matrix.GOOS }}-${{ matrix.GOARCH }}
path: ./um-${{ matrix.GOOS }}-${{ matrix.GOARCH }}${{ matrix.BIN_SUFFIX }}
archive:
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout codebase
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup vars
id: vars
run: |
echo "::set-output name=git_tag::$(git describe --tags --always)"
- name: prepare archive
run: |
mkdir -p dist prepare
- name: Download artifacts
uses: christopherhx/gitea-download-artifact@v4
with:
path: prepare
pattern: um-*
- name: repack archive
run: |
apt-get update
apt-get install -y strip-nondeterminism
./misc/repack.sh "${{ steps.vars.outputs.git_tag }}"
- name: Publish all-in-one archive
uses: christopherhx/gitea-upload-artifact@v4
with:
name: dist-all
path: dist

View File

@@ -1,65 +0,0 @@
name: Build
on:
push:
branches: [ master ]
paths:
- "**/*.go"
- "go.mod"
- "go.sum"
- ".github/workflows/*.yml"
pull_request:
branches: [ master ]
types: [ opened, synchronize, reopened ]
paths:
- "**/*.go"
- "go.mod"
- "go.sum"
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- target: "linux/amd64"
GOOS: "linux"
GOARCH: "amd64"
BIN_SUFFIX: ""
- target: "windows/amd64"
GOOS: "windows"
GOARCH: "amd64"
BIN_SUFFIX: ".exe"
- target: "darwin/amd64"
GOOS: "darwin"
GOARCH: "amd64"
BIN_SUFFIX: ""
steps:
- name: Checkout codebase
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.17
- name: Setup vars
id: vars
run: |
echo "::set-output name=short_sha::$(git rev-parse --short HEAD)"
echo "::set-output name=git_tag::$(git describe --tags --always)"
- name: Build
env:
GOOS: ${{ matrix.GOOS }}
GOARCH: ${{ matrix.GOARCH }}
CGO_ENABLED: 0
run: go build -v -trimpath -ldflags="-w -s -X main.AppVersion=${{ steps.vars.outputs.git_tag }}" -o um-${{ matrix.GOOS }}-${{ matrix.GOARCH }}${{ matrix.BIN_SUFFIX }} ./cmd/um
- name: Publish artifact
uses: actions/upload-artifact@v2
with:
name: um-${{ matrix.GOOS }}-${{ matrix.GOARCH }}
path: ./um-${{ matrix.GOOS }}-${{ matrix.GOARCH }}${{ matrix.BIN_SUFFIX }}

View File

@@ -1,84 +0,0 @@
name: Create Release
on:
push:
tags:
- "v*"
jobs:
create_release:
runs-on: ubuntu-latest
steps:
- name: Get current time
id: date
run: echo "::set-output name=date::$(date +'%Y/%m/%d')"
- name: Create release
id: release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: "Build ${{ steps.date.outputs.date }}"
draft: true
outputs:
upload_url: "${{ steps.release.outputs.upload_url }}"
build:
needs:
- create_release
runs-on: ubuntu-latest
strategy:
matrix:
include:
- target: "linux/amd64"
GOOS: "linux"
GOARCH: "amd64"
BIN_SUFFIX: ""
- target: "windows/amd64"
GOOS: "windows"
GOARCH: "amd64"
BIN_SUFFIX: ".exe"
- target: "windows/386"
GOOS: "windows"
GOARCH: "386"
BIN_SUFFIX: ".exe"
- target: "darwin/amd64"
GOOS: "darwin"
GOARCH: "amd64"
BIN_SUFFIX: ""
steps:
- name: Checkout codebase
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup vars
id: vars
run: |
echo "::set-output name=short_sha::$(git rev-parse --short HEAD)"
echo "::set-output name=git_tag::$(git describe --tags --always)"
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.17
- name: Build
env:
GOOS: ${{ matrix.GOOS }}
GOARCH: ${{ matrix.GOARCH }}
CGO_ENABLED: 0
run: go build -trimpath -v -ldflags="-w -s -X main.AppVersion=${{ steps.vars.outputs.git_tag }}" -o um ./cmd/um
- name: Upload release assets
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create_release.outputs.upload_url }}
asset_path: um
asset_name: um-${{ matrix.GOOS }}-${{ matrix.GOARCH }}${{ matrix.BIN_SUFFIX }}
asset_content_type: application/octet-stream

11
.gitignore vendored
View File

@@ -1 +1,12 @@
.idea
/dist
*.exe
/um
/um-*.tar.gz
/um-*.zip
/.vscode
/prepare
/dist

80
CHANGELOG.md Normal file
View File

@@ -0,0 +1,80 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [v0.2.19] - 2025-11-26
### Fixed
- MMKV parsing: Handle cases where MMKV value is empty.
## [v0.2.18] - 2025-11-16
### Changed
- QMC2: Fix `musicex\0` tag parsing.
- MMKV: Improved tolerance for corrupted MMKV file parsing.
- Updated project dependencies.
## [v0.2.17] - 2025-09-09 ⚠️ **(Broken Release)**
### Changed
- Update RegEx used to extract UDID in plist.
## [v0.2.16] - 2025-09-09 ⚠️ **(Broken Release)**
### Changed
- Update RegEx used to extract UDID in plist.
## [v0.2.15] - 2025-09-09 ⚠️ **(Broken Release)**
### Added
- Support MMKV dump in QQMusic Mac 10.x (AppStore version).
## [v0.2.14] - 2025-09-08 ⚠️ **(Broken Release)**
### Added
- Support MMKV dump in QQMusic Mac 10.x.
## [v0.2.13] - 2025-09-06 ⚠️ **(Broken Release)**
### Changed
- Updated project namespace and repository URLs to new url
- Upgraded Go version requirement to 1.25
- Restricted KGG database support to Windows platform only
- Enhanced MMKV key extraction logic with improved reliability
### Fixed
- Fixed NCM metadata parsing to properly handle mixed-type artist arrays
- Drop i386 targets in CI build
## [v0.2.12] - 2025-05-07
### Added
- KGG (KGMv5) file format support
- Support for `.mflacm` file extension
### Changed
- Updated default version identifier to "custom" for development builds
- Upgraded GoLang version
## [v0.2.11] - 2024-11-05
### Fixed
- Resolved relative path resolution issues on Windows platforms (#108)
- Improved cross-platform compatibility for file path handling
---
## Historical Versions
**Note**: This changelog was created starting from v0.2.11. For changes in earlier versions (v0.2.10 and below), please refer to the project's git commit history:
```bash
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.

View File

@@ -1,22 +1,35 @@
# Unlock Music Project - CLI Edition
Original: Web Edition https://github.com/ix64/unlock-music
Original: Web Edition https://git.um-react.app/um/web
- [Release Download](https://github.com/unlock-music/cli/releases/latest)
- [![Build Status](https://git.um-react.app/um/cli/actions/workflows/build.yml/badge.svg)](https://git.um-react.app/um/cli/actions?workflow=build.yml)
- [Release Download](https://git.um-react.app/um/cli/releases/latest)
- [Latest Build](https://git.um-react.app/um/cli/actions)
> **WARNING**
> 在本站 fork 不会起到备份的作用,只会浪费服务器储存空间。如无必要请勿 fork 该仓库。
## Features
- [x] All Algorithm Supported By `ix64/unlock-music`
- [ ] Complete Cover Image
- [ ] Parse Meta Data
- [ ] Complete Meta Data
- [x] All Algorithm Supported By `unlock-music/web`
- [x] Complete Metadata & Cover Image
## Hou to Build
## Release
- Requirements: **Golang 1.17**
[Latest release](https://git.um-react.app/um/cli/releases/latest).
1. Clone this repo `git clone https://github.com/unlock-music/cli && cd cli`
2. Build the executable `go build ./cmd/um`
## Install from source
- Requirements: **Golang 1.23.3**
1. run `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`.
It will produce `um` or `um.exe` (Windows).
## How to use

View File

@@ -1,16 +0,0 @@
package common
type Decoder interface {
Validate() error
Decode() error
GetCoverImage() []byte
GetAudioData() []byte
GetAudioExt() string
GetMeta() Meta
}
type Meta interface {
GetArtists() []string
GetTitle() string
GetAlbum() string
}

View File

@@ -1,30 +1,67 @@
package common
import (
"io"
"path/filepath"
"strings"
"git.um-react.app/um/cli/internal/utils"
"go.uber.org/zap"
)
type NewDecoderFunc func([]byte) Decoder
type QMCKeys map[string]string
type decoderItem struct {
noop bool
decoder NewDecoderFunc
type CryptoParams struct {
// KuGou kgg database path
KggDbPath string
// QMC Crypto config
QmcKeys QMCKeys
}
var decoderRegistry = make(map[string][]decoderItem)
func (k QMCKeys) Get(key string) (string, bool) {
value, ok := k[utils.NormalizeUnicode(key)]
return value, ok
}
type DecoderParams struct {
Reader io.ReadSeeker // required
Extension string // required, source extension, eg. ".mp3"
FilePath string // optional, source file path
Logger *zap.Logger // required
CryptoParams CryptoParams
}
type NewDecoderFunc func(p *DecoderParams) Decoder
type DecoderFactory struct {
noop bool
Suffix string
Create NewDecoderFunc
}
var DecoderRegistry []DecoderFactory
func RegisterDecoder(ext string, noop bool, dispatchFunc NewDecoderFunc) {
decoderRegistry[ext] = append(decoderRegistry[ext],
decoderItem{noop: noop, decoder: dispatchFunc})
DecoderRegistry = append(DecoderRegistry,
DecoderFactory{noop: noop, Create: dispatchFunc, Suffix: "." + strings.TrimPrefix(ext, ".")})
}
func GetDecoder(filename string, skipNoop bool) (rs []NewDecoderFunc) {
ext := strings.ToLower(strings.TrimLeft(filepath.Ext(filename), "."))
for _, dec := range decoderRegistry[ext] {
func GetDecoder(filename string, skipNoop bool) []DecoderFactory {
var result []DecoderFactory
// Some extensions contains multiple dots, e.g. ".kgm.flac", hence iterate
// all decoders for each extension.
name := strings.ToLower(filepath.Base(filename))
for _, dec := range DecoderRegistry {
if !strings.HasSuffix(name, dec.Suffix) {
continue
}
if skipNoop && dec.noop {
continue
}
rs = append(rs, dec.decoder)
result = append(result, dec)
}
return
return result
}

29
algo/common/interface.go Normal file
View File

@@ -0,0 +1,29 @@
package common
import (
"context"
"io"
)
type StreamDecoder interface {
Decrypt(buf []byte, offset int)
}
type Decoder interface {
Validate() error
io.Reader
}
type CoverImageGetter interface {
GetCoverImage(ctx context.Context) ([]byte, error)
}
type AudioMeta interface {
GetArtists() []string
GetTitle() string
GetAlbum() string
}
type AudioMetaGetter interface {
GetAudioMeta(ctx context.Context) (AudioMeta, error)
}

50
algo/common/meta.go Normal file
View File

@@ -0,0 +1,50 @@
package common
import (
"path"
"strings"
)
type filenameMeta struct {
artists []string
title string
album string
}
func (f *filenameMeta) GetArtists() []string {
return f.artists
}
func (f *filenameMeta) GetTitle() string {
return f.title
}
func (f *filenameMeta) GetAlbum() string {
return f.album
}
func ParseFilenameMeta(filename string) (meta AudioMeta) {
partName := strings.TrimSuffix(filename, path.Ext(filename))
items := strings.Split(partName, "-")
ret := &filenameMeta{}
switch len(items) {
case 0:
// no-op
case 1:
ret.title = strings.TrimSpace(items[0])
default:
ret.title = strings.TrimSpace(items[len(items)-1])
for _, v := range items[:len(items)-1] {
artists := strings.FieldsFunc(v, func(r rune) bool {
return r == ',' || r == '_'
})
for _, artist := range artists {
ret.artists = append(ret.artists, strings.TrimSpace(artist))
}
}
}
return ret
}

38
algo/common/meta_test.go Normal file
View File

@@ -0,0 +1,38 @@
package common
import (
"reflect"
"testing"
)
func TestParseFilenameMeta(t *testing.T) {
tests := []struct {
name string
wantMeta AudioMeta
}{
{
name: "test1",
wantMeta: &filenameMeta{title: "test1"},
},
{
name: "周杰伦 - 晴天.flac",
wantMeta: &filenameMeta{artists: []string{"周杰伦"}, title: "晴天"},
},
{
name: "Alan Walker _ Iselin Solheim - Sing Me to Sleep.flac",
wantMeta: &filenameMeta{artists: []string{"Alan Walker", "Iselin Solheim"}, title: "Sing Me to Sleep"},
},
{
name: "Christopher,Madcon - Limousine.flac",
wantMeta: &filenameMeta{artists: []string{"Christopher", "Madcon"}, title: "Limousine"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if gotMeta := ParseFilenameMeta(tt.name); !reflect.DeepEqual(gotMeta, tt.wantMeta) {
t.Errorf("ParseFilenameMeta() = %v, want %v", gotMeta, tt.wantMeta)
}
})
}
}

View File

@@ -2,46 +2,41 @@ package common
import (
"errors"
"strings"
"fmt"
"io"
"git.um-react.app/um/cli/internal/sniff"
)
type RawDecoder struct {
file []byte
rd io.ReadSeeker
audioExt string
}
func NewRawDecoder(file []byte) Decoder {
return &RawDecoder{file: file}
func NewRawDecoder(p *DecoderParams) Decoder {
return &RawDecoder{rd: p.Reader}
}
func (d *RawDecoder) Validate() error {
for ext, sniffer := range snifferRegistry {
if sniffer(d.file) {
d.audioExt = strings.ToLower(ext)
return nil
header := make([]byte, 16)
if _, err := io.ReadFull(d.rd, header); err != nil {
return fmt.Errorf("read file header failed: %v", err)
}
if _, err := d.rd.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("seek file failed: %v", err)
}
return errors.New("audio doesn't recognized")
}
func (d RawDecoder) Decode() error {
var ok bool
d.audioExt, ok = sniff.AudioExtension(header)
if !ok {
return errors.New("raw: sniff audio type failed")
}
return nil
}
func (d RawDecoder) GetCoverImage() []byte {
return nil
}
func (d RawDecoder) GetAudioData() []byte {
return d.file
}
func (d RawDecoder) GetAudioExt() string {
return d.audioExt
}
func (d RawDecoder) GetMeta() Meta {
return nil
func (d *RawDecoder) Read(p []byte) (n int, err error) {
return d.rd.Read(p)
}
func init() {

View File

@@ -1,55 +0,0 @@
package common
import "bytes"
type Sniffer func(header []byte) bool
var snifferRegistry = map[string]Sniffer{
".mp3": SnifferMP3,
".flac": SnifferFLAC,
".ogg": SnifferOGG,
".m4a": SnifferM4A,
".wav": SnifferWAV,
".wma": SnifferWMA,
".aac": SnifferAAC,
".dff": SnifferDFF,
}
func SniffAll(header []byte) (string, bool) {
for ext, sniffer := range snifferRegistry {
if sniffer(header) {
return ext, true
}
}
return "", false
}
func SnifferM4A(header []byte) bool {
return len(header) >= 8 && bytes.Equal([]byte("ftyp"), header[4:8])
}
func SnifferOGG(header []byte) bool {
return bytes.HasPrefix(header, []byte("OggS"))
}
func SnifferFLAC(header []byte) bool {
return bytes.HasPrefix(header, []byte("fLaC"))
}
func SnifferMP3(header []byte) bool {
return bytes.HasPrefix(header, []byte("ID3"))
}
func SnifferWAV(header []byte) bool {
return bytes.HasPrefix(header, []byte("RIFF"))
}
func SnifferWMA(header []byte) bool {
return bytes.HasPrefix(header, []byte("\x30\x26\xb2\x75\x8e\x66\xcf\x11\xa6\xd9\x00\xaa\x00\x62\xce\x6c"))
}
func SnifferAAC(header []byte) bool {
return bytes.HasPrefix(header, []byte{0xFF, 0xF1})
}
// SnifferDFF sniff a DSDIFF format
// reference to: https://www.sonicstudio.com/pdf/dsd/DSDIFF_1.5_Spec.pdf
func SnifferDFF(header []byte) bool {
return bytes.HasPrefix(header, []byte("FRM8"))
}

View File

@@ -1,94 +1,75 @@
package kgm
import (
"bytes"
"encoding/binary"
"errors"
"github.com/unlock-music/cli/algo/common"
"github.com/unlock-music/cli/internal/logging"
)
"fmt"
"io"
var (
vprHeader = []byte{
0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43,
0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31}
kgmHeader = []byte{
0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B,
0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14}
ErrKgmMagicHeader = errors.New("kgm/vpr magic header not matched")
"git.um-react.app/um/cli/algo/common"
)
type Decoder struct {
file []byte
key []byte
isVpr bool
audio []byte
rd io.ReadSeeker
cipher common.StreamDecoder
offset int
header header
KggDatabasePath string
}
func NewDecoder(file []byte) common.Decoder {
return &Decoder{
file: file,
func NewDecoder(p *common.DecoderParams) common.Decoder {
return &Decoder{rd: p.Reader, KggDatabasePath: p.CryptoParams.KggDbPath}
}
// Validate checks if the file is a valid Kugou (.kgm, .vpr, .kgma) file.
// rd will be seeked to the beginning of the encrypted audio.
func (d *Decoder) Validate() (err error) {
if err := d.header.FromFile(d.rd); err != nil {
return err
}
// TODO; validate crypto version
switch d.header.CryptoVersion {
case 3:
d.cipher, err = newKgmCryptoV3(&d.header)
if err != nil {
return fmt.Errorf("kgm init crypto v3: %w", err)
}
case 5:
d.cipher, err = newKgmCryptoV5(&d.header, d.KggDatabasePath)
if err != nil {
return fmt.Errorf("kgm init crypto v5: %w", err)
}
default:
return fmt.Errorf("kgm: unsupported crypto version %d", d.header.CryptoVersion)
}
// prepare for read
if _, err := d.rd.Seek(int64(d.header.AudioOffset), io.SeekStart); err != nil {
return fmt.Errorf("kgm seek to audio: %w", err)
}
}
func (d Decoder) GetCoverImage() []byte {
return nil
}
func (d Decoder) GetAudioData() []byte {
return d.audio
func (d *Decoder) Read(buf []byte) (int, error) {
n, err := d.rd.Read(buf)
if n > 0 {
d.cipher.Decrypt(buf[:n], d.offset)
d.offset += n
}
return n, err
}
func (d Decoder) GetAudioExt() string {
return "" // use sniffer
}
func (d Decoder) GetMeta() common.Meta {
return nil
}
func (d *Decoder) Validate() error {
if bytes.Equal(kgmHeader, d.file[:len(kgmHeader)]) {
d.isVpr = false
} else if bytes.Equal(vprHeader, d.file[:len(vprHeader)]) {
d.isVpr = true
} else {
return ErrKgmMagicHeader
}
d.key = d.file[0x1c:0x2c]
d.key = append(d.key, 0x00)
_ = d.file[0x2c:0x3c] //todo: key2
return nil
}
func (d *Decoder) Decode() error {
headerLen := binary.LittleEndian.Uint32(d.file[0x10:0x14])
dataEncrypted := d.file[headerLen:]
lenData := len(dataEncrypted)
initMask()
if fullMaskLen < lenData {
logging.Log().Warn("The file is too large and the processed audio is incomplete, " +
"please report to us about this file at https://github.com/unlock-music/cli/issues")
lenData = fullMaskLen
}
d.audio = make([]byte, lenData)
for i := 0; i < lenData; i++ {
med8 := dataEncrypted[i] ^ d.key[i%17] ^ maskV2PreDef[i%(16*17)] ^ maskV2[i>>4]
d.audio[i] = med8 ^ (med8&0xf)<<4
}
if d.isVpr {
for i := 0; i < lenData; i++ {
d.audio[i] ^= maskDiffVpr[i%17]
}
}
return nil
}
func init() {
// Kugou
common.RegisterDecoder("kgg", false, NewDecoder)
common.RegisterDecoder("kgm", false, NewDecoder)
common.RegisterDecoder("kgma", false, NewDecoder)
// Viper
common.RegisterDecoder("vpr", false, NewDecoder)
// Kugou Android
common.RegisterDecoder("kgm.flac", false, NewDecoder)
common.RegisterDecoder("vpr.flac", false, NewDecoder)
}

Binary file not shown.

93
algo/kgm/kgm_header.go Normal file
View File

@@ -0,0 +1,93 @@
package kgm
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
)
var (
vprHeader = []byte{
0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43,
0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31,
}
kgmHeader = []byte{
0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B,
0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14,
}
ErrKgmMagicHeader = errors.New("kgm magic header not matched")
)
// header is the header of a KGM file.
type header struct {
MagicHeader []byte // 0x00-0x0f: magic header
AudioOffset uint32 // 0x10-0x13: offset of audio data
CryptoVersion uint32 // 0x14-0x17: crypto version
CryptoSlot uint32 // 0x18-0x1b: crypto key slot
CryptoTestData []byte // 0x1c-0x2b: crypto test data
CryptoKey []byte // 0x2c-0x3b: crypto key
AudioHash string // v5: audio hash
}
func (h *header) FromFile(rd io.ReadSeeker) error {
if _, err := rd.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("kgm seek start: %w", err)
}
return h.FromBytes(rd)
}
func (h *header) FromBytes(r io.ReadSeeker) error {
h.MagicHeader = make([]byte, 16)
_, err := r.Read(h.MagicHeader)
if err != nil {
return err
}
if !bytes.Equal(kgmHeader, h.MagicHeader) && !bytes.Equal(vprHeader, h.MagicHeader) {
return ErrKgmMagicHeader
}
err = binary.Read(r, binary.LittleEndian, &h.AudioOffset)
if err != nil {
return err
}
err = binary.Read(r, binary.LittleEndian, &h.CryptoVersion)
if err != nil {
return err
}
err = binary.Read(r, binary.LittleEndian, &h.CryptoSlot)
if err != nil {
return err
}
h.CryptoTestData = make([]byte, 0x10)
_, err = r.Read(h.CryptoTestData)
if err != nil {
return err
}
h.CryptoKey = make([]byte, 0x10)
_, err = r.Read(h.CryptoKey)
if err != nil {
return err
}
if h.CryptoVersion == 5 {
r.Seek(0x08, io.SeekCurrent)
var audioHashLen uint32 = 0
err = binary.Read(r, binary.LittleEndian, &audioHashLen)
if err != nil {
return err
}
audioHashBuffer := make([]byte, audioHashLen)
_, err = r.Read(audioHashBuffer)
if err != nil {
return err
}
h.AudioHash = string(audioHashBuffer)
}
return nil
}

55
algo/kgm/kgm_v3.go Normal file
View File

@@ -0,0 +1,55 @@
package kgm
import (
"crypto/md5"
"fmt"
"git.um-react.app/um/cli/algo/common"
)
// kgmCryptoV3 is kgm file crypto v3
type kgmCryptoV3 struct {
slotBox []byte
fileBox []byte
}
var kgmV3Slot2Key = map[uint32][]byte{
1: {0x6C, 0x2C, 0x2F, 0x27},
}
func newKgmCryptoV3(header *header) (common.StreamDecoder, error) {
c := &kgmCryptoV3{}
slotKey, ok := kgmV3Slot2Key[header.CryptoSlot]
if !ok {
return nil, fmt.Errorf("kgm3: unknown crypto slot %d", header.CryptoSlot)
}
c.slotBox = kugouMD5(slotKey)
c.fileBox = append(kugouMD5(header.CryptoKey), 0x6b)
return c, nil
}
func (d *kgmCryptoV3) Decrypt(b []byte, offset int) {
for i := 0; i < len(b); i++ {
b[i] ^= d.fileBox[(offset+i)%len(d.fileBox)]
b[i] ^= b[i] << 4
b[i] ^= d.slotBox[(offset+i)%len(d.slotBox)]
b[i] ^= xorCollapseUint32(uint32(offset + i))
}
}
func xorCollapseUint32(i uint32) byte {
return byte(i) ^ byte(i>>8) ^ byte(i>>16) ^ byte(i>>24)
}
func kugouMD5(b []byte) []byte {
digest := md5.Sum(b)
ret := make([]byte, 16)
for i := 0; i < md5.Size; i += 2 {
ret[i] = digest[14-i]
ret[i+1] = digest[14-i+1]
}
return ret
}

30
algo/kgm/kgm_v5.go Normal file
View File

@@ -0,0 +1,30 @@
package kgm
import (
"fmt"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/algo/kgm/pc_kugou_db"
"git.um-react.app/um/cli/algo/qmc"
)
func newKgmCryptoV5(header *header, kggDatabasePath string) (common.StreamDecoder, error) {
if header.AudioHash == "" {
return nil, fmt.Errorf("kgm v5: missing audio hash")
}
if kggDatabasePath == "" {
return nil, fmt.Errorf("kgm v5: missing kgg database path")
}
m, err := pc_kugou_db.CachedDumpEKey(kggDatabasePath)
if err != nil {
return nil, fmt.Errorf("kgm v5: decrypt kgg database: %w", err)
}
ekey, ok := m[header.AudioHash]
if !ok || ekey == "" {
return nil, fmt.Errorf("kgm v5: ekey missing from db (audio_hash=%s)", header.AudioHash)
}
return qmc.NewQmcCipherDecoderFromEKey([]byte(ekey))
}

View File

@@ -1,59 +0,0 @@
package kgm
import (
"bytes"
_ "embed"
"github.com/ulikunitz/xz"
"github.com/unlock-music/cli/internal/logging"
"go.uber.org/zap"
"io"
)
var maskDiffVpr = []byte{
0x25, 0xDF, 0xE8, 0xA6, 0x75, 0x1E, 0x75, 0x0E,
0x2F, 0x80, 0xF3, 0x2D, 0xB8, 0xB6, 0xE3, 0x11,
0x00}
var maskV2PreDef = []byte{
0xB8, 0xD5, 0x3D, 0xB2, 0xE9, 0xAF, 0x78, 0x8C, 0x83, 0x33, 0x71, 0x51, 0x76, 0xA0, 0xCD, 0x37,
0x2F, 0x3E, 0x35, 0x8D, 0xA9, 0xBE, 0x98, 0xB7, 0xE7, 0x8C, 0x22, 0xCE, 0x5A, 0x61, 0xDF, 0x68,
0x69, 0x89, 0xFE, 0xA5, 0xB6, 0xDE, 0xA9, 0x77, 0xFC, 0xC8, 0xBD, 0xBD, 0xE5, 0x6D, 0x3E, 0x5A,
0x36, 0xEF, 0x69, 0x4E, 0xBE, 0xE1, 0xE9, 0x66, 0x1C, 0xF3, 0xD9, 0x02, 0xB6, 0xF2, 0x12, 0x9B,
0x44, 0xD0, 0x6F, 0xB9, 0x35, 0x89, 0xB6, 0x46, 0x6D, 0x73, 0x82, 0x06, 0x69, 0xC1, 0xED, 0xD7,
0x85, 0xC2, 0x30, 0xDF, 0xA2, 0x62, 0xBE, 0x79, 0x2D, 0x62, 0x62, 0x3D, 0x0D, 0x7E, 0xBE, 0x48,
0x89, 0x23, 0x02, 0xA0, 0xE4, 0xD5, 0x75, 0x51, 0x32, 0x02, 0x53, 0xFD, 0x16, 0x3A, 0x21, 0x3B,
0x16, 0x0F, 0xC3, 0xB2, 0xBB, 0xB3, 0xE2, 0xBA, 0x3A, 0x3D, 0x13, 0xEC, 0xF6, 0x01, 0x45, 0x84,
0xA5, 0x70, 0x0F, 0x93, 0x49, 0x0C, 0x64, 0xCD, 0x31, 0xD5, 0xCC, 0x4C, 0x07, 0x01, 0x9E, 0x00,
0x1A, 0x23, 0x90, 0xBF, 0x88, 0x1E, 0x3B, 0xAB, 0xA6, 0x3E, 0xC4, 0x73, 0x47, 0x10, 0x7E, 0x3B,
0x5E, 0xBC, 0xE3, 0x00, 0x84, 0xFF, 0x09, 0xD4, 0xE0, 0x89, 0x0F, 0x5B, 0x58, 0x70, 0x4F, 0xFB,
0x65, 0xD8, 0x5C, 0x53, 0x1B, 0xD3, 0xC8, 0xC6, 0xBF, 0xEF, 0x98, 0xB0, 0x50, 0x4F, 0x0F, 0xEA,
0xE5, 0x83, 0x58, 0x8C, 0x28, 0x2C, 0x84, 0x67, 0xCD, 0xD0, 0x9E, 0x47, 0xDB, 0x27, 0x50, 0xCA,
0xF4, 0x63, 0x63, 0xE8, 0x97, 0x7F, 0x1B, 0x4B, 0x0C, 0xC2, 0xC1, 0x21, 0x4C, 0xCC, 0x58, 0xF5,
0x94, 0x52, 0xA3, 0xF3, 0xD3, 0xE0, 0x68, 0xF4, 0x00, 0x23, 0xF3, 0x5E, 0x0A, 0x7B, 0x93, 0xDD,
0xAB, 0x12, 0xB2, 0x13, 0xE8, 0x84, 0xD7, 0xA7, 0x9F, 0x0F, 0x32, 0x4C, 0x55, 0x1D, 0x04, 0x36,
0x52, 0xDC, 0x03, 0xF3, 0xF9, 0x4E, 0x42, 0xE9, 0x3D, 0x61, 0xEF, 0x7C, 0xB6, 0xB3, 0x93, 0x50,
}
//go:embed kgm.v2.mask
var maskV2Xz []byte
var maskV2 []byte
var fullMaskLen int
var initMaskOK = false
//todo: decompress mask on demand
func initMask() {
if initMaskOK {
return
}
maskReader, err := xz.NewReader(bytes.NewReader(maskV2Xz))
if err != nil {
logging.Log().Fatal("load kgm mask failed", zap.Error(err))
}
maskV2, err = io.ReadAll(maskReader)
if err != nil {
logging.Log().Fatal("load kgm mask failed", zap.Error(err))
}
fullMaskLen = len(maskV2) * 16
initMaskOK = true
}

View File

@@ -0,0 +1,7 @@
//go:build !windows
package pc_kugou_db
func CachedDumpEKey(dbPath string) (map[string]string, error) {
return nil, nil
}

View File

@@ -0,0 +1,238 @@
package pc_kugou_db
// ported from lib_um_crypto_rust:
// https://git.um-react.app/um/lib_um_crypto_rust/src/tag/v0.1.10/um_crypto/kgm/src/pc_db_decrypt
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"database/sql"
"encoding/binary"
"fmt"
"os"
"sync"
_ "modernc.org/sqlite"
)
const PAGE_SIZE = 0x400
var SQLITE_HEADER = []byte("SQLite format 3\x00")
var DEFAULT_MASTER_KEY = []byte{
// master key (0x10 bytes)
0x1D, 0x61, 0x31, 0x45, 0xB2, 0x47, 0xBF, 0x7F, 0x3D, 0x18, 0x96, 0x72, 0x14, 0x4F, 0xE4, 0xBF,
0x00, 0x00, 0x00, 0x00, // page number (le)
0x73, 0x41, 0x6C, 0x54, // fixed value
}
func deriveIvSeed(seed uint32) uint32 {
var left uint32 = seed * 0x9EF4
var right uint32 = seed / 0xce26 * 0x7FFFFF07
var value uint32 = left - right
if value&0x8000_0000 == 0 {
return value
}
return value + 0x7FFF_FF07
}
// derivePageIv generates a 16-byte IV for database page.
func derivePageIv(page uint32) []byte {
iv := make([]byte, 0x10)
page = page + 1
for i := 0; i < 0x10; i += 4 {
page = deriveIvSeed(page)
binary.LittleEndian.PutUint32(iv[i:i+4], page)
}
digest := md5.Sum(iv)
return digest[:]
}
// derivePageKey generates a 16-byte AES key for database page.
func derivePageKey(page uint32) []byte {
masterKey := make([]byte, len(DEFAULT_MASTER_KEY))
copy(masterKey, DEFAULT_MASTER_KEY)
binary.LittleEndian.PutUint32(masterKey[0x10:0x14], page)
digest := md5.Sum(masterKey)
return digest[:]
}
// aes128cbcDecryptNoPadding decrypts the given buffer using AES-128-CBC with no padding.
func aes128cbcDecryptNoPadding(buffer, key, iv []byte) error {
if len(key) != 16 {
return fmt.Errorf("invalid key size: %d (must be 16 bytes for AES-128)", len(key))
}
if len(iv) != aes.BlockSize {
return fmt.Errorf("invalid IV size: %d (must be %d bytes)", len(iv), aes.BlockSize)
}
if len(buffer)%aes.BlockSize != 0 {
return fmt.Errorf("ciphertext length must be a multiple of %d bytes", aes.BlockSize)
}
block, err := aes.NewCipher(key)
if err != nil {
return err
}
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(buffer, buffer)
return nil
}
// decryptPage decrypts a single database page using AES-128-CBC (no padding).
// page start from 1.
func decryptPage(buffer []byte, page uint32) error {
key := derivePageKey(page)
iv := derivePageIv(page)
return aes128cbcDecryptNoPadding(buffer, key, iv)
}
func decryptPage1(buffer []byte) error {
if err := validateFirstPageHeader(buffer); err != nil {
return err
}
// Backup expected header, swap cipher text
expectedHeader := make([]byte, 8)
copy(expectedHeader, buffer[0x10:0x18])
copy(buffer[0x10:0x18], buffer[0x08:0x10])
if err := decryptPage(buffer[0x10:], 1); err != nil {
return err
}
// Validate header
if !bytes.Equal(buffer[0x10:0x18], expectedHeader) {
return fmt.Errorf("decrypt page 1 failed")
}
// Restore SQLite file header
copy(buffer[:0x10], SQLITE_HEADER)
return nil
}
func validateFirstPageHeader(header []byte) error {
o10 := binary.LittleEndian.Uint32(header[0x10:0x14])
o14 := binary.LittleEndian.Uint32(header[0x14:0x18])
v6 := ((o10 & 0xff) << 8) | ((o10 & 0xff00) << 16)
ok := o14 == 0x20204000 && (v6-0x200) <= 0xFE00 && ((v6-1)&v6) == 0
if !ok {
return fmt.Errorf("invalid page 1 header")
}
return nil
}
func decryptDatabase(buffer []byte) error {
dbSize := len(buffer)
// not encrypted
if bytes.Equal(buffer[:len(SQLITE_HEADER)], SQLITE_HEADER) {
return nil
}
if dbSize%PAGE_SIZE != 0 || dbSize == 0 {
return fmt.Errorf("invalid database size: %d", dbSize)
}
if err := decryptPage1(buffer[:PAGE_SIZE]); err != nil {
return err
}
offset := PAGE_SIZE
lastPage := uint32(dbSize / PAGE_SIZE)
var pageNumber uint32
for pageNumber = 2; pageNumber <= lastPage; pageNumber++ {
if err := decryptPage(buffer[offset:offset+PAGE_SIZE], uint32(pageNumber)); err != nil {
return err
}
offset += PAGE_SIZE
}
return nil
}
func extractKeyMapping(buffer []byte) (map[string]string, error) {
// Create an in-memory SQLite database
db, err := sql.Open("sqlite", "file::memory:?mode=memory&cache=shared")
if err != nil {
return nil, err
}
defer db.Close()
conn, err := db.Conn(context.Background())
if err != nil {
return nil, err
}
err = func() error {
defer conn.Close()
return conn.Raw(func(driverConn any) error {
type serializer interface {
Serialize() ([]byte, error)
Deserialize([]byte) error
}
return driverConn.(serializer).Deserialize(buffer)
})
}()
if err != nil {
return nil, fmt.Errorf("failed to import db: %w", err)
}
conn, err = db.Conn(context.Background())
if err != nil {
return nil, err
}
rows, err := conn.QueryContext(context.Background(), `
select EncryptionKeyId, EncryptionKey from ShareFileItems
where EncryptionKey != '' and EncryptionKey is not null
`)
if err != nil {
return nil, err
}
defer rows.Close()
m := make(map[string]string)
for rows.Next() {
var keyId, key string
if err := rows.Scan(&keyId, &key); err != nil {
continue
}
m[keyId] = key
}
return m, err
}
var kugouPcDatabaseDumpLock = &sync.Mutex{}
var kugouPcDatabaseDump = make(map[string]map[string]string)
func CachedDumpEKey(dbPath string) (map[string]string, error) {
dump, exist := kugouPcDatabaseDump[dbPath]
if !exist {
kugouPcDatabaseDumpLock.Lock()
defer kugouPcDatabaseDumpLock.Unlock()
if dump, exist = kugouPcDatabaseDump[dbPath]; !exist {
buffer, err := os.ReadFile(dbPath)
if err != nil {
return nil, err
}
if err = decryptDatabase(buffer); err != nil {
return nil, err
}
dump, err = extractKeyMapping(buffer)
if err != nil {
return nil, err
}
kugouPcDatabaseDump[dbPath] = dump
}
}
return dump, nil
}

View File

@@ -0,0 +1,22 @@
package pc_kugou_db
import (
"reflect"
"testing"
)
func TestDerivePageAESKey_Page0(t *testing.T) {
expectedKey := []byte{0x19, 0x62, 0xc0, 0x5f, 0xa2, 0xeb, 0xbe, 0x24, 0x28, 0xff, 0x52, 0x2b, 0x9e, 0x03, 0xea, 0xd4}
pageKey := derivePageKey(0)
if !reflect.DeepEqual(expectedKey, pageKey) {
t.Errorf("Derived AES key for page 0 does not match expected value: got %v, want %v", pageKey, expectedKey)
}
}
func TestDerivePageAESIv_Page0(t *testing.T) {
expectedIv := []byte{0x05, 0x5a, 0x67, 0x35, 0x93, 0x89, 0x2d, 0xdf, 0x3a, 0xb3, 0xb3, 0xc6, 0x21, 0xc3, 0x48, 0x02}
pageKey := derivePageIv(0)
if !reflect.DeepEqual(expectedIv, pageKey) {
t.Errorf("Derived AES iv for page 0 does not match expected value: got %v, want %v", pageKey, expectedIv)
}
}

View File

@@ -2,107 +2,78 @@ package kwm
import (
"bytes"
"encoding/binary"
"errors"
"github.com/unlock-music/cli/algo/common"
"fmt"
"io"
"strconv"
"strings"
"unicode"
"git.um-react.app/um/cli/algo/common"
)
var (
magicHeader = []byte{
0x79, 0x65, 0x65, 0x6C, 0x69, 0x6F, 0x6E, 0x2D,
0x6B, 0x75, 0x77, 0x6F, 0x2D, 0x74, 0x6D, 0x65}
ErrKwFileSize = errors.New("kwm invalid file size")
ErrKwMagicHeader = errors.New("kwm magic header not matched")
)
const magicHeader1 = "yeelion-kuwo-tme"
const magicHeader2 = "yeelion-kuwo\x00\x00\x00\x00"
const keyPreDefined = "MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk"
type Decoder struct {
file []byte
rd io.ReadSeeker
cipher common.StreamDecoder
offset int
key []byte
outputExt string
bitrate int
mask []byte
audio []byte
}
func (d *Decoder) GetCoverImage() []byte {
return nil
}
func (d *Decoder) GetAudioData() []byte {
return d.audio
}
func (d *Decoder) GetAudioExt() string {
return "." + d.outputExt
}
func (d *Decoder) GetMeta() common.Meta {
return nil
}
func NewDecoder(data []byte) common.Decoder {
//todo: Notice the input data will be changed for now
return &Decoder{file: data}
func NewDecoder(p *common.DecoderParams) common.Decoder {
return &Decoder{rd: p.Reader}
}
// Validate checks if the file is a valid Kuwo .kw file.
// rd will be seeked to the beginning of the encrypted audio.
func (d *Decoder) Validate() error {
lenData := len(d.file)
if lenData < 1024 {
return ErrKwFileSize
}
if !bytes.Equal(magicHeader, d.file[:16]) {
return ErrKwMagicHeader
}
return nil
}
func generateMask(key []byte) []byte {
keyInt := binary.LittleEndian.Uint64(key)
keyStr := strconv.FormatUint(keyInt, 10)
keyStrTrim := padOrTruncate(keyStr, 32)
mask := make([]byte, 32)
for i := 0; i < 32; i++ {
mask[i] = keyPreDefined[i] ^ keyStrTrim[i]
}
return mask
}
func (d *Decoder) parseBitrateAndType() {
bitType := string(bytes.TrimRight(d.file[0x30:0x38], string(byte(0))))
charPos := 0
for charPos = range bitType {
if !unicode.IsNumber(rune(bitType[charPos])) {
break
}
}
var err error
d.bitrate, err = strconv.Atoi(bitType[:charPos])
header := make([]byte, 0x400) // kwm header is fixed to 1024 bytes
_, err := io.ReadFull(d.rd, header)
if err != nil {
d.bitrate = 0
return fmt.Errorf("kwm read header: %w", err)
}
d.outputExt = strings.ToLower(bitType[charPos:])
// check magic header, 0x00 - 0x0F
magicHeader := header[:0x10]
if !bytes.Equal([]byte(magicHeader1), magicHeader) &&
!bytes.Equal([]byte(magicHeader2), magicHeader) {
return errors.New("kwm magic header not matched")
}
d.cipher = newKwmCipher(header[0x18:0x20]) // Crypto Key, 0x18 - 0x1F
d.bitrate, d.outputExt = parseBitrateAndType(header[0x30:0x38]) // Bitrate & File Extension, 0x30 - 0x38
return nil
}
func (d *Decoder) Decode() error {
d.parseBitrateAndType()
func parseBitrateAndType(header []byte) (int, string) {
tmp := strings.TrimRight(string(header), "\x00")
sep := strings.IndexFunc(tmp, func(r rune) bool {
return !unicode.IsDigit(r)
})
d.mask = generateMask(d.file[0x18:0x20])
bitrate, _ := strconv.Atoi(tmp[:sep]) // just ignore the error
outputExt := strings.ToLower(tmp[sep:])
return bitrate, outputExt
}
d.audio = d.file[1024:]
dataLen := len(d.audio)
for i := 0; i < dataLen; i++ {
d.audio[i] ^= d.mask[i&0x1F] //equals: [i % 32]
func (d *Decoder) Read(b []byte) (int, error) {
n, err := d.rd.Read(b)
if n > 0 {
d.cipher.Decrypt(b[:n], d.offset)
d.offset += n
}
return nil
return n, err
}
func padOrTruncate(raw string, length int) string {

31
algo/kwm/kwm_cipher.go Normal file
View File

@@ -0,0 +1,31 @@
package kwm
import (
"encoding/binary"
"strconv"
)
type kwmCipher struct {
mask []byte
}
func newKwmCipher(key []byte) *kwmCipher {
return &kwmCipher{mask: generateMask(key)}
}
func generateMask(key []byte) []byte {
keyInt := binary.LittleEndian.Uint64(key)
keyStr := strconv.FormatUint(keyInt, 10)
keyStrTrim := padOrTruncate(keyStr, 32)
mask := make([]byte, 32)
for i := 0; i < 32; i++ {
mask[i] = keyPreDefined[i] ^ keyStrTrim[i]
}
return mask
}
func (c kwmCipher) Decrypt(buf []byte, offset int) {
for i := range buf {
buf[i] ^= c.mask[(offset+i)&0x1F] // equivalent: [i % 32]
}
}

View File

@@ -1,25 +1,32 @@
package ncm
import (
"github.com/unlock-music/cli/algo/common"
"strings"
"go.uber.org/zap"
"git.um-react.app/um/cli/algo/common"
)
type RawMeta interface {
common.Meta
type ncmMeta interface {
common.AudioMeta
// GetFormat return the audio format, e.g. mp3, flac
GetFormat() string
// GetAlbumImageURL return the album image url
GetAlbumImageURL() string
}
type RawMetaMusic struct {
type ncmMetaMusic struct {
logger *zap.Logger
Format string `json:"format"`
MusicID int `json:"musicId"`
MusicName string `json:"musicName"`
Artist [][]interface{} `json:"artist"`
Artist interface{} `json:"artist"`
Album string `json:"album"`
AlbumID int `json:"albumId"`
AlbumPicDocID interface{} `json:"albumPicDocId"`
AlbumPic string `json:"albumPic"`
MvID int `json:"mvId"`
Flag int `json:"flag"`
Bitrate int `json:"bitrate"`
Duration int `json:"duration"`
@@ -27,37 +34,71 @@ type RawMetaMusic struct {
TransNames []interface{} `json:"transNames"`
}
func (m RawMetaMusic) GetAlbumImageURL() string {
return m.AlbumPic
}
func (m RawMetaMusic) GetArtists() (artists []string) {
for _, artist := range m.Artist {
for _, item := range artist {
name, ok := item.(string)
if ok {
artists = append(artists, name)
}
}
}
return
func newNcmMetaMusic(logger *zap.Logger) *ncmMetaMusic {
ncm := new(ncmMetaMusic)
ncm.logger = logger.With(zap.String("module", "ncmMetaMusic"))
return ncm
}
func (m RawMetaMusic) GetTitle() string {
func (m *ncmMetaMusic) GetAlbumImageURL() string {
return m.AlbumPic
}
func (m *ncmMetaMusic) GetArtists() []string {
m.logger.Debug("ncm artists raw", zap.Any("artists", m.Artist))
var artists []string
switch v := m.Artist.(type) {
// Case 1: Handles the format [['artistA'], ['artistB']]
case [][]string:
for _, artistSlice := range v {
artists = append(artists, artistSlice...)
}
// Case 2: Handles the simple format "artistA"
// Ref: https://git.unlock-music.dev/um/cli/issues/78
case string:
artists = []string{v}
// Case 3: Handles the mixed-type format [['artistA', 12345], ['artistB', 67890]]
// This is the key fix for correctly parsing artist info from certain files.
case []interface{}:
for _, item := range v {
if innerSlice, ok := item.([]interface{}); ok {
if len(innerSlice) > 0 {
// Assume the first element is the artist's name.
if artistName, ok := innerSlice[0].(string); ok {
artists = append(artists, artistName)
}
}
}
}
default:
// Log a warning if the artist type is unexpected and not handled.
m.logger.Warn("unexpected artist type", zap.Any("artists", m.Artist))
}
return artists
}
func (m *ncmMetaMusic) GetTitle() string {
return m.MusicName
}
func (m RawMetaMusic) GetAlbum() string {
func (m *ncmMetaMusic) GetAlbum() string {
return m.Album
}
func (m RawMetaMusic) GetFormat() string {
func (m *ncmMetaMusic) GetFormat() string {
return m.Format
}
//goland:noinspection SpellCheckingInspection
type RawMetaDJ struct {
type ncmMetaDJ struct {
ProgramID int `json:"programId"`
ProgramName string `json:"programName"`
MainMusic RawMetaMusic `json:"mainMusic"`
MainMusic ncmMetaMusic `json:"mainMusic"`
DjID int `json:"djId"`
DjName string `json:"djName"`
DjAvatarURL string `json:"djAvatarUrl"`
@@ -79,32 +120,32 @@ type RawMetaDJ struct {
RadioPurchaseCount int `json:"radioPurchaseCount"`
}
func (m RawMetaDJ) GetArtists() []string {
func (m *ncmMetaDJ) GetArtists() []string {
if m.DjName != "" {
return []string{m.DjName}
}
return m.MainMusic.GetArtists()
}
func (m RawMetaDJ) GetTitle() string {
func (m *ncmMetaDJ) GetTitle() string {
if m.ProgramName != "" {
return m.ProgramName
}
return m.MainMusic.GetTitle()
}
func (m RawMetaDJ) GetAlbum() string {
func (m *ncmMetaDJ) GetAlbum() string {
if m.Brand != "" {
return m.Brand
}
return m.MainMusic.GetAlbum()
}
func (m RawMetaDJ) GetFormat() string {
func (m *ncmMetaDJ) GetFormat() string {
return m.MainMusic.GetFormat()
}
func (m RawMetaDJ) GetAlbumImageURL() string {
func (m *ncmMetaDJ) GetAlbumImageURL() string {
if strings.HasPrefix(m.MainMusic.GetAlbumImageURL(), "http") {
return m.MainMusic.GetAlbumImageURL()
}

View File

@@ -2,203 +2,208 @@ package ncm
import (
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"github.com/unlock-music/cli/algo/common"
"github.com/unlock-music/cli/internal/logging"
"github.com/unlock-music/cli/internal/utils"
"go.uber.org/zap"
"io/ioutil"
"fmt"
"io"
"net/http"
"strings"
"go.uber.org/zap"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/internal/utils"
)
const magicHeader = "CTENFDAM"
var (
magicHeader = []byte{
0x43, 0x54, 0x45, 0x4E, 0x46, 0x44, 0x41, 0x4D}
keyCore = []byte{
0x68, 0x7a, 0x48, 0x52, 0x41, 0x6d, 0x73, 0x6f,
0x35, 0x6b, 0x49, 0x6e, 0x62, 0x61, 0x78, 0x57}
0x35, 0x6b, 0x49, 0x6e, 0x62, 0x61, 0x78, 0x57,
}
keyMeta = []byte{
0x23, 0x31, 0x34, 0x6C, 0x6A, 0x6B, 0x5F, 0x21,
0x5C, 0x5D, 0x26, 0x30, 0x55, 0x3C, 0x27, 0x28}
0x5C, 0x5D, 0x26, 0x30, 0x55, 0x3C, 0x27, 0x28,
}
)
func NewDecoder(data []byte) common.Decoder {
return &Decoder{
file: data,
fileLen: uint32(len(data)),
}
func NewDecoder(p *common.DecoderParams) common.Decoder {
return &Decoder{rd: p.Reader, logger: p.Logger.With(zap.String("module", "ncm"))}
}
type Decoder struct {
file []byte
fileLen uint32
logger *zap.Logger
rd io.ReadSeeker // rd is the original file reader
key []byte
box []byte
offset int
cipher common.StreamDecoder
metaRaw []byte
metaType string
meta RawMeta
meta ncmMeta
cover []byte
audio []byte
offsetKey uint32
offsetMeta uint32
offsetCover uint32
offsetAudio uint32
}
// Validate checks if the file is a valid Netease .ncm file.
// rd will be seeked to the beginning of the encrypted audio.
func (d *Decoder) Validate() error {
if !bytes.Equal(magicHeader, d.file[:len(magicHeader)]) {
if err := d.validateMagicHeader(); err != nil {
return err
}
if _, err := d.rd.Seek(2, io.SeekCurrent); err != nil { // 2 bytes gap
return fmt.Errorf("ncm seek file: %w", err)
}
keyData, err := d.readKeyData()
if err != nil {
return err
}
if err := d.readMetaData(); err != nil {
return fmt.Errorf("read meta date failed: %w", err)
}
if _, err := d.rd.Seek(5, io.SeekCurrent); err != nil { // 5 bytes gap
return fmt.Errorf("ncm seek gap: %w", err)
}
if err := d.readCoverData(); err != nil {
return fmt.Errorf("parse ncm cover file failed: %w", err)
}
if err := d.parseMeta(); err != nil {
return fmt.Errorf("parse meta failed: %w (raw json=%s)", err, string(d.metaRaw))
}
d.cipher = newNcmCipher(keyData)
return nil
}
func (d *Decoder) validateMagicHeader() error {
header := make([]byte, len(magicHeader)) // 0x00 - 0x07
if _, err := d.rd.Read(header); err != nil {
return fmt.Errorf("ncm read magic header: %w", err)
}
if !bytes.Equal([]byte(magicHeader), header) {
return errors.New("ncm magic header not match")
}
d.offsetKey = 8 + 2
return nil
}
func (d *Decoder) readKeyData() error {
if d.offsetKey == 0 || d.offsetKey+4 > d.fileLen {
return errors.New("invalid cover file offset")
func (d *Decoder) readKeyData() ([]byte, error) {
bKeyLen := make([]byte, 4) //
if _, err := io.ReadFull(d.rd, bKeyLen); err != nil {
return nil, fmt.Errorf("ncm read key length: %w", err)
}
bKeyLen := d.file[d.offsetKey : d.offsetKey+4]
iKeyLen := binary.LittleEndian.Uint32(bKeyLen)
d.offsetMeta = d.offsetKey + 4 + iKeyLen
bKeyRaw := make([]byte, iKeyLen)
if _, err := io.ReadFull(d.rd, bKeyRaw); err != nil {
return nil, fmt.Errorf("ncm read key data: %w", err)
}
for i := uint32(0); i < iKeyLen; i++ {
bKeyRaw[i] = d.file[i+4+d.offsetKey] ^ 0x64
bKeyRaw[i] ^= 0x64
}
d.key = utils.PKCS7UnPadding(utils.DecryptAes128Ecb(bKeyRaw, keyCore))[17:]
return nil
return utils.PKCS7UnPadding(utils.DecryptAES128ECB(bKeyRaw, keyCore))[17:], nil
}
func (d *Decoder) readMetaData() error {
if d.offsetMeta == 0 || d.offsetMeta+4 > d.fileLen {
return errors.New("invalid meta file offset")
bMetaLen := make([]byte, 4) //
if _, err := io.ReadFull(d.rd, bMetaLen); err != nil {
return fmt.Errorf("ncm read key length: %w", err)
}
bMetaLen := d.file[d.offsetMeta : d.offsetMeta+4]
iMetaLen := binary.LittleEndian.Uint32(bMetaLen)
d.offsetCover = d.offsetMeta + 4 + iMetaLen
if iMetaLen == 0 {
return errors.New("no any meta file found")
return nil // no meta data
}
// Why sub 22: Remove "163 key(Don't modify):"
bKeyRaw := make([]byte, iMetaLen-22)
for i := uint32(0); i < iMetaLen-22; i++ {
bKeyRaw[i] = d.file[d.offsetMeta+4+22+i] ^ 0x63
bMetaRaw := make([]byte, iMetaLen)
if _, err := io.ReadFull(d.rd, bMetaRaw); err != nil {
return fmt.Errorf("ncm read meta data: %w", err)
}
bMetaRaw = bMetaRaw[22:] // skip "163 key(Don't modify):"
for i := 0; i < len(bMetaRaw); i++ {
bMetaRaw[i] ^= 0x63
}
cipherText, err := base64.StdEncoding.DecodeString(string(bKeyRaw))
cipherText, err := base64.StdEncoding.DecodeString(string(bMetaRaw))
if err != nil {
return errors.New("decode ncm meta failed: " + err.Error())
}
metaRaw := utils.PKCS7UnPadding(utils.DecryptAes128Ecb(cipherText, keyMeta))
sepIdx := bytes.IndexRune(metaRaw, ':')
if sepIdx == -1 {
metaRaw := utils.PKCS7UnPadding(utils.DecryptAES128ECB(cipherText, keyMeta))
sep := bytes.IndexByte(metaRaw, ':')
if sep == -1 {
return errors.New("invalid ncm meta file")
}
d.metaType = string(metaRaw[:sepIdx])
d.metaRaw = metaRaw[sepIdx+1:]
d.metaType = string(metaRaw[:sep])
d.metaRaw = metaRaw[sep+1:]
return nil
}
func (d *Decoder) buildKeyBox() {
box := make([]byte, 256)
for i := 0; i < 256; i++ {
box[i] = byte(i)
func (d *Decoder) readCoverData() error {
bCoverFrameLen := make([]byte, 4)
if _, err := io.ReadFull(d.rd, bCoverFrameLen); err != nil {
return fmt.Errorf("ncm read cover length: %w", err)
}
keyLen := len(d.key)
var j byte
for i := 0; i < 256; i++ {
j = box[i] + j + d.key[i%keyLen]
box[i], box[j] = box[j], box[i]
coverFrameStartOffset, err := d.rd.Seek(0, io.SeekCurrent)
if err != nil {
return fmt.Errorf("ncm fetch cover frame start offset: %w", err)
}
coverFrameLen := binary.LittleEndian.Uint32(bCoverFrameLen)
d.box = make([]byte, 256)
var _i byte
for i := 0; i < 256; i++ {
_i = byte(i + 1)
si := box[_i]
sj := box[_i+si]
d.box[i] = box[si+sj]
bCoverLen := make([]byte, 4)
if _, err := io.ReadFull(d.rd, bCoverLen); err != nil {
return fmt.Errorf("ncm read cover length: %w", err)
}
iCoverLen := binary.LittleEndian.Uint32(bCoverLen)
coverBuf := make([]byte, iCoverLen)
if _, err := io.ReadFull(d.rd, coverBuf); err != nil {
return fmt.Errorf("ncm read cover data: %w", err)
}
d.cover = coverBuf
offsetAudioData := coverFrameStartOffset + int64(coverFrameLen) + 4
_, err = d.rd.Seek(offsetAudioData, io.SeekStart)
return err
}
func (d *Decoder) parseMeta() error {
switch d.metaType {
case "music":
d.meta = new(RawMetaMusic)
d.meta = newNcmMetaMusic(d.logger)
return json.Unmarshal(d.metaRaw, d.meta)
case "dj":
d.meta = new(RawMetaDJ)
d.meta = new(ncmMetaDJ)
return json.Unmarshal(d.metaRaw, d.meta)
default:
return errors.New("unknown ncm meta type: " + d.metaType)
}
}
func (d *Decoder) readCoverData() error {
if d.offsetCover == 0 || d.offsetCover+13 > d.fileLen {
return errors.New("invalid cover file offset")
func (d *Decoder) Read(buf []byte) (int, error) {
n, err := d.rd.Read(buf)
if n > 0 {
d.cipher.Decrypt(buf[:n], d.offset)
d.offset += n
}
coverLenStart := d.offsetCover + 5 + 4
bCoverLen := d.file[coverLenStart : coverLenStart+4]
iCoverLen := binary.LittleEndian.Uint32(bCoverLen)
d.offsetAudio = coverLenStart + 4 + iCoverLen
if iCoverLen == 0 {
return errors.New("no any cover file found")
}
d.cover = d.file[coverLenStart+4 : 4+coverLenStart+iCoverLen]
return nil
return n, err
}
func (d *Decoder) readAudioData() error {
if d.offsetAudio == 0 || d.offsetAudio > d.fileLen {
return errors.New("invalid audio offset")
}
audioRaw := d.file[d.offsetAudio:]
audioLen := len(audioRaw)
d.audio = make([]byte, audioLen)
for i := uint32(0); i < uint32(audioLen); i++ {
d.audio[i] = d.box[i&0xff] ^ audioRaw[i]
}
return nil
}
func (d *Decoder) Decode() error {
if err := d.readKeyData(); err != nil {
return err
}
d.buildKeyBox()
err := d.readMetaData()
if err == nil {
err = d.parseMeta()
}
if err != nil {
logging.Log().Warn("parse ncm meta file failed", zap.Error(err))
}
err = d.readCoverData()
if err != nil {
logging.Log().Warn("parse ncm cover file failed", zap.Error(err))
}
return d.readAudioData()
}
func (d Decoder) GetAudioExt() string {
func (d *Decoder) GetAudioExt() string {
if d.meta != nil {
if format := d.meta.GetFormat(); format != "" {
return "." + d.meta.GetFormat()
@@ -207,41 +212,40 @@ func (d Decoder) GetAudioExt() string {
return ""
}
func (d Decoder) GetAudioData() []byte {
return d.audio
}
func (d Decoder) GetCoverImage() []byte {
func (d *Decoder) GetCoverImage(ctx context.Context) ([]byte, error) {
if d.cover != nil {
return d.cover
return d.cover, nil
}
if d.meta == nil {
return nil, errors.New("ncm meta not found")
}
{
imgURL := d.meta.GetAlbumImageURL()
if d.meta != nil && !strings.HasPrefix(imgURL, "http") {
return nil
if !strings.HasPrefix(imgURL, "http") {
return nil, nil // no cover image
}
resp, err := http.Get(imgURL)
// fetch cover image
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imgURL, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
logging.Log().Warn("download image failed", zap.Error(err), zap.String("url", imgURL))
return nil
return nil, fmt.Errorf("ncm download image failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logging.Log().Warn("download image failed", zap.String("http", resp.Status),
zap.String("url", imgURL))
return nil
return nil, fmt.Errorf("ncm download image failed: unexpected http status %s", resp.Status)
}
data, err := ioutil.ReadAll(resp.Body)
d.cover, err = io.ReadAll(resp.Body)
if err != nil {
logging.Log().Warn("download image failed", zap.Error(err), zap.String("url", imgURL))
return nil
}
return data
return nil, fmt.Errorf("ncm download image failed: %w", err)
}
return d.cover, nil
}
func (d Decoder) GetMeta() common.Meta {
return d.meta
func (d *Decoder) GetAudioMeta(_ context.Context) (common.AudioMeta, error) {
return d.meta, nil
}
func init() {

42
algo/ncm/ncm_cipher.go Normal file
View File

@@ -0,0 +1,42 @@
package ncm
type ncmCipher struct {
key []byte
box []byte
}
func newNcmCipher(key []byte) *ncmCipher {
return &ncmCipher{
key: key,
box: buildKeyBox(key),
}
}
func (c *ncmCipher) Decrypt(buf []byte, offset int) {
for i := 0; i < len(buf); i++ {
buf[i] ^= c.box[(i+offset)&0xff]
}
}
func buildKeyBox(key []byte) []byte {
box := make([]byte, 256)
for i := 0; i < 256; i++ {
box[i] = byte(i)
}
var j byte
for i := 0; i < 256; i++ {
j = box[i] + j + key[i%len(key)]
box[i], box[j] = box[j], box[i]
}
ret := make([]byte, 256)
var _i byte
for i := 0; i < 256; i++ {
_i = byte(i + 1)
si := box[_i]
sj := box[_i+si]
ret[i] = box[si+sj]
}
return ret
}

39
algo/qmc/cipher_map.go Normal file
View File

@@ -0,0 +1,39 @@
package qmc
import "errors"
type mapCipher struct {
key []byte
box []byte
size int
}
func newMapCipher(key []byte) (*mapCipher, error) {
if len(key) == 0 {
return nil, errors.New("qmc/cipher_map: invalid key size")
}
c := &mapCipher{key: key, size: len(key)}
c.box = make([]byte, c.size)
return c, nil
}
func (c *mapCipher) getMask(offset int) byte {
if offset > 0x7FFF {
offset %= 0x7FFF
}
idx := (offset*offset + 71214) % c.size
return c.rotate(c.key[idx], byte(idx)&0x7)
}
func (c *mapCipher) rotate(value byte, bits byte) byte {
rotate := (bits + 4) % 8
left := value << rotate
right := value >> rotate
return left | right
}
func (c *mapCipher) Decrypt(buf []byte, offset int) {
for i := 0; i < len(buf); i++ {
buf[i] ^= c.getMask(offset + i)
}
}

View File

@@ -0,0 +1,53 @@
package qmc
import (
"fmt"
"os"
"reflect"
"testing"
)
func loadTestDataMapCipher(name string) ([]byte, []byte, []byte, error) {
key, err := os.ReadFile(fmt.Sprintf("./testdata/%s_key.bin", name))
if err != nil {
return nil, nil, nil, err
}
raw, err := os.ReadFile(fmt.Sprintf("./testdata/%s_raw.bin", name))
if err != nil {
return nil, nil, nil, err
}
target, err := os.ReadFile(fmt.Sprintf("./testdata/%s_target.bin", name))
if err != nil {
return nil, nil, nil, err
}
return key, raw, target, nil
}
func Test_mapCipher_Decrypt(t *testing.T) {
tests := []struct {
name string
wantErr bool
}{
{"mflac_map", false},
{"mgg_map", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key, raw, target, err := loadTestDataMapCipher(tt.name)
if err != nil {
t.Fatalf("load testing data failed: %s", err)
}
c, err := newMapCipher(key)
if err != nil {
t.Errorf("init mapCipher failed: %s", err)
return
}
c.Decrypt(raw, 0)
if !reflect.DeepEqual(raw, target) {
t.Error("overall")
}
})
}
}

124
algo/qmc/cipher_rc4.go Normal file
View File

@@ -0,0 +1,124 @@
package qmc
import (
"errors"
)
// A rc4Cipher is an instance of RC4 using a particular key.
type rc4Cipher struct {
box []byte
key []byte
hash uint32
n int
}
// newRC4Cipher creates and returns a new rc4Cipher. The key argument should be the
// RC4 key, at least 1 byte and at most 256 bytes.
func newRC4Cipher(key []byte) (*rc4Cipher, error) {
n := len(key)
if n == 0 {
return nil, errors.New("qmc/cipher_rc4: invalid key size")
}
var c = rc4Cipher{key: key, n: n}
c.box = make([]byte, n)
for i := 0; i < n; i++ {
c.box[i] = byte(i)
}
var j = 0
for i := 0; i < n; i++ {
j = (j + int(c.box[i]) + int(key[i%n])) % n
c.box[i], c.box[j] = c.box[j], c.box[i]
}
c.getHashBase()
return &c, nil
}
func (c *rc4Cipher) getHashBase() {
c.hash = 1
for i := 0; i < c.n; i++ {
v := uint32(c.key[i])
if v == 0 {
continue
}
nextHash := c.hash * v
if nextHash == 0 || nextHash <= c.hash {
break
}
c.hash = nextHash
}
}
const (
rc4SegmentSize = 5120
rc4FirstSegmentSize = 128
)
func (c *rc4Cipher) Decrypt(src []byte, offset int) {
toProcess := len(src)
processed := 0
markProcess := func(p int) (finished bool) {
offset += p
toProcess -= p
processed += p
return toProcess == 0
}
if offset < rc4FirstSegmentSize {
blockSize := toProcess
if blockSize > rc4FirstSegmentSize-offset {
blockSize = rc4FirstSegmentSize - offset
}
c.encFirstSegment(src[:blockSize], offset)
if markProcess(blockSize) {
return
}
}
if offset%rc4SegmentSize != 0 {
blockSize := toProcess
if blockSize > rc4SegmentSize-offset%rc4SegmentSize {
blockSize = rc4SegmentSize - offset%rc4SegmentSize
}
c.encASegment(src[processed:processed+blockSize], offset)
if markProcess(blockSize) {
return
}
}
for toProcess > rc4SegmentSize {
c.encASegment(src[processed:processed+rc4SegmentSize], offset)
markProcess(rc4SegmentSize)
}
if toProcess > 0 {
c.encASegment(src[processed:], offset)
}
}
func (c *rc4Cipher) encFirstSegment(buf []byte, offset int) {
for i := 0; i < len(buf); i++ {
buf[i] ^= c.key[c.getSegmentSkip(offset+i)]
}
}
func (c *rc4Cipher) encASegment(buf []byte, offset int) {
box := make([]byte, c.n)
copy(box, c.box)
j, k := 0, 0
skipLen := (offset % rc4SegmentSize) + c.getSegmentSkip(offset/rc4SegmentSize)
for i := -skipLen; i < len(buf); i++ {
j = (j + 1) % c.n
k = (int(box[j]) + k) % c.n
box[j], box[k] = box[k], box[j]
if i >= 0 {
buf[i] ^= box[(int(box[j])+int(box[k]))%c.n]
}
}
}
func (c *rc4Cipher) getSegmentSkip(id int) int {
seed := int(c.key[id%c.n])
idx := int64(float64(c.hash) / float64((id+1)*seed) * 100.0)
return int(idx % int64(c.n))
}

115
algo/qmc/cipher_rc4_test.go Normal file
View File

@@ -0,0 +1,115 @@
package qmc
import (
"os"
"reflect"
"testing"
)
func loadTestRC4CipherData(name string) ([]byte, []byte, []byte, error) {
prefix := "./testdata/" + name
key, err := os.ReadFile(prefix + "_key.bin")
if err != nil {
return nil, nil, nil, err
}
raw, err := os.ReadFile(prefix + "_raw.bin")
if err != nil {
return nil, nil, nil, err
}
target, err := os.ReadFile(prefix + "_target.bin")
if err != nil {
return nil, nil, nil, err
}
return key, raw, target, nil
}
func Test_rc4Cipher_Decrypt(t *testing.T) {
tests := []struct {
name string
wantErr bool
}{
{"mflac0_rc4", false},
{"mflac_rc4", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key, raw, target, err := loadTestRC4CipherData(tt.name)
if err != nil {
t.Fatalf("load testing data failed: %s", err)
}
c, err := newRC4Cipher(key)
if err != nil {
t.Errorf("init rc4Cipher failed: %s", err)
return
}
c.Decrypt(raw, 0)
if !reflect.DeepEqual(raw, target) {
t.Error("overall")
}
})
}
}
func BenchmarkRc4Cipher_Decrypt(b *testing.B) {
key, raw, _, err := loadTestRC4CipherData("mflac0_rc4")
if err != nil {
b.Fatalf("load testing data failed: %s", err)
}
c, err := newRC4Cipher(key)
if err != nil {
b.Errorf("init rc4Cipher failed: %s", err)
return
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
c.Decrypt(raw, 0)
}
}
func Test_rc4Cipher_encFirstSegment(t *testing.T) {
key, raw, target, err := loadTestRC4CipherData("mflac0_rc4")
if err != nil {
t.Fatalf("load testing data failed: %s", err)
}
t.Run("first-block(0~128)", func(t *testing.T) {
c, err := newRC4Cipher(key)
if err != nil {
t.Errorf("init rc4Cipher failed: %s", err)
return
}
c.Decrypt(raw[:128], 0)
if !reflect.DeepEqual(raw[:128], target[:128]) {
t.Error("first-block(0~128)")
}
})
}
func Test_rc4Cipher_encASegment(t *testing.T) {
key, raw, target, err := loadTestRC4CipherData("mflac0_rc4")
if err != nil {
t.Fatalf("load testing data failed: %s", err)
}
t.Run("align-block(128~5120)", func(t *testing.T) {
c, err := newRC4Cipher(key)
if err != nil {
t.Errorf("init rc4Cipher failed: %s", err)
return
}
c.Decrypt(raw[128:5120], 128)
if !reflect.DeepEqual(raw[128:5120], target[128:5120]) {
t.Error("align-block(128~5120)")
}
})
t.Run("simple-block(5120~10240)", func(t *testing.T) {
c, err := newRC4Cipher(key)
if err != nil {
t.Errorf("init rc4Cipher failed: %s", err)
return
}
c.Decrypt(raw[5120:10240], 5120)
if !reflect.DeepEqual(raw[5120:10240], target[5120:10240]) {
t.Error("align-block(128~5120)")
}
})
}

57
algo/qmc/cipher_static.go Normal file
View File

@@ -0,0 +1,57 @@
package qmc
func newStaticCipher() *staticCipher {
return &defaultStaticCipher
}
var defaultStaticCipher = staticCipher{}
type staticCipher struct{}
func (c *staticCipher) Decrypt(buf []byte, offset int) {
for i := 0; i < len(buf); i++ {
buf[i] ^= c.getMask(offset + i)
}
}
func (c *staticCipher) getMask(offset int) byte {
if offset > 0x7FFF {
offset %= 0x7FFF
}
idx := (offset*offset + 27) & 0xff
return staticCipherBox[idx]
}
var staticCipherBox = [...]byte{
0x77, 0x48, 0x32, 0x73, 0xDE, 0xF2, 0xC0, 0xC8, //0x00
0x95, 0xEC, 0x30, 0xB2, 0x51, 0xC3, 0xE1, 0xA0, //0x08
0x9E, 0xE6, 0x9D, 0xCF, 0xFA, 0x7F, 0x14, 0xD1, //0x10
0xCE, 0xB8, 0xDC, 0xC3, 0x4A, 0x67, 0x93, 0xD6, //0x18
0x28, 0xC2, 0x91, 0x70, 0xCA, 0x8D, 0xA2, 0xA4, //0x20
0xF0, 0x08, 0x61, 0x90, 0x7E, 0x6F, 0xA2, 0xE0, //0x28
0xEB, 0xAE, 0x3E, 0xB6, 0x67, 0xC7, 0x92, 0xF4, //0x30
0x91, 0xB5, 0xF6, 0x6C, 0x5E, 0x84, 0x40, 0xF7, //0x38
0xF3, 0x1B, 0x02, 0x7F, 0xD5, 0xAB, 0x41, 0x89, //0x40
0x28, 0xF4, 0x25, 0xCC, 0x52, 0x11, 0xAD, 0x43, //0x48
0x68, 0xA6, 0x41, 0x8B, 0x84, 0xB5, 0xFF, 0x2C, //0x50
0x92, 0x4A, 0x26, 0xD8, 0x47, 0x6A, 0x7C, 0x95, //0x58
0x61, 0xCC, 0xE6, 0xCB, 0xBB, 0x3F, 0x47, 0x58, //0x60
0x89, 0x75, 0xC3, 0x75, 0xA1, 0xD9, 0xAF, 0xCC, //0x68
0x08, 0x73, 0x17, 0xDC, 0xAA, 0x9A, 0xA2, 0x16, //0x70
0x41, 0xD8, 0xA2, 0x06, 0xC6, 0x8B, 0xFC, 0x66, //0x78
0x34, 0x9F, 0xCF, 0x18, 0x23, 0xA0, 0x0A, 0x74, //0x80
0xE7, 0x2B, 0x27, 0x70, 0x92, 0xE9, 0xAF, 0x37, //0x88
0xE6, 0x8C, 0xA7, 0xBC, 0x62, 0x65, 0x9C, 0xC2, //0x90
0x08, 0xC9, 0x88, 0xB3, 0xF3, 0x43, 0xAC, 0x74, //0x98
0x2C, 0x0F, 0xD4, 0xAF, 0xA1, 0xC3, 0x01, 0x64, //0xA0
0x95, 0x4E, 0x48, 0x9F, 0xF4, 0x35, 0x78, 0x95, //0xA8
0x7A, 0x39, 0xD6, 0x6A, 0xA0, 0x6D, 0x40, 0xE8, //0xB0
0x4F, 0xA8, 0xEF, 0x11, 0x1D, 0xF3, 0x1B, 0x3F, //0xB8
0x3F, 0x07, 0xDD, 0x6F, 0x5B, 0x19, 0x30, 0x19, //0xC0
0xFB, 0xEF, 0x0E, 0x37, 0xF0, 0x0E, 0xCD, 0x16, //0xC8
0x49, 0xFE, 0x53, 0x47, 0x13, 0x1A, 0xBD, 0xA4, //0xD0
0xF1, 0x40, 0x19, 0x60, 0x0E, 0xED, 0x68, 0x09, //0xD8
0x06, 0x5F, 0x4D, 0xCF, 0x3D, 0x1A, 0xFE, 0x20, //0xE0
0x77, 0xE4, 0xD9, 0xDA, 0xF9, 0xA4, 0x2B, 0x76, //0xE8
0x1C, 0x71, 0xDB, 0x00, 0xBC, 0xFD, 0x0C, 0x6C, //0xF0
0xA5, 0x47, 0xF7, 0xF6, 0x00, 0x79, 0x4A, 0x11, //0xF8
}

146
algo/qmc/client/base.go Normal file
View File

@@ -0,0 +1,146 @@
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type QQMusic struct {
http *http.Client
}
func (c *QQMusic) rpcDoRequest(ctx context.Context, reqBody any) ([]byte, error) {
reqBodyBuf, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("qqMusicClient[rpcDoRequest] marshal request: %w", err)
}
const endpointURL = "https://u.y.qq.com/cgi-bin/musicu.fcg"
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
endpointURL+fmt.Sprintf("?pcachetime=%d", time.Now().Unix()),
bytes.NewReader(reqBodyBuf),
)
if err != nil {
return nil, fmt.Errorf("qqMusicClient[rpcDoRequest] create request: %w", err)
}
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "zh-CN")
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// req.Header.Set("Accept-Encoding", "gzip, deflate")
reqp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("qqMusicClient[rpcDoRequest] send request: %w", err)
}
defer reqp.Body.Close()
respBodyBuf, err := io.ReadAll(reqp.Body)
if err != nil {
return nil, fmt.Errorf("qqMusicClient[rpcDoRequest] read response: %w", err)
}
return respBodyBuf, nil
}
type rpcRequest struct {
Method string `json:"method"`
Module string `json:"module"`
Param any `json:"param"`
}
type rpcResponse struct {
Code int `json:"code"`
Ts int64 `json:"ts"`
StartTs int64 `json:"start_ts"`
TraceID string `json:"traceid"`
}
type rpcSubResponse struct {
Code int `json:"code"`
Data json.RawMessage `json:"data"`
}
func (c *QQMusic) rpcCall(ctx context.Context,
protocol string, method string, module string,
param any,
) (json.RawMessage, error) {
reqBody := map[string]any{protocol: rpcRequest{
Method: method,
Module: module,
Param: param,
}}
respBodyBuf, err := c.rpcDoRequest(ctx, reqBody)
if err != nil {
return nil, fmt.Errorf("qqMusicClient[rpcCall] do request: %w", err)
}
// check rpc response status
respStatus := rpcResponse{}
if err := json.Unmarshal(respBodyBuf, &respStatus); err != nil {
return nil, fmt.Errorf("qqMusicClient[rpcCall] unmarshal response: %w", err)
}
if respStatus.Code != 0 {
return nil, fmt.Errorf("qqMusicClient[rpcCall] rpc error: %d", respStatus.Code)
}
// parse response data
var respBody map[string]json.RawMessage
if err := json.Unmarshal(respBodyBuf, &respBody); err != nil {
return nil, fmt.Errorf("qqMusicClient[rpcCall] unmarshal response: %w", err)
}
subRespBuf, ok := respBody[protocol]
if !ok {
return nil, fmt.Errorf("qqMusicClient[rpcCall] sub-response not found")
}
subResp := rpcSubResponse{}
if err := json.Unmarshal(subRespBuf, &subResp); err != nil {
return nil, fmt.Errorf("qqMusicClient[rpcCall] unmarshal sub-response: %w", err)
}
if subResp.Code != 0 {
return nil, fmt.Errorf("qqMusicClient[rpcCall] sub-response error: %d", subResp.Code)
}
return subResp.Data, nil
}
func (c *QQMusic) downloadFile(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("qmc[downloadFile] init request: %w", err)
}
//req.Header.Set("Accept", "image/webp,image/*,*/*;q=0.8") // jpeg is preferred to embed in audio
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.8,en-US;q=0.6,en;q=0.5;q=0.4")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.47.134 Safari/537.36 QBCore/3.53.47.400 QQBrowser/9.0.2524.400")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("qmc[downloadFile] send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("qmc[downloadFile] unexpected http status %s", resp.Status)
}
return io.ReadAll(resp.Body)
}
func NewQQMusicClient() *QQMusic {
return &QQMusic{
http: &http.Client{
Timeout: 10 * time.Second,
},
}
}

21
algo/qmc/client/cover.go Normal file
View File

@@ -0,0 +1,21 @@
package client
import (
"context"
"fmt"
"strconv"
)
func (c *QQMusic) AlbumCoverByID(ctx context.Context, albumID int) ([]byte, error) {
u := fmt.Sprintf("https://imgcache.qq.com/music/photo/album/%s/albumpic_%s_0.jpg",
strconv.Itoa(albumID%100),
strconv.Itoa(albumID),
)
return c.downloadFile(ctx, u)
}
func (c *QQMusic) AlbumCoverByMediaID(ctx context.Context, mediaID string) ([]byte, error) {
// original: https://y.gtimg.cn/music/photo_new/T002M000%s.jpg
u := fmt.Sprintf("https://y.gtimg.cn/music/photo_new/T002R500x500M000%s.jpg", mediaID)
return c.downloadFile(ctx, u)
}

52
algo/qmc/client/search.go Normal file
View File

@@ -0,0 +1,52 @@
package client
import (
"context"
"encoding/json"
"fmt"
)
type searchParams struct {
Grp int `json:"grp"`
NumPerPage int `json:"num_per_page"`
PageNum int `json:"page_num"`
Query string `json:"query"`
RemotePlace string `json:"remoteplace"`
SearchType int `json:"search_type"`
//SearchID string `json:"searchid"` // todo: it seems generated randomly
}
type searchResponse struct {
Body struct {
Song struct {
List []*TrackInfo `json:"list"`
} `json:"song"`
} `json:"body"`
Code int `json:"code"`
}
func (c *QQMusic) Search(ctx context.Context, keyword string) ([]*TrackInfo, error) {
resp, err := c.rpcCall(ctx,
"music.search.SearchCgiService",
"DoSearchForQQMusicDesktop",
"music.search.SearchCgiService",
&searchParams{
SearchType: 0, Query: keyword,
PageNum: 1, NumPerPage: 40,
// static values
Grp: 1, RemotePlace: "sizer.newclient.song",
})
if err != nil {
return nil, fmt.Errorf("qqMusicClient[Search] rpc call: %w", err)
}
respData := searchResponse{}
if err := json.Unmarshal(resp, &respData); err != nil {
return nil, fmt.Errorf("qqMusicClient[Search] unmarshal response: %w", err)
}
return respData.Body.Song.List, nil
}

178
algo/qmc/client/track.go Normal file
View File

@@ -0,0 +1,178 @@
package client
import (
"context"
"encoding/json"
"fmt"
"github.com/samber/lo"
)
type getTrackInfoParams struct {
Ctx int `json:"ctx"`
Ids []int `json:"ids"`
Types []int `json:"types"`
}
type getTrackInfoResponse struct {
Tracks []*TrackInfo `json:"tracks"`
}
func (c *QQMusic) GetTracksInfo(ctx context.Context, songIDs []int) ([]*TrackInfo, error) {
resp, err := c.rpcCall(ctx,
"Protocol_UpdateSongInfo",
"CgiGetTrackInfo",
"music.trackInfo.UniformRuleCtrl",
&getTrackInfoParams{Ctx: 0, Ids: songIDs, Types: []int{0}},
)
if err != nil {
return nil, fmt.Errorf("qqMusicClient[GetTrackInfo] rpc call: %w", err)
}
respData := getTrackInfoResponse{}
if err := json.Unmarshal(resp, &respData); err != nil {
return nil, fmt.Errorf("qqMusicClient[GetTrackInfo] unmarshal response: %w", err)
}
return respData.Tracks, nil
}
func (c *QQMusic) GetTrackInfo(ctx context.Context, songID int) (*TrackInfo, error) {
tracks, err := c.GetTracksInfo(ctx, []int{songID})
if err != nil {
return nil, fmt.Errorf("qqMusicClient[GetTrackInfo] get tracks info: %w", err)
}
if len(tracks) == 0 {
return nil, fmt.Errorf("qqMusicClient[GetTrackInfo] track not found")
}
return tracks[0], nil
}
type TrackSinger struct {
Id int `json:"id"`
Mid string `json:"mid"`
Name string `json:"name"`
Title string `json:"title"`
Type int `json:"type"`
Uin int `json:"uin"`
Pmid string `json:"pmid"`
}
type TrackAlbum struct {
Id int `json:"id"`
Mid string `json:"mid"`
Name string `json:"name"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Pmid string `json:"pmid"`
}
type TrackInfo struct {
Id int `json:"id"`
Type int `json:"type"`
Mid string `json:"mid"`
Name string `json:"name"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Singer []TrackSinger `json:"singer"`
Album TrackAlbum `json:"album"`
Mv struct {
Id int `json:"id"`
Vid string `json:"vid"`
Name string `json:"name"`
Title string `json:"title"`
Vt int `json:"vt"`
} `json:"mv"`
Interval int `json:"interval"`
Isonly int `json:"isonly"`
Language int `json:"language"`
Genre int `json:"genre"`
IndexCd int `json:"index_cd"`
IndexAlbum int `json:"index_album"`
TimePublic string `json:"time_public"`
Status int `json:"status"`
Fnote int `json:"fnote"`
File struct {
MediaMid string `json:"media_mid"`
Size24Aac int `json:"size_24aac"`
Size48Aac int `json:"size_48aac"`
Size96Aac int `json:"size_96aac"`
Size192Ogg int `json:"size_192ogg"`
Size192Aac int `json:"size_192aac"`
Size128Mp3 int `json:"size_128mp3"`
Size320Mp3 int `json:"size_320mp3"`
SizeApe int `json:"size_ape"`
SizeFlac int `json:"size_flac"`
SizeDts int `json:"size_dts"`
SizeTry int `json:"size_try"`
TryBegin int `json:"try_begin"`
TryEnd int `json:"try_end"`
Url string `json:"url"`
SizeHires int `json:"size_hires"`
HiresSample int `json:"hires_sample"`
HiresBitdepth int `json:"hires_bitdepth"`
B30S int `json:"b_30s"`
E30S int `json:"e_30s"`
Size96Ogg int `json:"size_96ogg"`
Size360Ra []interface{} `json:"size_360ra"`
SizeDolby int `json:"size_dolby"`
SizeNew []interface{} `json:"size_new"`
} `json:"file"`
Pay struct {
PayMonth int `json:"pay_month"`
PriceTrack int `json:"price_track"`
PriceAlbum int `json:"price_album"`
PayPlay int `json:"pay_play"`
PayDown int `json:"pay_down"`
PayStatus int `json:"pay_status"`
TimeFree int `json:"time_free"`
} `json:"pay"`
Action struct {
Switch int `json:"switch"`
Msgid int `json:"msgid"`
Alert int `json:"alert"`
Icons int `json:"icons"`
Msgshare int `json:"msgshare"`
Msgfav int `json:"msgfav"`
Msgdown int `json:"msgdown"`
Msgpay int `json:"msgpay"`
Switch2 int `json:"switch2"`
Icon2 int `json:"icon2"`
} `json:"action"`
Ksong struct {
Id int `json:"id"`
Mid string `json:"mid"`
} `json:"ksong"`
Volume struct {
Gain float64 `json:"gain"`
Peak float64 `json:"peak"`
Lra float64 `json:"lra"`
} `json:"volume"`
Label string `json:"label"`
Url string `json:"url"`
Ppurl string `json:"ppurl"`
Bpm int `json:"bpm"`
Version int `json:"version"`
Trace string `json:"trace"`
DataType int `json:"data_type"`
ModifyStamp int `json:"modify_stamp"`
Aid int `json:"aid"`
Tid int `json:"tid"`
Ov int `json:"ov"`
Sa int `json:"sa"`
Es string `json:"es"`
Vs []string `json:"vs"`
}
func (t *TrackInfo) GetArtists() []string {
return lo.Map(t.Singer, func(v TrackSinger, i int) string {
return v.Name
})
}
func (t *TrackInfo) GetTitle() string {
return t.Title
}
func (t *TrackInfo) GetAlbum() string {
return t.Album.Name
}

View File

@@ -1,70 +0,0 @@
package qmc
var oggPublicHeader1 = []byte{
0x4f, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff,
0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x01, 0x1e, 0x01, 0x76, 0x6f, 0x72,
0x62, 0x69, 0x73, 0x00, 0x00, 0x00, 0x00, 0x02, 0x44, 0xac, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0xee, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xb8, 0x01, 0x4f, 0x67, 0x67, 0x53, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00,
0xff, 0xff, 0xff, 0xff}
var oggPublicHeader2 = []byte{
0x03, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x2c, 0x00, 0x00, 0x00, 0x58, 0x69, 0x70, 0x68, 0x2e,
0x4f, 0x72, 0x67, 0x20, 0x6c, 0x69, 0x62, 0x56, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x20, 0x49, 0x20,
0x32, 0x30, 0x31, 0x35, 0x30, 0x31, 0x30, 0x35, 0x20, 0x28, 0xe2, 0x9b, 0x84, 0xe2, 0x9b, 0x84,
0xe2, 0x9b, 0x84, 0xe2, 0x9b, 0x84, 0x29, 0xff, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0x54,
0x49, 0x54, 0x4c, 0x45, 0x3d}
var oggPublicConfidence1 = []uint{
9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 0, 0,
0, 0, 9, 9, 9, 9, 0, 0, 0, 0, 9, 9, 9, 9, 9, 9,
9, 9, 9, 9, 9, 9, 9, 6, 3, 3, 3, 3, 6, 6, 6, 6,
3, 3, 3, 3, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 9, 9,
9, 9, 9, 9, 9, 9, 9, 9, 0, 0, 0, 0, 9, 9, 9, 9,
0, 0, 0, 0}
var oggPublicConfidence2 = []uint{
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 0, 1, 3, 3, 0, 1, 3, 3, 3,
3, 3, 3, 3, 3}
var (
defaultKey256Mask44 = []byte{
0xde, 0x51, 0xfa, 0xc3, 0x4a, 0xd6, 0xca, 0x90,
0x7e, 0x67, 0x5e, 0xf7, 0xd5, 0x52, 0x84, 0xd8,
0x47, 0x95, 0xbb, 0xa1, 0xaa, 0xc6, 0x66, 0x23,
0x92, 0x62, 0xf3, 0x74, 0xa1, 0x9f, 0xf4, 0xa0,
0x1d, 0x3f, 0x5b, 0xf0, 0x13, 0x0e, 0x09, 0x3d,
0xf9, 0xbc, 0x00, 0x11}
)
var key256MappingAll [][]int //[idx256][idx128]idx44
var key256Mapping128to44 map[int]int
func init() {
{ // init all mapping
key256MappingAll = make([][]int, 256)
for i := 0; i < 128; i++ {
realIdx := (i*i + 27) % 256
if key256MappingAll[realIdx] == nil {
key256MappingAll[realIdx] = []int{i}
} else {
key256MappingAll[realIdx] = append(key256MappingAll[realIdx], i)
}
}
}
{ // init
key256Mapping128to44 = make(map[int]int, 128)
idx44 := 0
for _, all128 := range key256MappingAll {
if all128 != nil {
for _, _i128 := range all128 {
key256Mapping128to44[_i128] = idx44
}
idx44++
}
}
}
}

161
algo/qmc/key_derive.go Normal file
View File

@@ -0,0 +1,161 @@
package qmc
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"math"
"golang.org/x/crypto/tea"
)
func simpleMakeKey(salt byte, length int) []byte {
keyBuf := make([]byte, length)
for i := 0; i < length; i++ {
tmp := math.Tan(float64(salt) + float64(i)*0.1)
keyBuf[i] = byte(math.Abs(tmp) * 100.0)
}
return keyBuf
}
const rawKeyPrefixV2 = "QQMusic EncV2,Key:"
func deriveKey(rawKey []byte) ([]byte, error) {
rawKeyDec := make([]byte, base64.StdEncoding.DecodedLen(len(rawKey)))
n, err := base64.StdEncoding.Decode(rawKeyDec, rawKey)
if err != nil {
return nil, err
}
rawKeyDec = rawKeyDec[:n]
if bytes.HasPrefix(rawKeyDec, []byte(rawKeyPrefixV2)) {
rawKeyDec, err = deriveKeyV2(bytes.TrimPrefix(rawKeyDec, []byte(rawKeyPrefixV2)))
if err != nil {
return nil, fmt.Errorf("deriveKeyV2 failed: %w", err)
}
}
return deriveKeyV1(rawKeyDec)
}
func deriveKeyV1(rawKeyDec []byte) ([]byte, error) {
if len(rawKeyDec) < 16 {
return nil, errors.New("key length is too short")
}
simpleKey := simpleMakeKey(106, 8)
teaKey := make([]byte, 16)
for i := 0; i < 8; i++ {
teaKey[i<<1] = simpleKey[i]
teaKey[i<<1+1] = rawKeyDec[i]
}
rs, err := decryptTencentTea(rawKeyDec[8:], teaKey)
if err != nil {
return nil, err
}
return append(rawKeyDec[:8], rs...), nil
}
var (
deriveV2Key1 = []byte{
0x33, 0x38, 0x36, 0x5A, 0x4A, 0x59, 0x21, 0x40,
0x23, 0x2A, 0x24, 0x25, 0x5E, 0x26, 0x29, 0x28,
}
deriveV2Key2 = []byte{
0x2A, 0x2A, 0x23, 0x21, 0x28, 0x23, 0x24, 0x25,
0x26, 0x5E, 0x61, 0x31, 0x63, 0x5A, 0x2C, 0x54,
}
)
func deriveKeyV2(raw []byte) ([]byte, error) {
buf, err := decryptTencentTea(raw, deriveV2Key1)
if err != nil {
return nil, err
}
buf, err = decryptTencentTea(buf, deriveV2Key2)
if err != nil {
return nil, err
}
n, err := base64.StdEncoding.Decode(buf, buf)
if err != nil {
return nil, err
}
return buf[:n], nil
}
func decryptTencentTea(inBuf []byte, key []byte) ([]byte, error) {
const saltLen = 2
const zeroLen = 7
if len(inBuf)%8 != 0 {
return nil, errors.New("inBuf size not a multiple of the block size")
}
if len(inBuf) < 16 {
return nil, errors.New("inBuf size too small")
}
blk, err := tea.NewCipherWithRounds(key, 32)
if err != nil {
return nil, err
}
destBuf := make([]byte, 8)
blk.Decrypt(destBuf, inBuf)
padLen := int(destBuf[0] & 0x7)
outLen := len(inBuf) - 1 - padLen - saltLen - zeroLen
out := make([]byte, outLen)
ivPrev := make([]byte, 8)
ivCur := inBuf[:8]
inBufPos := 8
destIdx := 1 + padLen
cryptBlock := func() {
ivPrev = ivCur
ivCur = inBuf[inBufPos : inBufPos+8]
xor8Bytes(destBuf, destBuf, inBuf[inBufPos:inBufPos+8])
blk.Decrypt(destBuf, destBuf)
inBufPos += 8
destIdx = 0
}
for i := 1; i <= saltLen; {
if destIdx < 8 {
destIdx++
i++
} else if destIdx == 8 {
cryptBlock()
}
}
outPos := 0
for outPos < outLen {
if destIdx < 8 {
out[outPos] = destBuf[destIdx] ^ ivPrev[destIdx]
destIdx++
outPos++
} else if destIdx == 8 {
cryptBlock()
}
}
for i := 1; i <= zeroLen; i++ {
if destBuf[destIdx] != ivPrev[destIdx] {
return nil, errors.New("zero check failed")
}
}
return out, nil
}
func xor8Bytes(dst, a, b []byte) {
for i := 0; i < 8; i++ {
dst[i] = a[i] ^ b[i]
}
}

View File

@@ -0,0 +1,57 @@
package qmc
import (
"fmt"
"os"
"reflect"
"testing"
)
func TestSimpleMakeKey(t *testing.T) {
expect := []byte{0x69, 0x56, 0x46, 0x38, 0x2b, 0x20, 0x15, 0x0b}
t.Run("106,8", func(t *testing.T) {
if got := simpleMakeKey(106, 8); !reflect.DeepEqual(got, expect) {
t.Errorf("simpleMakeKey() = %v, want %v", got, expect)
}
})
}
func loadDecryptKeyData(name string) ([]byte, []byte, error) {
keyRaw, err := os.ReadFile(fmt.Sprintf("./testdata/%s_key_raw.bin", name))
if err != nil {
return nil, nil, err
}
keyDec, err := os.ReadFile(fmt.Sprintf("./testdata/%s_key.bin", name))
if err != nil {
return nil, nil, err
}
return keyRaw, keyDec, nil
}
func TestDecryptKey(t *testing.T) {
tests := []struct {
name string
filename string
wantErr bool
}{
{"mflac0_rc4(512)", "mflac0_rc4", false},
{"mflac_map(256)", "mflac_map", false},
{"mflac_rc4(256)", "mflac_rc4", false},
{"mgg_map(256)", "mgg_map", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
raw, want, err := loadDecryptKeyData(tt.filename)
if err != nil {
t.Fatalf("load test data failed: %s", err)
}
got, err := deriveKey(raw)
if (err != nil) != tt.wantErr {
t.Errorf("deriveKey() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, want) {
t.Errorf("deriveKey() got = %v..., want %v...",
string(got[:32]), string(want[:32]))
}
})
}
}

View File

@@ -0,0 +1,38 @@
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) {
key1, err := qmmac.LoadMacKeysV8(logger)
if err != nil {
key1 = nil
logger.Warn("LoadMMKVOrDefault: could not read QQMusic v8.8.0 keys", zap.Error(err))
}
key2, err := qmmac.LoadMacKeysV10(logger)
if err != nil {
key2 = nil
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)
logger.Debug("Keys loaded", zap.Any("keys", allKeys), zap.Int("len", len(allKeys)))
return allKeys, nil
}

View File

@@ -0,0 +1,13 @@
//go:build !darwin
package qmc
import (
"git.um-react.app/um/cli/algo/common"
"go.uber.org/zap"
)
func LoadMMKVOrDefault(path string, key string, logger *zap.Logger) (result common.QMCKeys, err error) {
// Stub: do nothing
return nil, nil
}

View File

@@ -1,212 +0,0 @@
package qmc
import (
"bytes"
"errors"
"github.com/unlock-music/cli/algo/common"
"github.com/unlock-music/cli/internal/logging"
"go.uber.org/zap"
)
var (
ErrFailToMatchMask = errors.New("can not match at least one key")
ErrTestDataLength = errors.New("invalid length of test file")
ErrMaskLength128 = errors.New("incorrect mask length 128")
ErrMaskLength44 = errors.New("incorrect mask length 44")
ErrMaskDecode = errors.New("decode mask-128 to mask-58 failed")
ErrDetectFlacMask = errors.New("can not detect mflac mask")
ErrDetectMggMask = errors.New("can not detect mgg mask")
)
type Key256Mask struct {
matrix []byte // Mask 128
}
func NewKey256FromMask128(mask128 []byte) (*Key256Mask, error) {
if len(mask128) != 128 {
return nil, ErrMaskLength128
}
q := &Key256Mask{matrix: mask128}
return q, nil
}
func NewKey256FromMask44(mask44 []byte) (*Key256Mask, error) {
mask128, err := convertKey256Mask44to128(mask44)
if err != nil {
return nil, err
}
q := &Key256Mask{matrix: mask128}
return q, nil
}
func (q *Key256Mask) GetMatrix44() ([]byte, error) {
if len(q.matrix) != 128 {
return nil, ErrMaskLength128
}
matrix44 := make([]byte, 44)
idx44 := 0
for _, it256 := range key256MappingAll {
if it256 != nil {
it256Len := len(it256)
for i := 1; i < it256Len; i++ {
if q.matrix[it256[0]] != q.matrix[it256[i]] {
return nil, ErrMaskDecode
}
}
matrix44[idx44] = q.matrix[it256[0]]
idx44++
}
}
return matrix44, nil
}
func (q *Key256Mask) Decrypt(data []byte) []byte {
dst := make([]byte, len(data))
index := -1
maskIdx := -1
for cur := 0; cur < len(data); cur++ {
index++
maskIdx++
if index == 0x8000 || (index > 0x8000 && (index+1)%0x8000 == 0) {
index++
maskIdx++
}
if maskIdx >= 128 {
maskIdx -= 128
}
dst[cur] = data[cur] ^ q.matrix[maskIdx]
}
return dst
}
func convertKey256Mask44to128(mask44 []byte) ([]byte, error) {
if len(mask44) != 44 {
return nil, ErrMaskLength44
}
mask128 := make([]byte, 128)
idx44 := 0
for _, it256 := range key256MappingAll {
if it256 != nil {
for _, idx128 := range it256 {
mask128[idx128] = mask44[idx44]
}
idx44++
}
}
return mask128, nil
}
func getDefaultMask() *Key256Mask {
y, _ := NewKey256FromMask44(defaultKey256Mask44)
return y
}
func detectMflac256Mask(input []byte) (*Key256Mask, error) {
var q *Key256Mask
var rtErr = ErrDetectFlacMask
lenData := len(input)
lenTest := 0x8000
if lenData < 0x8000 {
lenTest = lenData
}
for blockIdx := 0; blockIdx < lenTest; blockIdx += 128 {
var err error
q, err = NewKey256FromMask128(input[blockIdx : blockIdx+128])
if err != nil {
continue
}
if common.SnifferFLAC(q.Decrypt(input[:4])) {
rtErr = nil
break
}
}
return q, rtErr
}
func detectMgg256Mask(input []byte) (*Key256Mask, error) {
if len(input) < 0x100 {
return nil, ErrTestDataLength
}
matrixConf := make([]map[uint8]uint, 44) //meaning: [idx58][value]confidence
for i := uint(0); i < 44; i++ {
matrixConf[i] = make(map[uint8]uint)
}
page2size := input[0x54] ^ input[0xC] ^ oggPublicHeader1[0xC]
spHeader, spConf := generateOggFullHeader(int(page2size))
lenTest := len(spHeader)
for idx128 := 0; idx128 < lenTest; idx128++ {
confidence := spConf[idx128]
if confidence > 0 {
mask := input[idx128] ^ spHeader[idx128]
idx44 := key256Mapping128to44[idx128&0x7f] // equals: [idx128 % 128]
if _, ok2 := matrixConf[idx44][mask]; ok2 {
matrixConf[idx44][mask] += confidence
} else {
matrixConf[idx44][mask] = confidence
}
}
}
matrix := make([]uint8, 44)
var err error
for i := uint(0); i < 44; i++ {
matrix[i], err = decideMgg256MaskItemConf(matrixConf[i])
if err != nil {
return nil, err
}
}
q, err := NewKey256FromMask44(matrix)
if err != nil {
return nil, err
}
if common.SnifferOGG(q.Decrypt(input[:4])) {
return q, nil
}
return nil, ErrDetectMggMask
}
func generateOggFullHeader(pageSize int) ([]byte, []uint) {
spec := make([]byte, pageSize+1)
spec[0], spec[1], spec[pageSize] = uint8(pageSize), 0xFF, 0xFF
for i := 2; i < pageSize; i++ {
spec[i] = 0xFF
}
specConf := make([]uint, pageSize+1)
specConf[0], specConf[1], specConf[pageSize] = 6, 0, 0
for i := 2; i < pageSize; i++ {
specConf[i] = 4
}
allConf := append(oggPublicConfidence1, specConf...)
allConf = append(allConf, oggPublicConfidence2...)
allHeader := bytes.Join(
[][]byte{oggPublicHeader1, spec, oggPublicHeader2},
[]byte{},
)
return allHeader, allConf
}
func decideMgg256MaskItemConf(confidence map[uint8]uint) (uint8, error) {
lenConf := len(confidence)
if lenConf == 0 {
return 0xff, ErrFailToMatchMask
} else if lenConf > 1 {
logging.Log().Warn("there are 2 potential value for the mask", zap.Any("confidence", confidence))
}
result := uint8(0)
conf := uint(0)
for idx, item := range confidence {
if item > conf {
result = idx
conf = item
}
}
return result, nil
}

View File

@@ -1,128 +1,287 @@
package qmc
import (
"encoding/base64"
"bytes"
"encoding/binary"
"errors"
"github.com/unlock-music/cli/algo/common"
)
"fmt"
"io"
"strconv"
"strings"
var (
ErrQmcFileLength = errors.New("invalid qmc file length")
ErrQmcKeyDecodeFailed = errors.New("base64 decode qmc key failed")
ErrQmcKeyLength = errors.New("unexpected decoded qmc key length")
"go.uber.org/zap"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/internal/sniff"
)
type Decoder struct {
file []byte
maskDetector func(encodedData []byte) (*Key256Mask, error)
mask *Key256Mask
audioExt string
key []byte
audio []byte
raw io.ReadSeeker // raw is the original file reader
params *common.DecoderParams
audio io.Reader // audio is the encrypted audio data
audioLen int // audioLen is the audio data length
offset int // offset is the current audio read position
decodedKey []byte // decodedKey is the decoded key for cipher
cipher common.StreamDecoder
songID int
rawMetaExtra2 int
albumID int
albumMediaID string
// cache
meta common.AudioMeta
cover []byte
embeddedCover bool // embeddedCover is true if the cover is embedded in the file
probeBuf *bytes.Buffer // probeBuf is the buffer for sniffing metadata, TODO: consider pipe?
// provider
logger *zap.Logger
}
func NewMflac256Decoder(data []byte) common.Decoder {
return &Decoder{file: data, maskDetector: detectMflac256Mask, audioExt: "flac"}
// Read implements io.Reader, offer the decrypted audio data.
// Validate should call before Read to check if the file is valid.
func (d *Decoder) Read(p []byte) (int, error) {
n, err := d.audio.Read(p)
if n > 0 {
d.cipher.Decrypt(p[:n], d.offset)
d.offset += n
_, _ = d.probeBuf.Write(p[:n]) // bytes.Buffer.Write never return error
}
return n, err
}
func NewMgg256Decoder(data []byte) common.Decoder {
return &Decoder{file: data, maskDetector: detectMgg256Mask, audioExt: "ogg"}
func NewDecoder(p *common.DecoderParams) common.Decoder {
return &Decoder{raw: p.Reader, params: p, logger: p.Logger}
}
func NewQmcCipherDecoder(key []byte) (common.StreamDecoder, error) {
if len(key) > 300 {
return newRC4Cipher(key)
} else if len(key) != 0 {
return newMapCipher(key)
}
return newStaticCipher(), nil
}
func NewQmcCipherDecoderFromEKey(ekey []byte) (common.StreamDecoder, error) {
key, err := deriveKey(ekey)
if err != nil {
return nil, err
}
return NewQmcCipherDecoder(key)
}
func (d *Decoder) Validate() error {
if nil != d.mask {
return nil
}
if nil != d.maskDetector {
if err := d.validateKey(); err != nil {
return err
}
var err error
d.mask, err = d.maskDetector(d.file)
return err
}
return errors.New("no mask or mask detector found")
}
func (d *Decoder) validateKey() error {
lenData := len(d.file)
if lenData < 4 {
return ErrQmcFileLength
}
keyLen := binary.LittleEndian.Uint32(d.file[lenData-4:])
if lenData < int(keyLen+4) {
return ErrQmcFileLength
}
var err error
d.key, err = base64.StdEncoding.DecodeString(
string(d.file[lenData-4-int(keyLen) : lenData-4]))
// search & derive key
err := d.searchKey()
if err != nil {
return ErrQmcKeyDecodeFailed
return err
}
if len(d.key) != 272 {
return ErrQmcKeyLength
// check cipher type and init decode cipher
d.cipher, err = NewQmcCipherDecoder(d.decodedKey)
if err != nil {
return fmt.Errorf("qmc init cipher: %w", err)
}
d.file = d.file[:lenData-4-int(keyLen)]
return nil
}
// test with first 16 bytes
if err := d.validateDecode(); err != nil {
return err
}
// reset position, limit to audio, prepare for Read
if _, err := d.raw.Seek(0, io.SeekStart); err != nil {
return err
}
d.audio = io.LimitReader(d.raw, int64(d.audioLen))
// prepare for sniffing metadata
d.probeBuf = bytes.NewBuffer(make([]byte, 0, d.audioLen))
func (d *Decoder) Decode() error {
d.audio = d.mask.Decrypt(d.file)
return nil
}
func (d Decoder) GetCoverImage() []byte {
func (d *Decoder) validateDecode() error {
_, err := d.raw.Seek(0, io.SeekStart)
if err != nil {
return fmt.Errorf("qmc seek to start: %w", err)
}
buf := make([]byte, 64)
if _, err := io.ReadFull(d.raw, buf); err != nil {
return fmt.Errorf("qmc read header: %w", err)
}
d.cipher.Decrypt(buf, 0)
_, ok := sniff.AudioExtension(buf)
if !ok {
return errors.New("qmc: detect file type failed")
}
return nil
}
func (d Decoder) GetAudioData() []byte {
return d.audio
}
func (d Decoder) GetAudioExt() string {
if d.audioExt != "" {
return "." + d.audioExt
func (d *Decoder) searchKey() (err error) {
fileSizeM4, err := d.raw.Seek(-4, io.SeekEnd)
if err != nil {
return err
}
return ""
}
fileSize := int(fileSizeM4) + 4
func (d Decoder) GetMeta() common.Meta {
if key, ok := d.params.CryptoParams.QmcKeys.Get(d.params.FilePath); ok {
d.logger.Debug("QQMusic Mac Legacy file", zap.String("file", d.params.FilePath), zap.String("key", key))
d.decodedKey, err = deriveKey([]byte(key))
if err == nil {
d.audioLen = fileSize
} else {
d.decodedKey = nil
d.logger.Warn("could not derive key, skip", zap.Error(err))
}
}
suffixBuf := make([]byte, 4)
if _, err := io.ReadFull(d.raw, suffixBuf); err != nil {
return err
}
switch string(suffixBuf) {
case "QTag":
return d.readRawMetaQTag()
case "STag":
return errors.New("qmc: file with 'STag' suffix doesn't contains media key")
// speculative guess for "musicex\0"
case "cex\x00":
footer, err := NewMusicExTag(d.raw)
if err != nil {
return err
}
d.audioLen = fileSize - int(footer.TagSize)
if key, ok := d.params.CryptoParams.QmcKeys.Get(footer.MediaFileName); ok {
d.logger.Debug("searchKey: using key from MediaFileName", zap.String("MediaFileName", footer.MediaFileName), zap.String("key", key))
d.decodedKey, err = deriveKey([]byte(key))
} else if d.decodedKey == nil {
return errors.New("searchKey: no key found for musicex tag")
}
return err
default:
// if we already have a key from legacy mmkv, use it
if d.decodedKey != nil {
return nil
}
// try to use suffix as key length
size := binary.LittleEndian.Uint32(suffixBuf)
if size <= 0xFFFF && size != 0 { // assume size is key len
return d.readRawKey(int64(size))
}
// try to use default static cipher,
// or the key read from the legacy mmkv
d.audioLen = fileSize
return nil
}
}
func DecoderFuncWithExt(ext string) common.NewDecoderFunc {
return func(file []byte) common.Decoder {
return &Decoder{file: file, audioExt: ext, mask: getDefaultMask()}
func (d *Decoder) readRawKey(rawKeyLen int64) error {
audioLen, err := d.raw.Seek(-(4 + rawKeyLen), io.SeekEnd)
if err != nil {
return err
}
d.audioLen = int(audioLen)
rawKeyData, err := io.ReadAll(io.LimitReader(d.raw, rawKeyLen))
if err != nil {
return err
}
// clean suffix NULs
rawKeyData = bytes.TrimRight(rawKeyData, "\x00")
d.decodedKey, err = deriveKey(rawKeyData)
return err
}
func (d *Decoder) readRawMetaQTag() error {
// get raw meta data len
if _, err := d.raw.Seek(-8, io.SeekEnd); err != nil {
return err
}
buf, err := io.ReadAll(io.LimitReader(d.raw, 4))
if err != nil {
return err
}
rawMetaLen := int64(binary.BigEndian.Uint32(buf))
// read raw meta data
audioLen, err := d.raw.Seek(-(8 + rawMetaLen), io.SeekEnd)
if err != nil {
return err
}
d.audioLen = int(audioLen)
rawMetaData, err := io.ReadAll(io.LimitReader(d.raw, rawMetaLen))
if err != nil {
return err
}
items := strings.Split(string(rawMetaData), ",")
if len(items) != 3 {
return errors.New("invalid raw meta data")
}
d.decodedKey, err = deriveKey([]byte(items[0]))
if err != nil {
return err
}
d.songID, err = strconv.Atoi(items[1])
if err != nil {
return err
}
d.rawMetaExtra2, err = strconv.Atoi(items[2])
if err != nil {
return err
}
return nil
}
//goland:noinspection SpellCheckingInspection
func init() {
common.RegisterDecoder("qmc0", false, DecoderFuncWithExt("mp3")) //QQ Music Mp3
common.RegisterDecoder("qmc3", false, DecoderFuncWithExt("mp3")) //QQ Music Mp3
supportedExts := []string{
"qmc0", "qmc3", //QQ Music MP3
"qmc2", "qmc4", "qmc6", "qmc8", //QQ Music M4A
"qmcflac", //QQ Music FLAC
"qmcogg", //QQ Music OGG
common.RegisterDecoder("qmc2", false, DecoderFuncWithExt("m4a")) //QQ Music M4A
common.RegisterDecoder("qmc4", false, DecoderFuncWithExt("m4a")) //QQ Music M4A
common.RegisterDecoder("qmc6", false, DecoderFuncWithExt("m4a")) //QQ Music M4A
common.RegisterDecoder("qmc8", false, DecoderFuncWithExt("m4a")) //QQ Music M4A
"tkm", //QQ Music Accompaniment M4A
common.RegisterDecoder("qmcflac", false, DecoderFuncWithExt("flac")) //QQ Music Flac
common.RegisterDecoder("qmcogg", false, DecoderFuncWithExt("ogg")) //QQ Music Ogg
common.RegisterDecoder("tkm", false, DecoderFuncWithExt("m4a")) //QQ Music Accompaniment M4a
"bkcmp3", "bkcm4a", "bkcflac", "bkcwav", "bkcape", "bkcogg", "bkcwma", //Moo Music
common.RegisterDecoder("bkcmp3", false, DecoderFuncWithExt("mp3")) //Moo Music Mp3
common.RegisterDecoder("bkcflac", false, DecoderFuncWithExt("flac")) //Moo Music Flac
"666c6163", //QQ Music Weiyun Flac
"6d7033", //QQ Music Weiyun Mp3
"6f6767", //QQ Music Weiyun Ogg
"6d3461", //QQ Music Weiyun M4a
"776176", //QQ Music Weiyun Wav
common.RegisterDecoder("666c6163", false, DecoderFuncWithExt("flac")) //QQ Music Weiyun Flac
common.RegisterDecoder("6d7033", false, DecoderFuncWithExt("mp3")) //QQ Music Weiyun Mp3
common.RegisterDecoder("6f6767", false, DecoderFuncWithExt("ogg")) //QQ Music Weiyun Ogg
common.RegisterDecoder("6d3461", false, DecoderFuncWithExt("m4a")) //QQ Music Weiyun M4a
common.RegisterDecoder("776176", false, DecoderFuncWithExt("wav")) //QQ Music Weiyun Wav
"mmp4", // QQ Music MP4 Container, tipically used for Dolby EAC3 stream
}
for _, ext := range supportedExts {
common.RegisterDecoder(ext, false, NewDecoder)
}
common.RegisterDecoder("mgg", false, NewMgg256Decoder) //QQ Music New Ogg
common.RegisterDecoder("mflac", false, NewMflac256Decoder) //QQ Music New Flac
// New ogg/flac:
extraExtsCanHaveSuffix := []string{"mgg", "mflac"}
// Mac also adds some extra suffix to ext:
extraExtSuffix := []string{"0", "1", "a", "h", "l", "m"}
for _, ext := range extraExtsCanHaveSuffix {
common.RegisterDecoder(ext, false, NewDecoder)
for _, suffix := range extraExtSuffix {
common.RegisterDecoder(ext+suffix, false, NewDecoder)
}
}
}

View File

@@ -0,0 +1,94 @@
package qmc
import (
bytes "bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"strings"
)
type MusicExTagV1 struct {
SongID uint32 // Song ID
Unknown1 uint32 // unused & unknown
Unknown2 uint32 // unused & unknown
MediaID string // Media ID
MediaFileName string // real file name
Unknown3 uint32 // unused; uninitialized memory?
// 16 byte at the end of tag.
// TagSize should be respected when parsing.
TagSize uint32 // 19.57: fixed value: 0xC0
TagVersion uint32 // 19.57: fixed value: 0x01
TagMagic []byte // fixed value "musicex\0" (8 bytes)
}
func NewMusicExTag(f io.ReadSeeker) (*MusicExTagV1, error) {
_, err := f.Seek(-16, io.SeekEnd)
if err != nil {
return nil, fmt.Errorf("musicex seek error: %w", err)
}
buffer := make([]byte, 16)
bytesRead, err := f.Read(buffer)
if err != nil {
return nil, fmt.Errorf("get musicex error: %w", err)
}
if bytesRead != 16 {
return nil, fmt.Errorf("MusicExV1: read %d bytes (expected %d)", bytesRead, 16)
}
tag := &MusicExTagV1{
TagSize: binary.LittleEndian.Uint32(buffer[0x00:0x04]),
TagVersion: binary.LittleEndian.Uint32(buffer[0x04:0x08]),
TagMagic: buffer[0x08:],
}
if !bytes.Equal(tag.TagMagic, []byte("musicex\x00")) {
return nil, errors.New("MusicEx magic mismatch")
}
if tag.TagVersion != 1 {
return nil, fmt.Errorf("unsupported musicex tag version. expecting 1, got %d", tag.TagVersion)
}
if tag.TagSize < 0xC0 {
return nil, fmt.Errorf("unsupported musicex tag size. expecting at least 0xC0, got 0x%02x", tag.TagSize)
}
buffer = make([]byte, tag.TagSize)
f.Seek(-int64(tag.TagSize), io.SeekEnd)
bytesRead, err = f.Read(buffer)
if err != nil {
return nil, fmt.Errorf("MusicExV1: Read error %w", err)
}
if uint32(bytesRead) != tag.TagSize {
return nil, fmt.Errorf("MusicExV1: read %d bytes (expected %d)", bytesRead, tag.TagSize)
}
tag.SongID = binary.LittleEndian.Uint32(buffer[0x00:0x04])
tag.Unknown1 = binary.LittleEndian.Uint32(buffer[0x04:0x08])
tag.Unknown2 = binary.LittleEndian.Uint32(buffer[0x08:0x0C])
tag.MediaID = readUnicodeTagName(buffer[0x0C:], 30*2)
tag.MediaFileName = readUnicodeTagName(buffer[0x48:], 50*2)
tag.Unknown3 = binary.LittleEndian.Uint32(buffer[0xAC:0xB0])
return tag, nil
}
// readUnicodeTagName reads a buffer to maxLen.
// reconstruct text by skipping alternate char (ascii chars encoded in UTF-16-LE),
// until finding a zero or reaching maxLen.
func readUnicodeTagName(buffer []byte, maxLen int) string {
builder := strings.Builder{}
for i := 0; i < maxLen; i += 2 {
chr := buffer[i]
if chr != 0 {
builder.WriteByte(chr)
} else {
break
}
}
return builder.String()
}

128
algo/qmc/qmc_meta.go Normal file
View File

@@ -0,0 +1,128 @@
package qmc
import (
"context"
"errors"
"fmt"
"strings"
"github.com/samber/lo"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/algo/qmc/client"
"git.um-react.app/um/cli/internal/ffmpeg"
)
func (d *Decoder) GetAudioMeta(ctx context.Context) (common.AudioMeta, error) {
if d.meta != nil {
return d.meta, nil
}
if d.songID != 0 {
if err := d.getMetaBySongID(ctx); err != nil {
return nil, err
}
return d.meta, nil
}
embedMeta, err := ffmpeg.ProbeReader(ctx, d.probeBuf)
if err != nil {
return nil, fmt.Errorf("qmc[GetAudioMeta] probe reader: %w", err)
}
d.meta = embedMeta
d.embeddedCover = embedMeta.HasAttachedPic()
if !d.embeddedCover && embedMeta.HasMetadata() {
if err := d.searchMetaOnline(ctx, embedMeta); err != nil {
return nil, err
}
return d.meta, nil
}
return d.meta, nil
}
func (d *Decoder) getMetaBySongID(ctx context.Context) error {
c := client.NewQQMusicClient() // todo: use global client
trackInfo, err := c.GetTrackInfo(ctx, d.songID)
if err != nil {
return fmt.Errorf("qmc[GetAudioMeta] get track info: %w", err)
}
d.meta = trackInfo
d.albumID = trackInfo.Album.Id
if trackInfo.Album.Pmid == "" {
d.albumMediaID = trackInfo.Album.Pmid
} else {
d.albumMediaID = trackInfo.Album.Mid
}
return nil
}
func (d *Decoder) searchMetaOnline(ctx context.Context, original common.AudioMeta) error {
c := client.NewQQMusicClient() // todo: use global client
keyword := lo.Compact(append(
[]string{original.GetTitle(), original.GetAlbum()},
original.GetArtists()...),
)
if len(keyword) == 0 {
return errors.New("qmc[searchMetaOnline] no keyword")
}
trackList, err := c.Search(ctx, strings.Join(keyword, " "))
if err != nil {
return fmt.Errorf("qmc[searchMetaOnline] search: %w", err)
}
if len(trackList) == 0 {
return errors.New("qmc[searchMetaOnline] no result")
}
meta := trackList[0]
d.meta = meta
d.albumID = meta.Album.Id
if meta.Album.Pmid == "" {
d.albumMediaID = meta.Album.Pmid
} else {
d.albumMediaID = meta.Album.Mid
}
return nil
}
func (d *Decoder) GetCoverImage(ctx context.Context) ([]byte, error) {
if d.cover != nil {
return d.cover, nil
}
if d.embeddedCover {
img, err := ffmpeg.ExtractAlbumArt(ctx, d.probeBuf)
if err != nil {
return nil, fmt.Errorf("qmc[GetCoverImage] extract album art: %w", err)
}
d.cover = img.Bytes()
return d.cover, nil
}
c := client.NewQQMusicClient() // todo: use global client
var err error
if d.albumMediaID != "" {
d.cover, err = c.AlbumCoverByMediaID(ctx, d.albumMediaID)
if err != nil {
return nil, fmt.Errorf("qmc[GetCoverImage] get cover by media id: %w", err)
}
} else if d.albumID != 0 {
d.cover, err = c.AlbumCoverByID(ctx, d.albumID)
if err != nil {
return nil, fmt.Errorf("qmc[GetCoverImage] get cover by id: %w", err)
}
} else {
return nil, errors.New("qmc[GetAudioMeta] album (or media) id is empty")
}
return d.cover, nil
}

101
algo/qmc/qmc_test.go Normal file
View File

@@ -0,0 +1,101 @@
package qmc
import (
"bytes"
"fmt"
"io"
"os"
"reflect"
"testing"
"git.um-react.app/um/cli/algo/common"
)
func loadTestDataQmcDecoder(filename string) ([]byte, []byte, error) {
encBody, err := os.ReadFile(fmt.Sprintf("./testdata/%s_raw.bin", filename))
if err != nil {
return nil, nil, err
}
encSuffix, err := os.ReadFile(fmt.Sprintf("./testdata/%s_suffix.bin", filename))
if err != nil {
return nil, nil, err
}
target, err := os.ReadFile(fmt.Sprintf("./testdata/%s_target.bin", filename))
if err != nil {
return nil, nil, err
}
return bytes.Join([][]byte{encBody, encSuffix}, nil), target, nil
}
func TestMflac0Decoder_Read(t *testing.T) {
tests := []struct {
name string
fileExt string
wantErr bool
}{
{"mflac0_rc4", ".mflac0", false},
{"mflac_rc4", ".mflac", false},
{"mflac_map", ".mflac", false},
{"mgg_map", ".mgg", false},
{"qmc0_static", ".qmc0", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
raw, target, err := loadTestDataQmcDecoder(tt.name)
if err != nil {
t.Fatal(err)
}
d := NewDecoder(&common.DecoderParams{
Reader: bytes.NewReader(raw),
Extension: tt.fileExt,
})
if err := d.Validate(); err != nil {
t.Errorf("validate file error = %v", err)
}
buf := make([]byte, len(target))
if _, err := io.ReadFull(d, buf); err != nil {
t.Errorf("read bytes from decoder error = %v", err)
return
}
if !reflect.DeepEqual(buf, target) {
t.Errorf("Decrypt() got = %v, want %v", buf[:32], target[:32])
}
})
}
}
func TestMflac0Decoder_Validate(t *testing.T) {
tests := []struct {
name string
fileExt string
wantErr bool
}{
{"mflac0_rc4", ".flac", false},
{"mflac_map", ".flac", false},
{"mgg_map", ".ogg", false},
{"qmc0_static", ".mp3", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
raw, _, err := loadTestDataQmcDecoder(tt.name)
if err != nil {
t.Fatal(err)
}
d := NewDecoder(&common.DecoderParams{
Reader: bytes.NewReader(raw),
Extension: tt.fileExt,
})
if err := d.Validate(); err != nil {
t.Errorf("read bytes from decoder error = %v", err)
return
}
})
}
}

View File

@@ -0,0 +1,163 @@
package qmmac
import (
"crypto/md5"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/internal/mmkv"
"go.uber.org/zap"
)
var _RE_UDID_V10 = regexp.MustCompile(`_\x10\(([0-9a-f]{40})`)
type QQMusicMacV10 struct {
logger *zap.Logger
mmkv_dir string
}
func (q *QQMusicMacV10) extractUdids(data []byte) ([]string, error) {
var result []string
for _, match := range _RE_UDID_V10.FindAllSubmatch(data, -1) {
udid := string(match[1])
q.logger.Debug("extractUdids: found udid", zap.String("udid", udid))
result = append(result, udid)
}
return result, nil
}
func (q *QQMusicMacV10) caesar(text string, shift int) string {
var result strings.Builder
for _, char := range []byte(text) {
var transformed byte
if 'A' <= char && char <= 'Z' {
transformed = (char-'A'+byte(shift))%26 + 'A'
} else if 'a' <= char && char <= 'z' {
transformed = (char-'a'+byte(shift))%26 + 'a'
} else if '0' <= char && char <= '9' {
transformed = (char-'0'+byte(shift))%10 + '0'
} else {
transformed = char
}
result.WriteByte(transformed)
}
return result.String()
}
func (q *QQMusicMacV10) mmkv(udid string, id int) (path string, key string, err error) {
str1 := q.caesar(udid, id+3)
int1, err := strconv.ParseInt(udid[5:7], 16, 32)
if err != nil {
return "", "", fmt.Errorf("getMmkv: could not parse udid: %w", err)
}
int2 := 5 + (int(int1)+id)%4
name := str1[:int2]
path = filepath.Join(q.mmkv_dir, name)
int3 := id + 0xa546
str3 := fmt.Sprintf("%s%04x", udid, int3)
hash := md5.Sum([]byte(str3))
key = fmt.Sprintf("%x", hash)[:16]
return path, key, nil
}
func (q *QQMusicMacV10) loadByPList(plist_path string) ([]common.QMCKeys, error) {
logger := q.logger.With(zap.String("plist", plist_path))
logger.Debug("loadMacKeysV10: load key from plist")
if f, err := os.Stat(plist_path); err != nil || f.IsDir() {
logger.Debug("loadMacKeysV10: plist not found")
return nil, nil
}
plist_data, err := os.ReadFile(plist_path)
if err != nil {
logger.Warn("loadMacKeysV10: could not read plist", zap.Error(err))
return nil, fmt.Errorf("loadMacKeysV10: could not read plist: %w", err)
}
udids, err := q.extractUdids(plist_data)
if err != nil {
logger.Warn("loadMacKeysV10: could not extract udid", zap.Error(err))
return nil, fmt.Errorf("loadMacKeysV10: could not extract udid: %w", err)
}
logger.Debug("loadMacKeysV10: read udid", zap.Strings("udids", udids))
var keysList []common.QMCKeys
for _, udid := range udids {
keys, err := q.loadByUDID(udid, logger)
if err != nil {
logger.Warn("loadMacKeysV10: could not load by udid", zap.String("udid", udid), zap.Error(err))
continue
}
keysList = append(keysList, keys)
}
return keysList, nil
}
func (q *QQMusicMacV10) loadByUDID(udid string, logger *zap.Logger) (common.QMCKeys, error) {
mmkv_path, mmkv_key, err := q.mmkv(udid, 1)
if err != nil {
logger.Warn("loadMacKeysV10: could not get mmkv name/key", zap.Error(err))
return nil, fmt.Errorf("loadMacKeysV10: could not get mmkv name/key: %w", err)
}
logger.Info("Using QQMusic 10.x mmkv", zap.String("mmkv", mmkv_path))
keys, err := mmkv.LoadFromPath(mmkv_path, mmkv_key, logger)
if err != nil {
logger.Warn("loadMacKeysV10: could not load mmkv", zap.String("mmkv", mmkv_path), zap.Error(err))
return nil, fmt.Errorf("loadMacKeysV10: could not load mmkv: %w", err)
}
return keys, nil
}
func LoadMacKeysV10(logger *zap.Logger) (common.QMCKeys, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
logger.Warn("Failed to get home dir")
return nil, fmt.Errorf("loadMacKeysV10: failed to get home: %w", err)
}
// MMKV dir is always inside the sandbox container
mmkv_dir := filepath.Join(
homeDir,
"Library/Containers/com.tencent.QQMusicMac/Data/",
"Library/Application Support/QQMusicMac/iData",
)
if f, err := os.Stat(mmkv_dir); err != nil || !f.IsDir() {
logger.Debug("loadMacKeysV10: mmkv dir not found", zap.String("mmkv_dir", mmkv_dir))
return nil, nil
}
// without sandbox
plist_without_sandbox := filepath.Join(
homeDir,
"Library/Preferences/com.tencent.QQMusicMac.plist",
)
// with sandbox (e.g. from App Store)
plist_sandboxed := filepath.Join(
homeDir,
"Library/Containers/com.tencent.QQMusicMac/Data/",
"Library/Preferences/com.tencent.QQMusicMac.plist",
)
q := QQMusicMacV10{
logger: logger,
mmkv_dir: mmkv_dir,
}
keys1, err := q.loadByPList(plist_without_sandbox)
if err != nil {
return nil, err
}
keys2, err := q.loadByPList(plist_sandboxed)
if err != nil {
return nil, err
}
return mmkv.Merge(append(keys1, keys2...)...), nil
}

View File

@@ -0,0 +1,30 @@
package qmmac
import (
"fmt"
"os"
"path/filepath"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/internal/mmkv"
"go.uber.org/zap"
)
func LoadMacKeysV8(logger *zap.Logger) (keys common.QMCKeys, err error) {
homeDir, err := os.UserHomeDir()
if err != nil {
logger.Warn("Failed to get home dir")
return nil, fmt.Errorf("loadMacKeysV8: failed to get home: %w", err)
}
p := filepath.Join(
homeDir,
"Library/Containers/com.tencent.QQMusicMac/Data",
"Library/Application Support/QQMusicMac/mmkv",
"MMKVStreamEncryptId",
)
if f, err := os.Stat(p); err == nil && !f.IsDir() {
logger.Info("Using QQMusic 8.x mmkv", zap.String("mmkv", p))
return mmkv.LoadFromPath(p, "", logger)
}
return nil, nil
}

1
algo/qmc/testdata/mflac0_rc4_key.bin vendored Normal file
View File

@@ -0,0 +1 @@
dRzX3p5ZYqAlp7lLSs9Zr0rw1iEZy23bB670x4ch2w97x14Zwpk1UXbKU4C2sOS7uZ0NB5QM7ve9GnSrr2JHxP74hVNONwVV77CdOOVb807317KvtI5Yd6h08d0c5W88rdV46C235YGDjUSZj5314YTzy0b6vgh4102P7E273r911Nl464XV83Hr00rkAHkk791iMGSJH95GztN28u2Nv5s9Xx38V69o4a8aIXxbx0g1EM0623OEtbtO9zsqCJfj6MhU7T8iVS6M3q19xhq6707E6r7wzPO6Yp4BwBmgg4F95Lfl0vyF7YO6699tb5LMnr7iFx29o98hoh3O3Rd8h9Juu8P1wG7vdnO5YtRlykhUluYQblNn7XwjBJ53HAyKVraWN5dG7pv7OMl1s0RykPh0p23qfYzAAMkZ1M422pEd07TA9OCKD1iybYxWH06xj6A8mzmcnYGT9P1a5Ytg2EF5LG3IknL2r3AUz99Y751au6Cr401mfAWK68WyEBe5

View File

@@ -0,0 +1 @@
ZFJ6WDNwNVrjEJZB1o6QjkQV2ZbHSw/2Eb00q1+4z9SVWYyFWO1PcSQrJ5326ubLklmk2ab3AEyIKNUu8DFoAoAc9dpzpTmc+pdkBHjM/bW2jWx+dCyC8vMTHE+DHwaK14UEEGW47ZXMDi7PRCQ2Jpm/oXVdHTIlyrc+bRmKfMith0L2lFQ+nW8CCjV6ao5ydwkZhhNOmRdrCDcUXSJH9PveYwra9/wAmGKWSs9nemuMWKnbjp1PkcxNQexicirVTlLX7PVgRyFyzNyUXgu+R2S4WTmLwjd8UsOyW/dc2mEoYt+vY2lq1X4hFBtcQGOAZDeC+mxrN0EcW8tjS6P4TjOjiOKNMxIfMGSWkSKL3H7z5K7nR1AThW20H2bP/LcpsdaL0uZ/js1wFGpdIfFx9rnLC78itL0WwDleIqp9TBMX/NwakGgIPIbjBwfgyD8d8XKYuLEscIH0ZGdjsadB5XjybgdE3ppfeFEcQiqpnodlTaQRm3KDIF9ATClP0mTl8XlsSojsZ468xseS1Ib2iinx/0SkK3UtJDwp8DH3/+ELisgXd69Bf0pve7wbrQzzMUs9/Ogvvo6ULsIkQfApJ8cSegDYklzGXiLNH7hZYnXDLLSNejD7NvQouULSmGsBbGzhZ5If0NP/6AhSbpzqWLDlabTDgeWWnFeZpBnlK6SMxo+YFFk1Y0XLKsd69+jj

BIN
algo/qmc/testdata/mflac0_rc4_raw.bin vendored Normal file

Binary file not shown.

BIN
algo/qmc/testdata/mflac0_rc4_suffix.bin vendored Normal file

Binary file not shown.

BIN
algo/qmc/testdata/mflac0_rc4_target.bin vendored Normal file

Binary file not shown.

1
algo/qmc/testdata/mflac_map_key.bin vendored Normal file
View File

@@ -0,0 +1 @@
yw7xWOyNQ8585Jwx3hjB49QLPKi38F89awnrQ0fq66NT9TDq1ppHNrFqhaDrU5AFk916D58I53h86304GqOFCCyFzBem68DqiXJ81bILEQwG3P3MOnoNzM820kNW9Lv9IJGNn9Xo497p82BLTm4hAX8JLBs0T2pilKvT429sK9jfg508GSk4d047Jxdz5Fou4aa33OkyFRBU3x430mgNBn04Lc9BzXUI2IGYXv3FGa9qE4Vb54kSjVv8ogbg47j3

View File

@@ -0,0 +1 @@
eXc3eFdPeU6+3f7GVeF35bMpIEIQj5JWOWt7G+jsR68Hx3BUFBavkTQ8dpPdP0XBIwPe+OfdsnTGVQqPyg3GCtQSrkgA0mwSQdr4DPzKLkEZFX+Cf1V6ChyipOuC6KT37eAxWMdV1UHf9/OCvydr1dc6SWK1ijRUcP6IAHQhiB+mZLay7XXrSPo32WjdBkn9c9sa2SLtI48atj5kfZ4oOq6QGeld2JA3Z+3wwCe6uTHthKaEHY8ufDYodEe3qqrjYpzkdx55pCtxCQa1JiNqFmJigWm4m3CDzhuJ7YqnjbD+mXxLi7BP1+z4L6nccE2h+DGHVqpGjR9+4LBpe4WHB4DrAzVp2qQRRQJxeHd1v88=

BIN
algo/qmc/testdata/mflac_map_raw.bin vendored Normal file

Binary file not shown.

BIN
algo/qmc/testdata/mflac_map_suffix.bin vendored Normal file

Binary file not shown.

BIN
algo/qmc/testdata/mflac_map_target.bin vendored Normal file

Binary file not shown.

1
algo/qmc/testdata/mflac_rc4_key.bin vendored Normal file
View File

@@ -0,0 +1 @@
pUtyvqr0TgAvR95mNmY7DmNl386TsJNAEIz95CEcgIgJCcs28686O7llxD5E74ldn70xMtd5cG58TA5ILw09I8BOTf5EdHKd6wwPn689DUK13y3Req6H0P33my2miJ5bQ2AA22B8vp4V0NJ3hBqNtFf7cId48V6W51e1kwgu1xKKawxe9BByT92MFlqrFaKH32dB2zFgyd38l2P1outr4l2XLq48F9G17ptRz4W8Loxu28RvZgv0BzL26Ht9I2L5VCwMzzt7OeZ55iQs40Tr6k81QGraIUJj5zeBMgJRMTaSgi19hU5x5a08Qd662MbFhZZ0FjVvaDy1nbIDhrC62c1lX6wf70O45h4W42VxloBVeZ9Sef4V7cWrjrEjj3DJ5w2iu6Q9uoal2f4390kue42Um5HcDFWqv3m56k6O89bRV424PaRra1k9Cd2L56IN2zfBYqNo2WP5VC68G8w1hfflOY0O52h4WdcpoHSjZm4b35N7l47dT4dwEXj1U4J5

View File

@@ -0,0 +1 @@
cFV0eXZxcjAF/IXJ9qJT1u5C3S5AgY9BoVtIQNBKfxQMt5hH7BF36ndIJGV5L6qw5h4G0IOIOOewdHmMCNfKJftHM4nv3B0iRlSdqJKdL08wO3sV0v8eZk0OiYAlxgseGcBquQWYS/0b5Lj/Ioi2NfpOthAY9vUiRPnfH3+7/2AJGudHjj4Gg1KkpPW3mXIKbsk+Ou9fhrUqs873BCdsmI6qRmVNhOkLaUcbG6Zin3XU0WkgnnjebR43S8N4bw5BTphFvhy42QvspnD7Ewb1tVZQMQ2N1s38nBjukdfCB9R6aRwITOvg2U7Lr0RjLpbrIn6A6iVilpINjK4VptuKUTlpDXQwgCjoqeHQaHNCWgYpdjB69lXn8km/BfzK7QyDbh0VgTikwAHF9tvPhin3AIDRcU0xsaWYKURRfJelX3pSN495ADlhXdEKL/+l60hVnY7t6iCMxJL3lOtdGtdUYUGUCc76PB1fX+0HTWCcfcwvXTEdczr9J1h2yTeJNqFQ5pNy8vX7Ws8k7vDQVFkw4llZjPhb0kg9aDNePTNIKSGwy/7eofrcUQlC9DI+qqqwQ5abA/93fNsPq6XU3uwawnrbBsdz8DDdjJiEDI7abkPIDIfr/uR0YzgBxW90t5bt6xAtuW+VSYAM7kGxI3RZTl7JgOT60MLyIWkYASrRhRPMGks8zL10ED/4yGTEB1nt

BIN
algo/qmc/testdata/mflac_rc4_raw.bin vendored Normal file

Binary file not shown.

BIN
algo/qmc/testdata/mflac_rc4_suffix.bin vendored Normal file

Binary file not shown.

BIN
algo/qmc/testdata/mflac_rc4_target.bin vendored Normal file

Binary file not shown.

1
algo/qmc/testdata/mgg_map_key.bin vendored Normal file
View File

@@ -0,0 +1 @@
zGxNk54pKJ0hDkAo80wHE80ycSWQ7z4m4E846zVy2sqCn14F42Y5S7GqeR11WpOV75sDLbE5dFP992t88l0pHy1yAQ49YK6YX6c543drBYLo55Hc4Y0Fyic6LQPiGqu2bG31r8vaq9wS9v63kg0X5VbnOD6RhO4t0RRhk3ajrA7p0iIy027z0L70LZjtw6E18H0D41nz6ASTx71otdF9z1QNC0JmCl51xvnb39zPExEXyKkV47S6QsK5hFh884QJ

1
algo/qmc/testdata/mgg_map_key_raw.bin vendored Normal file
View File

@@ -0,0 +1 @@
ekd4Tms1NHC53JEDO/AKVyF+I0bj0hHB7CZeoLDGSApaQB9Oo/pJTBGA/RO+nk5RXLXdHsffLiY4e8kt3LNo6qMl7S89vkiSFxx4Uoq4bGDJ7Jc+bYL6lLsa3M4sBvXS4XcPChrMDz+LmrJMGG6ua2fYyIz1d6TCRUBf1JJgCIkBbDAEeMVYc13qApitiz/apGAPmAnveCaDhfD5GxWsF+RfQ2OcnvrnIXe80Feh/0jx763DlsOBI3eIede6t5zYHokWkZmVEF1jMrnlvsgbQK2EzUWMblmLMsTKNILyZazEoKUyulqmyLO/c/KYE+USPOXPcbjlYFmLhSGHK7sQB5aBR153Yp+xh61ooh2NGAA=

BIN
algo/qmc/testdata/mgg_map_raw.bin vendored Normal file

Binary file not shown.

BIN
algo/qmc/testdata/mgg_map_suffix.bin vendored Normal file

Binary file not shown.

BIN
algo/qmc/testdata/mgg_map_target.bin vendored Normal file

Binary file not shown.

BIN
algo/qmc/testdata/qmc0_static_raw.bin vendored Normal file

Binary file not shown.

View File

BIN
algo/qmc/testdata/qmc0_static_target.bin vendored Normal file

Binary file not shown.

View File

@@ -3,77 +3,56 @@ package tm
import (
"bytes"
"errors"
"github.com/unlock-music/cli/algo/common"
"fmt"
"io"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/internal/sniff"
)
var replaceHeader = []byte{0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70}
var magicHeader = []byte{0x51, 0x51, 0x4D, 0x55} //0x15, 0x1D, 0x1A, 0x21
type Decoder struct {
file []byte
audio []byte
headerMatch bool
audioExt string
}
raw io.ReadSeeker // raw is the original file reader
func (d *Decoder) GetCoverImage() []byte {
return nil
}
func (d *Decoder) GetAudioData() []byte {
return d.audio
}
func (d *Decoder) GetAudioExt() string {
if d.audioExt != "" {
return "." + d.audioExt
}
return ""
}
func (d *Decoder) GetMeta() common.Meta {
return nil
offset int
audio io.Reader // audio is the decrypted audio data
}
func (d *Decoder) Validate() error {
if len(d.file) < 8 {
return errors.New("invalid file size")
header := make([]byte, 8)
if _, err := io.ReadFull(d.raw, header); err != nil {
return fmt.Errorf("tm read header: %w", err)
}
if !bytes.Equal(magicHeader, d.file[:4]) {
return errors.New("not a valid tm file")
}
d.headerMatch = true
if bytes.Equal(magicHeader, header[:len(magicHeader)]) { // replace m4a header
d.audio = io.MultiReader(bytes.NewReader(replaceHeader), d.raw)
return nil
}
}
func (d *Decoder) Decode() error {
d.audio = d.file
if d.headerMatch {
for i := 0; i < 8; i++ {
d.audio[i] = replaceHeader[i]
}
d.audioExt = "m4a"
}
if _, ok := sniff.AudioExtension(header); ok { // not encrypted
d.audio = io.MultiReader(bytes.NewReader(header), d.raw)
return nil
}
//goland:noinspection GoUnusedExportedFunction
func NewDecoder(data []byte) common.Decoder {
return &Decoder{file: data}
}
func DecoderFuncWithExt(ext string) common.NewDecoderFunc {
return func(file []byte) common.Decoder {
return &Decoder{file: file, audioExt: ext}
}
return errors.New("tm: valid magic header")
}
func (d *Decoder) Read(buf []byte) (int, error) {
return d.audio.Read(buf)
}
func NewTmDecoder(p *common.DecoderParams) common.Decoder {
return &Decoder{raw: p.Reader}
}
func init() {
// QQ Music IOS M4a
common.RegisterDecoder("tm2", false, DecoderFuncWithExt("m4a"))
common.RegisterDecoder("tm6", false, DecoderFuncWithExt("m4a"))
// QQ Music IOS Mp3
common.RegisterDecoder("tm0", false, common.NewRawDecoder)
common.RegisterDecoder("tm3", false, common.NewRawDecoder)
// QQ Music IOS M4a (replace header)
common.RegisterDecoder("tm2", false, NewTmDecoder)
common.RegisterDecoder("tm6", false, NewTmDecoder)
// QQ Music IOS Mp3 (not encrypted)
common.RegisterDecoder("tm0", false, NewTmDecoder)
common.RegisterDecoder("tm3", false, NewTmDecoder)
}

91
algo/xiami/xm.go Normal file
View File

@@ -0,0 +1,91 @@
package xiami
import (
"bytes"
"errors"
"fmt"
"io"
"git.um-react.app/um/cli/algo/common"
)
var (
magicHeader = []byte{'i', 'f', 'm', 't'}
magicHeader2 = []byte{0xfe, 0xfe, 0xfe, 0xfe}
typeMapping = map[string]string{
" WAV": "wav",
"FLAC": "flac",
" MP3": "mp3",
" A4M": "m4a",
}
ErrMagicHeader = errors.New("xm magic header not matched")
)
type Decoder struct {
rd io.ReadSeeker // rd is the original file reader
offset int
cipher common.StreamDecoder
outputExt string
}
func (d *Decoder) GetAudioExt() string {
if d.outputExt != "" {
return "." + d.outputExt
}
return ""
}
func NewDecoder(p *common.DecoderParams) common.Decoder {
return &Decoder{rd: p.Reader}
}
// Validate checks if the file is a valid xiami .xm file.
// rd will set to the beginning of the encrypted audio data.
func (d *Decoder) Validate() error {
header := make([]byte, 16) // xm header is fixed to 16 bytes
if _, err := io.ReadFull(d.rd, header); err != nil {
return fmt.Errorf("xm read header: %w", err)
}
// 0x00 - 0x03 and 0x08 - 0x0B: magic header
if !bytes.Equal(magicHeader, header[:4]) || !bytes.Equal(magicHeader2, header[8:12]) {
return ErrMagicHeader
}
// 0x04 - 0x07: Audio File Type
var ok bool
d.outputExt, ok = typeMapping[string(header[4:8])]
if !ok {
return fmt.Errorf("xm detect unknown audio type: %s", string(header[4:8]))
}
// 0x0C - 0x0E, Encrypt Start At, LittleEndian Unit24
encStartAt := uint32(header[12]) | uint32(header[13])<<8 | uint32(header[14])<<16
// 0x0F, XOR Mask
d.cipher = newXmCipher(header[15], int(encStartAt))
return nil
}
func (d *Decoder) Read(p []byte) (int, error) {
n, err := d.rd.Read(p)
if n > 0 {
d.cipher.Decrypt(p[:n], d.offset)
d.offset += n
}
return n, err
}
func init() {
// Xiami Wav/M4a/Mp3/Flac
common.RegisterDecoder("xm", false, NewDecoder)
// Xiami Typed Format
common.RegisterDecoder("wav", false, NewDecoder)
common.RegisterDecoder("mp3", false, NewDecoder)
common.RegisterDecoder("flac", false, NewDecoder)
common.RegisterDecoder("m4a", false, NewDecoder)
}

21
algo/xiami/xm_cipher.go Normal file
View File

@@ -0,0 +1,21 @@
package xiami
type xmCipher struct {
mask byte
encryptStartAt int
}
func newXmCipher(mask byte, encryptStartAt int) *xmCipher {
return &xmCipher{
mask: mask,
encryptStartAt: encryptStartAt,
}
}
func (c *xmCipher) Decrypt(buf []byte, offset int) {
for i := 0; i < len(buf); i++ {
if offset+i >= c.encryptStartAt {
buf[i] ^= c.mask
}
}
}

View File

@@ -0,0 +1,34 @@
package ximalaya
import (
_ "embed"
"encoding/binary"
)
const x2mHeaderSize = 1024
var x2mKey = [...]byte{'x', 'm', 'l', 'y'}
var x2mScrambleTable = [x2mHeaderSize]uint16{}
//go:embed x2m_scramble_table.bin
var x2mScrambleTableBytes []byte
func init() {
if len(x2mScrambleTableBytes) != 2*x2mHeaderSize {
panic("invalid x2m scramble table")
}
for i := range x2mScrambleTable {
x2mScrambleTable[i] = binary.LittleEndian.Uint16(x2mScrambleTableBytes[i*2:])
}
}
// decryptX2MHeader decrypts the header of ximalaya .x2m file.
// make sure input src is 1024(x2mHeaderSize) bytes long.
func decryptX2MHeader(src []byte) []byte {
dst := make([]byte, len(src))
for dstIdx := range src {
srcIdx := x2mScrambleTable[dstIdx]
dst[dstIdx] = src[srcIdx] ^ x2mKey[dstIdx%len(x2mKey)]
}
return dst
}

Binary file not shown.

View File

@@ -0,0 +1,40 @@
package ximalaya
import (
_ "embed"
"encoding/binary"
)
var x3mKey = [...]byte{
'3', '9', '8', '9', 'd', '1', '1', '1',
'a', 'a', 'd', '5', '6', '1', '3', '9',
'4', '0', 'f', '4', 'f', 'c', '4', '4',
'b', '6', '3', '9', 'b', '2', '9', '2',
}
const x3mHeaderSize = 1024
var x3mScrambleTable = [x3mHeaderSize]uint16{}
//go:embed x3m_scramble_table.bin
var x3mScrambleTableBytes []byte
func init() {
if len(x3mScrambleTableBytes) != 2*x3mHeaderSize {
panic("invalid x3m scramble table")
}
for i := range x3mScrambleTable {
x3mScrambleTable[i] = binary.LittleEndian.Uint16(x3mScrambleTableBytes[i*2:])
}
}
// decryptX3MHeader decrypts the header of ximalaya .x3m file.
// make sure input src is 1024 (x3mHeaderSize) bytes long.
func decryptX3MHeader(src []byte) []byte {
dst := make([]byte, len(src))
for dstIdx := range src {
srcIdx := x3mScrambleTable[dstIdx]
dst[dstIdx] = src[srcIdx] ^ x3mKey[dstIdx%len(x3mKey)]
}
return dst
}

Binary file not shown.

57
algo/ximalaya/ximalaya.go Normal file
View File

@@ -0,0 +1,57 @@
package ximalaya
import (
"bytes"
"fmt"
"io"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/internal/sniff"
)
type Decoder struct {
rd io.ReadSeeker
offset int
audio io.Reader
}
func NewDecoder(p *common.DecoderParams) common.Decoder {
return &Decoder{rd: p.Reader}
}
func (d *Decoder) Validate() error {
encryptedHeader := make([]byte, x2mHeaderSize)
if _, err := io.ReadFull(d.rd, encryptedHeader); err != nil {
return fmt.Errorf("ximalaya read header: %w", err)
}
{ // try to decode with x2m
header := decryptX2MHeader(encryptedHeader)
if _, ok := sniff.AudioExtension(header); ok {
d.audio = io.MultiReader(bytes.NewReader(header), d.rd)
return nil
}
}
{ // try to decode with x3m
// not read file again, since x2m and x3m have the same header size
header := decryptX3MHeader(encryptedHeader)
if _, ok := sniff.AudioExtension(header); ok {
d.audio = io.MultiReader(bytes.NewReader(header), d.rd)
return nil
}
}
return fmt.Errorf("ximalaya: unknown format")
}
func (d *Decoder) Read(p []byte) (n int, err error) {
return d.audio.Read(p)
}
func init() {
common.RegisterDecoder("x2m", false, NewDecoder)
common.RegisterDecoder("x3m", false, NewDecoder)
common.RegisterDecoder("xm", false, NewDecoder)
}

View File

@@ -1,106 +0,0 @@
package xm
import (
"bytes"
"errors"
"github.com/unlock-music/cli/algo/common"
"github.com/unlock-music/cli/internal/logging"
"go.uber.org/zap"
)
var (
magicHeader = []byte{'i', 'f', 'm', 't'}
magicHeader2 = []byte{0xfe, 0xfe, 0xfe, 0xfe}
typeMapping = map[string]string{
" WAV": "wav",
"FLAC": "flac",
" MP3": "mp3",
" A4M": "m4a",
}
ErrFileSize = errors.New("xm invalid file size")
ErrMagicHeader = errors.New("xm magic header not matched")
)
type Decoder struct {
file []byte
headerLen uint32
outputExt string
mask byte
audio []byte
}
func (d *Decoder) GetCoverImage() []byte {
return nil
}
func (d *Decoder) GetAudioData() []byte {
return d.audio
}
func (d *Decoder) GetAudioExt() string {
if d.outputExt != "" {
return "." + d.outputExt
}
return ""
}
func (d *Decoder) GetMeta() common.Meta {
return nil
}
func NewDecoder(data []byte) common.Decoder {
return &Decoder{file: data}
}
func (d *Decoder) Validate() error {
lenData := len(d.file)
if lenData < 16 {
return ErrFileSize
}
if !bytes.Equal(magicHeader, d.file[:4]) ||
!bytes.Equal(magicHeader2, d.file[8:12]) {
return ErrMagicHeader
}
var ok bool
d.outputExt, ok = typeMapping[string(d.file[4:8])]
if !ok {
return errors.New("detect unknown xm file type: " + string(d.file[4:8]))
}
if d.file[14] != 0 {
logging.Log().Warn("not a simple xm file", zap.Uint8("b[14]", d.file[14]))
}
d.headerLen = uint32(d.file[12]) | uint32(d.file[13])<<8 | uint32(d.file[14])<<16 // LittleEndian Unit24
if d.headerLen+16 > uint32(lenData) {
return ErrFileSize
}
return nil
}
func (d *Decoder) Decode() error {
d.mask = d.file[15]
d.audio = d.file[16:]
dataLen := uint32(len(d.audio))
for i := d.headerLen; i < dataLen; i++ {
d.audio[i] = ^(d.audio[i] - d.mask)
}
return nil
}
func DecoderFuncWithExt(ext string) common.NewDecoderFunc {
return func(file []byte) common.Decoder {
return &Decoder{file: file, outputExt: ext}
}
}
func init() {
// Xiami Wav/M4a/Mp3/Flac
common.RegisterDecoder("xm", false, NewDecoder)
// Xiami Typed Format
common.RegisterDecoder("wav", false, DecoderFuncWithExt("wav"))
common.RegisterDecoder("mp3", false, DecoderFuncWithExt("mp3"))
common.RegisterDecoder("flac", false, DecoderFuncWithExt("flac"))
common.RegisterDecoder("m4a", false, DecoderFuncWithExt("m4a"))
}

View File

@@ -1,58 +1,134 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"github.com/unlock-music/cli/algo/common"
_ "github.com/unlock-music/cli/algo/kgm"
_ "github.com/unlock-music/cli/algo/kwm"
_ "github.com/unlock-music/cli/algo/ncm"
_ "github.com/unlock-music/cli/algo/qmc"
_ "github.com/unlock-music/cli/algo/tm"
_ "github.com/unlock-music/cli/algo/xm"
"github.com/unlock-music/cli/internal/logging"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
"io"
"os"
"os/signal"
"path/filepath"
"runtime"
"runtime/debug"
"sort"
"strings"
"time"
"git.um-react.app/um/cli/algo/common"
_ "git.um-react.app/um/cli/algo/kgm"
_ "git.um-react.app/um/cli/algo/kwm"
_ "git.um-react.app/um/cli/algo/ncm"
"git.um-react.app/um/cli/algo/qmc"
_ "git.um-react.app/um/cli/algo/tm"
_ "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/sniff"
"git.um-react.app/um/cli/internal/utils"
"github.com/fsnotify/fsnotify"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var AppVersion = "v0.0.6"
var AppVersion = "custom"
var logger = setupLogger(false) // TODO: inject logger to application, instead of using global logger
func main() {
module, ok := debug.ReadBuildInfo()
if ok && module.Main.Version != "(devel)" {
AppVersion = module.Main.Version
}
app := cli.App{
Name: "Unlock Music CLI",
HelpName: "um",
Usage: "Unlock your encrypted music file https://github.com/unlock-music/cli",
Usage: "Unlock your encrypted music file https://git.um-react.app/um/cli",
Version: fmt.Sprintf("%s (%s,%s/%s)", AppVersion, runtime.Version(), runtime.GOOS, runtime.GOARCH),
Flags: []cli.Flag{
&cli.StringFlag{Name: "input", Aliases: []string{"i"}, Usage: "path to input file or 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-key", Aliases: []string{"key"}, Usage: "QQMusic mmkv password (16 ascii chars)", 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: "skip-noop", Aliases: []string{"n"}, Usage: "skip noop decoder", Required: false, Value: true},
&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},
&cli.BoolFlag{Name: "watch", Usage: "watch the input dir and process new files", Required: false, Value: false},
&cli.BoolFlag{Name: "supported-ext", Usage: "show supported file extensions and exit", Required: false, Value: false},
},
Action: appMain,
Copyright: "Copyright (c) 2020 - 2021 Unlock Music https://github.com/unlock-music/cli/blob/master/LICENSE",
Copyright: fmt.Sprintf("Copyright (c) 2020 - %d Unlock Music https://git.um-react.app/um/cli/src/branch/main/LICENSE", time.Now().Year()),
HideHelpCommand: true,
UsageText: "um [-o /path/to/output/dir] [--extra-flags] [-i] /path/to/input",
}
err := app.Run(os.Args)
if err != nil {
logging.Log().Fatal("run app failed", zap.Error(err))
logger.Fatal("run app failed", zap.Error(err))
}
}
func printSupportedExtensions() {
var exts []string
extSet := make(map[string]int)
for _, factory := range common.DecoderRegistry {
ext := strings.TrimPrefix(factory.Suffix, ".")
if n, ok := extSet[ext]; ok {
extSet[ext] = n + 1
} else {
extSet[ext] = 1
}
}
for ext := range extSet {
exts = append(exts, ext)
}
sort.Strings(exts)
for _, ext := range exts {
fmt.Printf("%s: %d\n", ext, extSet[ext])
}
}
func setupLogger(verbose bool) *zap.Logger {
logConfig := zap.NewProductionEncoderConfig()
logConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
logConfig.EncodeTime = zapcore.RFC3339TimeEncoder
enabler := zap.LevelEnablerFunc(func(level zapcore.Level) bool {
if verbose {
return true
}
return level >= zapcore.InfoLevel
})
return zap.New(zapcore.NewCore(
zapcore.NewConsoleEncoder(logConfig),
os.Stdout,
enabler,
))
}
func appMain(c *cli.Context) (err error) {
logger = setupLogger(c.Bool("verbose"))
cwd, err := os.Getwd()
if err != nil {
return err
}
if c.Bool("supported-ext") {
printSupportedExtensions()
return nil
}
input := c.String("input")
if input == "" {
switch c.Args().Len() {
case 0:
input, err = os.Getwd()
if err != nil {
return err
}
input = cwd
case 1:
input = c.Args().Get(0)
default:
@@ -60,25 +136,34 @@ func appMain(c *cli.Context) (err error) {
}
}
input, absErr := filepath.Abs(input)
if absErr != nil {
return fmt.Errorf("get abs path failed: %w", absErr)
}
output := c.String("output")
if output == "" {
var err error
output, err = os.Getwd()
if err != nil {
return err
}
if input == output {
return errors.New("input and output path are same")
}
}
skipNoop := c.Bool("skip-noop")
inputStat, err := os.Stat(input)
if err != nil {
return err
}
var inputDir string
if inputStat.IsDir() {
inputDir = input
} else {
inputDir = filepath.Dir(input)
}
inputDir, absErr = filepath.Abs(inputDir)
if absErr != nil {
return fmt.Errorf("get abs path (inputDir) failed: %w", absErr)
}
if output == "" {
// Default to where the input dir is
output = inputDir
}
logger.Debug("resolve input/output path", zap.String("inputDir", inputDir), zap.String("input", input), zap.String("output", output))
outputStat, err := os.Stat(output)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
@@ -91,79 +176,289 @@ func appMain(c *cli.Context) (err error) {
return errors.New("output should be a writable directory")
}
if inputStat.IsDir() {
return dealDirectory(input, output, skipNoop)
} else {
allDec := common.GetDecoder(inputStat.Name(), skipNoop)
if len(allDec) == 0 {
logging.Log().Fatal("skipping while no suitable decoder")
// QMC: Load keys
qmcKeys, err := qmc.LoadMMKVOrDefault(c.String("qmc-mmkv"), c.String("qmc-mmkv-key"), logger)
if err != nil {
return err
}
return tryDecFile(input, output, allDec)
kggDbPath := c.String("kgg-db")
if kggDbPath == "" {
kggDbPath = filepath.Join(os.Getenv("APPDATA"), "Kugou8", "KGMusicV3.db")
}
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"),
crypto: common.CryptoParams{
// KuGou
KggDbPath: kggDbPath,
// QQMusic
QmcKeys: qmcKeys,
},
}
if inputStat.IsDir() {
watchDir := c.Bool("watch")
if !watchDir {
return proc.processDir(input)
} else {
return proc.watchDir(input)
}
} else {
return proc.processFile(input)
}
}
func dealDirectory(inputDir string, outputDir string, skipNoop bool) error {
type processor struct {
logger *zap.Logger
inputDir string
outputDir string
skipNoopDecoder bool
removeSource bool
updateMetadata bool
overwriteOutput bool
crypto common.CryptoParams
}
func (p *processor) watchDir(inputDir string) error {
if err := p.processDir(inputDir); err != nil {
return err
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return fmt.Errorf("failed to create watcher: %w", err)
}
defer watcher.Close()
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) {
// try open with exclusive mode, to avoid file is still writing
f, err := os.OpenFile(event.Name, os.O_RDONLY, os.ModeExclusive)
if err != nil {
logger.Debug("failed to open file exclusively", zap.String("path", event.Name), zap.Error(err))
time.Sleep(1 * time.Second) // wait for file writing complete
continue
}
_ = f.Close()
if err := p.processFile(event.Name); err != nil {
logger.Warn("failed to process file", zap.String("path", event.Name), zap.Error(err))
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
logger.Error("file watcher got error", zap.Error(err))
}
}
}()
err = watcher.Add(inputDir)
if err != nil {
return fmt.Errorf("failed to watch dir %s: %w", inputDir, err)
}
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
<-signalCtx.Done()
return nil
}
func (p *processor) processDir(inputDir string) error {
items, err := os.ReadDir(inputDir)
if err != nil {
return err
}
var lastError error = nil
for _, item := range items {
filePath := filepath.Join(inputDir, item.Name())
if item.IsDir() {
if err = p.processDir(filePath); err != nil {
lastError = err
}
continue
}
allDec := common.GetDecoder(item.Name(), skipNoop)
if err := p.processFile(filePath); err != nil {
lastError = err
logger.Error("conversion failed", zap.String("source", item.Name()), zap.Error(err))
}
}
if lastError != nil {
return fmt.Errorf("last error: %w", lastError)
}
return nil
}
func (p *processor) processFile(filePath string) error {
p.logger.Debug("processFile", zap.String("file", filePath), zap.String("inputDir", p.inputDir))
allDec := common.GetDecoder(filePath, p.skipNoopDecoder)
if len(allDec) == 0 {
logging.Log().Info("skipping while no suitable decoder", zap.String("file", item.Name()))
continue
return errors.New("skipping while no suitable decoder")
}
err := tryDecFile(filepath.Join(inputDir, item.Name()), outputDir, allDec)
if err != nil {
logging.Log().Error("conversion failed", zap.String("source", item.Name()))
if err := p.process(filePath, allDec); err != nil {
return err
}
// if source file need to be removed
if p.removeSource {
err := os.RemoveAll(filePath)
if err != nil {
return err
}
logger.Info("source file removed after success conversion", zap.String("source", filePath))
}
return nil
}
func tryDecFile(inputFile string, outputDir string, allDec []common.NewDecoderFunc) error {
file, err := os.ReadFile(inputFile)
func (p *processor) findDecoder(decoders []common.DecoderFactory, params *common.DecoderParams) (*common.Decoder, *common.DecoderFactory, error) {
for _, factory := range decoders {
dec := factory.Create(params)
err := dec.Validate()
if err == nil {
return &dec, &factory, nil
}
logger.Warn("try decode failed", zap.Error(err))
}
return nil, nil, errors.New("no any decoder can resolve the file")
}
func (p *processor) process(inputFile string, allDec []common.DecoderFactory) error {
file, err := os.Open(inputFile)
if err != nil {
return err
}
defer file.Close()
logger := logger.With(zap.String("source", inputFile))
var dec common.Decoder
for _, decFunc := range allDec {
dec = decFunc(file)
if err := dec.Validate(); err == nil {
break
pDec, decoderFactory, err := p.findDecoder(allDec, &common.DecoderParams{
Reader: file,
Extension: filepath.Ext(inputFile),
FilePath: inputFile,
Logger: logger,
CryptoParams: p.crypto,
})
if err != nil {
return err
}
logging.Log().Warn("try decode failed", zap.Error(err))
dec = nil
dec := *pDec
params := &ffmpeg.UpdateMetadataParams{}
header := bytes.NewBuffer(nil)
_, err = io.CopyN(header, dec, 64)
if err != nil {
return fmt.Errorf("read header failed: %w", err)
}
if dec == nil {
return errors.New("no any decoder can resolve the file")
audio := io.MultiReader(header, dec)
params.AudioExt = sniff.AudioExtensionWithFallback(header.Bytes(), ".mp3")
if p.updateMetadata {
if audioMetaGetter, ok := dec.(common.AudioMetaGetter); ok {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// since ffmpeg doesn't support multiple input streams,
// we need to write the audio to a temp file.
// since qmc decoder doesn't support seeking & relying on ffmpeg probe, we need to read the whole file.
// TODO: support seeking or using pipe for qmc decoder.
params.Audio, err = utils.WriteTempFile(audio, params.AudioExt)
if err != nil {
return fmt.Errorf("updateAudioMeta write temp file: %w", err)
}
if err := dec.Decode(); err != nil {
return errors.New("failed while decoding: " + err.Error())
defer os.Remove(params.Audio)
params.Meta, err = audioMetaGetter.GetAudioMeta(ctx)
if err != nil {
logger.Warn("get audio meta failed", zap.Error(err))
}
outData := dec.GetAudioData()
outExt := dec.GetAudioExt()
if outExt == "" {
if ext, ok := common.SniffAll(outData); ok {
outExt = ext
if params.Meta == nil { // reset audio meta if failed
audio, err = os.Open(params.Audio)
if err != nil {
return fmt.Errorf("updateAudioMeta open temp file: %w", err)
}
}
}
}
if p.updateMetadata && params.Meta != nil {
if coverGetter, ok := dec.(common.CoverImageGetter); ok {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if cover, err := coverGetter.GetCoverImage(ctx); err != nil {
logger.Warn("get cover image failed", zap.Error(err))
} else if imgExt, ok := sniff.ImageExtension(cover); !ok {
logger.Warn("sniff cover image type failed", zap.Error(err))
} else {
outExt = ".mp3"
params.AlbumArtExt = imgExt
params.AlbumArt = cover
}
}
}
filenameOnly := strings.TrimSuffix(filepath.Base(inputFile), filepath.Ext(inputFile))
outPath := filepath.Join(outputDir, filenameOnly+outExt)
err = os.WriteFile(outPath, outData, 0644)
inputRelDir, err := filepath.Rel(p.inputDir, filepath.Dir(inputFile))
if err != nil {
return fmt.Errorf("get relative dir failed: %w", err)
}
inFilename := strings.TrimSuffix(filepath.Base(inputFile), decoderFactory.Suffix)
outPath := filepath.Join(p.outputDir, inputRelDir, inFilename+params.AudioExt)
if !p.overwriteOutput {
_, err := os.Stat(outPath)
if err == nil {
logger.Warn("output file already exist, skip", zap.String("destination", outPath))
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("stat output file failed: %w", err)
}
}
if params.Meta == nil {
outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
logging.Log().Info("successfully converted",
zap.String("source", inputFile), zap.String("destination", outPath))
defer outFile.Close()
if _, err := io.Copy(outFile, audio); err != nil {
return err
}
} else {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if err := ffmpeg.UpdateMeta(ctx, outPath, params, logger); err != nil {
return err
}
}
logger.Info("successfully converted", zap.String("source", inputFile), zap.String("destination", outPath))
return nil
}

37
go.mod
View File

@@ -1,17 +1,34 @@
module github.com/unlock-music/cli
module git.um-react.app/um/cli
go 1.17
go 1.25.1
require (
github.com/ulikunitz/xz v0.5.10
github.com/urfave/cli/v2 v2.3.0
go.uber.org/zap v1.19.1
github.com/fsnotify/fsnotify v1.9.0
github.com/go-flac/flacpicture v0.3.0
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/urfave/cli/v2 v2.27.7
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.44.0
golang.org/x/text v0.31.0
modernc.org/sqlite v1.40.0
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.38.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

150
go.sum
View File

@@ -1,69 +1,89 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
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/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8=
github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723 h1:sHOAIxRGBp443oHZIPB+HsUGaksVCXVQENPxwTfQdH4=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.19.1 h1:ue41HOKd1vGURxrmeKIgELGb3jPW9DMUDGtsinblHwI=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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/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=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
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/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
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/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/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
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/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.0 h1:bNWEDlYhNPAUdUdBzjAvn8icAs/2gaKlj4vM+tQ6KdQ=
modernc.org/sqlite v1.40.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

127
internal/ffmpeg/ffmpeg.go Normal file
View File

@@ -0,0 +1,127 @@
package ffmpeg
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
"go.uber.org/zap"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/internal/utils"
)
func ExtractAlbumArt(ctx context.Context, rd io.Reader) (*bytes.Buffer, error) {
cmd := exec.CommandContext(ctx, "ffmpeg",
"-i", "pipe:0", // input from stdin
"-an", // disable audio
"-codec:v", "copy", // copy video(image) codec
"-f", "image2", // use image2 muxer
"pipe:1", // output to stdout
)
cmd.Stdin = rd
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
cmd.Stdout, cmd.Stderr = stdout, stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg run: %w", err)
}
return stdout, nil
}
type UpdateMetadataParams struct {
Audio string // required
AudioExt string // required
Meta common.AudioMeta // required
AlbumArt []byte // optional
AlbumArtExt string // required if AlbumArt is not nil
}
func UpdateMeta(ctx context.Context, outPath string, params *UpdateMetadataParams, logger *zap.Logger) error {
if params.AudioExt == ".flac" {
return updateMetaFlac(ctx, outPath, params, logger.With(zap.String("module", "updateMetaFlac")))
} else {
return updateMetaFFmpeg(ctx, outPath, params)
}
}
func updateMetaFFmpeg(ctx context.Context, outPath string, params *UpdateMetadataParams) error {
builder := newFFmpegBuilder()
out := newOutputBuilder(outPath) // output to file
builder.SetFlag("y") // overwrite output file
builder.AddOutput(out)
// input audio -> output audio
builder.AddInput(newInputBuilder(params.Audio)) // input 0: audio
out.AddOption("map", "0:a")
out.AddOption("codec:a", "copy")
// input cover -> output cover
if params.AlbumArt != nil &&
params.AudioExt != ".wav" /* wav doesn't support attached image */ {
// write cover to temp file
artPath, err := utils.WriteTempFile(bytes.NewReader(params.AlbumArt), params.AlbumArtExt)
if err != nil {
return fmt.Errorf("updateAudioMeta write temp file: %w", err)
}
defer os.Remove(artPath)
builder.AddInput(newInputBuilder(artPath)) // input 1: cover
out.AddOption("map", "1:v")
switch params.AudioExt {
case ".ogg": // ogg only supports theora codec
out.AddOption("codec:v", "libtheora")
case ".m4a": // .m4a(mp4) requires set codec, disposition, stream metadata
out.AddOption("codec:v", "mjpeg")
out.AddOption("disposition:v", "attached_pic")
out.AddMetadata("s:v", "title", "Album cover")
out.AddMetadata("s:v", "comment", "Cover (front)")
case ".mp3":
out.AddOption("codec:v", "mjpeg")
out.AddMetadata("s:v", "title", "Album cover")
out.AddMetadata("s:v", "comment", "Cover (front)")
default: // other formats use default behavior
}
}
// set file metadata
album := params.Meta.GetAlbum()
if album != "" {
out.AddMetadata("", "album", album)
}
title := params.Meta.GetTitle()
if album != "" {
out.AddMetadata("", "title", title)
}
artists := params.Meta.GetArtists()
if len(artists) != 0 {
// TODO: it seems that ffmpeg doesn't support multiple artists
out.AddMetadata("", "artist", strings.Join(artists, " / "))
}
if params.AudioExt == ".mp3" {
out.AddOption("write_id3v1", "true")
out.AddOption("id3v2_version", "3")
}
// execute ffmpeg
cmd := builder.Command(ctx)
if stdout, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("ffmpeg run: %w, %s", err, string(stdout))
}
return nil
}

141
internal/ffmpeg/ffprobe.go Normal file
View File

@@ -0,0 +1,141 @@
package ffmpeg
import (
"bytes"
"context"
"encoding/json"
"io"
"os/exec"
"strings"
"github.com/samber/lo"
)
type Result struct {
Format *Format `json:"format"`
Streams []*Stream `json:"streams"`
}
func (r *Result) HasAttachedPic() bool {
return lo.ContainsBy(r.Streams, func(s *Stream) bool {
return s.CodecType == "video"
})
}
func (r *Result) getTagByKey(key string) string {
for k, v := range r.Format.Tags {
if key == strings.ToLower(k) {
return v
}
}
for _, stream := range r.Streams { // try to find in streams
if stream.CodecType != "audio" {
continue
}
for k, v := range stream.Tags {
if key == strings.ToLower(k) {
return v
}
}
}
return ""
}
func (r *Result) GetTitle() string {
return r.getTagByKey("title")
}
func (r *Result) GetAlbum() string {
return r.getTagByKey("album")
}
func (r *Result) GetArtists() []string {
artists := strings.Split(r.getTagByKey("artist"), "/")
for i := range artists {
artists[i] = strings.TrimSpace(artists[i])
}
return artists
}
func (r *Result) HasMetadata() bool {
return r.GetTitle() != "" || r.GetAlbum() != "" || len(r.GetArtists()) > 0
}
type Format struct {
Filename string `json:"filename"`
NbStreams int `json:"nb_streams"`
NbPrograms int `json:"nb_programs"`
FormatName string `json:"format_name"`
FormatLongName string `json:"format_long_name"`
StartTime string `json:"start_time"`
Duration string `json:"duration"`
BitRate string `json:"bit_rate"`
ProbeScore int `json:"probe_score"`
Tags map[string]string `json:"tags"`
}
type Stream struct {
Index int `json:"index"`
CodecName string `json:"codec_name"`
CodecLongName string `json:"codec_long_name"`
CodecType string `json:"codec_type"`
CodecTagString string `json:"codec_tag_string"`
CodecTag string `json:"codec_tag"`
SampleFmt string `json:"sample_fmt"`
SampleRate string `json:"sample_rate"`
Channels int `json:"channels"`
ChannelLayout string `json:"channel_layout"`
BitsPerSample int `json:"bits_per_sample"`
RFrameRate string `json:"r_frame_rate"`
AvgFrameRate string `json:"avg_frame_rate"`
TimeBase string `json:"time_base"`
StartPts int `json:"start_pts"`
StartTime string `json:"start_time"`
BitRate string `json:"bit_rate"`
Disposition *ProbeDisposition `json:"disposition"`
Tags map[string]string `json:"tags"`
}
type ProbeDisposition struct {
Default int `json:"default"`
Dub int `json:"dub"`
Original int `json:"original"`
Comment int `json:"comment"`
Lyrics int `json:"lyrics"`
Karaoke int `json:"karaoke"`
Forced int `json:"forced"`
HearingImpaired int `json:"hearing_impaired"`
VisualImpaired int `json:"visual_impaired"`
CleanEffects int `json:"clean_effects"`
AttachedPic int `json:"attached_pic"`
TimedThumbnails int `json:"timed_thumbnails"`
Captions int `json:"captions"`
Descriptions int `json:"descriptions"`
Metadata int `json:"metadata"`
Dependent int `json:"dependent"`
StillImage int `json:"still_image"`
}
func ProbeReader(ctx context.Context, rd io.Reader) (*Result, error) {
cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "quiet", // disable logging
"-print_format", "json", // use json format
"-show_format", "-show_streams", "-show_error", // retrieve format and streams
"pipe:0", // input from stdin
)
cmd.Stdin = rd
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
cmd.Stdout, cmd.Stderr = stdout, stderr
if err := cmd.Run(); err != nil {
return nil, err
}
ret := new(Result)
if err := json.Unmarshal(stdout.Bytes(), ret); err != nil {
return nil, err
}
return ret, nil
}

View File

@@ -0,0 +1,95 @@
package ffmpeg
import (
"context"
"mime"
"slices"
"strings"
"go.uber.org/zap"
"github.com/go-flac/flacpicture"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
)
func updateMetaFlac(_ context.Context, outPath string, m *UpdateMetadataParams, logger *zap.Logger) error {
f, err := flac.ParseFile(m.Audio)
if err != nil {
return err
}
// generate comment block
comment := flacvorbis.MetaDataBlockVorbisComment{Vendor: "unlock-music.dev"}
// add metadata
title := m.Meta.GetTitle()
if title != "" {
_ = comment.Add(flacvorbis.FIELD_TITLE, title)
}
album := m.Meta.GetAlbum()
if album != "" {
_ = comment.Add(flacvorbis.FIELD_ALBUM, album)
}
artists := m.Meta.GetArtists()
for _, artist := range artists {
_ = comment.Add(flacvorbis.FIELD_ARTIST, artist)
}
existCommentIdx := slices.IndexFunc(f.Meta, func(b *flac.MetaDataBlock) bool {
return b.Type == flac.VorbisComment
})
if existCommentIdx >= 0 { // copy existing comment fields
exist, err := flacvorbis.ParseFromMetaDataBlock(*f.Meta[existCommentIdx])
if err != nil {
for _, s := range exist.Comments {
if strings.HasPrefix(s, flacvorbis.FIELD_TITLE+"=") && title != "" ||
strings.HasPrefix(s, flacvorbis.FIELD_ALBUM+"=") && album != "" ||
strings.HasPrefix(s, flacvorbis.FIELD_ARTIST+"=") && len(artists) != 0 {
continue
}
comment.Comments = append(comment.Comments, s)
}
}
}
// add / replace flac comment
cmtBlock := comment.Marshal()
if existCommentIdx < 0 {
f.Meta = append(f.Meta, &cmtBlock)
} else {
f.Meta[existCommentIdx] = &cmtBlock
}
if m.AlbumArt != nil {
coverMime := mime.TypeByExtension(m.AlbumArtExt)
logger.Debug("cover image mime detect", zap.String("mime", coverMime))
cover, err := flacpicture.NewFromImageData(
flacpicture.PictureTypeFrontCover,
"Front cover",
m.AlbumArt,
coverMime,
)
if err != nil {
logger.Warn("failed to create flac cover", zap.Error(err))
} else {
coverBlock := cover.Marshal()
f.Meta = append(f.Meta, &coverBlock)
// add / replace flac cover
coverIdx := slices.IndexFunc(f.Meta, func(b *flac.MetaDataBlock) bool {
return b.Type == flac.Picture
})
if coverIdx < 0 {
f.Meta = append(f.Meta, &coverBlock)
} else {
f.Meta[coverIdx] = &coverBlock
}
}
}
return f.Save(outPath)
}

131
internal/ffmpeg/options.go Normal file
View File

@@ -0,0 +1,131 @@
package ffmpeg
import (
"context"
"os/exec"
"strings"
)
type ffmpegBuilder struct {
binary string // ffmpeg binary path
options map[string]string // global options
inputs []*inputBuilder // input options
outputs []*outputBuilder // output options
}
func newFFmpegBuilder() *ffmpegBuilder {
return &ffmpegBuilder{
binary: "ffmpeg",
options: make(map[string]string),
}
}
func (b *ffmpegBuilder) AddInput(src *inputBuilder) {
b.inputs = append(b.inputs, src)
}
func (b *ffmpegBuilder) AddOutput(dst *outputBuilder) {
b.outputs = append(b.outputs, dst)
}
func (b *ffmpegBuilder) SetBinary(bin string) {
b.binary = bin
}
func (b *ffmpegBuilder) SetFlag(flag string) {
b.options[flag] = ""
}
func (b *ffmpegBuilder) SetOption(name, value string) {
b.options[name] = value
}
func (b *ffmpegBuilder) Args() (args []string) {
for name, val := range b.options {
args = append(args, "-"+name)
if val != "" {
args = append(args, val)
}
}
for _, input := range b.inputs {
args = append(args, input.Args()...)
}
for _, output := range b.outputs {
args = append(args, output.Args()...)
}
return
}
func (b *ffmpegBuilder) Command(ctx context.Context) *exec.Cmd {
bin := "ffmpeg"
if b.binary != "" {
bin = b.binary
}
return exec.CommandContext(ctx, bin, b.Args()...)
}
// inputBuilder is the builder for ffmpeg input options
type inputBuilder struct {
path string
options map[string][]string
}
func newInputBuilder(path string) *inputBuilder {
return &inputBuilder{
path: path,
options: make(map[string][]string),
}
}
func (b *inputBuilder) AddOption(name, value string) {
b.options[name] = append(b.options[name], value)
}
func (b *inputBuilder) Args() (args []string) {
for name, values := range b.options {
for _, val := range values {
args = append(args, "-"+name, val)
}
}
return append(args, "-i", b.path)
}
// outputBuilder is the builder for ffmpeg output options
type outputBuilder struct {
path string
options map[string][]string
}
func newOutputBuilder(path string) *outputBuilder {
return &outputBuilder{
path: path,
options: make(map[string][]string),
}
}
func (b *outputBuilder) AddOption(name, value string) {
b.options[name] = append(b.options[name], value)
}
func (b *outputBuilder) Args() (args []string) {
for name, values := range b.options {
for _, val := range values {
args = append(args, "-"+name, val)
}
}
return append(args, b.path)
}
// AddMetadata is the shortcut for adding "metadata" option
func (b *outputBuilder) AddMetadata(stream, key, value string) {
optVal := strings.TrimSpace(key) + "=" + strings.TrimSpace(value)
if stream != "" {
b.AddOption("metadata:"+stream, optVal)
} else {
b.AddOption("metadata", optVal)
}
}

View File

@@ -1,41 +0,0 @@
package logging
import (
"os"
"sync"
"time"
)
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// newDefaultProductionLog configures a custom log that is
// intended for use by default if no other log is specified
// in a config. It writes to stderr, uses the console encoder,
// and enables INFO-level logs and higher.
func newDefaultProductionLog() *zap.Logger {
encCfg := zap.NewProductionEncoderConfig()
// if interactive terminal, make output more human-readable by default
encCfg.EncodeTime = func(ts time.Time, encoder zapcore.PrimitiveArrayEncoder) {
encoder.AppendString(ts.Format("2006/01/02 15:04:05.000"))
}
encCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder
enc := zapcore.NewConsoleEncoder(encCfg)
core := zapcore.NewCore(enc, zapcore.Lock(os.Stdout), zap.NewAtomicLevelAt(zap.DebugLevel))
return zap.New(core)
}
// Log returns the current default logger.
func Log() *zap.Logger {
defaultLoggerMu.RLock()
defer defaultLoggerMu.RUnlock()
return defaultLogger
}
var (
defaultLogger = newDefaultProductionLog()
defaultLoggerMu sync.RWMutex
)

14
internal/logging/zap.go Normal file
View File

@@ -0,0 +1,14 @@
package logging
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func NewZapLogger() (*zap.Logger, error) {
zapCfg := zap.NewDevelopmentConfig()
zapCfg.DisableStacktrace = true
zapCfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
zapCfg.EncoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout("2006/01/02 15:04:05.000")
return zapCfg.Build()
}

71
internal/mmkv/mmkv.go Normal file
View File

@@ -0,0 +1,71 @@
package mmkv
import (
"fmt"
"os"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/internal/utils"
go_mmkv "github.com/unlock-music/go-mmkv"
"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"
mr, err := os.Open(mmkv_path)
if err != nil {
logger.Error("LoadMMKV: Could not open mmkv file", zap.Error(err))
return nil, fmt.Errorf("LoadMMKV: open error: %w", err)
}
defer mr.Close()
cr, err := os.Open(mmkv_crc)
if err != nil {
// crc is optional
logger.Warn("LoadMMKV: Failed to open crc file, assuming no encryption", zap.Error(err))
key = ""
} else {
defer cr.Close()
}
var password []byte = nil
if key != "" {
password = make([]byte, 16)
copy(password, []byte(key))
}
mmkv, err := go_mmkv.NewMMKVReader(mr, password, cr)
if err != nil {
logger.Error("LoadMMKV: failed to create reader", zap.Error(err))
return nil, fmt.Errorf("LoadMMKV: NewMMKVReader error: %w", err)
}
result = make(common.QMCKeys)
for !mmkv.IsEOF() {
key, err := mmkv.ReadKey()
if err != nil {
logger.Error("LoadMMKV: read key error", zap.Error(err))
return nil, fmt.Errorf("LoadMMKV: read key error: %w", err)
}
value, err := mmkv.ReadStringValue()
if err != nil {
logger.Error("LoadMMKV: read value error", zap.Error(err))
return nil, fmt.Errorf("LoadMMKV: read value error: %w", err)
}
logger.Debug("LoadMMKV: read", zap.String("key", key), zap.String("value", value))
result[utils.NormalizeUnicode(key)] = utils.NormalizeUnicode(value)
}
return result, nil
}

105
internal/sniff/audio.go Normal file
View File

@@ -0,0 +1,105 @@
package sniff
import (
"bytes"
"encoding/binary"
"slices"
)
type Sniffer interface {
Sniff(header []byte) bool
}
var audioExtensions = map[string]Sniffer{
// ref: https://mimesniff.spec.whatwg.org
".mp3": prefixSniffer("ID3"), // todo: check mp3 without ID3v2 tag
".ogg": prefixSniffer("OggS"),
".wav": prefixSniffer("RIFF"),
// ref: https://www.loc.gov/preservation/digital/formats/fdd/fdd000027.shtml
".wma": prefixSniffer{
0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11,
0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c,
},
// ref: https://www.garykessler.net/library/file_sigs.html
".m4a": m4aSniffer{}, // MPEG-4 container, Apple Lossless Audio Codec
".mp4": &mpeg4Sniffer{}, // MPEG-4 container, other fallback
".flac": prefixSniffer("fLaC"), // ref: https://xiph.org/flac/format.html
".dff": prefixSniffer("FRM8"), // DSDIFF, ref: https://www.sonicstudio.com/pdf/dsd/DSDIFF_1.5_Spec.pdf
}
// AudioExtension sniffs the known audio types, and returns the file extension.
// header is recommended to at least 16 bytes.
func AudioExtension(header []byte) (string, bool) {
for ext, sniffer := range audioExtensions {
if sniffer.Sniff(header) {
return ext, true
}
}
return "", false
}
// AudioExtensionWithFallback is equivalent to AudioExtension, but returns fallback
// most likely to use .mp3 as fallback, because mp3 files may not have ID3v2 tag.
func AudioExtensionWithFallback(header []byte, fallback string) string {
ext, ok := AudioExtension(header)
if !ok {
return fallback
}
return ext
}
type prefixSniffer []byte
func (s prefixSniffer) Sniff(header []byte) bool {
return bytes.HasPrefix(header, s)
}
type m4aSniffer struct{}
func (m4aSniffer) Sniff(header []byte) bool {
box := readMpeg4FtypBox(header)
if box == nil {
return false
}
return box.majorBrand == "M4A " || slices.Contains(box.compatibleBrands, "M4A ")
}
type mpeg4Sniffer struct{}
func (s *mpeg4Sniffer) Sniff(header []byte) bool {
return readMpeg4FtypBox(header) != nil
}
type mpeg4FtpyBox struct {
majorBrand string
minorVersion uint32
compatibleBrands []string
}
func readMpeg4FtypBox(header []byte) *mpeg4FtpyBox {
if (len(header) < 8) || !bytes.Equal([]byte("ftyp"), header[4:8]) {
return nil // not a valid ftyp box
}
size := binary.BigEndian.Uint32(header[0:4]) // size
if size < 16 || size%4 != 0 {
return nil // invalid ftyp box
}
box := mpeg4FtpyBox{
majorBrand: string(header[8:12]),
minorVersion: binary.BigEndian.Uint32(header[12:16]),
}
// compatible brands
for i := 16; i < int(size) && i+4 < len(header); i += 4 {
box.compatibleBrands = append(box.compatibleBrands, string(header[i:i+4]))
}
return &box
}

30
internal/sniff/image.go Normal file
View File

@@ -0,0 +1,30 @@
package sniff
// ref: https://mimesniff.spec.whatwg.org
var imageMIMEs = map[string]Sniffer{
"image/jpeg": prefixSniffer{0xFF, 0xD8, 0xFF},
"image/png": prefixSniffer{0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'},
"image/bmp": prefixSniffer("BM"),
"image/webp": prefixSniffer("RIFF"),
"image/gif": prefixSniffer("GIF8"),
}
// ImageMIME sniffs the well-known image types, and returns its MIME.
func ImageMIME(header []byte) (string, bool) {
for ext, sniffer := range imageMIMEs {
if sniffer.Sniff(header) {
return ext, true
}
}
return "", false
}
// ImageExtension is equivalent to ImageMIME, but returns file extension
func ImageExtension(header []byte) (string, bool) {
ext, ok := ImageMIME(header)
if !ok {
return "", false
}
// todo: use mime.ExtensionsByType
return "." + ext[6:], true // "image/" is 6 bytes
}

View File

@@ -8,7 +8,7 @@ func PKCS7UnPadding(encrypt []byte) []byte {
return encrypt[:(length - unPadding)]
}
func DecryptAes128Ecb(data, key []byte) []byte {
func DecryptAES128ECB(data, key []byte) []byte {
cipher, _ := aes.NewCipher(key)
decrypted := make([]byte, len(data))
size := 16

24
internal/utils/temp.go Normal file
View File

@@ -0,0 +1,24 @@
package utils
import (
"fmt"
"io"
"os"
)
func WriteTempFile(rd io.Reader, ext string) (string, error) {
audioFile, err := os.CreateTemp("", "*"+ext)
if err != nil {
return "", fmt.Errorf("ffmpeg create temp file: %w", err)
}
if _, err := io.Copy(audioFile, rd); err != nil {
return "", fmt.Errorf("ffmpeg write temp file: %w", err)
}
if err := audioFile.Close(); err != nil {
return "", fmt.Errorf("ffmpeg close temp file: %w", err)
}
return audioFile.Name(), nil
}

10
internal/utils/unicode.go Normal file
View File

@@ -0,0 +1,10 @@
package utils
import "golang.org/x/text/unicode/norm"
// normalizeUnicode normalizes unicode string to NFC.
// since macOS may change some characters in the file name.
// e.g. "ぜ"(e3 81 9c) -> "ぜ"(e3 81 9b e3 82 99)
func NormalizeUnicode(str string) string {
return norm.NFC.String(str)
}

32
misc/release.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/bash
set -e
PLATFORMS=(
"linux/amd64"
"linux/arm64"
"darwin/amd64"
"darwin/arm64"
"windows/amd64"
"windows/386"
)
DEST_DIR=${DEST_DIR:-"dist"}
for PLATFORM in "${PLATFORMS[@]}"; do
GOOS=${PLATFORM%/*}
GOARCH=${PLATFORM#*/}
echo "Building for $GOOS/$GOARCH"
FILENAME="um-$GOOS-$GOARCH"
if [ "$GOOS" = "windows" ]; then
FILENAME="$FILENAME.exe"
fi
GOOS=$GOOS GOARCH=$GOARCH go build -v \
-o "${DEST_DIR}/${FILENAME}" \
-ldflags "-s -w -X main.AppVersion=$(git describe --tags --always --dirty)" \
./cmd/um
done
cd "$DEST_DIR"
sha256sum um-* > sha256sums.txt

51
misc/repack.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/bin/bash -e
# see .gitea/workflows/build.yml
APP_VERSION="${1:-$(git describe --tags --always)}"
pack() {
local is_windows=0
local suffix=""
if [[ "$1" == *.exe ]]; then
suffix=".exe"
is_windows=1
fi
local exe_dir="$(dirname "$1")"
local archive_name="$(basename "$1" ".exe")-${APP_VERSION}"
local exe_name="um${suffix}"
echo "archiving ${exe_name}..."
mv "$1" "${exe_name}"
if [[ "$is_windows" == 1 ]]; then
zip -Xqj9 "dist/${archive_name}.zip" "${exe_name}" README.md LICENSE
else
tar \
--sort=name --format=posix \
--pax-option=exthdr.name=%d/PaxHeaders/%f \
--pax-option=delete=atime,delete=ctime \
--clamp-mtime --mtime='1970-01-01T00:00:00Z' \
--numeric-owner --owner=0 --group=0 \
--mode=0755 -c \
"${exe_name}" README.md LICENSE |
gzip -9 >"dist/${archive_name}.tar.gz"
fi
rm -rf "$exe_dir" "${exe_name}"
}
for exe in prepare/*/um*; do
pack "$exe"
done
pushd dist
if command -v strip-nondeterminism >/dev/null 2>&1; then
echo 'strip archives...'
strip-nondeterminism *.zip *.tar.gz
fi
echo 'Creating checksum...'
sha256sum *.zip *.tar.gz >sha256sum.txt
ls -alh *.zip *.tar.gz sha256sum.txt
popd