Skip to content

Commit

Permalink
Add YouTube channel tooltip support (#157)
Browse files Browse the repository at this point in the history
Co-authored-by: zneix <zneix@zneix.eu>
Co-authored-by: pajlada <rasmus.karlsson@pajlada.com>
  • Loading branch information
3 people authored Jul 31, 2021
1 parent ff9ad41 commit 9844c07
Show file tree
Hide file tree
Showing 7 changed files with 292 additions and 8 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

- Made Reddit Score field in Livestreamfails tooltip use humanized value. (#164)
- Added support for customizable oEmbed resolving for websites with the `providers.json` file. See [`data/oembed/providers.json`](data/oembed/providers.json). Three new environment variables can be set. See [`internal/resolvers/oembed/README.md`](internal/resolvers/oembed/README.md) (#139, #152)
- Added support for YouTube channel links. (#157)
- Breaking: Environment variable `CHATTERINO_API_CACHE_TWITCH_CLIENT_ID` was renamed to `CHATTERINO_API_TWITCH_CLIENT_ID`. (#144)
- Dev, Breaking: Replaced `dankeroni/gotwitch` with `nicklaw5/helix`. This change requires you to add new environment variable: `CHATTERINO_API_TWITCH_CLIENT_SECRET` - it's a client secret generated for your Twitch application.

Expand Down
84 changes: 84 additions & 0 deletions cmd/api/link_resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,90 @@ func TestResolveTwitchClip2(t *testing.T) {
}
}

func TestResolveYouTubeChannelUserStandard(t *testing.T) {
router := chi.NewRouter()
cfg := config.New()
defaultresolver.Initialize(router, cfg, nil)
ts := httptest.NewServer(router)
defer ts.Close()
fmt.Println(ts.URL)
const url = `https%3A%2F%2Fwww.youtube.com%2Fuser%2Fpenguinz0`
res, err := http.Get(ts.URL + "/link_resolver/" + url)
if err != nil {
panic(err)
}

body, err := ioutil.ReadAll(res.Body)
if err != nil {
panic(err)
}
fmt.Println(string(body))
var jsonResponse resolver.Response
err = json.Unmarshal(body, &jsonResponse)
if err != nil {
panic(err)
}
if jsonResponse.Status != 200 {
t.Fatal("wrong status from api")
}
}

func TestResolveYouTubeChannelUserShortened(t *testing.T) {
router := chi.NewRouter()
cfg := config.New()
defaultresolver.Initialize(router, cfg, nil)
ts := httptest.NewServer(router)
defer ts.Close()
fmt.Println(ts.URL)
const url = `https%3A%2F%2Fwww.youtube.com%2Fc%2FMizkifDaily`
res, err := http.Get(ts.URL + "/link_resolver/" + url)
if err != nil {
panic(err)
}

body, err := ioutil.ReadAll(res.Body)
if err != nil {
panic(err)
}
fmt.Println(string(body))
var jsonResponse resolver.Response
err = json.Unmarshal(body, &jsonResponse)
if err != nil {
panic(err)
}
if jsonResponse.Status != 200 {
t.Fatal("wrong status from api")
}
}

func TestResolveYouTubeChannelIdentifier(t *testing.T) {
router := chi.NewRouter()
cfg := config.New()
defaultresolver.Initialize(router, cfg, nil)
ts := httptest.NewServer(router)
defer ts.Close()
fmt.Println(ts.URL)
const url = `https%3A%2F%2Fwww.youtube.com%2Fchannel%2FUCoqDr5RdFOlomTQI2tkaDOA`
res, err := http.Get(ts.URL + "/link_resolver/" + url)
if err != nil {
panic(err)
}

body, err := ioutil.ReadAll(res.Body)
if err != nil {
panic(err)
}
fmt.Println(string(body))
var jsonResponse resolver.Response
err = json.Unmarshal(body, &jsonResponse)
if err != nil {
panic(err)
}
if jsonResponse.Status != 200 {
t.Fatal("wrong status from api")
}
}

func TestResolve1M(t *testing.T) {
// var resp *http.Response
// var err error
Expand Down
39 changes: 39 additions & 0 deletions internal/resolvers/youtube/channelCacheKey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package youtube

import (
"strings"
)

type channelID struct {
ID string
chanType channelType
}

// Gets the channel type from a cache key type segment - used to identify what YouTube API to use
func getChannelTypeFromString(channelType string) channelType {
switch channelType {
case "c":
return CustomChannel
case "user":
return UserChannel
case "channel":
return IdentifierChannel
}

return InvalidChannel
}

func constructCacheKeyFromChannelID(ID channelID) string {
return string(ID.chanType) + ":" + ID.ID
}

func deconstructChannelIDFromCacheKey(cacheKey string) channelID {
splitKey := strings.Split(cacheKey, ":")

if len(splitKey) < 2 {
return channelID{ID: "", chanType: InvalidChannel}
}

return channelID{ID: splitKey[1], chanType: getChannelTypeFromString(splitKey[0])}
}

28 changes: 28 additions & 0 deletions internal/resolvers/youtube/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,23 @@ package youtube
import (
"net/url"
"path"
"regexp"
"strings"
)

type channelType string

const (
// InvalidChannel channel isn't of a known type or doesn't exist
InvalidChannel channelType = ""
// UserChannel channel ID is a username
UserChannel = "user"
// IdentifierChannel channel uses the YouTube channel ID format (UC*)
IdentifierChannel = "channel"
// CustomChannel channel uses a custom URL and requires a Search call for the ID
CustomChannel = "c"
)

func getYoutubeVideoIDFromURL(url *url.URL) string {
if strings.Contains(url.Path, "embed") {
return path.Base(url.Path)
Expand All @@ -17,3 +31,17 @@ func getYoutubeVideoIDFromURL(url *url.URL) string {
func getYoutubeVideoIDFromURL2(url *url.URL) string {
return path.Base(url.Path)
}

func getYoutubeChannelIDFromURL(url *url.URL) channelID {
pattern, err := regexp.Compile(`(user|c(?:hannel)?)/([\w-]+)`)
if err != nil {
return channelID{ID: "", chanType: InvalidChannel}
}

match := pattern.FindStringSubmatch(url.Path)
if match == nil || len(match) < 3 {
return channelID{ID: "", chanType: InvalidChannel}
}

return channelID{ID: match[2], chanType: getChannelTypeFromString(match[1])}
}
96 changes: 92 additions & 4 deletions internal/resolvers/youtube/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ import (
"github.com/Chatterino/api/pkg/resolver"
)

func load(videoID string, r *http.Request) (interface{}, time.Duration, error) {
func loadVideos(videoID string, r *http.Request) (interface{}, time.Duration, error) {
youtubeVideoParts := []string{
"statistics",
"snippet",
"contentDetails",
}

log.Println("[YouTube] GET", videoID)
log.Println("[YouTube] GET video", videoID)
youtubeResponse, err := youtubeClient.Videos.List(youtubeVideoParts).Id(videoID).Do()
if err != nil {
return &resolver.Response{
Expand All @@ -39,7 +39,7 @@ func load(videoID string, r *http.Request) (interface{}, time.Duration, error) {
return &resolver.Response{Status: 500, Message: "video unavailable"}, cache.NoSpecialDur, nil
}

data := youtubeTooltipData{
data := youtubeVideoTooltipData{
Title: video.Snippet.Title,
ChannelTitle: video.Snippet.ChannelTitle,
Duration: humanize.DurationPT(video.ContentDetails.Duration),
Expand All @@ -50,7 +50,7 @@ func load(videoID string, r *http.Request) (interface{}, time.Duration, error) {
}

var tooltip bytes.Buffer
if err := youtubeTooltipTemplate.Execute(&tooltip, data); err != nil {
if err := youtubeVideoTooltipTemplate.Execute(&tooltip, data); err != nil {
return &resolver.Response{
Status: http.StatusInternalServerError,
Message: "youtube template error " + resolver.CleanResponse(err.Error()),
Expand All @@ -68,3 +68,91 @@ func load(videoID string, r *http.Request) (interface{}, time.Duration, error) {
Thumbnail: thumbnail,
}, cache.NoSpecialDur, nil
}

func loadChannels(channelCacheKey string, r *http.Request) (interface{}, time.Duration, error) {
youtubeChannelParts := []string{
"statistics",
"snippet",
}

log.Println("[YouTube] GET channel", channelCacheKey)
builtRequest := youtubeClient.Channels.List(youtubeChannelParts)

channelID := deconstructChannelIDFromCacheKey(channelCacheKey)
if channelID.chanType == CustomChannel {
// Channels with custom URLs aren't searchable with the channel/list endpoint
// The only average way to do this at the moment is to do a YouTube search of that name
// and filter for channels. Not ideal...

searchRequest := youtubeClient.Search.List([]string{"snippet"}).Q(channelID.ID).Type("channel")
response, err := searchRequest.MaxResults(1).Do()

if err != nil {
return &resolver.Response{
Status: 500,
Message: "youtube search api error " + resolver.CleanResponse(err.Error()),
}, 1 * time.Hour, nil
}

if len(response.Items) != 1 {
return nil, cache.NoSpecialDur, errors.New("channel search response is not size 1")
}

channelID.ID = response.Items[0].Snippet.ChannelId
}

switch channelID.chanType {
case UserChannel:
builtRequest = builtRequest.ForUsername(channelID.ID)
case IdentifierChannel:
builtRequest = builtRequest.Id(channelID.ID)
case CustomChannel:
builtRequest = builtRequest.Id(channelID.ID)
case InvalidChannel:
return &resolver.Response{
Status: 500,
Message: "cached channel ID is invalid",
}, 1 * time.Hour, nil
}

youtubeResponse, err := builtRequest.Do()

if err != nil {
return &resolver.Response{
Status: 500,
Message: "youtube api error " + resolver.CleanResponse(err.Error()),
}, 1 * time.Hour, nil
}

if len(youtubeResponse.Items) != 1 {
return nil, cache.NoSpecialDur, errors.New("channel response is not size 1")
}

channel := youtubeResponse.Items[0]

data := youtubeChannelTooltipData{
Title: channel.Snippet.Title,
JoinedDate: humanize.CreationDateRFC3339(channel.Snippet.PublishedAt),
Subscribers: humanize.Number(channel.Statistics.SubscriberCount),
Views: humanize.Number(channel.Statistics.ViewCount),
}

var tooltip bytes.Buffer
if err := youtubeChannelTooltipTemplate.Execute(&tooltip, data); err != nil {
return &resolver.Response{
Status: http.StatusInternalServerError,
Message: "youtube template error " + resolver.CleanResponse(err.Error()),
}, cache.NoSpecialDur, nil
}

thumbnail := channel.Snippet.Thumbnails.Default.Url
if channel.Snippet.Thumbnails.Medium != nil {
thumbnail = channel.Snippet.Thumbnails.Medium.Url
}

return &resolver.Response{
Status: http.StatusOK,
Tooltip: url.PathEscape(tooltip.String()),
Thumbnail: thumbnail,
}, cache.NoSpecialDur, nil
}
9 changes: 8 additions & 1 deletion internal/resolvers/youtube/model.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package youtube

type youtubeTooltipData struct {
type youtubeVideoTooltipData struct {
Title string
ChannelTitle string
Duration string
Expand All @@ -9,3 +9,10 @@ type youtubeTooltipData struct {
LikeCount string
DislikeCount string
}

type youtubeChannelTooltipData struct {
Title string
JoinedDate string
Subscribers string
Views string
}
Loading

0 comments on commit 9844c07

Please sign in to comment.