diff --git a/algo/common/dispatch.go b/algo/common/dispatch.go index f37d35a..45d1804 100644 --- a/algo/common/dispatch.go +++ b/algo/common/dispatch.go @@ -29,6 +29,7 @@ func RegisterDecoder(ext string, noop bool, dispatchFunc NewDecoderFunc) { DecoderRegistry[ext] = append(DecoderRegistry[ext], decoderItem{noop: noop, decoder: dispatchFunc}) } + func GetDecoder(filename string, skipNoop bool) (rs []NewDecoderFunc) { ext := strings.ToLower(strings.TrimLeft(filepath.Ext(filename), ".")) for _, dec := range DecoderRegistry[ext] { diff --git a/algo/common/common.go b/algo/common/interface.go similarity index 73% rename from algo/common/common.go rename to algo/common/interface.go index 1bbb7a3..76ff53f 100644 --- a/algo/common/common.go +++ b/algo/common/interface.go @@ -5,6 +5,10 @@ import ( "io" ) +type StreamDecoder interface { + Decrypt(buf []byte, offset int) +} + type Decoder interface { Validate() error io.Reader @@ -14,12 +18,12 @@ type CoverImageGetter interface { GetCoverImage(ctx context.Context) ([]byte, error) } -type Meta interface { +type AudioMeta interface { GetArtists() []string GetTitle() string GetAlbum() string } -type StreamDecoder interface { - Decrypt(buf []byte, offset int) +type AudioMetaGetter interface { + GetAudioMeta(ctx context.Context) (AudioMeta, error) } diff --git a/algo/common/meta.go b/algo/common/meta.go new file mode 100644 index 0000000..8c174dc --- /dev/null +++ b/algo/common/meta.go @@ -0,0 +1,50 @@ +package common + +import ( + "path" + "strings" +) + +type filenameMeta struct { + artists []string + title string + album string +} + +func (f *filenameMeta) GetArtists() []string { + return f.artists +} + +func (f *filenameMeta) GetTitle() string { + return f.title +} + +func (f *filenameMeta) GetAlbum() string { + return f.album +} + +func ParseFilenameMeta(filename string) (meta AudioMeta) { + partName := strings.TrimSuffix(filename, path.Ext(filename)) + items := strings.Split(partName, "-") + ret := &filenameMeta{} + + switch len(items) { + case 0: + // no-op + case 1: + ret.title = strings.TrimSpace(items[0]) + default: + ret.title = strings.TrimSpace(items[len(items)-1]) + + for _, v := range items[:len(items)-1] { + artists := strings.FieldsFunc(v, func(r rune) bool { + return r == ',' || r == '_' + }) + for _, artist := range artists { + ret.artists = append(ret.artists, strings.TrimSpace(artist)) + } + } + } + + return ret +} diff --git a/algo/common/meta_test.go b/algo/common/meta_test.go new file mode 100644 index 0000000..f91a27f --- /dev/null +++ b/algo/common/meta_test.go @@ -0,0 +1,38 @@ +package common + +import ( + "reflect" + "testing" +) + +func TestParseFilenameMeta(t *testing.T) { + + tests := []struct { + name string + wantMeta AudioMeta + }{ + { + name: "test1", + wantMeta: &filenameMeta{title: "test1"}, + }, + { + name: "周杰伦 - 晴天.flac", + wantMeta: &filenameMeta{artists: []string{"周杰伦"}, title: "晴天"}, + }, + { + name: "Alan Walker _ Iselin Solheim - Sing Me to Sleep.flac", + wantMeta: &filenameMeta{artists: []string{"Alan Walker", "Iselin Solheim"}, title: "Sing Me to Sleep"}, + }, + { + name: "Christopher,Madcon - Limousine.flac", + wantMeta: &filenameMeta{artists: []string{"Christopher", "Madcon"}, title: "Limousine"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotMeta := ParseFilenameMeta(tt.name); !reflect.DeepEqual(gotMeta, tt.wantMeta) { + t.Errorf("ParseFilenameMeta() = %v, want %v", gotMeta, tt.wantMeta) + } + }) + } +} diff --git a/algo/ncm/meta.go b/algo/ncm/meta.go index a75cfa9..530d23d 100644 --- a/algo/ncm/meta.go +++ b/algo/ncm/meta.go @@ -6,12 +6,17 @@ import ( "unlock-music.dev/cli/algo/common" ) -type RawMeta interface { - common.Meta +type ncmMeta interface { + common.AudioMeta + + // GetFormat return the audio format, e.g. mp3, flac GetFormat() string + + // GetAlbumImageURL return the album image url GetAlbumImageURL() string } -type RawMetaMusic struct { + +type ncmMetaMusic struct { Format string `json:"format"` MusicID int `json:"musicId"` MusicName string `json:"musicName"` @@ -28,10 +33,11 @@ type RawMetaMusic struct { TransNames []interface{} `json:"transNames"` } -func (m RawMetaMusic) GetAlbumImageURL() string { +func (m *ncmMetaMusic) GetAlbumImageURL() string { return m.AlbumPic } -func (m RawMetaMusic) GetArtists() (artists []string) { + +func (m *ncmMetaMusic) GetArtists() (artists []string) { for _, artist := range m.Artist { for _, item := range artist { name, ok := item.(string) @@ -43,22 +49,23 @@ func (m RawMetaMusic) GetArtists() (artists []string) { return } -func (m RawMetaMusic) GetTitle() string { +func (m *ncmMetaMusic) GetTitle() string { return m.MusicName } -func (m RawMetaMusic) GetAlbum() string { +func (m *ncmMetaMusic) GetAlbum() string { return m.Album } -func (m RawMetaMusic) GetFormat() string { + +func (m *ncmMetaMusic) GetFormat() string { return m.Format } //goland:noinspection SpellCheckingInspection -type RawMetaDJ struct { +type ncmMetaDJ struct { ProgramID int `json:"programId"` ProgramName string `json:"programName"` - MainMusic RawMetaMusic `json:"mainMusic"` + MainMusic ncmMetaMusic `json:"mainMusic"` DjID int `json:"djId"` DjName string `json:"djName"` DjAvatarURL string `json:"djAvatarUrl"` @@ -80,32 +87,32 @@ type RawMetaDJ struct { RadioPurchaseCount int `json:"radioPurchaseCount"` } -func (m RawMetaDJ) GetArtists() []string { +func (m *ncmMetaDJ) GetArtists() []string { if m.DjName != "" { return []string{m.DjName} } return m.MainMusic.GetArtists() } -func (m RawMetaDJ) GetTitle() string { +func (m *ncmMetaDJ) GetTitle() string { if m.ProgramName != "" { return m.ProgramName } return m.MainMusic.GetTitle() } -func (m RawMetaDJ) GetAlbum() string { +func (m *ncmMetaDJ) GetAlbum() string { if m.Brand != "" { return m.Brand } return m.MainMusic.GetAlbum() } -func (m RawMetaDJ) GetFormat() string { +func (m *ncmMetaDJ) GetFormat() string { return m.MainMusic.GetFormat() } -func (m RawMetaDJ) GetAlbumImageURL() string { +func (m *ncmMetaDJ) GetAlbumImageURL() string { if strings.HasPrefix(m.MainMusic.GetAlbumImageURL(), "http") { return m.MainMusic.GetAlbumImageURL() } diff --git a/algo/ncm/ncm.go b/algo/ncm/ncm.go index f54f1ba..afa572f 100644 --- a/algo/ncm/ncm.go +++ b/algo/ncm/ncm.go @@ -41,7 +41,7 @@ type Decoder struct { metaRaw []byte metaType string - meta RawMeta + meta ncmMeta cover []byte } @@ -172,10 +172,10 @@ func (d *Decoder) readCoverData() error { func (d *Decoder) parseMeta() error { switch d.metaType { case "music": - d.meta = new(RawMetaMusic) + d.meta = new(ncmMetaMusic) return json.Unmarshal(d.metaRaw, d.meta) case "dj": - d.meta = new(RawMetaDJ) + d.meta = new(ncmMetaDJ) return json.Unmarshal(d.metaRaw, d.meta) default: return errors.New("unknown ncm meta type: " + d.metaType) @@ -232,8 +232,8 @@ func (d *Decoder) GetCoverImage(ctx context.Context) ([]byte, error) { return d.cover, nil } -func (d *Decoder) GetMeta() common.Meta { - return d.meta +func (d *Decoder) GetAudioMeta(_ context.Context) (common.AudioMeta, error) { + return d.meta, nil } func init() { diff --git a/algo/qmc/client/base.go b/algo/qmc/client/base.go new file mode 100644 index 0000000..7c3ea96 --- /dev/null +++ b/algo/qmc/client/base.go @@ -0,0 +1,146 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type QQMusic struct { + http *http.Client +} + +func (c *QQMusic) rpcDoRequest(ctx context.Context, reqBody any) ([]byte, error) { + reqBodyBuf, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("qqMusicClient[rpcDoRequest] marshal request: %w", err) + } + + const endpointURL = "https://u.y.qq.com/cgi-bin/musicu.fcg" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + endpointURL+fmt.Sprintf("?pcachetime=%d", time.Now().Unix()), + bytes.NewReader(reqBodyBuf), + ) + if err != nil { + return nil, fmt.Errorf("qqMusicClient[rpcDoRequest] create request: %w", err) + } + + req.Header.Set("Accept", "*/*") + req.Header.Set("Accept-Language", "zh-CN") + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // req.Header.Set("Accept-Encoding", "gzip, deflate") + + reqp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("qqMusicClient[rpcDoRequest] send request: %w", err) + } + defer reqp.Body.Close() + + respBodyBuf, err := io.ReadAll(reqp.Body) + if err != nil { + return nil, fmt.Errorf("qqMusicClient[rpcDoRequest] read response: %w", err) + } + + return respBodyBuf, nil +} + +type rpcRequest struct { + Method string `json:"method"` + Module string `json:"module"` + Param any `json:"param"` +} + +type rpcResponse struct { + Code int `json:"code"` + Ts int64 `json:"ts"` + StartTs int64 `json:"start_ts"` + TraceID string `json:"traceid"` +} + +type rpcSubResponse struct { + Code int `json:"code"` + Data json.RawMessage `json:"data"` +} + +func (c *QQMusic) rpcCall(ctx context.Context, + protocol string, method string, module string, + param any, +) (json.RawMessage, error) { + reqBody := map[string]any{protocol: rpcRequest{ + Method: method, + Module: module, + Param: param, + }} + + respBodyBuf, err := c.rpcDoRequest(ctx, reqBody) + if err != nil { + return nil, fmt.Errorf("qqMusicClient[rpcCall] do request: %w", err) + } + + // check rpc response status + respStatus := rpcResponse{} + if err := json.Unmarshal(respBodyBuf, &respStatus); err != nil { + return nil, fmt.Errorf("qqMusicClient[rpcCall] unmarshal response: %w", err) + } + if respStatus.Code != 0 { + return nil, fmt.Errorf("qqMusicClient[rpcCall] rpc error: %d", respStatus.Code) + } + + // parse response data + var respBody map[string]json.RawMessage + if err := json.Unmarshal(respBodyBuf, &respBody); err != nil { + return nil, fmt.Errorf("qqMusicClient[rpcCall] unmarshal response: %w", err) + } + + subRespBuf, ok := respBody[protocol] + if !ok { + return nil, fmt.Errorf("qqMusicClient[rpcCall] sub-response not found") + } + + subResp := rpcSubResponse{} + if err := json.Unmarshal(subRespBuf, &subResp); err != nil { + return nil, fmt.Errorf("qqMusicClient[rpcCall] unmarshal sub-response: %w", err) + } + + if subResp.Code != 0 { + return nil, fmt.Errorf("qqMusicClient[rpcCall] sub-response error: %d", subResp.Code) + } + + return subResp.Data, nil +} + +func (c *QQMusic) downloadFile(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("qmc[downloadFile] init request: %w", err) + } + + //req.Header.Set("Accept", "image/webp,image/*,*/*;q=0.8") // jpeg is preferred to embed in audio + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.8,en-US;q=0.6,en;q=0.5;q=0.4") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.47.134 Safari/537.36 QBCore/3.53.47.400 QQBrowser/9.0.2524.400") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("qmc[downloadFile] send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("qmc[downloadFile] unexpected http status %s", resp.Status) + } + + return io.ReadAll(resp.Body) +} + +func NewQQMusicClient() *QQMusic { + return &QQMusic{ + http: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} diff --git a/algo/qmc/client/cover.go b/algo/qmc/client/cover.go new file mode 100644 index 0000000..a94f106 --- /dev/null +++ b/algo/qmc/client/cover.go @@ -0,0 +1,21 @@ +package client + +import ( + "context" + "fmt" + "strconv" +) + +func (c *QQMusic) AlbumCoverByID(ctx context.Context, albumID int) ([]byte, error) { + u := fmt.Sprintf("https://imgcache.qq.com/music/photo/album/%s/albumpic_%s_0.jpg", + strconv.Itoa(albumID%100), + strconv.Itoa(albumID), + ) + return c.downloadFile(ctx, u) +} + +func (c *QQMusic) AlbumCoverByMediaID(ctx context.Context, mediaID string) ([]byte, error) { + // original: https://y.gtimg.cn/music/photo_new/T002M000%s.jpg + u := fmt.Sprintf("https://y.gtimg.cn/music/photo_new/T002R500x500M000%s.jpg", mediaID) + return c.downloadFile(ctx, u) +} diff --git a/algo/qmc/client/search.go b/algo/qmc/client/search.go new file mode 100644 index 0000000..d619574 --- /dev/null +++ b/algo/qmc/client/search.go @@ -0,0 +1,52 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" +) + +type searchParams struct { + Grp int `json:"grp"` + NumPerPage int `json:"num_per_page"` + PageNum int `json:"page_num"` + Query string `json:"query"` + RemotePlace string `json:"remoteplace"` + SearchType int `json:"search_type"` + //SearchID string `json:"searchid"` // todo: it seems generated randomly +} + +type searchResponse struct { + Body struct { + Song struct { + List []*TrackInfo `json:"list"` + } `json:"song"` + } `json:"body"` + Code int `json:"code"` +} + +func (c *QQMusic) Search(ctx context.Context, keyword string) ([]*TrackInfo, error) { + + resp, err := c.rpcCall(ctx, + "music.search.SearchCgiService", + "DoSearchForQQMusicDesktop", + "music.search.SearchCgiService", + &searchParams{ + SearchType: 0, Query: keyword, + PageNum: 1, NumPerPage: 40, + + // static values + Grp: 1, RemotePlace: "sizer.newclient.song", + }) + if err != nil { + return nil, fmt.Errorf("qqMusicClient[Search] rpc call: %w", err) + } + + respData := searchResponse{} + if err := json.Unmarshal(resp, &respData); err != nil { + return nil, fmt.Errorf("qqMusicClient[Search] unmarshal response: %w", err) + } + + return respData.Body.Song.List, nil + +} diff --git a/algo/qmc/client/track.go b/algo/qmc/client/track.go new file mode 100644 index 0000000..0d2b55b --- /dev/null +++ b/algo/qmc/client/track.go @@ -0,0 +1,178 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/samber/lo" +) + +type getTrackInfoParams struct { + Ctx int `json:"ctx"` + Ids []int `json:"ids"` + Types []int `json:"types"` +} + +type getTrackInfoResponse struct { + Tracks []*TrackInfo `json:"tracks"` +} + +func (c *QQMusic) GetTracksInfo(ctx context.Context, songIDs []int) ([]*TrackInfo, error) { + resp, err := c.rpcCall(ctx, + "Protocol_UpdateSongInfo", + "CgiGetTrackInfo", + "music.trackInfo.UniformRuleCtrl", + &getTrackInfoParams{Ctx: 0, Ids: songIDs, Types: []int{0}}, + ) + if err != nil { + return nil, fmt.Errorf("qqMusicClient[GetTrackInfo] rpc call: %w", err) + } + respData := getTrackInfoResponse{} + if err := json.Unmarshal(resp, &respData); err != nil { + return nil, fmt.Errorf("qqMusicClient[GetTrackInfo] unmarshal response: %w", err) + } + + return respData.Tracks, nil +} + +func (c *QQMusic) GetTrackInfo(ctx context.Context, songID int) (*TrackInfo, error) { + tracks, err := c.GetTracksInfo(ctx, []int{songID}) + if err != nil { + return nil, fmt.Errorf("qqMusicClient[GetTrackInfo] get tracks info: %w", err) + } + + if len(tracks) == 0 { + return nil, fmt.Errorf("qqMusicClient[GetTrackInfo] track not found") + } + + return tracks[0], nil +} + +type TrackSinger struct { + Id int `json:"id"` + Mid string `json:"mid"` + Name string `json:"name"` + Title string `json:"title"` + Type int `json:"type"` + Uin int `json:"uin"` + Pmid string `json:"pmid"` +} +type TrackAlbum struct { + Id int `json:"id"` + Mid string `json:"mid"` + Name string `json:"name"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Pmid string `json:"pmid"` +} +type TrackInfo struct { + Id int `json:"id"` + Type int `json:"type"` + Mid string `json:"mid"` + Name string `json:"name"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Singer []TrackSinger `json:"singer"` + Album TrackAlbum `json:"album"` + Mv struct { + Id int `json:"id"` + Vid string `json:"vid"` + Name string `json:"name"` + Title string `json:"title"` + Vt int `json:"vt"` + } `json:"mv"` + Interval int `json:"interval"` + Isonly int `json:"isonly"` + Language int `json:"language"` + Genre int `json:"genre"` + IndexCd int `json:"index_cd"` + IndexAlbum int `json:"index_album"` + TimePublic string `json:"time_public"` + Status int `json:"status"` + Fnote int `json:"fnote"` + File struct { + MediaMid string `json:"media_mid"` + Size24Aac int `json:"size_24aac"` + Size48Aac int `json:"size_48aac"` + Size96Aac int `json:"size_96aac"` + Size192Ogg int `json:"size_192ogg"` + Size192Aac int `json:"size_192aac"` + Size128Mp3 int `json:"size_128mp3"` + Size320Mp3 int `json:"size_320mp3"` + SizeApe int `json:"size_ape"` + SizeFlac int `json:"size_flac"` + SizeDts int `json:"size_dts"` + SizeTry int `json:"size_try"` + TryBegin int `json:"try_begin"` + TryEnd int `json:"try_end"` + Url string `json:"url"` + SizeHires int `json:"size_hires"` + HiresSample int `json:"hires_sample"` + HiresBitdepth int `json:"hires_bitdepth"` + B30S int `json:"b_30s"` + E30S int `json:"e_30s"` + Size96Ogg int `json:"size_96ogg"` + Size360Ra []interface{} `json:"size_360ra"` + SizeDolby int `json:"size_dolby"` + SizeNew []interface{} `json:"size_new"` + } `json:"file"` + Pay struct { + PayMonth int `json:"pay_month"` + PriceTrack int `json:"price_track"` + PriceAlbum int `json:"price_album"` + PayPlay int `json:"pay_play"` + PayDown int `json:"pay_down"` + PayStatus int `json:"pay_status"` + TimeFree int `json:"time_free"` + } `json:"pay"` + Action struct { + Switch int `json:"switch"` + Msgid int `json:"msgid"` + Alert int `json:"alert"` + Icons int `json:"icons"` + Msgshare int `json:"msgshare"` + Msgfav int `json:"msgfav"` + Msgdown int `json:"msgdown"` + Msgpay int `json:"msgpay"` + Switch2 int `json:"switch2"` + Icon2 int `json:"icon2"` + } `json:"action"` + Ksong struct { + Id int `json:"id"` + Mid string `json:"mid"` + } `json:"ksong"` + Volume struct { + Gain float64 `json:"gain"` + Peak float64 `json:"peak"` + Lra float64 `json:"lra"` + } `json:"volume"` + Label string `json:"label"` + Url string `json:"url"` + Ppurl string `json:"ppurl"` + Bpm int `json:"bpm"` + Version int `json:"version"` + Trace string `json:"trace"` + DataType int `json:"data_type"` + ModifyStamp int `json:"modify_stamp"` + Aid int `json:"aid"` + Tid int `json:"tid"` + Ov int `json:"ov"` + Sa int `json:"sa"` + Es string `json:"es"` + Vs []string `json:"vs"` +} + +func (t *TrackInfo) GetArtists() []string { + return lo.Map(t.Singer, func(v TrackSinger, i int) string { + return v.Name + }) +} + +func (t *TrackInfo) GetTitle() string { + return t.Title +} + +func (t *TrackInfo) GetAlbum() string { + return t.Album.Name +} diff --git a/algo/qmc/qmc.go b/algo/qmc/qmc.go index 3347882..d6c084d 100644 --- a/algo/qmc/qmc.go +++ b/algo/qmc/qmc.go @@ -27,9 +27,19 @@ type Decoder struct { decodedKey []byte // decodedKey is the decoded key for cipher cipher common.StreamDecoder - rawMetaExtra1 int + songID int rawMetaExtra2 int + albumID int + albumMediaID string + + // cache + meta common.AudioMeta + cover []byte + embeddedCover bool // embeddedCover is true if the cover is embedded in the file + probeBuf *bytes.Buffer // probeBuf is the buffer for sniffing metadata, TODO: consider pipe? + + // provider logger *zap.Logger } @@ -40,6 +50,8 @@ func (d *Decoder) Read(p []byte) (int, error) { if n > 0 { d.cipher.Decrypt(p[:n], d.offset) d.offset += n + + _, _ = d.probeBuf.Write(p[:n]) // bytes.Buffer.Write never return error } return n, err } @@ -81,6 +93,9 @@ func (d *Decoder) Validate() error { } d.audio = io.LimitReader(d.raw, int64(d.audioLen)) + // prepare for sniffing metadata + d.probeBuf = bytes.NewBuffer(make([]byte, 0, d.audioLen)) + return nil } @@ -199,7 +214,7 @@ func (d *Decoder) readRawMetaQTag() error { return err } - d.rawMetaExtra1, err = strconv.Atoi(items[1]) + d.songID, err = strconv.Atoi(items[1]) if err != nil { return err } diff --git a/algo/qmc/qmc_meta.go b/algo/qmc/qmc_meta.go new file mode 100644 index 0000000..d034faf --- /dev/null +++ b/algo/qmc/qmc_meta.go @@ -0,0 +1,128 @@ +package qmc + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/samber/lo" + + "unlock-music.dev/cli/algo/common" + "unlock-music.dev/cli/algo/qmc/client" + "unlock-music.dev/cli/internal/ffmpeg" +) + +func (d *Decoder) GetAudioMeta(ctx context.Context) (common.AudioMeta, error) { + if d.meta != nil { + return d.meta, nil + } + + if d.songID != 0 { + if err := d.getMetaBySongID(ctx); err != nil { + return nil, err + } + return d.meta, nil + } + + embedMeta, err := ffmpeg.ProbeReader(ctx, d.probeBuf) + if err != nil { + return nil, fmt.Errorf("qmc[GetAudioMeta] probe reader: %w", err) + } + d.meta = embedMeta + d.embeddedCover = embedMeta.HasAttachedPic() + + if !d.embeddedCover && embedMeta.HasMetadata() { + if err := d.searchMetaOnline(ctx, embedMeta); err != nil { + return nil, err + } + return d.meta, nil + } + + return d.meta, nil +} + +func (d *Decoder) getMetaBySongID(ctx context.Context) error { + c := client.NewQQMusicClient() // todo: use global client + trackInfo, err := c.GetTrackInfo(ctx, d.songID) + if err != nil { + return fmt.Errorf("qmc[GetAudioMeta] get track info: %w", err) + } + + d.meta = trackInfo + d.albumID = trackInfo.Album.Id + if trackInfo.Album.Pmid == "" { + d.albumMediaID = trackInfo.Album.Pmid + } else { + d.albumMediaID = trackInfo.Album.Mid + } + return nil +} + +func (d *Decoder) searchMetaOnline(ctx context.Context, original common.AudioMeta) error { + c := client.NewQQMusicClient() // todo: use global client + keyword := lo.WithoutEmpty(append( + []string{original.GetTitle(), original.GetAlbum()}, + original.GetArtists()...), + ) + if len(keyword) == 0 { + return errors.New("qmc[searchMetaOnline] no keyword") + } + + trackList, err := c.Search(ctx, strings.Join(keyword, " ")) + if err != nil { + return fmt.Errorf("qmc[searchMetaOnline] search: %w", err) + } + + if len(trackList) == 0 { + return errors.New("qmc[searchMetaOnline] no result") + } + + meta := trackList[0] + d.meta = meta + d.albumID = meta.Album.Id + if meta.Album.Pmid == "" { + d.albumMediaID = meta.Album.Pmid + } else { + d.albumMediaID = meta.Album.Mid + } + + return nil +} + +func (d *Decoder) GetCoverImage(ctx context.Context) ([]byte, error) { + if d.cover != nil { + return d.cover, nil + } + + if d.embeddedCover { + img, err := ffmpeg.ExtractAlbumArt(ctx, d.probeBuf) + if err != nil { + return nil, fmt.Errorf("qmc[GetCoverImage] extract album art: %w", err) + } + + d.cover = img.Bytes() + + return d.cover, nil + } + + c := client.NewQQMusicClient() // todo: use global client + var err error + + if d.albumMediaID != "" { + d.cover, err = c.AlbumCoverByMediaID(ctx, d.albumMediaID) + if err != nil { + return nil, fmt.Errorf("qmc[GetCoverImage] get cover by media id: %w", err) + } + } else if d.albumID != 0 { + d.cover, err = c.AlbumCoverByID(ctx, d.albumID) + if err != nil { + return nil, fmt.Errorf("qmc[GetCoverImage] get cover by id: %w", err) + } + } else { + return nil, errors.New("qmc[GetAudioMeta] album (or media) id is empty") + } + + return d.cover, nil + +} diff --git a/cmd/um/main.go b/cmd/um/main.go index 62eb144..989026d 100644 --- a/cmd/um/main.go +++ b/cmd/um/main.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "errors" "fmt" "io" @@ -24,8 +25,10 @@ import ( _ "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" ) var AppVersion = "v0.0.6" @@ -47,7 +50,9 @@ func main() { &cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "path to output dir", 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: "supported-ext", Usage: "Show supported file extensions and exit", Required: false, Value: false}, + &cli.BoolFlag{Name: "update-metadata", Usage: "update metadata & album art from network", Required: false, Value: false}, + + &cli.BoolFlag{Name: "supported-ext", Usage: "show supported file extensions and exit", Required: false, Value: false}, }, Action: appMain, @@ -102,9 +107,6 @@ func appMain(c *cli.Context) (err error) { } } - skipNoop := c.Bool("skip-noop") - removeSource := c.Bool("remove-source") - inputStat, err := os.Stat(input) if err != nil { return err @@ -122,18 +124,30 @@ func appMain(c *cli.Context) (err error) { return errors.New("output should be a writable directory") } + proc := &processor{ + outputDir: output, + skipNoopDecoder: c.Bool("skip-noop"), + removeSource: c.Bool("remove-source"), + updateMetadata: c.Bool("update-metadata"), + } + if inputStat.IsDir() { - return dealDirectory(input, output, skipNoop, removeSource) + return proc.processDir(input) } else { - allDec := common.GetDecoder(inputStat.Name(), skipNoop) - if len(allDec) == 0 { - logger.Fatal("skipping while no suitable decoder") - } - return tryDecFile(input, output, allDec, removeSource) + return proc.processFile(input) } } -func dealDirectory(inputDir string, outputDir string, skipNoop bool, removeSource bool) error { + +type processor struct { + outputDir string + + skipNoopDecoder bool + removeSource bool + updateMetadata bool +} + +func (p *processor) processDir(inputDir string) error { items, err := os.ReadDir(inputDir) if err != nil { return err @@ -142,14 +156,9 @@ func dealDirectory(inputDir string, outputDir string, skipNoop bool, removeSourc if item.IsDir() { continue } - allDec := common.GetDecoder(item.Name(), skipNoop) - if len(allDec) == 0 { - logger.Info("skipping while no suitable decoder", zap.String("file", item.Name())) - continue - } filePath := filepath.Join(inputDir, item.Name()) - err := tryDecFile(filePath, outputDir, allDec, removeSource) + err := p.processFile(filePath) if err != nil { logger.Error("conversion failed", zap.String("source", filePath), zap.Error(err)) } @@ -157,18 +166,27 @@ func dealDirectory(inputDir string, outputDir string, skipNoop bool, removeSourc return nil } -func tryDecFile(inputFile string, outputDir string, allDec []common.NewDecoderFunc, removeSource bool) error { +func (p *processor) processFile(filePath string) error { + allDec := common.GetDecoder(filePath, p.skipNoopDecoder) + if len(allDec) == 0 { + logger.Fatal("skipping while no suitable decoder") + } + return p.process(filePath, allDec) +} + +func (p *processor) process(inputFile string, allDec []common.NewDecoderFunc) error { file, err := os.Open(inputFile) if err != nil { return err } defer file.Close() + logger := logger.With(zap.String("source", inputFile)) decParams := &common.DecoderParams{ Reader: file, Extension: filepath.Ext(inputFile), FilePath: inputFile, - Logger: logger.With(zap.String("source", inputFile)), + Logger: logger, } var dec common.Decoder @@ -185,31 +203,87 @@ func tryDecFile(inputFile string, outputDir string, allDec []common.NewDecoderFu return errors.New("no any decoder can resolve the file") } + params := &ffmpeg.UpdateMetadataParams{} + header := bytes.NewBuffer(nil) _, err = io.CopyN(header, dec, 64) if err != nil { return fmt.Errorf("read header failed: %w", err) } + audio := io.MultiReader(header, dec) + params.AudioExt = sniff.AudioExtensionWithFallback(header.Bytes(), ".mp3") + + if p.updateMetadata { + if audioMetaGetter, ok := dec.(common.AudioMetaGetter); ok { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // since ffmpeg doesn't support multiple input streams, + // we need to write the audio to a temp file. + // since qmc decoder doesn't support seeking & relying on ffmpeg probe, we need to read the whole file. + // TODO: support seeking or using pipe for qmc decoder. + params.Audio, err = utils.WriteTempFile(audio, params.AudioExt) + if err != nil { + return fmt.Errorf("updateAudioMeta write temp file: %w", err) + } + defer os.Remove(params.Audio) + + params.Meta, err = audioMetaGetter.GetAudioMeta(ctx) + if err != nil { + logger.Warn("get audio meta failed", zap.Error(err)) + } + + if params.Meta == nil { // reset audio meta if failed + audio, err = os.Open(params.Audio) + if err != nil { + return fmt.Errorf("updateAudioMeta open temp file: %w", err) + } + } + } + } + + if p.updateMetadata && params.Meta != nil { + if coverGetter, ok := dec.(common.CoverImageGetter); ok { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if cover, err := coverGetter.GetCoverImage(ctx); err != nil { + logger.Warn("get cover image failed", zap.Error(err)) + } else if imgExt, ok := sniff.ImageExtension(cover); !ok { + logger.Warn("sniff cover image type failed", zap.Error(err)) + } else { + params.AlbumArtExt = imgExt + params.AlbumArt = cover + } + } + } - outExt := sniff.AudioExtensionWithFallback(header.Bytes(), ".mp3") inFilename := strings.TrimSuffix(filepath.Base(inputFile), filepath.Ext(inputFile)) + outPath := filepath.Join(p.outputDir, inFilename+params.AudioExt) - outPath := filepath.Join(outputDir, inFilename+outExt) - outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - return err - } - defer outFile.Close() + if params.Meta == nil { + outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer outFile.Close() - if _, err := io.Copy(outFile, header); err != nil { - return err - } - if _, err := io.Copy(outFile, dec); err != nil { - return err + if _, err := io.Copy(outFile, audio); err != nil { + return err + } + outFile.Close() + + } else { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + if err := ffmpeg.UpdateMeta(ctx, outPath, params); err != nil { + return err + } } // if source file need to be removed - if removeSource { + if p.removeSource { err := os.RemoveAll(inputFile) if err != nil { return err diff --git a/go.mod b/go.mod index 77ed2b1..67f7119 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,14 @@ module unlock-music.dev/cli go 1.19 require ( + github.com/go-flac/flacpicture v0.2.0 + github.com/go-flac/flacvorbis v0.1.0 + github.com/go-flac/go-flac v0.3.1 github.com/samber/lo v1.36.0 github.com/urfave/cli/v2 v2.23.6 go.uber.org/zap v1.24.0 golang.org/x/crypto v0.3.0 - golang.org/x/exp v0.0.0-20221204150635-6dcec336b2bb + golang.org/x/exp v0.0.0-20221205204356-47842c84f3db golang.org/x/text v0.5.0 unlock-music.dev/mmkv v0.0.0-20221204231432-41a75bd29939 ) diff --git a/go.sum b/go.sum index c86c908..865c2c8 100644 --- a/go.sum +++ b/go.sum @@ -4,9 +4,18 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ddliu/go-httpclient v0.5.1 h1:ys4KozrhBaGdI1yuWIFwNNILqhnMU9ozTvRNfCTorvs= +github.com/ddliu/go-httpclient v0.5.1/go.mod h1:8QVbjq00YK2f2MQyiKuWMdaKOFRcoD9VuubkNCNOuZo= +github.com/go-flac/flacpicture v0.2.0 h1:rS/ZOR/ZxlEwMf3yOPFcTAmGyoV6rDtcYdd+6CwWQAw= +github.com/go-flac/flacpicture v0.2.0/go.mod h1:M4a1J0v6B5NHsck4GA1yZg0vFQzETVPd3kuj6Ow+q9o= +github.com/go-flac/flacvorbis v0.1.0 h1:xStJfPrZ/IoA2oBUEwgrlaSf+Opo6/YuQfkqVhkP0cM= +github.com/go-flac/flacvorbis v0.1.0/go.mod h1:70N9vVkQ4Jew0oBWkwqDMIE21h7pMUtQJpnMD0js6XY= +github.com/go-flac/go-flac v0.3.1 h1:BWA7HdO67S4ZLWSVHCxsDHuedFFu5RiV/wmuhvO6Hxo= +github.com/go-flac/go-flac v0.3.1/go.mod h1:jG9IumOfAXr+7J40x0AiQIbJzXf9Y7+Zs/2CNWe4LMk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= @@ -35,8 +44,8 @@ go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/exp v0.0.0-20221204150635-6dcec336b2bb h1:QIsP/NmClBICkqnJ4rSIhnrGiGR7Yv9ZORGGnmmLTPk= -golang.org/x/exp v0.0.0-20221204150635-6dcec336b2bb/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/exp v0.0.0-20221205204356-47842c84f3db h1:D/cFflL63o2KSLJIwjlcIt8PR064j/xsmdEJL/YvY/o= +golang.org/x/exp v0.0.0-20221205204356-47842c84f3db/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/ffmpeg/ffmpeg.go b/internal/ffmpeg/ffmpeg.go new file mode 100644 index 0000000..9fe9ac9 --- /dev/null +++ b/internal/ffmpeg/ffmpeg.go @@ -0,0 +1,125 @@ +package ffmpeg + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "unlock-music.dev/cli/algo/common" + "unlock-music.dev/cli/internal/utils" +) + +func ExtractAlbumArt(ctx context.Context, rd io.Reader) (*bytes.Buffer, error) { + cmd := exec.CommandContext(ctx, "ffmpeg", + "-i", "pipe:0", // input from stdin + "-an", // disable audio + "-codec:v", "copy", // copy video(image) codec + "-f", "image2", // use image2 muxer + "pipe:1", // output to stdout + ) + + cmd.Stdin = rd + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + cmd.Stdout, cmd.Stderr = stdout, stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("ffmpeg run: %w", err) + } + + return stdout, nil +} + +type UpdateMetadataParams struct { + Audio string // required + AudioExt string // required + + Meta common.AudioMeta // required + + AlbumArt []byte // optional + AlbumArtExt string // required if AlbumArt is not nil +} + +func UpdateMeta(ctx context.Context, outPath string, params *UpdateMetadataParams) error { + if params.AudioExt == ".flac" { + return updateMetaFlac(ctx, outPath, params) + } else { + return updateMetaFFmpeg(ctx, outPath, params) + } +} +func updateMetaFFmpeg(ctx context.Context, outPath string, params *UpdateMetadataParams) error { + builder := newFFmpegBuilder() + + out := newOutputBuilder(outPath) // output to file + builder.SetFlag("y") // overwrite output file + builder.AddOutput(out) + + // input audio -> output audio + builder.AddInput(newInputBuilder(params.Audio)) // input 0: audio + out.AddOption("map", "0:a") + out.AddOption("codec:a", "copy") + + // input cover -> output cover + if params.AlbumArt != nil && + params.AudioExt != ".wav" /* wav doesn't support attached image */ { + + // write cover to temp file + artPath, err := utils.WriteTempFile(bytes.NewReader(params.AlbumArt), params.AlbumArtExt) + if err != nil { + return fmt.Errorf("updateAudioMeta write temp file: %w", err) + } + defer os.Remove(artPath) + + builder.AddInput(newInputBuilder(artPath)) // input 1: cover + out.AddOption("map", "1:v") + + switch params.AudioExt { + case ".ogg": // ogg only supports theora codec + out.AddOption("codec:v", "libtheora") + case ".m4a": // .m4a(mp4) requires set codec, disposition, stream metadata + out.AddOption("codec:v", "mjpeg") + out.AddOption("disposition:v", "attached_pic") + out.AddMetadata("s:v", "title", "Album cover") + out.AddMetadata("s:v", "comment", "Cover (front)") + case ".mp3": + out.AddOption("codec:v", "mjpeg") + out.AddMetadata("s:v", "title", "Album cover") + out.AddMetadata("s:v", "comment", "Cover (front)") + default: // other formats use default behavior + } + } + + // set file metadata + album := params.Meta.GetAlbum() + if album != "" { + out.AddMetadata("", "album", album) + } + + title := params.Meta.GetTitle() + if album != "" { + out.AddMetadata("", "title", title) + } + + artists := params.Meta.GetArtists() + if len(artists) != 0 { + // TODO: it seems that ffmpeg doesn't support multiple artists + out.AddMetadata("", "artist", strings.Join(artists, " / ")) + } + + if params.AudioExt == ".mp3" { + out.AddOption("write_id3v1", "true") + out.AddOption("id3v2_version", "3") + } + + // execute ffmpeg + cmd := builder.Command(ctx) + + if stdout, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("ffmpeg run: %w, %s", err, string(stdout)) + } + + return nil +} diff --git a/internal/ffmpeg/ffprobe.go b/internal/ffmpeg/ffprobe.go new file mode 100644 index 0000000..a79a92e --- /dev/null +++ b/internal/ffmpeg/ffprobe.go @@ -0,0 +1,141 @@ +package ffmpeg + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os/exec" + "strings" + + "github.com/samber/lo" +) + +type Result struct { + Format *Format `json:"format"` + Streams []*Stream `json:"streams"` +} + +func (r *Result) HasAttachedPic() bool { + return lo.ContainsBy(r.Streams, func(s *Stream) bool { + return s.CodecType == "video" + }) +} + +func (r *Result) getTagByKey(key string) string { + for k, v := range r.Format.Tags { + if key == strings.ToLower(k) { + return v + } + } + + for _, stream := range r.Streams { // try to find in streams + if stream.CodecType != "audio" { + continue + } + for k, v := range stream.Tags { + if key == strings.ToLower(k) { + return v + } + } + } + return "" +} +func (r *Result) GetTitle() string { + return r.getTagByKey("title") +} + +func (r *Result) GetAlbum() string { + return r.getTagByKey("album") +} + +func (r *Result) GetArtists() []string { + artists := strings.Split(r.getTagByKey("artist"), "/") + for i := range artists { + artists[i] = strings.TrimSpace(artists[i]) + } + return artists +} + +func (r *Result) HasMetadata() bool { + return r.GetTitle() != "" || r.GetAlbum() != "" || len(r.GetArtists()) > 0 +} + +type Format struct { + Filename string `json:"filename"` + NbStreams int `json:"nb_streams"` + NbPrograms int `json:"nb_programs"` + FormatName string `json:"format_name"` + FormatLongName string `json:"format_long_name"` + StartTime string `json:"start_time"` + Duration string `json:"duration"` + BitRate string `json:"bit_rate"` + ProbeScore int `json:"probe_score"` + Tags map[string]string `json:"tags"` +} + +type Stream struct { + Index int `json:"index"` + CodecName string `json:"codec_name"` + CodecLongName string `json:"codec_long_name"` + CodecType string `json:"codec_type"` + CodecTagString string `json:"codec_tag_string"` + CodecTag string `json:"codec_tag"` + SampleFmt string `json:"sample_fmt"` + SampleRate string `json:"sample_rate"` + Channels int `json:"channels"` + ChannelLayout string `json:"channel_layout"` + BitsPerSample int `json:"bits_per_sample"` + RFrameRate string `json:"r_frame_rate"` + AvgFrameRate string `json:"avg_frame_rate"` + TimeBase string `json:"time_base"` + StartPts int `json:"start_pts"` + StartTime string `json:"start_time"` + BitRate string `json:"bit_rate"` + Disposition *ProbeDisposition `json:"disposition"` + Tags map[string]string `json:"tags"` +} + +type ProbeDisposition struct { + Default int `json:"default"` + Dub int `json:"dub"` + Original int `json:"original"` + Comment int `json:"comment"` + Lyrics int `json:"lyrics"` + Karaoke int `json:"karaoke"` + Forced int `json:"forced"` + HearingImpaired int `json:"hearing_impaired"` + VisualImpaired int `json:"visual_impaired"` + CleanEffects int `json:"clean_effects"` + AttachedPic int `json:"attached_pic"` + TimedThumbnails int `json:"timed_thumbnails"` + Captions int `json:"captions"` + Descriptions int `json:"descriptions"` + Metadata int `json:"metadata"` + Dependent int `json:"dependent"` + StillImage int `json:"still_image"` +} + +func ProbeReader(ctx context.Context, rd io.Reader) (*Result, error) { + cmd := exec.CommandContext(ctx, "ffprobe", + "-v", "quiet", // disable logging + "-print_format", "json", // use json format + "-show_format", "-show_streams", "-show_error", // retrieve format and streams + "pipe:0", // input from stdin + ) + + cmd.Stdin = rd + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + cmd.Stdout, cmd.Stderr = stdout, stderr + + if err := cmd.Run(); err != nil { + return nil, err + } + + ret := new(Result) + if err := json.Unmarshal(stdout.Bytes(), ret); err != nil { + return nil, err + } + + return ret, nil +} diff --git a/internal/ffmpeg/meta_flac.go b/internal/ffmpeg/meta_flac.go new file mode 100644 index 0000000..4ec6a9d --- /dev/null +++ b/internal/ffmpeg/meta_flac.go @@ -0,0 +1,90 @@ +package ffmpeg + +import ( + "context" + "mime" + "strings" + + "github.com/go-flac/flacpicture" + "github.com/go-flac/flacvorbis" + "github.com/go-flac/go-flac" + "golang.org/x/exp/slices" +) + +func updateMetaFlac(_ context.Context, outPath string, m *UpdateMetadataParams) error { + f, err := flac.ParseFile(m.Audio) + if err != nil { + return err + } + + // generate comment block + comment := flacvorbis.MetaDataBlockVorbisComment{Vendor: "unlock-music.dev"} + + // add metadata + title := m.Meta.GetTitle() + if title != "" { + _ = comment.Add(flacvorbis.FIELD_TITLE, title) + } + + album := m.Meta.GetAlbum() + if album != "" { + _ = comment.Add(flacvorbis.FIELD_ALBUM, album) + } + + artists := m.Meta.GetArtists() + for _, artist := range artists { + _ = comment.Add(flacvorbis.FIELD_ARTIST, artist) + } + + existCommentIdx := slices.IndexFunc(f.Meta, func(b *flac.MetaDataBlock) bool { + return b.Type == flac.VorbisComment + }) + if existCommentIdx >= 0 { // copy existing comment fields + exist, err := flacvorbis.ParseFromMetaDataBlock(*f.Meta[existCommentIdx]) + if err != nil { + for _, s := range exist.Comments { + if strings.HasPrefix(s, flacvorbis.FIELD_TITLE+"=") && title != "" || + strings.HasPrefix(s, flacvorbis.FIELD_ALBUM+"=") && album != "" || + strings.HasPrefix(s, flacvorbis.FIELD_ARTIST+"=") && len(artists) != 0 { + continue + } + comment.Comments = append(comment.Comments, s) + } + } + } + + // add / replace flac comment + cmtBlock := comment.Marshal() + if existCommentIdx < 0 { + f.Meta = append(f.Meta, &cmtBlock) + } else { + f.Meta[existCommentIdx] = &cmtBlock + } + + if m.AlbumArt != nil { + + cover, err := flacpicture.NewFromImageData( + flacpicture.PictureTypeFrontCover, + "Front cover", + m.AlbumArt, + mime.TypeByExtension(m.AlbumArtExt), + ) + if err != nil { + return err + } + coverBlock := cover.Marshal() + f.Meta = append(f.Meta, &coverBlock) + + // add / replace flac cover + coverIdx := slices.IndexFunc(f.Meta, func(b *flac.MetaDataBlock) bool { + return b.Type == flac.Picture + }) + if coverIdx < 0 { + f.Meta = append(f.Meta, &coverBlock) + } else { + f.Meta[coverIdx] = &coverBlock + } + } + + return f.Save(outPath) +} diff --git a/internal/ffmpeg/options.go b/internal/ffmpeg/options.go new file mode 100644 index 0000000..d7aeafd --- /dev/null +++ b/internal/ffmpeg/options.go @@ -0,0 +1,131 @@ +package ffmpeg + +import ( + "context" + "os/exec" + "strings" +) + +type ffmpegBuilder struct { + binary string // ffmpeg binary path + options map[string]string // global options + inputs []*inputBuilder // input options + outputs []*outputBuilder // output options +} + +func newFFmpegBuilder() *ffmpegBuilder { + return &ffmpegBuilder{ + binary: "ffmpeg", + options: make(map[string]string), + } +} + +func (b *ffmpegBuilder) AddInput(src *inputBuilder) { + b.inputs = append(b.inputs, src) +} + +func (b *ffmpegBuilder) AddOutput(dst *outputBuilder) { + b.outputs = append(b.outputs, dst) +} + +func (b *ffmpegBuilder) SetBinary(bin string) { + b.binary = bin +} + +func (b *ffmpegBuilder) SetFlag(flag string) { + b.options[flag] = "" +} + +func (b *ffmpegBuilder) SetOption(name, value string) { + b.options[name] = value +} + +func (b *ffmpegBuilder) Args() (args []string) { + for name, val := range b.options { + args = append(args, "-"+name) + if val != "" { + args = append(args, val) + } + } + + for _, input := range b.inputs { + args = append(args, input.Args()...) + } + for _, output := range b.outputs { + args = append(args, output.Args()...) + } + + return +} + +func (b *ffmpegBuilder) Command(ctx context.Context) *exec.Cmd { + bin := "ffmpeg" + if b.binary != "" { + bin = b.binary + } + + return exec.CommandContext(ctx, bin, b.Args()...) +} + +// inputBuilder is the builder for ffmpeg input options +type inputBuilder struct { + path string + options map[string][]string +} + +func newInputBuilder(path string) *inputBuilder { + return &inputBuilder{ + path: path, + options: make(map[string][]string), + } +} + +func (b *inputBuilder) AddOption(name, value string) { + b.options[name] = append(b.options[name], value) +} + +func (b *inputBuilder) Args() (args []string) { + for name, values := range b.options { + for _, val := range values { + args = append(args, "-"+name, val) + } + } + return append(args, "-i", b.path) +} + +// outputBuilder is the builder for ffmpeg output options +type outputBuilder struct { + path string + options map[string][]string +} + +func newOutputBuilder(path string) *outputBuilder { + return &outputBuilder{ + path: path, + options: make(map[string][]string), + } +} + +func (b *outputBuilder) AddOption(name, value string) { + b.options[name] = append(b.options[name], value) +} + +func (b *outputBuilder) Args() (args []string) { + for name, values := range b.options { + for _, val := range values { + args = append(args, "-"+name, val) + } + } + return append(args, b.path) +} + +// AddMetadata is the shortcut for adding "metadata" option +func (b *outputBuilder) AddMetadata(stream, key, value string) { + optVal := strings.TrimSpace(key) + "=" + strings.TrimSpace(value) + + if stream != "" { + b.AddOption("metadata:"+stream, optVal) + } else { + b.AddOption("metadata", optVal) + } +} diff --git a/internal/sniff/audio.go b/internal/sniff/audio.go index 2f36276..12454dd 100644 --- a/internal/sniff/audio.go +++ b/internal/sniff/audio.go @@ -13,7 +13,7 @@ type Sniffer interface { var audioExtensions = map[string]Sniffer{ // ref: https://mimesniff.spec.whatwg.org - ".mp3": prefixSniffer("ID3"), + ".mp3": prefixSniffer("ID3"), // todo: check mp3 without ID3v2 tag ".ogg": prefixSniffer("OggS"), ".wav": prefixSniffer("RIFF"), diff --git a/internal/sniff/image.go b/internal/sniff/image.go new file mode 100644 index 0000000..ed567a0 --- /dev/null +++ b/internal/sniff/image.go @@ -0,0 +1,30 @@ +package sniff + +// ref: https://mimesniff.spec.whatwg.org +var imageMIMEs = map[string]Sniffer{ + "image/jpeg": prefixSniffer{0xFF, 0xD8, 0xFF}, + "image/png": prefixSniffer{'P', 'N', 'G', '\r', '\n', 0x1A, '\n'}, + "image/bmp": prefixSniffer("BM"), + "image/webp": prefixSniffer("RIFF"), + "image/gif": prefixSniffer("GIF8"), +} + +// ImageMIME sniffs the well-known image types, and returns its MIME. +func ImageMIME(header []byte) (string, bool) { + for ext, sniffer := range imageMIMEs { + if sniffer.Sniff(header) { + return ext, true + } + } + return "", false +} + +// ImageExtension is equivalent to ImageMIME, but returns file extension +func ImageExtension(header []byte) (string, bool) { + ext, ok := ImageMIME(header) + if !ok { + return "", false + } + // todo: use mime.ExtensionsByType + return "." + ext[6:], true // "image/" is 6 bytes +} diff --git a/internal/utils/temp.go b/internal/utils/temp.go new file mode 100644 index 0000000..6e9cfe0 --- /dev/null +++ b/internal/utils/temp.go @@ -0,0 +1,24 @@ +package utils + +import ( + "fmt" + "io" + "os" +) + +func WriteTempFile(rd io.Reader, ext string) (string, error) { + audioFile, err := os.CreateTemp("", "*"+ext) + if err != nil { + return "", fmt.Errorf("ffmpeg create temp file: %w", err) + } + + if _, err := io.Copy(audioFile, rd); err != nil { + return "", fmt.Errorf("ffmpeg write temp file: %w", err) + } + + if err := audioFile.Close(); err != nil { + return "", fmt.Errorf("ffmpeg close temp file: %w", err) + } + + return audioFile.Name(), nil +}