Skip to content

Commit

Permalink
Add youtube search function
Browse files Browse the repository at this point in the history
  • Loading branch information
fakelag committed Aug 3, 2024
1 parent 9ca484e commit c54ad0e
Show file tree
Hide file tree
Showing 2 changed files with 252 additions and 74 deletions.
227 changes: 163 additions & 64 deletions youtubeapi/youtube.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package youtubeapi
import (
"encoding/json"
"errors"
"fmt"
"math/rand"
"net/url"
"os/exec"
Expand Down Expand Up @@ -80,6 +81,88 @@ func (yt *Youtube) SetCmdExecutor(exec cmd.CommandExecutor) {
yt.executor = exec
}

func (yt *Youtube) SearchYoutubeMedia(numSearchResults int, videoIdOrSearchTerm string) ([]*YoutubeMedia, error) {
ytDlp, err := getYtDlpPath()

if err != nil {
return nil, err
}

replacer := strings.NewReplacer(
"\"", "",
"'", "",
)

videoArg := fmt.Sprintf("ytsearch%d:%s", numSearchResults, replacer.Replace(videoIdOrSearchTerm))

args := []string{
videoArg,
"--playlist-end", "1",
"--extract-audio",
"--quiet",
"--audio-format", "opus",
"--ignore-errors",
"--no-color",
"--no-check-formats",
"--max-downloads", "0",
"-s",
"--get-url",
"--print-json",
}

resultChannel, errorChannel := yt.executor.RunCommandWithTimeout(ytDlp, yt.streamUrlTimeout, args...)

var stdout string

select {
case result := <-resultChannel:
stdout = *result
break
case err := <-errorChannel:
return nil, err
}

searchResults := make([]*YoutubeMedia, 0)

if len(stdout) == 0 {
return searchResults, nil
}

jsonLines := strings.Split(stdout, "\n")

if len(jsonLines) < 2 {
return searchResults, nil
}

for i := 0; i < len(jsonLines); i += 2 {
videoStreamURL := jsonLines[i]
videoJson := jsonLines[i+1]

var object YtDlpObject
if err := json.Unmarshal([]byte(videoJson), &object); err != nil {
return nil, err
}

if object.Type != "video" {
continue
}

media, _, err := yt.getMediaOrPlaylistFromJsonAndStreamURL(&object, videoJson, videoStreamURL)

if err != nil {
return nil, err
}

if media == nil {
continue
}

searchResults = append(searchResults, media)
}

return searchResults, nil
}

func (yt *Youtube) GetYoutubeMedia(videoIdOrSearchTerm string) (*YoutubeMedia, error) {
ytDlp, err := getYtDlpPath()

Expand Down Expand Up @@ -146,38 +229,12 @@ func (yt *Youtube) GetYoutubeMedia(videoIdOrSearchTerm string) (*YoutubeMedia, e
return nil, err
}

if object.Type == "video" {
var ytDlpVideo YtDlpVideo
if err := json.Unmarshal([]byte(videoJson), &ytDlpVideo); err != nil {
return nil, err
}

media := &YoutubeMedia{
ID: ytDlpVideo.ID,
VideoTitle: ytDlpVideo.Title,
VideoThumbnail: ytDlpVideo.Thumbnail,
VideoIsLiveStream: ytDlpVideo.IsLiveStream,
VideoDuration: time.Duration(ytDlpVideo.Duration) * time.Second,
VideoLink: "https://www.youtube.com/watch?v=" + ytDlpVideo.ID,
StreamURL: videoStreamURL,
ytAPI: yt,
}

streamExpireUnixSecondsMatch := yt.streamUrlExpireRegex.FindStringSubmatch(videoStreamURL)

if len(streamExpireUnixSecondsMatch) >= 4 {
unixSeconds, err := strconv.ParseInt(streamExpireUnixSecondsMatch[3], 10, 64)

if err == nil && unixSeconds > 0 {
expirationTime := time.Unix(unixSeconds, 0)
media.StreamExpiresAt = &expirationTime
}
}

return media, nil
} else {
if object.Type != "video" {
return nil, ErrorUnrecognisedObject
}

media, _, err := yt.getMediaOrPlaylistFromJsonAndStreamURL(&object, videoJson, videoStreamURL)
return media, err
}

func (yt *Youtube) GetYoutubePlaylist(playlistIdOrUrl string) (*YoutubePlaylist, error) {
Expand Down Expand Up @@ -229,40 +286,8 @@ func (yt *Youtube) GetYoutubePlaylist(playlistIdOrUrl string) (*YoutubePlaylist,
return nil, ErrorUnrecognisedObject
}

var ytDlpPlaylist YtDlpPlayList
if err := json.Unmarshal([]byte(playlistJson), &ytDlpPlaylist); err != nil {
return nil, err
}

rngSource := rand.NewSource(time.Now().Unix())
rng := rand.New(rngSource)

playList := NewYoutubePlaylist(ytDlpPlaylist.ID, ytDlpPlaylist.Title, ytDlpPlaylist.PlaylistURL, rng, len(ytDlpPlaylist.Entries))

for index, video := range ytDlpPlaylist.Entries {
thumbnailUrl := ""
thumbnailWidth := 0

for _, thumbnail := range video.Thumbnails {
if thumbnail.Width > thumbnailWidth {
thumbnailUrl = thumbnail.URL
thumbnailWidth = thumbnail.Width
}
}

playList.mediaList[index] = &YoutubeMedia{
ID: video.ID,
VideoTitle: video.Title,
VideoThumbnail: thumbnailUrl,
VideoIsLiveStream: video.LiveStatus == "is_live",
VideoDuration: time.Duration(video.Duration) * time.Second,
VideoLink: "https://www.youtube.com/watch?v=" + video.ID,
StreamURL: "",
ytAPI: yt,
}
}

return playList, nil
_, playList, err := yt.getMediaOrPlaylistFromJsonAndStreamURL(&object, playlistJson, "")
return playList, err
}

func NewYoutubePlaylist(
Expand Down Expand Up @@ -312,6 +337,80 @@ func getYoutubeUrlVideoId(urlString string) string {
return ""
}

func (yt *Youtube) getMediaOrPlaylistFromJsonAndStreamURL(
object *YtDlpObject,
ytdlpJson string,
videoStreamURL string,
) (*YoutubeMedia, *YoutubePlaylist, error) {
if object.Type == "video" {
var ytDlpVideo YtDlpVideo
if err := json.Unmarshal([]byte(ytdlpJson), &ytDlpVideo); err != nil {
return nil, nil, err
}

media := &YoutubeMedia{
ID: ytDlpVideo.ID,
VideoTitle: ytDlpVideo.Title,
VideoThumbnail: ytDlpVideo.Thumbnail,
VideoIsLiveStream: ytDlpVideo.IsLiveStream,
VideoDuration: time.Duration(ytDlpVideo.Duration) * time.Second,
VideoLink: "https://www.youtube.com/watch?v=" + ytDlpVideo.ID,
StreamURL: videoStreamURL,
ytAPI: yt,
}

streamExpireUnixSecondsMatch := yt.streamUrlExpireRegex.FindStringSubmatch(videoStreamURL)

if len(streamExpireUnixSecondsMatch) >= 4 {
unixSeconds, err := strconv.ParseInt(streamExpireUnixSecondsMatch[3], 10, 64)

if err == nil && unixSeconds > 0 {
expirationTime := time.Unix(unixSeconds, 0)
media.StreamExpiresAt = &expirationTime
}
}

return media, nil, nil
} else if object.Type == "playlist" {
var ytDlpPlaylist YtDlpPlayList
if err := json.Unmarshal([]byte(ytdlpJson), &ytDlpPlaylist); err != nil {
return nil, nil, err
}

rngSource := rand.NewSource(time.Now().Unix())
rng := rand.New(rngSource)

playList := NewYoutubePlaylist(ytDlpPlaylist.ID, ytDlpPlaylist.Title, ytDlpPlaylist.PlaylistURL, rng, len(ytDlpPlaylist.Entries))

for index, video := range ytDlpPlaylist.Entries {
thumbnailUrl := ""
thumbnailWidth := 0

for _, thumbnail := range video.Thumbnails {
if thumbnail.Width > thumbnailWidth {
thumbnailUrl = thumbnail.URL
thumbnailWidth = thumbnail.Width
}
}

playList.mediaList[index] = &YoutubeMedia{
ID: video.ID,
VideoTitle: video.Title,
VideoThumbnail: thumbnailUrl,
VideoIsLiveStream: video.LiveStatus == "is_live",
VideoDuration: time.Duration(video.Duration) * time.Second,
VideoLink: "https://www.youtube.com/watch?v=" + video.ID,
StreamURL: "",
ytAPI: yt,
}
}

return nil, playList, nil
} else {
return nil, nil, ErrorUnrecognisedObject
}
}

func getYtDlpPath() (string, error) {
path, err := exec.LookPath("yt-dlp")

Expand Down
99 changes: 89 additions & 10 deletions youtubeapi/youtube_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ import (
. "github.com/onsi/gomega"
)

var mockVideoJson string = strings.ReplaceAll(`{
"id": "123",
"fulltitle": "Mock Title",
"duration": 70,
"thumbnail": "foo",
"is_live": false,
"_type": "video"
}`, "\n", "")
func makeMockVideoJson(id string, title string) string {
return strings.ReplaceAll(fmt.Sprintf(`{
"id": "%s",
"fulltitle": "%s",
"duration": 70,
"thumbnail": "foo",
"is_live": false,
"_type": "video"
}`, id, title), "\n", "")
}

var mockPlaylistJson string = strings.ReplaceAll(`{
"id": "1234",
Expand Down Expand Up @@ -78,7 +80,7 @@ var _ = Describe("YT Download", func() {
When("Downloading a singular video", func() {
It("Downloads a video stream URL from Youtube", func() {
mockExecutor := &testutils.MockCommandExecutor{
MockStdoutResult: "url123\n" + mockVideoJson,
MockStdoutResult: "url123\n" + makeMockVideoJson("123", "Mock Title"),
}

yt := youtubeapi.NewYoutubeAPI()
Expand Down Expand Up @@ -106,7 +108,7 @@ var _ = Describe("YT Download", func() {
"https://rr5---sn-qo5-ixas.googlevideo.com/videoplayback?expire=" + fmt.Sprintf("%d", timeUnix) + "&ei=123&foo=bar\n",
} {
mockExecutor := &testutils.MockCommandExecutor{
MockStdoutResult: streamUrl + mockVideoJson,
MockStdoutResult: streamUrl + makeMockVideoJson("123", "Mock Title"),
}

yt := youtubeapi.NewYoutubeAPI()
Expand Down Expand Up @@ -227,4 +229,81 @@ var _ = Describe("YT Download", func() {
Expect(err).To(MatchError(youtubeapi.ErrorNoPlaylistFound))
})
})

When("Searching from youtube", func() {
It("Downloads a video stream URL from Youtube", func() {
lines := []string{
"url4video1",
makeMockVideoJson("1", "Video 1"),
"url4video2",
makeMockVideoJson("2", "Video 2"),
"url4video3",
makeMockVideoJson("3", "Video 3"),
}
mockExecutor := &testutils.MockCommandExecutor{
MockStdoutResult: strings.Join(lines, "\n"),
}

yt := youtubeapi.NewYoutubeAPI()
yt.SetCmdExecutor(mockExecutor)

searchResults, err := yt.SearchYoutubeMedia(3, "foo")

Expect(err).To(BeNil())
Expect(searchResults).NotTo(BeNil())
Expect(searchResults).To(HaveLen(3))

vid := searchResults[0]
Expect(vid).NotTo(BeNil())
Expect(vid.ID).To(Equal("1"))
Expect(vid.Title()).To(Equal("Video 1"))
Expect(vid.FileURL()).To(Equal("url4video1"))
Expect(vid.Thumbnail()).To(Equal("foo"))

vid = searchResults[1]
Expect(vid).NotTo(BeNil())
Expect(vid.ID).To(Equal("2"))
Expect(vid.Title()).To(Equal("Video 2"))
Expect(vid.FileURL()).To(Equal("url4video2"))
Expect(vid.Thumbnail()).To(Equal("foo"))

vid = searchResults[2]
Expect(vid).NotTo(BeNil())
Expect(vid.ID).To(Equal("3"))
Expect(vid.Title()).To(Equal("Video 3"))
Expect(vid.FileURL()).To(Equal("url4video3"))
Expect(vid.Thumbnail()).To(Equal("foo"))
})

It("Returns sensible results when receiving an invalid response from ytdlp", func() {
mockExecutor := &testutils.MockCommandExecutor{
MockStdoutResult: "streamurl\n{",
}

yt := youtubeapi.NewYoutubeAPI()
yt.SetCmdExecutor(mockExecutor)

searchResults, err := yt.SearchYoutubeMedia(5, "foo")
Expect(err).To(MatchError("unexpected end of JSON input"))
Expect(searchResults).To(BeNil())

mockExecutor.MockStdoutResult = "streamurl\n{\"_type\":\"something\"}"

searchResults, err = yt.SearchYoutubeMedia(5, "foo")
Expect(err).To(BeNil())
Expect(searchResults).To(HaveLen(0))

mockExecutor.MockStdoutResult = "streamurl"

searchResults, err = yt.SearchYoutubeMedia(5, "foo")
Expect(err).To(BeNil())
Expect(searchResults).To(HaveLen(0))

mockExecutor.MockStdoutResult = ""

searchResults, err = yt.SearchYoutubeMedia(5, "foo")
Expect(err).To(BeNil())
Expect(searchResults).To(HaveLen(0))
})
})
})

0 comments on commit c54ad0e

Please sign in to comment.