218 lines
5.7 KiB
Go
218 lines
5.7 KiB
Go
package rss
|
||
|
||
import (
|
||
"context"
|
||
"encoding/xml"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
"videoweb/app/service/configser"
|
||
"videoweb/pub/common/log"
|
||
"videoweb/pub/common/netutil"
|
||
"videoweb/pub/common/utils"
|
||
"videoweb/pub/models/cfg"
|
||
"videoweb/pub/models/videoinformationmod"
|
||
"videoweb/pub/pubdef"
|
||
|
||
"go.mongodb.org/mongo-driver/bson"
|
||
"go.mongodb.org/mongo-driver/mongo/options"
|
||
)
|
||
|
||
const (
|
||
maxRSSItems = 200 // RSS 条目上限
|
||
)
|
||
|
||
var generateMu sync.Mutex
|
||
|
||
// RSS 2.0 结构定义
|
||
type rssFeed struct {
|
||
XMLName xml.Name `xml:"rss"`
|
||
Version string `xml:"version,attr"`
|
||
Atom string `xml:"xmlns:atom,attr"`
|
||
Channel rssChannel `xml:"channel"`
|
||
}
|
||
|
||
type rssChannel struct {
|
||
Title string `xml:"title"`
|
||
Link string `xml:"link"`
|
||
Description string `xml:"description"`
|
||
Language string `xml:"language"`
|
||
LastBuildDate string `xml:"lastBuildDate"`
|
||
AtomLink atomLink `xml:"atom:link"`
|
||
Items []rssItem `xml:"item"`
|
||
}
|
||
|
||
type atomLink struct {
|
||
Href string `xml:"href,attr"`
|
||
Rel string `xml:"rel,attr"`
|
||
Type string `xml:"type,attr"`
|
||
}
|
||
|
||
type rssItem struct {
|
||
Title string `xml:"title"`
|
||
Link string `xml:"link"`
|
||
Description string `xml:"description"`
|
||
PubDate string `xml:"pubDate"`
|
||
GUID string `xml:"guid"`
|
||
Enclosure *rssEnclosure `xml:"enclosure,omitempty"`
|
||
}
|
||
|
||
type rssEnclosure struct {
|
||
URL string `xml:"url,attr"`
|
||
Type string `xml:"type,attr"`
|
||
Length string `xml:"length,attr"`
|
||
}
|
||
|
||
// GenerateRSS 生成 rss.xml,输出到 outDir/rss.xml(Web 侧通过 /rss.xml 静态暴露)
|
||
func GenerateRSS(ctx context.Context, outDir string) {
|
||
generateMu.Lock()
|
||
defer generateMu.Unlock()
|
||
|
||
scfg, srvErr := configser.GetSystemConfig(ctx)
|
||
if srvErr != nil || scfg == nil {
|
||
log.Error("GenerateRSS GetSystemConfig", log.Any("err", srvErr))
|
||
return
|
||
}
|
||
|
||
// 查询最新的已上架视频
|
||
filter := bson.M{
|
||
videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK,
|
||
}
|
||
opt := options.Find().
|
||
SetSort(bson.M{videoinformationmod.FN.VideoInformation.AddedTime: -1}).
|
||
SetLimit(int64(maxRSSItems))
|
||
videoList, vsErr := videoinformationmod.FindManyVideoInformations(ctx, filter, opt)
|
||
if vsErr != nil {
|
||
log.Error("GenerateRSS FindManyVideoInformations", log.Any("err", vsErr))
|
||
return
|
||
}
|
||
|
||
items := buildRSSItems(scfg, videoList)
|
||
feedURL := utils.JoinUrlPath(scfg.WebAddr, "/rss.xml")
|
||
feed := rssFeed{
|
||
Version: "2.0",
|
||
Atom: "http://www.w3.org/2005/Atom",
|
||
Channel: rssChannel{
|
||
Title: scfg.WebTitle,
|
||
Link: scfg.WebAddr,
|
||
Description: scfg.WebDescription,
|
||
Language: "zh-cn",
|
||
LastBuildDate: time.Now().Format(time.RFC1123Z),
|
||
AtomLink: atomLink{
|
||
Href: feedURL,
|
||
Rel: "self",
|
||
Type: "application/rss+xml",
|
||
},
|
||
Items: items,
|
||
},
|
||
}
|
||
|
||
const filename = "rss.xml"
|
||
if err := writeRSSFile(outDir, filename, feed); err != nil {
|
||
log.Error("GenerateRSS write", log.Any("err", err))
|
||
return
|
||
}
|
||
|
||
// 写入成功,rename .tmp → 正式文件
|
||
src := filepath.Join(outDir, filename+".tmp")
|
||
dst := filepath.Join(outDir, filename)
|
||
if err := os.Rename(src, dst); err != nil {
|
||
log.Error("GenerateRSS rename", log.Any("err", err))
|
||
os.Remove(src)
|
||
return
|
||
}
|
||
|
||
log.Info("GenerateRSS done", log.Any("items", len(items)))
|
||
}
|
||
|
||
// absModIndex 根据 id 确定性选取切片索引,保证结果非负
|
||
func absModIndex(id int64, length int) int {
|
||
idx := id % int64(length)
|
||
if idx < 0 {
|
||
idx = -idx
|
||
}
|
||
return int(idx)
|
||
}
|
||
|
||
func buildRSSItems(scfg *cfg.SysCfg, videoList videoinformationmod.VideoInformationList) []rssItem {
|
||
logoURL := utils.JoinUrlPath(scfg.WebAddr, "/static/images/logo.png")
|
||
|
||
items := make([]rssItem, 0, len(videoList))
|
||
for _, v := range videoList {
|
||
desc := v.Desc
|
||
if desc == "" {
|
||
desc = v.Title
|
||
}
|
||
|
||
pageURL := utils.JoinUrlPathKeepSlash(scfg.WebAddr, fmt.Sprintf("/video/detail?id=%d", v.Id))
|
||
|
||
pubDate := ""
|
||
if !v.AddedTime.IsZero() {
|
||
pubDate = v.AddedTime.Format(time.RFC1123Z)
|
||
}
|
||
|
||
item := rssItem{
|
||
Title: v.Title,
|
||
Link: pageURL,
|
||
Description: desc,
|
||
PubDate: pubDate,
|
||
GUID: pageURL,
|
||
}
|
||
|
||
// 封面图优先级:UnencryptedImage > SitemapDefaultImages 确定性选取 > logo
|
||
encURL := ""
|
||
encType := "image/jpeg"
|
||
if v.UnencryptedImage != "" && scfg.UnencryptedCdn != "" {
|
||
encURL = netutil.MergeURL(scfg.UnencryptedCdn, v.UnencryptedImage)
|
||
} else if len(scfg.SitemapDefaultImages) > 0 && scfg.UnencryptedCdn != "" {
|
||
img := scfg.SitemapDefaultImages[absModIndex(v.Id, len(scfg.SitemapDefaultImages))]
|
||
encURL = netutil.MergeURL(scfg.UnencryptedCdn, img)
|
||
} else {
|
||
encURL = logoURL
|
||
encType = "image/png"
|
||
}
|
||
if strings.HasSuffix(encURL, ".png") {
|
||
encType = "image/png"
|
||
} else if strings.HasSuffix(encURL, ".webp") {
|
||
encType = "image/webp"
|
||
}
|
||
item.Enclosure = &rssEnclosure{
|
||
URL: encURL,
|
||
Type: encType,
|
||
Length: "0",
|
||
}
|
||
|
||
items = append(items, item)
|
||
}
|
||
return items
|
||
}
|
||
|
||
// writeRSSFile 将数据写入 rssDir/filename.tmp,由调用方负责 rename
|
||
func writeRSSFile(rssDir, filename string, data interface{}) error {
|
||
if err := os.MkdirAll(rssDir, 0755); err != nil {
|
||
return fmt.Errorf("mkdir %s: %w", rssDir, err)
|
||
}
|
||
path := filepath.Join(rssDir, filename+".tmp")
|
||
file, err := os.Create(path)
|
||
if err != nil {
|
||
return fmt.Errorf("create %s: %w", filename, err)
|
||
}
|
||
defer file.Close()
|
||
|
||
if _, err := file.WriteString(xml.Header); err != nil {
|
||
return fmt.Errorf("write header %s: %w", filename, err)
|
||
}
|
||
encoder := xml.NewEncoder(file)
|
||
encoder.Indent("", " ")
|
||
if err := encoder.Encode(data); err != nil {
|
||
return fmt.Errorf("encode %s: %w", filename, err)
|
||
}
|
||
if err := encoder.Flush(); err != nil {
|
||
return fmt.Errorf("flush %s: %w", filename, err)
|
||
}
|
||
return file.Sync()
|
||
}
|