Add support of media elements for Atom feeds
This commit is contained in:
parent
f90e9dfab0
commit
912a98788e
6 changed files with 569 additions and 102 deletions
|
@ -15,6 +15,7 @@ import (
|
||||||
"miniflux.app/logger"
|
"miniflux.app/logger"
|
||||||
"miniflux.app/model"
|
"miniflux.app/model"
|
||||||
"miniflux.app/reader/date"
|
"miniflux.app/reader/date"
|
||||||
|
"miniflux.app/reader/media"
|
||||||
"miniflux.app/reader/sanitizer"
|
"miniflux.app/reader/sanitizer"
|
||||||
"miniflux.app/url"
|
"miniflux.app/url"
|
||||||
)
|
)
|
||||||
|
@ -35,9 +36,9 @@ type atomEntry struct {
|
||||||
Updated string `xml:"updated"`
|
Updated string `xml:"updated"`
|
||||||
Links []atomLink `xml:"link"`
|
Links []atomLink `xml:"link"`
|
||||||
Summary atomContent `xml:"summary"`
|
Summary atomContent `xml:"summary"`
|
||||||
Content atomContent `xml:"content"`
|
Content atomContent `xml:"http://www.w3.org/2005/Atom content"`
|
||||||
MediaGroup atomMediaGroup `xml:"http://search.yahoo.com/mrss/ group"`
|
|
||||||
Author atomAuthor `xml:"author"`
|
Author atomAuthor `xml:"author"`
|
||||||
|
media.Element
|
||||||
}
|
}
|
||||||
|
|
||||||
type atomAuthor struct {
|
type atomAuthor struct {
|
||||||
|
@ -58,10 +59,6 @@ type atomContent struct {
|
||||||
XML string `xml:",innerxml"`
|
XML string `xml:",innerxml"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type atomMediaGroup struct {
|
|
||||||
Description string `xml:"http://search.yahoo.com/mrss/ description"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *atomFeed) Transform() *model.Feed {
|
func (a *atomFeed) Transform() *model.Feed {
|
||||||
feed := new(model.Feed)
|
feed := new(model.Feed)
|
||||||
feed.FeedURL = getRelationURL(a.Links, "self")
|
feed.FeedURL = getRelationURL(a.Links, "self")
|
||||||
|
@ -179,8 +176,9 @@ func getContent(a *atomEntry) string {
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.MediaGroup.Description != "" {
|
mediaDescription := a.FirstMediaDescription()
|
||||||
return a.MediaGroup.Description
|
if mediaDescription != "" {
|
||||||
|
return mediaDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
@ -203,13 +201,50 @@ func getHash(a *atomEntry) string {
|
||||||
|
|
||||||
func getEnclosures(a *atomEntry) model.EnclosureList {
|
func getEnclosures(a *atomEntry) model.EnclosureList {
|
||||||
enclosures := make(model.EnclosureList, 0)
|
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 {
|
for _, link := range a.Links {
|
||||||
if strings.ToLower(link.Rel) == "enclosure" {
|
if strings.ToLower(link.Rel) == "enclosure" {
|
||||||
|
if _, found := duplicates[link.URL]; !found {
|
||||||
|
duplicates[link.URL] = true
|
||||||
length, _ := strconv.ParseInt(link.Length, 10, 0)
|
length, _ := strconv.ParseInt(link.Length, 10, 0)
|
||||||
enclosures = append(enclosures, &model.Enclosure{URL: link.URL, MimeType: link.Type, Size: length})
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return enclosures
|
return enclosures
|
||||||
}
|
}
|
||||||
|
|
|
@ -472,31 +472,30 @@ func TestParseEntryWithEnclosures(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(feed.Entries[0].Enclosures) != 2 {
|
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" {
|
expectedResults := []struct {
|
||||||
t.Errorf("Incorrect enclosure URL, got: %s", feed.Entries[0].Enclosures[0].URL)
|
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" {
|
for index, enclosure := range feed.Entries[0].Enclosures {
|
||||||
t.Errorf("Incorrect enclosure type, got: %s", feed.Entries[0].Enclosures[0].MimeType)
|
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[0].Size != 1234 {
|
if expectedResults[index].mimeType != enclosure.MimeType {
|
||||||
t.Errorf("Incorrect enclosure length, got: %d", feed.Entries[0].Enclosures[0].Size)
|
t.Errorf(`Unexpected enclosure type, got %q instead of %q`, enclosure.MimeType, expectedResults[index].mimeType)
|
||||||
}
|
}
|
||||||
|
|
||||||
if feed.Entries[0].Enclosures[1].URL != "http://www.example.org/myaudiofile.torrent" {
|
if expectedResults[index].size != enclosure.Size {
|
||||||
t.Errorf("Incorrect enclosure URL, got: %s", feed.Entries[0].Enclosures[1].URL)
|
t.Errorf(`Unexpected enclosure size, got %d instead of %d`, enclosure.Size, expectedResults[index].size)
|
||||||
}
|
}
|
||||||
|
|
||||||
if feed.Entries[0].Enclosures[1].MimeType != "application/x-bittorrent" {
|
|
||||||
t.Errorf("Incorrect enclosure type, got: %s", feed.Entries[0].Enclosures[1].MimeType)
|
|
||||||
}
|
|
||||||
|
|
||||||
if feed.Entries[0].Enclosures[1].Size != 4567 {
|
|
||||||
t.Errorf("Incorrect enclosure length, got: %d", feed.Entries[0].Enclosures[1].Size)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -596,3 +595,137 @@ func TestParseWithInvalidCharacterEntity(t *testing.T) {
|
||||||
t.Errorf(`Incorrect URL, got: %q`, feed.SiteURL)
|
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
reader/media/media.go
Normal file
176
reader/media/media.go
Normal file
|
@ -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
reader/media/media_test.go
Normal file
110
reader/media/media_test.go
Normal file
|
@ -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`)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"miniflux.app/logger"
|
"miniflux.app/logger"
|
||||||
"miniflux.app/model"
|
"miniflux.app/model"
|
||||||
"miniflux.app/reader/date"
|
"miniflux.app/reader/date"
|
||||||
|
"miniflux.app/reader/media"
|
||||||
"miniflux.app/reader/sanitizer"
|
"miniflux.app/reader/sanitizer"
|
||||||
"miniflux.app/url"
|
"miniflux.app/url"
|
||||||
)
|
)
|
||||||
|
@ -78,49 +79,7 @@ type rssItem struct {
|
||||||
Creator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
Creator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||||
EnclosureLinks []rssEnclosure `xml:"enclosure"`
|
EnclosureLinks []rssEnclosure `xml:"enclosure"`
|
||||||
OrigEnclosureLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origEnclosureLink"`
|
OrigEnclosureLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origEnclosureLink"`
|
||||||
MediaGroup []rssMediaGroup `xml:"http://search.yahoo.com/mrss/ group"`
|
media.Element
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *rssFeed) SiteURL() string {
|
func (r *rssFeed) SiteURL() string {
|
||||||
|
@ -253,13 +212,13 @@ func (r *rssItem) Enclosures() model.EnclosureList {
|
||||||
enclosures := make(model.EnclosureList, 0)
|
enclosures := make(model.EnclosureList, 0)
|
||||||
duplicates := make(map[string]bool, 0)
|
duplicates := make(map[string]bool, 0)
|
||||||
|
|
||||||
for _, mediaThumbnail := range r.MediaThumbnails {
|
for _, mediaThumbnail := range r.AllMediaThumbnails() {
|
||||||
if _, found := duplicates[mediaThumbnail.URL]; !found {
|
if _, found := duplicates[mediaThumbnail.URL]; !found {
|
||||||
duplicates[mediaThumbnail.URL] = true
|
duplicates[mediaThumbnail.URL] = true
|
||||||
enclosures = append(enclosures, &model.Enclosure{
|
enclosures = append(enclosures, &model.Enclosure{
|
||||||
URL: mediaThumbnail.URL,
|
URL: mediaThumbnail.URL,
|
||||||
MimeType: "image/*",
|
MimeType: mediaThumbnail.MimeType(),
|
||||||
Size: 0,
|
Size: mediaThumbnail.Size(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -285,13 +244,7 @@ func (r *rssItem) Enclosures() model.EnclosureList {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, mediaContentItem := range r.MediaGroup {
|
for _, mediaContent := range r.AllMediaContents() {
|
||||||
for _, mediaContent := range mediaContentItem.MediaList {
|
|
||||||
r.MediaContents = append(r.MediaContents, mediaContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, mediaContent := range r.MediaContents {
|
|
||||||
if _, found := duplicates[mediaContent.URL]; !found {
|
if _, found := duplicates[mediaContent.URL]; !found {
|
||||||
duplicates[mediaContent.URL] = true
|
duplicates[mediaContent.URL] = true
|
||||||
enclosures = append(enclosures, &model.Enclosure{
|
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
|
return enclosures
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue