add download cmd

This commit is contained in:
aynakeya
2025-08-06 00:29:00 +08:00
parent baf8756a98
commit a6501d0c6c
13 changed files with 666 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
package cmds
import (
"bytes"
"fmt"
"github.com/AynaLivePlayer/miaosic"
"github.com/AynaLivePlayer/miaosic/cmd/miaosic/cmds/tagwriter"
"github.com/gabriel-vasile/mimetype"
"github.com/schollz/progressbar/v3"
"github.com/spf13/cobra"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
)
var (
writeMetadata bool
downloadQuality string
)
func init() {
CmdDownload.Flags().BoolVar(&writeMetadata, "metadata", true, "Write metadata (tags, cover, lyrics) to the file")
CmdDownload.Flags().StringVar(&downloadQuality, "quality", "", "Quality preference (e.g., 128k, 320k, flac)")
}
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),
Run: func(cmd *cobra.Command, args []string) {
// Steps 1-3: Get provider, media info, and URL (this part is unchanged)
providerName := args[0]
uri := args[1]
provider, ok := miaosic.GetProvider(providerName)
if !ok {
fmt.Printf("Provider not found: %s\n", providerName)
return
}
meta, ok := provider.MatchMedia(uri)
if !ok {
fmt.Printf("URI not matched by provider: %s\n", uri)
return
}
fmt.Println("Fetching media info...")
info, err := provider.GetMediaInfo(meta)
if err != nil {
fmt.Printf("Error getting media info: %v\n", err)
return
}
fmt.Printf("Found: %s - %s\n", info.Artist, info.Title)
fmt.Println("Fetching media URL...")
urls, err := provider.GetMediaUrl(meta, miaosic.Quality(downloadQuality))
if err != nil || len(urls) == 0 {
fmt.Printf("Error getting media URL or no URL found: %v\n", err)
return
}
mediaURL := urls[0]
fmt.Printf("Selected quality: %s\n", mediaURL.Quality)
// Step 4: Download media file with progress bar (this part is unchanged)
resp, err := http.Get(mediaURL.Url) // Simplified GET for clarity
if err != nil {
fmt.Printf("Error starting download: %v\n", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Printf("Error downloading file: server responded with status %d\n", resp.StatusCode)
return
}
totalSize, _ := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64)
bar := progressbar.NewOptions64(totalSize,
progressbar.OptionSetDescription(fmt.Sprintf("Downloading %s...", info.Title)),
progressbar.OptionSetWriter(os.Stderr),
progressbar.OptionShowBytes(true),
progressbar.OptionSetWidth(40),
progressbar.OptionOnCompletion(func() { fmt.Fprint(os.Stderr, "\n") }),
)
// Create a buffer and copy the download into it, with progress
mediaData := &bytes.Buffer{}
pRead := progressbar.NewReader(resp.Body, bar)
_, err = io.Copy(mediaData, &pRead)
if err != nil {
fmt.Printf("Error during download: %v\n", err)
return
}
// *** NEW: Detect content type from the first 512 bytes ***
downloadedBytes := mediaData.Bytes()
detectedContentType := mimetype.Detect(downloadedBytes[:min(512, len(downloadedBytes))]).String()
ext, err := extensionFromContentType(detectedContentType)
if err != nil {
// Fallback strategy if detection is inconclusive
fmt.Printf("Warning: Could not determine file type from content (%s). Falling back to URL extension.\n", detectedContentType)
parsedURL, urlErr := url.Parse(mediaURL.Url)
if urlErr != nil {
// If URL is malformed, we can't get an extension.
ext = ""
} else {
// Get extension from the path, which has no query string.
ext = filepath.Ext(parsedURL.Path)
}
if ext == "" {
fmt.Println("Warning: Could not determine file type from URL. Defaulting to .mp3.")
ext = ".mp3" // Final fallback
}
}
fmt.Printf("Detected file type: %s (%s)\n", detectedContentType, ext)
// Step 5: Save the file from the buffer
filename := sanitizeFilename(fmt.Sprintf("%s - %s%s", info.Artist, info.Title, ext))
err = os.WriteFile(filename, downloadedBytes, 0644)
if err != nil {
fmt.Printf("Error saving file to disk: %v\n", err)
return
}
// If metadata writing is disabled, we are done.
if !writeMetadata {
fmt.Printf("Download complete! Saved to %s\n", filename)
return
}
// Step 6: Write Metadata (same as before)
fmt.Println("Writing metadata...")
lyric, _ := provider.GetMediaLyric(meta)
var coverData []byte
if info.Cover.Url != "" {
fmt.Println("Downloading cover art...")
coverResp, err := http.Get(info.Cover.Url)
if err == nil && coverResp.StatusCode == http.StatusOK {
defer coverResp.Body.Close()
coverData, _ = io.ReadAll(coverResp.Body)
info.Cover.Data = coverData
fmt.Println("Cover art downloaded.")
} else {
fmt.Println("Could not download cover art.")
}
}
err = tagFile(filename, ext, info, lyric, info.Cover)
if err != nil {
fmt.Printf("Error writing metadata: %v\n", err)
fmt.Println("File is saved without metadata.")
} else {
fmt.Println("Metadata written successfully.")
}
fmt.Printf("Download complete! Saved to %s\n", filename)
},
}
// tagFile and its helpers (tagMp3, tagFlac) remain unchanged.
func tagFile(filename, ext string, info miaosic.MediaInfo, lyric []miaosic.Lyrics, cover miaosic.Picture) error {
switch strings.ToLower(ext) {
case ".mp3":
return tagwriter.WriteId3v2(filename, info, lyric, cover)
case ".flac":
return tagwriter.WriteFlac(filename, info, lyric, cover)
default:
return fmt.Errorf("unsupported file type for tagging: %s", ext)
}
}
func extensionFromContentType(ct string) (string, error) {
switch ct {
case "audio/mpeg":
return ".mp3", nil
case "audio/flac", "audio/x-flac":
return ".flac", nil
case "audio/mp4":
return ".m4a", nil
case "audio/aac":
return ".aac", nil
}
return "", fmt.Errorf("unsupported content type: %s", ct)
}

