Skip to content
Merged
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
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/golang/mock v1.6.0
github.com/hashicorp/go-multierror v1.1.1
github.com/jessevdk/go-flags v1.6.1
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 All @@ -36,6 +37,7 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt v3.2.1+incompatible // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
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/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
Expand Down Expand Up @@ -82,6 +84,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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, downloader Do
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
141 changes: 141 additions & 0 deletions pkg/builder/twitch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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, _ := 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
}
51 changes: 51 additions & 0 deletions pkg/builder/twitch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package builder

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/mxpv/podsync/pkg/model"
)

func TestParseURL_TwitchUser(t *testing.T) {
info, err := ParseURL("https://www.twitch.tv/samueletienne")
require.NoError(t, err)
require.Equal(t, model.TypeUser, info.LinkType)
require.Equal(t, model.ProviderTwitch, info.Provider)
require.Equal(t, "samueletienne", info.ItemID)

info, err = ParseURL("https://twitch.tv/testuser")
require.NoError(t, err)
require.Equal(t, model.TypeUser, info.LinkType)
require.Equal(t, model.ProviderTwitch, info.Provider)
require.Equal(t, "testuser", info.ItemID)
}

func TestParseURL_TwitchInvalidLink(t *testing.T) {
_, err := ParseURL("https://www.twitch.tv/")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid id")

_, err = ParseURL("https://www.twitch.tv//")
require.Error(t, err)
require.Contains(t, err.Error(), "invald twitch user path")

_, err = ParseURL("https://www.twitch.tv/user/extra/path")
require.Error(t, err)
require.Contains(t, err.Error(), "invald twitch user path")
}

func TestNewTwitchBuilder_InvalidKey(t *testing.T) {
_, err := NewTwitchBuilder("invalid_key")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid twitch key")

_, err = NewTwitchBuilder("only_one_part")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid twitch key")

_, err = NewTwitchBuilder("")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid twitch key")
}
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
}
Loading