19 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
31 changed files with 416 additions and 702 deletions

View File

@@ -1,148 +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: [
'um-*.tar.gz',
'um-*.zip',
],
checksum: 'sha256',
draft: true,
title: '${DRONE_TAG}',
},
};
local StepGoBuild(GOOS, GOARCH) = {
local windows = GOOS == 'windows',
local archiveExt = if windows then 'zip' else 'tar.gz',
local filepath = 'dist/um-%s-%s-%s.%s' % [GOOS, GOARCH, '$(git describe --tags --always)', archiveExt],
local archive = if windows then [
// Ensure zip is installed
'command -v zip >/dev/null || (apt update && apt install -y zip)',
'zip -9 -j -r "%s" $DIST_DIR' % filepath,
] else [
'tar -c -C $DIST_DIR um | gzip -9 > "%s"' % filepath,
],
name: 'go build %s/%s' % [GOOS, GOARCH],
image: 'golang:1.23',
environment: {
GOOS: GOOS,
GOARCH: GOARCH,
GOPROXY: 'https://goproxy.io,direct',
},
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',
] + archive,
};
local StepUploadArtifact(GOOS, GOARCH) = {
local windows = GOOS == 'windows',
local archiveExt = if windows then 'zip' else 'tar.gz',
local filename = 'um-%s-%s-%s.%s' % [GOOS, GOARCH, '$(git describe --tags --always)', archiveExt],
local filepath = 'dist/%s' % filename,
local pkgname = '${DRONE_REPO_NAME}-build',
name: 'upload artifact',
image: 'golang:1.23', // 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.23',
environment: {
GOPROXY: 'https://goproxy.io,direct',
},
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.23',
environment: {
GOPROXY: 'https://goproxy.io,direct',
},
commands: ['go test -v ./...'],
},
StepGoBuild('linux', 'amd64'),
StepGoBuild('linux', 'arm64'),
StepGoBuild('linux', '386'),
StepGoBuild('windows', 'amd64'),
StepGoBuild('windows', 'arm64'),
StepGoBuild('windows', '386'),
StepGoBuild('darwin', 'amd64'),
StepGoBuild('darwin', 'arm64'),
{
name: 'prepare root',
image: 'golang:1.23',
commands: [
'mv dist/*.tar.gz dist/*.zip ./',
],
},
CreateRelease(),
],
trigger: {
event: ['tag'],
},
};
[
PipelineBuild('linux', 'amd64', true),
PipelineBuild('windows', 'amd64', false),
PipelineBuild('darwin', 'amd64', false),
PipelineRelease(),
]

View File

