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" ) 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 ) 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) w.Write([]byte("method not allowed")) return } w.Header().Add("Content-Type", ContentTypeApplicationJSON) w.Header().Add("Access-Control-Allow-Origin", "*") if strings.HasPrefix(r.URL.Path, "/api/download") { videoID := r.URL.Query().Get("video_id") if videoID == "" { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error": "no video ID provided"}`)) 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 } 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 } } w.WriteHeader(http.StatusOK) err = json.NewEncoder(w).Encode(audioFile) if err != nil { log.Printf("error encoding audio file: %v", err) } return } if strings.HasPrefix(r.URL.Path, "/api/audio") { log.Printf("got headers for audio request: %+v", r.Header) videoID := r.URL.Query().Get("video_id") if videoID == "" { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error": "no video ID provided"}`)) 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 } 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()) return } if strings.HasPrefix(r.URL.Path, "/api/peaks") { videoID := r.URL.Query().Get("video_id") if videoID == "" { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error": "no video ID provided"}`)) return } start, err := strconv.ParseInt(r.URL.Query().Get("start"), 0, 64) if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error": "invalid start parameter provided"}`)) return } end, err := strconv.ParseInt(r.URL.Query().Get("end"), 0, 64) if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error": "invalid end parameter provided"}`)) return } numBins, err := strconv.Atoi(r.URL.Query().Get("bins")) if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error": "invalid bins parameter provided"}`)) 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 } 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) if err != nil { log.Printf("error generating peaks: %v", err) w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"error": "could not generate peaks"}`)) } w.WriteHeader(http.StatusOK) err = json.NewEncoder(w).Encode(peaks) if err != nil { log.Printf("error encoding peaks: %v", err) } return } w.WriteHeader(http.StatusNotFound) w.Write([]byte("page not found")) } func main() { srv := http.Server{ ReadTimeout: DefaultTimeout, WriteTimeout: DefaultTimeout, Addr: DefaultHTTPBindAddr, Handler: http.HandlerFunc(handleRequest), } log.Fatal(srv.ListenAndServe()) }