216 lines
5.7 KiB
Go
216 lines
5.7 KiB
Go
package glance
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const videosWidgetPlaylistPrefix = "playlist:"
|
|
|
|
var (
|
|
videosWidgetTemplate = mustParseTemplate("videos.html", "widget-base.html", "video-card-contents.html")
|
|
videosWidgetGridTemplate = mustParseTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
|
|
videosWidgetVerticalListTemplate = mustParseTemplate("videos-vertical-list.html", "widget-base.html")
|
|
)
|
|
|
|
type videosWidget struct {
|
|
widgetBase `yaml:",inline"`
|
|
Videos videoList `yaml:"-"`
|
|
VideoUrlTemplate string `yaml:"video-url-template"`
|
|
Style string `yaml:"style"`
|
|
CollapseAfter int `yaml:"collapse-after"`
|
|
CollapseAfterRows int `yaml:"collapse-after-rows"`
|
|
Channels []string `yaml:"channels"`
|
|
Playlists []string `yaml:"playlists"`
|
|
Limit int `yaml:"limit"`
|
|
IncludeShorts bool `yaml:"include-shorts"`
|
|
}
|
|
|
|
func (widget *videosWidget) initialize() error {
|
|
widget.withTitle("Videos").withCacheDuration(time.Hour)
|
|
|
|
if widget.Limit <= 0 {
|
|
widget.Limit = 25
|
|
}
|
|
|
|
if widget.CollapseAfterRows == 0 || widget.CollapseAfterRows < -1 {
|
|
widget.CollapseAfterRows = 4
|
|
}
|
|
|
|
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
|
widget.CollapseAfter = 7
|
|
}
|
|
|
|
// A bit cheeky, but from a user's perspective it makes more sense when channels and
|
|
// playlists are separate things rather than specifying a list of channels and some of
|
|
// them awkwardly have a "playlist:" prefix
|
|
if len(widget.Playlists) > 0 {
|
|
initialLen := len(widget.Channels)
|
|
widget.Channels = append(widget.Channels, make([]string, len(widget.Playlists))...)
|
|
|
|
for i := range widget.Playlists {
|
|
widget.Channels[initialLen+i] = videosWidgetPlaylistPrefix + widget.Playlists[i]
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (widget *videosWidget) update(ctx context.Context) {
|
|
videos, err := fetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate, widget.IncludeShorts)
|
|
|
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
|
return
|
|
}
|
|
|
|
if len(videos) > widget.Limit {
|
|
videos = videos[:widget.Limit]
|
|
}
|
|
|
|
widget.Videos = videos
|
|
}
|
|
|
|
func (widget *videosWidget) Render() template.HTML {
|
|
var template *template.Template
|
|
|
|
switch widget.Style {
|
|
case "grid-cards":
|
|
template = videosWidgetGridTemplate
|
|
case "vertical-list":
|
|
template = videosWidgetVerticalListTemplate
|
|
default:
|
|
template = videosWidgetTemplate
|
|
}
|
|
|
|
return widget.renderTemplate(widget, template)
|
|
}
|
|
|
|
type youtubeFeedResponseXml struct {
|
|
Channel string `xml:"author>name"`
|
|
ChannelLink string `xml:"author>uri"`
|
|
Videos []struct {
|
|
Title string `xml:"title"`
|
|
Published string `xml:"published"`
|
|
Link struct {
|
|
Href string `xml:"href,attr"`
|
|
} `xml:"link"`
|
|
|
|
Group struct {
|
|
Thumbnail struct {
|
|
Url string `xml:"url,attr"`
|
|
} `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
|
} `xml:"http://search.yahoo.com/mrss/ group"`
|
|
} `xml:"entry"`
|
|
}
|
|
|
|
func parseYoutubeFeedTime(t string) time.Time {
|
|
parsedTime, err := time.Parse("2006-01-02T15:04:05-07:00", t)
|
|
if err != nil {
|
|
return time.Now()
|
|
}
|
|
|
|
return parsedTime
|
|
}
|
|
|
|
type video struct {
|
|
ThumbnailUrl string
|
|
Title string
|
|
Url string
|
|
Author string
|
|
AuthorUrl string
|
|
TimePosted time.Time
|
|
}
|
|
|
|
type videoList []video
|
|
|
|
func (v videoList) sortByNewest() videoList {
|
|
sort.Slice(v, func(i, j int) bool {
|
|
return v[i].TimePosted.After(v[j].TimePosted)
|
|
})
|
|
|
|
return v
|
|
}
|
|
|
|
func fetchYoutubeChannelUploads(channelOrPlaylistIDs []string, videoUrlTemplate string, includeShorts bool) (videoList, error) {
|
|
requests := make([]*http.Request, 0, len(channelOrPlaylistIDs))
|
|
|
|
for i := range channelOrPlaylistIDs {
|
|
var feedUrl string
|
|
if strings.HasPrefix(channelOrPlaylistIDs[i], videosWidgetPlaylistPrefix) {
|
|
feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" +
|
|
strings.TrimPrefix(channelOrPlaylistIDs[i], videosWidgetPlaylistPrefix)
|
|
} else if !includeShorts && strings.HasPrefix(channelOrPlaylistIDs[i], "UC") {
|
|
playlistId := strings.Replace(channelOrPlaylistIDs[i], "UC", "UULF", 1)
|
|
feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId
|
|
} else {
|
|
feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelOrPlaylistIDs[i]
|
|
}
|
|
|
|
request, _ := http.NewRequest("GET", feedUrl, nil)
|
|
requests = append(requests, request)
|
|
}
|
|
|
|
job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultHTTPClient), requests).withWorkers(30)
|
|
responses, errs, err := workerPoolDo(job)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", errNoContent, err)
|
|
}
|
|
|
|
videos := make(videoList, 0, len(channelOrPlaylistIDs)*15)
|
|
var failed int
|
|
|
|
for i := range responses {
|
|
if errs[i] != nil {
|
|
failed++
|
|
slog.Error("Failed to fetch youtube feed", "channel", channelOrPlaylistIDs[i], "error", errs[i])
|
|
continue
|
|
}
|
|
|
|
response := responses[i]
|
|
|
|
for j := range response.Videos {
|
|
v := &response.Videos[j]
|
|
var videoUrl string
|
|
|
|
if videoUrlTemplate == "" {
|
|
videoUrl = v.Link.Href
|
|
} else {
|
|
parsedUrl, err := url.Parse(v.Link.Href)
|
|
|
|
if err == nil {
|
|
videoUrl = strings.ReplaceAll(videoUrlTemplate, "{VIDEO-ID}", parsedUrl.Query().Get("v"))
|
|
} else {
|
|
videoUrl = "#"
|
|
}
|
|
}
|
|
|
|
videos = append(videos, video{
|
|
ThumbnailUrl: v.Group.Thumbnail.Url,
|
|
Title: v.Title,
|
|
Url: videoUrl,
|
|
Author: response.Channel,
|
|
AuthorUrl: response.ChannelLink + "/videos",
|
|
TimePosted: parseYoutubeFeedTime(v.Published),
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(videos) == 0 {
|
|
return nil, errNoContent
|
|
}
|
|
|
|
videos.sortByNewest()
|
|
|
|
if failed > 0 {
|
|
return videos, fmt.Errorf("%w: missing videos from %d channels", errPartialContent, failed)
|
|
}
|
|
|
|
return videos, nil
|
|
}
|