@@ -1,257 +0,0 @@
---
kind: pipeline
name: build linux/amd64
steps:
- commands:
- git fetch --tags
image: alpine/git
name: fetch tags
- commands:
- go test -v ./...
environment:
GOPROXY: https://goproxy.io,direct
image: golang:1.23
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 -c -C $DIST_DIR um | gzip -9 > "dist/um-linux-amd64-$(git describe --tags
--always).tar.gz"
environment:
GOARCH: amd64
GOOS: linux
GOPROXY: https://goproxy.io,direct
image: golang:1.23
name: go build linux/amd64
- commands:
- curl --fail --include --user "um-release-bot:$GITEA_API_KEY" --upload-file "dist/um-linux-amd64-$(git
describe --tags --always).tar.gz" "$DRONE_GITEA_SERVER/api/packages/${DRONE_REPO_NAMESPACE}/generic/${DRONE_REPO_NAME}-build/${DRONE_BUILD_NUMBER}/um-linux-amd64-$(git
describe --tags --always).tar.gz"
- sha256sum dist/um-linux-amd64-$(git describe --tags --always).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.23
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
- command -v zip >/dev/null || (apt update && apt install -y zip)
- zip -9 -j -r "dist/um-windows-amd64-$(git describe --tags --always).zip" $DIST_DIR
environment:
GOARCH: amd64
GOOS: windows
GOPROXY: https://goproxy.io,direct
image: golang:1.23
name: go build windows/amd64
- commands:
- curl --fail --include --user "um-release-bot:$GITEA_API_KEY" --upload-file "dist/um-windows-amd64-$(git
describe --tags --always).zip" "$DRONE_GITEA_SERVER/api/packages/${DRONE_REPO_NAMESPACE}/generic/${DRONE_REPO_NAME}-build/${DRONE_BUILD_NUMBER}/um-windows-amd64-$(git
describe --tags --always).zip"
- sha256sum dist/um-windows-amd64-$(git describe --tags --always).zip
- 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.23
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 -c -C $DIST_DIR um | gzip -9 > "dist/um-darwin-amd64-$(git describe --tags
--always).tar.gz"
environment:
GOARCH: amd64
GOOS: darwin
GOPROXY: https://goproxy.io,direct
image: golang:1.23
name: go build darwin/amd64
- commands:
- curl --fail --include --user "um-release-bot:$GITEA_API_KEY" --upload-file "dist/um-darwin-amd64-$(git
describe --tags --always).tar.gz" "$DRONE_GITEA_SERVER/api/packages/${DRONE_REPO_NAMESPACE}/generic/${DRONE_REPO_NAME}-build/${DRONE_BUILD_NUMBER}/um-darwin-amd64-$(git
describe --tags --always).tar.gz"
- sha256sum dist/um-darwin-amd64-$(git describe --tags --always).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.23
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 ./...
environment:
GOPROXY: https://goproxy.io,direct
image: golang:1.23
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 -c -C $DIST_DIR um | gzip -9 > "dist/um-linux-amd64-$(git describe --tags
--always).tar.gz"
environment:
GOARCH: amd64
GOOS: linux
GOPROXY: https://goproxy.io,direct
image: golang:1.23
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 -c -C $DIST_DIR um | gzip -9 > "dist/um-linux-arm64-$(git describe --tags
--always).tar.gz"
environment:
GOARCH: arm64
GOOS: linux
GOPROXY: https://goproxy.io,direct
image: golang:1.23
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 -c -C $DIST_DIR um | gzip -9 > "dist/um-linux-386-$(git describe --tags --always).tar.gz"
environment:
GOARCH: "386"
GOOS: linux
GOPROXY: https://goproxy.io,direct
image: golang:1.23
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
- command -v zip >/dev/null || (apt update && apt install -y zip)
- zip -9 -j -r "dist/um-windows-amd64-$(git describe --tags --always).zip" $DIST_DIR
environment:
GOARCH: amd64
GOOS: windows
GOPROXY: https://goproxy.io,direct
image: golang:1.23
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
- command -v zip >/dev/null || (apt update && apt install -y zip)
- zip -9 -j -r "dist/um-windows-arm64-$(git describe --tags --always).zip" $DIST_DIR
environment:
GOARCH: arm64
GOOS: windows
GOPROXY: https://goproxy.io,direct
image: golang:1.23
name: go build windows/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
- command -v zip >/dev/null || (apt update && apt install -y zip)
- zip -9 -j -r "dist/um-windows-386-$(git describe --tags --always).zip" $DIST_DIR
environment:
GOARCH: "386"
GOOS: windows
GOPROXY: https://goproxy.io,direct
image: golang:1.23
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 -c -C $DIST_DIR um | gzip -9 > "dist/um-darwin-amd64-$(git describe --tags
--always).tar.gz"
environment:
GOARCH: amd64
GOOS: darwin
GOPROXY: https://goproxy.io,direct
image: golang:1.23
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 -c -C $DIST_DIR um | gzip -9 > "dist/um-darwin-arm64-$(git describe --tags
--always).tar.gz"
environment:
GOARCH: arm64
GOOS: darwin
GOPROXY: https://goproxy.io,direct
image: golang:1.23
name: go build darwin/arm64
- commands:
- mv dist/*.tar.gz dist/*.zip ./
image: golang:1.23
name: prepare root
- 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:
- um-*.tar.gz
- um-*.zip
title: ${DRONE_TAG}
trigger:
event:
- tag
type: docker

View File

@@ -1,5 +1,6 @@
name: Build
on:
workflow_dispatch:
push:
paths:
- "**/*.go"
@@ -25,13 +26,8 @@ jobs:
- darwin
GOARCH:
- "amd64"
- "386"
- "arm64"
exclude:
- GOOS: darwin
GOARCH: "386"
include:
- GOOS: windows
BIN_SUFFIX: ".exe"
@@ -42,10 +38,10 @@ jobs:
with:
fetch-depth: 0
- name: Set up Go 1.23
- name: Set up Go 1.25
uses: actions/setup-go@v5
with:
go-version: ^1.23
go-version: ^1.25
- name: Setup vars
id: vars
@@ -73,6 +69,14 @@ jobs:
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
@@ -81,26 +85,11 @@ jobs:
with:
path: prepare
pattern: um-*
- name: prepare archive
- name: repack archive
run: |
for exe in prepare/*/um-*.exe; do
zip -9 -j "dist/$(basename "$exe" .exe).zip" "$exe"
rm -f "$exe"
done
for exe in prepare/*/um-*; do
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 \
-cv \
-C "$(dirname "$exe")" \
"$(basename "$exe")" \
| gzip -9 > "dist/$(basename "$exe").tar.gz"
rm -f "$exe"
done
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:

