41 Commits

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

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

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

This fix improves compatibility with a wider range of NCM files and ensures artist metadata is reliably parsed, preventing data loss for affected files.
2025-08-09 23:58:03 +00:00
鲁树人
0a94383ba3 ci: make zip work 2025-05-09 11:21:22 +09:00
鲁树人
369112af01 ci: fix repack script 2025-05-09 05:06:40 +09:00
鲁树人
111952199f ci: allow manual trigger of build job 2025-05-09 05:03:00 +09:00
鲁树人
a217170077 ci: fix archive name and zip package 2025-05-09 05:00:21 +09:00
鲁树人
1a943309fa ci: simplify packing script logic 2025-05-09 04:47:18 +09:00
鲁树人
c0649d1246 ci: remove i386 target 2025-05-09 04:47:06 +09:00
鲁树人
3344db1645 docs: update reference to gitea actions 2025-05-08 07:11:55 +09:00
鲁树人
791f9c0621 ci: strip archives, reduce verbosity 2025-05-08 07:02:42 +09:00
鲁树人
896ace49fd ci: cleanup build job, follow old release naming 2025-05-08 06:36:33 +09:00
鲁树人
17cde2a1a5 refactor: rename functions to follow go pattern 2025-05-08 06:10:17 +09:00
鲁树人
61fba401c7 chore: mark default version as "custom" 2025-05-07 09:27:34 +09:00
鲁树人
a968be6063 Merge pull request 'kgg 支持' (#122) from feat/kgg into main
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/122
2025-05-07 00:26:25 +00:00
鲁树人
000ef4ac13 build: upgrade setup-go version 2025-05-07 09:18:00 +09:00
鲁树人
05e5783336 build: get ci working again 2025-05-07 09:13:47 +09:00
鲁树人
006bad8c48 feat: first version of kgg support 2025-05-07 08:12:57 +09:00
鲁树人
380ed78b6b Merge pull request 'fix: .mflacm support' (#117) from ccforeverd/cli:main into main
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/117
2025-02-18 11:07:38 +00:00
Zhang
208aceb1b5 fix: .mflacm support 2025-02-18 18:46:37 +08:00
鲁树人
28ca8cef60 docs: update readme about go version and how to build from source 2024-11-22 00:57:09 +09:00
鲁树人
2b0bd2985e chore: bump golang and dependency version 2024-11-22 00:50:52 +09:00
41 changed files with 1194 additions and 896 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.22',
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.22', // 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.22',
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.22',
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.22',
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.22
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.22
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.22
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.22
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.22
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.22
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.22
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.22
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.22
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.22
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.22
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.22
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.22
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.22
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.22
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.22
name: go build darwin/arm64
- commands:
- mv dist/*.tar.gz dist/*.zip ./
image: golang:1.22
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

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

5
.gitignore vendored
View File

@@ -3,5 +3,10 @@
/dist
*.exe
/um
/um-*.tar.gz
/um-*.zip
/.vscode
/prepare
/dist

80
CHANGELOG.md Normal file
View File

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

View File

@@ -1,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 该仓库。
@@ -14,24 +14,25 @@ Original: Web Edition https://git.unlock-music.dev/um/web
- [x] All Algorithm Supported By `unlock-music/web`
- [x] Complete Metadata & Cover Image
## Hou to Build
## Release
- Requirements: **Golang 1.19**
[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
- 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"
@@ -15,6 +31,8 @@ type DecoderParams struct {
FilePath string // optional, source file path
Logger *zap.Logger // required
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 {
@@ -14,10 +14,12 @@ type Decoder struct {
offset int
header header
KggDatabasePath string
}
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.
@@ -34,6 +36,11 @@ func (d *Decoder) Validate() (err error) {
if err != nil {
return fmt.Errorf("kgm init crypto v3: %w", err)
}
case 5:
d.cipher, err = newKgmCryptoV5(&d.header, d.KggDatabasePath)
if err != nil {
return fmt.Errorf("kgm init crypto v5: %w", err)
}
default:
return fmt.Errorf("kgm: unsupported crypto version %d", d.header.CryptoVersion)
}
@@ -57,6 +64,7 @@ func (d *Decoder) Read(buf []byte) (int, error) {
func init() {
// Kugou
common.RegisterDecoder("kgg", false, NewDecoder)
common.RegisterDecoder("kgm", false, NewDecoder)
common.RegisterDecoder("kgma", false, NewDecoder)
// Viper

View File

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

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

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"
"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,158 +0,0 @@
package qmc
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/samber/lo"
"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)
}
}
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)
}
func OpenMMKV(mmkvPath string, key string, logger *zap.Logger) error {
filePath, fileName := filepath.Split(mmkvPath)
mgr, err := mmkv.NewManager(filepath.Dir(filePath))
if err != nil {
return fmt.Errorf("init mmkv manager: %w", err)
}
// If `vaultKey` is empty, the key is ignored.
streamKeyVault, err = mgr.OpenVaultCrypto(fileName, key)
if err != nil {
return fmt.Errorf("open mmkv vault: %w", err)
}
logger.Debug("mmkv vault opened", zap.Strings("keys", streamKeyVault.Keys()))
return 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)
func getRelativeMMKVDir(file string) (string, error) {
mmkvDir := filepath.Join(filepath.Dir(file), "../mmkv")
if _, err := os.Stat(mmkvDir); err != nil {
return "", fmt.Errorf("stat default mmkv dir: %w", err)
}
keyFile := filepath.Join(mmkvDir, "MMKVStreamEncryptId")
if _, err := os.Stat(keyFile); err != nil {
return "", fmt.Errorf("stat default mmkv file: %w", err)
}
return mmkvDir, nil
}
func getDefaultMMKVDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("get user home dir: %w", err)
}
mmkvDir := filepath.Join(
homeDir,
"Library/Containers/com.tencent.QQMusicMac/Data",
"Library/Application Support/QQMusicMac/mmkv",
)
if _, err := os.Stat(mmkvDir); err != nil {
return "", fmt.Errorf("stat default mmkv dir: %w", err)
}
keyFile := filepath.Join(mmkvDir, "MMKVStreamEncryptId")
if _, err := os.Stat(keyFile); err != nil {
return "", fmt.Errorf("stat default mmkv file: %w", err)
}
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,38 @@
package qmc
import (
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/algo/qmc/qmmac"
"git.um-react.app/um/cli/internal/mmkv"
"go.uber.org/zap"
)
func LoadMMKVOrDefault(path string, key string, logger *zap.Logger) (result common.QMCKeys, err error) {
key1, err := qmmac.LoadMacKeysV8(logger)
if err != nil {
key1 = nil
logger.Warn("LoadMMKVOrDefault: could not read QQMusic v8.8.0 keys", zap.Error(err))
}
key2, err := qmmac.LoadMacKeysV10(logger)
if err != nil {
key2 = nil
logger.Warn("LoadMMKVOrDefault: could not read QQMusic v10.x keys", zap.Error(err))
}
userKeys := make(common.QMCKeys)
if path != "" {
logger.Info("Using user mmkv")
userKeys, err = mmkv.LoadFromPath(path, key, logger)
if err != nil {
userKeys = nil
logger.Warn("LoadMMKVOrDefault: could not read user keys", zap.Error(err))
}
}
allKeys := mmkv.Merge(key1, key2, userKeys)
logger.Debug("Keys loaded", zap.Any("keys", allKeys), zap.Int("len", len(allKeys)))
return allKeys, nil
}

View File

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

View File

@@ -5,14 +5,14 @@ import (
"encoding/binary"
"errors"
"fmt"
"go.uber.org/zap"
"io"
"runtime"
"strconv"
"strings"
"unlock-music.dev/cli/algo/common"
"unlock-music.dev/cli/internal/sniff"
"go.uber.org/zap"
"git.um-react.app/um/cli/algo/common"
"git.um-react.app/um/cli/internal/sniff"
)
type Decoder struct {
@@ -59,6 +59,23 @@ func NewDecoder(p *common.DecoderParams) common.Decoder {
return &Decoder{raw: p.Reader, params: p, logger: p.Logger}
}
func NewQmcCipherDecoder(key []byte) (common.StreamDecoder, error) {
if len(key) > 300 {
return newRC4Cipher(key)
} else if len(key) != 0 {
return newMapCipher(key)
}
return newStaticCipher(), nil
}
func NewQmcCipherDecoderFromEKey(ekey []byte) (common.StreamDecoder, error) {
key, err := deriveKey(ekey)
if err != nil {
return nil, err
}
return NewQmcCipherDecoder(key)
}
func (d *Decoder) Validate() error {
// search & derive key
err := d.searchKey()
@@ -67,18 +84,9 @@ func (d *Decoder) Validate() error {
}
// check cipher type and init decode cipher
if len(d.decodedKey) > 300 {
d.cipher, err = newRC4Cipher(d.decodedKey)
d.cipher, err = NewQmcCipherDecoder(d.decodedKey)
if err != nil {
return err
}
} else if len(d.decodedKey) != 0 {
d.cipher, err = newMapCipher(d.decodedKey)
if err != nil {
return err
}
} else {
d.cipher = newStaticCipher()
return fmt.Errorf("qmc init cipher: %w", err)
}
// test with first 16 bytes
@@ -124,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
} else {
d.decodedKey = nil
d.logger.Warn("could not derive key, skip", zap.Error(err))
}
d.logger.Warn("read key from mmkv failed", zap.Error(err))
}
suffixBuf := make([]byte, 4)
@@ -144,25 +153,34 @@ func (d *Decoder) searchKey() (err error) {
return d.readRawMetaQTag()
case "STag":
return errors.New("qmc: file with 'STag' suffix doesn't contains media key")
// speculative guess for "musicex\0"
case "cex\x00":
footer, err := NewMusicExTag(d.raw)
if err != nil {
return err
}
d.audioLen = fileSize - int(footer.TagSize)
d.decodedKey, err = readKeyFromMMKVCustom(footer.MediaFileName)
if err != nil {
return err
if key, ok := d.params.CryptoParams.QmcKeys.Get(footer.MediaFileName); ok {
d.logger.Debug("searchKey: using key from MediaFileName", zap.String("MediaFileName", footer.MediaFileName), zap.String("key", key))
d.decodedKey, err = deriveKey([]byte(key))
} else if d.decodedKey == nil {
return errors.New("searchKey: no key found for musicex tag")
}
return nil
return err
default:
size := binary.LittleEndian.Uint32(suffixBuf)
// if we already have a key from legacy mmkv, use it
if d.decodedKey != nil {
return nil
}
// try to use suffix as key length
size := binary.LittleEndian.Uint32(suffixBuf)
if size <= 0xFFFF && size != 0 { // assume size is key len
return d.readRawKey(int64(size))
}
// try to use default static cipher
// try to use default static cipher,
// or the key read from the legacy mmkv
d.audioLen = fileSize
return nil
}
@@ -185,13 +203,9 @@ func (d *Decoder) readRawKey(rawKeyLen int64) error {
rawKeyData = bytes.TrimRight(rawKeyData, "\x00")
d.decodedKey, err = deriveKey(rawKeyData)
if err != nil {
return err
}
return nil
}
func (d *Decoder) readRawMetaQTag() error {
// get raw meta data len
if _, err := d.raw.Seek(-8, io.SeekEnd); err != nil {
@@ -263,7 +277,7 @@ func init() {
// New ogg/flac:
extraExtsCanHaveSuffix := []string{"mgg", "mflac"}
// Mac also adds some extra suffix to ext:
extraExtSuffix := []string{"0", "1", "a", "h", "l"}
extraExtSuffix := []string{"0", "1", "a", "h", "l", "m"}
for _, ext := range extraExtsCanHaveSuffix {
common.RegisterDecoder(ext, false, NewDecoder)
for _, suffix := range extraExtSuffix {

View File

@@ -42,24 +42,25 @@ func NewMusicExTag(f io.ReadSeeker) (*MusicExTagV1, error) {
tag := &MusicExTagV1{
TagSize: binary.LittleEndian.Uint32(buffer[0x00:0x04]),
TagVersion: binary.LittleEndian.Uint32(buffer[0x04:0x08]),
TagMagic: buffer[0x04:0x0C],
TagMagic: buffer[0x08:],
}
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))
return nil, fmt.Errorf("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))
return nil, fmt.Errorf("unsupported musicex tag size. expecting at least 0xC0, got 0x%02x", tag.TagSize)
}
buffer = make([]byte, tag.TagSize)
f.Seek(-int64(tag.TagSize), io.SeekEnd)
bytesRead, err = f.Read(buffer)
if err != nil {
return nil, err
return nil, fmt.Errorf("MusicExV1: Read error %w", err)
}
if uint32(bytesRead) != tag.TagSize {
return nil, fmt.Errorf("MusicExV1: read %d bytes (expected %d)", bytesRead, tag.TagSize)

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) {
@@ -61,7 +61,7 @@ func (d *Decoder) getMetaBySongID(ctx context.Context) error {
func (d *Decoder) searchMetaOnline(ctx context.Context, original common.AudioMeta) error {
c := client.NewQQMusicClient() // todo: use global client
keyword := lo.WithoutEmpty(append(
keyword := lo.Compact(append(
[]string{original.GetTitle(), original.GetAlbum()},
original.GetArtists()...),
)

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

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

View File

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

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

@@ -5,10 +5,6 @@ import (
"context"
"errors"
"fmt"
"github.com/fsnotify/fsnotify"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"io"
"os"
"os/signal"
@@ -18,20 +14,25 @@ import (
"sort"
"strings"
"time"
"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"
"git.um-react.app/um/cli/algo/common"
_ "git.um-react.app/um/cli/algo/kgm"
_ "git.um-react.app/um/cli/algo/kwm"
_ "git.um-react.app/um/cli/algo/ncm"
"git.um-react.app/um/cli/algo/qmc"
_ "git.um-react.app/um/cli/algo/tm"
_ "git.um-react.app/um/cli/algo/xiami"
_ "git.um-react.app/um/cli/algo/ximalaya"
"git.um-react.app/um/cli/internal/ffmpeg"
"git.um-react.app/um/cli/internal/sniff"
"git.um-react.app/um/cli/internal/utils"
"github.com/fsnotify/fsnotify"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var AppVersion = "v0.2.11"
var AppVersion = "custom"
var logger = setupLogger(false) // TODO: inject logger to application, instead of using global logger
@@ -43,13 +44,14 @@ 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},
&cli.BoolFlag{Name: "verbose", Aliases: []string{"V"}, Usage: "verbose logging", Required: false, Value: false},
@@ -61,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",
}
@@ -174,13 +176,15 @@ 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)
// 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{
@@ -191,6 +195,14 @@ func appMain(c *cli.Context) (err error) {
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() {
@@ -215,6 +227,8 @@ type processor struct {
removeSource bool
updateMetadata bool
overwriteOutput bool
crypto common.CryptoParams
}
func (p *processor) watchDir(inputDir string) error {
@@ -346,6 +360,7 @@ func (p *processor) process(inputFile string, allDec []common.DecoderFactory) er
Extension: filepath.Ext(inputFile),
FilePath: inputFile,
Logger: logger,
CryptoParams: p.crypto,
})
if err != nil {
return err

34
go.mod
View File

@@ -1,26 +1,34 @@
module unlock-music.dev/cli
module git.um-react.app/um/cli
go 1.19
go 1.25.1
require (
github.com/fsnotify/fsnotify v1.7.0
github.com/fsnotify/fsnotify v1.9.0
github.com/go-flac/flacpicture v0.3.0
github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/go-flac v1.0.0
github.com/samber/lo v1.39.0
github.com/urfave/cli/v2 v2.27.1
github.com/samber/lo v1.52.0
github.com/unlock-music/go-mmkv v0.1.4
github.com/urfave/cli/v2 v2.27.7
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.26.0
golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f
golang.org/x/text v0.17.0
unlock-music.dev/mmkv v0.0.0-20240424090133-31549c6a948b
golang.org/x/crypto v0.44.0
golang.org/x/text v0.31.0
modernc.org/sqlite v1.40.0
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sys v0.23.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.38.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

102
go.sum
View File

@@ -1,47 +1,89 @@
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.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
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/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/unlock-music/go-mmkv v0.1.3 h1:BtO77wjmypCeaX3n8VRDX5ZWHlYdJ9xEfKGui9s4PM0=
github.com/unlock-music/go-mmkv v0.1.3/go.mod h1:u1fYHzYOO7et1E55Q86lTvjXf6p14CIELszAHI8T9zQ=
github.com/unlock-music/go-mmkv v0.1.4 h1:onjxxlNd9Fcp4RYL8khOgp/DRY0n+Iuw958TdRoB/FA=
github.com/unlock-music/go-mmkv v0.1.4/go.mod h1:u1fYHzYOO7et1E55Q86lTvjXf6p14CIELszAHI8T9zQ=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
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/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY=
golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
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=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
unlock-music.dev/mmkv v0.0.0-20240424090133-0953e6901f3a h1:UW0sxgwsfGGC/SrKvvAbZ4HZyOQ3fqs8qr3lBxG6Fzo=
unlock-music.dev/mmkv v0.0.0-20240424090133-0953e6901f3a/go.mod h1:qr34SM3x8xRxyUfGzefH/rSi+DUXkQZcSfXY/yfuTeo=
unlock-music.dev/mmkv v0.0.0-20240424090133-31549c6a948b h1:VIJ0mDqj0OgX1ZvL9gbAH8kkqyrDlpVt5yUeGYSJ1/s=
unlock-music.dev/mmkv v0.0.0-20240424090133-31549c6a948b/go.mod h1:qr34SM3x8xRxyUfGzefH/rSi+DUXkQZcSfXY/yfuTeo=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.0 h1:bNWEDlYhNPAUdUdBzjAvn8icAs/2gaKlj4vM+tQ6KdQ=
modernc.org/sqlite v1.40.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

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

View File

@@ -2,14 +2,15 @@ package ffmpeg
import (
"context"
"go.uber.org/zap"
"mime"
"slices"
"strings"
"go.uber.org/zap"
"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 {

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

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

View File

@@ -3,8 +3,7 @@ package sniff
import (
"bytes"
"encoding/binary"
"golang.org/x/exp/slices"
"slices"
)
type Sniffer interface {

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