diff --git a/CHANGELOG.md b/CHANGELOG.md index aa19d99edb9e29b26846fbb0a578ffd2c6f420b3..19136c43e8b3536585344fc029aa33216dc9c875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * Added a new tool, `export_synapse_for_import`, which can be used to do an offline import from Synapse. * After running this tool, use the `gdpr_import` tool to bring the export into the media repo. * Added thumbnailing support for some audio waveforms (MP3, WAV, OGG, and FLAC). +* Added audio metadata (duration, etc) to the unstable `/info` endpoint. Aligns with [MSC2380](https://github.com/matrix-org/matrix-doc/pull/2380). ### Fixed diff --git a/api/unstable/info.go b/api/unstable/info.go index 086f47aea2ed04c7440f0c0968f275af1f984982..e880aec2f4d9c1c14680cb816e2de84fc2398857 100644 --- a/api/unstable/info.go +++ b/api/unstable/info.go @@ -1,9 +1,12 @@ package unstable import ( + "bytes" "database/sql" + "io/ioutil" "net/http" "strconv" + "strings" "github.com/disintegration/imaging" "github.com/gorilla/mux" @@ -13,6 +16,9 @@ import ( "github.com/turt2live/matrix-media-repo/common/rcontext" "github.com/turt2live/matrix-media-repo/controllers/download_controller" "github.com/turt2live/matrix-media-repo/storage" + "github.com/turt2live/matrix-media-repo/thumbnailing" + "github.com/turt2live/matrix-media-repo/thumbnailing/i" + "github.com/turt2live/matrix-media-repo/util" "github.com/turt2live/matrix-media-repo/util/cleanup" ) @@ -27,13 +33,17 @@ type mediaInfoThumbnail struct { } type MediaInfoResponse struct { - ContentUri string `json:"content_uri"` - ContentType string `json:"content_type"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - Size int64 `json:"size"` - Hashes mediaInfoHashes `json:"hashes"` - Thumbnails []*mediaInfoThumbnail `json:"thumbnails,omitempty"` + ContentUri string `json:"content_uri"` + ContentType string `json:"content_type"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Size int64 `json:"size"` + Hashes mediaInfoHashes `json:"hashes"` + Thumbnails []*mediaInfoThumbnail `json:"thumbnails,omitempty"` + DurationSeconds float64 `json:"duration,omitempty"` + NumTotalSamples int `json:"num_total_samples,omitempty"` + KeySamples [][2]float64 `json:"key_samples,omitempty"` + NumChannels int `json:"num_channels,omitempty"` } func MediaInfo(r *http.Request, rctx rcontext.RequestContext, user api.UserInfo) interface{} { @@ -72,6 +82,12 @@ func MediaInfo(r *http.Request, rctx rcontext.RequestContext, user api.UserInfo) } defer cleanup.DumpAndCloseStream(streamedMedia.Stream) + b, err := ioutil.ReadAll(streamedMedia.Stream) + if err != nil { + rctx.Log.Error("Unexpected error processing media: " + err.Error()) + return api.InternalServerError("Unexpected Error") + } + response := &MediaInfoResponse{ ContentUri: streamedMedia.KnownMedia.MxcUri(), ContentType: streamedMedia.KnownMedia.ContentType, @@ -81,7 +97,7 @@ func MediaInfo(r *http.Request, rctx rcontext.RequestContext, user api.UserInfo) }, } - img, err := imaging.Decode(streamedMedia.Stream) + img, err := imaging.Decode(bytes.NewBuffer(b)) if err == nil { response.Width = img.Bounds().Max.X response.Height = img.Bounds().Max.Y @@ -106,5 +122,20 @@ func MediaInfo(r *http.Request, rctx rcontext.RequestContext, user api.UserInfo) response.Thumbnails = infoThumbs } + if strings.HasPrefix(response.ContentType, "audio/") { + generator, err := thumbnailing.GetGenerator(util.ByteCloser(b), response.ContentType, false) + if err == nil { + if audiogenerator, ok := generator.(i.AudioGenerator); ok { + audioInfo, err := audiogenerator.GetAudioData(b, 768, rctx) + if err == nil { + response.KeySamples = audioInfo.KeySamples + response.NumChannels = audioInfo.Channels + response.DurationSeconds = audioInfo.Duration.Seconds() + response.NumTotalSamples = audioInfo.TotalSamples + } + } + } + } + return response } diff --git a/thumbnailing/i/01-factories.go b/thumbnailing/i/01-factories.go index ac60eadb932cd9e46c79a4cec124322da154d95f..6ae368bf3cabe61d51c92f1066976d8c21ab6f3c 100644 --- a/thumbnailing/i/01-factories.go +++ b/thumbnailing/i/01-factories.go @@ -12,6 +12,10 @@ type Generator interface { GenerateThumbnail(b []byte, contentType string, width int, height int, method string, animated bool, ctx rcontext.RequestContext) (*m.Thumbnail, error) } +type AudioGenerator interface { + GetAudioData(b []byte, nKeys int, ctx rcontext.RequestContext) (*m.AudioInfo, error) +} + var generators = make([]Generator, 0) func GetGenerator(img []byte, contentType string, needsAnimation bool) Generator { diff --git a/thumbnailing/i/flac.go b/thumbnailing/i/flac.go index 39ce1ff41d7f91ae2cbc6906d55ab527f3a41d0f..c7f73995d2e1ad03e8ec9386debe4a14c3834824 100644 --- a/thumbnailing/i/flac.go +++ b/thumbnailing/i/flac.go @@ -34,6 +34,16 @@ func (d flacGenerator) GenerateThumbnail(b []byte, contentType string, width int return mp3Generator{}.GenerateFromStream(audio, format, width, height) } +func (d flacGenerator) GetAudioData(b []byte, nKeys int, ctx rcontext.RequestContext) (*m.AudioInfo, error) { + audio, format, err := flac.Decode(util.ByteCloser(b)) + if err != nil { + return nil, errors.New("flac: error decoding audio: " + err.Error()) + } + + defer audio.Close() + return mp3Generator{}.GetDataFromStream(audio, format, nKeys) +} + func init() { generators = append(generators, flacGenerator{}) } diff --git a/thumbnailing/i/mp3.go b/thumbnailing/i/mp3.go index b7086c68363829513dfc26c703c76f9dc907d85e..c25c3d77259d94addd9902887089c7b6650cccf0 100644 --- a/thumbnailing/i/mp3.go +++ b/thumbnailing/i/mp3.go @@ -42,7 +42,17 @@ func (d mp3Generator) GenerateThumbnail(b []byte, contentType string, width int, return d.GenerateFromStream(audio, format, width, height) } -func (d mp3Generator) GenerateFromStream(audio beep.StreamSeekCloser, format beep.Format, width int, height int) (*m.Thumbnail, error) { +func (d mp3Generator) GetAudioData(b []byte, nKeys int, ctx rcontext.RequestContext) (*m.AudioInfo, error) { + audio, format, err := mp3.Decode(util.ByteCloser(b)) + if err != nil { + return nil, errors.New("mp3: error decoding audio: " + err.Error()) + } + + defer audio.Close() + return d.GetDataFromStream(audio, format, nKeys) +} + +func (d mp3Generator) GetDataFromStream(audio beep.StreamSeekCloser, format beep.Format, nKeys int) (*m.AudioInfo, error) { allSamples := make([][2]float64, 0) moreSamples := true @@ -63,15 +73,34 @@ func (d mp3Generator) GenerateFromStream(audio beep.StreamSeekCloser, format bee } } - // Figure out a resolution that will work for us - everyNth := int(math.Round(float64(len(allSamples)) / float64(width))) - averagedSamples := make([]float64, 0) + downsampled := make([][2]float64, 0) + everyNth := int(math.Round(float64(len(allSamples)) / float64(nKeys))) for i, s := range allSamples { if i%everyNth != 0 { continue } + downsampled = append(downsampled, s) + } + + return &m.AudioInfo{ + Duration: format.SampleRate.D(len(allSamples)), + Channels: format.NumChannels, + TotalSamples: len(allSamples), + KeySamples: downsampled, + }, nil +} + +func (d mp3Generator) GenerateFromStream(audio beep.StreamSeekCloser, format beep.Format, width int, height int) (*m.Thumbnail, error) { + info, err := d.GetDataFromStream(audio, format, width) + if err != nil { + return nil, errors.New("beep-visual: error sampling audio: " + err.Error()) + } + + // Average out all the samples + averagedSamples := make([]float64, 0) + for _, s := range info.KeySamples { avg := (s[0] + s[1]) / 2 - if format.NumChannels == 1 { + if info.Channels == 1 { avg = s[0] } averagedSamples = append(averagedSamples, avg) @@ -104,7 +133,7 @@ func (d mp3Generator) GenerateFromStream(audio beep.StreamSeekCloser, format bee // Encode to a png imgData := &bytes.Buffer{} - err := imaging.Encode(imgData, img, imaging.PNG) + err = imaging.Encode(imgData, img, imaging.PNG) if err != nil { return nil, errors.New("beep-visual: error encoding thumbnail: " + err.Error()) } diff --git a/thumbnailing/i/ogg.go b/thumbnailing/i/ogg.go index bba26a8377b5aec7b2c12cbf55ccad3930e3aeff..dec2d9696869d75803f84819360098bdfdba94cb 100644 --- a/thumbnailing/i/ogg.go +++ b/thumbnailing/i/ogg.go @@ -34,6 +34,16 @@ func (d oggGenerator) GenerateThumbnail(b []byte, contentType string, width int, return mp3Generator{}.GenerateFromStream(audio, format, width, height) } +func (d oggGenerator) GetAudioData(b []byte, nKeys int, ctx rcontext.RequestContext) (*m.AudioInfo, error) { + audio, format, err := vorbis.Decode(util.ByteCloser(b)) + if err != nil { + return nil, errors.New("ogg: error decoding audio: " + err.Error()) + } + + defer audio.Close() + return mp3Generator{}.GetDataFromStream(audio, format, nKeys) +} + func init() { generators = append(generators, oggGenerator{}) } diff --git a/thumbnailing/i/wav.go b/thumbnailing/i/wav.go index c513af95b62add8a0f6ab86f929cb3ab02bd1477..ac34f2140fe58b8025816a7a12f9c153354bc9f1 100644 --- a/thumbnailing/i/wav.go +++ b/thumbnailing/i/wav.go @@ -34,6 +34,16 @@ func (d wavGenerator) GenerateThumbnail(b []byte, contentType string, width int, return mp3Generator{}.GenerateFromStream(audio, format, width, height) } +func (d wavGenerator) GetAudioData(b []byte, nKeys int, ctx rcontext.RequestContext) (*m.AudioInfo, error) { + audio, format, err := wav.Decode(util.ByteCloser(b)) + if err != nil { + return nil, errors.New("wav: error decoding audio: " + err.Error()) + } + + defer audio.Close() + return mp3Generator{}.GetDataFromStream(audio, format, nKeys) +} + func init() { generators = append(generators, wavGenerator{}) } diff --git a/thumbnailing/m/audio_info.go b/thumbnailing/m/audio_info.go new file mode 100644 index 0000000000000000000000000000000000000000..a194cd4d8841c73b822f6d66aa54c77a1d37272c --- /dev/null +++ b/thumbnailing/m/audio_info.go @@ -0,0 +1,12 @@ +package m + +import ( + "time" +) + +type AudioInfo struct { + KeySamples [][2]float64 + Duration time.Duration + TotalSamples int + Channels int +} diff --git a/thumbnailing/thumbnail.go b/thumbnailing/thumbnail.go index 357cfe6b04457848ef8ef30471885af18e3d0088..422374de097e2738198a84af8cc41290170b89d9 100644 --- a/thumbnailing/thumbnail.go +++ b/thumbnailing/thumbnail.go @@ -42,3 +42,18 @@ func GenerateThumbnail(imgStream io.ReadCloser, contentType string, width int, h return generator.GenerateThumbnail(b, contentType, width, height, method, animated, ctx) } + +func GetGenerator(imgStream io.ReadCloser, contentType string, animated bool) (i.Generator, error) { + defer cleanup.DumpAndCloseStream(imgStream) + b, err := ioutil.ReadAll(imgStream) + if err != nil { + return nil, err + } + + generator := i.GetGenerator(b, contentType, animated) + if generator == nil { + return nil, ErrUnsupported + } + + return generator, nil +}