diff --git a/backend/cmd/ytdebug/main.go b/backend/cmd/ytdebug/main.go index 3633072..063efc3 100644 --- a/backend/cmd/ytdebug/main.go +++ b/backend/cmd/ytdebug/main.go @@ -13,7 +13,6 @@ import ( "time" "git.netflux.io/rob/clipper/media" - "git.netflux.io/rob/clipper/youtube" youtubev2 "github.com/kkdai/youtube/v2" ) @@ -57,7 +56,7 @@ func main() { fmt.Println("In descending order of preference:") for n, f := range formats { - fmt.Printf("%d: %s\n", n+1, youtube.FormatDebugString(&f, verbose)) + fmt.Printf("%d: %s\n", n+1, formatDebugString(&f, verbose)) } } @@ -95,3 +94,27 @@ func downloadAll(formats youtubev2.FormatList) { wg.Wait() } + +func formatDebugString(format *youtubev2.Format, includeURL bool) string { + url := "hidden" + if includeURL { + url = format.URL + } + return fmt.Sprintf( + "iTag = %d, mime_type = %s, quality = %s, quality_label = %s, bitrate = %d, fps = %d, width = %d, height = %d, content_length = %d, duration = %v, audio_channels = %d, audio_sample_rate = %s, audio_quality = %s, url = %s", + format.ItagNo, + format.MimeType, + format.Quality, + format.QualityLabel, + format.Bitrate, + format.FPS, + format.Width, + format.Height, + format.ContentLength, + format.ApproxDurationMs, + format.AudioChannels, + format.AudioSampleRate, + format.AudioQuality, + url, + ) +} diff --git a/backend/server/handlers.go b/backend/server/handlers.go deleted file mode 100644 index 474b60a..0000000 --- a/backend/server/handlers.go +++ /dev/null @@ -1,83 +0,0 @@ -package server - -// // getMediaSet is a handler that responds with a MediaSet. -// func getMediaSet(c echo.Context) error { -// videoID := c.Param("id") -// mediaSet := media.NewMediaSet(videoID) - -// if mediaSet.Exists() { -// if err := mediaSet.Load(); err != nil { -// log.Printf("error loading MediaSet: %v", err) -// return echo.NewHTTPError(http.StatusInternalServerError, "could not fetch media set") -// } -// return c.JSON(http.StatusOK, mediaSet) -// } - -// var youtubeClient youtubev2.Client -// downloader := youtube.NewDownloader(&youtubeClient) -// mediaSet, err := downloader.Download(c.Request().Context(), videoID) -// if err != nil { -// log.Printf("error downloading MediaSet: %v", err) -// return echo.NewHTTPError(http.StatusInternalServerError, "could not fetch media set") -// } -// return c.JSON(http.StatusOK, mediaSet) -// } - -// // getThumbnails is a handler that responds with a MediaSet thumbnail grid. -// func getThumbnails(c echo.Context) error { -// videoID := c.Param("id") -// mediaSet := media.NewMediaSet(videoID) -// if err := mediaSet.Load(); err != nil { -// log.Printf("error loading MediaSet: %v", err) -// return echo.NewHTTPError(http.StatusInternalServerError, "could not load media set") -// } - -// return c.File(mediaSet.ThumbnailPath()) -// } - -// // getVideo is a handler that responds with the video file for a MediaSet -// func getVideo(c echo.Context) error { -// videoID := c.Param("id") -// mediaSet := media.NewMediaSet(videoID) -// if err := mediaSet.Load(); err != nil { -// log.Printf("error loading MediaSet: %v", err) -// return echo.NewHTTPError(http.StatusInternalServerError, "could not load media set") -// } - -// return c.File(mediaSet.VideoPath()) -// } - -// // getPeaks is a handler that returns a two-dimensional array of peaks, with -// // the number of bins matching the provided parameter. -// func getPeaks(c echo.Context) error { -// videoID := c.Param("id") - -// start, err := strconv.ParseInt(c.QueryParam("start"), 0, 64) -// if err != nil { -// return echo.NewHTTPError(http.StatusBadRequest, "invalid start parameter provided") -// } - -// end, err := strconv.ParseInt(c.QueryParam("end"), 0, 64) -// if err != nil { -// return echo.NewHTTPError(http.StatusBadRequest, "invalid end parameter provided") -// } - -// numBins, err := strconv.Atoi(c.QueryParam("bins")) -// if err != nil { -// return echo.NewHTTPError(http.StatusBadRequest, "invalid bins parameter provided") -// } - -// mediaSet := media.NewMediaSet(videoID) -// if err = mediaSet.Load(); err != nil { -// log.Printf("error loading MediaSet: %v", err) -// return echo.NewHTTPError(http.StatusInternalServerError, "could not load media set") -// } - -// peaks, err := mediaSet.Peaks(start, end, numBins) -// if err != nil { -// log.Printf("error generating peaks: %v", err) -// return echo.NewHTTPError(http.StatusInternalServerError, "could not generate peaks") -// } - -// return json.NewEncoder(c.Response()).Encode(peaks) -// } diff --git a/backend/server/server.go b/backend/server/server.go index ba6626a..55707ec 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -11,7 +11,6 @@ import ( pbMediaSet "git.netflux.io/rob/clipper/generated/pb/media_set" "git.netflux.io/rob/clipper/media" - "git.netflux.io/rob/clipper/youtube" "github.com/google/uuid" "github.com/improbable-eng/grpc-web/go/grpcweb" "google.golang.org/grpc" @@ -60,7 +59,7 @@ type Options struct { BindAddr string Timeout time.Duration Store media.Store - YoutubeClient youtube.YoutubeClient + YoutubeClient media.YoutubeClient S3Client media.S3Client } diff --git a/backend/youtube/helpers.go b/backend/youtube/helpers.go deleted file mode 100644 index 810b851..0000000 --- a/backend/youtube/helpers.go +++ /dev/null @@ -1,81 +0,0 @@ -package youtube - -import ( - "fmt" - "sort" - "strings" - - youtubev2 "github.com/kkdai/youtube/v2" -) - -func FormatDebugString(format *youtubev2.Format, includeURL bool) string { - url := "hidden" - if includeURL { - url = format.URL - } - return fmt.Sprintf( - "iTag = %d, mime_type = %s, quality = %s, quality_label = %s, bitrate = %d, fps = %d, width = %d, height = %d, content_length = %d, duration = %v, audio_channels = %d, audio_sample_rate = %s, audio_quality = %s, url = %s", - format.ItagNo, - format.MimeType, - format.Quality, - format.QualityLabel, - format.Bitrate, - format.FPS, - format.Width, - format.Height, - format.ContentLength, - format.ApproxDurationMs, - format.AudioChannels, - format.AudioSampleRate, - format.AudioQuality, - url, - ) -} - -// SortAudio returns the provided formats ordered in descending preferred -// order. The ideal candidate is opus-encoded stereo audio in a webm container, -// with the lowest available bitrate. -func SortAudio(inFormats youtubev2.FormatList) youtubev2.FormatList { - var formats youtubev2.FormatList - for _, format := range inFormats { - if format.FPS == 0 && format.AudioChannels > 0 { - formats = append(formats, format) - } - } - sort.SliceStable(formats, func(i, j int) bool { - isOpusI := strings.Contains(formats[i].MimeType, "opus") - isOpusJ := strings.Contains(formats[j].MimeType, "opus") - if isOpusI && isOpusJ { - isStereoI := formats[i].AudioChannels == 2 - isStereoJ := formats[j].AudioChannels == 2 - if isStereoI && isStereoJ { - return formats[i].ContentLength < formats[j].ContentLength - } - return isStereoI - } - return isOpusI - }) - return formats -} - -// SortVideo returns the provided formats ordered in descending preferred -// order. The ideal candidate is video in an mp4 container with a low -// bitrate, with audio channels (needed to allow synced playback on the -// website). -func SortVideo(inFormats youtubev2.FormatList) youtubev2.FormatList { - var formats youtubev2.FormatList - for _, format := range inFormats { - if format.FPS > 0 && format.ContentLength > 0 { - formats = append(formats, format) - } - } - sort.SliceStable(formats, func(i, j int) bool { - isMP4I := strings.Contains(formats[i].MimeType, "mp4") - isMP4J := strings.Contains(formats[j].MimeType, "mp4") - if isMP4I && isMP4J { - return formats[i].ContentLength < formats[j].ContentLength - } - return isMP4I - }) - return formats -} diff --git a/backend/youtube/youtube.go b/backend/youtube/youtube.go deleted file mode 100644 index ddf92c9..0000000 --- a/backend/youtube/youtube.go +++ /dev/null @@ -1,235 +0,0 @@ -package youtube - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "os" - "os/exec" - "strconv" - "sync" - "time" - - "git.netflux.io/rob/clipper/media" - - youtubev2 "github.com/kkdai/youtube/v2" -) - -const SizeOfInt16 = 2 - -const ( - rawAudioCodec = "pcm_s16le" - rawAudioFormat = "s16le" - rawAudioSampleRate = 48000 -) - -const ( - thumbnailPrescaleWidth = -1 - thumbnailPrescaleHeight = 120 - thumbnailWidth = 177 // 16:9 - thumbnailHeight = 100 // " -) - -// 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 -} - -// NewDownloader returns a new *Downloader. -func NewDownloader(youtubeClient YoutubeClient) *Downloader { - return &Downloader{youtubeClient: youtubeClient} -} - -type audioResult struct { - *media.Audio - err error -} - -type videoResult struct { - *media.Video - err error -} - -// 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(ctx context.Context, videoID string) (*media.MediaSet, error) { - var video *youtubev2.Video - video, err := d.youtubeClient.GetVideoContext(ctx, videoID) - if err != nil { - return nil, fmt.Errorf("error fetching video: %v", err) - } - - mediaSet := media.NewMediaSet(videoID) - - audioResultChan := make(chan audioResult, 1) - videoResultChan := make(chan videoResult, 1) - - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer close(audioResultChan) - audio, audioErr := d.downloadAudio(ctx, video, mediaSet.EncodedAudioPath(), mediaSet.RawAudioPath()) - result := audioResult{audio, audioErr} - audioResultChan <- result - wg.Done() - }() - go func() { - defer close(videoResultChan) - video, videoErr := d.downloadVideo(ctx, video, mediaSet.VideoPath()) - result := videoResult{video, videoErr} - videoResultChan <- result - wg.Done() - }() - - wg.Wait() - - audioResult := <-audioResultChan - videoResult := <-videoResultChan - - if err = audioResult.err; err != nil { - return nil, fmt.Errorf("error downloading audio: %v", err) - } - if err = videoResult.err; err != nil { - return nil, fmt.Errorf("error downloading video: %v", err) - } - - mediaSet.Audio = *audioResult.Audio - mediaSet.Video = *videoResult.Video - - 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) - } - - log.Println("finished downloading mediaset") - - return mediaSet, nil -} - -func (d *Downloader) downloadAudio(ctx context.Context, video *youtubev2.Video, outPath, rawOutPath string) (*media.Audio, error) { - if len(video.Formats) == 0 { - return nil, errors.New("error selecting audio format: no format available") - } - format := SortAudio(video.Formats)[0] - log.Printf("selected audio format: %s", FormatDebugString(&format, false)) - - stream, _, err := d.youtubeClient.GetStreamContext(ctx, video, &format) - if err != nil { - return nil, fmt.Errorf("error fetching audio stream: %v", err) - } - reader := progressReader{Reader: stream, label: "audio", exp: int(format.ContentLength)} - - rawAudioFile, err := os.Create(rawOutPath) - if err != nil { - return nil, fmt.Errorf("error creating raw audio file: %v", err) - } - - encodedAudioFile, err := os.Create(outPath) - if err != nil { - return nil, fmt.Errorf("error creating encoded audio file: %v", err) - } - streamReader := io.TeeReader(&reader, encodedAudioFile) - - var errOut bytes.Buffer - cmd := exec.CommandContext(ctx, "ffmpeg", "-i", "-", "-f", rawAudioFormat, "-ar", strconv.Itoa(rawAudioSampleRate), "-acodec", rawAudioCodec, "-") - 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(rawOutPath) - if err != nil { - return nil, fmt.Errorf("error opening raw audio file: %v", err) - } - - fi, err := rawAudioFile.Stat() - if err != nil { - return nil, fmt.Errorf("error reading raw audio 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) - } - - return &media.Audio{ - Bytes: fi.Size(), - Channels: format.AudioChannels, - Frames: numFrames, - SampleRate: sampleRate, - }, nil -} - -type progressReader struct { - io.Reader - label string - total, exp int -} - -func (pw *progressReader) Read(p []byte) (int, error) { - n, err := pw.Reader.Read(p) - pw.total += n - - log.Printf("[ProgressReader] [%s] Read %d of %d (%.02f%%) bytes from the provided reader", pw.label, pw.total, pw.exp, (float32(pw.total)/float32(pw.exp))*100.0) - - return n, err -} - -func (d *Downloader) downloadVideo(ctx context.Context, video *youtubev2.Video, outPath string) (*media.Video, error) { - if len(video.Formats) == 0 { - return nil, errors.New("error selecting audio format: no format available") - } - format := SortVideo(video.Formats)[0] - log.Printf("selected video format: %s", FormatDebugString(&format, false)) - - durationMsecs, err := strconv.Atoi(format.ApproxDurationMs) - if err != nil { - return nil, fmt.Errorf("could not parse video duration: %s", err) - } - - stream, _, err := d.youtubeClient.GetStreamContext(ctx, video, &format) - if err != nil { - return nil, fmt.Errorf("error fetching video stream: %v", err) - } - reader := progressReader{Reader: stream, label: "video", exp: int(format.ContentLength)} - - videoFile, err := os.Create(outPath) - if err != nil { - return nil, fmt.Errorf("error creating video file: %v", err) - } - - if _, err = io.Copy(videoFile, &reader); err != nil { - return nil, fmt.Errorf("error processing video: %v", err) - } - - return &media.Video{ - Bytes: format.ContentLength, - ThumbnailWidth: thumbnailWidth, - ThumbnailHeight: thumbnailHeight, - Duration: time.Duration(durationMsecs) * time.Millisecond, - }, nil -} diff --git a/backend/youtube/youtube2.go b/backend/youtube/youtube2.go deleted file mode 100644 index d24ca96..0000000 --- a/backend/youtube/youtube2.go +++ /dev/null @@ -1,70 +0,0 @@ -package youtube - -import ( - "context" - "errors" - "fmt" - "io" - "log" - "strconv" - "time" - - "git.netflux.io/rob/clipper/media" - youtubev2 "github.com/kkdai/youtube/v2" -) - -// YoutubeClient wraps the youtube.Client client. -type YoutubeClient interface { - GetVideoContext(context.Context, string) (*youtubev2.Video, error) - GetStreamContext(context.Context, *youtubev2.Video, *youtubev2.Format) (io.ReadCloser, int64, error) -} - -// MediaSetService implements a MediaSetService for Youtube videos. -type MediaSetService struct { - youtubeClient YoutubeClient -} - -// not used -func (s *MediaSetService) GetMediaSet(ctx context.Context, id string) (*media.MediaSet, error) { - var video *youtubev2.Video - video, err := s.youtubeClient.GetVideoContext(ctx, id) - if err != nil { - return nil, fmt.Errorf("error fetching video: %v", err) - } - - if len(video.Formats) == 0 { - return nil, errors.New("no format available") - } - - audioFormat := SortAudio(video.Formats)[0] - videoFormat := SortVideo(video.Formats)[0] - - durationMsecs, err := strconv.Atoi(videoFormat.ApproxDurationMs) - if err != nil { - log.Printf("GetMediaSet: invalid duration %s", videoFormat.ApproxDurationMs) - return nil, errors.New("error parsing format") - } - duration := time.Duration(durationMsecs) * time.Millisecond - - sampleRate, err := strconv.Atoi(videoFormat.AudioSampleRate) - if err != nil { - log.Printf("GetMediaSet: invalid samplerate %s", videoFormat.AudioSampleRate) - return nil, errors.New("error parsing format") - } - - return &media.MediaSet{ - YoutubeID: "", - Audio: media.Audio{ - Bytes: audioFormat.ContentLength, - Channels: audioFormat.AudioChannels, - Frames: 0, - SampleRate: sampleRate, - }, - Video: media.Video{ - Bytes: videoFormat.ContentLength, - Duration: duration, - ThumbnailWidth: videoFormat.Width, - ThumbnailHeight: videoFormat.Height, - }, - }, nil -}