Browse Source

Add support of media elements for Atom feeds

Frédéric Guillot 6 years ago
parent
commit
912a98788e
6 changed files with 569 additions and 102 deletions
  1. 52 17
      reader/atom/atom.go
  2. 152 19
      reader/atom/parser_test.go
  3. 176 0
      reader/media/media.go
  4. 110 0
      reader/media/media_test.go
  5. 49 0
      reader/rss/parser_test.go
  6. 30 66
      reader/rss/rss.go

+ 52 - 17
reader/atom/atom.go

@@ -15,6 +15,7 @@ import (
 	"miniflux.app/logger"
 	"miniflux.app/model"
 	"miniflux.app/reader/date"
+	"miniflux.app/reader/media"
 	"miniflux.app/reader/sanitizer"
 	"miniflux.app/url"
 )
@@ -29,15 +30,15 @@ type atomFeed struct {
 }
 
 type atomEntry struct {
-	ID         string         `xml:"id"`
-	Title      atomContent    `xml:"title"`
-	Published  string         `xml:"published"`
-	Updated    string         `xml:"updated"`
-	Links      []atomLink     `xml:"link"`
-	Summary    atomContent    `xml:"summary"`
-	Content    atomContent    `xml:"content"`
-	MediaGroup atomMediaGroup `xml:"http://search.yahoo.com/mrss/ group"`
-	Author     atomAuthor     `xml:"author"`
+	ID        string      `xml:"id"`
+	Title     atomContent `xml:"title"`
+	Published string      `xml:"published"`
+	Updated   string      `xml:"updated"`
+	Links     []atomLink  `xml:"link"`
+	Summary   atomContent `xml:"summary"`
+	Content   atomContent `xml:"http://www.w3.org/2005/Atom content"`
+	Author    atomAuthor  `xml:"author"`
+	media.Element
 }
 
 type atomAuthor struct {
@@ -58,10 +59,6 @@ type atomContent struct {
 	XML  string `xml:",innerxml"`
 }
 
-type atomMediaGroup struct {
-	Description string `xml:"http://search.yahoo.com/mrss/ description"`
-}
-
 func (a *atomFeed) Transform() *model.Feed {
 	feed := new(model.Feed)
 	feed.FeedURL = getRelationURL(a.Links, "self")
@@ -179,8 +176,9 @@ func getContent(a *atomEntry) string {
 		return r
 	}
 
-	if a.MediaGroup.Description != "" {
-		return a.MediaGroup.Description
+	mediaDescription := a.FirstMediaDescription()
+	if mediaDescription != "" {
+		return mediaDescription
 	}
 
 	return ""
@@ -203,11 +201,48 @@ func getHash(a *atomEntry) string {
 
 func getEnclosures(a *atomEntry) model.EnclosureList {
 	enclosures := make(model.EnclosureList, 0)
+	duplicates := make(map[string]bool, 0)
+
+	for _, mediaThumbnail := range a.AllMediaThumbnails() {
+		if _, found := duplicates[mediaThumbnail.URL]; !found {
+			duplicates[mediaThumbnail.URL] = true
+			enclosures = append(enclosures, &model.Enclosure{
+				URL:      mediaThumbnail.URL,
+				MimeType: mediaThumbnail.MimeType(),
+				Size:     mediaThumbnail.Size(),
+			})
+		}
+	}
 
 	for _, link := range a.Links {
 		if strings.ToLower(link.Rel) == "enclosure" {
-			length, _ := strconv.ParseInt(link.Length, 10, 0)
-			enclosures = append(enclosures, &model.Enclosure{URL: link.URL, MimeType: link.Type, Size: length})
+			if _, found := duplicates[link.URL]; !found {
+				duplicates[link.URL] = true
+				length, _ := strconv.ParseInt(link.Length, 10, 0)
+				enclosures = append(enclosures, &model.Enclosure{URL: link.URL, MimeType: link.Type, Size: length})
+			}
+		}
+	}
+
+	for _, mediaContent := range a.AllMediaContents() {
+		if _, found := duplicates[mediaContent.URL]; !found {
+			duplicates[mediaContent.URL] = true
+			enclosures = append(enclosures, &model.Enclosure{
+				URL:      mediaContent.URL,
+				MimeType: mediaContent.MimeType(),
+				Size:     mediaContent.Size(),
+			})
+		}
+	}
+
+	for _, mediaPeerLink := range a.AllMediaPeerLinks() {
+		if _, found := duplicates[mediaPeerLink.URL]; !found {
+			duplicates[mediaPeerLink.URL] = true
+			enclosures = append(enclosures, &model.Enclosure{
+				URL:      mediaPeerLink.URL,
+				MimeType: mediaPeerLink.MimeType(),
+				Size:     mediaPeerLink.Size(),
+			})
 		}
 	}
 

+ 152 - 19
reader/atom/parser_test.go

@@ -472,31 +472,30 @@ func TestParseEntryWithEnclosures(t *testing.T) {
 	}
 
 	if len(feed.Entries[0].Enclosures) != 2 {
-		t.Errorf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
+		t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
 	}
 
-	if feed.Entries[0].Enclosures[0].URL != "http://www.example.org/myaudiofile.mp3" {
-		t.Errorf("Incorrect enclosure URL, got: %s", feed.Entries[0].Enclosures[0].URL)
+	expectedResults := []struct {
+		url      string
+		mimeType string
+		size     int64
+	}{
+		{"http://www.example.org/myaudiofile.mp3", "audio/mpeg", 1234},
+		{"http://www.example.org/myaudiofile.torrent", "application/x-bittorrent", 4567},
 	}
 
-	if feed.Entries[0].Enclosures[0].MimeType != "audio/mpeg" {
-		t.Errorf("Incorrect enclosure type, got: %s", feed.Entries[0].Enclosures[0].MimeType)
-	}
-
-	if feed.Entries[0].Enclosures[0].Size != 1234 {
-		t.Errorf("Incorrect enclosure length, got: %d", feed.Entries[0].Enclosures[0].Size)
-	}
-
-	if feed.Entries[0].Enclosures[1].URL != "http://www.example.org/myaudiofile.torrent" {
-		t.Errorf("Incorrect enclosure URL, got: %s", feed.Entries[0].Enclosures[1].URL)
-	}
+	for index, enclosure := range feed.Entries[0].Enclosures {
+		if expectedResults[index].url != enclosure.URL {
+			t.Errorf(`Unexpected enclosure URL, got %q instead of %q`, enclosure.URL, expectedResults[index].url)
+		}
 
-	if feed.Entries[0].Enclosures[1].MimeType != "application/x-bittorrent" {
-		t.Errorf("Incorrect enclosure type, got: %s", feed.Entries[0].Enclosures[1].MimeType)
-	}
+		if expectedResults[index].mimeType != enclosure.MimeType {
+			t.Errorf(`Unexpected enclosure type, got %q instead of %q`, enclosure.MimeType, expectedResults[index].mimeType)
+		}
 
-	if feed.Entries[0].Enclosures[1].Size != 4567 {
-		t.Errorf("Incorrect enclosure length, got: %d", feed.Entries[0].Enclosures[1].Size)
+		if expectedResults[index].size != enclosure.Size {
+			t.Errorf(`Unexpected enclosure size, got %d instead of %d`, enclosure.Size, expectedResults[index].size)
+		}
 	}
 }
 
@@ -596,3 +595,137 @@ func TestParseWithInvalidCharacterEntity(t *testing.T) {
 		t.Errorf(`Incorrect URL, got: %q`, feed.SiteURL)
 	}
 }
+
+func TestParseMediaGroup(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
+		<id>http://www.example.org/myfeed</id>
+		<title>My Video Feed</title>
+		<updated>2005-07-15T12:00:00Z</updated>
+		<link href="http://example.org" />
+		<link rel="self" href="http://example.org/myfeed" />
+		<entry>
+			<id>http://www.example.org/entries/1</id>
+			<title>Some Video</title>
+			<updated>2005-07-15T12:00:00Z</updated>
+			<link href="http://www.example.org/entries/1" />
+			<media:group>
+				<media:title>Another title</media:title>
+				<media:content url="https://www.youtube.com/v/abcd" type="application/x-shockwave-flash" width="640" height="390"/>
+				<media:thumbnail url="https://example.org/thumbnail.jpg" width="480" height="360"/>
+				<media:description>Some description
+A website: http://example.org/</media:description>
+			</media:group>
+		</entry>
+  	</feed>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
+	}
+
+	if feed.Entries[0].URL != "http://www.example.org/entries/1" {
+		t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
+	}
+
+	if feed.Entries[0].Content != `Some description<br>A website: <a href="http://example.org/">http://example.org/</a>` {
+		t.Errorf("Incorrect entry content, got: %q", feed.Entries[0].Content)
+	}
+
+	if len(feed.Entries[0].Enclosures) != 2 {
+		t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
+	}
+
+	expectedResults := []struct {
+		url      string
+		mimeType string
+		size     int64
+	}{
+		{"https://example.org/thumbnail.jpg", "image/*", 0},
+		{"https://www.youtube.com/v/abcd", "application/x-shockwave-flash", 0},
+	}
+
+	for index, enclosure := range feed.Entries[0].Enclosures {
+		if expectedResults[index].url != enclosure.URL {
+			t.Errorf(`Unexpected enclosure URL, got %q instead of %q`, enclosure.URL, expectedResults[index].url)
+		}
+
+		if expectedResults[index].mimeType != enclosure.MimeType {
+			t.Errorf(`Unexpected enclosure type, got %q instead of %q`, enclosure.MimeType, expectedResults[index].mimeType)
+		}
+
+		if expectedResults[index].size != enclosure.Size {
+			t.Errorf(`Unexpected enclosure size, got %d instead of %d`, enclosure.Size, expectedResults[index].size)
+		}
+	}
+}
+
+func TestParseMediaElements(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
+		<id>http://www.example.org/myfeed</id>
+		<title>My Video Feed</title>
+		<updated>2005-07-15T12:00:00Z</updated>
+		<link href="http://example.org" />
+		<link rel="self" href="http://example.org/myfeed" />
+		<entry>
+			<id>http://www.example.org/entries/1</id>
+			<title>Some Video</title>
+			<updated>2005-07-15T12:00:00Z</updated>
+			<link href="http://www.example.org/entries/1" />
+			<media:title>Another title</media:title>
+			<media:content url="https://www.youtube.com/v/abcd" type="application/x-shockwave-flash" width="640" height="390"/>
+			<media:thumbnail url="https://example.org/thumbnail.jpg" width="480" height="360"/>
+			<media:description>Some description
+A website: http://example.org/</media:description>
+		</entry>
+  	</feed>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
+	}
+
+	if feed.Entries[0].URL != "http://www.example.org/entries/1" {
+		t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
+	}
+
+	if feed.Entries[0].Content != `Some description<br>A website: <a href="http://example.org/">http://example.org/</a>` {
+		t.Errorf("Incorrect entry content, got: %q", feed.Entries[0].Content)
+	}
+
+	if len(feed.Entries[0].Enclosures) != 2 {
+		t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
+	}
+
+	expectedResults := []struct {
+		url      string
+		mimeType string
+		size     int64
+	}{
+		{"https://example.org/thumbnail.jpg", "image/*", 0},
+		{"https://www.youtube.com/v/abcd", "application/x-shockwave-flash", 0},
+	}
+
+	for index, enclosure := range feed.Entries[0].Enclosures {
+		if expectedResults[index].url != enclosure.URL {
+			t.Errorf(`Unexpected enclosure URL, got %q instead of %q`, enclosure.URL, expectedResults[index].url)
+		}
+
+		if expectedResults[index].mimeType != enclosure.MimeType {
+			t.Errorf(`Unexpected enclosure type, got %q instead of %q`, enclosure.MimeType, expectedResults[index].mimeType)
+		}
+
+		if expectedResults[index].size != enclosure.Size {
+			t.Errorf(`Unexpected enclosure size, got %d instead of %d`, enclosure.Size, expectedResults[index].size)
+		}
+	}
+}

+ 176 - 0
reader/media/media.go

@@ -0,0 +1,176 @@
+// Copyright 2019 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package media // import "miniflux.app/reader/media"
+
+import (
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+var textLinkRegex = regexp.MustCompile(`(?mi)(\bhttps?:\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])`)
+
+// Element represents XML media elements.
+type Element struct {
+	MediaGroups       []Group         `xml:"http://search.yahoo.com/mrss/ group"`
+	MediaContents     []Content       `xml:"http://search.yahoo.com/mrss/ content"`
+	MediaThumbnails   []Thumbnail     `xml:"http://search.yahoo.com/mrss/ thumbnail"`
+	MediaDescriptions DescriptionList `xml:"http://search.yahoo.com/mrss/ description"`
+	MediaPeerLinks    []PeerLink      `xml:"http://search.yahoo.com/mrss/ peerLink"`
+}
+
+// AllMediaThumbnails returns all thumbnail elements merged together.
+func (e *Element) AllMediaThumbnails() []Thumbnail {
+	var items []Thumbnail
+	items = append(items, e.MediaThumbnails...)
+	for _, mediaGroup := range e.MediaGroups {
+		items = append(items, mediaGroup.MediaThumbnails...)
+	}
+	return items
+}
+
+// AllMediaContents returns all content elements merged together.
+func (e *Element) AllMediaContents() []Content {
+	var items []Content
+	items = append(items, e.MediaContents...)
+	for _, mediaGroup := range e.MediaGroups {
+		items = append(items, mediaGroup.MediaContents...)
+	}
+	return items
+}
+
+// AllMediaPeerLinks returns all peer link elements merged together.
+func (e *Element) AllMediaPeerLinks() []PeerLink {
+	var items []PeerLink
+	items = append(items, e.MediaPeerLinks...)
+	for _, mediaGroup := range e.MediaGroups {
+		items = append(items, mediaGroup.MediaPeerLinks...)
+	}
+	return items
+}
+
+// FirstMediaDescription returns the first description element.
+func (e *Element) FirstMediaDescription() string {
+	description := e.MediaDescriptions.First()
+	if description != "" {
+		return description
+	}
+
+	for _, mediaGroup := range e.MediaGroups {
+		description = mediaGroup.MediaDescriptions.First()
+		if description != "" {
+			return description
+		}
+	}
+
+	return ""
+}
+
+// Group represents a XML element "media:group".
+type Group struct {
+	MediaContents     []Content       `xml:"http://search.yahoo.com/mrss/ content"`
+	MediaThumbnails   []Thumbnail     `xml:"http://search.yahoo.com/mrss/ thumbnail"`
+	MediaDescriptions DescriptionList `xml:"http://search.yahoo.com/mrss/ description"`
+	MediaPeerLinks    []PeerLink      `xml:"http://search.yahoo.com/mrss/ peerLink"`
+}
+
+// Content represents a XML element "media:content".
+type Content struct {
+	URL      string `xml:"url,attr"`
+	Type     string `xml:"type,attr"`
+	FileSize string `xml:"fileSize,attr"`
+	Medium   string `xml:"medium,attr"`
+}
+
+// MimeType returns the attachment mime type.
+func (mc *Content) MimeType() string {
+	switch {
+	case mc.Type == "" && mc.Medium == "image":
+		return "image/*"
+	case mc.Type == "" && mc.Medium == "video":
+		return "video/*"
+	case mc.Type == "" && mc.Medium == "audio":
+		return "audio/*"
+	case mc.Type == "" && mc.Medium == "video":
+		return "video/*"
+	case mc.Type != "":
+		return mc.Type
+	default:
+		return "application/octet-stream"
+	}
+}
+
+// Size returns the attachment size.
+func (mc *Content) Size() int64 {
+	if mc.FileSize == "" {
+		return 0
+	}
+	size, _ := strconv.ParseInt(mc.FileSize, 10, 0)
+	return size
+}
+
+// Thumbnail represents a XML element "media:thumbnail".
+type Thumbnail struct {
+	URL string `xml:"url,attr"`
+}
+
+// MimeType returns the attachment mime type.
+func (t *Thumbnail) MimeType() string {
+	return "image/*"
+}
+
+// Size returns the attachment size.
+func (t *Thumbnail) Size() int64 {
+	return 0
+}
+
+// PeerLink represents a XML element "media:peerLink".
+type PeerLink struct {
+	URL  string `xml:"href,attr"`
+	Type string `xml:"type,attr"`
+}
+
+// MimeType returns the attachment mime type.
+func (p *PeerLink) MimeType() string {
+	if p.Type != "" {
+		return p.Type
+	}
+	return "application/octet-stream"
+}
+
+// Size returns the attachment size.
+func (p *PeerLink) Size() int64 {
+	return 0
+}
+
+// Description represents a XML element "media:description".
+type Description struct {
+	Type        string `xml:"type,attr"`
+	Description string `xml:",chardata"`
+}
+
+// HTML returns the description as HTML.
+func (d *Description) HTML() string {
+	if d.Type == "html" {
+		return d.Description
+	}
+
+	content := strings.Replace(d.Description, "\n", "<br>", -1)
+	return textLinkRegex.ReplaceAllString(content, `<a href="${1}">${1}</a>`)
+}
+
+// DescriptionList represents a list of "media:description" XML elements.
+type DescriptionList []Description
+
+// First returns the first non-empty description.
+func (dl DescriptionList) First() string {
+	for _, description := range dl {
+		contents := description.HTML()
+		if contents != "" {
+			return contents
+		}
+	}
+	return ""
+}

+ 110 - 0
reader/media/media_test.go

@@ -0,0 +1,110 @@
+// Copyright 2019 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package media // import "miniflux.app/reader/media"
+
+import "testing"
+
+func TestContentMimeType(t *testing.T) {
+	scenarios := []struct {
+		inputType, inputMedium, expectedMimeType string
+	}{
+		{"image/png", "image", "image/png"},
+		{"", "image", "image/*"},
+		{"", "video", "video/*"},
+		{"", "audio", "audio/*"},
+		{"", "", "application/octet-stream"},
+	}
+
+	for _, scenario := range scenarios {
+		content := &Content{Type: scenario.inputType, Medium: scenario.inputMedium}
+		result := content.MimeType()
+		if result != scenario.expectedMimeType {
+			t.Errorf(`Unexpected mime type, got %q instead of %q for type=%q medium=%q`,
+				result,
+				scenario.expectedMimeType,
+				scenario.inputType,
+				scenario.inputMedium,
+			)
+		}
+	}
+}
+
+func TestContentSize(t *testing.T) {
+	scenarios := []struct {
+		inputSize    string
+		expectedSize int64
+	}{
+		{"", 0},
+		{"123", int64(123)},
+	}
+
+	for _, scenario := range scenarios {
+		content := &Content{FileSize: scenario.inputSize}
+		result := content.Size()
+		if result != scenario.expectedSize {
+			t.Errorf(`Unexpected size, got %d instead of %d for %q`,
+				result,
+				scenario.expectedSize,
+				scenario.inputSize,
+			)
+		}
+	}
+}
+
+func TestPeerLinkType(t *testing.T) {
+	scenarios := []struct {
+		inputType        string
+		expectedMimeType string
+	}{
+		{"", "application/octet-stream"},
+		{"application/x-bittorrent", "application/x-bittorrent"},
+	}
+
+	for _, scenario := range scenarios {
+		peerLink := &PeerLink{Type: scenario.inputType}
+		result := peerLink.MimeType()
+		if result != scenario.expectedMimeType {
+			t.Errorf(`Unexpected mime type, got %q instead of %q for %q`,
+				result,
+				scenario.expectedMimeType,
+				scenario.inputType,
+			)
+		}
+	}
+}
+
+func TestDescription(t *testing.T) {
+	scenarios := []struct {
+		inputType           string
+		inputContent        string
+		expectedDescription string
+	}{
+		{"", "", ""},
+		{"html", "a <b>c</b>", "a <b>c</b>"},
+		{"plain", "a\nhttp://www.example.org/", `a<br><a href="http://www.example.org/">http://www.example.org/</a>`},
+	}
+
+	for _, scenario := range scenarios {
+		desc := &Description{Type: scenario.inputType, Description: scenario.inputContent}
+		result := desc.HTML()
+		if result != scenario.expectedDescription {
+			t.Errorf(`Unexpected description, got %q instead of %q for %q`,
+				result,
+				scenario.expectedDescription,
+				scenario.inputType,
+			)
+		}
+	}
+}
+
+func TestFirstDescription(t *testing.T) {
+	var descList DescriptionList
+	descList = append(descList, Description{})
+	descList = append(descList, Description{Description: "Something"})
+
+	if descList.First() != "Something" {
+		t.Errorf(`Unexpected description`)
+	}
+}

+ 49 - 0
reader/rss/parser_test.go

@@ -771,3 +771,52 @@ func TestParseEntryWithMediaContent(t *testing.T) {
 		}
 	}
 }
+
+func TestParseEntryWithMediaPeerLink(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
+		<channel>
+		<title>My Example Feed</title>
+		<link>http://example.org</link>
+		<item>
+			<title>Example Item</title>
+			<link>http://www.example.org/entries/1</link>
+			<media:peerLink type="application/x-bittorrent" href="http://www.example.org/file.torrent" />
+		</item>
+		</channel>
+		</rss>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
+	}
+	if len(feed.Entries[0].Enclosures) != 1 {
+		t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
+	}
+
+	expectedResults := []struct {
+		url      string
+		mimeType string
+		size     int64
+	}{
+		{"http://www.example.org/file.torrent", "application/x-bittorrent", 0},
+	}
+
+	for index, enclosure := range feed.Entries[0].Enclosures {
+		if expectedResults[index].url != enclosure.URL {
+			t.Errorf(`Unexpected enclosure URL, got %q instead of %q`, enclosure.URL, expectedResults[index].url)
+		}
+
+		if expectedResults[index].mimeType != enclosure.MimeType {
+			t.Errorf(`Unexpected enclosure type, got %q instead of %q`, enclosure.MimeType, expectedResults[index].mimeType)
+		}
+
+		if expectedResults[index].size != enclosure.Size {
+			t.Errorf(`Unexpected enclosure size, got %d instead of %d`, enclosure.Size, expectedResults[index].size)
+		}
+	}
+}

+ 30 - 66
reader/rss/rss.go

@@ -15,6 +15,7 @@ import (
 	"miniflux.app/logger"
 	"miniflux.app/model"
 	"miniflux.app/reader/date"
+	"miniflux.app/reader/media"
 	"miniflux.app/reader/sanitizer"
 	"miniflux.app/url"
 )
@@ -65,62 +66,20 @@ func (enclosure *rssEnclosure) Size() int64 {
 }
 
 type rssItem struct {
-	GUID              string               `xml:"guid"`
-	Title             string               `xml:"title"`
-	Links             []rssLink            `xml:"link"`
-	OriginalLink      string               `xml:"http://rssnamespace.org/feedburner/ext/1.0 origLink"`
-	CommentLinks      []rssCommentLink     `xml:"comments"`
-	Description       string               `xml:"description"`
-	EncodedContent    string               `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
-	PubDate           string               `xml:"pubDate"`
-	Date              string               `xml:"http://purl.org/dc/elements/1.1/ date"`
-	Authors           []rssAuthor          `xml:"author"`
-	Creator           string               `xml:"http://purl.org/dc/elements/1.1/ creator"`
-	EnclosureLinks    []rssEnclosure       `xml:"enclosure"`
-	OrigEnclosureLink string               `xml:"http://rssnamespace.org/feedburner/ext/1.0 origEnclosureLink"`
-	MediaGroup        []rssMediaGroup      `xml:"http://search.yahoo.com/mrss/ group"`
-	MediaContents     []rssMediaContent    `xml:"http://search.yahoo.com/mrss/ content"`
-	MediaThumbnails   []rssMediaThumbnails `xml:"http://search.yahoo.com/mrss/ thumbnail"`
-}
-
-type rssMediaGroup struct {
-	MediaList []rssMediaContent `xml:"content"`
-}
-
-type rssMediaContent struct {
-	URL      string `xml:"url,attr"`
-	Type     string `xml:"type,attr"`
-	FileSize string `xml:"fileSize,attr"`
-	Medium   string `xml:"medium,attr"`
-}
-
-func (mediaContent *rssMediaContent) MimeType() string {
-	switch {
-	case mediaContent.Type == "" && mediaContent.Medium == "image":
-		return "image/*"
-	case mediaContent.Type == "" && mediaContent.Medium == "video":
-		return "video/*"
-	case mediaContent.Type == "" && mediaContent.Medium == "audio":
-		return "audio/*"
-	case mediaContent.Type == "" && mediaContent.Medium == "video":
-		return "video/*"
-	case mediaContent.Type != "":
-		return mediaContent.Type
-	default:
-		return "application/octet-stream"
-	}
-}
-
-func (mediaContent *rssMediaContent) Size() int64 {
-	if mediaContent.FileSize == "" {
-		return 0
-	}
-	size, _ := strconv.ParseInt(mediaContent.FileSize, 10, 0)
-	return size
-}
-
-type rssMediaThumbnails struct {
-	URL string `xml:"url,attr"`
+	GUID              string           `xml:"guid"`
+	Title             string           `xml:"title"`
+	Links             []rssLink        `xml:"link"`
+	OriginalLink      string           `xml:"http://rssnamespace.org/feedburner/ext/1.0 origLink"`
+	CommentLinks      []rssCommentLink `xml:"comments"`
+	Description       string           `xml:"description"`
+	EncodedContent    string           `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
+	PubDate           string           `xml:"pubDate"`
+	Date              string           `xml:"http://purl.org/dc/elements/1.1/ date"`
+	Authors           []rssAuthor      `xml:"author"`
+	Creator           string           `xml:"http://purl.org/dc/elements/1.1/ creator"`
+	EnclosureLinks    []rssEnclosure   `xml:"enclosure"`
+	OrigEnclosureLink string           `xml:"http://rssnamespace.org/feedburner/ext/1.0 origEnclosureLink"`
+	media.Element
 }
 
 func (r *rssFeed) SiteURL() string {
@@ -253,13 +212,13 @@ func (r *rssItem) Enclosures() model.EnclosureList {
 	enclosures := make(model.EnclosureList, 0)
 	duplicates := make(map[string]bool, 0)
 
-	for _, mediaThumbnail := range r.MediaThumbnails {
+	for _, mediaThumbnail := range r.AllMediaThumbnails() {
 		if _, found := duplicates[mediaThumbnail.URL]; !found {
 			duplicates[mediaThumbnail.URL] = true
 			enclosures = append(enclosures, &model.Enclosure{
 				URL:      mediaThumbnail.URL,
-				MimeType: "image/*",
-				Size:     0,
+				MimeType: mediaThumbnail.MimeType(),
+				Size:     mediaThumbnail.Size(),
 			})
 		}
 	}
@@ -285,13 +244,7 @@ func (r *rssItem) Enclosures() model.EnclosureList {
 		}
 	}
 
-	for _, mediaContentItem := range r.MediaGroup {
-		for _, mediaContent := range mediaContentItem.MediaList {
-			r.MediaContents = append(r.MediaContents, mediaContent)
-		}
-	}
-
-	for _, mediaContent := range r.MediaContents {
+	for _, mediaContent := range r.AllMediaContents() {
 		if _, found := duplicates[mediaContent.URL]; !found {
 			duplicates[mediaContent.URL] = true
 			enclosures = append(enclosures, &model.Enclosure{
@@ -302,6 +255,17 @@ func (r *rssItem) Enclosures() model.EnclosureList {
 		}
 	}
 
+	for _, mediaPeerLink := range r.AllMediaPeerLinks() {
+		if _, found := duplicates[mediaPeerLink.URL]; !found {
+			duplicates[mediaPeerLink.URL] = true
+			enclosures = append(enclosures, &model.Enclosure{
+				URL:      mediaPeerLink.URL,
+				MimeType: mediaPeerLink.MimeType(),
+				Size:     mediaPeerLink.Size(),
+			})
+		}
+	}
+
 	return enclosures
 }