seek-tune/server/spotify/youtube.go

312 lines
8.7 KiB
Go

package spotify
import (
"context"
"fmt"
"log"
"log/slog"
"os"
"os/exec"
"path/filepath"
"song-recognition/utils"
ytdlib "github.com/kkdai/youtube/v2"
"errors"
"io"
"io/ioutil"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/buger/jsonparser"
"google.golang.org/api/option"
"google.golang.org/api/youtube/v3"
)
const developerKey = ""
// https://github.com/BharatKalluri/spotifydl/blob/v0.1.0/src/youtube.go
func getYoutubeIdWithAPI(spTrack Track) (string, error) {
service, err := youtube.NewService(context.TODO(), option.WithAPIKey(developerKey))
if err != nil {
log.Fatalf("Error creating new YouTube client: %v", err)
return "", err
}
// Video category ID 10 is for music videos
query := fmt.Sprintf("'%s' %s %s", spTrack.Title, spTrack.Artist, spTrack.Album) /* example: 'Lovesong' The Cure Disintegration */
call := service.Search.List([]string{"id", "snippet"}).Q(query).VideoCategoryId("10").Type("video")
response, err := call.Do()
if err != nil {
log.Fatalf("Error making search API call: %v", err)
return "", err
}
for _, item := range response.Items {
switch item.Id.Kind {
case "youtube#video":
return item.Id.VideoId, nil
}
}
// TODO: Handle when the query returns no songs (highly unlikely since the query is coming from spotify though)
return "", nil
}
var httpClient = &http.Client{}
var durationMatchThreshold = 5
type SearchResult struct {
Title, Uploader, URL, Duration, ID string
Live bool
SourceName string
Extra []string
}
func convertStringDurationToSeconds(durationStr string) int {
splitEntities := strings.Split(durationStr, ":")
if len(splitEntities) == 1 {
seconds, _ := strconv.Atoi(splitEntities[0])
return seconds
} else if len(splitEntities) == 2 {
seconds, _ := strconv.Atoi(splitEntities[1])
minutes, _ := strconv.Atoi(splitEntities[0])
return (minutes * 60) + seconds
} else if len(splitEntities) == 3 {
seconds, _ := strconv.Atoi(splitEntities[2])
minutes, _ := strconv.Atoi(splitEntities[1])
hours, _ := strconv.Atoi(splitEntities[0])
return ((hours * 60) * 60) + (minutes * 60) + seconds
} else {
return 0
}
}
// GetYoutubeId takes the query as string and returns the search results video ID's
func GetYoutubeId(track Track) (string, error) {
songDurationInSeconds := track.Duration
// searchQuery := fmt.Sprintf("'%s' %s %s", track.Title, track.Artist, track.Album)
searchQuery := fmt.Sprintf("'%s' %s", track.Title, track.Artist)
searchResults, err := ytSearch(searchQuery, 10)
if err != nil {
return "", err
}
if len(searchResults) == 0 {
errorMessage := fmt.Sprintf("no songs found for %s", searchQuery)
return "", errors.New(errorMessage)
}
// Try for the closest match timestamp wise
for _, result := range searchResults {
allowedDurationRangeStart := songDurationInSeconds - durationMatchThreshold
allowedDurationRangeEnd := songDurationInSeconds + durationMatchThreshold
resultSongDuration := convertStringDurationToSeconds(result.Duration)
if resultSongDuration >= allowedDurationRangeStart && resultSongDuration <= allowedDurationRangeEnd {
fmt.Println("INFO: ", fmt.Sprintf("Found song with id '%s'", result.ID))
return result.ID, nil
}
}
return "", fmt.Errorf("could not settle on a song from search result for: %s", searchQuery)
}
func getContent(data []byte, index int) []byte {
id := fmt.Sprintf("[%d]", index)
contents, _, _, _ := jsonparser.Get(data, "contents", "twoColumnSearchResultsRenderer", "primaryContents", "sectionListRenderer", "contents", id, "itemSectionRenderer", "contents")
return contents
}
func ytSearch(searchTerm string, limit int) (results []*SearchResult, err error) {
ytSearchUrl := fmt.Sprintf(
"https://www.youtube.com/results?search_query=%s", url.QueryEscape(searchTerm),
)
// fmt.Println("Search URL: ", ytSearchUrl)
req, err := http.NewRequest("GET", ytSearchUrl, nil)
if err != nil {
return nil, errors.New("cannot get youtube page")
}
req.Header.Add("Accept-Language", "en")
res, err := httpClient.Do(req)
if err != nil {
return nil, errors.New("cannot get youtube page")
}
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(res.Body)
if res.StatusCode != 200 {
return nil, errors.New("failed to make a request to youtube")
}
buffer, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, errors.New("cannot read response from youtube")
}
body := string(buffer)
splitScript := strings.Split(body, `window["ytInitialData"] = `)
if len(splitScript) != 2 {
splitScript = strings.Split(body, `var ytInitialData = `)
}
if len(splitScript) != 2 {
return nil, errors.New("invalid response from youtube")
}
splitScript = strings.Split(splitScript[1], `window["ytInitialPlayerResponse"] = null;`)
jsonData := []byte(splitScript[0])
index := 0
var contents []byte
for {
contents = getContent(jsonData, index)
_, _, _, err = jsonparser.Get(contents, "[0]", "carouselAdRenderer")
if err == nil {
index++
} else {
break
}
}
_, err = jsonparser.ArrayEach(contents, func(value []byte, t jsonparser.ValueType, i int, err error) {
if err != nil {
return
}
if limit > 0 && len(results) >= limit {
return
}
id, err := jsonparser.GetString(value, "videoRenderer", "videoId")
if err != nil {
return
}
title, err := jsonparser.GetString(value, "videoRenderer", "title", "runs", "[0]", "text")
if err != nil {
return
}
uploader, err := jsonparser.GetString(value, "videoRenderer", "ownerText", "runs", "[0]", "text")
if err != nil {
return
}
live := false
duration, err := jsonparser.GetString(value, "videoRenderer", "lengthText", "simpleText")
if err != nil {
duration = ""
live = true
}
results = append(results, &SearchResult{
Title: title,
Uploader: uploader,
Duration: duration,
ID: id,
URL: fmt.Sprintf("https://youtube.com/watch?v=%s", id),
Live: live,
SourceName: "youtube",
})
})
if err != nil {
return results, err
}
return results, nil
}
// downloadYTaudio1 downloads audio from a YouTube video using the github.com/kkdai/youtube library.
func downloadYTaudio1(id, outputFilePath string) (string, error) {
logger := utils.GetLogger()
dir := filepath.Dir(outputFilePath)
if stat, err := os.Stat(dir); err != nil || !stat.IsDir() {
logger.Error("Invalid directory for output file", slog.Any("error", err))
return "", errors.New("output directory does not exist or is not a directory")
}
client := ytdlib.Client{}
video, err := client.GetVideo(id)
if err != nil {
logger.Error("Error getting YouTube video", slog.Any("error", err))
return "", err
}
/*
itag code: 140, container: m4a, content: audio, bitrate: 128k
change the FindByItag parameter to 139 if you want smaller files (but with a bitrate of 48k)
https://gist.github.com/sidneys/7095afe4da4ae58694d128b1034e01e2
*/
formats := video.Formats.Itag(140)
/* in some cases, when attempting to download the audio
using the library github.com/kkdai/youtube,
the download fails (and shows the file size as 0 bytes)
until the second or third attempt. */
var fileSize int64
file, err := os.Create(outputFilePath)
if err != nil {
logger.Error("Error creating file", slog.Any("error", err))
return "", err
}
for fileSize == 0 {
stream, _, err := client.GetStream(video, &formats[0])
if err != nil {
logger.Error("Error getting stream", slog.Any("error", err))
return "", err
}
if _, err = io.Copy(file, stream); err != nil {
logger.Error("Error copying stream to file", slog.Any("error", err))
return "", err
}
fileSize, _ = GetFileSize(outputFilePath)
}
defer file.Close()
return outputFilePath + "m4a", nil
}
// downloadYTaudio2 downloads audio from a YouTube video using yt-dlp command line tool.
func downloadYTaudio2(videoURL, outputFilePath string) (string, error) {
logger := utils.GetLogger()
dir := filepath.Dir(outputFilePath)
if stat, err := os.Stat(dir); err != nil || !stat.IsDir() {
logger.Error("Invalid directory for output file", slog.Any("error", err))
return "", errors.New("output directory does not exist or is not a directory")
}
_, err := exec.LookPath("yt-dlp")
if err != nil {
logger.Error("yt-dlp not found in PATH", slog.Any("error", err))
return "", errors.New("yt-dlp is not installed or not in PATH")
}
audioFmt := "wav"
cmd := exec.Command(
"yt-dlp",
"-f", "bestaudio",
"--extract-audio",
"--audio-format", audioFmt,
"-o", outputFilePath,
videoURL,
)
output, err := cmd.CombinedOutput()
if err != nil {
logger.Error("yt-dlp command failed", slog.String("output", string(output)), slog.Any("error", err))
return "", err
}
return outputFilePath + "." + audioFmt, nil
}