View File

@@ -1,5 +1,7 @@
package miaosic
const VERSION = "0.2.6"
type Picture struct {
Url string `json:"url"`
Data []byte `json:"data"`

67
tag/reader.go Normal file
View File

@@ -0,0 +1,67 @@
package tag
import (
"github.com/dhowden/tag"
"github.com/gabriel-vasile/mimetype"
"io"
)
func Read(r io.ReadSeeker) (Metadata, error) {
_, err := r.Seek(0, io.SeekStart)
if err != nil {
return Metadata{}, err
}
b := make([]byte, 512)
_, err = io.ReadFull(r, b)
if err != nil {
return Metadata{}, err
}
mimeType := mimetype.Detect(b).String()
_, err = r.Seek(0, io.SeekStart)
switch {
case string(b[0:4]) == "fLaC":
return ReadFLACTags(r, mimeType)
//case string(b[0:4]) == "OggS":
// return ReadOGGTags(r)
//case string(b[4:8]) == "ftyp":
// return ReadAtoms(r)
case string(b[0:3]) == "ID3":
return ReadID3v2Tags(r, mimeType)
//case string(b[0:4]) == "DSD ":
// return ReadDSFTags(r)
}
return fallbackRead(r, mimeType)
}
func fallbackRead(r io.ReadSeeker, mime string) (Metadata, error) {
meta := Metadata{
Mimetype: mime,
}
m, err := tag.ReadFrom(r)
if err != nil {
return Metadata{}, err
}
meta.Format = string(m.Format())
meta.Title = m.Title()
meta.Artist = m.Artist()
meta.Album = m.Album()
meta.Lyrics = []Lyrics{}
if m.Lyrics() != "" {
meta.Lyrics = append(meta.Lyrics, Lyrics{
Lang: "unk",
Lyrics: m.Lyrics(),
})
}
meta.Pictures = []Picture{}
if m.Picture() != nil {
p := m.Picture()
meta.Pictures = append(meta.Pictures, Picture{
Mimetype: p.MIMEType,
Type: PictureTypeFrontCover,
Description: p.Description,
Data: p.Data,
})
}
return meta, nil
}

65
tag/reader_flac.go Normal file
View File

@@ -0,0 +1,65 @@
package tag
import (
"github.com/go-flac/flacpicture/v2"
"github.com/go-flac/flacvorbis/v2"
"github.com/go-flac/go-flac/v2"
"io"
"strings"
)
func ReadFLACTags(r io.ReadSeeker, mime string) (Metadata, error) {
meta := Metadata{
Mimetype: mime,
Format: FormatVORBIS,
}
metadata, err := flac.ParseMetadata(r)
if err != nil {
return Metadata{}, err
}
for _, block := range metadata.Meta {
switch block.Type {
case flac.VorbisComment:
comment, err := flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
for _, tag := range comment.Comments {
parts := strings.SplitN(tag, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.ToUpper(parts[0])
value := parts[1]
switch key {
case flacvorbis.FIELD_TITLE:
meta.Title = value
case flacvorbis.FIELD_ARTIST:
meta.Artist = value
case flacvorbis.FIELD_ALBUM:
meta.Album = value
case "LYRICS":
meta.Lyrics = append(meta.Lyrics, Lyrics{
Lang: "unk",
Lyrics: value,
})
}
}
case flac.Picture:
pic, err := flacpicture.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
meta.Pictures = append(meta.Pictures, Picture{
Mimetype: pic.MIME,
Type: byte(pic.PictureType),
Description: pic.Description,
Data: pic.ImageData,
})
default:
// do nothing
}
}
return meta, nil
}

50
tag/reader_id3v2.go Normal file
View File

@@ -0,0 +1,50 @@
package tag
import (
"github.com/bogem/id3v2/v2"
"io"
)
func ReadID3v2Tags(r io.ReadSeeker, mime string) (Metadata, error) {
meta := Metadata{
Mimetype: mime,
}
tags, err := id3v2.ParseReader(r, id3v2.Options{Parse: true})
if err != nil {
return meta, err
}
if tags.Version() == 3 {
meta.Format = FormatID3v2_3
}
if tags.Version() == 4 {
meta.Format = FormatID3v2_4
}
meta.Title = tags.Title()
meta.Artist = tags.Artist()
meta.Album = tags.Album()
meta.Lyrics = make([]Lyrics, 0)
for _, frame := range tags.GetFrames("USLT") {
lyricFrame, ok := frame.(id3v2.UnsynchronisedLyricsFrame)
if !ok {
continue
}
meta.Lyrics = append(meta.Lyrics, Lyrics{
Lang: lyricFrame.Language,
Lyrics: lyricFrame.Lyrics,
})
}
meta.Pictures = make([]Picture, 0)
for _, frame := range tags.GetFrames("APIC") {
pic, ok := frame.(id3v2.PictureFrame)
if !ok {
continue
}
meta.Pictures = append(meta.Pictures, Picture{
Mimetype: pic.MimeType,
Type: pic.PictureType,
Description: pic.Description,
Data: pic.Picture,
})
}
return meta, nil
}

26
tag/reader_test.go Normal file
View File

@@ -0,0 +1,26 @@
package tag
import (
"github.com/k0kubun/pp/v3"
"github.com/stretchr/testify/require"
"os"
"testing"
)
func TestReader(t *testing.T) {
f, err := os.Open("/home/aynakeya/workspace/AynaLivePlayer/pkg/miaosic/cmd/miaosic/Mili - world.execute (me) ;.mp3")
require.NoError(t, err)
meta, err := Read(f)
require.NoError(t, err)
pp.Println(meta)
f.Close()
}
func TestReader_Flac(t *testing.T) {
f, err := os.Open("/home/aynakeya/workspace/AynaLivePlayer/pkg/miaosic/cmd/miaosic/欢子 - 心痛2009.flac")
require.NoError(t, err)
meta, err := Read(f)
require.NoError(t, err)
pp.Println(meta)
f.Close()
}

4
tag/readme.md Normal file
View File

@@ -0,0 +1,4 @@
# Simple tag reader / writer
- https://id3.org/id3v2.3.0#ID3v2_header
- https://www.xiph.org/vorbis/doc/v-comment.html

View File

@@ -1 +1,29 @@
package tag
type Picture struct {
Mimetype string
Type byte
Description string
Data []byte
}
func (p Picture) TypeName() string {
return pictureTypes[p.Type]
}
type Lyrics struct {
Lang string `json:"lang"`
Lyrics string `json:"lyrics"`
}
type Metadata struct {
Format string `json:"format"`
Mimetype string `json:"mimetype"`
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
Lyrics []Lyrics `json:"lyrics"`
Pictures []Picture `json:"pictures"`
}

57
tag/tag_const.go Normal file
View File

@@ -0,0 +1,57 @@
package tag
const (
FormatID3v2_2 = "ID3v2.2"
FormatID3v2_3 = "ID3v2.3"
FormatID3v2_4 = "ID3v2.4"
FormatMP4 = "MP4"
FormatVORBIS = "VORBIS"
)
const (
PictureTypeOther = iota
PictureTypeFileIcon
PictureTypeOtherFileIcon
PictureTypeFrontCover
PictureTypeBackCover
PictureTypeLeafletPage
PictureTypeMedia
PictureTypeLeadArtistSoloist
PictureTypeArtistPerformer
PictureTypeConductor
PictureTypeBandOrchestra
PictureTypeComposer
PictureTypeLyricistTextWriter
PictureTypeRecordingLocation
PictureTypeDuringRecording
PictureTypeDuringPerformance
PictureTypeMovieScreenCaPictureTypeure
PictureTypeBrightColouredFish
PictureTypeIllustration
PictureTypeBandArtistLogotype
PictureTypePublisherStudioLogotype
)
var pictureTypes = map[byte]string{
0x00: "Other",
0x01: "32x32 pixels 'file icon' (PNG only)",
0x02: "Other file icon",
0x03: "Cover (front)",
0x04: "Cover (back)",
0x05: "Leaflet page",
0x06: "Media (e.g. lable side of CD)",
0x07: "Lead artist/lead performer/soloist",
0x08: "Artist/performer",
0x09: "Conductor",
0x0A: "Band/Orchestra",
0x0B: "Composer",
0x0C: "Lyricist/text writer",
0x0D: "Recording Location",
0x0E: "During recording",
0x0F: "During performance",
0x10: "Movie/video screen capture",
0x11: "A bright coloured fish",
0x12: "Illustration",
0x13: "Band/artist logotype",
0x14: "Publisher/Studio logotype",
}

55
tag/writer.go Normal file
View File

@@ -0,0 +1,55 @@
package tag
import (
"errors"
"github.com/gabriel-vasile/mimetype"
"io"
"os"
)
func fixMeta(meta *Metadata) {
// fix picture meme
for idx, _ := range meta.Pictures {
if meta.Pictures[idx].Mimetype == "" {
meta.Pictures[idx].Mimetype = mimetype.Detect(meta.Pictures[idx].Data).String()
}
}
}
// Write metadata to file, input file will be closed after this method
func Write(f *os.File, meta Metadata) error {
_, err := f.Seek(0, io.SeekStart)
if err != nil {
return err
}
b := make([]byte, 512)
_, err = io.ReadFull(f, b)
if err != nil {
return err
}
mimeType := mimetype.Detect(b).String()
_, err = f.Seek(0, io.SeekStart)
fixMeta(&meta)
switch mimeType {
case "audio/flac", "audio/x-flac":
return WriteFlacTags(f, meta)
//case string(b[0:4]) == "OggS":
// return ReadOGGTags(r)
//case string(b[4:8]) == "ftyp":
// return ReadAtoms(r)
case "audio/mpeg":
return WriteID3v2Tags(f, meta)
//case string(b[0:4]) == "DSD ":
// return ReadDSFTags(r)
}
return errors.New("miaosic: mime-type not supported")
}
func WriteTo(path string, meta Metadata) error {
file, err := os.Open(path)
defer file.Close()
if err != nil {
return err
}
return Write(file, meta)
}

85
tag/writer_flac.go Normal file
View File

@@ -0,0 +1,85 @@
package tag
import (
"fmt"
"github.com/AynaLivePlayer/miaosic"
"github.com/go-flac/flacpicture/v2"
"github.com/go-flac/flacvorbis/v2"
"github.com/go-flac/go-flac/v2"
"os"
)
type posMetaBlock[T any] struct {
block T
idx int
}
func WriteFlacTags(f *os.File, meta Metadata) error {
flacFile, err := flac.ParseBytes(f)
if err != nil {
return fmt.Errorf("error parsing flac file: %w", err)
}
var commentBlock posMetaBlock[*flacvorbis.MetaDataBlockVorbisComment]
var pictures = map[byte]posMetaBlock[*flacpicture.MetadataBlockPicture]{}
var pic *flacpicture.MetadataBlockPicture
var cmt *flacvorbis.MetaDataBlockVorbisComment
for idx, metaBlock := range flacFile.Meta {
if metaBlock.Type == flac.VorbisComment {
cmt, err = flacvorbis.ParseFromMetaDataBlock(*metaBlock)
if err == nil {
commentBlock = posMetaBlock[*flacvorbis.MetaDataBlockVorbisComment]{
block: cmt,
idx: idx,
}
}
}
if metaBlock.Type == flac.Picture {
pic, err = flacpicture.ParseFromMetaDataBlock(*metaBlock)
if err == nil {
pictures[byte(pic.PictureType)] = posMetaBlock[*flacpicture.MetadataBlockPicture]{
block: pic,
idx: idx,
}
}
}
}
// write comment, include basic info and lyrcis
commentBlockExists := true
if commentBlock.block == nil {
commentBlock.block = &flacvorbis.MetaDataBlockVorbisComment{
Comments: []string{},
}
commentBlockExists = false
}
// just reset all
commentBlock.block.Vendor = "miaosic" + miaosic.VERSION
commentBlock.block.Comments = []string{}
_ = commentBlock.block.Add(flacvorbis.FIELD_TITLE, meta.Title)
_ = commentBlock.block.Add(flacvorbis.FIELD_ARTIST, meta.Artist)
_ = commentBlock.block.Add(flacvorbis.FIELD_ALBUM, meta.Album)
for _, lyric := range meta.Lyrics {
_ = commentBlock.block.Add("LYRICS", lyric.Lyrics)
}
commentBlockMeta := commentBlock.block.Marshal()
if commentBlockExists {
flacFile.Meta[commentBlock.idx] = &commentBlockMeta
} else {
flacFile.Meta = append(flacFile.Meta, &commentBlockMeta)
}
// write file
for _, picture := range meta.Pictures {
newPic, err := flacpicture.NewFromImageData(flacpicture.PictureType(picture.Type),
picture.Description, picture.Data, picture.Mimetype)
if err != nil {
continue
}
picBlock, ok := pictures[picture.Type]
picBlockMeta := newPic.Marshal()
if ok {
flacFile.Meta[picBlock.idx] = &picBlockMeta
} else {
flacFile.Meta = append(flacFile.Meta, &picBlockMeta)
}
}
return flacFile.Save(f.Name())
}

15
tag/writer_flac_test.go Normal file
View File

@@ -0,0 +1,15 @@
package tag
import (
"github.com/stretchr/testify/require"
"os"
"testing"
)
func TestWriterFlac(t *testing.T) {
f, err := os.Open("/home/aynakeya/workspace/AynaLivePlayer/pkg/miaosic/cmd/miaosic/data.flac")
require.NoError(t, err)
err = WriteFlacTags(f, Metadata{})
require.NoError(t, err)
require.NoError(t, f.Close())
}

26
tag/writer_id3v2.go Normal file
View File

@@ -0,0 +1,26 @@
package tag
import (
"fmt"
"github.com/bogem/id3v2/v2"
"os"
)
func WriteID3v2Tags(f *os.File, meta Metadata) error {
tag, err := id3v2.ParseReader(f, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("error parsing mp3 file: %w", err)
}
tag.SetTitle(meta.Title)
tag.SetArtist(meta.Artist)
tag.SetAlbum(meta.Album)
for _, lyric := range meta.Lyrics {
uslf := id3v2.UnsynchronisedLyricsFrame{Encoding: id3v2.EncodingUTF8, Language: lyric.Lang[:min(3, len(lyric.Lang))], Lyrics: lyric.Lyrics}
tag.AddUnsynchronisedLyricsFrame(uslf)
}
for _, pic := range meta.Pictures {
picFrame := id3v2.PictureFrame{Encoding: id3v2.EncodingUTF8, MimeType: pic.Mimetype, PictureType: pic.Type, Description: pic.Description, Picture: pic.Data}
tag.AddAttachedPicture(picFrame)
}
return tag.Save()
}