From ce3817dab8467685ac6fdef35671aa39c7ce9c23 Mon Sep 17 00:00:00 2001 From: Rob Watson Date: Mon, 13 Sep 2021 20:58:28 +0200 Subject: [PATCH] Extract core logic from main.go --- backend/main.go | 277 +++++-------------------------------- backend/media/media_set.go | 99 +++++++++++++ backend/youtube/youtube.go | 138 ++++++++++++++++++ 3 files changed, 274 insertions(+), 240 deletions(-) create mode 100644 backend/media/media_set.go create mode 100644 backend/youtube/youtube.go diff --git a/backend/main.go b/backend/main.go index ef19398..8027ddc 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,220 +1,25 @@ package main import ( - "bytes" - "encoding/binary" "encoding/json" - "errors" - "fmt" - "io" "log" "net/http" - "os" - "os/exec" "strconv" "strings" "time" - "github.com/kkdai/youtube/v2" + "git.netflux.io/rob/clipper/media" + "git.netflux.io/rob/clipper/youtube" + + youtubev2 "github.com/kkdai/youtube/v2" ) const ( - SizeOfInt16 = 2 - ContentTypeAudioM4A = "audio/m4a" ContentTypeApplicationJSON = "application/json" - ItagM4AAudio = 140 - - DefaultFormat = "s16le" - DefaultAudioCodec = "pcm_s16le" - DefaultSampleRate = 48000 - DefaultHTTPBindAddr = "0.0.0.0:8888" - DefaultTimeout = 30 * time.Second + DefaultHTTPBindAddr = "0.0.0.0:8888" + DefaultTimeout = 30 * time.Second ) -type AudioFileMetadata struct { - Bytes int64 `json:"bytes"` - Channels int `json:"channels"` - Frames int64 `json:"frames"` - SampleRate int `json:"sample_rate"` -} - -type AudioFile struct { - AudioFileMetadata - videoID string - exists bool -} - -func NewAudioFile(videoID string) (*AudioFile, error) { - f := &AudioFile{videoID: videoID} - - if f.Exists() { - metadataFile, err := os.Open(f.metadataPath()) - if err != nil { - return nil, fmt.Errorf("error opening metadata file: %v", err) - } - defer func() { _ = metadataFile.Close() }() - - if err := json.NewDecoder(metadataFile).Decode(f); err != nil { - return nil, fmt.Errorf("error decoding metadata: %v", err) - } - - f.exists = true - } - - return f, nil -} - -func (f *AudioFile) rawAudioPath() string { return fmt.Sprintf("cache/%s.raw", f.videoID) } -func (f *AudioFile) encodedAudioPath() string { return fmt.Sprintf("cache/%s.m4a", f.videoID) } -func (f *AudioFile) metadataPath() string { return fmt.Sprintf("cache/%s.json", f.videoID) } - -func (f *AudioFile) Exists() bool { - if f.exists { - return true - } - if _, err := os.Stat(f.metadataPath()); err == nil { - f.exists = true - return true - } - return false -} - -func (f *AudioFile) Download() error { - log.Println("fetching audio stream from youtube...") - var ytClient youtube.Client - - video, err := ytClient.GetVideo(f.videoID) - if err != nil { - return fmt.Errorf("error fetching video: %v", err) - } - - var format *youtube.Format - for _, candidate := range video.Formats.WithAudioChannels() { - if format == nil || (candidate.ContentLength > 0 && candidate.ContentLength < format.ContentLength) { - format = &candidate - } - } - if format == nil { - return errors.New("error selecting format: no format available") - } - - stream, _, err := ytClient.GetStream(video, format) - if err != nil { - return fmt.Errorf("error fetching stream: %v", err) - } - - rawAudioFile, err := os.Create(f.rawAudioPath()) - if err != nil { - return fmt.Errorf("error creating raw audio file: %v", err) - } - - encodedAudioFile, err := os.Create(f.encodedAudioPath()) - if err != nil { - return fmt.Errorf("error creating encoded audio file: %v", err) - } - streamReader := io.TeeReader(stream, encodedAudioFile) - - var errOut bytes.Buffer - cmd := exec.Command("ffmpeg", "-i", "-", "-f", DefaultFormat, "-ar", strconv.Itoa(DefaultSampleRate), "-acodec", DefaultAudioCodec, "-") - cmd.Stdin = streamReader - cmd.Stdout = rawAudioFile - cmd.Stderr = &errOut - - if err = cmd.Run(); err != nil { - log.Println(errOut.String()) - return fmt.Errorf("error processing audio: %v", err) - } - - if err = rawAudioFile.Close(); err != nil { - return fmt.Errorf("error writing file: %v", err) - } - - rawAudioFile, err = os.Open(f.rawAudioPath()) - if err != nil { - return fmt.Errorf("error reading file: %v", err) - } - - fi, err := rawAudioFile.Stat() - if err != nil { - return fmt.Errorf("error reading file info: %v", err) - } - - numFrames := fi.Size() / int64(SizeOfInt16) / int64(format.AudioChannels) - sampleRate, err := strconv.Atoi(format.AudioSampleRate) - if err != nil { - return fmt.Errorf("invalid samplerate: %s", format.AudioSampleRate) - } - - f.AudioFileMetadata = AudioFileMetadata{ - Bytes: fi.Size(), - Channels: format.AudioChannels, - Frames: numFrames, - SampleRate: sampleRate, - } - - metadataFile, err := os.Create(f.metadataPath()) - if err != nil { - return fmt.Errorf("error opening metadata file: %v", err) - } - - if err = json.NewEncoder(metadataFile).Encode(f.AudioFileMetadata); err != nil { - return fmt.Errorf("error encoding metadata: %v", err) - } - - if err = metadataFile.Close(); err != nil { - return fmt.Errorf("error writing metadata file: %v", err) - } - - return nil -} - -func (f *AudioFile) Peaks(start, end int64, numBins int) ([][]int16, error) { - if !f.Exists() { - return nil, errors.New("cannot compute peaks for non-existent file") - } - - var err error - fptr, err := os.Open(f.rawAudioPath()) - if err != nil { - return nil, fmt.Errorf("audio open error: %v", err) - } - defer fptr.Close() - - numChannels := f.Channels - - startByte := start * int64(numChannels) * SizeOfInt16 - if _, err = fptr.Seek(startByte, io.SeekStart); err != nil { - return nil, fmt.Errorf("audio seek error: %v", err) - } - - numFrames := end - start - framesPerBin := numFrames / int64(numBins) - - peaks := make([][]int16, numChannels) - for i := 0; i < numChannels; i++ { - peaks[i] = make([]int16, numBins) - } - - samples := make([]int16, framesPerBin*int64(numChannels)) - - for binNum := 0; binNum < numBins; binNum++ { - if err := binary.Read(fptr, binary.LittleEndian, samples); err != nil { - return nil, fmt.Errorf("error reading samples: %v", err) - } - for i, samp := range samples { - if samp < 0 { - samp = -samp - } - chanIndex := i % numChannels - if samp > peaks[chanIndex][binNum] { - peaks[chanIndex][binNum] = samp - } - } - } - - return peaks, nil -} - func handleRequest(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) @@ -233,28 +38,35 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { return } - audioFile, err := NewAudioFile(videoID) - if err != nil { - log.Printf("error building audio file: %v", err) - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error": "could not download audio"}`)) - return - } + mediaSet := new(media.MediaSet) + mediaSet.ID = videoID - if !audioFile.Exists() { - if err = audioFile.Download(); err != nil { - log.Printf("error downloading audio file: %v", err) + if mediaSet.Exists() { + // just load the metadata.json file: + if err := mediaSet.Load(); err != nil { + log.Printf("error loading MediaSet: %v", err) w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error": "could not download audio"}`)) + w.Write([]byte(`{"error": "could not fetch media"}`)) + return + } + } else { + // download everything from YouTube: + var err error + var youtubeClient youtubev2.Client + downloader := youtube.NewDownloader(&youtubeClient) + mediaSet, err = downloader.Download(videoID) + if err != nil { + log.Printf("error downloading MediaSet: %v", err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error": "could not fetch media"}`)) return } } w.WriteHeader(http.StatusOK) - err = json.NewEncoder(w).Encode(audioFile) - if err != nil { - log.Printf("error encoding audio file: %v", err) + if err := json.NewEncoder(w).Encode(mediaSet); err != nil { + log.Printf("error encoding MediaSet: %v", err) } return @@ -269,23 +81,15 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { return } - audioFile, err := NewAudioFile(videoID) - if err != nil { - log.Printf("error building audio file: %v", err) + mediaSet := media.MediaSet{ID: videoID} + if err := mediaSet.Load(); err != nil { + log.Printf("error loading MediaSet: %v", err) w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error": "could not download audio"}`)) + w.Write([]byte(`{"error": "could not fetch media"}`)) return } - if !audioFile.Exists() { - if err = audioFile.Download(); err != nil { - log.Printf("error downloading audio file: %v", err) - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error": "could not download audio"}`)) - return - } - } - http.ServeFile(w, r, audioFile.encodedAudioPath()) + http.ServeFile(w, r, mediaSet.EncodedAudioPath()) return } @@ -317,22 +121,15 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { return } - audioFile, err := NewAudioFile(videoID) - if err != nil { - log.Printf("error building audio file: %v", err) + mediaSet := media.MediaSet{ID: videoID} + if err = mediaSet.Load(); err != nil { + log.Printf("error loading MediaSet: %v", err) w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error": "could not download audio"}`)) + w.Write([]byte(`{"error": "could not fetch media"}`)) return } - if !audioFile.Exists() { - log.Println("audio file does not exists, cannot compute peaks") - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error": "audio file not available"}`)) - return - } - - peaks, err := audioFile.Peaks(start, end, numBins) + peaks, err := mediaSet.Peaks(start, end, numBins) if err != nil { log.Printf("error generating peaks: %v", err) w.WriteHeader(http.StatusInternalServerError) diff --git a/backend/media/media_set.go b/backend/media/media_set.go new file mode 100644 index 0000000..2ef6966 --- /dev/null +++ b/backend/media/media_set.go @@ -0,0 +1,99 @@ +package media + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "os" +) + +const SizeOfInt16 = 2 + +// MediaSet represents the media and metadata associated with a single media +// resource (for example, a YouTube video). +type MediaSet struct { + ID string `json:"id"` + Source string `json:"source"` + Bytes int64 `json:"bytes"` + Channels int `json:"channels"` + Frames int64 `json:"frames"` + SampleRate int `json:"sample_rate"` + + exists bool +} + +func (m *MediaSet) RawAudioPath() string { return fmt.Sprintf("cache/%s.raw", m.ID) } +func (m *MediaSet) EncodedAudioPath() string { return fmt.Sprintf("cache/%s.m4a", m.ID) } +func (m *MediaSet) VideoPath() string { return fmt.Sprintf("cache/%s.mp4", m.ID) } +func (m *MediaSet) MetadataPath() string { return fmt.Sprintf("cache/%s.json", m.ID) } + +func (m *MediaSet) Exists() bool { + if m.exists { + return true + } + if _, err := os.Stat(m.MetadataPath()); err == nil { + m.exists = true + return true + } + return false +} + +func (m *MediaSet) Load() error { + metadataFile, err := os.Open(m.MetadataPath()) + if err != nil { + return fmt.Errorf("error opening metadata file: %v", err) + } + defer func() { _ = metadataFile.Close() }() + + if err := json.NewDecoder(metadataFile).Decode(m); err != nil { + return fmt.Errorf("error decoding metadata: %v", err) + } + return nil +} + +func (m *MediaSet) Peaks(start, end int64, numBins int) ([][]int16, error) { + if !m.Exists() { + return nil, errors.New("cannot compute peaks for non-existent MediaSet") + } + + var err error + fptr, err := os.Open(m.RawAudioPath()) + if err != nil { + return nil, fmt.Errorf("audio open error: %v", err) + } + defer fptr.Close() + + startByte := start * int64(m.Channels) * SizeOfInt16 + if _, err = fptr.Seek(startByte, io.SeekStart); err != nil { + return nil, fmt.Errorf("audio seek error: %v", err) + } + + numFrames := end - start + framesPerBin := numFrames / int64(numBins) + + peaks := make([][]int16, m.Channels) + for i := 0; i < m.Channels; i++ { + peaks[i] = make([]int16, numBins) + } + + samples := make([]int16, framesPerBin*int64(m.Channels)) + + for binNum := 0; binNum < numBins; binNum++ { + if err := binary.Read(fptr, binary.LittleEndian, samples); err != nil { + return nil, fmt.Errorf("error reading samples: %v", err) + } + for i, samp := range samples { + if samp < 0 { + samp = -samp + } + chanIndex := i % m.Channels + if samp > peaks[chanIndex][binNum] { + peaks[chanIndex][binNum] = samp + } + } + } + + return peaks, nil +} diff --git a/backend/youtube/youtube.go b/backend/youtube/youtube.go new file mode 100644 index 0000000..b236e6b --- /dev/null +++ b/backend/youtube/youtube.go @@ -0,0 +1,138 @@ +package youtube + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "os" + "os/exec" + "strconv" + + "git.netflux.io/rob/clipper/media" + + youtubev2 "github.com/kkdai/youtube/v2" +) + +const ( + SizeOfInt16 = 2 + + EncodedAudioCodec = "pcm_s16le" + EncodedAudioFormat = "s16le" + EncodedAudioSampleRate = 48000 +) + +// YoutubeClient wraps the youtube.Client client. +type YoutubeClient interface { + GetVideo(string) (*youtubev2.Video, error) + GetStream(*youtubev2.Video, *youtubev2.Format) (io.ReadCloser, int64, error) +} + +// Downloader downloads a set of Youtube media for a given video ID, including +// separate audio and video files and a JSON metadata file. Additionally, it +// also renders the downloaded audio file as a raw audio file. +type Downloader struct { + youtubeClient YoutubeClient +} + +func NewDownloader(youtubeClient YoutubeClient) *Downloader { + return &Downloader{youtubeClient: youtubeClient} +} + +// Download downloads the relevant audio and video files for the provided +// Youtube video ID. If successful, a *media.MediaSet struct containing +// metadata about the downloaded items is returned. +func (d *Downloader) Download(videoID string) (*media.MediaSet, error) { + video, err := d.youtubeClient.GetVideo(videoID) + if err != nil { + return nil, fmt.Errorf("error fetching video: %v", err) + } + + // TODO: improve selection of audio and video format. + // Perhaps download both separately? + var format *youtubev2.Format + for i := range video.Formats { + candidate := video.Formats[i] + if candidate.FPS == 0 || candidate.AudioChannels == 0 { + continue + } + if format == nil || (candidate.ContentLength > 0 && candidate.ContentLength < format.ContentLength) { + format = &candidate + } + } + if format == nil { + return nil, errors.New("error selecting format: no format available") + } + log.Printf("selected format: %+v", format) + + stream, _, err := d.youtubeClient.GetStream(video, format) + if err != nil { + return nil, fmt.Errorf("error fetching stream: %v", err) + } + + mediaSet := media.MediaSet{ID: videoID, Source: "youtube"} + + rawAudioFile, err := os.Create(mediaSet.RawAudioPath()) + if err != nil { + return nil, fmt.Errorf("error creating raw audio file: %v", err) + } + + encodedAudioFile, err := os.Create(mediaSet.EncodedAudioPath()) + if err != nil { + return nil, fmt.Errorf("error creating encoded audio file: %v", err) + } + streamReader := io.TeeReader(stream, encodedAudioFile) + + var errOut bytes.Buffer + cmd := exec.Command("ffmpeg", "-i", "-", "-f", EncodedAudioFormat, "-ar", strconv.Itoa(EncodedAudioSampleRate), "-acodec", EncodedAudioCodec, "-") + cmd.Stdin = streamReader + cmd.Stdout = rawAudioFile + cmd.Stderr = &errOut + + if err = cmd.Run(); err != nil { + log.Println(errOut.String()) + return nil, fmt.Errorf("error processing audio: %v", err) + } + + if err = rawAudioFile.Close(); err != nil { + return nil, fmt.Errorf("error writing raw audio file: %v", err) + } + + rawAudioFile, err = os.Open(mediaSet.RawAudioPath()) + if err != nil { + return nil, fmt.Errorf("error reading file: %v", err) + } + + fi, err := rawAudioFile.Stat() + if err != nil { + return nil, fmt.Errorf("error reading file info: %v", err) + } + + numFrames := fi.Size() / int64(SizeOfInt16) / int64(format.AudioChannels) + sampleRate, err := strconv.Atoi(format.AudioSampleRate) + if err != nil { + return nil, fmt.Errorf("invalid samplerate: %s", format.AudioSampleRate) + } + + mediaSet.Bytes = fi.Size() + mediaSet.Channels = format.AudioChannels + mediaSet.Frames = numFrames + mediaSet.SampleRate = sampleRate + + metadataFile, err := os.Create(mediaSet.MetadataPath()) + if err != nil { + return nil, fmt.Errorf("error opening metadata file: %v", err) + } + + if err = json.NewEncoder(metadataFile).Encode(mediaSet); err != nil { + return nil, fmt.Errorf("error encoding metadata: %v", err) + } + + if err = metadataFile.Close(); err != nil { + return nil, fmt.Errorf("error writing metadata file: %v", err) + } + + return &mediaSet, nil +}