Skip to content

Commit

Permalink
feat(youtube): Allow ignoring posts of certain types
Browse files Browse the repository at this point in the history
  • Loading branch information
marvin-roesch committed Jan 20, 2024
1 parent 9c7b6a9 commit 59fdf59
Show file tree
Hide file tree
Showing 2 changed files with 100 additions and 51 deletions.
36 changes: 28 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,25 +193,45 @@ Any tweet and retweet that has been posted since the offset and is not from an e

### YouTube Feed (`youtube`)
Checks a YouTube channel's atom feed (see e.g. [Brandon Sanderson's channel](https://www.youtube.com/feeds/videos.xml?channel_id=UC3g-w83Cb5pEAu5UmRrge-A))
for new videos and livestreams. If no starting offset is specified, all videos currently in the feed will be posted.
for new videos and livestreams. If no starting offset is specified, all videos currently in the feed will be posted.

Note that this requires access to the YouTube API for identifying livestreams and related data like scheduled start times, to this end, you need to acquire an API token for the YouTube Data API.

#### Configuration
The YAML structure for this plugin's configuration is as follows:
```yaml
channelId: ChannelId
token: youtubeToken
nickname: Brandon
message: Brandon posted on YouTube
messages:
video: Brandon posted a video on YouTube
livestream: Brandon will be streaming live %s
excludedPostTypes:
- short
```
| Field | Mandatory | Description |
|---------------------|:---------:|-------------------------------------------------------------|
| `channelId` | ✔️ | The *ID* of the YouTube channel for which to check the feed |
| `nickname` | ❌ | Nickname for the YouTube channel to use in Discord messages |
| `message` | ❌ | Custom message to display for new videos |
| Field | Mandatory | Description |
|---------------------|:---------:|----------------------------------------------------------------------------------------------|
| `channelId` | ✔️ | The *ID* of the YouTube channel for which to check the feed |
| `token` | ✔️ | Token for the YouTube Data API v3 |
| `nickname` | ❌ | Nickname for the YouTube channel to use in Discord messages |
| `messages` | ❌ | A dictionary where keys represent the post type and values are custom messages for that type |
| `excludedPostTypes` | ❌ | A list of post types from the feed not to report |

Note that the *ID* of the channel is required here, which can differ from the username visible in a channel's URL.
A channel ID can be retrieved from a channel page's source code.

If `nickname` and `message` are all omitted, the channel name for the YouTube channel will be used in a standard message.
If `nickname` and `messages` are all omitted, the channel name for the YouTube channel will be used in a standard message.

Both `messages` and `excludedPostTypes` support several different post types, namely `short`, `livestream`, `premiere`, and `video`.
The latter is used by default if no other type could be identified.
The messages for `livestream` and `premiere` can use `%s` within their definition as a placeholder for a relative timestamp in the Discord message.

#### Acquiring an API token
Getting access to the YouTube Data API, like most other Google services, requires a Google Cloud project.
See the [official guide](https://developers.google.com/workspace/guides/create-project) for setting that up.

Within your project's Cloud Console, you must enable the [YouTube Data API v3](https://console.cloud.google.com/apis/api/youtube.googleapis.com).
Then you can create [API key credentials](https://console.cloud.google.com/apis/api/youtube.googleapis.com/credentials) for that API, which will be the token you need to specify in the config.

#### Offset format
Offsets are stored as a JSON object such as
Expand Down
115 changes: 72 additions & 43 deletions plugins/youtube.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,21 @@ import (
)

type YouTubePlugin struct {
ChannelId string `mapstructure:"channelId"`
Nickname string
Messages map[string]string
Token string
client *http.Client
ChannelId string `mapstructure:"channelId"`
Nickname string
Messages map[string]string
Token string
ExcludedPostTypes []string `mapstructure:"excludedPostTypes"`

excludedTypes map[string]bool
client *http.Client
}

func (plugin YouTubePlugin) Name() string {
func (plugin *YouTubePlugin) Name() string {
return "youtube"
}

func (plugin YouTubePlugin) Validate() error {
func (plugin *YouTubePlugin) Validate() error {
if len(plugin.ChannelId) == 0 {
return fmt.Errorf("channel ID for YouTube must not be empty")
}
Expand All @@ -31,10 +34,15 @@ func (plugin YouTubePlugin) Validate() error {
return fmt.Errorf("either a channel nickname or a YouTube post message must be given")
}

plugin.excludedTypes = make(map[string]bool)
for _, postType := range plugin.ExcludedPostTypes {
plugin.excludedTypes[postType] = true
}

return nil
}

func (plugin YouTubePlugin) OffsetPrototype() interface{} {
func (plugin *YouTubePlugin) OffsetPrototype() interface{} {
return map[string]bool{}
}

Expand All @@ -45,7 +53,7 @@ type YouTubePost struct {
VideoID string
}

func (plugin YouTubePlugin) Check(offset interface{}, context PluginContext) (interface{}, error) {
func (plugin *YouTubePlugin) Check(offset interface{}, context PluginContext) (interface{}, error) {
context.Info.Println("Checking for YouTube updates...")

plugin.client = &http.Client{
Expand Down Expand Up @@ -124,27 +132,26 @@ func (plugin YouTubePlugin) Check(offset interface{}, context PluginContext) (in
}

for _, entry := range sortedEntries {
message := fmt.Sprintf("%s posted something on YouTube", plugin.Nickname)
if configMessage, exists := plugin.Messages["video"]; exists {
message = configMessage
}

shortMessage, err := plugin.getShortMessage(entry)
info, err := plugin.buildPostInfo(entry, youtubeService)
if err != nil {
return nil, err
return handledEntries, err
}

if shortMessage != nil {
message = *shortMessage
if exclude, present := plugin.excludedTypes[info.Type]; present && exclude {
context.Info.Printf("Ignoring YouTube %s '%s'", info.Type, entry.Title)
handledEntries[entry.ID] = true

continue
}

liveEventMessage, err := plugin.getLiveEventMessage(entry, youtubeService, context)
if err != nil {
return nil, err
template := info.DefaultTemplate
if configTemplate, exists := plugin.Messages[info.Type]; exists {
template = configTemplate
}

if liveEventMessage != nil {
message = *liveEventMessage
message := template
if info.FormatMessage != nil {
message = info.FormatMessage(template)
}

if err = context.Discord.Send(
Expand All @@ -158,17 +165,42 @@ func (plugin YouTubePlugin) Check(offset interface{}, context PluginContext) (in

handledEntries[entry.ID] = true

context.Info.Println("Reported YouTube post", entry.Title)
context.Info.Printf("Reported YouTube post '%s'", entry.Title)
}

return handledEntries, nil
}

func (plugin YouTubePlugin) getLiveEventMessage(entry YouTubePost, youtubeService *youtube.Service, context PluginContext) (*string, error) {
type postInfo struct {
Type string
DefaultTemplate string
FormatMessage func(string) string
}

func (plugin *YouTubePlugin) buildPostInfo(entry YouTubePost, youtubeService *youtube.Service) (*postInfo, error) {
if entry.VideoID == "" {
return nil, nil
}

info, err := plugin.buildLiveEventInfo(entry, youtubeService)
if info != nil || err != nil {
return info, err
}

info, err = plugin.buildShortInfo(entry)
if info != nil || err != nil {
return info, err
}

info = &postInfo{
Type: "video",
DefaultTemplate: fmt.Sprintf("%s posted something on YouTube", plugin.Nickname),
}

return info, nil
}

func (plugin *YouTubePlugin) buildLiveEventInfo(entry YouTubePost, youtubeService *youtube.Service) (*postInfo, error) {
videoList, err := youtubeService.Videos.List([]string{"liveStreamingDetails", "status"}).Id(entry.VideoID).Do()

if err != nil {
Expand All @@ -194,26 +226,23 @@ func (plugin YouTubePlugin) getLiveEventMessage(entry YouTubePost, youtubeServic
return nil, nil
}

videoType := "livestream"
template := fmt.Sprintf("%s is going live on YouTube %%s!", plugin.Nickname)
if video.Status.UploadStatus == "processed" {
videoType = "premiere"
template = fmt.Sprintf("%s will premiere a video on YouTube %%s!", plugin.Nickname)
info := postInfo{
Type: "livestream",
DefaultTemplate: fmt.Sprintf("%s is going live on YouTube %%s!", plugin.Nickname),
FormatMessage: func(template string) string {
return fmt.Sprintf(template, fmt.Sprintf("<t:%d:R>", parsedStart.Unix()))
},
}

if configMessage, exists := plugin.Messages[videoType]; exists {
template = configMessage
if video.Status.UploadStatus == "processed" {
info.Type = "premiere"
info.DefaultTemplate = fmt.Sprintf("%s will premiere a video on YouTube %%s!", plugin.Nickname)
}

result := fmt.Sprintf(template, fmt.Sprintf("<t:%d:R>", parsedStart.Unix()))
return &result, nil
return &info, nil
}

func (plugin YouTubePlugin) getShortMessage(entry YouTubePost) (*string, error) {
if entry.VideoID == "" {
return nil, nil
}

func (plugin *YouTubePlugin) buildShortInfo(entry YouTubePost) (*postInfo, error) {
response, err := plugin.client.Head(fmt.Sprintf("https://www.youtube.com/shorts/%s", entry.VideoID))

if err != nil {
Expand All @@ -224,10 +253,10 @@ func (plugin YouTubePlugin) getShortMessage(entry YouTubePost) (*string, error)
return nil, nil
}

message := fmt.Sprintf("%s posted a short on YouTube!", plugin.Nickname)
if configMessage, exists := plugin.Messages["short"]; exists {
message = configMessage
info := postInfo{
Type: "short",
DefaultTemplate: fmt.Sprintf("%s posted a short on YouTube!", plugin.Nickname),
}

return &message, nil
return &info, nil
}

0 comments on commit 59fdf59

Please sign in to comment.