From cd7f61af441fb163627e08de5daaacd4111cd7b7 Mon Sep 17 00:00:00 2001 From: aynakeya Date: Tue, 21 Apr 2026 00:08:16 +0800 Subject: [PATCH] fix id3 write utf8 character failed. fix description and example --- README.md | 9 +- cmd/miaosic/README.md | 388 +++++++++++++--------------------- cmd/miaosic/cmds/download.go | 36 ++-- cmd/miaosic/cmds/info.go | 7 +- cmd/miaosic/cmds/lyric.go | 7 +- cmd/miaosic/cmds/providers.go | 1 + cmd/miaosic/cmds/qrcode.go | 17 +- cmd/miaosic/cmds/quality.go | 8 +- cmd/miaosic/cmds/search.go | 7 +- cmd/miaosic/cmds/tag.go | 163 ++++++++++++++ cmd/miaosic/cmds/url.go | 7 +- cmd/miaosic/main.go | 8 +- tag/writer_id3v2.go | 37 +++- tag/writer_id3v2_test.go | 25 +++ 14 files changed, 446 insertions(+), 274 deletions(-) create mode 100644 cmd/miaosic/cmds/tag.go create mode 100644 tag/writer_id3v2_test.go diff --git a/README.md b/README.md index c7a2c1a..3c35d74 100644 --- a/README.md +++ b/README.md @@ -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 ` +3. `miaosic url|download|lyric ...` +4. `miaosic tag read|write ...` for local metadata operations. ## Available Providers diff --git a/cmd/miaosic/README.md b/cmd/miaosic/README.md index 864def8..eebe3fe 100644 --- a/cmd/miaosic/README.md +++ b/cmd/miaosic/README.md @@ -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 [flags] -``` +## 详细命令 -**参数:** -- ``: 音乐提供者(如 netease, qq 等) -- ``: 搜索关键词 - -**标志:** -- `-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 -``` - -**参数:** -- ``: 音乐提供者 -- ``: 媒体URI(URL 或 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 [flags] -``` - -**参数:** -- ``: 音乐提供者 -- ``: 媒体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 [flags] -``` - -**参数:** -- ``: 音乐提供者 -- ``: 媒体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 -``` - -**参数:** -- ``: 支持登录的音乐提供者 - -**示例:** -```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 -``` - -**参数:** -- ``: 音乐提供者 -- ``: 从 getqrcode 命令获取的 key - -**示例:** -```bash -miaosic qrlogin verify netease 1234567890abcdef -``` - -**输出示例:** -``` -Login successful! -Session: -``` - -#### 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 [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 [flags] +``` +flags: +- `-j, --json` JSON 输出 -## 常见问题 +示例: +```bash +miaosic info netease 1827600686 +miaosic info qq 004Z8Ihr0JIu5s -j +``` -### 如何保存登录状态? +### url -使用 `--session-file` 参数指定会话文件路径: +```bash +miaosic url [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 [flags] +``` + +flags: +- `-j, --json` JSON 输出 + +示例: +```bash +miaosic quality netease +``` + +### lyric + +```bash +miaosic lyric [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 [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 +``` + +验证登录: +```bash +miaosic qrlogin verify +``` + +示例: ```bash miaosic --session-file ~/.miaosic_session.json qrlogin getqrcode netease miaosic --session-file ~/.miaosic_session.json qrlogin verify netease ``` -### 为什么二维码在我的终端不显示? +### tag -确保您使用的终端支持显示图片(如 iTerm2、Windows Terminal 等)。如果不支持,可以考虑使用其他终端或使用文本模式的二维码实现。 +读取标签: +```bash +miaosic tag read [--format plain|json] +``` -### 如何添加新的音乐提供者? +写入标签: +```bash +miaosic tag write [flags] +``` -1. 实现 `miaosic.MediaProvider` 接口 \ No newline at end of file +`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 +``` diff --git a/cmd/miaosic/cmds/download.go b/cmd/miaosic/cmds/download.go index f226cb4..a7f3549 100644 --- a/cmd/miaosic/cmds/download.go +++ b/cmd/miaosic/cmds/download.go @@ -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 ", - 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 --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) diff --git a/cmd/miaosic/cmds/info.go b/cmd/miaosic/cmds/info.go index 2246966..2954b9d 100644 --- a/cmd/miaosic/cmds/info.go +++ b/cmd/miaosic/cmds/info.go @@ -13,8 +13,11 @@ func init() { var CmdInfo = &cobra.Command{ Use: "info ", - 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] diff --git a/cmd/miaosic/cmds/lyric.go b/cmd/miaosic/cmds/lyric.go index bc413ae..c71e249 100644 --- a/cmd/miaosic/cmds/lyric.go +++ b/cmd/miaosic/cmds/lyric.go @@ -23,8 +23,11 @@ func init() { var CmdLyric = &cobra.Command{ Use: "lyric ", - 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] diff --git a/cmd/miaosic/cmds/providers.go b/cmd/miaosic/cmds/providers.go index e0ea1ae..c0aaa5b 100644 --- a/cmd/miaosic/cmds/providers.go +++ b/cmd/miaosic/cmds/providers.go @@ -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() diff --git a/cmd/miaosic/cmds/qrcode.go b/cmd/miaosic/cmds/qrcode.go index 6014e39..b98b9f5 100644 --- a/cmd/miaosic/cmds/qrcode.go +++ b/cmd/miaosic/cmds/qrcode.go @@ -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 ", - Short: "Get QR code for login", - Args: cobra.ExactArgs(1), + Use: "getqrcode ", + 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 ", - Short: "Verify QR login", - Args: cobra.ExactArgs(2), + Use: "verify ", + Short: "Verify QR login", + Long: "Verify a scanned QR login key and persist the provider session.", + Example: " miaosic qrlogin verify netease \n miaosic --session-file ~/.miaosic_session.json qrlogin verify qq ", + Args: cobra.ExactArgs(2), Run: func(cmd *cobra.Command, args []string) { providerName := args[0] key := args[1] diff --git a/cmd/miaosic/cmds/quality.go b/cmd/miaosic/cmds/quality.go index f44edfa..ff43c5a 100644 --- a/cmd/miaosic/cmds/quality.go +++ b/cmd/miaosic/cmds/quality.go @@ -13,9 +13,11 @@ func init() { } var CmdQuality = &cobra.Command{ - Use: "quality ", - Short: "List supported qualities for a provider", - Args: cobra.ExactArgs(1), + Use: "quality ", + 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] diff --git a/cmd/miaosic/cmds/search.go b/cmd/miaosic/cmds/search.go index 22100b4..41f5d6a 100644 --- a/cmd/miaosic/cmds/search.go +++ b/cmd/miaosic/cmds/search.go @@ -21,8 +21,11 @@ func init() { var CmdSearch = &cobra.Command{ Use: "search ", - 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:] diff --git a/cmd/miaosic/cmds/tag.go b/cmd/miaosic/cmds/tag.go new file mode 100644 index 0000000..bd64e3e --- /dev/null +++ b/cmd/miaosic/cmds/tag.go @@ -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 ", + 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 ", + 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: ") + } 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: ") + } 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)) + } + } +} diff --git a/cmd/miaosic/cmds/url.go b/cmd/miaosic/cmds/url.go index eead7c3..5145980 100644 --- a/cmd/miaosic/cmds/url.go +++ b/cmd/miaosic/cmds/url.go @@ -14,8 +14,11 @@ func init() { var CmdUrl = &cobra.Command{ Use: "url ", - 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] diff --git a/cmd/miaosic/main.go b/cmd/miaosic/main.go index 5d8555e..d9098e1 100644 --- a/cmd/miaosic/main.go +++ b/cmd/miaosic/main.go @@ -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() { diff --git a/tag/writer_id3v2.go b/tag/writer_id3v2.go index 71ebe59..64b327d 100644 --- a/tag/writer_id3v2.go +++ b/tag/writer_id3v2.go @@ -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 diff --git a/tag/writer_id3v2_test.go b/tag/writer_id3v2_test.go new file mode 100644 index 0000000..7ba6f8a --- /dev/null +++ b/tag/writer_id3v2_test.go @@ -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) +}