podcast/podcast.go

478 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package podcast
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"strconv"
"time"
"unicode/utf8"
"github.com/pkg/errors"
)
const (
pVersion = "1.3.1"
)
// Podcast represents a podcast.
type Podcast struct {
XMLName xml.Name `xml:"channel"`
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
Category string `xml:"category,omitempty"`
Cloud string `xml:"cloud,omitempty"`
Copyright string `xml:"copyright,omitempty"`
Docs string `xml:"docs,omitempty"`
Generator string `xml:"generator,omitempty"`
Language string `xml:"language,omitempty"`
LastBuildDate string `xml:"lastBuildDate,omitempty"`
ManagingEditor string `xml:"managingEditor,omitempty"`
PubDate string `xml:"pubDate,omitempty"`
Rating string `xml:"rating,omitempty"`
SkipHours string `xml:"skipHours,omitempty"`
SkipDays string `xml:"skipDays,omitempty"`
TTL int `xml:"ttl,omitempty"`
WebMaster string `xml:"webMaster,omitempty"`
Email string `xml:"email,omitempty"`
Image *Image
TextInput *TextInput
AtomLink *AtomLink
// https://help.apple.com/itc/podcasts_connect/#/itcb54353390
IAuthor string `xml:"itunes:author,omitempty"`
ISubtitle string `xml:"itunes:subtitle,omitempty"`
ISummary *ISummary
IBlock string `xml:"itunes:block,omitempty"`
IImage *IImage
IDuration string `xml:"itunes:duration,omitempty"`
IExplicit string `xml:"itunes:explicit,omitempty"`
IComplete string `xml:"itunes:complete,omitempty"`
INewFeedURL string `xml:"itunes:new-feed-url,omitempty"`
IOwner *Author // Author is formatted for itunes as-is
ICategories []*ICategory
IType string `xml:"itunes:type,omitempty"`
Items []*Item
encode func(w io.Writer, o interface{}) error
}
// New instantiates a Podcast with required parameters.
//
// Nil-able fields are optional but recommended as they are formatted
// to the expected proper formats.
func New(title, link, description string,
pubDate, lastBuildDate *time.Time) Podcast {
return Podcast{
Title: title,
Link: link,
Description: description,
Generator: fmt.Sprintf("feeder v%s (gitlab.com/iarenzana/feeder)", pVersion),
PubDate: parseDateRFC1123Z(pubDate),
LastBuildDate: parseDateRFC1123Z(lastBuildDate),
Language: "es-es",
IType: "episodic",
// setup dependency (could inject later)
encode: encoder,
}
}
// AddAuthor adds the specified Author to the podcast.
func (p *Podcast) AddAuthor(name, email string) {
if len(email) == 0 {
return
}
p.ManagingEditor = parseAuthorNameEmail(&Author{
Name: name,
Email: email,
})
p.IAuthor = p.ManagingEditor
}
// AddAtomLink adds a FQDN reference to an atom feed.
func (p *Podcast) AddAtomLink(href string) {
if len(href) == 0 {
return
}
p.AtomLink = &AtomLink{
HREF: href,
Rel: "self",
Type: "application/rss+xml",
}
}
// AddCategory adds the category to the Podcast.
//
// ICategory can be listed multiple times.
//
// Calling this method multiple times will APPEND the category to the existing
// list, if any, including ICategory.
//
// Note that Apple iTunes has a specific list of categories that only can be
// used and will invalidate the feed if deviated from the list. That list is
// as follows.
//
// * Arts
// * Design
// * Fashion & Beauty
// * Food
// * Literature
// * Performing Arts
// * Visual Arts
// * Business
// * Business News
// * Careers
// * Investing
// * Management & Marketing
// * Shopping
// * Comedy
// * Education
// * Education Technology
// * Higher Education
// * K-12
// * Language Courses
// * Training
// * Games & Hobbies
// * Automotive
// * Aviation
// * Hobbies
// * Other Games
// * Video Games
// * Government & Organizations
// * Local
// * National
// * Non-Profit
// * Regional
// * Health
// * Alternative Health
// * Fitness & Nutrition
// * Self-Help
// * Sexuality
// * Kids & Family
// * Music
// * News & Politics
// * Religion & Spirituality
// * Buddhism
// * Christianity
// * Hinduism
// * Islam
// * Judaism
// * Other
// * Spirituality
// * Science & Medicine
// * Medicine
// * Natural Sciences
// * Social Sciences
// * Society & Culture
// * History
// * Personal Journals
// * Philosophy
// * Places & Travel
// * Sports & Recreation
// * Amateur
// * College & High School
// * Outdoor
// * Professional
// * Technology
// * Gadgets
// * Podcasting
// * Software How-To
// * Tech News
// * TV & Film
func (p *Podcast) AddCategory(category string, subCategories []string) {
if len(category) == 0 {
return
}
// RSS 2.0 Category only supports 1-tier
if len(p.Category) > 0 {
p.Category = p.Category + "," + category
} else {
p.Category = category
}
icat := ICategory{Text: category}
for _, c := range subCategories {
if len(c) == 0 {
continue
}
icat2 := ICategory{Text: c}
icat.ICategories = append(icat.ICategories, &icat2)
}
p.ICategories = append(p.ICategories, &icat)
}
// AddImage adds the specified Image to the Podcast.
//
// Podcast feeds contain artwork that is a minimum size of
// 1400 x 1400 pixels and a maximum size of 3000 x 3000 pixels,
// 72 dpi, in JPEG or PNG format with appropriate file
// extensions (.jpg, .png), and in the RGB colorspace. To optimize
// images for mobile devices, Apple recommends compressing your
// image files.
func (p *Podcast) AddImage(url string) {
if len(url) == 0 {
return
}
p.Image = &Image{
URL: url,
Title: p.Title,
Link: p.Link,
}
p.IImage = &IImage{HREF: url}
}
// AddItem adds the podcast episode. It returns a count of Items added or any
// errors in validation that may have occurred.
//
// This method takes the "itunes overrides" approach to populating
// itunes tags according to the overrides rules in the specification.
// This not only complies completely with iTunes parsing rules; but, it also
// displays what is possible to be set on an individual episode level if you
// wish to have more fine grain control over your content.
//
// This method imposes strict validation of the Item being added to confirm
// to Podcast and iTunes specifications.
//
// Article minimal requirements are:
//
// * Title
// * Description
// * Link
//
// Audio, Video and Downloads minimal requirements are:
//
// * Title
// * Description
// * Enclosure (HREF, Type and Length all required)
//
// The following fields are always overwritten (don't set them):
//
// * GUID
// * PubDateFormatted
// * AuthorFormatted
// * Enclosure.TypeFormatted
// * Enclosure.LengthFormatted
//
// Recommendations:
//
// * Just set the minimal fields: the rest get set for you.
// * Always set an Enclosure.Length, to be nice to your downloaders.
// * Follow Apple's best practices to enrich your podcasts:
// https://help.apple.com/itc/podcasts_connect/#/itc2b3780e76
// * For specifications of itunes tags, see:
// https://help.apple.com/itc/podcasts_connect/#/itcb54353390
//
func (p *Podcast) AddItem(i Item) (int, error) {
// initial guards for required fields
if len(i.Title) == 0 || len(i.Description) == 0 {
return len(p.Items), errors.New("Title and Description are required")
}
if i.Enclosure != nil {
if len(i.Enclosure.URL) == 0 {
return len(p.Items),
errors.New(i.Title + ": Enclosure.URL is required")
}
if i.Enclosure.Type.String() == enclosureDefault {
return len(p.Items),
errors.New(i.Title + ": Enclosure.Type is required")
}
} else if len(i.Link) == 0 {
return len(p.Items),
errors.New(i.Title + ": Link is required when not using Enclosure")
}
// corrective actions and overrides
//
i.PubDateFormatted = parseDateRFC1123Z(i.PubDate)
i.AuthorFormatted = parseAuthorNameEmail(i.Author)
if i.Enclosure != nil {
if len(i.GUID) == 0 {
i.GUID = i.Enclosure.URL // yep, GUID is the Permlink URL
}
if i.Enclosure.Length < 0 {
i.Enclosure.Length = 0
}
i.Enclosure.LengthFormatted = strconv.FormatInt(i.Enclosure.Length, 10)
i.Enclosure.TypeFormatted = i.Enclosure.Type.String()
// allow Link to be set for article references to Downloads,
// otherwise set it to the enclosurer's URL.
if len(i.Link) == 0 {
i.Link = i.Enclosure.URL
}
} else {
i.GUID = i.Link // yep, GUID is the Permlink URL
}
// iTunes it
//
if len(i.IAuthor) == 0 {
switch {
case i.Author != nil:
i.IAuthor = i.Author.Email
case len(p.IAuthor) != 0:
i.Author = &Author{Email: p.IAuthor}
i.IAuthor = p.IAuthor
case len(p.ManagingEditor) != 0:
i.Author = &Author{Email: p.ManagingEditor}
i.IAuthor = p.ManagingEditor
}
}
if i.IImage == nil {
if p.Image != nil {
i.IImage = &IImage{HREF: p.Image.URL}
}
}
p.Items = append(p.Items, &i)
//if episode number is not passed; we will just increase the items by one
if i.IEpisode == 0 && p.IType == "episodic" {
i.IEpisode = len(p.Items) + 1
}
return len(p.Items), nil
}
// AddPubDate adds the datetime as a parsed PubDate.
//
// UTC time is used by default.
func (p *Podcast) AddPubDate(datetime *time.Time) {
p.PubDate = parseDateRFC1123Z(datetime)
}
// AddLastBuildDate adds the datetime as a parsed PubDate.
//
// UTC time is used by default.
func (p *Podcast) AddLastBuildDate(datetime *time.Time) {
p.LastBuildDate = parseDateRFC1123Z(datetime)
}
// AddSubTitle adds the iTunes subtitle that is displayed with the title
// in iTunes.
//
// Note that this field should be just a few words long according to Apple.
// This method will truncate the string to 64 chars if too long with "..."
func (p *Podcast) AddSubTitle(subTitle string) {
count := utf8.RuneCountInString(subTitle)
if count == 0 {
return
}
if count > 64 {
s := []rune(subTitle)
subTitle = string(s[0:61]) + "..."
}
p.ISubtitle = subTitle
}
// AddSummary adds the iTunes summary.
//
// Limit: 4000 characters
//
// Note that this field is a CDATA encoded field which allows for rich text
// such as html links: `<a href="http://www.apple.com">Apple</a>`.
func (p *Podcast) AddSummary(summary string) {
count := utf8.RuneCountInString(summary)
if count == 0 {
return
}
if count > 4000 {
s := []rune(summary)
summary = string(s[0:4000])
}
p.ISummary = &ISummary{
Text: summary,
}
}
// AddIType adds the specified type to the podcast to the podcast.
func (p *Podcast) AddIType(iType string) {
if len(iType) == 0 {
return
}
if iType == "serial" {
p.IType = "serial"
return
}
p.IType = "episodic"
}
// Bytes returns an encoded []byte slice.
func (p *Podcast) Bytes() []byte {
return []byte(p.String())
}
// Encode writes the bytes to the io.Writer stream in RSS 2.0 specification.
func (p *Podcast) Encode(w io.Writer) error {
if _, err := w.Write([]byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")); err != nil {
return errors.Wrap(err, "podcast.Encode: w.Write return error")
}
atomLink := ""
if p.AtomLink != nil {
atomLink = "http://www.w3.org/2005/Atom"
}
wrapped := podcastWrapper{
ITUNESNS: "http://www.itunes.com/dtds/podcast-1.0.dtd",
ATOMNS: atomLink,
Version: "2.0",
Channel: p,
}
return p.encode(w, wrapped)
}
// String encodes the Podcast state to a string.
func (p *Podcast) String() string {
b := new(bytes.Buffer)
if err := p.Encode(b); err != nil {
return "String: podcast.write returned the error: " + err.Error()
}
return b.String()
}
// // Write implements the io.Writer interface to write an RSS 2.0 stream
// // that is compliant to the RSS 2.0 specification.
// func (p *Podcast) Write(b []byte) (n int, err error) {
// buf := bytes.NewBuffer(b)
// if err := p.Encode(buf); err != nil {
// return 0, errors.Wrap(err, "Write: podcast.encode returned error")
// }
// return buf.Len(), nil
// }
type podcastWrapper struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
ATOMNS string `xml:"xmlns:atom,attr,omitempty"`
ITUNESNS string `xml:"xmlns:itunes,attr"`
Channel *Podcast
}
var encoder = func(w io.Writer, o interface{}) error {
e := xml.NewEncoder(w)
e.Indent("", " ")
if err := e.Encode(o); err != nil {
return errors.Wrap(err, "podcast.encoder: e.Encode returned error")
}
return nil
}
var parseAuthorNameEmail = func(a *Author) string {
var author string
if a != nil {
author = a.Email
if len(a.Name) > 0 {
author = fmt.Sprintf("%s", a.Name)
}
}
return author
}