3
.gitignore vendored
View File

@@ -7,3 +7,6 @@
/um-*.tar.gz
/um-*.zip
/.vscode
/prepare
/dist

View File

@@ -1,10 +1,10 @@
# 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)
- [Release Download](https://git.unlock-music.dev/um/cli/releases/latest)
- [Latest Build](https://git.unlock-music.dev/um/-/packages/generic/cli-build/)
- [![Build Status](https://git.um-react.app/um/cli/actions/workflows/build.yml/badge.svg)](https://git.um-react.app/um/cli/actions?workflow=build.yml)
- [Release Download](https://git.um-react.app/um/cli/releases/latest)
- [Latest Build](https://git.um-react.app/um/cli/actions)
> **WARNING**
> 在本站 fork 不会起到备份的作用,只会浪费服务器储存空间。如无必要请勿 fork 该仓库。
@@ -16,13 +16,13 @@ Original: Web Edition https://git.unlock-music.dev/um/web
## Release
[Latest release](https://git.unlock-music.dev/um/cli/releases/latest).
[Latest release](https://git.um-react.app/um/cli/releases/latest).
## Install from source
- Requirements: **Golang 1.23.3**
1. run `go install unlock-music.dev/cli/cmd/um@master`
1. run `go install git.um-react.app/um/cli/cmd/um@main`
### Build from repo source
@@ -36,13 +36,3 @@ It will produce `um` or `um.exe` (Windows).
- Drag the encrypted file to `um.exe` (Tested on Windows)
- Run: `./um [-o <output dir>] [-i] <input dir/file>`
- Use `./um -h` to show help menu
## Update CI pipeline
1. Install [Drone CI binary](https://docs.drone.io/cli/install/)
2. Edit `.drone.jsonnet`
3. Update drone CI pipeline:
```sh
drone jsonnet --format --stream
```

View File

@@ -5,9 +5,25 @@ import (
"path/filepath"
"strings"
"git.um-react.app/um/cli/internal/utils"
"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 {
Reader io.ReadSeeker // required
Extension string // required, source extension, eg. ".mp3"
@@ -16,8 +32,7 @@ type DecoderParams struct {
Logger *zap.Logger // required
// KuGou
KggDatabasePath string
CryptoParams CryptoParams
}
type NewDecoderFunc func(p *DecoderParams) Decoder

View File

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

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"io"
"unlock-music.dev/cli/algo/common"
"git.um-react.app/um/cli/algo/common"
)
type Decoder struct {
@@ -19,7 +19,7 @@ type Decoder struct {
}
func NewDecoder(p *common.DecoderParams) common.Decoder {
return &Decoder{rd: p.Reader, KggDatabasePath: p.KggDatabasePath}
return &Decoder{rd: p.Reader, KggDatabasePath: p.CryptoParams.KggDbPath}
}
// Validate checks if the file is a valid Kugou (.kgm, .vpr, .kgma) file.

View File

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

View File

@@ -3,9 +3,9 @@ package kgm
import (
"fmt"
"unlock-music.dev/cli/algo/common"
"unlock-music.dev/cli/algo/kgm/pc_kugou_db"
"unlock-music.dev/cli/algo/qmc"
"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) {

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

@@ -1,7 +1,7 @@
package pc_kugou_db
// ported from lib_um_crypto_rust:
// https://git.unlock-music.dev/um/lib_um_crypto_rust/src/tag/v0.1.10/um_crypto/kgm/src/pc_db_decrypt
// https://git.um-react.app/um/lib_um_crypto_rust/src/tag/v0.1.10/um_crypto/kgm/src/pc_db_decrypt
import (
"bytes"
@@ -28,7 +28,7 @@ var DEFAULT_MASTER_KEY = []byte{
0x73, 0x41, 0x6C, 0x54, // fixed value
}
func next_page_iv(seed uint32) uint32 {
func deriveIvSeed(seed uint32) uint32 {
var left uint32 = seed * 0x9EF4
var right uint32 = seed / 0xce26 * 0x7FFFFF07
var value uint32 = left - right
@@ -38,25 +38,28 @@ func next_page_iv(seed uint32) uint32 {
return value + 0x7FFF_FF07
}
func derive_page_aes_key(seed uint32) []byte {
master_key := make([]byte, len(DEFAULT_MASTER_KEY))
copy(master_key, DEFAULT_MASTER_KEY)
binary.LittleEndian.PutUint32(master_key[0x10:0x14], seed)
digest := md5.Sum(master_key)
return digest[:]
}
func derive_page_aes_iv(seed uint32) []byte {
// derivePageIv generates a 16-byte IV for database page.
func derivePageIv(page uint32) []byte {
iv := make([]byte, 0x10)
seed = seed + 1
page = page + 1
for i := 0; i < 0x10; i += 4 {
seed = next_page_iv(seed)
binary.LittleEndian.PutUint32(iv[i:i+4], seed)
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))
@@ -78,43 +81,40 @@ func aes128cbcDecryptNoPadding(buffer, key, iv []byte) error {
return nil
}
func decrypt_db_page(buffer []byte, page_number uint32) error {
key := derive_page_aes_key(page_number)
iv := derive_page_aes_iv(page_number)
// 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 decrypt_page_1(page []byte) error {
if err := validate_page_1_header(page); err != nil {
func decryptPage1(buffer []byte) error {
if err := validateFirstPageHeader(buffer); err != nil {
return err
}
// Backup expected hdr value
expected_hdr_value := make([]byte, 8)
copy(expected_hdr_value, page[0x10:0x18])
// Copy encrypted hdr over
hdr := page[:0x10]
copy(page[0x10:0x18], hdr[0x08:0x10])
if err := decrypt_db_page(page[0x10:], 1); err != nil {
// 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(page[0x10:0x18], expected_hdr_value[:8]) {
if !bytes.Equal(buffer[0x10:0x18], expectedHeader) {
return fmt.Errorf("decrypt page 1 failed")
}
// Apply SQLite header
copy(hdr, SQLITE_HEADER)
// Restore SQLite file header
copy(buffer[:0x10], SQLITE_HEADER)
return nil
}
func validate_page_1_header(header []byte) error {
func validateFirstPageHeader(header []byte) error {
o10 := binary.LittleEndian.Uint32(header[0x10:0x14])
o14 := binary.LittleEndian.Uint32(header[0x14:0x18])
@@ -126,28 +126,28 @@ func validate_page_1_header(header []byte) error {
return nil
}
func decryptPcDatabase(buffer []byte) error {
db_size := len(buffer)
func decryptDatabase(buffer []byte) error {
dbSize := len(buffer)
// not encrypted
if bytes.Equal(buffer[:len(SQLITE_HEADER)], SQLITE_HEADER) {
return nil
}
if db_size%PAGE_SIZE != 0 || db_size == 0 {
return fmt.Errorf("invalid database size: %d", db_size)
if dbSize%PAGE_SIZE != 0 || dbSize == 0 {
return fmt.Errorf("invalid database size: %d", dbSize)
}
last_page := db_size / PAGE_SIZE
// page 1 is the header
if err := decrypt_page_1(buffer[:PAGE_SIZE]); err != nil {
if err := decryptPage1(buffer[:PAGE_SIZE]); err != nil {
return err
}
offset := PAGE_SIZE
for page_no := 2; page_no <= last_page; page_no++ {
if err := decrypt_db_page(buffer[offset:offset+PAGE_SIZE], uint32(page_no)); err != nil {
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
@@ -223,7 +223,7 @@ func CachedDumpEKey(dbPath string) (map[string]string, error) {
if err != nil {
return nil, err
}
if err = decryptPcDatabase(buffer); err != nil {
if err = decryptDatabase(buffer); err != nil {
return nil, err
}
dump, err = extractKeyMapping(buffer)

View File

@@ -7,7 +7,7 @@ import (
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 := derive_page_aes_key(0)
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)
}
@@ -15,7 +15,7 @@ func TestDerivePageAESKey_Page0(t *testing.T) {
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 := derive_page_aes_iv(0)
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"
"unicode"
"unlock-music.dev/cli/algo/common"
"git.um-react.app/um/cli/algo/common"
)
const magicHeader1 = "yeelion-kuwo-tme"

View File

@@ -1,10 +1,11 @@
package ncm
import (
"go.uber.org/zap"
"strings"
"unlock-music.dev/cli/algo/common"
"go.uber.org/zap"
"git.um-react.app/um/cli/algo/common"
)
type ncmMeta interface {
@@ -44,22 +45,40 @@ func (m *ncmMetaMusic) GetAlbumImageURL() string {
}
func (m *ncmMetaMusic) GetArtists() []string {
m.logger.Debug("ncm artists", zap.Any("artists", m.Artist))
m.logger.Debug("ncm artists raw", zap.Any("artists", m.Artist))
var artists []string
switch v := m.Artist.(type) {
var artists []string = nil
if jsonArtists, ok := m.Artist.([][]string); ok {
for _, artist := range jsonArtists {
for _, name := range artist {
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)
}
}
}
}
} else if artist, ok := m.Artist.(string); ok {
// #78: artist is a string type.
// https://git.unlock-music.dev/um/cli/issues/78
artists = []string{artist}
} else {
default:
// Log a warning if the artist type is unexpected and not handled.
m.logger.Warn("unexpected artist type", zap.Any("artists", m.Artist))
}
return artists
}

View File

@@ -8,13 +8,14 @@ import (
"encoding/json"
"errors"
"fmt"
"go.uber.org/zap"
"io"
"net/http"
"strings"
"unlock-music.dev/cli/algo/common"
"unlock-music.dev/cli/internal/utils"
"go.uber.org/zap"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/internal/utils"
)
const magicHeader = "CTENFDAM"

View File

@@ -1,118 +1,75 @@
package qmc
import (
"errors"
"fmt"
"os"
"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"
"golang.org/x/exp/slices"
"golang.org/x/text/unicode/norm"
"unlock-music.dev/mmkv"
)
var streamKeyVault mmkv.Vault
// TODO: move to factory
func readKeyFromMMKV(file string, logger *zap.Logger) ([]byte, error) {
if file == "" {
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)
}
func mergeMMKVKeys(keys ...common.QMCKeys) common.QMCKeys {
result := make(common.QMCKeys)
for _, k := range keys {
for key, value := range k {
result[utils.NormalizeUnicode(key)] = utils.NormalizeUnicode(value)
}
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()))
}
_, 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)
return result
}
func OpenMMKV(mmkvPath string, key string, logger *zap.Logger) error {
filePath, fileName := filepath.Split(mmkvPath)
mgr, err := mmkv.NewManager(filepath.Dir(filePath))
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 {
return fmt.Errorf("init mmkv manager: %w", err)
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()
}
// If `vaultKey` is empty, the key is ignored.
streamKeyVault, err = mgr.OpenVaultCrypto(fileName, key)
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 {
return fmt.Errorf("open mmkv vault: %w", err)
logger.Error("LoadMMKV: failed to create reader", zap.Error(err))
return nil, fmt.Errorf("LoadMMKV: NewMMKVReader error: %w", err)
}
logger.Debug("mmkv vault opened", zap.Strings("keys", streamKeyVault.Keys()))
return nil
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
}
// /
func readKeyFromMMKVCustom(mid string) ([]byte, error) {
if streamKeyVault == nil {
return nil, fmt.Errorf("mmkv vault not loaded")
}
// get ekey from mmkv vault
eKey, err := streamKeyVault.GetBytes(mid)
if err != nil {
return nil, fmt.Errorf("get eKey error: %w", err)
}
return deriveKey(eKey)
}
// / getRelativeMMKVDir get mmkv dir relative to file (legacy QQMusic for macOS behaviour)
// getRelativeMMKVDir get mmkv dir relative to file (legacy QQMusic for macOS behaviour)
func getRelativeMMKVDir(file string) (string, error) {
mmkvDir := filepath.Join(filepath.Dir(file), "../mmkv")
if _, err := os.Stat(mmkvDir); err != nil {
@@ -149,10 +106,3 @@ func getDefaultMMKVDir() (string, error) {
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"
"fmt"
"io"
"runtime"
"strconv"
"strings"
"go.uber.org/zap"
"unlock-music.dev/cli/algo/common"
"unlock-music.dev/cli/internal/sniff"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/internal/sniff"
)
type Decoder struct {
@@ -133,14 +132,15 @@ func (d *Decoder) searchKey() (err error) {
}
fileSize := int(fileSizeM4) + 4
//goland:noinspection GoBoolExpressions
if runtime.GOOS == "darwin" && !strings.HasPrefix(d.params.Extension, ".qmc") {
d.decodedKey, err = readKeyFromMMKV(d.params.FilePath, d.logger)
if key, ok := d.params.CryptoParams.QmcKeys.Get(d.params.FilePath); ok {
d.logger.Debug("QQMusic Mac Legacy file", zap.String("file", d.params.FilePath), zap.String("key", key))
d.decodedKey, err = deriveKey([]byte(key))
if err == nil {
d.audioLen = fileSize
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)
@@ -153,17 +153,17 @@ func (d *Decoder) searchKey() (err error) {
return d.readRawMetaQTag()
case "STag":
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)
d.decodedKey, err = readKeyFromMMKVCustom(footer.MediaFileName)
if err != nil {
return err
if key, ok := d.params.CryptoParams.QmcKeys.Get(footer.MediaFileName); ok {
d.decodedKey, err = deriveKey([]byte(key))
}
return nil
return err
default:
size := binary.LittleEndian.Uint32(suffixBuf)

View File

@@ -8,9 +8,9 @@ import (
"github.com/samber/lo"
"unlock-music.dev/cli/algo/common"
"unlock-music.dev/cli/algo/qmc/client"
"unlock-music.dev/cli/internal/ffmpeg"
"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) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,21 +15,21 @@ import (
"strings"
"time"
"git.um-react.app/um/cli/algo/common"
_ "git.um-react.app/um/cli/algo/kgm"
_ "git.um-react.app/um/cli/algo/kwm"
_ "git.um-react.app/um/cli/algo/ncm"
"git.um-react.app/um/cli/algo/qmc"
_ "git.um-react.app/um/cli/algo/tm"
_ "git.um-react.app/um/cli/algo/xiami"
_ "git.um-react.app/um/cli/algo/ximalaya"
"git.um-react.app/um/cli/internal/ffmpeg"
"git.um-react.app/um/cli/internal/sniff"
"git.um-react.app/um/cli/internal/utils"
"github.com/fsnotify/fsnotify"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"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/ffmpeg"
"unlock-music.dev/cli/internal/sniff"
"unlock-music.dev/cli/internal/utils"
)
var AppVersion = "custom"
@@ -44,13 +44,13 @@ func main() {
app := cli.App{
Name: "Unlock Music CLI",
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),
Flags: []cli.Flag{
&cli.StringFlag{Name: "input", Aliases: []string{"i"}, Usage: "path to input file or dir", Required: false},
&cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "path to output dir", Required: false},
&cli.StringFlag{Name: "qmc-mmkv", Aliases: []string{"db"}, Usage: "path to qmc mmkv (.crc file also required)", Required: false},
&cli.StringFlag{Name: "qmc-mmkv-key", Aliases: []string{"key"}, Usage: "mmkv password (16 ascii chars)", Required: false},
&cli.StringFlag{Name: "qmc-mmkv", Aliases: []string{"db"}, Usage: "path to QQMusic mmkv path", Required: false},
&cli.StringFlag{Name: "qmc-mmkv-key", Aliases: []string{"key"}, Usage: "QQMusic mmkv password (16 ascii chars)", Required: false},
&cli.StringFlag{Name: "kgg-db", Usage: "path to kgg db (win32 kugou v11)", Required: false},
&cli.BoolFlag{Name: "remove-source", Aliases: []string{"rs"}, Usage: "remove source file", Required: false, Value: false},
&cli.BoolFlag{Name: "skip-noop", Aliases: []string{"n"}, Usage: "skip noop decoder", Required: false, Value: true},
@@ -63,7 +63,7 @@ func main() {
},
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,
UsageText: "um [-o /path/to/output/dir] [--extra-flags] [-i] /path/to/input",
}
@@ -176,13 +176,10 @@ func appMain(c *cli.Context) (err error) {
return errors.New("output should be a writable directory")
}
if mmkv := c.String("qmc-mmkv"); mmkv != "" {
// If key is not set, the mmkv vault will be treated as unencrypted.
key := c.String("qmc-mmkv-key")
err := qmc.OpenMMKV(mmkv, key, logger)
if err != nil {
return err
}
// 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")
@@ -194,11 +191,18 @@ func appMain(c *cli.Context) (err error) {
logger: logger,
inputDir: inputDir,
outputDir: output,
kggDbPath: kggDbPath,
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() {
@@ -219,12 +223,12 @@ type processor struct {
inputDir string
outputDir string
kggDbPath string
skipNoopDecoder bool
removeSource bool
updateMetadata bool
overwriteOutput bool
crypto common.CryptoParams
}
func (p *processor) watchDir(inputDir string) error {
@@ -352,11 +356,11 @@ func (p *processor) process(inputFile string, allDec []common.DecoderFactory) er
logger := logger.With(zap.String("source", inputFile))
pDec, decoderFactory, err := p.findDecoder(allDec, &common.DecoderParams{
Reader: file,
Extension: filepath.Ext(inputFile),
FilePath: inputFile,
Logger: logger,
KggDatabasePath: p.kggDbPath,
Reader: file,
Extension: filepath.Ext(inputFile),
FilePath: inputFile,
Logger: logger,
CryptoParams: p.crypto,
})
if err != nil {
return err

9
go.mod
View File

@@ -1,6 +1,6 @@
module unlock-music.dev/cli
module git.um-react.app/um/cli
go 1.23.3
go 1.25.1
require (
github.com/fsnotify/fsnotify v1.8.0
@@ -8,12 +8,13 @@ require (
github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/go-flac v1.0.0
github.com/samber/lo v1.47.0
github.com/unlock-music/go-mmkv v0.1.1
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
unlock-music.dev/mmkv v0.1.0
modernc.org/sqlite v1.37.0
)
require (
@@ -27,9 +28,7 @@ require (
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sys v0.31.0 // indirect
google.golang.org/protobuf v1.35.2 // indirect
modernc.org/libc v1.62.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.9.1 // indirect
modernc.org/sqlite v1.37.0 // indirect
)

50
go.sum
View File

@@ -1,13 +1,9 @@
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
@@ -16,6 +12,8 @@ github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGO
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=
@@ -28,18 +26,14 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/unlock-music/go-mmkv v0.1.1 h1:1w3shjaBMp58jRrRkxPghnMpvyUoP4ZkA++W9ueFpE8=
github.com/unlock-music/go-mmkv v0.1.1/go.mod h1:E1h/HBpWPOKnCQw6LSQZkMpzxQojGDe+T5f/DRDXBsc=
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw=
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -48,36 +42,44 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.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=
unlock-music.dev/mmkv v0.1.0 h1:hgUHo0gJVoiKZ6bOcFOw2LHFqNiefIe+jb5o0OyL720=
unlock-music.dev/mmkv v0.1.0/go.mod h1:qr34SM3x8xRxyUfGzefH/rSi+DUXkQZcSfXY/yfuTeo=
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=

View File

@@ -4,14 +4,15 @@ import (
"bytes"
"context"
"fmt"
"go.uber.org/zap"
"io"
"os"
"os/exec"
"strings"
"unlock-music.dev/cli/algo/common"
"unlock-music.dev/cli/internal/utils"
"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) {

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