2021-09-04 22:29:12 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
2021-09-11 09:00:27 +00:00
|
|
|
"bytes"
|
|
|
|
"encoding/binary"
|
|
|
|
"encoding/json"
|
|
|
|
"errors"
|
2021-09-05 09:49:59 +00:00
|
|
|
"fmt"
|
|
|
|
"io"
|
2021-09-04 22:29:12 +00:00
|
|
|
"log"
|
2021-09-06 09:39:43 +00:00
|
|
|
"net/http"
|
2021-09-05 09:49:59 +00:00
|
|
|
"os"
|
2021-09-11 09:00:27 +00:00
|
|
|
"os/exec"
|
|
|
|
"strconv"
|
2021-09-06 09:39:43 +00:00
|
|
|
"strings"
|
|
|
|
"time"
|
2021-09-04 22:29:12 +00:00
|
|
|
|
|
|
|
"github.com/kkdai/youtube/v2"
|
|
|
|
)
|
|
|
|
|
2021-09-05 05:24:51 +00:00
|
|
|
const (
|
2021-09-11 09:00:27 +00:00
|
|
|
SizeOfInt16 = 2
|
|
|
|
ContentTypeAudioM4A = "audio/m4a"
|
|
|
|
ContentTypeApplicationJSON = "application/json"
|
|
|
|
ItagM4AAudio = 140
|
|
|
|
|
|
|
|
DefaultFormat = "s16le"
|
|
|
|
DefaultAudioCodec = "pcm_s16le"
|
|
|
|
DefaultSampleRate = 48000
|
2021-09-06 09:39:43 +00:00
|
|
|
DefaultHTTPBindAddr = "0.0.0.0:8888"
|
2021-09-11 09:00:27 +00:00
|
|
|
DefaultTimeout = 30 * time.Second
|
2021-09-05 05:24:51 +00:00
|
|
|
)
|
|
|
|
|
2021-09-11 09:00:27 +00:00
|
|
|
type AudioFileMetadata struct {
|
|
|
|
Bytes int64 `json:"bytes"`
|
|
|
|
Channels int `json:"channels"`
|
|
|
|
Frames int64 `json:"frames"`
|
|
|
|
SampleRate int `json:"sample_rate"`
|
2021-09-05 09:49:59 +00:00
|
|
|
}
|
|
|
|
|
2021-09-11 09:00:27 +00:00
|
|
|
type AudioFile struct {
|
|
|
|
AudioFileMetadata
|
|
|
|
videoID string
|
|
|
|
exists bool
|
|
|
|
}
|
2021-09-04 22:29:12 +00:00
|
|
|
|
2021-09-11 09:00:27 +00:00
|
|
|
func NewAudioFile(videoID string) (*AudioFile, error) {
|
|
|
|
f := &AudioFile{videoID: videoID}
|
2021-09-05 09:49:59 +00:00
|
|
|
|
2021-09-11 09:00:27 +00:00
|
|
|
if f.Exists() {
|
|
|
|
metadataFile, err := os.Open(f.metadataPath())
|
2021-09-05 09:49:59 +00:00
|
|
|
if err != nil {
|
2021-09-11 09:00:27 +00:00
|
|
|
return nil, fmt.Errorf("error opening metadata file: %v", err)
|
2021-09-05 09:49:59 +00:00
|
|
|
}
|
2021-09-11 09:00:27 +00:00
|
|
|
defer func() { _ = metadataFile.Close() }()
|
2021-09-05 09:49:59 +00:00
|
|
|
|
2021-09-11 09:00:27 +00:00
|
|
|
if err := json.NewDecoder(metadataFile).Decode(f); err != nil {
|
|
|
|
return nil, fmt.Errorf("error decoding metadata: %v", err)
|
2021-09-05 09:49:59 +00:00
|
|
|
}
|
2021-09-04 22:29:12 +00:00
|
|
|
|
2021-09-11 09:00:27 +00:00
|
|
|
f.exists = true
|
2021-09-05 09:49:59 +00:00
|
|
|
}
|
2021-09-04 22:29:12 +00:00
|
|
|
|
2021-09-11 09:00:27 +00:00
|
|
|
return f, nil
|
2021-09-06 09:39:43 +00:00
|
|
|
}
|
2021-09-04 22:29:12 +00:00
|
|
|
|
2021-09-11 09:00:27 +00:00
|
|
|
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
|
2021-09-06 09:39:43 +00:00
|
|
|
}
|
2021-09-05 05:24:51 +00:00
|
|
|
|
2021-09-11 09:00:27 +00:00
|
|
|
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)
|
2021-09-06 09:39:43 +00:00
|
|
|
}
|
2021-09-11 09:00:27 +00:00
|
|
|
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
|
2021-09-05 09:49:59 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2021-09-11 09:00:27 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2021-09-06 09:39:43 +00:00
|
|
|
func handleRequest(w http.ResponseWriter, r *http.Request) {
|
|
|
|
if r.Method != http.MethodGet {
|
|
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
|
|
w.Write([]byte("method not allowed"))
|
|
|
|
return
|
|
|
|
}
|
2021-09-05 05:24:51 +00:00
|
|
|
|
2021-09-11 09:00:27 +00:00
|
|
|
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)
|
|
|
|
}
|
2021-09-06 09:39:43 +00:00
|
|
|
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2021-09-11 09:00:27 +00:00
|
|
|
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())
|
|
|
|
|
2021-09-06 09:39:43 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2021-09-11 09:00:27 +00:00
|
|
|
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)
|
|
|
|
}
|
2021-09-06 09:39:43 +00:00
|
|
|
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2021-09-11 09:00:27 +00:00
|
|
|
w.WriteHeader(http.StatusNotFound)
|
|
|
|
w.Write([]byte("page not found"))
|
2021-09-06 09:39:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func main() {
|
|
|
|
srv := http.Server{
|
|
|
|
ReadTimeout: DefaultTimeout,
|
|
|
|
WriteTimeout: DefaultTimeout,
|
|
|
|
Addr: DefaultHTTPBindAddr,
|
|
|
|
Handler: http.HandlerFunc(handleRequest),
|
2021-09-04 22:29:12 +00:00
|
|
|
}
|
|
|
|
|
2021-09-06 09:39:43 +00:00
|
|
|
log.Fatal(srv.ListenAndServe())
|
2021-09-04 22:29:12 +00:00
|
|
|
}
|