51 Commits

Author SHA1 Message Date
鲁树人
1b62887619 ci: bump go version to 1.25 2025-09-06 23:59:55 +09:00
鲁树人
35dbfdc5d1 chore: update url to new git host 2025-09-06 23:58:58 +09:00
鲁树人
f6614973ee ci: print artifact information at end 2025-09-06 23:54:56 +09:00
鲁树人
00fefc1297 chore: rename project namespace 2025-09-06 23:50:08 +09:00
鲁树人
4671143030 refactor: restrict kgg db support to Windows only 2025-09-06 23:48:21 +09:00
鲁树人
9b0455b0fd refactor: improve mmkv logic 2025-09-06 23:44:07 +09:00
鲁树人
92ad51402e fix: use mmkv from github 2025-09-03 00:43:40 +09:00
鲁树人
589e573b55 Merge pull request 'Fix: Artist metadata is lost for NCM files with mixed-type artist arrays' (#131) from solitudechn/cli:fix/handle-mixed-type-artist into main
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/131
2025-08-11 17:09:24 +00:00
solitudechn
20b746bee5 fix(ncm): Handle mixed-type artist field in metadata parsing
The current implementation for parsing artist metadata in `ncmMetaMusic.GetArtists` does not handle cases where the "artist" JSON field is a list containing mixed-type inner lists.

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

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

This fix improves compatibility with a wider range of NCM files and ensures artist metadata is reliably parsed, preventing data loss for affected files.
2025-08-09 23:58:03 +00:00
鲁树人
0a94383ba3 ci: make zip work 2025-05-09 11:21:22 +09:00
鲁树人
369112af01 ci: fix repack script 2025-05-09 05:06:40 +09:00
鲁树人
111952199f ci: allow manual trigger of build job 2025-05-09 05:03:00 +09:00
鲁树人
a217170077 ci: fix archive name and zip package 2025-05-09 05:00:21 +09:00
鲁树人
1a943309fa ci: simplify packing script logic 2025-05-09 04:47:18 +09:00
鲁树人
c0649d1246 ci: remove i386 target 2025-05-09 04:47:06 +09:00
鲁树人
3344db1645 docs: update reference to gitea actions 2025-05-08 07:11:55 +09:00
鲁树人
791f9c0621 ci: strip archives, reduce verbosity 2025-05-08 07:02:42 +09:00
鲁树人
896ace49fd ci: cleanup build job, follow old release naming 2025-05-08 06:36:33 +09:00
鲁树人
17cde2a1a5 refactor: rename functions to follow go pattern 2025-05-08 06:10:17 +09:00
鲁树人
61fba401c7 chore: mark default version as "custom" 2025-05-07 09:27:34 +09:00
鲁树人
a968be6063 Merge pull request 'kgg 支持' (#122) from feat/kgg into main
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/122
2025-05-07 00:26:25 +00:00
鲁树人
000ef4ac13 build: upgrade setup-go version 2025-05-07 09:18:00 +09:00
鲁树人
05e5783336 build: get ci working again 2025-05-07 09:13:47 +09:00
鲁树人
006bad8c48 feat: first version of kgg support 2025-05-07 08:12:57 +09:00
鲁树人
380ed78b6b Merge pull request 'fix: .mflacm support' (#117) from ccforeverd/cli:main into main
Reviewed-on: https://git.unlock-music.dev/um/cli/pulls/117
2025-02-18 11:07:38 +00:00
Zhang
208aceb1b5 fix: .mflacm support 2025-02-18 18:46:37 +08:00
鲁树人
28ca8cef60 docs: update readme about go version and how to build from source 2024-11-22 00:57:09 +09:00
鲁树人
2b0bd2985e chore: bump golang and dependency version 2024-11-22 00:50:52 +09:00
鲁树人
72ace9fc62 chore: bump version to v0.2.11 2024-11-05 16:56:40 +09:00
鲁树人
074e4f874f fix #108: rel path resolution in windows 2024-11-05 16:27:14 +09:00
鲁树人
2bfb5ffddf chore: bump version to v0.2.10 2024-11-04 14:25:02 +09:00
鲁树人
2c9de7c56c fix #107: windows dnd path error 2024-11-04 14:24:32 +09:00
鲁树人
b374c11c86 chore: bump app version to v0.2.9 2024-11-02 13:59:22 +09:00
鲁树人
6493b2c5fc fix #78: skip parsing cover art if image is unsupported 2024-11-02 13:49:40 +09:00
鲁树人
f753b9c67d fix #78 #106: app crash due to imcompatible ncm metadata json 2024-11-02 13:44:29 +09:00
鲁树人
8829a3b3ba refactor: rework on logging 2024-11-02 13:43:56 +09:00
鲁树人
b2ef27761f chore: bump version to v0.2.8 2024-10-25 23:03:34 +09:00
鲁树人
7f7cb66fe5 fix #103: incorrect output path when input is a single file 2024-10-25 23:02:32 +09:00
鲁树人
77729cf653 chore: bump version to v0.2.7 2024-10-21 08:45:09 +09:00
鲁树人
b317b89ae9 ci: append version number to file name, fix tar.gz.gz archive. 2024-10-21 08:45:09 +09:00
鲁树人
89b629304e chore: bump version to v0.2.6 2024-10-21 07:30:22 +09:00
鲁树人
c0c3bda9ce ci: add windows arm64 build 2024-10-21 07:29:52 +09:00
鲁树人
fa8f7a1565 chore: bump version to v0.2.5 2024-10-21 06:23:17 +09:00
鲁树人
d4d5e5ddf4 ci: upload zip for windows build 2024-10-21 06:10:36 +09:00
鲁树人
57c1aa3e54 ci: produce zip for windows build 2024-10-21 06:04:54 +09:00
鲁树人
b0998d8c8a docs: document steps to update CI pipeline 2024-10-21 06:02:31 +09:00
鲁树人
f819726f3e chore: bump version to v0.2.4 2024-10-21 03:59:52 +09:00
鲁树人
fbad7ec450 fix #102: support multi-part kgm extensions (kgm.flac/vpr.flac) 2024-10-21 03:58:21 +09:00
鲁树人
19bc6c466e refactor: allow multi-part extensions (#102) 2024-10-21 03:57:56 +09:00
鲁树人
b9e2a38f82 fix: do not throw error when file already exists 2024-10-21 03:56:52 +09:00
鲁树人
673ea8491f docs: add do not fork notice 2024-10-13 05:45:25 +09:00
35 changed files with 1055 additions and 829 deletions

View File

@@ -1,126 +0,0 @@
// generate .drone.yaml, run:
// drone jsonnet --format --stream
local CreateRelease() = {
name: 'create release',
image: 'plugins/gitea-release',
settings: {
api_key: { from_secret: 'GITEA_API_KEY' },
base_url: 'https://git.unlock-music.dev',
files: 'dist/*',
checksum: 'sha256',
draft: true,
title: '${DRONE_TAG}',
},
};
local StepGoBuild(GOOS, GOARCH) = {
local filepath = 'dist/um-%s-%s.tar.gz' % [GOOS, GOARCH],
name: 'go build %s/%s' % [GOOS, GOARCH],
image: 'golang:1.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',
'tar cz -f %s -C $DIST_DIR .' % filepath,
],
};
local StepUploadArtifact(GOOS, GOARCH) = {
local filename = 'um-%s-%s.tar.gz' % [GOOS, GOARCH],
local filepath = 'dist/%s' % filename,
local pkgname = '${DRONE_REPO_NAME}-build',
name: 'upload artifact',
image: 'golang:1.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', '386'),
StepGoBuild('darwin', 'amd64'),
StepGoBuild('darwin', 'arm64'),
CreateRelease(),
],
trigger: {
event: ['tag'],
},
};
[
PipelineBuild('linux', 'amd64', true),
PipelineBuild('windows', 'amd64', false),
PipelineBuild('darwin', 'amd64', false),
PipelineRelease(),
]

View File

@@ -1,226 +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 cz -f dist/um-linux-amd64.tar.gz -C $DIST_DIR .
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.tar.gz"
"$DRONE_GITEA_SERVER/api/packages/${DRONE_REPO_NAMESPACE}/generic/${DRONE_REPO_NAME}-build/${DRONE_BUILD_NUMBER}/um-linux-amd64.tar.gz"
- sha256sum dist/um-linux-amd64.tar.gz
- echo $DRONE_GITEA_SERVER/${DRONE_REPO_NAMESPACE}/-/packages/generic/${DRONE_REPO_NAME}-build/${DRONE_BUILD_NUMBER}
environment:
DRONE_GITEA_SERVER: https://git.unlock-music.dev
GITEA_API_KEY:
from_secret: GITEA_API_KEY
image: golang:1.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
- tar cz -f dist/um-windows-amd64.tar.gz -C $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.tar.gz"
"$DRONE_GITEA_SERVER/api/packages/${DRONE_REPO_NAMESPACE}/generic/${DRONE_REPO_NAME}-build/${DRONE_BUILD_NUMBER}/um-windows-amd64.tar.gz"
- sha256sum dist/um-windows-amd64.tar.gz
- echo $DRONE_GITEA_SERVER/${DRONE_REPO_NAMESPACE}/-/packages/generic/${DRONE_REPO_NAME}-build/${DRONE_BUILD_NUMBER}
environment:
DRONE_GITEA_SERVER: https://git.unlock-music.dev
GITEA_API_KEY:
from_secret: GITEA_API_KEY
image: golang:1.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 cz -f dist/um-darwin-amd64.tar.gz -C $DIST_DIR .
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.tar.gz"
"$DRONE_GITEA_SERVER/api/packages/${DRONE_REPO_NAMESPACE}/generic/${DRONE_REPO_NAME}-build/${DRONE_BUILD_NUMBER}/um-darwin-amd64.tar.gz"
- sha256sum dist/um-darwin-amd64.tar.gz
- echo $DRONE_GITEA_SERVER/${DRONE_REPO_NAMESPACE}/-/packages/generic/${DRONE_REPO_NAME}-build/${DRONE_BUILD_NUMBER}
environment:
DRONE_GITEA_SERVER: https://git.unlock-music.dev
GITEA_API_KEY:
from_secret: GITEA_API_KEY
image: golang:1.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 cz -f dist/um-linux-amd64.tar.gz -C $DIST_DIR .
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 cz -f dist/um-linux-arm64.tar.gz -C $DIST_DIR .
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 cz -f dist/um-linux-386.tar.gz -C $DIST_DIR .
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
- tar cz -f dist/um-windows-amd64.tar.gz -C $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
- tar cz -f dist/um-windows-386.tar.gz -C $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 cz -f dist/um-darwin-amd64.tar.gz -C $DIST_DIR .
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 cz -f dist/um-darwin-arm64.tar.gz -C $DIST_DIR .
environment:
GOARCH: arm64
GOOS: darwin
GOPROXY: https://goproxy.io,direct
image: golang:1.22
name: go build darwin/arm64
- image: plugins/gitea-release
name: create release
settings:
api_key:
from_secret: GITEA_API_KEY
base_url: https://git.unlock-music.dev
checksum: sha256
draft: true
files: dist/*
title: ${DRONE_TAG}
trigger:
event:
- tag
type: docker

View File

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

View File

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

View File

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

8
.gitignore vendored
View File

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

View File

@@ -1,21 +1,35 @@
# 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 该仓库。
## Features
- [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

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,28 +31,37 @@ type DecoderParams struct {
FilePath string // optional, source file path
Logger *zap.Logger // required
CryptoParams CryptoParams
}
type NewDecoderFunc func(p *DecoderParams) Decoder
type decoderItem struct {
type DecoderFactory struct {
noop bool
decoder NewDecoderFunc
Suffix string
Create NewDecoderFunc
}
var DecoderRegistry = make(map[string][]decoderItem)
var DecoderRegistry []DecoderFactory
func RegisterDecoder(ext string, noop bool, dispatchFunc NewDecoderFunc) {
DecoderRegistry[ext] = append(DecoderRegistry[ext],
decoderItem{noop: noop, decoder: dispatchFunc})
DecoderRegistry = append(DecoderRegistry,
DecoderFactory{noop: noop, Create: dispatchFunc, Suffix: "." + strings.TrimPrefix(ext, ".")})
}
func GetDecoder(filename string, skipNoop bool) (rs []NewDecoderFunc) {
ext := strings.ToLower(strings.TrimLeft(filepath.Ext(filename), "."))
for _, dec := range DecoderRegistry[ext] {
func GetDecoder(filename string, skipNoop bool) []DecoderFactory {
var result []DecoderFactory
// Some extensions contains multiple dots, e.g. ".kgm.flac", hence iterate
// all decoders for each extension.
name := strings.ToLower(filepath.Base(filename))
for _, dec := range DecoderRegistry {
if !strings.HasSuffix(name, dec.Suffix) {
continue
}
if skipNoop && dec.noop {
continue
}
rs = append(rs, dec.decoder)
result = append(result, dec)
}
return
return result
}

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,8 +64,12 @@ 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
common.RegisterDecoder("vpr", false, NewDecoder)
// Kugou Android
common.RegisterDecoder("kgm.flac", false, NewDecoder)
common.RegisterDecoder("vpr.flac", false, NewDecoder)
}

View File

@@ -29,6 +29,8 @@ type header struct {
CryptoSlot uint32 // 0x18-0x1b: crypto key slot
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

@@ -3,7 +3,9 @@ package ncm
import (
"strings"
"unlock-music.dev/cli/algo/common"
"go.uber.org/zap"
"git.um-react.app/um/cli/algo/common"
)
type ncmMeta interface {
@@ -17,9 +19,11 @@ type ncmMeta interface {
}
type ncmMetaMusic struct {
logger *zap.Logger
Format string `json:"format"`
MusicName string `json:"musicName"`
Artist [][]interface{} `json:"artist"`
Artist interface{} `json:"artist"`
Album string `json:"album"`
AlbumPicDocID interface{} `json:"albumPicDocId"`
AlbumPic string `json:"albumPic"`
@@ -30,20 +34,52 @@ type ncmMetaMusic struct {
TransNames []interface{} `json:"transNames"`
}
func newNcmMetaMusic(logger *zap.Logger) *ncmMetaMusic {
ncm := new(ncmMetaMusic)
ncm.logger = logger.With(zap.String("module", "ncmMetaMusic"))
return ncm
}
func (m *ncmMetaMusic) GetAlbumImageURL() string {
return m.AlbumPic
}
func (m *ncmMetaMusic) GetArtists() (artists []string) {
for _, artist := range m.Artist {
for _, item := range artist {
name, ok := item.(string)
if ok {
artists = append(artists, name)
func (m *ncmMetaMusic) GetArtists() []string {
m.logger.Debug("ncm artists raw", zap.Any("artists", m.Artist))
var artists []string
switch v := m.Artist.(type) {
// Case 1: Handles the format [['artistA'], ['artistB']]
case [][]string:
for _, artistSlice := range v {
artists = append(artists, artistSlice...)
}
// Case 2: Handles the simple format "artistA"
// Ref: https://git.unlock-music.dev/um/cli/issues/78
case string:
artists = []string{v}
// Case 3: Handles the mixed-type format [['artistA', 12345], ['artistB', 67890]]
// This is the key fix for correctly parsing artist info from certain files.
case []interface{}:
for _, item := range v {
if innerSlice, ok := item.([]interface{}); ok {
if len(innerSlice) > 0 {
// Assume the first element is the artist's name.
if artistName, ok := innerSlice[0].(string); ok {
artists = append(artists, artistName)
}
}
}
return
}
default:
// Log a warning if the artist type is unexpected and not handled.
m.logger.Warn("unexpected artist type", zap.Any("artists", m.Artist))
}
return artists
}
func (m *ncmMetaMusic) GetTitle() string {

View File

@@ -12,8 +12,10 @@ import (
"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"
@@ -30,10 +32,11 @@ var (
)
func NewDecoder(p *common.DecoderParams) common.Decoder {
return &Decoder{rd: p.Reader}
return &Decoder{rd: p.Reader, logger: p.Logger.With(zap.String("module", "ncm"))}
}
type Decoder struct {
logger *zap.Logger
rd io.ReadSeeker // rd is the original file reader
offset int
@@ -74,7 +77,7 @@ func (d *Decoder) Validate() error {
}
if err := d.parseMeta(); err != nil {
return fmt.Errorf("parse meta failed: %w", err)
return fmt.Errorf("parse meta failed: %w (raw json=%s)", err, string(d.metaRaw))
}
d.cipher = newNcmCipher(keyData)
@@ -181,7 +184,7 @@ func (d *Decoder) readCoverData() error {
func (d *Decoder) parseMeta() error {
switch d.metaType {
case "music":
d.meta = new(ncmMetaMusic)
d.meta = newNcmMetaMusic(d.logger)
return json.Unmarshal(d.metaRaw, d.meta)
case "dj":
d.meta = new(ncmMetaDJ)

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")
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)
}
}
return result
}
//goland:noinspection GoBoolExpressions
if runtime.GOOS != "darwin" {
return nil, errors.New("mmkv vault not supported on this platform")
}
func LoadMMKV(path string, key string, logger *zap.Logger) (result common.QMCKeys, err error) {
mmkv_path := path
mmkv_crc := path + ".crc"
if streamKeyVault == nil {
mmkvDir, err := getRelativeMMKVDir(file)
mr, err := os.Open(mmkv_path)
if err != nil {
mmkvDir, err = getDefaultMMKVDir()
logger.Error("LoadMMKV: Could not open mmkv file", zap.Error(err))
return nil, fmt.Errorf("LoadMMKV: open error: %w", err)
}
defer mr.Close()
cr, err := os.Open(mmkv_crc)
if err == nil {
// crc is optional
cr = nil
defer cr.Close()
}
var password []byte = nil
if key != "" {
password = make([]byte, 16)
copy(password, []byte(key))
}
mmkv, err := go_mmkv.NewMMKVReader(mr, password, cr)
if err != nil {
return nil, fmt.Errorf("mmkv key valut not found: %w", err)
}
logger.Error("LoadMMKV: failed to create reader", zap.Error(err))
return nil, fmt.Errorf("LoadMMKV: NewMMKVReader error: %w", err)
}
mgr, err := mmkv.NewManager(mmkvDir)
result = make(common.QMCKeys)
for !mmkv.IsEOF() {
key, err := mmkv.ReadKey()
if err != nil {
return nil, fmt.Errorf("init mmkv manager: %w", err)
logger.Error("LoadMMKV: read key error", zap.Error(err))
return nil, fmt.Errorf("LoadMMKV: read key error: %w", err)
}
streamKeyVault, err = mgr.OpenVault("MMKVStreamEncryptId")
value, err := mmkv.ReadStringValue()
if err != nil {
return nil, fmt.Errorf("open mmkv vault: %w", err)
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)
}
logger.Debug("mmkv vault opened", zap.Strings("keys", streamKeyVault.Keys()))
return result, nil
}
_, 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)
// 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

@@ -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
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)
@@ -144,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)
@@ -185,13 +194,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 +268,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

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

@@ -8,7 +8,6 @@ import (
"io"
"os"
"os/signal"
"path"
"path/filepath"
"runtime"
"runtime/debug"
@@ -16,27 +15,26 @@ 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"
"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/logging"
"unlock-music.dev/cli/internal/sniff"
"unlock-music.dev/cli/internal/utils"
"go.uber.org/zap/zapcore"
)
var AppVersion = "v0.2.3"
var AppVersion = "custom"
var logger, _ = logging.NewZapLogger() // TODO: inject logger to application, instead of using global logger
var logger = setupLogger(false) // TODO: inject logger to application, instead of using global logger
func main() {
module, ok := debug.ReadBuildInfo()
@@ -46,15 +44,17 @@ 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},
&cli.BoolFlag{Name: "update-metadata", Usage: "update metadata & album art from network", Required: false, Value: false},
&cli.BoolFlag{Name: "overwrite", Usage: "overwrite output file without asking", Required: false, Value: false},
&cli.BoolFlag{Name: "watch", Usage: "watch the input dir and process new files", Required: false, Value: false},
@@ -63,10 +63,11 @@ 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",
}
err := app.Run(os.Args)
if err != nil {
logger.Fatal("run app failed", zap.Error(err))
@@ -75,16 +76,45 @@ func main() {
func printSupportedExtensions() {
var exts []string
for ext := range common.DecoderRegistry {
extSet := make(map[string]int)
for _, factory := range common.DecoderRegistry {
ext := strings.TrimPrefix(factory.Suffix, ".")
if n, ok := extSet[ext]; ok {
extSet[ext] = n + 1
} else {
extSet[ext] = 1
}
}
for ext := range extSet {
exts = append(exts, ext)
}
sort.Strings(exts)
for _, ext := range exts {
fmt.Printf("%s: %d\n", ext, len(common.DecoderRegistry[ext]))
fmt.Printf("%s: %d\n", ext, extSet[ext])
}
}
func setupLogger(verbose bool) *zap.Logger {
logConfig := zap.NewProductionEncoderConfig()
logConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
logConfig.EncodeTime = zapcore.RFC3339TimeEncoder
enabler := zap.LevelEnablerFunc(func(level zapcore.Level) bool {
if verbose {
return true
}
return level >= zapcore.InfoLevel
})
return zap.New(zapcore.NewCore(
zapcore.NewConsoleEncoder(logConfig),
os.Stdout,
enabler,
))
}
func appMain(c *cli.Context) (err error) {
logger = setupLogger(c.Bool("verbose"))
cwd, err := os.Getwd()
if err != nil {
return err
@@ -106,21 +136,34 @@ func appMain(c *cli.Context) (err error) {
}
}
input, absErr := filepath.Abs(input)
if absErr != nil {
return fmt.Errorf("get abs path failed: %w", absErr)
}
output := c.String("output")
inputStat, err := os.Stat(input)
if err != nil {
return err
}
if output == "" {
// Default to where the input is
var inputDir string
if inputStat.IsDir() {
output = input
inputDir = input
} else {
output = path.Dir(input)
inputDir = filepath.Dir(input)
}
inputDir, absErr = filepath.Abs(inputDir)
if absErr != nil {
return fmt.Errorf("get abs path (inputDir) failed: %w", absErr)
}
if output == "" {
// Default to where the input dir is
output = inputDir
}
logger.Debug("resolve input/output path", zap.String("inputDir", inputDir), zap.String("input", input), zap.String("output", output))
outputStat, err := os.Stat(output)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
@@ -133,22 +176,33 @@ 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{
inputDir: input,
logger: logger,
inputDir: inputDir,
outputDir: output,
skipNoopDecoder: c.Bool("skip-noop"),
removeSource: c.Bool("remove-source"),
updateMetadata: c.Bool("update-metadata"),
overwriteOutput: c.Bool("overwrite"),
crypto: common.CryptoParams{
// KuGou
KggDbPath: kggDbPath,
// QQMusic
QmcKeys: qmcKeys,
},
}
if inputStat.IsDir() {
@@ -165,6 +219,7 @@ func appMain(c *cli.Context) (err error) {
}
type processor struct {
logger *zap.Logger
inputDir string
outputDir string
@@ -172,6 +227,8 @@ type processor struct {
removeSource bool
updateMetadata bool
overwriteOutput bool
crypto common.CryptoParams
}
func (p *processor) watchDir(inputDir string) error {
@@ -256,6 +313,8 @@ func (p *processor) processDir(inputDir string) error {
}
func (p *processor) processFile(filePath string) error {
p.logger.Debug("processFile", zap.String("file", filePath), zap.String("inputDir", p.inputDir))
allDec := common.GetDecoder(filePath, p.skipNoopDecoder)
if len(allDec) == 0 {
return errors.New("skipping while no suitable decoder")
@@ -276,7 +335,19 @@ func (p *processor) processFile(filePath string) error {
return nil
}
func (p *processor) process(inputFile string, allDec []common.NewDecoderFunc) error {
func (p *processor) findDecoder(decoders []common.DecoderFactory, params *common.DecoderParams) (*common.Decoder, *common.DecoderFactory, error) {
for _, factory := range decoders {
dec := factory.Create(params)
err := dec.Validate()
if err == nil {
return &dec, &factory, nil
}
logger.Warn("try decode failed", zap.Error(err))
}
return nil, nil, errors.New("no any decoder can resolve the file")
}
func (p *processor) process(inputFile string, allDec []common.DecoderFactory) error {
file, err := os.Open(inputFile)
if err != nil {
return err
@@ -284,26 +355,17 @@ func (p *processor) process(inputFile string, allDec []common.NewDecoderFunc) er
defer file.Close()
logger := logger.With(zap.String("source", inputFile))
decParams := &common.DecoderParams{
pDec, decoderFactory, err := p.findDecoder(allDec, &common.DecoderParams{
Reader: file,
Extension: filepath.Ext(inputFile),
FilePath: inputFile,
Logger: logger,
CryptoParams: p.crypto,
})
if err != nil {
return err
}
var dec common.Decoder
for _, decFunc := range allDec {
dec = decFunc(decParams)
if err := dec.Validate(); err == nil {
break
} else {
logger.Warn("try decode failed", zap.Error(err))
dec = nil
}
}
if dec == nil {
return errors.New("no any decoder can resolve the file")
}
dec := *pDec
params := &ffmpeg.UpdateMetadataParams{}
@@ -365,13 +427,14 @@ func (p *processor) process(inputFile string, allDec []common.NewDecoderFunc) er
return fmt.Errorf("get relative dir failed: %w", err)
}
inFilename := strings.TrimSuffix(filepath.Base(inputFile), filepath.Ext(inputFile))
inFilename := strings.TrimSuffix(filepath.Base(inputFile), decoderFactory.Suffix)
outPath := filepath.Join(p.outputDir, inputRelDir, inFilename+params.AudioExt)
if !p.overwriteOutput {
_, err := os.Stat(outPath)
if err == nil {
return fmt.Errorf("output file %s is already exist", outPath)
logger.Warn("output file already exist, skip", zap.String("destination", outPath))
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("stat output file failed: %w", err)
}
@@ -391,7 +454,7 @@ func (p *processor) process(inputFile string, allDec []common.NewDecoderFunc) er
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if err := ffmpeg.UpdateMeta(ctx, outPath, params); err != nil {
if err := ffmpeg.UpdateMeta(ctx, outPath, params, logger); 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.8.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.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.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.29.0
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
golang.org/x/text v0.20.0
modernc.org/sqlite v1.37.0
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // 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/sys v0.31.0 // indirect
modernc.org/libc v1.62.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.9.1 // indirect
)

98
go.sum
View File

@@ -1,47 +1,85 @@
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/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.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=
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/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.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/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.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-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=
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.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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=
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=
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.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -9,8 +9,10 @@ import (
"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) {
@@ -43,9 +45,9 @@ type UpdateMetadataParams struct {
AlbumArtExt string // required if AlbumArt is not nil
}
func UpdateMeta(ctx context.Context, outPath string, params *UpdateMetadataParams) error {
func UpdateMeta(ctx context.Context, outPath string, params *UpdateMetadataParams, logger *zap.Logger) error {
if params.AudioExt == ".flac" {
return updateMetaFlac(ctx, outPath, params)
return updateMetaFlac(ctx, outPath, params, logger.With(zap.String("module", "updateMetaFlac")))
} else {
return updateMetaFFmpeg(ctx, outPath, params)
}

View File

@@ -2,6 +2,7 @@ package ffmpeg
import (
"context"
"go.uber.org/zap"
"mime"
"strings"
@@ -11,7 +12,7 @@ import (
"golang.org/x/exp/slices"
)
func updateMetaFlac(_ context.Context, outPath string, m *UpdateMetadataParams) error {
func updateMetaFlac(_ context.Context, outPath string, m *UpdateMetadataParams, logger *zap.Logger) error {
f, err := flac.ParseFile(m.Audio)
if err != nil {
return err
@@ -62,16 +63,18 @@ func updateMetaFlac(_ context.Context, outPath string, m *UpdateMetadataParams)
}
if m.AlbumArt != nil {
coverMime := mime.TypeByExtension(m.AlbumArtExt)
logger.Debug("cover image mime detect", zap.String("mime", coverMime))
cover, err := flacpicture.NewFromImageData(
flacpicture.PictureTypeFrontCover,
"Front cover",
m.AlbumArt,
mime.TypeByExtension(m.AlbumArtExt),
coverMime,
)
if err != nil {
return err
}
logger.Warn("failed to create flac cover", zap.Error(err))
} else {
coverBlock := cover.Marshal()
f.Meta = append(f.Meta, &coverBlock)
@@ -85,6 +88,7 @@ func updateMetaFlac(_ context.Context, outPath string, m *UpdateMetadataParams)
f.Meta[coverIdx] = &coverBlock
}
}
}
return f.Save(outPath)
}

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