Files
gonic/server/podcasts/podcasts.go
Alex McGrath b57860a967 make podcast audio finding more robust
this will check that if there is no type field in an enclosure that
the url ends with an audio filename, if none of these are true, itll
then check audio:content for an item and do those checks again.
2021-02-06 16:19:20 +00:00

466 lines
12 KiB
Go

package podcasts
import (
"errors"
"fmt"
"io"
"log"
"mime"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/jinzhu/gorm"
"github.com/mmcdole/gofeed"
"go.senan.xyz/gonic/multierr"
"go.senan.xyz/gonic/server/db"
gmime "go.senan.xyz/gonic/server/mime"
"go.senan.xyz/gonic/server/scanner/tags"
)
type Podcasts struct {
DB *db.DB
PodcastBasePath string
}
const (
episodeDownloading = "downloading"
episodeSkipped = "skipped"
episodeDeleted = "deleted"
)
func (p *Podcasts) GetPodcastOrAll(userID int, id int, includeEpisodes bool) ([]*db.Podcast, error) {
podcasts := []*db.Podcast{}
q := p.DB.Where("user_id=?", userID)
if id != 0 {
q = q.Where("id=?", id)
}
if err := q.Find(&podcasts).Error; err != nil {
return nil, fmt.Errorf("finding podcasts: %w", err)
}
if !includeEpisodes {
return podcasts, nil
}
for _, c := range podcasts {
episodes, err := p.GetPodcastEpisodes(id)
if err != nil {
return nil, fmt.Errorf("finding podcast episodes: %w", err)
}
c.Episodes = episodes
}
return podcasts, nil
}
func (p *Podcasts) GetPodcastEpisodes(podcastID int) ([]*db.PodcastEpisode, error) {
episodes := []*db.PodcastEpisode{}
err := p.DB.
Where("podcast_id=?", podcastID).
Order("publish_date DESC").
Find(&episodes).
Error
if err != nil {
return nil, fmt.Errorf("find episodes by podcast id: %w", err)
}
return episodes, nil
}
func (p *Podcasts) AddNewPodcast(feed *gofeed.Feed, userID int) (*db.Podcast, error) {
podcast := db.Podcast{
Description: feed.Description,
ImageURL: feed.Image.URL,
UserID: userID,
Title: feed.Title,
URL: feed.FeedLink,
}
podPath := podcast.Fullpath(p.PodcastBasePath)
err := os.Mkdir(podPath, 0755)
if err != nil && !os.IsExist(err) {
return nil, err
}
if err := p.DB.Save(&podcast).Error; err != nil {
return &podcast, err
}
if err := p.AddNewEpisodes(podcast.ID, feed.Items); err != nil {
return nil, err
}
go func() {
if err := p.downloadPodcastCover(podPath, &podcast); err != nil {
log.Printf("error downloading podcast cover: %v", err)
}
}()
return &podcast, nil
}
func getEntriesAfterDate(feed []*gofeed.Item, after time.Time) []*gofeed.Item {
items := []*gofeed.Item{}
for _, item := range feed {
if item.PublishedParsed.Before(after) || item.PublishedParsed.Equal(after) {
continue
}
items = append(items, item)
}
return items
}
func (p *Podcasts) AddNewEpisodes(podcastID int, items []*gofeed.Item) error {
podcastEpisode := db.PodcastEpisode{}
err := p.DB.
Where("podcast_id=?", podcastID).
Order("publish_date DESC").
First(&podcastEpisode).Error
itemFound := true
if errors.Is(err, gorm.ErrRecordNotFound) {
itemFound = false
} else if err != nil {
return err
}
if !itemFound {
for _, item := range items {
if err := p.AddEpisode(podcastID, item); err != nil {
return err
}
}
return nil
}
for _, item := range getEntriesAfterDate(items, *podcastEpisode.PublishDate) {
if err := p.AddEpisode(podcastID, item); err != nil {
return err
}
}
return nil
}
func getSecondsFromString(time string) int {
duration, err := strconv.Atoi(time)
if err == nil {
return duration
}
splitTime := strings.Split(time, ":")
if len(splitTime) == 3 {
hours, _ := strconv.Atoi(splitTime[0])
minutes, _ := strconv.Atoi(splitTime[1])
seconds, _ := strconv.Atoi(splitTime[2])
return (3600 * hours) + (60 * minutes) + seconds
}
if len(splitTime) == 2 {
minutes, _ := strconv.Atoi(splitTime[0])
seconds, _ := strconv.Atoi(splitTime[1])
return (60 * minutes) + seconds
}
return 0
}
func (p *Podcasts) AddEpisode(podcastID int, item *gofeed.Item) error {
duration := 0
// if it has the media extension use it
for _, content := range item.Extensions["media"]["content"] {
durationExt := content.Attrs["duration"]
duration = getSecondsFromString(durationExt)
if duration != 0 {
break
}
}
// if the itunes extension is available, use AddEpisode
if duration == 0 {
duration = getSecondsFromString(item.ITunesExt.Duration)
}
if episode, ok := p.findEnclosureAudio(podcastID, duration, item); ok {
if err := p.DB.Save(episode).Error; err != nil {
return err
}
return nil
}
if episode, ok := p.findMediaAudio(podcastID, duration, item); ok {
if err := p.DB.Save(episode).Error; err != nil {
return err
}
return nil
}
// hopefully shouldnt reach here
log.Println("failed to find audio in feed item, skipping")
return nil
}
func isAudio(mediaType, url string) bool {
if mediaType != "" && strings.HasPrefix(mediaType, "audio") {
return true
}
_, ok := gmime.FromExtension(filepath.Ext(url)[1:])
return ok
}
func itemToEpisode(podcastID, size, duration int, audio string,
item *gofeed.Item) *db.PodcastEpisode {
return &db.PodcastEpisode{
PodcastID: podcastID,
Description: item.Description,
Title: item.Title,
Length: duration,
Size: size,
PublishDate: item.PublishedParsed,
AudioURL: audio,
Status: episodeSkipped,
}
}
func (p *Podcasts) findEnclosureAudio(podcastID, duration int,
item *gofeed.Item) (*db.PodcastEpisode, bool) {
for _, enc := range item.Enclosures {
if !isAudio(enc.Type, enc.URL) {
continue
}
size, _ := strconv.Atoi(enc.Length)
return itemToEpisode(podcastID, size, duration, enc.URL, item), true
}
return nil, false
}
func (p *Podcasts) findMediaAudio(podcastID, duration int,
item *gofeed.Item) (*db.PodcastEpisode, bool) {
extensions, ok := item.Extensions["media"]["content"]
if !ok {
return nil, false
}
for _, ext := range extensions {
if !isAudio(ext.Attrs["type"], ext.Attrs["url"]) {
continue
}
return itemToEpisode(podcastID, 0, duration, ext.Attrs["url"], item), true
}
return nil, false
}
func (p *Podcasts) RefreshPodcasts() error {
podcasts := []*db.Podcast{}
if err := p.DB.Find(&podcasts).Error; err != nil {
return fmt.Errorf("find podcasts: %w", err)
}
var errs *multierr.Err
if errors.As(p.refreshPodcasts(podcasts), &errs) && errs.Len() > 0 {
return fmt.Errorf("refresh podcasts: %w", errs)
}
return nil
}
func (p *Podcasts) RefreshPodcastsForUser(userID int) error {
podcasts := []*db.Podcast{}
err := p.DB.
Where("user_id=?", userID).
Find(&podcasts).
Error
if err != nil {
return fmt.Errorf("find podcasts: %w", err)
}
var errs *multierr.Err
if errors.As(p.refreshPodcasts(podcasts), &errs) && errs.Len() > 0 {
return fmt.Errorf("refresh podcasts: %w", errs)
}
return nil
}
func (p *Podcasts) refreshPodcasts(podcasts []*db.Podcast) error {
var errs multierr.Err
for _, podcast := range podcasts {
fp := gofeed.NewParser()
feed, err := fp.ParseURL(podcast.URL)
if err != nil {
errs.Add(fmt.Errorf("refreshing podcast with url %q: %w", podcast.URL, err))
continue
}
if err = p.AddNewEpisodes(podcast.ID, feed.Items); err != nil {
errs.Add(fmt.Errorf("adding episodes: %w", err))
continue
}
}
return errs
}
func (p *Podcasts) DownloadEpisode(episodeID int) error {
podcastEpisode := db.PodcastEpisode{}
podcast := db.Podcast{}
err := p.DB.
Where("id=?", episodeID).
First(&podcastEpisode).
Error
if err != nil {
return fmt.Errorf("get podcast episode by id: %w", err)
}
err = p.DB.
Where("id=?", podcastEpisode.PodcastID).
First(&podcast).
Error
if err != nil {
return fmt.Errorf("get podcast by id: %w", err)
}
if podcastEpisode.Status == episodeDownloading {
log.Printf("Already downloading podcast episode with id %d", episodeID)
return nil
}
podcastEpisode.Status = episodeDownloading
p.DB.Save(&podcastEpisode)
// nolint: bodyclose
resp, err := http.Get(podcastEpisode.AudioURL)
if err != nil {
return fmt.Errorf("fetch podcast audio: %w", err)
}
filename, ok := getContentDispositionFilename(resp.Header.Get("content-disposition"))
if !ok {
audioURL, err := url.Parse(podcastEpisode.AudioURL)
if err != nil {
return fmt.Errorf("parse podcast audio url: %w", err)
}
filename = path.Base(audioURL.Path)
}
filename = p.findUniqueEpisodeName(&podcast, &podcastEpisode, filename)
audioFile, err := os.Create(path.Join(podcast.Fullpath(p.PodcastBasePath), filename))
if err != nil {
return fmt.Errorf("create audio file: %w", err)
}
podcastEpisode.Filename = filename
podcastEpisode.Path = path.Join(filepath.Clean(podcast.Title), filename)
p.DB.Save(&podcastEpisode)
go func() {
if err := p.doPodcastDownload(&podcastEpisode, audioFile, resp.Body); err != nil {
log.Printf("error downloading podcast: %v", err)
}
}()
return nil
}
func (p *Podcasts) findUniqueEpisodeName(
podcast *db.Podcast,
podcastEpisode *db.PodcastEpisode,
filename string) string {
podcastPath := path.Join(podcast.Fullpath(p.PodcastBasePath), filename)
if _, err := os.Stat(podcastPath); os.IsNotExist(err) {
return filename
}
titlePath := fmt.Sprintf("%s%s", podcastEpisode.Title, filepath.Ext(filename))
podcastPath = path.Join(podcast.Fullpath(p.PodcastBasePath), titlePath)
if _, err := os.Stat(podcastPath); os.IsNotExist(err) {
return titlePath
}
// try to find a filename like FILENAME (1).mp3 incrementing
return findEpisode(podcast.Fullpath(p.PodcastBasePath), filename, 1)
}
func findEpisode(base, filename string, count int) string {
testFile := fmt.Sprintf("%s (%d)%s", filename, count, filepath.Ext(filename))
podcastPath := path.Join(base, testFile)
if _, err := os.Stat(podcastPath); os.IsNotExist(err) {
return testFile
}
return findEpisode(base, filename, count+1)
}
func getContentDispositionFilename(header string) (string, bool) {
_, params, _ := mime.ParseMediaType(header)
filename, ok := params["filename"]
return filename, ok
}
func (p *Podcasts) downloadPodcastCover(podPath string, podcast *db.Podcast) error {
imageURL, err := url.Parse(podcast.ImageURL)
if err != nil {
return fmt.Errorf("parse image url: %w", err)
}
ext := path.Ext(imageURL.Path)
resp, err := http.Get(podcast.ImageURL)
if err != nil {
return fmt.Errorf("fetch image url: %w", err)
}
defer resp.Body.Close()
if ext == "" {
contentHeader := resp.Header.Get("content-disposition")
filename, _ := getContentDispositionFilename(contentHeader)
ext = path.Ext(filename)
}
coverPath := path.Join(podPath, "cover"+ext)
coverFile, err := os.Create(coverPath)
if err != nil {
return fmt.Errorf("creating podcast cover: %w", err)
}
if _, err := io.Copy(coverFile, resp.Body); err != nil {
return fmt.Errorf("writing podcast cover: %w", err)
}
podcastPath := filepath.Clean(podcast.Title)
podcastFilename := fmt.Sprintf("cover%s", ext)
podcast.ImagePath = path.Join(podcastPath, podcastFilename)
if err := p.DB.Save(podcast).Error; err != nil {
return fmt.Errorf("save podcast: %w", err)
}
return nil
}
func (p *Podcasts) doPodcastDownload(podcastEpisode *db.PodcastEpisode, file *os.File, src io.Reader) error {
if _, err := io.Copy(file, src); err != nil {
return fmt.Errorf("writing podcast episode: %w", err)
}
defer file.Close()
stat, _ := file.Stat()
podcastPath := path.Join(p.PodcastBasePath, podcastEpisode.Path)
podcastTags, err := tags.New(podcastPath)
if err != nil {
log.Printf("error parsing podcast: %e", err)
podcastEpisode.Status = "error"
p.DB.Save(podcastEpisode)
return nil
}
podcastEpisode.Bitrate = podcastTags.Bitrate()
podcastEpisode.Status = "completed"
podcastEpisode.Length = podcastTags.Length()
podcastEpisode.Size = int(stat.Size())
return p.DB.Save(podcastEpisode).Error
}
func (p *Podcasts) DeletePodcast(userID, podcastID int) error {
podcast := db.Podcast{}
err := p.DB.
Where("id=? AND user_id=?", podcastID, userID).
First(&podcast).
Error
if err != nil {
return err
}
var userCount int
p.DB.
Model(&db.Podcast{}).
Where("title=?", podcast.Title).
Count(&userCount)
if userCount == 1 {
// only delete the folder if there are not multiple listeners
if err = os.RemoveAll(podcast.Fullpath(p.PodcastBasePath)); err != nil {
return fmt.Errorf("delete podcast directory: %w", err)
}
}
err = p.DB.
Where("id=? AND user_id=?", podcastID, userID).
Delete(db.Podcast{}).
Error
if err != nil {
return fmt.Errorf("delete podcast row: %w", err)
}
return nil
}
func (p *Podcasts) DeletePodcastEpisode(podcastEpisodeID int) error {
episode := db.PodcastEpisode{}
err := p.DB.First(&episode, podcastEpisodeID).Error
if err != nil {
return err
}
episode.Status = episodeDeleted
p.DB.Save(&episode)
if err := os.Remove(filepath.Join(p.PodcastBasePath, episode.Path)); err != nil {
return err
}
return err
}