mirror of
https://github.com/cgzirim/seek-tune.git
synced 2025-12-18 09:24:19 +00:00
312 lines
8.7 KiB
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
|
|
}
|