diff --git a/.gitignore b/.gitignore index 40732b0..c8d1bbe 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ +*.m4a /backend/cache/ diff --git a/backend/main.go b/backend/main.go index e43c77b..c78db19 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,70 +1,41 @@ package main import ( - "bytes" - "encoding/binary" "fmt" "io" "log" + "net/http" "os" - "os/exec" - "strconv" + "strings" + "time" "github.com/kkdai/youtube/v2" ) const ( - ItagM4AAudio = 140 - SizeOfInt16 = 2 - - DefaultAudioCodec = "pcm_s16le" - DefaultFormat = "s16le" - DefaultSampleRate = 44100 + ContentTypeAudioM4A = "audio/m4a" + ItagM4AAudio = 140 + DefaultHTTPBindAddr = "0.0.0.0:8888" + DefaultTimeout = 5 * time.Second ) -type byteToIntConverter struct { - callback func(p []uint16) - buf []uint16 -} - -func (w byteToIntConverter) Write(p []byte) (int, error) { - if len(p)%2 != 0 { - panic("need to handle odd number of bytes") - } - - // grow the internal buffer if necessary: - exp := len(p) / SizeOfInt16 - if len(w.buf) < exp { - w.buf = append(w.buf, make([]uint16, exp)...) - - } - for i := 0; i < len(p); i += SizeOfInt16 { - w.buf[i/2] = binary.LittleEndian.Uint16(p[i : i+SizeOfInt16]) - } - - w.callback(w.buf) - - return len(p), nil -} - type AudioDownloader struct { videoID string ytClient youtube.Client + reader io.Reader } -func NewAudioDownloader(videoID string) *AudioDownloader { - return &AudioDownloader{videoID: videoID} -} - -func (d *AudioDownloader) ReadInts(fn func(p []uint16)) error { - cachePath := fmt.Sprintf("cache/%s.raw", d.videoID) - - var reader io.Reader +func NewAudioDownloader(videoID string) (*AudioDownloader, error) { + var ( + reader io.Reader + ytClient youtube.Client + ) + cachePath := fmt.Sprintf("cache/%s.m4a", videoID) fetch := true + if _, err := os.Stat(cachePath); err == nil { if fptr, err := os.Open(cachePath); err == nil { - defer fptr.Close() reader = fptr fetch = false } else { @@ -75,48 +46,93 @@ func (d *AudioDownloader) ReadInts(fn func(p []uint16)) error { if fetch { log.Println("fetching audio stream from youtube...") - video, err := d.ytClient.GetVideo(d.videoID) + video, err := ytClient.GetVideo(videoID) if err != nil { - return fmt.Errorf("error fetching video: %v", err) + return nil, fmt.Errorf("error fetching video: %v", err) } format := video.Formats.FindByItag(ItagM4AAudio) - stream, _, err := d.ytClient.GetStream(video, format) + log.Printf("M4A format expected to contain %d bytes", format.ContentLength) + stream, _, err := ytClient.GetStream(video, format) if err != nil { - return fmt.Errorf("error fetching stream: %v", err) + return nil, fmt.Errorf("error fetching stream: %v", err) } + // TODO: only allow the cached file to be accessed after it has been + // successfully downloaded. cacheFile, err := os.Create(cachePath) if err != nil { - return fmt.Errorf("error creating cache file: %v", err) + return nil, fmt.Errorf("error creating cache file: %v", err) } reader = io.TeeReader(stream, cacheFile) } - var errOut bytes.Buffer - cmd := exec.Command("ffmpeg", "-i", "-", "-f", DefaultFormat, "-ar", strconv.Itoa(DefaultSampleRate), "-acodec", DefaultAudioCodec, "-") - cmd.Stdin = reader - cmd.Stdout = byteToIntConverter{callback: fn} - cmd.Stderr = &errOut + return &AudioDownloader{ + videoID: videoID, + ytClient: ytClient, + reader: reader, + }, nil +} - if err := cmd.Run(); err != nil { - log.Println(errOut.String()) - return fmt.Errorf("error calling ffmpeg: %v", err) +func (d *AudioDownloader) Read(p []byte) (int, error) { + return d.reader.Read(p) +} + +func (d *AudioDownloader) Close() error { + if rc, ok := d.reader.(io.ReadCloser); ok { + return rc.Close() } - return nil } -func main() { - videoID := "s_oJYdRlrv0" - downloader := NewAudioDownloader(videoID) - err := downloader.ReadInts(func(p []uint16) { - // log.Printf("ints = %+v", p[0:16]) - }) - - if err != nil { - log.Fatalf("error calling downloader: %v", err) +func handleRequest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write([]byte("method not allowed")) + return } - log.Println("done") + if !strings.HasPrefix(r.URL.Path, "/api/audio") { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("page not found")) + return + } + + videoID := r.URL.Query().Get("video_id") + if videoID == "" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("no video ID provided")) + return + } + + downloader, err := NewAudioDownloader(videoID) + if err != nil { + log.Printf("downloader error: %v", err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("could not download video")) + return + } + defer downloader.Close() + + w.Header().Add("Content-Type", ContentTypeAudioM4A) + w.WriteHeader(http.StatusOK) + + n, err := io.Copy(w, downloader) + if err != nil { + log.Printf("error writing response: %v", err) + return + } + + log.Printf("wrote %d bytes for video ID %s", n, videoID) +} + +func main() { + srv := http.Server{ + ReadTimeout: DefaultTimeout, + WriteTimeout: DefaultTimeout, + Addr: DefaultHTTPBindAddr, + Handler: http.HandlerFunc(handleRequest), + } + + log.Fatal(srv.ListenAndServe()) }