mirror of
https://github.com/mxpv/podsync.git
synced 2024-05-11 05:55:04 +00:00
303 lines
6.6 KiB
Go
303 lines
6.6 KiB
Go
package feeds
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
itunes "github.com/mxpv/podcast"
|
|
"github.com/pkg/errors"
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/mxpv/podsync/pkg/api"
|
|
"github.com/mxpv/podsync/pkg/model"
|
|
)
|
|
|
|
type Builder interface {
|
|
Build(feed *model.Feed) error
|
|
}
|
|
|
|
type storage interface {
|
|
SaveFeed(feed *model.Feed) error
|
|
GetFeed(hashID string) (*model.Feed, error)
|
|
UpdateFeed(feed *model.Feed) error
|
|
GetMetadata(hashID string) (*model.Feed, error)
|
|
Downgrade(userID string, featureLevel int) ([]string, error)
|
|
}
|
|
|
|
type cacheService interface {
|
|
Set(key, value string, ttl time.Duration) error
|
|
Get(key string) (string, error)
|
|
Invalidate(key ...string) error
|
|
}
|
|
|
|
type Service struct {
|
|
generator IDGen
|
|
storage storage
|
|
builders map[api.Provider]Builder
|
|
cache cacheService
|
|
}
|
|
|
|
func NewFeedService(db storage, cache cacheService, builders map[api.Provider]Builder) (*Service, error) {
|
|
idGen, err := NewIDGen()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
svc := &Service{
|
|
generator: idGen,
|
|
storage: db,
|
|
builders: builders,
|
|
cache: cache,
|
|
}
|
|
|
|
return svc, nil
|
|
}
|
|
|
|
func (s *Service) CreateFeed(req *api.CreateFeedRequest, identity *api.Identity) (string, error) {
|
|
feed, err := s.makeFeed(req, identity)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Make sure builder exists for this provider
|
|
_, ok := s.builders[feed.Provider]
|
|
if !ok {
|
|
return "", fmt.Errorf("failed to get builder for URL: %s", req.URL)
|
|
}
|
|
|
|
if err := s.storage.SaveFeed(feed); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return feed.HashID, nil
|
|
}
|
|
|
|
func (s *Service) makeFeed(req *api.CreateFeedRequest, identity *api.Identity) (*model.Feed, error) {
|
|
feed, err := parseURL(req.URL)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to create feed for URL: %s", req.URL)
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
|
|
feed.UserID = identity.UserID
|
|
feed.FeatureLevel = identity.FeatureLevel
|
|
feed.Quality = req.Quality
|
|
feed.Format = req.Format
|
|
feed.PageSize = req.PageSize
|
|
feed.CreatedAt = now
|
|
feed.LastAccess = now
|
|
|
|
switch {
|
|
case identity.FeatureLevel >= api.ExtendedPagination:
|
|
if feed.PageSize > 600 {
|
|
feed.PageSize = 600
|
|
}
|
|
case identity.FeatureLevel == api.ExtendedFeatures:
|
|
if feed.PageSize > 150 {
|
|
feed.PageSize = 150
|
|
}
|
|
default:
|
|
feed.Quality = api.QualityHigh
|
|
feed.Format = api.FormatVideo
|
|
feed.PageSize = 50
|
|
}
|
|
|
|
// Generate short id
|
|
hashID, err := s.generator.Generate()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to generate id for feed")
|
|
}
|
|
|
|
feed.HashID = hashID
|
|
|
|
return feed, nil
|
|
}
|
|
|
|
func (s Service) QueryFeed(hashID string) (*model.Feed, error) {
|
|
return s.storage.GetFeed(hashID)
|
|
}
|
|
|
|
func makeEnclosure(feed *model.Feed, id string, lengthInBytes int64) (string, itunes.EnclosureType, int64) {
|
|
ext := "mp4"
|
|
contentType := itunes.MP4
|
|
if feed.Format == api.FormatAudio {
|
|
ext = "m4a"
|
|
contentType = itunes.M4A
|
|
}
|
|
|
|
url := fmt.Sprintf("http://podsync.net/download/%s/%s.%s", feed.HashID, id, ext)
|
|
return url, contentType, lengthInBytes
|
|
}
|
|
|
|
func short(str string, i int) string {
|
|
runes := []rune(str)
|
|
if len(runes) > i {
|
|
return string(runes[:i]) + " ..."
|
|
}
|
|
|
|
return str
|
|
}
|
|
|
|
func (s *Service) BuildFeed(hashID string) ([]byte, error) {
|
|
const (
|
|
cacheTTL = 30 * time.Minute
|
|
maxDescriptionLen = 384
|
|
)
|
|
|
|
cached, err := s.cache.Get(hashID)
|
|
if err == nil {
|
|
return []byte(cached), nil
|
|
}
|
|
|
|
// Query feed from DynamoDB
|
|
|
|
feed, err := s.QueryFeed(hashID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Rebuild feed using YouTube API
|
|
|
|
builder, ok := s.builders[feed.Provider]
|
|
if !ok {
|
|
return nil, errors.Wrapf(err, "failed to get builder for feed: %s", hashID)
|
|
}
|
|
|
|
log.Infof("building new feed %q", hashID)
|
|
|
|
oldLastID := feed.LastID
|
|
|
|
if err := builder.Build(feed); err != nil {
|
|
log.WithError(err).WithField("feed_id", hashID).Error("failed to build feed")
|
|
return nil, err
|
|
}
|
|
|
|
if len(feed.Episodes) > 300 {
|
|
for _, episode := range feed.Episodes {
|
|
episode.Description = short(episode.Description, maxDescriptionLen)
|
|
}
|
|
}
|
|
|
|
if oldLastID != feed.LastID {
|
|
if err := s.storage.UpdateFeed(feed); err != nil {
|
|
log.WithError(err).WithField("feed_id", hashID).Error("failed to save feed")
|
|
}
|
|
}
|
|
|
|
podcast, err := s.buildPodcast(feed)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
body := podcast.String()
|
|
|
|
// Save to cache
|
|
|
|
if err := s.cache.Set(hashID, body, cacheTTL); err != nil {
|
|
log.WithError(err).Errorf("failed to save cache for feed %q", hashID)
|
|
return nil, err
|
|
}
|
|
|
|
return []byte(body), nil
|
|
}
|
|
|
|
func (s *Service) buildPodcast(feed *model.Feed) (*itunes.Podcast, error) {
|
|
const (
|
|
podsyncGenerator = "Podsync generator"
|
|
defaultCategory = "TV & Film"
|
|
)
|
|
|
|
now := time.Now().UTC()
|
|
|
|
p := itunes.New(feed.Title, feed.ItemURL, feed.Description, &feed.PubDate, &now)
|
|
p.Generator = podsyncGenerator
|
|
p.AddSubTitle(feed.Title)
|
|
p.AddCategory(defaultCategory, nil)
|
|
p.AddImage(feed.CoverArt)
|
|
p.IAuthor = feed.Title
|
|
p.AddSummary(feed.Description)
|
|
|
|
if feed.Explicit {
|
|
p.IExplicit = "yes"
|
|
} else {
|
|
p.IExplicit = "no"
|
|
}
|
|
|
|
if feed.Language != "" {
|
|
p.Language = feed.Language
|
|
}
|
|
|
|
for i, episode := range feed.Episodes {
|
|
item := itunes.Item{
|
|
GUID: episode.ID,
|
|
Link: episode.VideoURL,
|
|
Title: episode.Title,
|
|
Description: episode.Description,
|
|
ISubtitle: episode.Title,
|
|
IOrder: strconv.Itoa(i),
|
|
}
|
|
|
|
item.AddSummary(episode.Description)
|
|
item.AddImage(episode.Thumbnail)
|
|
item.AddPubDate(&episode.PubDate)
|
|
item.AddDuration(episode.Duration)
|
|
item.AddEnclosure(makeEnclosure(feed, episode.ID, episode.Size))
|
|
|
|
// p.AddItem requires description to be not empty, use workaround
|
|
if item.Description == "" {
|
|
item.Description = " "
|
|
}
|
|
|
|
if feed.Explicit {
|
|
item.IExplicit = "yes"
|
|
} else {
|
|
item.IExplicit = "no"
|
|
}
|
|
|
|
_, err := p.AddItem(item)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to add item to podcast (id %q)", episode.ID)
|
|
}
|
|
}
|
|
|
|
return &p, nil
|
|
}
|
|
|
|
func (s Service) GetMetadata(hashID string) (*api.Metadata, error) {
|
|
feed, err := s.storage.GetMetadata(hashID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &api.Metadata{
|
|
Provider: feed.Provider,
|
|
Format: feed.Format,
|
|
Quality: feed.Quality,
|
|
}, nil
|
|
}
|
|
|
|
func (s Service) Downgrade(patronID string, featureLevel int) error {
|
|
logger := log.WithFields(log.Fields{
|
|
"user_id": patronID,
|
|
"level": featureLevel,
|
|
})
|
|
|
|
logger.Info("downgrading patron")
|
|
|
|
ids, err := s.storage.Downgrade(patronID, featureLevel)
|
|
if err != nil {
|
|
logger.WithError(err).Error("database error while downgrading patron")
|
|
return err
|
|
}
|
|
|
|
if s.cache.Invalidate(ids...) != nil {
|
|
logger.WithError(err).Error("failed to invalidate cached feeds")
|
|
return err
|
|
}
|
|
|
|
logger.Info("successfully updated user")
|
|
return nil
|
|
}
|