Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Twitch channels #438

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/golang/mock v1.6.0
github.com/hashicorp/go-multierror v1.1.1
github.com/jessevdk/go-flags v1.5.0
github.com/nicklaw5/helix v1.25.0
github.com/pelletier/go-toml v1.9.5
github.com/pkg/errors v0.9.1
github.com/robfig/cron/v3 v3.0.1
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ github.com/eduncan911/podcast v1.4.2/go.mod h1:mSxiK1z5KeNO0YFaQ3ElJlUZbbDV9dA7R
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gilliek/go-opml v1.0.0 h1:X8xVjtySRXU/x6KvaiXkn7OV3a4DHqxY8Rpv6U/JvCY=
github.com/gilliek/go-opml v1.0.0/go.mod h1:fOxmtlzyBvUjU6bjpdjyxCGlWz+pgtAHrHf/xRZl3lk=
github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c=
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
Expand All @@ -57,6 +59,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/nicklaw5/helix v1.25.0 h1:Mrz537izZVsGdM3I46uGAAlslj61frgkhS/9xQqyT/M=
github.com/nicklaw5/helix v1.25.0/go.mod h1:yvXZFapT6afIoxnAvlWiJiUMsYnoHl7tNs+t0bloAMw=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
Expand Down
2 changes: 2 additions & 0 deletions pkg/builder/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ func New(ctx context.Context, provider model.Provider, key string) (Builder, err
return NewVimeoBuilder(ctx, key)
case model.ProviderSoundcloud:
return NewSoundcloudBuilder()
case model.ProviderTwitch:
return NewTwitchBuilder(key)
default:
return nil, errors.Errorf("unsupported provider %q", provider)
}
Expand Down
146 changes: 146 additions & 0 deletions pkg/builder/twitch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package builder

import (
"context"
"fmt"
"strings"
"time"

"github.com/mxpv/podsync/pkg/feed"
"github.com/mxpv/podsync/pkg/model"
"github.com/nicklaw5/helix"
"github.com/pkg/errors"
)

type TwitchBuilder struct {
client *helix.Client
}

func (t *TwitchBuilder) Build(_ctx context.Context, cfg *feed.Config) (*model.Feed, error) {
info, err := ParseURL(cfg.URL)
if err != nil {
return nil, errors.Wrap(err, "failed to parse URL")
}

feed := &model.Feed{
ItemID: info.ItemID,
Provider: info.Provider,
LinkType: info.LinkType,
Format: cfg.Format,
Quality: cfg.Quality,
PageSize: cfg.PageSize,
UpdatedAt: time.Now().UTC(),
}

if info.LinkType == model.TypeUser {

users, err := t.client.GetUsers(&helix.UsersParams{
Logins: []string{info.ItemID},
})
if err != nil {
return nil, errors.Wrapf(err, "failed to get user: %s", info.ItemID)
}
user := users.Data.Users[0]

feed.Title = user.DisplayName
feed.Author = user.DisplayName
feed.Description = user.Description
feed.ItemURL = fmt.Sprintf("https://www.twitch.tv/%s", user.Login)
feed.CoverArt = user.ProfileImageURL
feed.PubDate = user.CreatedAt.Time

isStreaming := false
streamID := ""
streams, err := t.client.GetStreams(&helix.StreamsParams{
UserIDs: []string{user.ID},
})
if len(streams.Data.Streams) > 0 {
isStreaming = true
streamID = streams.Data.Streams[0].ID
}

videos, err := t.client.GetVideos(&helix.VideosParams{
UserID: user.ID,
Period: "all",
Type: "archive",
Sort: "time",
First: 100,
})
if err != nil {
return nil, errors.Wrapf(err, "failed to get videos for user: %s", info.ItemID)
}

var added = 0
for _, video := range videos.Data.Videos {

// Do not add the video of an ongoing stream because it will be incomplete
if !isStreaming || video.StreamID != streamID {

date, err := time.Parse(time.RFC3339, video.PublishedAt)
if err != nil {
return nil, errors.Wrapf(err, "cannot parse PublishedAt time: %s", video.PublishedAt)
}

replacer := strings.NewReplacer("%{width}", "300", "%{height}", "300")
thumbnailUrl := replacer.Replace(video.ThumbnailURL)

duration, err := time.ParseDuration(video.Duration)
if err != nil {
return nil, errors.Wrapf(err, "cannot parse duration: %s", video.Duration)
}
durationSeconds := int64(duration.Seconds())

feed.Episodes = append(feed.Episodes, &model.Episode{
ID: video.ID,
Title: fmt.Sprintf("%s (%s)", video.Title, date.Format("2006-01-02 15:04 UTC")),
Description: video.Description,
Thumbnail: thumbnailUrl,
Duration: durationSeconds,
Size: durationSeconds * 33013, // Very rough estimate
VideoURL: video.URL,
PubDate: date,
Status: model.EpisodeNew,
})

added++
if added >= feed.PageSize {
return feed, nil
}
}

}

return feed, nil

}

return nil, errors.New("unsupported feed type")
}

func NewTwitchBuilder(clientIDSecret string) (*TwitchBuilder, error) {
parts := strings.Split(clientIDSecret, ":")
if len(parts) != 2 {
return nil, errors.New("invalid twitch key, need to be \"CLIENT_ID:CLIENT_SECRET\"")
}

clientID := parts[0]
clientSecret := parts[1]

client, err := helix.NewClient(&helix.Options{
ClientID: clientID,
ClientSecret: clientSecret,
})
if err != nil {
return nil, errors.Wrap(err, "failed to create twitch client")
}

token, err := client.RequestAppAccessToken([]string{})
if err != nil {
return nil, errors.Wrap(err, "failed to request twitch app token")
}

// Set the access token on the client
client.SetAppAccessToken(token.Data.AccessToken)

return &TwitchBuilder{client: client}, nil
}
31 changes: 31 additions & 0 deletions pkg/builder/url.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,19 @@ func ParseURL(link string) (model.Info, error) {
return info, nil
}

if strings.HasSuffix(parsed.Host, "twitch.tv") {
kind, id, err := parseTwitchURL(parsed)
if err != nil {
return model.Info{}, err
}

info.Provider = model.ProviderTwitch
info.LinkType = kind
info.ItemID = id

return info, nil
}

return model.Info{}, errors.New("unsupported URL host")
}

Expand Down Expand Up @@ -186,3 +199,21 @@ func parseSoundcloudURL(parsed *url.URL) (model.Type, string, error) {

return kind, id, nil
}

func parseTwitchURL(parsed *url.URL) (model.Type, string, error) {
// - https://www.twitch.tv/samueletienne
path := parsed.EscapedPath()
parts := strings.Split(path, "/")
if len(parts) != 2 {
return "", "", errors.Errorf("invald twitch user path: %s", path)
}

kind := model.TypeUser

id := parts[1]
if id == "" {
return "", "", errors.New("invalid id")
}

return kind, id, nil
}
3 changes: 2 additions & 1 deletion pkg/model/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ const (
ProviderYoutube = Provider("youtube")
ProviderVimeo = Provider("vimeo")
ProviderSoundcloud = Provider("soundcloud")
ProviderTwitch = Provider("twitch")
)

// Info represents data extracted from URL
type Info struct {
LinkType Type // Either group, channel or user
Provider Provider // Youtube, Vimeo, or SoundCloud
Provider Provider // Youtube, Vimeo, SoundCloud or Twitch
ItemID string
}