AuraGem Servers > Tree [main]
/gemini/youtube/youtube.go/
package youtube
import (
"context"
"embed"
"errors"
"fmt"
"html"
"net"
"net/http"
"net/url"
"path"
"strings"
"time"
//"log"
ytd "github.com/kkdai/youtube/v2"
"gitlab.com/clseibold/auragem_sis/config"
sis "gitlab.com/sis-suite/smallnetinformationservices"
"google.golang.org/api/option"
"google.golang.org/api/youtube/v3"
)
var (
youtubeAPIKey = config.YoutubeApiKey
maxResults = int64(25) /*flag.Int64("max-results", 25, "Max YouTube results")*/
)
//go:embed index.gmi
var content embed.FS
func HandleYoutube(s sis.VirtualServerHandle) {
// Create Youtube Service
service, err1 := youtube.NewService(context.Background(), option.WithAPIKey(youtubeAPIKey))
if err1 != nil {
//log.Fatalf("Error creating new Youtube client: %v", err1)
panic(err1)
}
searchRoute := getSearchRouteFunc(service)
videoPageRoute := getVideoPageRouteFunc(service)
videoDownloadRoute := getVideoDownloadRouteFunc()
s.AddRoute("/cgi-bin/youtube.cgi", func(request *sis.Request) {
request.Redirect("/youtube/") // TODO: Temporary Redirect
})
s.AddRoute("/youtube", indexRoute)
s.AddRoute("/youtube/search", searchRoute)
s.AddRoute("/youtube/search/:page", searchRoute)
s.AddRoute("/youtube/video/:id/", videoPageRoute)
s.AddRoute("/youtube/downloadVideo/:quality/:id", videoDownloadRoute)
handleCaptionDownload(s)
handleChannelPage(s, service)
handlePlaylistPage(s, service)
}
func indexRoute(request *sis.Request) {
creationDate, _ := time.ParseInLocation(time.RFC3339, "2024-03-17T11:57:00", time.Local)
abstract := "#AuraGem YouTube Proxy\n\nProxies YouTube to Scroll/Gemini. Lets you search and download videos and playlists.\n"
request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: creationDate.UTC(), UpdateDate: creationDate.UTC(), Language: "en", Abstract: abstract})
if request.ScrollMetadataRequested() {
request.Scroll(abstract)
return
}
request.Gemini("# AuraGem YouTube Proxy\n\nWelcome to the AuraGem YouTube Proxy!\n\n")
request.PromptLine("/youtube/search/", "Search")
fmt.Printf("Proxied Under: %s\n", request.ProxiedUnder)
request.Gemini("=> / AuraGem Home\n")
request.Gemini("=> gemini://kwiecien.us/gemcast/20210425.gmi See This Proxy Featured on Gemini Radio\n")
}
func getSearchRouteFunc(service *youtube.Service) sis.RequestHandler {
return func(request *sis.Request) {
request.SetNoLanguage()
query, err := request.Query()
if err != nil {
request.TemporaryFailure(err.Error())
return
}
if query == "" {
request.RequestInput("Search Query:")
} else {
rawQuery, err := request.RawQuery()
if err != nil {
request.TemporaryFailure(err.Error())
return
}
abstract := fmt.Sprintf("# AuraGem YouTube Proxy Search - Query %s\n", query)
request.SetScrollMetadataResponse(sis.ScrollMetadata{Language: "en", Abstract: abstract})
if request.ScrollMetadataRequested() {
request.Scroll(abstract)
return
}
page := request.GetParam("page")
if page == "" {
searchYoutube(request, service, query, rawQuery, "")
} else {
searchYoutube(request, service, query, rawQuery, page)
}
}
}
}
func handleVideoClassification(video *youtube.Video, request *sis.Request) {
handleTopicId := false
if video.Snippet != nil {
switch video.Snippet.CategoryId {
case "1": // Film & Animation
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "2": // Autos & Vehicles
request.SetClassification(sis.ScrollResponseUDC_Engineering)
case "10": // Music
request.SetClassification(sis.ScrollResponseUDC_Music)
case "15": // Pets and Animals
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "17": // Sports
request.SetClassification(sis.ScrollResponseUDC_Sport)
case "18": // Short Movies
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "19": // Travel & Events
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "20": // Gaming
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "21": // Videoblogging
request.SetClassification(sis.ScrollResponseUDC_PersonalLog)
case "22": // People & Blogs
request.SetClassification(sis.ScrollResponseUDC_PersonalLog)
case "23": // Comedy
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "24": // Entertainment
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "25": // News and Politics
request.SetClassification(sis.ScrollResponseUDC_SocialScience)
case "26": // Howto and Style
request.SetClassification(sis.ScrollResponseUDC_Reference)
case "27": // Education
request.SetClassification(sis.ScrollResponseUDC_SocialScience)
case "28": // Science and Technology
handleTopicId = true
//request.SetClassification(sis.ScrollResponseUDC_Technology) // TODO
case "29": // Nonprofits & Activism
request.SetClassification(sis.ScrollResponseUDC_SocialScience)
case "30": // Movies
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "31": // Anime/Animation
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "32": // Action/Adventure
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "33": // Classics
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "34": // Comedy
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "35": // Documentary
handleTopicId = true
case "36": // Drama
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "37": // Family
request.SetClassification(sis.ScrollResponseUDC_PersonalLog)
case "38": // Foreign
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "39": // Horror
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "40": // Sci-Fi/Fantasy
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "41": // Thriller
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "42": // Shorts
handleTopicId = true
case "43": // Shows
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
case "44": // Trailers
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
}
}
if handleTopicId && video.TopicDetails != nil {
outer:
for _, topic := range video.TopicDetails.TopicIds {
switch topic {
case "/m/01k8wb":
request.SetClassification(sis.ScrollResponseUDC_GeneralKnowledge)
break outer
case "/m/04rlf", "/m/02mscn", "/m/0ggq0m", "/m/01lyv", "/m/02lkt", "/m/0glt670", "/m/05rwpb", "/m/03_d0", "/m/028sqc", "/m/0g293", "/m/064t9", "/m/06cqb", "/m/06j6l", "/m/06by7", "/m/0gywn":
request.SetClassification(sis.ScrollResponseUDC_Music)
break outer
case "/m/0bzvm2", "/m/025zzc", "/m/02ntfj", "/m/0b1vjn", "/m/02hygl", "/m/04q1x3q", "/m/01sjng", "/m/0403l3g", "/m/021bp2", "/m/022dc6", "/m/03hf_rm": // Gaming
request.SetClassification(sis.ScrollResponseUDC_GamingVideos)
break outer
case "/m/06ntj", "/m/0jm_", "/m/018jz", "/m/018w8", "/m/01cgz", "/m/09xp_", "/m/02vx4", "/m/037hz", "/m/03tmr", "/m/01h7lh", "/m/0410tth", "/m/07bs0", "/m/07_53": // Sports
request.SetClassification(sis.ScrollResponseUDC_Sport)
break outer
case "/m/02jjt", "/m/09kqc", "/m/02vxn", "/m/05qjc", "/m/066wd", "/m/0f2f9": // Entertainment
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
break outer
case "/m/032tl": // Fashion -> Art
request.SetClassification(sis.ScrollResponseUDC_Art)
break outer
case "/m/027x7n": // Fitness -> Sport
request.SetClassification(sis.ScrollResponseUDC_Sport)
break outer
case "/m/02wbm": // Food -> Art
request.SetClassification(sis.ScrollResponseUDC_Art)
break outer
case "/m/03glg": // Hobby -> Recreation
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
break outer
case "/m/068hy": // Pets -> Recreation
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
break outer
case "/m/041xxh": // Beauty
request.SetClassification(sis.ScrollResponseUDC_Art)
break outer
case "/m/07c1v": // Computer Technology
request.SetClassification(sis.ScrollResponseUDC_Class0)
break outer
case "/m/07bxq": // Tourism -> Recreation
request.SetClassification(sis.ScrollResponseUDC_Entertainment)
break outer
case "/m/07yv9": // Vehicles -> Engineering/General Technology
request.SetClassification(sis.ScrollResponseUDC_Engineering)
break outer
case "/m/06bvp": // Religion
request.SetClassification(sis.ScrollResponseUDC_Religion)
break outer
case "/m/05qt0": // Politics
request.SetClassification(sis.ScrollResponseUDC_SocialScience)
break outer
case "/m/01h6rj": // Military
request.SetClassification(sis.ScrollResponseUDC_SocialScience)
break outer
case "/m/0kt51": // Health
request.SetClassification(sis.ScrollResponseUDC_Medicine)
break outer
case "/m/09s1f": // Business
request.SetClassification(sis.ScrollResponseUDC_AppliedScience)
break outer
case "/m/098wr", "/m/019_rr":
request.SetClassification(sis.ScrollResponseUDC_SocialScience)
}
}
}
}
func getVideoPageRouteFunc(service *youtube.Service) sis.RequestHandler {
return func(request *sis.Request) {
id := request.GetParam("id")
call := service.Videos.List([]string{"id", "snippet", "status"}).Id(id).MaxResults(1)
response, err := call.Do()
if err != nil {
//log.Fatalf("Error: %v", err) // TODO
panic(err)
}
if len(response.Items) == 0 {
request.TemporaryFailure("Video not found.")
return
}
video := response.Items[0]
handleVideoClassification(video, request)
lang := request.Server.DefaultLanguage()
if video.Snippet.DefaultLanguage != "" {
lang = video.Snippet.DefaultLanguage
}
publishDate := video.Snippet.PublishedAt
if video.Status.PrivacyStatus == "private" {
publishDate = video.Status.PublishAt
}
publishDateParsed, _ := time.Parse(time.RFC3339, publishDate)
abstract := fmt.Sprintf("# Video - %s\n%s\n", html.UnescapeString(video.Snippet.Title), html.UnescapeString(video.Snippet.Description))
request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: html.UnescapeString(video.Snippet.ChannelTitle), PublishDate: publishDateParsed.UTC(), UpdateDate: publishDateParsed.UTC(), Language: lang, Abstract: abstract})
if request.ScrollMetadataRequested() {
request.Scroll(abstract)
return
}
//video.ContentDetails.RegionRestriction.Allowed
//video.ContentDetails.Definition
var downloadFormatsBuilder strings.Builder
var captionsBuilder strings.Builder
client := ytd.Client{}
ytd_vid, err := client.GetVideo(video.Id)
retries := 0
for err != nil {
// Try again, for a maximum of 5 times.
ytd_vid, err = client.GetVideo(video.Id)
retries += 1
if retries == 5 {
break
}
time.Sleep(time.Millisecond * 120)
}
if err != nil { // If still getting an error after retrying 5 times.
fmt.Printf("Couldn't find video in ytd client.\n")
fmt.Fprintf(&downloadFormatsBuilder, "No downloads available yet. Try again later.\n")
} else {
// List Download Formats
formats := ytd_vid.Formats.WithAudioChannels().Type("video/mp4") // TODO
formats.Sort()
if len(formats) == 0 {
fmt.Fprintf(&downloadFormatsBuilder, "No downloads available yet. The video could be a future livestream or premiere.\n")
} else {
for _, format := range formats {
audioQuality := ""
switch format.AudioQuality {
case "AUDIO_QUALITY_HIGH":
audioQuality = "High Audio Quality"
case "AUDIO_QUALITY_MEDIUM":
audioQuality = "Medium Audio Quality"
case "AUDIO_QUALITY_LOW":
audioQuality = "Low Audio Quality"
}
fmt.Fprintf(&downloadFormatsBuilder, "=> /youtube/downloadVideo/%s/%s.mp4 Download Video - %s (%s)\n", format.Quality, video.Id, format.Quality, audioQuality)
}
}
_, transcript_err := client.GetTranscript(ytd_vid, "en")
if !errors.Is(transcript_err, ytd.ErrTranscriptDisabled) {
fmt.Fprintf(&captionsBuilder, "=> /youtube/video/%s/transcript/ View Video Transcript\n\n", video.Id)
}
// Captions
if len(ytd_vid.CaptionTracks) > 0 {
fmt.Fprintf(&captionsBuilder, "## Captions\n")
for _, caption := range ytd_vid.CaptionTracks {
captionString := caption.LanguageCode + ".srv3"
if caption.Kind != "" {
captionString = caption.Kind + "_" + captionString
}
fmt.Fprintf(&captionsBuilder, "=> /youtube/video/%s/caption/%s %s %s\n", video.Id, url.PathEscape(captionString), caption.Kind, caption.LanguageCode)
}
}
}
request.Gemini(fmt.Sprintf(`# Video: %s
%s
=> https://youtube.com/watch?v=%s On YouTube
=> gopher://auragem.ddns.net/1/g/youtube/video/%s/ On Gopher
=> spartan://auragem.ddns.net/g/youtube/video/%s/ On Spartan
=> nex://auragem.ddns.net/gemini/youtube/video/%s/ On Nex
%s
## Description
%s
=> /youtube/channel/%s/ Uploaded by %s
`, html.UnescapeString(video.Snippet.Title), downloadFormatsBuilder.String() /*video.Id, */, video.Id, video.Id, video.Id, video.Id, captionsBuilder.String(), html.UnescapeString(video.Snippet.Description), video.Snippet.ChannelId, html.UnescapeString(video.Snippet.ChannelTitle)))
}
}
func handleCaptionDownload(s sis.VirtualServerHandle) {
s.AddRoute("/youtube/video/:id/transcript", func(request *sis.Request) {
client := ytd.Client{}
videoId := request.GetParam("id")
video, err := client.GetVideo(videoId)
retries := 0
for err != nil {
// Try again, for a maximum of 5 times.
video, err = client.GetVideo(videoId)
retries += 1
if retries == 5 {
break
}
time.Sleep(time.Millisecond * 120)
}
if err != nil {
//panic(err)
request.TemporaryFailure("Error: Couldn't find video. %s\n", err.Error())
return
}
time.Sleep(time.Millisecond * 120)
// Go through each requested language and try to find a transcript for them. Otherwise, fallback to english.
// If still no transcript found, then error out.
requestedLanguages := append(request.ScrollRequestedLanguages, "en") // Append fallback language
var transcript ytd.VideoTranscript
var found bool = false
for _, lang := range requestedLanguages {
var err error
transcript, err = client.GetTranscript(video, lang)
if err == nil {
request.SetLanguage(lang)
found = true
break
}
}
if !found {
request.TemporaryFailure("Video doesn't have a transcript.\n")
return
}
request.Gemini(transcript.String())
})
s.AddRoute("/youtube/video/:id/caption/:caption", func(request *sis.Request) {
client := ytd.Client{}
videoId := request.GetParam("id")
video, err := client.GetVideo(videoId)
retries := 0
for err != nil {
// Try again, for a maximum of 5 times.
video, err = client.GetVideo(videoId)
retries += 1
if retries == 5 {
break
}
time.Sleep(time.Millisecond * 120)
}
if err != nil {
//panic(err)
request.TemporaryFailure("Error: Couldn't find video. %s\n", err.Error())
return
}
captionString := request.GetParam("caption")
kind, lang, foundKind := strings.Cut(captionString, "_")
if !foundKind {
lang = kind
kind = ""
}
lang = strings.TrimSuffix(lang, ".srv3")
fmt.Printf("Getting caption using kind %s and lang %s\n", kind, lang)
var foundCaption = false
var captionFound ytd.CaptionTrack
for _, caption := range video.CaptionTracks {
if caption.Kind == kind && caption.LanguageCode == lang {
captionFound = caption
foundCaption = true
request.SetLanguage(caption.LanguageCode)
break
}
}
if !foundCaption {
request.TemporaryFailure("Caption not found.")
return
} else {
http_client := http.DefaultClient
response, err := http_client.Get(captionFound.BaseURL)
if err != nil {
request.TemporaryFailure("Couldn't download caption file.")
return
} else {
request.Stream("text/xml; charset=UTF-8", response.Body)
response.Body.Close()
}
}
})
}
func filterYT(fl ytd.FormatList, test func(ytd.Format) bool) ytd.FormatList {
var ret []ytd.Format
for _, format := range fl {
if test(format) {
ret = append(ret, format)
}
}
return ytd.FormatList(ret)
}
func getVideoDownloadRouteFunc() sis.RequestHandler {
ipsDownloading := make(map[string]struct{})
videoQualities := []string{"hd1080", "hd720", "medium", "tiny"}
return func(request *sis.Request) {
_, ok := ipsDownloading[request.IPHash()]
if ok {
request.TemporaryFailure("You are already downloading a video from the proxy. Please wait until that is finished before downloading another.\n")
return
}
ipsDownloading[request.IPHash()] = struct{}{}
defer func() {
delete(ipsDownloading, request.IPHash())
}()
desiredMaxQuality := request.GetParam("quality")
idStr := request.GetParam("id")
extension := path.Ext(idStr)
videoId := strings.TrimSuffix(idStr, "."+extension)
client := ytd.Client{}
client.HTTPClient = &http.Client{Transport: &http.Transport{
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ForceAttemptHTTP2: true,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
}}
video, err := client.GetVideo(videoId)
retries := 0
for err != nil {
// Try again, for a maximum of 5 times.
video, err = client.GetVideo(videoId)
retries += 1
if retries == 5 {
break
}
time.Sleep(time.Millisecond * 120)
}
// If still can't get video after 5 retries, then error out
if err != nil {
//panic(err)
request.TemporaryFailure("Error: Couldn't download video. %s\n", err.Error())
return
}
audioFormats := video.Formats.WithAudioChannels()
audioFormats.Sort()
audioFormats_mediumAudioQuality := filterYT(video.Formats, func(format ytd.Format) bool {
return format.AudioQuality == "AUDIO_QUALITY_MEDIUM"
})
audioFormats_lowAudioQuality := filterYT(video.Formats, func(format ytd.Format) bool {
return format.AudioQuality == "AUDIO_QUALITY_LOW"
})
//fmt.Printf("Formats: %v\n", audioFormats)
var format *ytd.Format = nil
skip := true
for _, quality := range videoQualities {
if quality == desiredMaxQuality {
skip = false
} else if skip {
continue
}
// Try medium audio quality first
//format = audioFormats_mediumAudioQuality.FindByQuality(quality)
var list ytd.FormatList = audioFormats_mediumAudioQuality.Quality(quality)
if len(list) <= 0 {
fmt.Printf("Could not find %s-quality video with medium audio. Trying low audio quality.\n", quality)
// If not found, then try low audio quality
//format = audioFormats_lowAudioQuality.FindByQuality(quality)
list = audioFormats_lowAudioQuality.Quality(quality)
if len(list) <= 0 {
fmt.Printf("Could not find %s-quality video with audio. Trying next quality.\n", quality)
continue
}
}
// If a format was found, break
format = &list[0]
break
}
if format == nil {
// No format was found with video and audio
fmt.Printf("Could not find any video with audio at or below desired quality of %s.\n", desiredMaxQuality)
if desiredMaxQuality != "hd720" {
request.TemporaryFailure("Video at or below desired quality of %s not found. Try a higher quality.\n", desiredMaxQuality)
return
//return c.Gemini("Error: Video At or Below Desired Quality of %s Not Found. Try a higher quality.\n%v", desiredMaxQuality, err) // TODO: Do different thing?
} else {
request.TemporaryFailure("Video with Audio not found. The video could be a future premiere or livestream.\n")
return
//return c.Gemini("Error: Video with Audio Not Found.\n%v", err) // TODO: Do different thing?
}
}
// Handle Scroll protocol Metadata
abstract := fmt.Sprintf("# %s\n%s\n", video.Title, video.Description)
// TODO: The language should be a BCP47 string
request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: video.Author, PublishDate: video.PublishDate.UTC(), Language: format.LanguageDisplayName(), Abstract: abstract})
if request.ScrollMetadataRequested() {
request.Scroll(abstract)
return
}
//format := video.Formats.AudioChannels(2).FindByQuality("hd1080")
//.DownloadSeparatedStreams(ctx, "", video, "hd1080", "mp4")
//resp, err := client.GetStream(video, format)
rc, _, err := client.GetStream(video, format)
if err != nil {
request.TemporaryFailure("Video not found.\n")
return
//return c.Gemini("Error: Video Not Found\n%v", err)
}
request.StreamBuffer(format.MimeType, rc, make([]byte, 1*1024*1024)) // 1 MB Buffer
//err2 := c.Stream(format.MimeType, rc)
rc.Close()
//url, err := client.GetStreamURL(video, format)
//return err2
}
}
func handleChannelPage(g sis.VirtualServerHandle, service *youtube.Service) {
// Channel Home
g.AddRoute("/youtube/channel/:id", func(request *sis.Request) {
template := `# Channel: %s
=> /youtube/channel/%s/videos/ All Videos
=> /youtube/channel/%s/playlists/ Playlists
=> /youtube/channel/%s/communityposts/ Community Posts
=> /youtube/channel/%s/activity/ Gemini Sub Activity Feed
## About
%s
## Recent Videos
=> /youtube/channel/%s/videos/ All Videos
`
call := service.Channels.List([]string{"id", "snippet", "contentDetails"}).Id(request.GetParam("id")).MaxResults(1)
response, err := call.Do()
if err != nil {
//log.Fatalf("Error: %v", err) // TODO
panic(err)
}
channel := response.Items[0]
// Handle Scroll Protocol Metadata
abstract := fmt.Sprintf("# Channel: %s\n%s\n", html.UnescapeString(channel.Snippet.Title), html.UnescapeString(channel.Snippet.Description))
request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: html.UnescapeString(channel.Snippet.Title), Language: channel.Snippet.DefaultLanguage, Abstract: abstract})
if request.ScrollMetadataRequested() {
request.Scroll(abstract)
return
}
request.Gemini(fmt.Sprintf(template, html.UnescapeString(channel.Snippet.Title), channel.Id, channel.Id, channel.Id, channel.Id, html.UnescapeString(channel.Snippet.Description), channel.Id))
})
// Channel Playlists
g.AddRoute("/youtube/channel/:id/playlists/:page", func(request *sis.Request) {
getChannelPlaylists(request, service, request.GetParam("id"), request.GetParam("page"))
})
g.AddRoute("/youtube/channel/:id/playlists", func(request *sis.Request) {
getChannelPlaylists(request, service, request.GetParam("id"), "")
})
// Channel Videos/Uploads
g.AddRoute("/youtube/channel/:id/videos/:page", func(request *sis.Request) {
getChannelVideos(request, service, request.GetParam("id"), request.GetParam("page"))
})
g.AddRoute("/youtube/channel/:id/videos", func(request *sis.Request) {
getChannelVideos(request, service, request.GetParam("id"), "")
})
g.AddRoute("/youtube/channel/:id/activity", func(request *sis.Request) {
getChannelActivity(request, service, request.GetParam("id"))
})
}
func handlePlaylistPage(g sis.VirtualServerHandle, service *youtube.Service) {
g.AddRoute("/youtube/playlist/:id/:page", func(request *sis.Request) {
getPlaylistVideos(request, service, request.GetParam("id"), request.GetParam("page"))
})
g.AddRoute("/youtube/playlist/:id", func(request *sis.Request) {
getPlaylistVideos(request, service, request.GetParam("id"), "")
})
}