YouTube's UULF and UU playlist prefixes for filtering Shorts are no longer working (returning 404/500 errors). Fall back to the standard channel_id= feed URL which works reliably. Shorts may appear in the feed but the widget will no longer error out.
214 lines
5.5 KiB
Go
214 lines
5.5 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 {
|
|
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
|
|
}
|