Skip to content

Commit

Permalink
YouTube playlist resolver (#597)
Browse files Browse the repository at this point in the history
Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
  • Loading branch information
M4tthewDE and pajlada authored Mar 2, 2024
1 parent cdfe5d2 commit 9be2fb0
Show file tree
Hide file tree
Showing 9 changed files with 609 additions and 3 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Minor: Add playlist support to YouTube resolver. (#597)

## 2.0.3

- Breaking: Go version 1.20 is now the minimum required version to build this project. (#586)
Expand Down
104 changes: 104 additions & 0 deletions internal/resolvers/youtube/data_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ var (
videos = map[string]*youtubeAPI.VideoListResponse{}
channels = map[string]*youtubeAPI.ChannelListResponse{}
channelSearches = map[string]*youtubeAPI.SearchListResponse{}
playlists = map[string]*youtubeAPI.PlaylistListResponse{}
)

func init() {
Expand Down Expand Up @@ -182,4 +183,107 @@ func init() {
},
},
}

playlists["404"] = &youtubeAPI.PlaylistListResponse{
Items: []*youtubeAPI.Playlist{},
}

playlists["warframe"] = &youtubeAPI.PlaylistListResponse{
Items: []*youtubeAPI.Playlist{
{
Snippet: &youtubeAPI.PlaylistSnippet{
Title: "Cool Warframe playlist",
Description: "Very cool videos about Warframe",
ChannelTitle: "Warframe Highlights",
PublishedAt: "2020-10-12T07:20:50.52Z",
Thumbnails: &youtubeAPI.ThumbnailDetails{
Maxres: &youtubeAPI.Thumbnail{
Url: "maxres-url",
},
Default: &youtubeAPI.Thumbnail{
Url: "default-url",
},
},
},
ContentDetails: &youtubeAPI.PlaylistContentDetails{
ItemCount: 123,
},
},
},
}

playlists["warframeDefaultThumbnail"] = &youtubeAPI.PlaylistListResponse{
Items: []*youtubeAPI.Playlist{
{
Snippet: &youtubeAPI.PlaylistSnippet{
Title: "Cool Warframe playlist",
Description: "Very cool videos about Warframe",
ChannelTitle: "Warframe Highlights",
PublishedAt: "2020-10-12T07:20:50.52Z",
Thumbnails: &youtubeAPI.ThumbnailDetails{
Default: &youtubeAPI.Thumbnail{
Url: "default-url",
},
},
},
ContentDetails: &youtubeAPI.PlaylistContentDetails{
ItemCount: 123,
},
},
},
}

playlists["warframeNoThumbnail"] = &youtubeAPI.PlaylistListResponse{
Items: []*youtubeAPI.Playlist{
{
Snippet: &youtubeAPI.PlaylistSnippet{
Title: "Cool Warframe playlist",
Description: "Very cool videos about Warframe",
ChannelTitle: "Warframe Highlights",
PublishedAt: "2020-10-12T07:20:50.52Z",
Thumbnails: &youtubeAPI.ThumbnailDetails{},
},
ContentDetails: &youtubeAPI.PlaylistContentDetails{
ItemCount: 123,
},
},
},
}

playlists["warframeMultiple"] = &youtubeAPI.PlaylistListResponse{
Items: []*youtubeAPI.Playlist{
{
Snippet: &youtubeAPI.PlaylistSnippet{
Title: "Cool Warframe playlist",
Description: "Very cool videos about Warframe",
ChannelTitle: "Warframe Highlights",
PublishedAt: "2020-10-12T07:20:50.52Z",
Thumbnails: &youtubeAPI.ThumbnailDetails{
Maxres: &youtubeAPI.Thumbnail{
Url: "maxres-url",
},
},
},
ContentDetails: &youtubeAPI.PlaylistContentDetails{
ItemCount: 123,
},
},
{
Snippet: &youtubeAPI.PlaylistSnippet{
Title: "Cool Warframe playlist",
Description: "Very cool videos about Warframe",
ChannelTitle: "Warframe Highlights",
PublishedAt: "2020-10-12T07:20:50.52Z",
Thumbnails: &youtubeAPI.ThumbnailDetails{
Maxres: &youtubeAPI.Thumbnail{
Url: "maxres-url",
},
},
},
ContentDetails: &youtubeAPI.PlaylistContentDetails{
ItemCount: 123,
},
},
},
}
}
19 changes: 17 additions & 2 deletions internal/resolvers/youtube/initialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,22 @@ const (
<br><b>Subscribers:</b> {{.Subscribers}}
<br><b>Views:</b> {{.Views}}
</div>
`

youtubePlaylistTooltip = `<div style="text-align: left;">
<b>{{.Title}}</b>
<br><b>Description:</b> {{.Description}}
<br><b>Channel:</b> {{.Channel}}
<br><b>Videos:</b> {{.VideoCount}}
<br><b>Published:</b> {{.PublishedAt}}
</div>
`
)

var (
youtubeVideoTooltipTemplate = template.Must(template.New("youtubeVideoTooltip").Parse(youtubeVideoTooltip))
youtubeChannelTooltipTemplate = template.Must(template.New("youtubeChannelTooltip").Parse(youtubeChannelTooltip))
youtubeVideoTooltipTemplate = template.Must(template.New("youtubeVideoTooltip").Parse(youtubeVideoTooltip))
youtubeChannelTooltipTemplate = template.Must(template.New("youtubeChannelTooltip").Parse(youtubeChannelTooltip))
youtubePlaylistTooltipTemplate = template.Must(template.New("youtubePlaylistTooltip").Parse(youtubePlaylistTooltip))
)

func NewYouTubeVideoResolvers(ctx context.Context, cfg config.APIConfig, pool db.Pool, youtubeClient *youtubeAPI.Service) (resolver.Resolver, resolver.Resolver) {
Expand Down Expand Up @@ -69,6 +79,11 @@ func Initialize(ctx context.Context, cfg config.APIConfig, pool db.Pool, resolve
return
}

playlistResolver := NewYouTubePlaylistResolver(ctx, cfg, pool, youtubeClient)

// Handle YouTube playlists
*resolvers = append(*resolvers, playlistResolver)

// Handle YouTube channels (youtube.com/c/chan, youtube.com/chan, youtube.com/user/chan)
*resolvers = append(*resolvers, NewYouTubeChannelResolver(ctx, cfg, pool, youtubeClient))

Expand Down
2 changes: 1 addition & 1 deletion internal/resolvers/youtube/initialize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,6 @@ func TestInitialize(t *testing.T) {
customResolvers := []resolver.Resolver{}
c.Assert(customResolvers, qt.HasLen, 0)
Initialize(ctx, cfg, pool, &customResolvers)
c.Assert(customResolvers, qt.HasLen, 3)
c.Assert(customResolvers, qt.HasLen, 4)
})
}
121 changes: 121 additions & 0 deletions internal/resolvers/youtube/playlist_loader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package youtube

import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"

"github.com/Chatterino/api/internal/logger"
"github.com/Chatterino/api/internal/staticresponse"
"github.com/Chatterino/api/pkg/cache"
"github.com/Chatterino/api/pkg/humanize"
"github.com/Chatterino/api/pkg/resolver"
youtubeAPI "google.golang.org/api/youtube/v3"
)

type youtubePlaylistTooltipData struct {
Title string
Description string
Channel string
VideoCount string
PublishedAt string
}

type YouTubePlaylistLoader struct {
youtubeClient *youtubeAPI.Service
}

func getThumbnailUrl(thumbnailDetails *youtubeAPI.ThumbnailDetails) string {
if thumbnailDetails.Maxres != nil {
return thumbnailDetails.Maxres.Url
}
if thumbnailDetails.Default != nil {
return thumbnailDetails.Default.Url
}
return ""
}

func (r *YouTubePlaylistLoader) Load(ctx context.Context, playlistCacheKey string, req *http.Request) ([]byte, *int, *string, time.Duration, error) {
log := logger.FromContext(ctx)
log.Debugw("[YouTube] GET playlist",
"cacheKey", playlistCacheKey,
)

playlistId, err := getPlaylistFromCacheKey(playlistCacheKey)
if err != nil {
return resolver.InternalServerErrorf("YouTube API playlist is invalid for key: %s", playlistCacheKey)
}

youtubePlaylistParts := []string{
"snippet",
"contentDetails",
}

youtubeResponse, err := r.youtubeClient.Playlists.List(youtubePlaylistParts).Id(playlistId).Do()
if err != nil {
return resolver.InternalServerErrorf("YouTube API error: %s", err)
}

if len(youtubeResponse.Items) == 0 {
return staticresponse.NotFoundf("No YouTube playlist with the ID %s found", playlistId).
WithCacheDuration(24 * time.Hour).
Return()
}

if len(youtubeResponse.Items) > 1 {
return resolver.InternalServerErrorf("YouTube playlist response contained %d items", len(youtubeResponse.Items))
}

youtubePlaylist := youtubeResponse.Items[0]

data := youtubePlaylistTooltipData{
Title: youtubePlaylist.Snippet.Title,
Description: youtubePlaylist.Snippet.Description,
Channel: youtubePlaylist.Snippet.ChannelTitle,
VideoCount: humanize.NumberInt64(youtubePlaylist.ContentDetails.ItemCount),
PublishedAt: humanize.CreationDateRFC3339(youtubePlaylist.Snippet.PublishedAt),
}

var tooltip bytes.Buffer
if err := youtubePlaylistTooltipTemplate.Execute(&tooltip, data); err != nil {
return resolver.InternalServerErrorf("YouTube template error: %s", err.Error())
}

statusCode := http.StatusOK
contentType := "application/json"

response := &resolver.Response{
Status: statusCode,
Tooltip: tooltip.String(),
Thumbnail: getThumbnailUrl(youtubePlaylist.Snippet.Thumbnails),
}

payload, err := json.Marshal(response)
if err != nil {
return resolver.InternalServerErrorf("YouTube marshaling error: %s", err.Error())
}

return payload, &statusCode, &contentType, cache.NoSpecialDur, nil
}

func getPlaylistFromCacheKey(cacheKey string) (string, error) {
splitKey := strings.Split(cacheKey, ":")

if len(splitKey) < 2 {
return "", errors.New("invalid playlist")
}

return splitKey[1], nil
}

func NewYouTubePlaylistLoader(youtubeClient *youtubeAPI.Service) *YouTubePlaylistLoader {
loader := &YouTubePlaylistLoader{
youtubeClient: youtubeClient,
}

return loader
}
70 changes: 70 additions & 0 deletions internal/resolvers/youtube/playlist_resolver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package youtube

import (
"context"
"fmt"
"net/http"
"net/url"
"regexp"

"github.com/Chatterino/api/internal/db"
"github.com/Chatterino/api/internal/logger"
"github.com/Chatterino/api/internal/staticresponse"
"github.com/Chatterino/api/pkg/cache"
"github.com/Chatterino/api/pkg/config"
"github.com/Chatterino/api/pkg/utils"
youtubeAPI "google.golang.org/api/youtube/v3"
)

var youtubePlaylistRegex = regexp.MustCompile(`^/playlist$`)

type YouTubePlaylistResolver struct {
playlistCache cache.Cache
}

func (r *YouTubePlaylistResolver) Check(ctx context.Context, url *url.URL) (context.Context, bool) {
if !utils.IsSubdomainOf(url, "youtube.com") {
return ctx, false
}

q := url.Query()
if !q.Has("list") {
return ctx, false
}

matches := youtubePlaylistRegex.MatchString(url.Path)
return ctx, matches
}

func (r *YouTubePlaylistResolver) Run(ctx context.Context, url *url.URL, req *http.Request) (*cache.Response, error) {
log := logger.FromContext(ctx)

q := url.Query()

playlistId := q.Get("list")
if playlistId == "" {
log.Warnw("[YouTube] Failed to get playlist ID from url",
"url", url,
)

return &staticresponse.RNoLinkInfoFound, nil
}

return r.playlistCache.Get(ctx, fmt.Sprintf("playlist:%s", playlistId), req)
}

func (r *YouTubePlaylistResolver) Name() string {
return "youtube:playlist"
}

func NewYouTubePlaylistResolver(ctx context.Context, cfg config.APIConfig, pool db.Pool, youtubeClient *youtubeAPI.Service) *YouTubePlaylistResolver {
loader := NewYouTubePlaylistLoader(youtubeClient)

r := &YouTubePlaylistResolver{
playlistCache: cache.NewPostgreSQLCache(
ctx, cfg, pool, cache.NewPrefixKeyProvider("youtube:playlist"), loader, cfg.YoutubeChannelCacheDuration,
),
}

return r
}
Loading

0 comments on commit 9be2fb0

Please sign in to comment.