diff --git a/cmd/file.go b/cmd/file.go index 2635cadd1..3b4ac4c63 100644 --- a/cmd/file.go +++ b/cmd/file.go @@ -142,7 +142,7 @@ func genMissingThumbnails() *cobra.Command { } canGenerateWaveform := func(mimeType string) bool { switch mimeType { - case "audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav": + case "audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", "audio/m4a": return true default: return false @@ -245,6 +245,11 @@ func genMissingThumbnails() *cobra.Command { if err != nil { return fmt.Errorf("failed to generate thumbnail: %w", err) } + case "audio/m4a": + r, err = ip.WaveformM4a(src, waveformWidth, waveformHeight) + if err != nil { + return fmt.Errorf("failed to generate thumbnail: %w", err) + } default: return nil } @@ -290,7 +295,7 @@ func genMissingThumbnails() *cobra.Command { "AND f.mime IN ("+ // サムネイル生成が可能なmimeが変わったらここを変える "'image/jpeg', 'image/png', 'image/gif', 'image/webp', "+ - "'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/x-wav'"+ + "'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/x-wav', 'audio/m4a'"+ ") "+ "GROUP BY f.id, f.created_at "+ "HAVING COUNT(ft.file_id) = 0 "+ diff --git a/service/file/manager_impl.go b/service/file/manager_impl.go index 00c9bf7e9..577761706 100644 --- a/service/file/manager_impl.go +++ b/service/file/manager_impl.go @@ -58,7 +58,7 @@ func (m *managerImpl) canGenerateThumbnail(mimeType string) bool { func (m *managerImpl) canGenerateWaveform(mimeType string) bool { switch mimeType { - case "audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav": + case "audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", "audio/m4a": return true default: return false @@ -146,7 +146,13 @@ func (m *managerImpl) Save(ctx context.Context, args SaveArgs) (model.File, erro if err != nil { m.l.Warn("failed to generate thumbnail", zap.Error(err), zap.Stringer("fid", f.ID)) } + case "audio/m4a": + r, err = m.ip.WaveformM4a(src, waveformWidth, waveformHeight) + if err != nil { + m.l.Warn("failed to generate thumbnail", zap.Error(err), zap.Stringer("fid", f.ID)) + } } + if r != nil { thumbnail := model.FileThumbnail{ diff --git a/service/file/manager_impl_test.go b/service/file/manager_impl_test.go index d0fda17da..fcb0e3f0c 100644 --- a/service/file/manager_impl_test.go +++ b/service/file/manager_impl_test.go @@ -415,6 +415,69 @@ func TestManagerImpl_Save(t *testing.T) { assert.EqualValues(t, "image/svg+xml", thumbs[0].Mime) } }) + t.Run("audio with generating waveform (m4a, io.ReadSeeker)", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + repo := mock_repository.NewMockFileRepository(ctrl) + fs := mock_storage.NewMockFileStorage(ctrl) + ip := mock_imaging.NewMockProcessor(ctrl) + fm := initFM(t, repo, fs, ip) + + data := []byte("test text file") + hash := "7e6d5d7ae4965bfecc6d818f76eb832b" + args := SaveArgs{ + FileName: "dummy.m4a", + FileSize: int64(len(data)), + MimeType: "audio/m4a", + FileType: model.FileTypeUserFile, + ChannelID: optional.From(uuid.NewV3(uuid.Nil, "c")), + Src: bytes.NewReader(data), + } + waveform := bytes.NewBufferString("dummy svg file") + + fs.EXPECT(). + SaveByKey(gomock.Any(), gomock.Any(), args.FileName, args.MimeType, args.FileType). + Do(func(src io.Reader, _, _, _ string, _ model.FileType) { + _, _ = io.Copy(io.Discard, src) + }). + Return(nil). + Times(1) + fs.EXPECT(). + SaveByKey(gomock.Any(), gomock.Any(), gomock.Any(), "image/svg+xml", model.FileTypeThumbnail). + DoAndReturn(func(src io.Reader, _, _, _ string, _ model.FileType) error { + _, _ = io.Copy(io.Discard, src) + return nil + }). + Times(1) + repo.EXPECT(). + SaveFileMeta(gomock.Any(), gomock.Any(), []*model.FileACLEntry{{UserID: uuid.Nil, Allow: true}}). + Do(func(_ context.Context, meta *model.FileMeta, _ []*model.FileACLEntry) { meta.CreatedAt = time.Now() }). + Return(nil). + Times(1) + ip.EXPECT(). + WaveformM4a(gomock.Any(), gomock.Any(), gomock.Any()). + Do(func(src io.ReadSeeker, _, _ int) { _, _ = io.Copy(io.Discard, src) }). + Return(waveform, nil). + Times(1) + + result, err := fm.Save(context.TODO(), args) + if assert.NoError(t, err) { + assert.NotEmpty(t, result.GetID()) + assert.EqualValues(t, args.FileName, result.GetFileName()) + assert.EqualValues(t, args.FileSize, result.GetFileSize()) + assert.EqualValues(t, args.MimeType, result.GetMIMEType()) + assert.EqualValues(t, args.FileType, result.GetFileType()) + assert.EqualValues(t, args.ChannelID, result.GetUploadChannelID()) + assert.EqualValues(t, args.CreatorID, result.GetCreatorID()) + assert.EqualValues(t, hash, result.GetMD5Hash()) + assert.EqualValues(t, false, result.IsAnimatedImage()) + assert.NotEmpty(t, result.GetCreatedAt()) + thumbs := result.GetThumbnails() + assert.EqualValues(t, 1, len(thumbs)) + assert.EqualValues(t, model.ThumbnailTypeWaveform, thumbs[0].Type) + assert.EqualValues(t, "image/svg+xml", thumbs[0].Mime) + } + }) } func TestManagerImpl_Get(t *testing.T) { diff --git a/service/imaging/mock_imaging/mock_processor.go b/service/imaging/mock_imaging/mock_processor.go index d5c06e3b8..52fd08ee4 100644 --- a/service/imaging/mock_imaging/mock_processor.go +++ b/service/imaging/mock_imaging/mock_processor.go @@ -110,3 +110,18 @@ func (mr *MockProcessorMockRecorder) WaveformWav(src, width, height interface{}) mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WaveformWav", reflect.TypeOf((*MockProcessor)(nil).WaveformWav), src, width, height) } + +// WaveformM4a mocks base method. +func (m *MockProcessor) WaveformM4a(src io.ReadSeeker, width, height int) (io.Reader, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WaveformM4a", src, width, height) + ret0, _ := ret[0].(io.Reader) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// WaveformM4a indicates an expected call of WaveformM4a. +func (mr *MockProcessorMockRecorder) WaveformM4a(src, width, height interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WaveformM4a", reflect.TypeOf((*MockProcessor)(nil).WaveformM4a), src, width, height) +} \ No newline at end of file diff --git a/service/imaging/processor.go b/service/imaging/processor.go index e01416596..761f0bf3e 100644 --- a/service/imaging/processor.go +++ b/service/imaging/processor.go @@ -13,4 +13,5 @@ type Processor interface { FitAnimationGIF(src io.Reader, width, height int) (*bytes.Reader, error) WaveformMp3(src io.ReadSeeker, width, height int) (io.Reader, error) WaveformWav(src io.ReadSeeker, width, height int) (io.Reader, error) + WaveformM4a(src io.ReadSeeker, width, height int) (io.Reader, error) } diff --git a/service/imaging/processor_default.go b/service/imaging/processor_default.go index 0a37fe91e..6f2d29f43 100644 --- a/service/imaging/processor_default.go +++ b/service/imaging/processor_default.go @@ -262,3 +262,11 @@ func (p *defaultProcessor) WaveformWav(src io.ReadSeeker, width, height int) (io Height: height, }) } +func (p *defaultProcessor) WaveformM4a(src io.ReadSeeker, width, height int) (io.Reader, error) { + d := m4a.NewDecoder(src) + return waveform.OutputWaveformImageM4a(d, &waveform.Option{ + Resolution: width / 5, + Width: width, + Height: height, + }) +} \ No newline at end of file diff --git a/service/qall/soundboard_impl.go b/service/qall/soundboard_impl.go index 441785625..28b469b6a 100644 --- a/service/qall/soundboard_impl.go +++ b/service/qall/soundboard_impl.go @@ -115,7 +115,7 @@ func (m *soundboardManager) DeleteSoundboardItem(ctx context.Context, soundID uu } // checkAudioDuration は拡張子(ext)に基づいて対応ライブラリを使い、秒数をチェックする -// mp3 / wav / ogg に対応し、それ以外は "we only support mp3, wav, ogg" エラー +// mp3 / wav / ogg / m4a に対応し、それ以外は "we only support mp3, wav, ogg, m4a" エラー func checkAudioDuration(fileBytes []byte, contentType string, maxSeconds float64) error { switch contentType { case "audio/mpeg", "audio/mp3": @@ -147,9 +147,18 @@ func checkAudioDuration(fileBytes []byte, contentType string, maxSeconds float64 return fmt.Errorf("audio is too long (%.1f sec). Must be <= %.0f", dur, maxSeconds) } return nil + case "audio/m4a": + dur, err := getM4aDuration(fileBytes) + if err != nil { + return fmt.Errorf("m4a decode error: %w", err) + } + if dur > maxSeconds { + return fmt.Errorf("audio is too long (%.1f sec). Must be <= %.0f", dur, maxSeconds) + } + return nil default: - return errors.New("we only support .mp3, .wav, .ogg") + return errors.New("we only support .mp3, .wav, .ogg, .m4a") } } @@ -206,3 +215,23 @@ func getOggDuration(data []byte) (float64, error) { seconds := sampleCount / sampleRate return seconds, nil } + +// getM4aDuration returns duration in seconds for M4A +func getM4aDuration(data []byte) (float64, error) { + r := bytes.NewReader(data) + m4aDecoder := m4a.NewDecoder(r) + buf, err := m4aDecoder.FullPCMBuffer() + if err != nil { + return 0, err + } + if buf == nil || buf.Format == nil { + return 0, errors.New("invalid m4a format or buffer") + } + sampleRate := float64(buf.Format.SampleRate) + sampleCount := float64(len(buf.Data)) // PCMBufferのサンプル数 + if sampleRate <= 0 { + return 0, errors.New("invalid m4a sample rate") + } + seconds := sampleCount / sampleRate + return seconds, nil +} \ No newline at end of file