114 Commits

Author SHA1 Message Date
鲁树人
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
48 changed files with 2645 additions and 849 deletions

View File

@@ -1,119 +0,0 @@
// generate .drone.yaml, run:
// drone jsonnet --format --stream
local CreateRelease() = {
name: 'create release',
image: 'plugins/gitea-release',
settings: {
api_key: { from_secret: 'GITEA_API_KEY' },
base_url: 'https://git.unlock-music.dev',
files: 'dist/*',
checksum: 'sha256',
draft: true,
title: '${DRONE_TAG}',
},
};
local StepGoBuild(GOOS, GOARCH) = {
local filepath = 'dist/um-%s-%s.tar.gz' % [GOOS, GOARCH],
name: 'go build %s/%s' % [GOOS, GOARCH],
image: 'golang:1.19',
environment: {
GOOS: GOOS,
GOARCH: GOARCH,
},
commands: [
'DIST_DIR=$(mktemp -d)',
'go build -v -trimpath -ldflags="-w -s -X main.AppVersion=$(git describe --tags --always)" -o $DIST_DIR ./cmd/um',
'mkdir -p dist',
'tar cz -f %s -C $DIST_DIR .' % filepath,
],
};
local StepUploadArtifact(GOOS, GOARCH) = {
local filename = 'um-%s-%s.tar.gz' % [GOOS, GOARCH],
local filepath = 'dist/%s' % filename,
local pkgname = '${DRONE_REPO_NAME}-build',
name: 'upload artifact',
image: 'golang:1.19', // reuse golang:1.19 for curl
environment: {
DRONE_GITEA_SERVER: 'https://git.unlock-music.dev',
GITEA_API_KEY: { from_secret: 'GITEA_API_KEY' },
},
commands: [
'curl --fail --include --user "um-release-bot:$GITEA_API_KEY" ' +
'--upload-file "%s" ' % filepath +
'"$DRONE_GITEA_SERVER/api/packages/${DRONE_REPO_NAMESPACE}/generic/%s/${DRONE_BUILD_NUMBER}/%s"' % [pkgname, filename],
'sha256sum %s' % filepath,
'echo $DRONE_GITEA_SERVER/${DRONE_REPO_NAMESPACE}/-/packages/generic/%s/${DRONE_BUILD_NUMBER}' % pkgname,
],
};
local PipelineBuild(GOOS, GOARCH, RUN_TEST) = {
name: 'build %s/%s' % [GOOS, GOARCH],
kind: 'pipeline',
type: 'docker',
steps: [
{
name: 'fetch tags',
image: 'alpine/git',
commands: ['git fetch --tags'],
},
] +
(
if RUN_TEST then [{
name: 'go test',
image: 'golang:1.19',
commands: ['go test -v ./...'],
}] else []
)
+
[
StepGoBuild(GOOS, GOARCH),
StepUploadArtifact(GOOS, GOARCH),
],
trigger: {
event: ['push', 'pull_request'],
},
};
local PipelineRelease() = {
name: 'release',
kind: 'pipeline',
type: 'docker',
steps: [
{
name: 'fetch tags',
image: 'alpine/git',
commands: ['git fetch --tags'],
},
{
name: 'go test',
image: 'golang:1.19',
commands: ['go test -v ./...'],
},
StepGoBuild('linux', 'amd64'),
StepGoBuild('linux', 'arm64'),
StepGoBuild('linux', '386'),
StepGoBuild('windows', 'amd64'),
StepGoBuild('windows', '386'),
StepGoBuild('darwin', 'amd64'),
StepGoBuild('darwin', 'arm64'),
CreateRelease(),
],
trigger: {
event: ['tag'],
},
};
[
PipelineBuild('linux', 'amd64', true),
PipelineBuild('windows', 'amd64', false),
PipelineBuild('darwin', 'amd64', false),
PipelineRelease(),
]

View File

