glance/internal/glance/widget-videos.go
2025-03-16 23:17:07 +00:00

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
}