fix id3 write utf8 character failed. fix description and example

This commit is contained in:
aynakeya
2026-04-21 00:08:16 +08:00
parent cada3cffd0
commit cd7f61af44
14 changed files with 446 additions and 274 deletions

View File

@@ -4,11 +4,16 @@ Music Provider Repository, provide a universal interface for different music ser
## Command line Tool
please check [miaosic cmd tool](./cmd/miaosic/README.md)
See [miaosic CLI docs](./cmd/miaosic/README.md).
## How to Use
please figure it out by yourself.
Use the CLI commands documented in [cmd/miaosic/README.md](./cmd/miaosic/README.md).
Typical flow:
1. `miaosic providers`
2. `miaosic search <provider> <keyword>`
3. `miaosic url|download|lyric ...`
4. `miaosic tag read|write <file> ...` for local metadata operations.
## Available Providers

View File

@@ -1,272 +1,188 @@
# miaosic 命令行工具
# miaosic CLI
> 注意: 改文档由deepseek生成实例可能会有错误
`miaosic` 是一个音乐服务命令行工具支持搜索、信息查询、URL 解析、歌词、下载、二维码登录以及音频标签读写。
`miaosic` 是一个功能强大的音乐信息命令行工具支持多种音乐平台提供者可以搜索音乐、获取媒体信息、获取播放URL、下载歌词以及进行二维码登录等操作。
## 功能特性
- 🎵 搜索音乐(支持分页)
- 获取媒体详细信息
- 🔗 获取媒体播放URL支持指定音质
- 📜 获取歌词(支持多语言)
- 🔑 二维码登录支持
- 📋 列出所有支持的提供者及登录状态
- 💾 会话管理(跨命令保存登录状态)
## 使用指南
### 基本命令结构
## 基本用法
```bash
miaosic [全局参数] <命令> [命令参数] [命令标志]
miaosic [全局参数] <命令> [参数] [flags]
```
### 全局参数
## 全局参数
| 参数 | 缩写 | 描述 |
|------------------|------|--------------------|
| `--session-file` | `-s` | 指定会话文件路径用于保存登录状态 |
| `--json` | `-j` | 使用json输出 |
| 参数 | 缩写 | 说明 |
|---|---|---|
| `--session-file` | `-s` | 会话文件路径用于保存/恢复登录状态 |
### 命令列表
说明:`--json/-j` 不是全局参数,只在部分命令中提供。
#### 1. 搜索音乐:`search`
## 命令概览
搜索指定提供者的音乐。
| 命令 | 说明 |
|---|---|
| `providers` | 列出所有 provider 及登录状态 |
| `search` | 按关键词搜索 |
| `info` | 查询媒体信息 |
| `url` | 获取可播放 URL |
| `quality` | 查看 provider 支持的音质 |
| `lyric` | 获取歌词并可导出到文件 |
| `download` | 下载媒体(可选写入标签) |
| `qrlogin` | 二维码登录流程 |
| `tag` | 读取/写入本地音频标签 |
```bash
./miaosic search <provider> <keyword> [flags]
```
## 详细命令
**参数:**
- `<provider>`: 音乐提供者(如 netease, qq 等)
- `<keyword>`: 搜索关键词
**标志:**
- `-p, --page`: 页码默认1
- `--page-size`: 每页结果数默认10
**示例:**
```bash
./miaosic search netease "周杰伦" -p 1 --page-size 5
```
**输出示例:**
```
Page.01 for "周杰伦"
1. 屋顶 - 周杰伦,温岚 - 男女情歌对唱冠军全记录 - 5257138
2. 想你就写信 (Live) - 周杰伦,李硕,张鑫 - 中国新歌声第二季 第13期 - 509781655
3. 布拉格广场 - 蔡依林,周杰伦 - 看我72变 - 210049
4. 默 (Live) - 李荣浩,周杰伦 - 2021中国好声音 第1期 - 1888354230
5. 因为爱情 (Live) - 周杰伦,那英 - 中国新歌声第二季 第1期 - 490595315
```
#### 2. 获取媒体信息:`info`
获取指定URI的媒体信息。
```bash
./miaosic info <provider> <uri>
```
**参数:**
- `<provider>`: 音乐提供者
- `<uri>`: 媒体URIURL 或 ID
**示例:**
```bash
./miaosic info netease 1827600686
```
**输出示例:**
```
Title: 还是会想你
Artist: 林达浪,h3R3
Album: 还是会想你
Cover https://p1.music.126.net/9FhSEQtMhP-JP3_U84YfWQ==/109951165798773745.jpg
Provider: netease
Identifier: 1827600686
```
#### 3. 获取媒体URL`url`
获取指定URI的媒体播放URL。(部分歌源可能需要登陆)
```bash
miaosic url <provider> <uri> [flags]
```
**参数:**
- `<provider>`: 音乐提供者
- `<uri>`: 媒体URI
**标志:**
- `--quality`: 音质偏好128k, 192k, 256k, 320k, hq, sq
**示例:**
```bash
miaosic url netease 1827600686
```
**输出示例:**
```
URL 1:
Quality: 320k
URL: http://example.com/audio.mp3
Headers:
User-Agent: Mozilla/5.0
Referer: http://example.com
```
#### 4. 获取歌词:`lyric`
获取指定URI的歌词。
```bash
miaosic lyric <provider> <uri> [flags]
```
**参数:**
- `<provider>`: 音乐提供者
- `<uri>`: 媒体URI
**标志:**
- `-o, --output`: 指定输出文件
- `--save`: 自动保存歌词文件名格式歌名_歌手名.lrc
**示例:**
```bash
# 控制台输出歌词
miaosic lyric netease 1827600686
# 保存歌词到指定文件
miaosic lyric netease 1827600686 -o lyrics.txt
# 自动保存歌词
miaosic lyric netease 1827600686 --save
```
**输出示例(控制台):**
```
Language: zh
-----
[00:00.00]歌曲名:晴天
[00:05.00]歌手:周杰伦
...
-----
Language: en
-----
[00:00.00]Song: Qing Tian
[00:05.00]Artist: Jay Chou
...
-----
```
#### 5. 二维码登录:`qrlogin`
提供二维码登录相关操作。
##### 获取登录二维码:`getqrcode`
```bash
miaosic qrlogin getqrcode <provider>
```
**参数:**
- `<provider>`: 支持登录的音乐提供者
**示例:**
```bash
miaosic qrlogin getqrcode netease
```
**输出示例:**
```
Scan this QR code to login:
[显示二维码图片]
Key: 1234567890abcdef
URL: https://login.example.com/qrcode?key=1234567890abcdef
```
##### 验证登录:`verify`
```bash
miaosic qrlogin verify <provider> <key>
```
**参数:**
- `<provider>`: 音乐提供者
- `<key>`: 从 getqrcode 命令获取的 key
**示例:**
```bash
miaosic qrlogin verify netease 1234567890abcdef
```
**输出示例:**
```
Login successful!
Session: <session_string>
```
#### 6. 列出提供者:`providers`
列出所有注册的提供者及其登录状态。
### providers
```bash
miaosic providers
```
**输出示例:**
```
- bilibili-video: Not supported
- kugou: Not logged in
- kugou-instr: Not supported
- kuwo: Not supported
- netease: Logged in
```
## 配置文件
使用 `--session-file` 参数指定会话文件路径,以便在不同命令之间保持登录状态:
### search
```bash
miaosic --session-file ~/.miaosic_session.json providers
miaosic search <provider> <keyword> [flags]
```
会话文件格式为 JSON
```json
{
"netease": "session_string_here",
"qq": "another_session_string"
}
flags:
- `-p, --page` 页码,默认 `1`
- `--page-size` 每页数量,默认 `10`
- `-j, --json` JSON 输出
示例:
```bash
miaosic search netease "周杰伦" -p 1 --page-size 5
miaosic search qq Jay -j
```
## 支持的提供者
### info
| 提供者 | 搜索 | 媒体信息 | 播放URL | 歌词 | 登录 |
|----------------|------|----------|---------|------|------|
| netease | ✓ | ✓ | ✓ | ✓ | ✓ |
| kuwo | ✓ | ✓ | ✓ | ✓ | ✗ |
| kugou | ✓ | ✓ | ✓ | ✓ | ✓ |
| bilibili-video | ✓ | ✓ | ✓ | ✓ | ✗ |
```bash
miaosic info <provider> <uri> [flags]
```
flags:
- `-j, --json` JSON 输出
## 常见问题
示例:
```bash
miaosic info netease 1827600686
miaosic info qq 004Z8Ihr0JIu5s -j
```
### 如何保存登录状态?
### url
使用 `--session-file` 参数指定会话文件路径:
```bash
miaosic url <provider> <uri> [flags]
```
flags:
- `--quality` 音质偏好,如 `128k/320k/flac/hq/sq`
- `-j, --json` JSON 输出
示例:
```bash
miaosic url netease 1827600686 --quality 320k
miaosic url qq 004Z8Ihr0JIu5s -j
```
### quality
```bash
miaosic quality <provider> [flags]
```
flags:
- `-j, --json` JSON 输出
示例:
```bash
miaosic quality netease
```
### lyric
```bash
miaosic lyric <provider> <uri> [flags]
```
flags:
- `-o, --output` 输出到指定文件
- `--save` 自动按歌曲信息命名保存
- `-j, --json` JSON 输出
示例:
```bash
miaosic lyric netease 1827600686
miaosic lyric netease 1827600686 --save
miaosic lyric qq 004Z8Ihr0JIu5s -o lyric.lrc
```
### download
```bash
miaosic download <provider> <uri> [flags]
```
flags:
- `--quality` 指定音质偏好
- `--filename` 指定输出文件名
- `--use-actual-ext``--filename` 后缀和实际下载到的音频后缀不一致时,自动替换为实际后缀
- `--metadata` 下载后写入标签(标题/艺人/专辑/歌词/封面)
示例:
```bash
miaosic download netease 1827600686
miaosic download qq 004Z8Ihr0JIu5s --quality 320k --filename song.mp3
miaosic download kugou 3e3f9e3a4b47125e4b4558ca0bb4264a --quality flac --filename song.flac --use-actual-ext
miaosic download netease 1827600686 --metadata
```
### qrlogin
获取二维码:
```bash
miaosic qrlogin getqrcode <provider>
```
验证登录:
```bash
miaosic qrlogin verify <provider> <key>
```
示例:
```bash
miaosic --session-file ~/.miaosic_session.json qrlogin getqrcode netease
miaosic --session-file ~/.miaosic_session.json qrlogin verify netease <key>
```
### 为什么二维码在我的终端不显示?
### tag
确保您使用的终端支持显示图片(如 iTerm2、Windows Terminal 等)。如果不支持,可以考虑使用其他终端或使用文本模式的二维码实现。
读取标签:
```bash
miaosic tag read <file> [--format plain|json]
```
### 如何添加新的音乐提供者?
写入标签:
```bash
miaosic tag write <file> [flags]
```
1. 实现 `miaosic.MediaProvider` 接口
`tag write` flags:
- `--title`
- `--artist`
- `--album`
- `--lyrics`
- `--lyrics-lang` (默认 `eng`)
- `--cover` 封面图片路径
- `--cover-type` 封面类型(默认前封面)
示例:
```bash
miaosic tag read ./data/test.mp3
miaosic tag read ./data/test.m4a --format json
miaosic tag write ./data/test.flac --title "Title" --artist "Artist" --album "Album"
miaosic tag write ./data/test.mp3 --lyrics "hello world" --lyrics-lang eng
miaosic tag write ./data/test.m4a --cover ./data/cover.jpg --cover-type 3
```

View File

@@ -14,28 +14,30 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
)
var (
writeMetadata bool
downloadQuality string
specifiedFilename string
useActualExt bool
)
func init() {
CmdDownload.Flags().BoolVar(&writeMetadata, "metadata", false, "Write metadata (tags, cover, lyrics) to the file")
CmdDownload.Flags().StringVar(&downloadQuality, "quality", "", "Quality preference (e.g., 128k, 320k, flac)")
CmdDownload.Flags().StringVar(&specifiedFilename, "filename", "", "Filename to use for download")
CmdDownload.Flags().BoolVar(&useActualExt, "use-actual-ext", false, "If --filename extension mismatches detected audio extension, replace it with actual extension")
}
var CmdDownload = &cobra.Command{
Use: "download <provider> <uri>",
Short: "Download media, with metadata and cover art",
Long: `Downloads a media file from a provider.
It fetches media information, URL, lyrics, and cover art.
By default, it writes all available metadata to the downloaded file.
Supported formats for metadata include MP3 and FLAC.`,
Args: cobra.ExactArgs(2),
Short: "Download media from a provider URI",
Long: `Download a media file from provider URI, with optional quality and filename.
When --metadata is enabled, miaosic also writes title/artist/album/lyrics/cover tags.`,
Example: " miaosic download netease 1827600686\n miaosic download qq 004Z8Ihr0JIu5s --quality 320k --filename song.mp3\n miaosic download kugou <uri> --filename song.flac --use-actual-ext\n miaosic download netease 1827600686 --metadata",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
// Steps 1-3: Get provider, media info, and URL (this part is unchanged)
providerName := args[0]
@@ -97,20 +99,28 @@ Supported formats for metadata include MP3 and FLAC.`,
}
downloadedBytes := mediaData.Bytes()
detectedExt := mimetype.Detect(downloadedBytes[:min(512, len(downloadedBytes))]).Extension()
parsedURL, urlErr := url.Parse(mediaURL.Url)
var ext string
if urlErr != nil {
ext = ""
} else {
ext = filepath.Ext(parsedURL.Path)
urlExt := ""
if urlErr == nil {
urlExt = filepath.Ext(parsedURL.Path)
}
if ext == "" {
ext = mimetype.Detect(downloadedBytes[:min(512, len(downloadedBytes))]).Extension()
actualExt := detectedExt
if actualExt == "" {
actualExt = urlExt
}
ext := actualExt
filename := sanitizeFilename(fmt.Sprintf("%s - %s%s", info.Artist, info.Title, ext))
// Step 5: Save the file from the buffer
if specifiedFilename != "" {
filename = specifiedFilename
if useActualExt && actualExt != "" {
currentExt := filepath.Ext(filename)
if !strings.EqualFold(currentExt, actualExt) {
base := filename[:len(filename)-len(currentExt)]
filename = base + actualExt
}
}
}
err = os.WriteFile(filename, downloadedBytes, 0644)

View File

@@ -13,8 +13,11 @@ func init() {
var CmdInfo = &cobra.Command{
Use: "info <provider> <uri>",
Short: "Get media info",
Args: cobra.ExactArgs(2),
Short: "Get media metadata by provider and URI",
Long: `Resolve a provider-specific URI and print media metadata such as
title, artist, album, cover, and identifier.`,
Example: " miaosic info netease 1827600686\n miaosic info qq https://y.qq.com/n/ryqq/songDetail/004Z8Ihr0JIu5s -j",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
providerName := args[0]
uri := args[1]

View File

@@ -23,8 +23,11 @@ func init() {
var CmdLyric = &cobra.Command{
Use: "lyric <provider> <uri>",
Short: "Get media lyrics",
Args: cobra.ExactArgs(2),
Short: "Get lyrics for a media item",
Long: `Fetch lyric tracks for a provider media URI.
Lyrics can be printed, exported to JSON, or written to .lrc files.`,
Example: " miaosic lyric netease 1827600686\n miaosic lyric netease 1827600686 --save\n miaosic lyric qq 004Z8Ihr0JIu5s -o lyric.lrc",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
providerName := args[0]
uri := args[1]

View File

@@ -9,6 +9,7 @@ import (
var CmdProviders = &cobra.Command{
Use: "providers",
Short: "List all registered providers and login status",
Long: "List all available providers and whether each provider is logged in or supports login.",
Run: func(cmd *cobra.Command, args []string) {
providers := miaosic.ListAvailableProviders()

View File

@@ -13,12 +13,15 @@ import (
var CmdQrlogin = &cobra.Command{
Use: "qrlogin",
Short: "QR code login operations",
Long: "Manage provider login sessions using QR code flow.",
}
var getqrcodeCmd = &cobra.Command{
Use: "getqrcode <provider>",
Short: "Get QR code for login",
Args: cobra.ExactArgs(1),
Use: "getqrcode <provider>",
Short: "Get QR code for login",
Long: "Generate and print a login QR code, then return key/url for verification.",
Example: " miaosic qrlogin getqrcode netease\n miaosic qrlogin getqrcode qq",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
providerName := args[0]
@@ -58,9 +61,11 @@ var getqrcodeCmd = &cobra.Command{
}
var verifyCmd = &cobra.Command{
Use: "verify <provider> <key>",
Short: "Verify QR login",
Args: cobra.ExactArgs(2),
Use: "verify <provider> <key>",
Short: "Verify QR login",
Long: "Verify a scanned QR login key and persist the provider session.",
Example: " miaosic qrlogin verify netease <key>\n miaosic --session-file ~/.miaosic_session.json qrlogin verify qq <key>",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
providerName := args[0]
key := args[1]

View File

@@ -13,9 +13,11 @@ func init() {
}
var CmdQuality = &cobra.Command{
Use: "quality <provider>",
Short: "List supported qualities for a provider",
Args: cobra.ExactArgs(1),
Use: "quality <provider>",
Short: "List supported qualities for a provider",
Long: "List quality options supported by a provider, such as 128k, 320k, flac, hq, or sq.",
Example: " miaosic quality netease\n miaosic quality qq -j",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
providerName := args[0]

View File

@@ -21,8 +21,11 @@ func init() {
var CmdSearch = &cobra.Command{
Use: "search <provider> <keyword>",
Short: "Search media by keyword",
Args: cobra.MinimumNArgs(2),
Short: "Search media by keyword from a provider",
Long: `Search media from the specified provider using keyword text.
Supports paging and optional JSON output.`,
Example: " miaosic search netease \"周杰伦\"\n miaosic search qq Jay -p 2 --page-size 20 -j",
Args: cobra.MinimumNArgs(2),
Run: func(cmd *cobra.Command, args []string) {
providerName := args[0]
keywords := args[1:]

163
cmd/miaosic/cmds/tag.go Normal file
View File

@@ -0,0 +1,163 @@
package cmds
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/AynaLivePlayer/miaosic/tag"
"github.com/gabriel-vasile/mimetype"
"github.com/spf13/cobra"
)
var (
tagReadFormat string
tagWriteTitle string
tagWriteArtist string
tagWriteAlbum string
tagWriteLyrics string
tagWriteLang string
tagWriteCover string
tagWriteCoverTyp byte
)
func init() {
CmdTagRead.Flags().StringVar(&tagReadFormat, "format", "plain", "output format: plain or json")
CmdTagWrite.Flags().StringVar(&tagWriteTitle, "title", "", "title")
CmdTagWrite.Flags().StringVar(&tagWriteArtist, "artist", "", "artist")
CmdTagWrite.Flags().StringVar(&tagWriteAlbum, "album", "", "album")
CmdTagWrite.Flags().StringVar(&tagWriteLyrics, "lyrics", "", "lyrics text")
CmdTagWrite.Flags().StringVar(&tagWriteLang, "lyrics-lang", "eng", "lyrics language")
CmdTagWrite.Flags().StringVar(&tagWriteCover, "cover", "", "cover image path")
CmdTagWrite.Flags().Uint8Var(&tagWriteCoverTyp, "cover-type", tag.PictureTypeFrontCover, "cover picture type")
CmdTag.AddCommand(CmdTagRead)
CmdTag.AddCommand(CmdTagWrite)
}
var CmdTag = &cobra.Command{
Use: "tag",
Short: "Read or write audio metadata tags",
Long: "Read or update tags (title, artist, album, lyrics, cover) for local audio files.",
}
var CmdTagRead = &cobra.Command{
Use: "read <file>",
Short: "Read metadata tags from audio file",
Long: "Read metadata tags from a local audio file and print in plain or JSON format.",
Example: " miaosic tag read ./data/test.wav\n miaosic tag read ./data/test.mp3 --format json",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
filepath := args[0]
f, err := os.Open(filepath)
if err != nil {
fmt.Printf("Error opening file: %v\n", err)
return
}
defer f.Close()
meta, err := tag.Read(f)
if err != nil {
fmt.Printf("Error reading tags: %v\n", err)
return
}
switch strings.ToLower(tagReadFormat) {
case "json":
b, err := json.MarshalIndent(meta, "", " ")
if err != nil {
fmt.Printf("Error marshaling metadata: %v\n", err)
return
}
fmt.Println(string(b))
case "plain", "":
printTagMetadata(meta)
default:
fmt.Printf("Unsupported format: %s (supported: plain, json)\n", tagReadFormat)
}
},
}
var CmdTagWrite = &cobra.Command{
Use: "write <file>",
Short: "Write metadata tags to audio file",
Long: `Write selected metadata fields to a local audio file.
Existing tags are preserved unless the corresponding flag is provided.`,
Example: " miaosic tag write ./song.mp3 --title \"Hello\" --artist \"A\"\n miaosic tag write ./song.m4a --lyrics \"line1\" --lyrics-lang eng\n miaosic tag write ./song.flac --cover ./data/cover.jpg --cover-type 3",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
filepath := args[0]
if tagWriteTitle == "" && tagWriteArtist == "" && tagWriteAlbum == "" && tagWriteLyrics == "" && tagWriteCover == "" {
fmt.Println("Nothing to write. Use at least one of --title/--artist/--album/--lyrics/--cover.")
return
}
meta := tag.Metadata{}
if f, err := os.Open(filepath); err == nil {
if existing, readErr := tag.Read(f); readErr == nil {
meta = existing
}
_ = f.Close()
}
if tagWriteTitle != "" {
meta.Title = tagWriteTitle
}
if tagWriteArtist != "" {
meta.Artist = tagWriteArtist
}
if tagWriteAlbum != "" {
meta.Album = tagWriteAlbum
}
if tagWriteLyrics != "" {
meta.Lyrics = []tag.Lyrics{{
Lang: tagWriteLang,
Lyrics: tagWriteLyrics,
}}
}
if tagWriteCover != "" {
coverData, err := os.ReadFile(tagWriteCover)
if err != nil {
fmt.Printf("Error reading cover image: %v\n", err)
return
}
meta.Pictures = []tag.Picture{{
Mimetype: mimetype.Detect(coverData).String(),
Type: tagWriteCoverTyp,
Description: "cover",
Data: coverData,
}}
}
if err := tag.WriteTo(filepath, meta); err != nil {
fmt.Printf("Error writing tags: %v\n", err)
return
}
fmt.Println("Tags written successfully.")
},
}
func printTagMetadata(meta tag.Metadata) {
fmt.Println("Format:", meta.Format)
fmt.Println("Mimetype:", meta.Mimetype)
fmt.Println("Title:", meta.Title)
fmt.Println("Artist:", meta.Artist)
fmt.Println("Album:", meta.Album)
if len(meta.Lyrics) == 0 {
fmt.Println("Lyrics: <none>")
} else {
for i, lyric := range meta.Lyrics {
fmt.Printf("Lyrics[%d]: [%s] %s\n", i, lyric.Lang, lyric.Lyrics)
}
}
if len(meta.Pictures) == 0 {
fmt.Println("Pictures: <none>")
} else {
for i, pic := range meta.Pictures {
fmt.Printf("Picture[%d]: type=%d mime=%s size=%d\n", i, pic.Type, pic.Mimetype, len(pic.Data))
}
}
}

View File

@@ -14,8 +14,11 @@ func init() {
var CmdUrl = &cobra.Command{
Use: "url <provider> <uri>",
Short: "Get media URLs",
Args: cobra.ExactArgs(2),
Short: "Resolve playable media URLs",
Long: `Resolve one or more playable URLs from a provider media URI.
Use --quality to request a preferred quality, and -j for JSON output.`,
Example: " miaosic url netease 1827600686\n miaosic url qq 004Z8Ihr0JIu5s --quality 320k -j",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
providerName := args[0]
uri := args[1]

View File

@@ -2,6 +2,7 @@ package main
import (
"fmt"
"github.com/AynaLivePlayer/miaosic/cmd/miaosic/cmds"
"github.com/AynaLivePlayer/miaosic/cmd/miaosic/internal"
_ "github.com/AynaLivePlayer/miaosic/providers/bilivideo"
@@ -21,8 +22,10 @@ func init() {
var rootCmd = &cobra.Command{
Use: "miaosic",
Short: "cmdline tool for miaosic.",
Long: `cmdline tool for miaosic: a music searching tools`,
Short: "CLI for searching, fetching, downloading, and tagging music",
Long: `miaosic is a command-line client for music providers.
It supports search, media info, URL resolving, lyrics, downloads,
QR login, and audio metadata tag read/write operations.`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
if err := internal.RestoreSessions(sessionFile); err != nil {
fmt.Printf("Error restoring sessions from file: %v\n", err)
@@ -47,6 +50,7 @@ func init() {
rootCmd.AddCommand(cmds.CmdLyric)
rootCmd.AddCommand(cmds.CmdQuality)
rootCmd.AddCommand(cmds.CmdDownload)
rootCmd.AddCommand(cmds.CmdTag)
}
func main() {

View File

@@ -5,19 +5,22 @@ import (
"github.com/bogem/id3v2/v2"
"io"
"os"
"strings"
)
func setID3v2Metadata(tag *id3v2.Tag, meta Metadata) {
tag.DeleteAllFrames()
// Use Unicode-safe default encoding for text frames like TIT2/TPE1/TALB.
tag.SetDefaultEncoding(id3v2.EncodingUTF8)
tag.SetTitle(meta.Title)
tag.SetArtist(meta.Artist)
tag.SetAlbum(meta.Album)
for _, lyric := range meta.Lyrics {
lang := lyric.Lang
if lang == "" {
lang = "unk"
uslf := id3v2.UnsynchronisedLyricsFrame{
Encoding: id3v2.EncodingUTF8,
Language: normalizeID3Language(lyric.Lang),
Lyrics: lyric.Lyrics,
}
uslf := id3v2.UnsynchronisedLyricsFrame{Encoding: id3v2.EncodingUTF8, Language: lang[:min(3, len(lang))], Lyrics: lyric.Lyrics}
tag.AddUnsynchronisedLyricsFrame(uslf)
tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{
Encoding: id3v2.EncodingUTF8,
@@ -31,6 +34,32 @@ func setID3v2Metadata(tag *id3v2.Tag, meta Metadata) {
}
}
func normalizeID3Language(lang string) string {
l := strings.ToLower(strings.TrimSpace(lang))
switch l {
case "", "unk", "unknown":
return "und"
case "zh", "chi", "zho", "zh-cn", "zh-hans", "zh-hant":
return "zho"
case "en":
return "eng"
case "ja", "jp":
return "jpn"
case "ko":
return "kor"
}
if len(l) >= 3 {
c := l[:3]
for i := 0; i < 3; i++ {
if c[i] < 'a' || c[i] > 'z' {
return "und"
}
}
return c
}
return "und"
}
func WriteID3v2Tags(f *os.File, meta Metadata) error {
if _, err := f.Seek(0, io.SeekStart); err != nil {
return err

25
tag/writer_id3v2_test.go Normal file
View File

@@ -0,0 +1,25 @@
package tag
import (
"bytes"
"testing"
"github.com/bogem/id3v2/v2"
"github.com/stretchr/testify/require"
)
func TestSetID3v2MetadataSupportsUnicode(t *testing.T) {
meta := Metadata{
Title: "影子小姐",
Artist: "封茗囧菌",
Album: "中文专辑",
Lyrics: []Lyrics{{Lang: "zh", Lyrics: "这是一段中文歌词"}},
}
tag := id3v2.NewEmptyTag()
setID3v2Metadata(tag, meta)
var buf bytes.Buffer
_, err := tag.WriteTo(&buf)
require.NoError(t, err)
}