@@ -1,212 +0,0 @@
---
kind: pipeline
name: build linux/amd64
steps:
- commands:
- git fetch --tags
image: alpine/git
name: fetch tags
- commands:
- go test -v ./...
image: golang:1.19
name: go test
- commands:
- DIST_DIR=$(mktemp -d)
- go build -v -trimpath -ldflags="-w -s -X main.AppVersion=$(git describe --tags
--always)" -o $DIST_DIR ./cmd/um
- mkdir -p dist
- tar cz -f dist/um-linux-amd64.tar.gz -C $DIST_DIR .
environment:
GOARCH: amd64
GOOS: linux
image: golang:1.19
name: go build linux/amd64
- commands:
- curl --fail --include --user "um-release-bot:$GITEA_API_KEY" --upload-file "dist/um-linux-amd64.tar.gz"
"$DRONE_GITEA_SERVER/api/packages/${DRONE_REPO_NAMESPACE}/generic/${DRONE_REPO_NAME}-build/${DRONE_BUILD_NUMBER}/um-linux-amd64.tar.gz"
- sha256sum dist/um-linux-amd64.tar.gz
- echo $DRONE_GITEA_SERVER/${DRONE_REPO_NAMESPACE}/-/packages/generic/${DRONE_REPO_NAME}-build/${DRONE_BUILD_NUMBER}
environment:
DRONE_GITEA_SERVER: https://git.unlock-music.dev
GITEA_API_KEY:
from_secret: GITEA_API_KEY
image: golang:1.19
name: upload artifact
trigger:
event:
- push
- pull_request
type: docker
---
kind: pipeline
name: build windows/amd64
steps:
- commands:
- git fetch --tags
image: alpine/git
name: fetch tags
- commands:
- DIST_DIR=$(mktemp -d)
- go build -v -trimpath -ldflags="-w -s -X main.AppVersion=$(git describe --tags
--always)" -o $DIST_DIR ./cmd/um
- mkdir -p dist
- tar cz -f dist/um-windows-amd64.tar.gz -C $DIST_DIR .
environment:
GOARCH: amd64
GOOS: windows
image: golang:1.19
name: go build windows/amd64
- commands:
- curl --fail --include --user "um-release-bot:$GITEA_API_KEY" --upload-file "dist/um-windows-amd64.tar.gz"
"$DRONE_GITEA_SERVER/api/packages/${DRONE_REPO_NAMESPACE}/generic/${DRONE_REPO_NAME}-build/${DRONE_BUILD_NUMBER}/um-windows-amd64.tar.gz"
- sha256sum dist/um-windows-amd64.tar.gz
- echo $DRONE_GITEA_SERVER/${DRONE_REPO_NAMESPACE}/-/packages/generic/${DRONE_REPO_NAME}-build/${DRONE_BUILD_NUMBER}
environment:
DRONE_GITEA_SERVER: https://git.unlock-music.dev
GITEA_API_KEY:
from_secret: GITEA_API_KEY
image: golang:1.19
name: upload artifact
trigger:
event:
- push
- pull_request
type: docker
---
kind: pipeline
name: build darwin/amd64
steps:
- commands:
- git fetch --tags
image: alpine/git
name: fetch tags
- commands:
- DIST_DIR=$(mktemp -d)
- go build -v -trimpath -ldflags="-w -s -X main.AppVersion=$(git describe --tags
--always)" -o $DIST_DIR ./cmd/um
- mkdir -p dist
- tar cz -f dist/um-darwin-amd64.tar.gz -C $DIST_DIR .
environment:
GOARCH: amd64
GOOS: darwin
image: golang:1.19
name: go build darwin/amd64
- commands:
- curl --fail --include --user "um-release-bot:$GITEA_API_KEY" --upload-file "dist/um-darwin-amd64.tar.gz"
"$DRONE_GITEA_SERVER/api/packages/${DRONE_REPO_NAMESPACE}/generic/${DRONE_REPO_NAME}-build/${DRONE_BUILD_NUMBER}/um-darwin-amd64.tar.gz"
- sha256sum dist/um-darwin-amd64.tar.gz
- echo $DRONE_GITEA_SERVER/${DRONE_REPO_NAMESPACE}/-/packages/generic/${DRONE_REPO_NAME}-build/${DRONE_BUILD_NUMBER}
environment:
DRONE_GITEA_SERVER: https://git.unlock-music.dev
GITEA_API_KEY:
from_secret: GITEA_API_KEY
image: golang:1.19
name: upload artifact
trigger:
event:
- push
- pull_request
type: docker
---
kind: pipeline
name: release
steps:
- commands:
- git fetch --tags
image: alpine/git
name: fetch tags
- commands:
- go test -v ./...
image: golang:1.19
name: go test
- commands:
- DIST_DIR=$(mktemp -d)
- go build -v -trimpath -ldflags="-w -s -X main.AppVersion=$(git describe --tags
--always)" -o $DIST_DIR ./cmd/um
- mkdir -p dist
- tar cz -f dist/um-linux-amd64.tar.gz -C $DIST_DIR .
environment:
GOARCH: amd64
GOOS: linux
image: golang:1.19
name: go build linux/amd64
- commands:
- DIST_DIR=$(mktemp -d)
- go build -v -trimpath -ldflags="-w -s -X main.AppVersion=$(git describe --tags
--always)" -o $DIST_DIR ./cmd/um
- mkdir -p dist
- tar cz -f dist/um-linux-arm64.tar.gz -C $DIST_DIR .
environment:
GOARCH: arm64
GOOS: linux
image: golang:1.19
name: go build linux/arm64
- commands:
- DIST_DIR=$(mktemp -d)
- go build -v -trimpath -ldflags="-w -s -X main.AppVersion=$(git describe --tags
--always)" -o $DIST_DIR ./cmd/um
- mkdir -p dist
- tar cz -f dist/um-linux-386.tar.gz -C $DIST_DIR .
environment:
GOARCH: "386"
GOOS: linux
image: golang:1.19
name: go build linux/386
- commands:
- DIST_DIR=$(mktemp -d)
- go build -v -trimpath -ldflags="-w -s -X main.AppVersion=$(git describe --tags
--always)" -o $DIST_DIR ./cmd/um
- mkdir -p dist
- tar cz -f dist/um-windows-amd64.tar.gz -C $DIST_DIR .
environment:
GOARCH: amd64
GOOS: windows
image: golang:1.19
name: go build windows/amd64
- commands:
- DIST_DIR=$(mktemp -d)
- go build -v -trimpath -ldflags="-w -s -X main.AppVersion=$(git describe --tags
--always)" -o $DIST_DIR ./cmd/um
- mkdir -p dist
- tar cz -f dist/um-windows-386.tar.gz -C $DIST_DIR .
environment:
GOARCH: "386"
GOOS: windows
image: golang:1.19
name: go build windows/386
- commands:
- DIST_DIR=$(mktemp -d)
- go build -v -trimpath -ldflags="-w -s -X main.AppVersion=$(git describe --tags
--always)" -o $DIST_DIR ./cmd/um
- mkdir -p dist
- tar cz -f dist/um-darwin-amd64.tar.gz -C $DIST_DIR .
environment:
GOARCH: amd64
GOOS: darwin
image: golang:1.19
name: go build darwin/amd64
- commands:
- DIST_DIR=$(mktemp -d)
- go build -v -trimpath -ldflags="-w -s -X main.AppVersion=$(git describe --tags
--always)" -o $DIST_DIR ./cmd/um
- mkdir -p dist
- tar cz -f dist/um-darwin-arm64.tar.gz -C $DIST_DIR .
environment:
GOARCH: arm64
GOOS: darwin
image: golang:1.19
name: go build darwin/arm64
- image: plugins/gitea-release
name: create release
settings:
api_key:
from_secret: GITEA_API_KEY
base_url: https://git.unlock-music.dev
checksum: sha256
draft: true
files: dist/*
title: ${DRONE_TAG}
trigger:
event:
- tag
type: docker

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,67 +0,0 @@
name: Build
on:
push:
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: 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: 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

9
.gitignore vendored
View File

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

View File

@@ -1,23 +1,35 @@
# Unlock Music Project - CLI Edition # Unlock Music Project - CLI Edition
Original: Web Edition https://git.unlock-music.dev/um/web Original: Web Edition https://git.um-react.app/um/web
- [![Build Status](https://ci.unlock-music.dev/api/badges/um/cli/status.svg)](https://ci.unlock-music.dev/um/cli) - [![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.unlock-music.dev/um/cli/releases/latest) - [Release Download](https://git.um-react.app/um/cli/releases/latest)
- [Latest Build](https://git.unlock-music.dev/um/-/packages/generic/cli-build/) - [Latest Build](https://git.um-react.app/um/cli/actions)
> **WARNING**
> 在本站 fork 不会起到备份的作用,只会浪费服务器储存空间。如无必要请勿 fork 该仓库。
## Features ## Features
- [x] All Algorithm Supported By `unlock-music/web` - [x] All Algorithm Supported By `unlock-music/web`
- [ ] Complete Cover Image - [x] Complete Metadata & Cover Image
- [ ] Parse Meta Data
- [ ] Complete Meta Data
## Hou to Build ## Release
- Requirements: **Golang 1.17** [Latest release](https://git.um-react.app/um/cli/releases/latest).
1. run `go install unlock-music.dev/cli/cmd/um@master` ## 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 ## How to use

View File

@@ -5,9 +5,25 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"git.um-react.app/um/cli/internal/utils"
"go.uber.org/zap" "go.uber.org/zap"
) )
type QMCKeys map[string]string
type CryptoParams struct {
// KuGou kgg database path
KggDbPath string
// QMC Crypto config
QmcKeys QMCKeys
}
func (k QMCKeys) Get(key string) (string, bool) {
value, ok := k[utils.NormalizeUnicode(key)]
return value, ok
}
type DecoderParams struct { type DecoderParams struct {
Reader io.ReadSeeker // required Reader io.ReadSeeker // required
Extension string // required, source extension, eg. ".mp3" Extension string // required, source extension, eg. ".mp3"
@@ -15,27 +31,37 @@ type DecoderParams struct {
FilePath string // optional, source file path FilePath string // optional, source file path
Logger *zap.Logger // required Logger *zap.Logger // required
CryptoParams CryptoParams
} }
type NewDecoderFunc func(p *DecoderParams) Decoder type NewDecoderFunc func(p *DecoderParams) Decoder
type decoderItem struct { type DecoderFactory struct {
noop bool noop bool
decoder NewDecoderFunc Suffix string
Create NewDecoderFunc
} }
var DecoderRegistry = make(map[string][]decoderItem) var DecoderRegistry []DecoderFactory
func RegisterDecoder(ext string, noop bool, dispatchFunc NewDecoderFunc) { func RegisterDecoder(ext string, noop bool, dispatchFunc NewDecoderFunc) {
DecoderRegistry[ext] = append(DecoderRegistry[ext], DecoderRegistry = append(DecoderRegistry,
decoderItem{noop: noop, decoder: dispatchFunc}) 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), ".")) func GetDecoder(filename string, skipNoop bool) []DecoderFactory {
for _, dec := range DecoderRegistry[ext] { 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 { if skipNoop && dec.noop {
continue continue
} }
rs = append(rs, dec.decoder) result = append(result, dec)
} }
return return result
} }

View File

@@ -5,6 +5,10 @@ import (
"io" "io"
) )
type StreamDecoder interface {
Decrypt(buf []byte, offset int)
}
type Decoder interface { type Decoder interface {
Validate() error Validate() error
io.Reader io.Reader
@@ -14,12 +18,12 @@ type CoverImageGetter interface {
GetCoverImage(ctx context.Context) ([]byte, error) GetCoverImage(ctx context.Context) ([]byte, error)
} }
type Meta interface { type AudioMeta interface {
GetArtists() []string GetArtists() []string
GetTitle() string GetTitle() string
GetAlbum() string GetAlbum() string
} }
type StreamDecoder interface { type AudioMetaGetter interface {
Decrypt(buf []byte, offset int) 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

@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"unlock-music.dev/cli/internal/sniff" "git.um-react.app/um/cli/internal/sniff"
) )
type RawDecoder struct { type RawDecoder struct {

View File

@@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"io" "io"
"unlock-music.dev/cli/algo/common" "git.um-react.app/um/cli/algo/common"
) )
type Decoder struct { type Decoder struct {
@@ -14,10 +14,12 @@ type Decoder struct {
offset int offset int
header header header header
KggDatabasePath string
} }
func NewDecoder(p *common.DecoderParams) common.Decoder { func NewDecoder(p *common.DecoderParams) common.Decoder {
return &Decoder{rd: p.Reader} return &Decoder{rd: p.Reader, KggDatabasePath: p.CryptoParams.KggDbPath}
} }
// Validate checks if the file is a valid Kugou (.kgm, .vpr, .kgma) file. // Validate checks if the file is a valid Kugou (.kgm, .vpr, .kgma) file.
@@ -34,6 +36,11 @@ func (d *Decoder) Validate() (err error) {
if err != nil { if err != nil {
return fmt.Errorf("kgm init crypto v3: %w", err) 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: default:
return fmt.Errorf("kgm: unsupported crypto version %d", d.header.CryptoVersion) return fmt.Errorf("kgm: unsupported crypto version %d", d.header.CryptoVersion)
} }
@@ -57,8 +64,12 @@ func (d *Decoder) Read(buf []byte) (int, error) {
func init() { func init() {
// Kugou // Kugou
common.RegisterDecoder("kgg", false, NewDecoder)
common.RegisterDecoder("kgm", false, NewDecoder) common.RegisterDecoder("kgm", false, NewDecoder)
common.RegisterDecoder("kgma", false, NewDecoder) common.RegisterDecoder("kgma", false, NewDecoder)
// Viper // Viper
common.RegisterDecoder("vpr", false, NewDecoder) common.RegisterDecoder("vpr", false, NewDecoder)
// Kugou Android
common.RegisterDecoder("kgm.flac", false, NewDecoder)
common.RegisterDecoder("vpr.flac", false, NewDecoder)
} }

View File

@@ -29,6 +29,8 @@ type header struct {
CryptoSlot uint32 // 0x18-0x1b: crypto key slot CryptoSlot uint32 // 0x18-0x1b: crypto key slot
CryptoTestData []byte // 0x1c-0x2b: crypto test data CryptoTestData []byte // 0x1c-0x2b: crypto test data
CryptoKey []byte // 0x2c-0x3b: crypto key CryptoKey []byte // 0x2c-0x3b: crypto key
AudioHash string // v5: audio hash
} }
func (h *header) FromFile(rd io.ReadSeeker) error { func (h *header) FromFile(rd io.ReadSeeker) error {
@@ -36,29 +38,56 @@ func (h *header) FromFile(rd io.ReadSeeker) error {
return fmt.Errorf("kgm seek start: %w", err) return fmt.Errorf("kgm seek start: %w", err)
} }
buf := make([]byte, 0x3c) return h.FromBytes(rd)
if _, err := io.ReadFull(rd, buf); err != nil {
return fmt.Errorf("kgm read header: %w", err)
}
return h.FromBytes(buf)
} }
func (h *header) FromBytes(buf []byte) error { func (h *header) FromBytes(r io.ReadSeeker) error {
if len(buf) < 0x3c { h.MagicHeader = make([]byte, 16)
return errors.New("invalid kgm header length") _, err := r.Read(h.MagicHeader)
if err != nil {
return err
} }
h.MagicHeader = buf[:0x10]
if !bytes.Equal(kgmHeader, h.MagicHeader) && !bytes.Equal(vprHeader, h.MagicHeader) { if !bytes.Equal(kgmHeader, h.MagicHeader) && !bytes.Equal(vprHeader, h.MagicHeader) {
return ErrKgmMagicHeader return ErrKgmMagicHeader
} }
h.AudioOffset = binary.LittleEndian.Uint32(buf[0x10:0x14]) err = binary.Read(r, binary.LittleEndian, &h.AudioOffset)
h.CryptoVersion = binary.LittleEndian.Uint32(buf[0x14:0x18]) if err != nil {
h.CryptoSlot = binary.LittleEndian.Uint32(buf[0x18:0x1c]) return err
h.CryptoTestData = buf[0x1c:0x2c] }
h.CryptoKey = buf[0x2c:0x3c] 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 return nil
} }

View File

@@ -4,7 +4,7 @@ import (
"crypto/md5" "crypto/md5"
"fmt" "fmt"
"unlock-music.dev/cli/algo/common" "git.um-react.app/um/cli/algo/common"
) )
// kgmCryptoV3 is kgm file crypto v3 // kgmCryptoV3 is kgm file crypto v3

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

@@ -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

@@ -9,7 +9,7 @@ import (
"strings" "strings"
"unicode" "unicode"
"unlock-music.dev/cli/algo/common" "git.um-react.app/um/cli/algo/common"
) )
const magicHeader1 = "yeelion-kuwo-tme" const magicHeader1 = "yeelion-kuwo-tme"

View File

@@ -3,62 +3,102 @@ package ncm
import ( import (
"strings" "strings"
"unlock-music.dev/cli/algo/common" "go.uber.org/zap"
"git.um-react.app/um/cli/algo/common"
) )
type RawMeta interface { type ncmMeta interface {
common.Meta common.AudioMeta
// GetFormat return the audio format, e.g. mp3, flac
GetFormat() string GetFormat() string
// GetAlbumImageURL return the album image url
GetAlbumImageURL() string GetAlbumImageURL() string
} }
type RawMetaMusic struct {
Format string `json:"format"` type ncmMetaMusic struct {
MusicID int `json:"musicId"` logger *zap.Logger
MusicName string `json:"musicName"`
Artist [][]interface{} `json:"artist"` Format string `json:"format"`
Album string `json:"album"` MusicName string `json:"musicName"`
AlbumID int `json:"albumId"` Artist interface{} `json:"artist"`
AlbumPicDocID interface{} `json:"albumPicDocId"` Album string `json:"album"`
AlbumPic string `json:"albumPic"` AlbumPicDocID interface{} `json:"albumPicDocId"`
MvID int `json:"mvId"` AlbumPic string `json:"albumPic"`
Flag int `json:"flag"` Flag int `json:"flag"`
Bitrate int `json:"bitrate"` Bitrate int `json:"bitrate"`
Duration int `json:"duration"` Duration int `json:"duration"`
Alias []interface{} `json:"alias"` Alias []interface{} `json:"alias"`
TransNames []interface{} `json:"transNames"` TransNames []interface{} `json:"transNames"`
} }
func (m RawMetaMusic) GetAlbumImageURL() string { func newNcmMetaMusic(logger *zap.Logger) *ncmMetaMusic {
ncm := new(ncmMetaMusic)
ncm.logger = logger.With(zap.String("module", "ncmMetaMusic"))
return ncm
}
func (m *ncmMetaMusic) GetAlbumImageURL() string {
return m.AlbumPic return m.AlbumPic
} }
func (m RawMetaMusic) GetArtists() (artists []string) {
for _, artist := range m.Artist { func (m *ncmMetaMusic) GetArtists() []string {
for _, item := range artist { m.logger.Debug("ncm artists raw", zap.Any("artists", m.Artist))
name, ok := item.(string) var artists []string
if ok { switch v := m.Artist.(type) {
artists = append(artists, name)
// 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
return artists
} }
func (m RawMetaMusic) GetTitle() string { func (m *ncmMetaMusic) GetTitle() string {
return m.MusicName return m.MusicName
} }
func (m RawMetaMusic) GetAlbum() string { func (m *ncmMetaMusic) GetAlbum() string {
return m.Album return m.Album
} }
func (m RawMetaMusic) GetFormat() string {
func (m *ncmMetaMusic) GetFormat() string {
return m.Format return m.Format
} }
//goland:noinspection SpellCheckingInspection //goland:noinspection SpellCheckingInspection
type RawMetaDJ struct { type ncmMetaDJ struct {
ProgramID int `json:"programId"` ProgramID int `json:"programId"`
ProgramName string `json:"programName"` ProgramName string `json:"programName"`
MainMusic RawMetaMusic `json:"mainMusic"` MainMusic ncmMetaMusic `json:"mainMusic"`
DjID int `json:"djId"` DjID int `json:"djId"`
DjName string `json:"djName"` DjName string `json:"djName"`
DjAvatarURL string `json:"djAvatarUrl"` DjAvatarURL string `json:"djAvatarUrl"`
@@ -80,32 +120,32 @@ type RawMetaDJ struct {
RadioPurchaseCount int `json:"radioPurchaseCount"` RadioPurchaseCount int `json:"radioPurchaseCount"`
} }
func (m RawMetaDJ) GetArtists() []string { func (m *ncmMetaDJ) GetArtists() []string {
if m.DjName != "" { if m.DjName != "" {
return []string{m.DjName} return []string{m.DjName}
} }
return m.MainMusic.GetArtists() return m.MainMusic.GetArtists()
} }
func (m RawMetaDJ) GetTitle() string { func (m *ncmMetaDJ) GetTitle() string {
if m.ProgramName != "" { if m.ProgramName != "" {
return m.ProgramName return m.ProgramName
} }
return m.MainMusic.GetTitle() return m.MainMusic.GetTitle()
} }
func (m RawMetaDJ) GetAlbum() string { func (m *ncmMetaDJ) GetAlbum() string {
if m.Brand != "" { if m.Brand != "" {
return m.Brand return m.Brand
} }
return m.MainMusic.GetAlbum() return m.MainMusic.GetAlbum()
} }
func (m RawMetaDJ) GetFormat() string { func (m *ncmMetaDJ) GetFormat() string {
return m.MainMusic.GetFormat() return m.MainMusic.GetFormat()
} }
func (m RawMetaDJ) GetAlbumImageURL() string { func (m *ncmMetaDJ) GetAlbumImageURL() string {
if strings.HasPrefix(m.MainMusic.GetAlbumImageURL(), "http") { if strings.HasPrefix(m.MainMusic.GetAlbumImageURL(), "http") {
return m.MainMusic.GetAlbumImageURL() return m.MainMusic.GetAlbumImageURL()
} }

View File

@@ -12,8 +12,10 @@ import (
"net/http" "net/http"
"strings" "strings"
"unlock-music.dev/cli/algo/common" "go.uber.org/zap"
"unlock-music.dev/cli/internal/utils"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/internal/utils"
) )
const magicHeader = "CTENFDAM" const magicHeader = "CTENFDAM"
@@ -30,18 +32,19 @@ var (
) )
func NewDecoder(p *common.DecoderParams) common.Decoder { func NewDecoder(p *common.DecoderParams) common.Decoder {
return &Decoder{rd: p.Reader} return &Decoder{rd: p.Reader, logger: p.Logger.With(zap.String("module", "ncm"))}
} }
type Decoder struct { type Decoder struct {
rd io.ReadSeeker // rd is the original file reader logger *zap.Logger
rd io.ReadSeeker // rd is the original file reader
offset int offset int
cipher common.StreamDecoder cipher common.StreamDecoder
metaRaw []byte metaRaw []byte
metaType string metaType string
meta RawMeta meta ncmMeta
cover []byte cover []byte
} }
@@ -74,7 +77,7 @@ func (d *Decoder) Validate() error {
} }
if err := d.parseMeta(); err != nil { if err := d.parseMeta(); err != nil {
return fmt.Errorf("parse meta failed: %w", err) return fmt.Errorf("parse meta failed: %w (raw json=%s)", err, string(d.metaRaw))
} }
d.cipher = newNcmCipher(keyData) d.cipher = newNcmCipher(keyData)
@@ -149,12 +152,18 @@ func (d *Decoder) readMetaData() error {
} }
func (d *Decoder) readCoverData() error { func (d *Decoder) readCoverData() error {
bCoverCRC := make([]byte, 4) bCoverFrameLen := make([]byte, 4)
if _, err := io.ReadFull(d.rd, bCoverCRC); err != nil { if _, err := io.ReadFull(d.rd, bCoverFrameLen); err != nil {
return fmt.Errorf("ncm read cover crc: %w", err) return fmt.Errorf("ncm read cover length: %w", err)
} }
bCoverLen := make([]byte, 4) // 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)
bCoverLen := make([]byte, 4)
if _, err := io.ReadFull(d.rd, bCoverLen); err != nil { if _, err := io.ReadFull(d.rd, bCoverLen); err != nil {
return fmt.Errorf("ncm read cover length: %w", err) return fmt.Errorf("ncm read cover length: %w", err)
} }
@@ -166,16 +175,19 @@ func (d *Decoder) readCoverData() error {
} }
d.cover = coverBuf d.cover = coverBuf
return nil offsetAudioData := coverFrameStartOffset + int64(coverFrameLen) + 4
_, err = d.rd.Seek(offsetAudioData, io.SeekStart)
return err
} }
func (d *Decoder) parseMeta() error { func (d *Decoder) parseMeta() error {
switch d.metaType { switch d.metaType {
case "music": case "music":
d.meta = new(RawMetaMusic) d.meta = newNcmMetaMusic(d.logger)
return json.Unmarshal(d.metaRaw, d.meta) return json.Unmarshal(d.metaRaw, d.meta)
case "dj": case "dj":
d.meta = new(RawMetaDJ) d.meta = new(ncmMetaDJ)
return json.Unmarshal(d.metaRaw, d.meta) return json.Unmarshal(d.metaRaw, d.meta)
default: default:
return errors.New("unknown ncm meta type: " + d.metaType) return errors.New("unknown ncm meta type: " + d.metaType)
@@ -232,8 +244,8 @@ func (d *Decoder) GetCoverImage(ctx context.Context) ([]byte, error) {
return d.cover, nil return d.cover, nil
} }
func (d *Decoder) GetMeta() common.Meta { func (d *Decoder) GetAudioMeta(_ context.Context) (common.AudioMeta, error) {
return d.meta return d.meta, nil
} }
func init() { func init() {

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,85 +1,75 @@
package qmc package qmc
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"github.com/samber/lo" "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" "go.uber.org/zap"
"golang.org/x/exp/slices"
"golang.org/x/text/unicode/norm"
"unlock-music.dev/mmkv"
) )
var streamKeyVault mmkv.Vault func mergeMMKVKeys(keys ...common.QMCKeys) common.QMCKeys {
result := make(common.QMCKeys)
// TODO: move to factory for _, k := range keys {
func readKeyFromMMKV(file string, logger *zap.Logger) ([]byte, error) { for key, value := range k {
if file == "" { result[utils.NormalizeUnicode(key)] = utils.NormalizeUnicode(value)
return nil, errors.New("file path is required while reading key from mmkv")
}
//goland:noinspection GoBoolExpressions
if runtime.GOOS != "darwin" {
return nil, errors.New("mmkv vault not supported on this platform")
}
if streamKeyVault == nil {
mmkvDir, err := getRelativeMMKVDir(file)
if err != nil {
mmkvDir, err = getDefaultMMKVDir()
if err != nil {
return nil, fmt.Errorf("mmkv key valut not found: %w", err)
}
} }
mgr, err := mmkv.NewManager(mmkvDir)
if err != nil {
return nil, fmt.Errorf("init mmkv manager: %w", err)
}
streamKeyVault, err = mgr.OpenVault("MMKVStreamEncryptId")
if err != nil {
return nil, fmt.Errorf("open mmkv vault: %w", err)
}
logger.Debug("mmkv vault opened", zap.Strings("keys", streamKeyVault.Keys()))
} }
return result
_, partName := filepath.Split(file)
partName = normalizeUnicode(partName)
buf, err := streamKeyVault.GetBytes(file)
if buf == nil {
filePaths := streamKeyVault.Keys()
fileNames := lo.Map(filePaths, func(filePath string, _ int) string {
_, name := filepath.Split(filePath)
return normalizeUnicode(name)
})
for _, key := range fileNames { // fallback: match filename only
if key != partName {
continue
}
idx := slices.Index(fileNames, key)
buf, err = streamKeyVault.GetBytes(filePaths[idx])
if err != nil {
logger.Warn("read key from mmkv", zap.String("key", filePaths[idx]), zap.Error(err))
}
}
}
if len(buf) == 0 {
return nil, errors.New("key not found in mmkv vault")
}
return deriveKey(buf)
} }
func LoadMMKV(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
cr = nil
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
}
// getRelativeMMKVDir get mmkv dir relative to file (legacy QQMusic for macOS behaviour)
func getRelativeMMKVDir(file string) (string, error) { func getRelativeMMKVDir(file string) (string, error) {
mmkvDir := filepath.Join(filepath.Dir(file), "../mmkv") mmkvDir := filepath.Join(filepath.Dir(file), "../mmkv")
if _, err := os.Stat(mmkvDir); err != nil { if _, err := os.Stat(mmkvDir); err != nil {
@@ -102,7 +92,7 @@ func getDefaultMMKVDir() (string, error) {
mmkvDir := filepath.Join( mmkvDir := filepath.Join(
homeDir, homeDir,
"Library/Containers/com.tencent.QQMusicMac/Data", // todo: make configurable "Library/Containers/com.tencent.QQMusicMac/Data",
"Library/Application Support/QQMusicMac/mmkv", "Library/Application Support/QQMusicMac/mmkv",
) )
if _, err := os.Stat(mmkvDir); err != nil { if _, err := os.Stat(mmkvDir); err != nil {
@@ -116,10 +106,3 @@ func getDefaultMMKVDir() (string, error) {
return mmkvDir, nil return mmkvDir, nil
} }
// 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)
}

View File

@@ -0,0 +1,65 @@
package qmc
import (
"fmt"
"os"
"path/filepath"
"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) {
key1, err := loadMacKeysV8(logger)
if err != nil {
key1 = nil
logger.Warn("LoadMMKVOrDefault: could not read QQMusic v8.8.0 keys", zap.Error(err))
}
key2, err := 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 = LoadMMKV(path, key, logger)
if err != nil {
userKeys = nil
logger.Warn("LoadMMKVOrDefault: could not read user keys", zap.Error(err))
}
}
allKeys := mergeMMKVKeys(key1, key2, userKeys)
logger.Debug("Keys loaded", zap.Any("keys", allKeys), zap.Int("len", len(allKeys)))
return allKeys, nil
}
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 LoadMMKV(p, "", logger)
}
return nil, nil
}
func loadMacKeysV10(logger *zap.Logger) (common.QMCKeys, error) {
// TODO: stub only
var _ = logger
return nil, 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

@@ -6,14 +6,13 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"runtime"
"strconv" "strconv"
"strings" "strings"
"go.uber.org/zap" "go.uber.org/zap"
"unlock-music.dev/cli/algo/common" "git.um-react.app/um/cli/algo/common"
"unlock-music.dev/cli/internal/sniff" "git.um-react.app/um/cli/internal/sniff"
) )
type Decoder struct { type Decoder struct {
@@ -27,9 +26,19 @@ type Decoder struct {
decodedKey []byte // decodedKey is the decoded key for cipher decodedKey []byte // decodedKey is the decoded key for cipher
cipher common.StreamDecoder cipher common.StreamDecoder
rawMetaExtra1 int songID int
rawMetaExtra2 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 logger *zap.Logger
} }
@@ -40,6 +49,8 @@ func (d *Decoder) Read(p []byte) (int, error) {
if n > 0 { if n > 0 {
d.cipher.Decrypt(p[:n], d.offset) d.cipher.Decrypt(p[:n], d.offset)
d.offset += n d.offset += n
_, _ = d.probeBuf.Write(p[:n]) // bytes.Buffer.Write never return error
} }
return n, err return n, err
} }
@@ -48,6 +59,23 @@ func NewDecoder(p *common.DecoderParams) common.Decoder {
return &Decoder{raw: p.Reader, params: p, logger: p.Logger} 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 { func (d *Decoder) Validate() error {
// search & derive key // search & derive key
err := d.searchKey() err := d.searchKey()
@@ -56,18 +84,9 @@ func (d *Decoder) Validate() error {
} }
// check cipher type and init decode cipher // check cipher type and init decode cipher
if len(d.decodedKey) > 300 { d.cipher, err = NewQmcCipherDecoder(d.decodedKey)
d.cipher, err = newRC4Cipher(d.decodedKey) if err != nil {
if err != nil { return fmt.Errorf("qmc init cipher: %w", err)
return err
}
} else if len(d.decodedKey) != 0 {
d.cipher, err = newMapCipher(d.decodedKey)
if err != nil {
return err
}
} else {
d.cipher = newStaticCipher()
} }
// test with first 16 bytes // test with first 16 bytes
@@ -81,6 +100,9 @@ func (d *Decoder) Validate() error {
} }
d.audio = io.LimitReader(d.raw, int64(d.audioLen)) d.audio = io.LimitReader(d.raw, int64(d.audioLen))
// prepare for sniffing metadata
d.probeBuf = bytes.NewBuffer(make([]byte, 0, d.audioLen))
return nil return nil
} }
@@ -90,7 +112,7 @@ func (d *Decoder) validateDecode() error {
return fmt.Errorf("qmc seek to start: %w", err) return fmt.Errorf("qmc seek to start: %w", err)
} }
buf := make([]byte, 16) buf := make([]byte, 64)
if _, err := io.ReadFull(d.raw, buf); err != nil { if _, err := io.ReadFull(d.raw, buf); err != nil {
return fmt.Errorf("qmc read header: %w", err) return fmt.Errorf("qmc read header: %w", err)
} }
@@ -110,14 +132,15 @@ func (d *Decoder) searchKey() (err error) {
} }
fileSize := int(fileSizeM4) + 4 fileSize := int(fileSizeM4) + 4
//goland:noinspection GoBoolExpressions if key, ok := d.params.CryptoParams.QmcKeys.Get(d.params.FilePath); ok {
if runtime.GOOS == "darwin" && !strings.HasPrefix(d.params.Extension, ".qmc") { d.logger.Debug("QQMusic Mac Legacy file", zap.String("file", d.params.FilePath), zap.String("key", key))
d.decodedKey, err = readKeyFromMMKV(d.params.FilePath, d.logger) d.decodedKey, err = deriveKey([]byte(key))
if err == nil { if err == nil {
d.audioLen = fileSize d.audioLen = fileSize
return return nil
} }
d.logger.Warn("read key from mmkv failed", zap.Error(err)) d.decodedKey = nil
d.logger.Warn("could not derive key, skip", zap.Error(err))
} }
suffixBuf := make([]byte, 4) suffixBuf := make([]byte, 4)
@@ -130,6 +153,17 @@ func (d *Decoder) searchKey() (err error) {
return d.readRawMetaQTag() return d.readRawMetaQTag()
case "STag": case "STag":
return errors.New("qmc: file with 'STag' suffix doesn't contains media key") return errors.New("qmc: file with 'STag' suffix doesn't contains media key")
// 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.decodedKey, err = deriveKey([]byte(key))
}
return err
default: default:
size := binary.LittleEndian.Uint32(suffixBuf) size := binary.LittleEndian.Uint32(suffixBuf)
@@ -160,11 +194,7 @@ func (d *Decoder) readRawKey(rawKeyLen int64) error {
rawKeyData = bytes.TrimRight(rawKeyData, "\x00") rawKeyData = bytes.TrimRight(rawKeyData, "\x00")
d.decodedKey, err = deriveKey(rawKeyData) d.decodedKey, err = deriveKey(rawKeyData)
if err != nil { return err
return err
}
return nil
} }
func (d *Decoder) readRawMetaQTag() error { func (d *Decoder) readRawMetaQTag() error {
@@ -199,7 +229,7 @@ func (d *Decoder) readRawMetaQTag() error {
return err return err
} }
d.rawMetaExtra1, err = strconv.Atoi(items[1]) d.songID, err = strconv.Atoi(items[1])
if err != nil { if err != nil {
return err return err
} }
@@ -229,12 +259,20 @@ func init() {
"6d3461", //QQ Music Weiyun M4a "6d3461", //QQ Music Weiyun M4a
"776176", //QQ Music Weiyun Wav "776176", //QQ Music Weiyun Wav
"mgg", "mgg1", "mggl", //QQ Music New Ogg "mmp4", // QQ Music MP4 Container, tipically used for Dolby EAC3 stream
"mflac", "mflac0", //QQ Music New Flac
"mflach", // QQ Music Flac (storing key in dedicate MMKV)
} }
for _, ext := range supportedExts { for _, ext := range supportedExts {
common.RegisterDecoder(ext, false, NewDecoder) common.RegisterDecoder(ext, false, NewDecoder)
} }
// 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,93 @@
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[0x04:0x0C],
}
if !bytes.Equal(tag.TagMagic, []byte("musicex\x00")) {
return nil, errors.New("MusicEx magic mismatch")
}
if tag.TagVersion != 1 {
return nil, errors.New(fmt.Sprintf("unsupported musicex tag version. expecting 1, got %d", tag.TagVersion))
}
if tag.TagSize < 0xC0 {
return nil, errors.New(fmt.Sprintf("unsupported musicex tag size. expecting at least 0xC0, got 0x%02x", tag.TagSize))
}
buffer = make([]byte, tag.TagSize)
bytesRead, err = f.Read(buffer)
if err != nil {
return nil, 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.WithoutEmpty(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
}

View File

@@ -8,7 +8,7 @@ import (
"reflect" "reflect"
"testing" "testing"
"unlock-music.dev/cli/algo/common" "git.um-react.app/um/cli/algo/common"
) )
func loadTestDataQmcDecoder(filename string) ([]byte, []byte, error) { func loadTestDataQmcDecoder(filename string) ([]byte, []byte, error) {

View File

@@ -6,8 +6,8 @@ import (
"fmt" "fmt"
"io" "io"
"unlock-music.dev/cli/algo/common" "git.um-react.app/um/cli/algo/common"
"unlock-music.dev/cli/internal/sniff" "git.um-react.app/um/cli/internal/sniff"
) )
var replaceHeader = []byte{0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70} var replaceHeader = []byte{0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70}

View File

@@ -6,7 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"unlock-music.dev/cli/algo/common" "git.um-react.app/um/cli/algo/common"
) )
var ( var (

View File

@@ -5,8 +5,8 @@ import (
"fmt" "fmt"
"io" "io"
"unlock-music.dev/cli/algo/common" "git.um-react.app/um/cli/algo/common"
"unlock-music.dev/cli/internal/sniff" "git.um-react.app/um/cli/internal/sniff"
) )
type Decoder struct { type Decoder struct {

View File

@@ -2,10 +2,12 @@ package main
import ( import (
"bytes" "bytes"
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"os/signal"
"path/filepath" "path/filepath"
"runtime" "runtime"
"runtime/debug" "runtime/debug"
@@ -13,24 +15,26 @@ import (
"strings" "strings"
"time" "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" "github.com/urfave/cli/v2"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore"
"unlock-music.dev/cli/algo/common"
_ "unlock-music.dev/cli/algo/kgm"
_ "unlock-music.dev/cli/algo/kwm"
_ "unlock-music.dev/cli/algo/ncm"
_ "unlock-music.dev/cli/algo/qmc"
_ "unlock-music.dev/cli/algo/tm"
_ "unlock-music.dev/cli/algo/xiami"
_ "unlock-music.dev/cli/algo/ximalaya"
"unlock-music.dev/cli/internal/logging"
"unlock-music.dev/cli/internal/sniff"
) )
var AppVersion = "v0.0.6" var AppVersion = "custom"
var logger, _ = logging.NewZapLogger() // TODO: inject logger to application, instead of using global logger var logger = setupLogger(false) // TODO: inject logger to application, instead of using global logger
func main() { func main() {
module, ok := debug.ReadBuildInfo() module, ok := debug.ReadBuildInfo()
@@ -40,37 +44,82 @@ func main() {
app := cli.App{ app := cli.App{
Name: "Unlock Music CLI", Name: "Unlock Music CLI",
HelpName: "um", HelpName: "um",
Usage: "Unlock your encrypted music file https://git.unlock-music.dev/um/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), Version: fmt.Sprintf("%s (%s,%s/%s)", AppVersion, runtime.Version(), runtime.GOOS, runtime.GOARCH),
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.StringFlag{Name: "input", Aliases: []string{"i"}, Usage: "path to input file or dir", Required: false}, &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: "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: "remove-source", Aliases: []string{"rs"}, Usage: "remove source file", Required: false, Value: false},
&cli.BoolFlag{Name: "skip-noop", Aliases: []string{"n"}, Usage: "skip noop decoder", Required: false, Value: true}, &cli.BoolFlag{Name: "skip-noop", Aliases: []string{"n"}, Usage: "skip noop decoder", Required: false, Value: true},
&cli.BoolFlag{Name: "supported-ext", Usage: "Show supported file extensions and exit", Required: false, Value: false}, &cli.BoolFlag{Name: "verbose", Aliases: []string{"V"}, Usage: "verbose logging", Required: false, Value: false},
&cli.BoolFlag{Name: "update-metadata", Usage: "update metadata & album art from network", Required: false, Value: false},
&cli.BoolFlag{Name: "overwrite", Usage: "overwrite output file without asking", Required: false, Value: false},
&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, Action: appMain,
Copyright: fmt.Sprintf("Copyright (c) 2020 - %d Unlock Music https://git.unlock-music.dev/um/cli/src/branch/master/LICENSE", time.Now().Year()), 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, HideHelpCommand: true,
UsageText: "um [-o /path/to/output/dir] [--extra-flags] [-i] /path/to/input", UsageText: "um [-o /path/to/output/dir] [--extra-flags] [-i] /path/to/input",
} }
err := app.Run(os.Args) err := app.Run(os.Args)
if err != nil { if err != nil {
logger.Fatal("run app failed", zap.Error(err)) logger.Fatal("run app failed", zap.Error(err))
} }
} }
func printSupportedExtensions() { func printSupportedExtensions() {
var exts []string var exts []string
for ext := range common.DecoderRegistry { 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) exts = append(exts, ext)
} }
sort.Strings(exts) sort.Strings(exts)
for _, ext := range exts { for _, ext := range exts {
fmt.Printf("%s: %d\n", ext, len(common.DecoderRegistry[ext])) 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) { 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") { if c.Bool("supported-ext") {
printSupportedExtensions() printSupportedExtensions()
return nil return nil
@@ -79,10 +128,7 @@ func appMain(c *cli.Context) (err error) {
if input == "" { if input == "" {
switch c.Args().Len() { switch c.Args().Len() {
case 0: case 0:
input, err = os.Getwd() input = cwd
if err != nil {
return err
}
case 1: case 1:
input = c.Args().Get(0) input = c.Args().Get(0)
default: default:
@@ -90,26 +136,34 @@ func appMain(c *cli.Context) (err error) {
} }
} }
output := c.String("output") input, absErr := filepath.Abs(input)
if output == "" { if absErr != nil {
var err error return fmt.Errorf("get abs path failed: %w", absErr)
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") output := c.String("output")
removeSource := c.Bool("remove-source")
inputStat, err := os.Stat(input) inputStat, err := os.Stat(input)
if err != nil { if err != nil {
return err 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) outputStat, err := os.Stat(output)
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
@@ -122,102 +176,289 @@ func appMain(c *cli.Context) (err error) {
return errors.New("output should be a writable directory") return errors.New("output should be a writable directory")
} }
// QMC: Load keys
qmcKeys, err := qmc.LoadMMKVOrDefault(c.String("qmc-mmkv"), c.String("qmc-mmkv-key"), logger)
if err != nil {
return err
}
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() { if inputStat.IsDir() {
return dealDirectory(input, output, skipNoop, removeSource) watchDir := c.Bool("watch")
} else { if !watchDir {
allDec := common.GetDecoder(inputStat.Name(), skipNoop) return proc.processDir(input)
if len(allDec) == 0 { } else {
logger.Fatal("skipping while no suitable decoder") return proc.watchDir(input)
} }
return tryDecFile(input, output, allDec, removeSource) } else {
return proc.processFile(input)
} }
} }
func dealDirectory(inputDir string, outputDir string, skipNoop bool, removeSource 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) items, err := os.ReadDir(inputDir)
if err != nil { if err != nil {
return err return err
} }
var lastError error = nil
for _, item := range items { for _, item := range items {
filePath := filepath.Join(inputDir, item.Name())
if item.IsDir() { if item.IsDir() {
continue if err = p.processDir(filePath); err != nil {
} lastError = err
allDec := common.GetDecoder(item.Name(), skipNoop) }
if len(allDec) == 0 {
logger.Info("skipping while no suitable decoder", zap.String("file", item.Name()))
continue continue
} }
filePath := filepath.Join(inputDir, item.Name()) if err := p.processFile(filePath); err != nil {
err := tryDecFile(filePath, outputDir, allDec, removeSource) lastError = err
if err != nil { logger.Error("conversion failed", zap.String("source", item.Name()), zap.Error(err))
logger.Error("conversion failed", zap.String("source", filePath), zap.Error(err))
} }
} }
if lastError != nil {
return fmt.Errorf("last error: %w", lastError)
}
return nil return nil
} }
func tryDecFile(inputFile string, outputDir string, allDec []common.NewDecoderFunc, removeSource bool) error { 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 {
return errors.New("skipping while no suitable decoder")
}
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 (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) file, err := os.Open(inputFile)
if err != nil { if err != nil {
return err return err
} }
defer file.Close() defer file.Close()
logger := logger.With(zap.String("source", inputFile))
decParams := &common.DecoderParams{ pDec, decoderFactory, err := p.findDecoder(allDec, &common.DecoderParams{
Reader: file, Reader: file,
Extension: filepath.Ext(inputFile), Extension: filepath.Ext(inputFile),
FilePath: inputFile, FilePath: inputFile,
Logger: logger.With(zap.String("source", inputFile)), Logger: logger,
CryptoParams: p.crypto,
})
if err != nil {
return err
} }
dec := *pDec
var dec common.Decoder params := &ffmpeg.UpdateMetadataParams{}
for _, decFunc := range allDec {
dec = decFunc(decParams)
if err := dec.Validate(); err == nil {
break
} else {
logger.Warn("try decode failed", zap.Error(err))
dec = nil
}
}
if dec == nil {
return errors.New("no any decoder can resolve the file")
}
header := bytes.NewBuffer(nil) header := bytes.NewBuffer(nil)
_, err = io.CopyN(header, dec, 16) _, err = io.CopyN(header, dec, 64)
if err != nil { if err != nil {
return fmt.Errorf("read header failed: %w", err) return fmt.Errorf("read header failed: %w", err)
} }
audio := io.MultiReader(header, dec)
params.AudioExt = sniff.AudioExtensionWithFallback(header.Bytes(), ".mp3")
outExt := sniff.AudioExtensionWithFallback(header.Bytes(), ".mp3") if p.updateMetadata {
inFilename := strings.TrimSuffix(filepath.Base(inputFile), filepath.Ext(inputFile)) if audioMetaGetter, ok := dec.(common.AudioMetaGetter); ok {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
outPath := filepath.Join(outputDir, inFilename+outExt) // since ffmpeg doesn't support multiple input streams,
outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) // 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)
}
defer os.Remove(params.Audio)
params.Meta, err = audioMetaGetter.GetAudioMeta(ctx)
if err != nil {
logger.Warn("get audio meta failed", zap.Error(err))
}
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 {
params.AlbumArtExt = imgExt
params.AlbumArt = cover
}
}
}
inputRelDir, err := filepath.Rel(p.inputDir, filepath.Dir(inputFile))
if err != nil { if err != nil {
return err return fmt.Errorf("get relative dir failed: %w", err)
}
defer outFile.Close()
if _, err := io.Copy(outFile, header); err != nil {
return err
}
if _, err := io.Copy(outFile, dec); err != nil {
return err
} }
// if source file need to be removed inFilename := strings.TrimSuffix(filepath.Base(inputFile), decoderFactory.Suffix)
if removeSource { outPath := filepath.Join(p.outputDir, inputRelDir, inFilename+params.AudioExt)
err := os.RemoveAll(inputFile)
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 { if err != nil {
return err return err
} }
logger.Info("successfully converted, and source file is removed", zap.String("source", inputFile), zap.String("destination", outPath)) defer outFile.Close()
if _, err := io.Copy(outFile, audio); err != nil {
return err
}
} else { } else {
logger.Info("successfully converted", zap.String("source", inputFile), zap.String("destination", outPath)) 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 return nil
} }

41
go.mod
View File

@@ -1,23 +1,34 @@
module unlock-music.dev/cli module git.um-react.app/um/cli
go 1.19 go 1.25.1
require ( require (
github.com/samber/lo v1.36.0 github.com/fsnotify/fsnotify v1.8.0
github.com/urfave/cli/v2 v2.23.6 github.com/go-flac/flacpicture v0.3.0
go.uber.org/zap v1.24.0 github.com/go-flac/flacvorbis v0.2.0
golang.org/x/crypto v0.3.0 github.com/go-flac/go-flac v1.0.0
golang.org/x/exp v0.0.0-20221204150635-6dcec336b2bb github.com/samber/lo v1.47.0
golang.org/x/text v0.5.0 github.com/unlock-music/go-mmkv v0.1.1
unlock-music.dev/mmkv v0.0.0-20221204231432-41a75bd29939 github.com/urfave/cli/v2 v2.27.5
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.29.0
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
golang.org/x/text v0.20.0
modernc.org/sqlite v1.37.0
) )
require ( require (
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/golang/protobuf v1.5.2 // 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 v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/multierr v1.8.0 // indirect golang.org/x/sys v0.31.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect modernc.org/libc v1.62.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.9.1 // indirect
) )

125
go.sum
View File

@@ -1,54 +1,85 @@
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 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 v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.36.0 h1:4LaOxH1mHnbDGhTVE0i1z8v/lWaQW8AIfOD3HU4mSaw= github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
github.com/samber/lo v1.36.0/go.mod h1:HLeWcJRRyLKp3+/XBJvOrerCQn9mhdKMHyd7IRlgeQ8= github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/unlock-music/go-mmkv v0.1.1 h1:1w3shjaBMp58jRrRkxPghnMpvyUoP4ZkA++W9ueFpE8=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/unlock-music/go-mmkv v0.1.1/go.mod h1:E1h/HBpWPOKnCQw6LSQZkMpzxQojGDe+T5f/DRDXBsc=
github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M= github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
github.com/urfave/cli/v2 v2.23.6 h1:iWmtKD+prGo1nKUtLO0Wg4z9esfBM4rAV4QRLQiEmJ4= github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/urfave/cli/v2 v2.23.6/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/exp v0.0.0-20221204150635-6dcec336b2bb h1:QIsP/NmClBICkqnJ4rSIhnrGiGR7Yv9ZORGGnmmLTPk= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/exp v0.0.0-20221204150635-6dcec336b2bb/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
unlock-music.dev/mmkv v0.0.0-20221204231432-41a75bd29939 h1:qWv734RbYjIHtHhZSRbdSyAEJ5K1rWcPSuUOen86tvI= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
unlock-music.dev/mmkv v0.0.0-20221204231432-41a75bd29939/go.mod h1:1+Hdsrk8gl1i4/oxOnAhx6y51DAcUfi2CDni6Qhk8Kw= modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
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.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/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.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
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,94 @@
package ffmpeg
import (
"context"
"go.uber.org/zap"
"mime"
"strings"
"github.com/go-flac/flacpicture"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
"golang.org/x/exp/slices"
)
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,6 +1,11 @@
package sniff package sniff
import "bytes" import (
"bytes"
"encoding/binary"
"golang.org/x/exp/slices"
)
type Sniffer interface { type Sniffer interface {
Sniff(header []byte) bool Sniff(header []byte) bool
@@ -8,7 +13,7 @@ type Sniffer interface {
var audioExtensions = map[string]Sniffer{ var audioExtensions = map[string]Sniffer{
// ref: https://mimesniff.spec.whatwg.org // ref: https://mimesniff.spec.whatwg.org
".mp3": prefixSniffer("ID3"), ".mp3": prefixSniffer("ID3"), // todo: check mp3 without ID3v2 tag
".ogg": prefixSniffer("OggS"), ".ogg": prefixSniffer("OggS"),
".wav": prefixSniffer("RIFF"), ".wav": prefixSniffer("RIFF"),
@@ -19,8 +24,8 @@ var audioExtensions = map[string]Sniffer{
}, },
// ref: https://www.garykessler.net/library/file_sigs.html // ref: https://www.garykessler.net/library/file_sigs.html
".m4a": mpeg4Sniffer{}, // MPEG-4 container, m4a treat as audio ".m4a": m4aSniffer{}, // MPEG-4 container, Apple Lossless Audio Codec
".aac": prefixSniffer{0xFF, 0xF1}, // MPEG-4 AAC-LC ".mp4": &mpeg4Sniffer{}, // MPEG-4 container, other fallback
".flac": prefixSniffer("fLaC"), // ref: https://xiph.org/flac/format.html ".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 ".dff": prefixSniffer("FRM8"), // DSDIFF, ref: https://www.sonicstudio.com/pdf/dsd/DSDIFF_1.5_Spec.pdf
@@ -54,8 +59,48 @@ func (s prefixSniffer) Sniff(header []byte) bool {
return bytes.HasPrefix(header, s) 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{} type mpeg4Sniffer struct{}
func (mpeg4Sniffer) Sniff(header []byte) bool { func (s *mpeg4Sniffer) Sniff(header []byte) bool {
return len(header) >= 8 && bytes.Equal([]byte("ftyp"), header[4:8]) 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
}

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)
}

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