Browse Source

initial version

master
Eric Duncan 5 years ago
parent
commit
5b523d8227
  1. 7
      .travis.yml
  2. 505
      README.md
  3. 12
      author.go
  4. 31
      doc.go
  5. 53
      enclosure.go
  6. 87
      enclosure_test.go
  7. 171
      example_test.go
  8. 174
      examples_test.go
  9. 21
      image.go
  10. 60
      item.go
  11. 26
      itunes.go
  12. 294
      podcast.go
  13. 240
      podcast_test.go
  14. 12
      textinput.go

7
.travis.yml

@ -0,0 +1,7 @@
language: go
go:
- stable
script:
- go test -cover -v ./...

505
README.md

@ -0,0 +1,505 @@
# podcast
`import "github.com/eduncan911/podcast"`
* [Overview](#pkg-overview)
* [Index](#pkg-index)
* [Examples](#pkg-examples)
## <a name="pkg-overview">Overview</a>
Package `podcast` is an iTunes and RSS 2.0 podcast generator for GoLang that
enforces strict compliance by using its simple interface.
[![GoDoc](<a href="https://godoc.org/github.com/eduncan911/podcast?status.svg">https://godoc.org/github.com/eduncan911/podcast?status.svg</a>)](<a href="https://godoc.org/github.com/eduncan911/podcast">https://godoc.org/github.com/eduncan911/podcast</a>) [![Build Status](<a href="https://travis-ci.org/eduncan911/podcast.svg?branch=master">https://travis-ci.org/eduncan911/podcast.svg?branch=master</a>)](<a href="https://travis-ci.org/eduncan911/podcast">https://travis-ci.org/eduncan911/podcast</a>) [![Go Report Card](<a href="https://goreportcard.com/badge/github.com/eduncan911/podcast">https://goreportcard.com/badge/github.com/eduncan911/podcast</a>)](<a href="https://goreportcard.com/report/github.com/eduncan911/podcast">https://goreportcard.com/report/github.com/eduncan911/podcast</a>)
Full documentation with detailed examples located at [![GoDoc](<a href="https://godoc.org/github.com/eduncan911/podcast?status.svg">https://godoc.org/github.com/eduncan911/podcast?status.svg</a>)](<a href="https://godoc.org/github.com/eduncan911/podcast">https://godoc.org/github.com/eduncan911/podcast</a>)
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: <a href="https://cyber.harvard.edu/rss/rss.html">https://cyber.harvard.edu/rss/rss.html</a>
Podcasts: <a href="https://help.apple.com/itc/podcasts_connect/#/itca5b22233">https://help.apple.com/itc/podcasts_connect/#/itca5b22233</a>
## <a name="pkg-index">Index</a>
* [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)
#### <a name="pkg-examples">Examples</a>
* [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)
#### <a name="pkg-files">Package files</a>
[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)
## <a name="Author">type</a> [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.
## <a name="Enclosure">type</a> [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.
## <a name="EnclosureType">type</a> [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.
### <a name="EnclosureType.String">func</a> (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.
## <a name="ICategory">type</a> [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.
## <a name="IImage">type</a> [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.
## <a name="Image">type</a> [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.
## <a name="Item">type</a> [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.
### <a name="Item.AddEnclosure">func</a> (\*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.
## <a name="Podcast">type</a> [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.
### <a name="New">func</a> [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.
### <a name="Podcast.AddAuthor">func</a> (\*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.
### <a name="Podcast.AddCategory">func</a> (\*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.
### <a name="Podcast.AddImage">func</a> (\*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.
### <a name="Podcast.AddItem">func</a> (\*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:
<a href="https://help.apple.com/itc/podcasts_connect/#/itc2b3780e76">https://help.apple.com/itc/podcasts_connect/#/itc2b3780e76</a>
* For specifications of itunes tags, see:
<a href="https://help.apple.com/itc/podcasts_connect/#/itcb54353390">https://help.apple.com/itc/podcasts_connect/#/itcb54353390</a>
### <a name="Podcast.Bytes">func</a> (\*Podcast) [Bytes](/src/target/podcast.go?s=6829:6861#L213)
``` go
func (p *Podcast) Bytes() []byte
```
Bytes returns an encoded []byte slice.
### <a name="Podcast.Encode">func</a> (\*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.
### <a name="Podcast.String">func</a> (\*Podcast) [String](/src/target/podcast.go?s=7091:7124#L223)
``` go
func (p *Podcast) String() string
```
String encodes the Podcast state to a string.
## <a name="TextInput">type</a> [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)

12
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"`
}

31
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

53
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"`
}

87
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())
}

171
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:
// <?xml version="1.0" encoding="UTF-8"?>
// <rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
// <channel>
// <title>Sample Podcasts</title>
// <link>http://example.com/</link>
// <description>An example Podcast</description>
// <generator>go podcast v1.0.0 (github.com/eduncan911/podcast)</generator>
// <language>en-us</language>
// <lastBuildDate>Wed, 01 Feb 2017 07:51:00 -0500</lastBuildDate>
// <managingEditor>jane.doe@example.com (Jane Doe)</managingEditor>
// <pubDate>Wed, 01 Feb 2017 07:51:00 -0500</pubDate>
// <image>
// <url>http://example.com/podcast.jpg</url>
// <title></title>
// <link></link>
// </image>
// <itunes:author>jane.doe@example.com (Jane Doe)</itunes:author>
// <itunes:subtitle>A simple Podcast</itunes:subtitle>
// <itunes:image href="http://example.com/podcast.jpg"></itunes:image>
// <item>
// <guid>http://example.com/0.mp3</guid>
// <title>Episode 0</title>
// <link>http://example.com/0.mp3</link>
// <description>Description for Episode 0</description>
// <pubDate>Wed, 01 Feb 2017 07:51:00 -0500</pubDate>
// <enclosure url="http://example.com/0.mp3" length="55" type="audio/mpeg"></enclosure>
// <itunes:author>jane.doe@example.com (Jane Doe)</itunes:author>
// <itunes:subtitle>A simple episode 0</itunes:subtitle>
// <itunes:image href="http://example.com/podcast.jpg"></itunes:image>
// <itunes:duration>55</itunes:duration>
// </item>
// <item>
// <guid>http://example.com/1.mp3</guid>
// <title>Episode 1</title>
// <link>http://example.com/1.mp3</link>
// <description>Description for Episode 1</description>
// <pubDate>Wed, 01 Feb 2017 07:51:00 -0500</pubDate>
// <enclosure url="http://example.com/1.mp3" length="110" type="audio/mpeg"></enclosure>
// <itunes:author>jane.doe@example.com (Jane Doe)</itunes:author>
// <itunes:subtitle>A simple episode 1</itunes:subtitle>
// <itunes:image href="http://example.com/podcast.jpg"></itunes:image>
// <itunes:duration>110</itunes:duration>
// </item>
// </channel>
// </rss>
}
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:
// <?xml version="1.0" encoding="UTF-8"?>
// <rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
// <channel>
// <title>eduncan911 Podcasts</title>
// <link>http://eduncan911.com/</link>
// <description>An example Podcast</description>
// <generator>go podcast v1.0.0 (github.com/eduncan911/podcast)</generator>
// <language>en-us</language>
// <lastBuildDate>Wed, 01 Feb 2017 08:21:52 -0500</lastBuildDate>
// <pubDate>Wed, 01 Feb 2017 08:21:52 -0500</pubDate>
// <item>
// <guid>http://example.com/0.mp3</guid>
// <title>Episode 0</title>
// <link>http://example.com/0.mp3</link>
// <description>Description for Episode 0</description>
// <pubDate>Wed, 01 Feb 2017 08:21:52 -0500</pubDate>
// </item>
// <item>
// <guid>http://example.com/1.mp3</guid>
// <title>Episode 1</title>
// <link>http://example.com/1.mp3</link>
// <description>Description for Episode 1</description>
// <pubDate>Wed, 01 Feb 2017 08:21:52 -0500</pubDate>
// </item>
// <item>
// <guid>http://example.com/2.mp3</guid>
// <title>Episode 2</title>
// <link>http://example.com/2.mp3</link>
// <description>Description for Episode 2</description>
// <pubDate>Wed, 01 Feb 2017 08:21:52 -0500</pubDate>
// </item>
// </channel>
// </rss>
}

174
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:
// <?xml version="1.0" encoding="UTF-8"?>
// <rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
// <channel>
// <title>eduncan911 Podcasts</title>
// <link>http://eduncan911.com/</link>
// <description>An example Podcast</description>
// <generator>go podcast v1.0.0 (github.com/eduncan911/podcast)</generator>
// <language>en-us</language>
// <lastBuildDate>Wed, 01 Feb 2017 09:11:00 -0500</lastBuildDate>
// <pubDate>Wed, 01 Feb 2017 09:11:00 -0500</pubDate>
// <item>
// <guid>http://example.com/0.mp3</guid>
// <title>Episode 0</title>
// <link>http://example.com/0.mp3</link>
// <description>Description for Episode 0</description>
// <pubDate>Wed, 01 Feb 2017 09:11:00 -0500</pubDate>
// </item>
// <item>
// <guid>http://example.com/1.mp3</guid>
// <title>Episode 1</title>
// <link>http://example.com/1.mp3</link>
// <description>Description for Episode 1</description>
// <pubDate>Wed, 01 Feb 2017 09:11:00 -0500</pubDate>
// </item>
// <item>
// <guid>http://example.com/2.mp3</guid>
// <title>Episode 2</title>
// <link>http://example.com/2.mp3</link>
// <description>Description for Episode 2</description>
// <pubDate>Wed, 01 Feb 2017 09:11:00 -0500</pubDate>
// </item>
// <item>
// <guid>http://example.com/3.mp3</guid>
// <title>Episode 3</title>
// <link>http://example.com/3.mp3</link>
// <description>Description for Episode 3</description>
// <pubDate>Wed, 01 Feb 2017 09:11:00 -0500</pubDate>
// </item>
// </channel>
// </rss>
}

21
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"`
}

60
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,
}
}

26
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
}

294
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("", " ")
// <?xml version="1.0" encoding="UTF-8"?>
w.Write([]byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"))
// <rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
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)
}

240
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"