14 changed files with 1693 additions and 0 deletions
@ -0,0 +1,7 @@
|
||||
language: go |
||||
|
||||
go: |
||||
- stable |
||||
|
||||
script: |
||||
- go test -cover -v ./... |
@ -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. |
||||
|
||||
[](<a href="https://godoc.org/github.com/eduncan911/podcast">https://godoc.org/github.com/eduncan911/podcast</a>) [](<a href="https://travis-ci.org/eduncan911/podcast">https://travis-ci.org/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 [](<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) |
@ -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"` |
||||
} |
@ -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.
|
||||
//
|
||||
// [](https://godoc.org/github.com/eduncan911/podcast) [](https://travis-ci.org/eduncan911/podcast) [](https://goreportcard.com/report/github.com/eduncan911/podcast)
|
||||
//
|
||||
// Full documentation with detailed examples located at [](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 |
@ -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"` |
||||
} |
@ -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()) |
||||
} |
@ -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>
|
||||
} |
@ -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>
|
||||
} |
@ -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"` |
||||
} |
@ -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, |
||||
} |
||||
} |
@ -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 |
||||
} |
@ -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) |
||||
} |
@ -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" |