2019-10-29 14:38:29 -07:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"fmt"
|
|
|
|
"io/ioutil"
|
2019-11-13 18:16:35 -08:00
|
|
|
"os"
|
2019-10-29 14:38:29 -07:00
|
|
|
"path/filepath"
|
|
|
|
"strconv"
|
2019-11-15 10:57:55 -08:00
|
|
|
"strings"
|
2019-10-29 14:38:29 -07:00
|
|
|
"time"
|
|
|
|
|
|
|
|
itunes "github.com/mxpv/podcast"
|
|
|
|
"github.com/pkg/errors"
|
2019-10-30 00:13:02 -07:00
|
|
|
log "github.com/sirupsen/logrus"
|
2019-10-29 14:38:29 -07:00
|
|
|
|
|
|
|
"github.com/mxpv/podsync/pkg/config"
|
2019-12-21 14:15:07 -08:00
|
|
|
"github.com/mxpv/podsync/pkg/feed"
|
2019-10-29 14:38:29 -07:00
|
|
|
"github.com/mxpv/podsync/pkg/link"
|
|
|
|
"github.com/mxpv/podsync/pkg/model"
|
2019-12-01 15:19:08 -08:00
|
|
|
"github.com/mxpv/podsync/pkg/storage"
|
2019-10-29 14:38:29 -07:00
|
|
|
)
|
|
|
|
|
2019-11-13 18:16:35 -08:00
|
|
|
type Downloader interface {
|
2019-11-27 21:07:39 -08:00
|
|
|
Download(ctx context.Context, feedConfig *config.Feed, episode *model.Episode, feedPath string) (string, error)
|
2019-11-13 18:16:35 -08:00
|
|
|
}
|
|
|
|
|
2019-10-29 14:38:29 -07:00
|
|
|
type Updater struct {
|
2019-11-13 18:16:35 -08:00
|
|
|
config *config.Config
|
|
|
|
downloader Downloader
|
2019-12-01 15:19:08 -08:00
|
|
|
db storage.Storage
|
2019-10-29 14:38:29 -07:00
|
|
|
}
|
|
|
|
|
2019-12-01 15:19:08 -08:00
|
|
|
func NewUpdater(config *config.Config, downloader Downloader, db storage.Storage) (*Updater, error) {
|
|
|
|
return &Updater{
|
|
|
|
config: config,
|
|
|
|
downloader: downloader,
|
|
|
|
db: db,
|
|
|
|
}, nil
|
2019-10-29 14:38:29 -07:00
|
|
|
}
|
|
|
|
|
2019-11-13 18:16:35 -08:00
|
|
|
func (u *Updater) Update(ctx context.Context, feedConfig *config.Feed) error {
|
2019-10-30 00:13:02 -07:00
|
|
|
log.WithFields(log.Fields{
|
2019-11-13 18:16:35 -08:00
|
|
|
"feed_id": feedConfig.ID,
|
|
|
|
"format": feedConfig.Format,
|
|
|
|
"quality": feedConfig.Quality,
|
|
|
|
}).Infof("-> updating %s", feedConfig.URL)
|
2019-10-30 00:13:02 -07:00
|
|
|
started := time.Now()
|
|
|
|
|
2019-11-13 18:16:35 -08:00
|
|
|
// Make sure feed directory exists
|
|
|
|
feedPath := filepath.Join(u.config.Server.DataDir, feedConfig.ID)
|
|
|
|
log.Debugf("creating directory for feed %q", feedPath)
|
|
|
|
if err := os.MkdirAll(feedPath, 0755); err != nil {
|
|
|
|
return errors.Wrapf(err, "failed to create directory for feed %q", feedConfig.ID)
|
|
|
|
}
|
|
|
|
|
2019-12-01 15:19:08 -08:00
|
|
|
if err := u.updateFeed(ctx, feedConfig); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := u.downloadEpisodes(ctx, feedConfig, feedPath); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := u.buildXML(ctx, feedConfig); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
elapsed := time.Since(started)
|
|
|
|
nextUpdate := time.Now().Add(feedConfig.UpdatePeriod.Duration)
|
|
|
|
log.Infof("successfully updated feed in %s, next update at %s", elapsed, nextUpdate.Format(time.Kitchen))
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// updateFeed pulls API for new episodes and saves them to database
|
|
|
|
func (u *Updater) updateFeed(ctx context.Context, feedConfig *config.Feed) error {
|
2019-10-29 14:38:29 -07:00
|
|
|
// Create an updater for this feed type
|
2019-11-13 18:16:35 -08:00
|
|
|
provider, err := u.makeBuilder(ctx, feedConfig)
|
2019-10-29 14:38:29 -07:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Query API to get episodes
|
2019-10-30 00:13:02 -07:00
|
|
|
log.Debug("building feed")
|
2019-11-13 18:16:35 -08:00
|
|
|
result, err := provider.Build(ctx, feedConfig)
|
2019-10-29 14:38:29 -07:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2019-10-30 00:13:02 -07:00
|
|
|
log.Debugf("received %d episode(s) for %q", len(result.Episodes), result.Title)
|
|
|
|
|
2019-12-01 16:01:36 -08:00
|
|
|
if err := u.db.AddFeed(ctx, feedConfig.ID, result); err != nil {
|
2019-12-01 15:19:08 -08:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
log.Debug("successfully saved updates to storage")
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (u *Updater) downloadEpisodes(ctx context.Context, feedConfig *config.Feed, targetDir string) error {
|
|
|
|
var (
|
2020-01-25 15:58:41 -08:00
|
|
|
feedID = feedConfig.ID
|
|
|
|
downloadList []*model.Episode
|
2019-12-01 15:19:08 -08:00
|
|
|
)
|
|
|
|
|
|
|
|
// Build the list of files to download
|
2020-01-25 15:58:41 -08:00
|
|
|
if err := u.db.WalkEpisodes(ctx, feedID, func(episode *model.Episode) error {
|
|
|
|
if episode.Status != model.EpisodeNew && episode.Status != model.EpisodeError {
|
2019-12-01 15:19:08 -08:00
|
|
|
// File already downloaded
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2020-01-25 15:58:41 -08:00
|
|
|
downloadList = append(downloadList, episode)
|
2019-12-01 15:19:08 -08:00
|
|
|
return nil
|
|
|
|
}); err != nil {
|
|
|
|
return errors.Wrapf(err, "failed to build update list")
|
|
|
|
}
|
2019-11-13 18:16:35 -08:00
|
|
|
|
2019-12-01 16:01:36 -08:00
|
|
|
var (
|
2020-01-25 15:58:41 -08:00
|
|
|
downloadCount = len(downloadList)
|
2019-12-01 16:01:36 -08:00
|
|
|
downloaded = 0
|
|
|
|
)
|
|
|
|
|
2020-01-25 15:58:41 -08:00
|
|
|
if downloadCount > 0 {
|
|
|
|
log.Infof("download count: %d", downloadCount)
|
2019-12-01 16:01:36 -08:00
|
|
|
} else {
|
|
|
|
log.Info("no episodes to download")
|
|
|
|
return nil
|
|
|
|
}
|
2019-11-13 18:16:35 -08:00
|
|
|
|
2019-12-01 15:19:08 -08:00
|
|
|
// Download pending episodes
|
2020-01-25 15:58:41 -08:00
|
|
|
|
|
|
|
for idx, episode := range downloadList {
|
2019-11-13 18:16:35 -08:00
|
|
|
logger := log.WithFields(log.Fields{
|
|
|
|
"index": idx,
|
|
|
|
"episode_id": episode.ID,
|
|
|
|
})
|
|
|
|
|
2019-12-01 15:19:08 -08:00
|
|
|
// Check whether episode exists on disk
|
2020-01-25 15:58:41 -08:00
|
|
|
|
2019-12-01 15:19:08 -08:00
|
|
|
episodePath := filepath.Join(targetDir, u.episodeName(feedConfig, episode))
|
|
|
|
stat, err := os.Stat(episodePath)
|
|
|
|
if err == nil {
|
|
|
|
logger.Infof("episode %q already exists on disk (%s)", episode.ID, episodePath)
|
2019-11-13 18:16:35 -08:00
|
|
|
|
2019-12-01 15:19:08 -08:00
|
|
|
// File already exists, update file status and disk size
|
2020-01-25 15:58:41 -08:00
|
|
|
if err := u.db.UpdateEpisode(feedID, episode.ID, func(episode *model.Episode) error {
|
|
|
|
episode.Size = stat.Size()
|
|
|
|
episode.Status = model.EpisodeDownloaded
|
2019-12-01 15:19:08 -08:00
|
|
|
return nil
|
|
|
|
}); err != nil {
|
|
|
|
logger.WithError(err).Error("failed to update file info")
|
|
|
|
return err
|
2019-11-23 12:28:57 -08:00
|
|
|
}
|
2019-12-01 15:19:08 -08:00
|
|
|
|
|
|
|
return nil
|
|
|
|
} else if os.IsNotExist(err) {
|
|
|
|
// Will download, do nothing here
|
2019-11-13 18:16:35 -08:00
|
|
|
} else {
|
2019-12-01 15:19:08 -08:00
|
|
|
logger.WithError(err).Error("failed to stat file")
|
|
|
|
return err
|
2019-11-13 18:16:35 -08:00
|
|
|
}
|
|
|
|
|
2019-12-01 15:19:08 -08:00
|
|
|
// Download episode to disk
|
|
|
|
|
|
|
|
logger.Infof("! downloading episode %s", episode.VideoURL)
|
|
|
|
output, err := u.downloader.Download(ctx, feedConfig, episode, episodePath)
|
|
|
|
if err != nil {
|
|
|
|
logger.WithError(err).Errorf("youtube-dl error: %s", output)
|
|
|
|
|
|
|
|
// YouTube might block host with HTTP Error 429: Too Many Requests
|
|
|
|
// We still need to generate XML, so just stop sending download requests and
|
|
|
|
// retry next time
|
|
|
|
if strings.Contains(output, "HTTP Error 429") {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
|
2020-01-25 15:58:41 -08:00
|
|
|
if err := u.db.UpdateEpisode(feedID, episode.ID, func(episode *model.Episode) error {
|
|
|
|
episode.Status = model.EpisodeError
|
2019-12-01 15:19:08 -08:00
|
|
|
return nil
|
|
|
|
}); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
// Update file status in database
|
|
|
|
|
2019-12-01 16:01:36 -08:00
|
|
|
logger.Infof("successfully downloaded file %q", episode.ID)
|
|
|
|
|
2020-01-25 15:58:41 -08:00
|
|
|
if err := u.db.UpdateEpisode(feedID, episode.ID, func(episode *model.Episode) error {
|
2019-12-01 15:19:08 -08:00
|
|
|
// Record file size of newly downloaded file
|
|
|
|
size, err := u.fileSize(episodePath)
|
|
|
|
if err != nil {
|
|
|
|
logger.WithError(err).Error("failed to get episode file size")
|
|
|
|
} else {
|
2020-01-25 15:58:41 -08:00
|
|
|
logger.Debugf("file size: %d bytes", episode.Size)
|
|
|
|
episode.Size = size
|
2019-12-01 15:19:08 -08:00
|
|
|
}
|
|
|
|
|
2020-01-25 15:58:41 -08:00
|
|
|
episode.Status = model.EpisodeDownloaded
|
2019-12-01 15:19:08 -08:00
|
|
|
return nil
|
|
|
|
}); err != nil {
|
|
|
|
return err
|
2019-11-13 18:16:35 -08:00
|
|
|
}
|
2019-12-01 16:01:36 -08:00
|
|
|
|
|
|
|
downloaded++
|
2019-11-13 18:16:35 -08:00
|
|
|
}
|
|
|
|
|
2019-12-01 16:01:36 -08:00
|
|
|
log.Infof("downloaded %d episode(s)", downloaded)
|
2019-12-01 15:19:08 -08:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (u *Updater) buildXML(ctx context.Context, feedConfig *config.Feed) error {
|
|
|
|
feed, err := u.db.GetFeed(ctx, feedConfig.ID)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2019-10-29 14:38:29 -07:00
|
|
|
// Build iTunes XML feed with data received from builder
|
2019-10-30 00:13:02 -07:00
|
|
|
log.Debug("building iTunes podcast feed")
|
2019-12-01 15:19:08 -08:00
|
|
|
podcast, err := u.buildPodcast(feed, feedConfig)
|
2019-10-29 14:38:29 -07:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Save XML to disk
|
2019-11-13 18:16:35 -08:00
|
|
|
xmlName := fmt.Sprintf("%s.xml", feedConfig.ID)
|
2019-10-29 14:38:29 -07:00
|
|
|
xmlPath := filepath.Join(u.config.Server.DataDir, xmlName)
|
2019-10-30 00:13:02 -07:00
|
|
|
log.Debugf("saving feed XML file to %s", xmlPath)
|
2019-10-29 14:52:19 -07:00
|
|
|
if err := ioutil.WriteFile(xmlPath, []byte(podcast.String()), 0600); err != nil {
|
2019-10-29 14:38:29 -07:00
|
|
|
return errors.Wrapf(err, "failed to write XML feed to disk")
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2019-12-01 15:19:08 -08:00
|
|
|
func (u *Updater) buildPodcast(feed *model.Feed, cfg *config.Feed) (*itunes.Podcast, error) {
|
2019-10-29 14:38:29 -07:00
|
|
|
const (
|
2019-10-30 00:13:02 -07:00
|
|
|
podsyncGenerator = "Podsync generator (support us at https://github.com/mxpv/podsync)"
|
2019-10-29 14:38:29 -07:00
|
|
|
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),
|
|
|
|
}
|
|
|
|
|
|
|
|
pubDate := episode.PubDate
|
|
|
|
if pubDate.IsZero() {
|
|
|
|
pubDate = now
|
|
|
|
}
|
|
|
|
|
|
|
|
item.AddPubDate(&pubDate)
|
|
|
|
|
|
|
|
item.AddSummary(episode.Description)
|
|
|
|
item.AddImage(episode.Thumbnail)
|
|
|
|
item.AddDuration(episode.Duration)
|
2019-10-30 00:13:02 -07:00
|
|
|
item.AddEnclosure(u.makeEnclosure(feed, episode, cfg))
|
2019-10-29 14:38:29 -07:00
|
|
|
|
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2019-11-27 21:07:39 -08:00
|
|
|
func (u *Updater) makeEnclosure(
|
|
|
|
feed *model.Feed,
|
|
|
|
episode *model.Episode,
|
|
|
|
cfg *config.Feed,
|
|
|
|
) (string, itunes.EnclosureType, int64) {
|
2019-10-29 14:38:29 -07:00
|
|
|
ext := "mp4"
|
|
|
|
contentType := itunes.MP4
|
|
|
|
if feed.Format == model.FormatAudio {
|
2019-11-14 23:33:46 -08:00
|
|
|
ext = "mp3"
|
|
|
|
contentType = itunes.MP3
|
2019-10-29 14:38:29 -07:00
|
|
|
}
|
|
|
|
|
2019-11-15 10:57:55 -08:00
|
|
|
url := fmt.Sprintf(
|
2019-11-23 13:25:07 -08:00
|
|
|
"%s/%s/%s.%s",
|
|
|
|
u.hostname(),
|
2019-11-15 10:57:55 -08:00
|
|
|
cfg.ID,
|
|
|
|
episode.ID,
|
|
|
|
ext,
|
|
|
|
)
|
|
|
|
|
2019-10-29 14:38:29 -07:00
|
|
|
return url, contentType, episode.Size
|
|
|
|
}
|
|
|
|
|
2019-11-23 13:25:07 -08:00
|
|
|
func (u *Updater) hostname() string {
|
|
|
|
hostname := strings.TrimSuffix(u.config.Server.Hostname, "/")
|
|
|
|
if !strings.HasPrefix(hostname, "http") {
|
|
|
|
hostname = fmt.Sprintf("http://%s", hostname)
|
|
|
|
}
|
|
|
|
|
|
|
|
return hostname
|
|
|
|
}
|
|
|
|
|
2019-11-13 18:16:35 -08:00
|
|
|
func (u *Updater) episodeName(feedConfig *config.Feed, episode *model.Episode) string {
|
|
|
|
ext := "mp4"
|
|
|
|
if feedConfig.Format == model.FormatAudio {
|
|
|
|
ext = "mp3"
|
|
|
|
}
|
|
|
|
|
|
|
|
return fmt.Sprintf("%s.%s", episode.ID, ext)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (u *Updater) fileSize(path string) (int64, error) {
|
|
|
|
info, err := os.Stat(path)
|
|
|
|
if err != nil {
|
|
|
|
return 0, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return info.Size(), nil
|
|
|
|
}
|
|
|
|
|
2019-12-21 14:15:07 -08:00
|
|
|
func (u *Updater) makeBuilder(ctx context.Context, cfg *config.Feed) (feed.Builder, error) {
|
2019-10-29 14:38:29 -07:00
|
|
|
var (
|
2019-12-21 14:15:07 -08:00
|
|
|
provider feed.Builder
|
2019-10-29 14:38:29 -07:00
|
|
|
err error
|
|
|
|
)
|
|
|
|
|
2019-10-30 00:13:02 -07:00
|
|
|
info, err := link.Parse(cfg.URL)
|
2019-10-29 14:38:29 -07:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
switch info.Provider {
|
|
|
|
case link.ProviderYoutube:
|
2019-12-21 14:15:07 -08:00
|
|
|
provider, err = feed.NewYouTubeBuilder(u.config.Tokens.YouTube)
|
2019-10-29 14:38:29 -07:00
|
|
|
case link.ProviderVimeo:
|
2019-12-21 14:15:07 -08:00
|
|
|
provider, err = feed.NewVimeoBuilder(ctx, u.config.Tokens.Vimeo)
|
2019-10-29 14:38:29 -07:00
|
|
|
default:
|
|
|
|
return nil, errors.Errorf("unsupported provider %q", info.Provider)
|
|
|
|
}
|
|
|
|
|
|
|
|
return provider, err
|
|
|
|
}
|