From 5b523d822738237d7745c4016612b08101916c6e Mon Sep 17 00:00:00 2001 From: Eric Duncan Date: Thu, 2 Feb 2017 07:42:33 -0500 Subject: [PATCH] initial version --- .travis.yml | 7 + README.md | 505 ++++++++++++++++++++++++++++++++++++++++++++++ author.go | 12 ++ doc.go | 31 +++ enclosure.go | 53 +++++ enclosure_test.go | 87 ++++++++ example_test.go | 171 ++++++++++++++++ examples_test.go | 174 ++++++++++++++++ image.go | 21 ++ item.go | 60 ++++++ itunes.go | 26 +++ podcast.go | 294 +++++++++++++++++++++++++++ podcast_test.go | 240 ++++++++++++++++++++++ textinput.go | 12 ++ 14 files changed, 1693 insertions(+) create mode 100644 .travis.yml create mode 100644 README.md create mode 100644 author.go create mode 100644 doc.go create mode 100644 enclosure.go create mode 100644 enclosure_test.go create mode 100644 example_test.go create mode 100644 examples_test.go create mode 100644 image.go create mode 100644 item.go create mode 100644 itunes.go create mode 100644 podcast.go create mode 100644 podcast_test.go create mode 100644 textinput.go diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b7798d5 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: go + +go: +- stable + +script: + - go test -cover -v ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c676e4 --- /dev/null +++ b/README.md @@ -0,0 +1,505 @@ + + +# podcast +`import "github.com/eduncan911/podcast"` + +* [Overview](#pkg-overview) +* [Index](#pkg-index) +* [Examples](#pkg-examples) + +## Overview +Package `podcast` is an iTunes and RSS 2.0 podcast generator for GoLang that +enforces strict compliance by using its simple interface. + +[![GoDoc](https://godoc.org/github.com/eduncan911/podcast?status.svg)](https://godoc.org/github.com/eduncan911/podcast) [![Build Status](https://travis-ci.org/eduncan911/podcast.svg?branch=master)](https://travis-ci.org/eduncan911/podcast) [![Go Report Card](https://goreportcard.com/badge/github.com/eduncan911/podcast)](https://goreportcard.com/report/github.com/eduncan911/podcast) + +Full documentation with detailed examples located at [![GoDoc](https://godoc.org/github.com/eduncan911/podcast?status.svg)](https://godoc.org/github.com/eduncan911/podcast) + +Usage + + + $ go get -u github.com/eduncan911/podcast + +The API exposes a number of method receivers on structs that implements the +logic required to comply with the specifications and ensure a compliant feed. +A number of overrides occur to help with iTunes visibility of your episodes. + +See the detailed Examples in the GoDocs for complete usage. + +### Extensiblity +In no way are you restricted in having full control over your feeds. You may +choose to skip the API methods and instead use the structs directly. The +fields have been grouped by RSS 2.0 and iTunes fields. + +iTunes specific fields are all prefixed with the letter `I`. + +### References +RSS 2.0: https://cyber.harvard.edu/rss/rss.html + +Podcasts: https://help.apple.com/itc/podcasts_connect/#/itca5b22233 + + + + +## Index +* [type Author](#Author) +* [type Enclosure](#Enclosure) +* [type EnclosureType](#EnclosureType) + * [func (et EnclosureType) String() string](#EnclosureType.String) +* [type ICategory](#ICategory) +* [type IImage](#IImage) +* [type Image](#Image) +* [type Item](#Item) + * [func (i *Item) AddEnclosure(url string, enclosureType EnclosureType, lengthInSeconds int64)](#Item.AddEnclosure) +* [type Podcast](#Podcast) + * [func New(title, link, description string, pubDate, lastBuildDate *time.Time) Podcast](#New) + * [func (p *Podcast) AddAuthor(a Author)](#Podcast.AddAuthor) + * [func (p *Podcast) AddCategory(category string, subCategories []string)](#Podcast.AddCategory) + * [func (p *Podcast) AddImage(i Image)](#Podcast.AddImage) + * [func (p *Podcast) AddItem(i Item) (int, error)](#Podcast.AddItem) + * [func (p *Podcast) Bytes() []byte](#Podcast.Bytes) + * [func (p *Podcast) Encode(w io.Writer) error](#Podcast.Encode) + * [func (p *Podcast) String() string](#Podcast.String) +* [type TextInput](#TextInput) + +#### Examples +* [Package](#example_) +* [New](#example_New) +* [Podcast.AddAuthor](#example_Podcast_AddAuthor) +* [Podcast.AddCategory](#example_Podcast_AddCategory) +* [Podcast.AddImage](#example_Podcast_AddImage) +* [Podcast.AddItem](#example_Podcast_AddItem) +* [Podcast.Bytes](#example_Podcast_Bytes) +* [Package (Encode)](#example__encode) + +#### Package files +[author.go](/src/github.com/eduncan911/podcast/author.go) [doc.go](/src/github.com/eduncan911/podcast/doc.go) [enclosure.go](/src/github.com/eduncan911/podcast/enclosure.go) [image.go](/src/github.com/eduncan911/podcast/image.go) [item.go](/src/github.com/eduncan911/podcast/item.go) [itunes.go](/src/github.com/eduncan911/podcast/itunes.go) [podcast.go](/src/github.com/eduncan911/podcast/podcast.go) [textinput.go](/src/github.com/eduncan911/podcast/textinput.go) + + + + + + +## type [Author](/src/target/author.go?s=149:287#L1) +``` go +type Author struct { + XMLName xml.Name `xml:"itunes:owner"` + Name string `xml:"itunes:name"` + Email string `xml:"itunes:email"` +} +``` +Author represents a named author and email. + +For iTunes compiance, both Name and Email are required. + + + + + + + + + + +## type [Enclosure](/src/target/enclosure.go?s=814:1118#L36) +``` go +type Enclosure struct { + XMLName xml.Name `xml:"enclosure"` + URL string `xml:"url,attr"` + Length int64 `xml:"-"` + LengthFormatted string `xml:"length,attr"` + Type EnclosureType `xml:"-"` + TypeFormatted string `xml:"type,attr"` +} +``` +Enclosure represents a download enclosure. + + + + + + + + + + +## type [EnclosureType](/src/target/enclosure.go?s=274:296#L11) +``` go +type EnclosureType int +``` +EnclosureType specifies the type of the enclosure. + + +``` go +const ( + M4A EnclosureType = iota + M4V + MP4 + MP3 + MOV + PDF + EPUB +) +``` +EnclosureType specifies the type of the enclosure. + + + + + + + + + + +### func (EnclosureType) [String](/src/target/enclosure.go?s=371:410#L14) +``` go +func (et EnclosureType) String() string +``` +String returns the MIME type encoding of the specified EnclosureType. + + + + +## type [ICategory](/src/target/itunes.go?s=645:782#L12) +``` go +type ICategory struct { + XMLName xml.Name `xml:"itunes:category"` + Text string `xml:"text,attr"` + ICategories []*ICategory +} +``` +ICategory is a 2-tier classification system for iTunes. + + + + + + + + + + +## type [IImage](/src/target/itunes.go?s=487:584#L6) +``` go +type IImage struct { + XMLName xml.Name `xml:"itunes:image"` + HREF string `xml:"href,attr"` +} +``` +IImage represents an iTunes image. + +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. + + + + + + + + + + +## type [Image](/src/target/image.go?s=398:656#L3) +``` go +type Image struct { + XMLName xml.Name `xml:"image"` + // TODO: is it URL or Link? which is it? + URL string `xml:"url"` + Title string `xml:"title"` + Link string `xml:"link"` + Width int `xml:"width,omitempty"` + Height int `xml:"height,omitempty"` +} +``` +Image represents an image. + +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. + + + + + + + + + + +## type [Item](/src/target/item.go?s=606:1746#L15) +``` go +type Item struct { + XMLName xml.Name `xml:"item"` + GUID string `xml:"guid"` + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Author *Author `xml:"-"` + AuthorFormatted string `xml:"author,omitempty"` + Category string `xml:"category,omitempty"` + Comments string `xml:"comments,omitempty"` + Source string `xml:"source,omitempty"` + PubDate *time.Time `xml:"-"` + PubDateFormatted string `xml:"pubDate,omitempty"` + Enclosure *Enclosure + + // https://help.apple.com/itc/podcasts_connect/#/itcb54353390 + IAuthor string `xml:"itunes:author,omitempty"` + ISubtitle string `xml:"itunes:subtitle,omitempty"` + // TODO: CDATA + ISummary string `xml:"itunes:summary,omitempty"` + IImage *IImage + IDuration string `xml:"itunes:duration,omitempty"` + IExplicit string `xml:"itunes:explicit,omitempty"` + IIsClosedCaptioned string `xml:"itunes:isClosedCaptioned,omitempty"` + IOrder string `xml:"itunes:order,omitempty"` +} +``` +Item represents a single entry in a podcast. + +Article minimal requirements are: +- Title +- Description +- Link + +Audio minimal requirements are: +- Title +- Description +- Enclosure (HREF, Type and Length all required) + +Recommendations: +- Setting the minimal fields sets most of other fields, including iTunes. +- Use the Published time.Time setting instead of PubDate. +- Always set an Enclosure.Length, to be nice to your downloaders. +- Use Enclosure.Type instead of setting TypeFormatted for valid extensions. + + + + + + + + + + +### func (\*Item) [AddEnclosure](/src/target/item.go?s=1813:1906#L43) +``` go +func (i *Item) AddEnclosure( + url string, enclosureType EnclosureType, lengthInSeconds int64) +``` +AddEnclosure adds the downloadable asset to the podcast Item. + + + + +## type [Podcast](/src/target/podcast.go?s=176:1774#L9) +``` go +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"` + Image *Image + TextInput *TextInput + + // https://help.apple.com/itc/podcasts_connect/#/itcb54353390 + IAuthor string `xml:"itunes:author,omitempty"` + ISubtitle string `xml:"itunes:subtitle,omitempty"` + // TODO: CDATA + ISummary string `xml:"itunes:summary,omitempty"` + 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 + + Items []*Item +} +``` +Podcast represents a podcast. + + + + + + + +### func [New](/src/target/podcast.go?s=1940:2025#L52) +``` go +func New(title, link, description string, + pubDate, lastBuildDate *time.Time) Podcast +``` +New instantiates a Podcast with required parameters. + +Nil-able fields are optional but recommended as they are formatted +to the expected proper formats. + + + + + +### func (\*Podcast) [AddAuthor](/src/target/podcast.go?s=2403:2440#L67) +``` go +func (p *Podcast) AddAuthor(a Author) +``` +AddAuthor adds the specified Author to the podcast. + + + + +### func (\*Podcast) [AddCategory](/src/target/podcast.go?s=2631:2701#L75) +``` go +func (p *Podcast) AddCategory(category string, subCategories []string) +``` +AddCategory adds the cateories to the Podcast in comma delimited format. + +subCategories are optional. + + + + +### func (\*Podcast) [AddImage](/src/target/podcast.go?s=3478:3513#L103) +``` go +func (p *Podcast) AddImage(i Image) +``` +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 (\*Podcast) [AddItem](/src/target/podcast.go?s=4960:5006#L145) +``` go +func (p *Podcast) AddItem(i Item) (int, error) +``` +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 individial eposide 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 (\*Podcast) [Bytes](/src/target/podcast.go?s=6829:6861#L213) +``` go +func (p *Podcast) Bytes() []byte +``` +Bytes returns an encoded []byte slice. + + + + +### func (\*Podcast) [Encode](/src/target/podcast.go?s=6971:7014#L218) +``` go +func (p *Podcast) Encode(w io.Writer) error +``` +Encode writes the bytes to the io.Writer stream in RSS 2.0 specification. + + + + +### func (\*Podcast) [String](/src/target/podcast.go?s=7091:7124#L223) +``` go +func (p *Podcast) String() string +``` +String encodes the Podcast state to a string. + + + + +## type [TextInput](/src/target/textinput.go?s=77:290#L1) +``` go +type TextInput struct { + XMLName xml.Name `xml:"textInput"` + Title string `xml:"title"` + Description string `xml:"description"` + Name string `xml:"name"` + Link string `xml:"link"` +} +``` +TextInput represents text inputs. + + + + + + + + + + + + + + +- - - +Generated by [godoc2md](http://godoc.org/github.com/davecheney/godoc2md) diff --git a/author.go b/author.go new file mode 100644 index 0000000..e51589f --- /dev/null +++ b/author.go @@ -0,0 +1,12 @@ +package podcast + +import "encoding/xml" + +// Author represents a named author and email. +// +// For iTunes compiance, both Name and Email are required. +type Author struct { + XMLName xml.Name `xml:"itunes:owner"` + Name string `xml:"itunes:name"` + Email string `xml:"itunes:email"` +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..ef536c2 --- /dev/null +++ b/doc.go @@ -0,0 +1,31 @@ +// Package `podcast` is an iTunes and RSS 2.0 podcast generator for GoLang that +// enforces strict compliance by using its simple interface. +// +// [![GoDoc](https://godoc.org/github.com/eduncan911/podcast?status.svg)](https://godoc.org/github.com/eduncan911/podcast) [![Build Status](https://travis-ci.org/eduncan911/podcast.svg?branch=master)](https://travis-ci.org/eduncan911/podcast) [![Go Report Card](https://goreportcard.com/badge/github.com/eduncan911/podcast)](https://goreportcard.com/report/github.com/eduncan911/podcast) +// +// Full documentation with detailed examples located at [![GoDoc](https://godoc.org/github.com/eduncan911/podcast?status.svg)](https://godoc.org/github.com/eduncan911/podcast) +// +// Usage +// +// $ go get -u github.com/eduncan911/podcast +// +// The API exposes a number of method receivers on structs that implements the +// logic required to comply with the specifications and ensure a compliant feed. +// A number of overrides occur to help with iTunes visibility of your episodes. +// +// See the detailed Examples in the GoDocs for complete usage. +// +// Extensiblity +// +// In no way are you restricted in having full control over your feeds. You may +// choose to skip the API methods and instead use the structs directly. The +// fields have been grouped by RSS 2.0 and iTunes fields. +// +// iTunes specific fields are all prefixed with the letter `I`. +// +// References +// +// RSS 2.0: https://cyber.harvard.edu/rss/rss.html +// +// Podcasts: https://help.apple.com/itc/podcasts_connect/#/itca5b22233 +package podcast diff --git a/enclosure.go b/enclosure.go new file mode 100644 index 0000000..bd423fd --- /dev/null +++ b/enclosure.go @@ -0,0 +1,53 @@ +package podcast + +import "encoding/xml" + +// EnclosureType specifies the type of the enclosure. +const ( + M4A EnclosureType = iota + M4V + MP4 + MP3 + MOV + PDF + EPUB +) + +const ( + enclosureDefault = "application/octet-stream" +) + +// EnclosureType specifies the type of the enclosure. +type EnclosureType int + +// String returns the MIME type encoding of the specified EnclosureType. +func (et EnclosureType) String() string { + // https://help.apple.com/itc/podcasts_connect/#/itcb54353390 + switch et { + case M4A: + return "audio/x-m4a" + case M4V: + return "video/x-m4v" + case MP4: + return "video/mp4" + case MP3: + return "audio/mpeg" + case MOV: + return "video/quicktime" + case PDF: + return "application/pdf" + case EPUB: + return "document/x-epub" + } + return enclosureDefault +} + +// Enclosure represents a download enclosure. +type Enclosure struct { + XMLName xml.Name `xml:"enclosure"` + URL string `xml:"url,attr"` + Length int64 `xml:"-"` + LengthFormatted string `xml:"length,attr"` + Type EnclosureType `xml:"-"` + TypeFormatted string `xml:"type,attr"` +} diff --git a/enclosure_test.go b/enclosure_test.go new file mode 100644 index 0000000..56c534e --- /dev/null +++ b/enclosure_test.go @@ -0,0 +1,87 @@ +package podcast_test + +import ( + "github.com/eduncan911/podcast" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestEnclosureTypeM4A(t *testing.T) { + t.Parallel() + + // act + v := podcast.M4A.String() + + // assert + assert.EqualValues(t, "audio/x-m4a", v) +} + +func TestEnclosureTypeM4V(t *testing.T) { + t.Parallel() + + // act + v := podcast.M4V.String() + + // assert + assert.EqualValues(t, "video/x-m4v", v) +} + +func TestEnclosureTypeMP4(t *testing.T) { + t.Parallel() + + // act + v := podcast.MP4.String() + + // assert + assert.EqualValues(t, "video/mp4", v) +} + +func TestEnclosureTypeMP3(t *testing.T) { + t.Parallel() + + // act + v := podcast.MP3.String() + + // assert + assert.EqualValues(t, "audio/mpeg", v) +} + +func TestEnclosureTypeMOV(t *testing.T) { + t.Parallel() + + // act + v := podcast.MOV.String() + + // assert + assert.EqualValues(t, "video/quicktime", v) +} + +func TestEnclosureTypePDF(t *testing.T) { + t.Parallel() + + // act + v := podcast.PDF.String() + + // assert + assert.EqualValues(t, "application/pdf", v) +} + +func TestEnclosureTypeEPUB(t *testing.T) { + t.Parallel() + + // act + v := podcast.EPUB.String() + + // assert + assert.EqualValues(t, "document/x-epub", v) +} + +func TestEnclosureTypeDefault(t *testing.T) { + t.Parallel() + + // act + v := podcast.EnclosureType(99) + + // assert + assert.EqualValues(t, "application/octet-stream", v.String()) +} diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..784cc4e --- /dev/null +++ b/example_test.go @@ -0,0 +1,171 @@ +package podcast_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "strconv" + "time" + + "github.com/eduncan911/podcast" +) + +func Example() { + now := time.Date(2017, time.February, 1, 7, 51, 0, 0, time.Local) + + p := podcast.New( + "Sample Podcasts", + "http://example.com/", + "An example Podcast", + &now, &now, + ) + p.ISubtitle = "A simple Podcast" + p.AddImage(podcast.Image{URL: "http://example.com/podcast.jpg"}) + p.AddAuthor(podcast.Author{ + Name: "Jane Doe", + Email: "jane.doe@example.com", + }) + + for i := int64(0); i < 2; i++ { + n := strconv.FormatInt(i, 10) + + item := podcast.Item{ + Title: "Episode " + n, + Description: "Description for Episode " + n, + ISubtitle: "A simple episode " + n, + PubDate: &now, + } + item.AddEnclosure( + "http://example.com/"+n+".mp3", podcast.MP3, 55*(i+1)) + + // check for validation errors + if _, err := p.AddItem(item); err != nil { + os.Stderr.WriteString("item validation error: " + err.Error()) + } + } + + // Podcast.Encode writes to an io.Writer + if err := p.Encode(os.Stdout); err != nil { + os.Stderr.WriteString("error writing to stdout: " + err.Error()) + } + + // Output: + // + // + // + // Sample Podcasts + // http://example.com/ + // An example Podcast + // go podcast v1.0.0 (github.com/eduncan911/podcast) + // en-us + // Wed, 01 Feb 2017 07:51:00 -0500 + // jane.doe@example.com (Jane Doe) + // Wed, 01 Feb 2017 07:51:00 -0500 + // + // http://example.com/podcast.jpg + // + // + // + // jane.doe@example.com (Jane Doe) + // A simple Podcast + // + // + // http://example.com/0.mp3 + // Episode 0 + // http://example.com/0.mp3 + // Description for Episode 0 + // Wed, 01 Feb 2017 07:51:00 -0500 + // + // jane.doe@example.com (Jane Doe) + // A simple episode 0 + // + // 55 + // + // + // http://example.com/1.mp3 + // Episode 1 + // http://example.com/1.mp3 + // Description for Episode 1 + // Wed, 01 Feb 2017 07:51:00 -0500 + // + // jane.doe@example.com (Jane Doe) + // A simple episode 1 + // + // 110 + // + // + // +} + +func Example_encode() { + + // ResponseWriter example using Podcast.Encode(w io.Writer). + // + httpHandler := func(w http.ResponseWriter, r *http.Request) { + + p := podcast.New( + "eduncan911 Podcasts", + "http://eduncan911.com/", + "An example Podcast", + &pubDate, &pubDate, + ) + for i := int64(0); i < 3; i++ { + n := strconv.FormatInt(i, 10) + + item := podcast.Item{ + Title: "Episode " + n, + Link: "http://example.com/" + n + ".mp3", + Description: "Description for Episode " + n, + PubDate: &pubDate, + } + if _, err := p.AddItem(item); err != nil { + fmt.Println(item.Title, ": error", err.Error()) + return + } + } + + w.Header().Set("Content-Type", "application/xml") + if err := p.Encode(w); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } + + rr := httptest.NewRecorder() + httpHandler(rr, nil) + os.Stdout.Write(rr.Body.Bytes()) + // Output: + // + // + // + // eduncan911 Podcasts + // http://eduncan911.com/ + // An example Podcast + // go podcast v1.0.0 (github.com/eduncan911/podcast) + // en-us + // Wed, 01 Feb 2017 08:21:52 -0500 + // Wed, 01 Feb 2017 08:21:52 -0500 + // + // http://example.com/0.mp3 + // Episode 0 + // http://example.com/0.mp3 + // Description for Episode 0 + // Wed, 01 Feb 2017 08:21:52 -0500 + // + // + // http://example.com/1.mp3 + // Episode 1 + // http://example.com/1.mp3 + // Description for Episode 1 + // Wed, 01 Feb 2017 08:21:52 -0500 + // + // + // http://example.com/2.mp3 + // Episode 2 + // http://example.com/2.mp3 + // Description for Episode 2 + // Wed, 01 Feb 2017 08:21:52 -0500 + // + // + // +} diff --git a/examples_test.go b/examples_test.go new file mode 100644 index 0000000..3c93863 --- /dev/null +++ b/examples_test.go @@ -0,0 +1,174 @@ +package podcast_test + +import ( + "fmt" + "os" + "strconv" + "time" + + "github.com/eduncan911/podcast" +) + +var ( + pubDate = time.Date(2017, time.February, 1, 8, 21, 52, 0, time.Local) +) + +func ExampleNew() { + ti, l, d := "title", "link", "description" + + p := podcast.New(ti, l, d, &pubDate, &pubDate) + + fmt.Println(p.Title, p.Link, p.Description, p.Language) + fmt.Println(p.PubDate, p.LastBuildDate) + // Output: + // title link description en-us + // Wed, 01 Feb 2017 08:21:52 -0500 Wed, 01 Feb 2017 08:21:52 -0500 +} + +func ExamplePodcast_AddAuthor() { + p := podcast.New("title", "link", "description", nil, nil) + + p.AddAuthor(podcast.Author{ + Name: "the name", + Email: "me@test.com", + }) + + fmt.Println(p.ManagingEditor) + fmt.Println(p.IAuthor) + // Output: + // me@test.com (the name) + // me@test.com (the name) +} + +func ExamplePodcast_AddCategory() { + p := podcast.New("title", "link", "description", nil, nil) + + p.AddCategory("Taby", nil) + p.AddCategory("North American", []string{"Long Hair", "Short Hair"}) + p.AddCategory("Simese", nil) + + fmt.Println(len(p.ICategories), len(p.ICategories[1].ICategories)) + fmt.Println(p.Category) + // Output: + // 3 2 + // Taby,North American,Simese +} + +func ExamplePodcast_AddImage() { + p := podcast.New("title", "link", "description", nil, nil) + + p.AddImage(podcast.Image{ + URL: "http://example.com/image.jpg", + }) + + if p.Image != nil && p.IImage != nil { + fmt.Println(p.Image.URL) + fmt.Println(p.IImage.HREF) + } + // Output: + // http://example.com/image.jpg + // http://example.com/image.jpg +} + +func ExamplePodcast_AddItem() { + p := podcast.New("title", "link", "description", &pubDate, &pubDate) + p.AddAuthor(podcast.Author{Name: "the name", Email: "me@test.com"}) + p.AddImage(podcast.Image{URL: "http://example.com/image.jpg"}) + + item := podcast.Item{ + Title: "Episode 1", + Description: "Description for Episode 1", + ISubtitle: "A simple episode 1", + PubDate: &pubDate, + } + item.AddEnclosure( + "http://example.com/1.mp3", + podcast.MP3, + 183, + ) + if _, err := p.AddItem(item); err != nil { + fmt.Println("item validation error: " + err.Error()) + } + + if len(p.Items) != 1 { + fmt.Println("expected 1 item in the collection") + } + pp := p.Items[0] + fmt.Println( + pp.GUID, pp.Title, pp.Link, pp.Description, pp.Author, + pp.AuthorFormatted, pp.Category, pp.Comments, pp.Source, + pp.PubDate, pp.PubDateFormatted, *pp.Enclosure, + pp.IAuthor, pp.IDuration, pp.IExplicit, pp.IIsClosedCaptioned, + pp.IOrder, pp.ISubtitle, pp.ISummary) + // Output: + // http://example.com/1.mp3 Episode 1 http://example.com/1.mp3 Description for Episode 1 &{{ } me@test.com (the name)} 2017-02-01 08:21:52 -0500 EST Wed, 01 Feb 2017 08:21:52 -0500 {{ } http://example.com/1.mp3 183 183 audio/mpeg audio/mpeg} me@test.com (the name) 183 A simple episode 1 +} + +func ExamplePodcast_Bytes() { + pubDate := time.Date(2017, time.February, 1, 9, 11, 0, 0, time.Local) + + p := podcast.New( + "eduncan911 Podcasts", + "http://eduncan911.com/", + "An example Podcast", + &pubDate, &pubDate, + ) + for i := int64(0); i < 4; i++ { + n := strconv.FormatInt(i, 10) + + item := podcast.Item{ + Title: "Episode " + n, + Link: "http://example.com/" + n + ".mp3", + Description: "Description for Episode " + n, + PubDate: &pubDate, + } + if _, err := p.AddItem(item); err != nil { + fmt.Println(item.Title, ": error", err.Error()) + return + } + } + + os.Stdout.Write(p.Bytes()) + + // Output: + // + // + // + // eduncan911 Podcasts + // http://eduncan911.com/ + // An example Podcast + // go podcast v1.0.0 (github.com/eduncan911/podcast) + // en-us + // Wed, 01 Feb 2017 09:11:00 -0500 + // Wed, 01 Feb 2017 09:11:00 -0500 + // + // http://example.com/0.mp3 + // Episode 0 + // http://example.com/0.mp3 + // Description for Episode 0 + // Wed, 01 Feb 2017 09:11:00 -0500 + // + // + // http://example.com/1.mp3 + // Episode 1 + // http://example.com/1.mp3 + // Description for Episode 1 + // Wed, 01 Feb 2017 09:11:00 -0500 + // + // + // http://example.com/2.mp3 + // Episode 2 + // http://example.com/2.mp3 + // Description for Episode 2 + // Wed, 01 Feb 2017 09:11:00 -0500 + // + // + // http://example.com/3.mp3 + // Episode 3 + // http://example.com/3.mp3 + // Description for Episode 3 + // Wed, 01 Feb 2017 09:11:00 -0500 + // + // + // +} diff --git a/image.go b/image.go new file mode 100644 index 0000000..d35b5cb --- /dev/null +++ b/image.go @@ -0,0 +1,21 @@ +package podcast + +import "encoding/xml" + +// Image represents an image. +// +// 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. +type Image struct { + XMLName xml.Name `xml:"image"` + // TODO: is it URL or Link? which is it? + URL string `xml:"url"` + Title string `xml:"title"` + Link string `xml:"link"` + Width int `xml:"width,omitempty"` + Height int `xml:"height,omitempty"` +} diff --git a/item.go b/item.go new file mode 100644 index 0000000..c6c0754 --- /dev/null +++ b/item.go @@ -0,0 +1,60 @@ +package podcast + +import ( + "encoding/xml" + "time" +) + +// Item represents a single entry in a podcast. +// +// Article minimal requirements are: +// - Title +// - Description +// - Link +// +// Audio minimal requirements are: +// - Title +// - Description +// - Enclosure (HREF, Type and Length all required) +// +// Recommendations: +// - Setting the minimal fields sets most of other fields, including iTunes. +// - Use the Published time.Time setting instead of PubDate. +// - Always set an Enclosure.Length, to be nice to your downloaders. +// - Use Enclosure.Type instead of setting TypeFormatted for valid extensions. +type Item struct { + XMLName xml.Name `xml:"item"` + GUID string `xml:"guid"` + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Author *Author `xml:"-"` + AuthorFormatted string `xml:"author,omitempty"` + Category string `xml:"category,omitempty"` + Comments string `xml:"comments,omitempty"` + Source string `xml:"source,omitempty"` + PubDate *time.Time `xml:"-"` + PubDateFormatted string `xml:"pubDate,omitempty"` + Enclosure *Enclosure + + // https://help.apple.com/itc/podcasts_connect/#/itcb54353390 + IAuthor string `xml:"itunes:author,omitempty"` + ISubtitle string `xml:"itunes:subtitle,omitempty"` + // TODO: CDATA + ISummary string `xml:"itunes:summary,omitempty"` + IImage *IImage + IDuration string `xml:"itunes:duration,omitempty"` + IExplicit string `xml:"itunes:explicit,omitempty"` + IIsClosedCaptioned string `xml:"itunes:isClosedCaptioned,omitempty"` + IOrder string `xml:"itunes:order,omitempty"` +} + +// AddEnclosure adds the downloadable asset to the podcast Item. +func (i *Item) AddEnclosure( + url string, enclosureType EnclosureType, lengthInSeconds int64) { + i.Enclosure = &Enclosure{ + URL: url, + Type: enclosureType, + Length: lengthInSeconds, + } +} diff --git a/itunes.go b/itunes.go new file mode 100644 index 0000000..d8f9838 --- /dev/null +++ b/itunes.go @@ -0,0 +1,26 @@ +package podcast + +import "encoding/xml" + +// Speicfication: https://help.apple.com/itc/podcasts_connect/#/itcb54353390 +// + +// IImage represents an iTunes image. +// +// 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. +type IImage struct { + XMLName xml.Name `xml:"itunes:image"` + HREF string `xml:"href,attr"` +} + +// ICategory is a 2-tier classification system for iTunes. +type ICategory struct { + XMLName xml.Name `xml:"itunes:category"` + Text string `xml:"text,attr"` + ICategories []*ICategory +} diff --git a/podcast.go b/podcast.go new file mode 100644 index 0000000..8f287de --- /dev/null +++ b/podcast.go @@ -0,0 +1,294 @@ +package podcast + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "strconv" + "time" + + "github.com/pkg/errors" +) + +const ( + pVersion = "1.0.0" +) + +// 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"` + Image *Image + TextInput *TextInput + + // https://help.apple.com/itc/podcasts_connect/#/itcb54353390 + IAuthor string `xml:"itunes:author,omitempty"` + ISubtitle string `xml:"itunes:subtitle,omitempty"` + // TODO: CDATA + ISummary string `xml:"itunes:summary,omitempty"` + 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 + + Items []*Item +} + +// 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 { + p := Podcast{ + Title: title, + Link: link, + Description: description, + Generator: fmt.Sprintf("go podcast v%s (github.com/eduncan911/podcast)", pVersion), + PubDate: parseDateRFC1123Z(pubDate), + LastBuildDate: parseDateRFC1123Z(lastBuildDate), + Language: "en-us", + } + return p +} + +// AddAuthor adds the specified Author to the podcast. +func (p *Podcast) AddAuthor(a Author) { + p.ManagingEditor = parseAuthorNameEmail(&a) + p.IAuthor = p.ManagingEditor +} + +// AddCategory adds the cateories to the Podcast in comma delimited format. +// +// subCategories are optional. +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 { + 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(i Image) { + p.Image = &i + p.IImage = &IImage{HREF: p.Image.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 individial eposide 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 reuired") + } + 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 { + 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 { + if i.Author != nil { + i.IAuthor = i.Author.Email + } else if len(p.IAuthor) != 0 { + i.Author = &Author{Email: p.IAuthor} + i.IAuthor = p.IAuthor + } else if 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} + } + } + if i.Enclosure != nil { + i.IDuration = parseDuration(i.Enclosure.Length) + } + + p.Items = append(p.Items, &i) + return len(p.Items), nil +} + +// 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 { + return encode(w, *p) +} + +// String encodes the Podcast state to a string. +func (p *Podcast) String() string { + b := new(bytes.Buffer) + if err := encode(b, *p); err != nil { + return "String: podcast.write returned the error: " + err.Error() + } + return b.String() +} + +type podcastWrapper struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + XMLNS string `xml:"xmlns:itunes,attr"` + Channel *Podcast +} + +// encode writes the bytes to the io.Writer in RSS 2.0 specification. +func encode(w io.Writer, p Podcast) error { + e := xml.NewEncoder(w) + e.Indent("", " ") + + // + w.Write([]byte("\n")) + // + wrapped := podcastWrapper{ + XMLNS: "http://www.itunes.com/dtds/podcast-1.0.dtd", + Version: "2.0", + Channel: &p, + } + if err := e.Encode(wrapped); err != nil { + return errors.Wrap(err, "podcast.encode: Encode returned error") + } + return nil +} + +func parseDateRFC1123Z(t *time.Time) string { + if t != nil && !t.IsZero() { + return t.Format(time.RFC1123Z) + } + return time.Now().UTC().Format(time.RFC1123Z) +} + +func parseAuthorNameEmail(a *Author) string { + var author string + if a != nil { + author = a.Email + if len(a.Name) > 0 { + author = fmt.Sprintf("%s (%s)", a.Email, a.Name) + } + } + return author +} + +func parseDuration(duration int64) string { + // TODO: parse the output into iTunes nicely formatted version. + // + // iTunes supports the following: + // HH:MM:SS + // H:MM:SS + // MM:SS + // M:SS + return strconv.FormatInt(duration, 10) +} diff --git a/podcast_test.go b/podcast_test.go new file mode 100644 index 0000000..9f36b84 --- /dev/null +++ b/podcast_test.go @@ -0,0 +1,240 @@ +package podcast_test + +import ( + "testing" + "time" + + "github.com/eduncan911/podcast" + "github.com/stretchr/testify/assert" +) + +func TestNewNils(t *testing.T) { + t.Parallel() + + // arrange + ti, l, d := "title", "link", "description" + + // act + p := podcast.New(ti, l, d, nil, nil) + + // assert + assert.EqualValues(t, ti, p.Title) + assert.EqualValues(t, l, p.Link) + assert.EqualValues(t, d, p.Description) + assert.True(t, time.Now().UTC().Format(time.RFC1123Z) >= p.PubDate) +} + +func TestAddCategoryEmpty(t *testing.T) { + t.Parallel() + + // arrange + p := podcast.New("title", "link", "description", nil, nil) + + // act + p.AddCategory("", nil) + + // assert + assert.Len(t, p.ICategories, 0) + assert.Len(t, p.Category, 0) +} + +func TestAddItemEmptyTitleDescription(t *testing.T) { + t.Parallel() + + // arrange + p := podcast.New("title", "link", "description", nil, nil) + i := podcast.Item{} + + // act + added, err := p.AddItem(i) + + // assert + assert.EqualValues(t, 0, added) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Title") + assert.Contains(t, err.Error(), "Description") +} + +func TestAddItemEnclosureURLEmpty(t *testing.T) { + t.Parallel() + + // arrange + p := podcast.New("title", "link", "description", nil, nil) + i := podcast.Item{Title: "title", Description: "desc"} + i.AddEnclosure("", podcast.MP3, 1) + + // act + added, err := p.AddItem(i) + + // assert + assert.EqualValues(t, 0, added) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Enclosure.URL is required") +} + +func TestAddItemEnclosureTypeEmpty(t *testing.T) { + t.Parallel() + + // arrange + p := podcast.New("title", "link", "description", nil, nil) + i := podcast.Item{Title: "title", Description: "desc"} + i.AddEnclosure("http://example.com/1.mp3", 99, 1) + + // act + added, err := p.AddItem(i) + + // assert + assert.EqualValues(t, 0, added) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Enclosure.Type is required") +} + +func TestAddItemLinkEmpty(t *testing.T) { + t.Parallel() + + // arrange + p := podcast.New("title", "link", "description", nil, nil) + i := podcast.Item{Title: "title", Description: "desc"} + + // act + added, err := p.AddItem(i) + + // assert + assert.EqualValues(t, 0, added) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Link is required") +} + +func TestAddItemEnclosureLengthMin(t *testing.T) { + t.Parallel() + + // arrange + p := podcast.New("title", "link", "description", nil, nil) + i := podcast.Item{Title: "title", Description: "desc"} + i.AddEnclosure("http://example.com/1.mp3", podcast.MP3, -1) + + // act + added, err := p.AddItem(i) + + // assert + assert.EqualValues(t, 1, added) + assert.NoError(t, err) + assert.Len(t, p.Items, 1) + assert.EqualValues(t, 0, p.Items[0].Enclosure.Length) +} + +func TestAddItemEnclosureNoLinkOverride(t *testing.T) { + t.Parallel() + + // arrange + p := podcast.New("title", "link", "description", nil, nil) + i := podcast.Item{Title: "title", Description: "desc"} + i.AddEnclosure("http://example.com/1.mp3", podcast.MP3, -1) + + // act + added, err := p.AddItem(i) + + // assert + assert.EqualValues(t, 1, added) + assert.NoError(t, err) + assert.Len(t, p.Items, 1) + assert.EqualValues(t, i.Enclosure.URL, p.Items[0].Link) +} + +func TestAddItemEnclosureLinkPresentNoOverride(t *testing.T) { + t.Parallel() + + // arrange + theLink := "http://someotherurl.com/story.html" + p := podcast.New("title", "link", "description", nil, nil) + i := podcast.Item{Title: "title", Description: "desc"} + i.Link = theLink + i.AddEnclosure("http://example.com/1.mp3", podcast.MP3, -1) + + // act + added, err := p.AddItem(i) + + // assert + assert.EqualValues(t, 1, added) + assert.NoError(t, err) + assert.Len(t, p.Items, 1) + assert.EqualValues(t, theLink, p.Items[0].Link) +} + +func TestAddItemNoEnclosureGUIDValid(t *testing.T) { + t.Parallel() + + // arrange + theLink := "http://someotherurl.com/story.html" + p := podcast.New("title", "link", "description", nil, nil) + i := podcast.Item{Title: "title", Description: "desc"} + i.Link = theLink + + // act + added, err := p.AddItem(i) + + // assert + assert.EqualValues(t, 1, added) + assert.NoError(t, err) + assert.Len(t, p.Items, 1) + assert.EqualValues(t, theLink, p.Items[0].GUID) +} + +func TestAddItemAuthor(t *testing.T) { + t.Parallel() + + // arrange + theAuthor := podcast.Author{Name: "Jane Doe", Email: "me@janedoe.com"} + p := podcast.New("title", "link", "description", nil, nil) + i := podcast.Item{Title: "title", Description: "desc", Link: "http://a.co/"} + i.Author = &theAuthor + + // act + added, err := p.AddItem(i) + + // assert + assert.EqualValues(t, 1, added) + assert.NoError(t, err) + assert.Len(t, p.Items, 1) + assert.EqualValues(t, &theAuthor, p.Items[0].Author) + assert.EqualValues(t, theAuthor.Email, p.Items[0].IAuthor) +} + +func TestAddItemRootManagingEditorSetsAuthorIAuthor(t *testing.T) { + t.Parallel() + + // arrange + theAuthor := "me@janedoe.com" + p := podcast.New("title", "link", "description", nil, nil) + p.ManagingEditor = theAuthor + i := podcast.Item{Title: "title", Description: "desc", Link: "http://a.co/"} + + // act + added, err := p.AddItem(i) + + // assert + assert.EqualValues(t, 1, added) + assert.NoError(t, err) + assert.Len(t, p.Items, 1) + assert.EqualValues(t, theAuthor, p.Items[0].Author.Email) + assert.EqualValues(t, theAuthor, p.Items[0].IAuthor) +} + +func TestAddItemRootIAuthorSetsAuthorIAuthor(t *testing.T) { + t.Parallel() + + // arrange + p := podcast.New("title", "link", "description", nil, nil) + p.IAuthor = "me@janedoe.com" + i := podcast.Item{Title: "title", Description: "desc", Link: "http://a.co/"} + + // act + added, err := p.AddItem(i) + + // assert + assert.EqualValues(t, 1, added) + assert.NoError(t, err) + assert.Len(t, p.Items, 1) + assert.EqualValues(t, "me@janedoe.com", p.Items[0].Author.Email) + assert.EqualValues(t, "me@janedoe.com", p.Items[0].IAuthor) +} diff --git a/textinput.go b/textinput.go new file mode 100644 index 0000000..296f25e --- /dev/null +++ b/textinput.go @@ -0,0 +1,12 @@ +package podcast + +import "encoding/xml" + +// TextInput represents text inputs. +type TextInput struct { + XMLName xml.Name `xml:"textInput"` + Title string `xml:"title"` + Description string `xml:"description"` + Name string `xml:"name"` + Link string `xml:"link"` +}