mirror of
https://github.com/spl0k/supysonic.git
synced 2024-12-22 17:06:17 +00:00
Correct FK fields to match parent column type.
Add error handling for fetching an rss feed. Add error handling for failure modes of feedparser Add more optional fields to episode as a 'child' Add planet money rss feed for testing
This commit is contained in:
parent
08a83a8492
commit
be305225e1
@ -13,6 +13,7 @@ from time import mktime
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
import feedparser
|
import feedparser
|
||||||
|
|
||||||
from ..db import PodcastChannel, PodcastEpisode, PodcastStatus
|
from ..db import PodcastChannel, PodcastEpisode, PodcastStatus
|
||||||
@ -38,30 +39,73 @@ def get_podcasts():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_feed(url):
|
||||||
|
parsed_url = urlparse(url)
|
||||||
|
|
||||||
|
has_scheme_and_location = parsed_url.scheme and (parsed_url.netloc or parsed_url.path)
|
||||||
|
if not has_scheme_and_location:
|
||||||
|
return None, request.formatter.error(10, 'unexepected url')
|
||||||
|
|
||||||
|
try:
|
||||||
|
feed = feedparser.parse(url)
|
||||||
|
except Exception as ex:
|
||||||
|
return None, request.formatter.error(0, 'unexpected error')
|
||||||
|
|
||||||
|
if feed.status != 200:
|
||||||
|
return None, request.formatter.error(0, 'http:' + feed.status)
|
||||||
|
|
||||||
|
if feed.bozo:
|
||||||
|
return None, request.formatter.error(0, feed.bozo_exception)
|
||||||
|
|
||||||
|
if not hasattr(feed.feed, 'title'):
|
||||||
|
return None, request.formatter.error(10, 'title missing')
|
||||||
|
|
||||||
|
return feed, None
|
||||||
|
|
||||||
|
|
||||||
@api.route("/createPodcastChannel.view", methods=["GET", "POST"])
|
@api.route("/createPodcastChannel.view", methods=["GET", "POST"])
|
||||||
@require_podcast
|
@require_podcast
|
||||||
def create_podcast_channel():
|
def create_podcast_channel():
|
||||||
url = request.values["url"]
|
url = request.values["url"]
|
||||||
parsed_url = urlparse(url)
|
feed, error = fetch_feed(url)
|
||||||
has_scheme_and_location = parsed_url.scheme and (parsed_url.netloc or parsed_url.path)
|
if error:
|
||||||
if not has_scheme_and_location:
|
return error
|
||||||
return request.formatter.error(10, 'unexepected url')
|
|
||||||
|
|
||||||
feed = feedparser.parse(url)
|
|
||||||
channel = PodcastChannel(
|
channel = PodcastChannel(
|
||||||
url=url,
|
url=url,
|
||||||
title=feed.feed.title,
|
title=feed.feed.title,
|
||||||
)
|
)
|
||||||
|
|
||||||
for item in feed.entries:
|
for item in feed.entries:
|
||||||
channel.episodes.create(
|
# NOTE: 'suffix' and 'bitrate' will be set when downloading to local file
|
||||||
title=item.title,
|
|
||||||
description=item.description,
|
fields = {
|
||||||
stream_url=item.links[0].href,
|
'title': item.title,
|
||||||
duration=item.links[0].length,
|
'description': item['description'],
|
||||||
publish_date=datetime.fromtimestamp(mktime(item.published_parsed)),
|
'year': item.published_parsed.tm_year,
|
||||||
status=PodcastStatus.new.value,
|
'publish_date': datetime.fromtimestamp(mktime(item.published_parsed)),
|
||||||
)
|
'status': PodcastStatus.new.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
audio_link = next((link for link in item.links if link.type == 'audio/mpeg'), None)
|
||||||
|
if audio_link:
|
||||||
|
fields['stream_url'] = audio_link.href
|
||||||
|
fields['size'] = audio_link.length
|
||||||
|
fields['content_type'] = audio_link.type
|
||||||
|
else:
|
||||||
|
fields['status'] = PodcastStatus.error.value
|
||||||
|
fields['error_message'] = 'Audio link not found in episode xml'
|
||||||
|
|
||||||
|
if item['itunes_duration']:
|
||||||
|
fields['duration'] = item.itunes_duration
|
||||||
|
|
||||||
|
if feed.feed['image'] and feed.feed.image['href']:
|
||||||
|
fields['cover_art'] = feed.feed.image.href
|
||||||
|
|
||||||
|
if feed.feed['tags']:
|
||||||
|
fields['genre'] = ",".join([tag['term'] for tag in feed.feed.tags])
|
||||||
|
|
||||||
|
channel.episodes.create(**fields)
|
||||||
|
|
||||||
return request.formatter.empty
|
return request.formatter.empty
|
||||||
|
|
||||||
|
@ -641,7 +641,15 @@ class PodcastEpisode(db.Entity):
|
|||||||
# Status params mirror PodcastStatus
|
# Status params mirror PodcastStatus
|
||||||
status = Required(int, min=1, max=6, default=1)
|
status = Required(int, min=1, max=6, default=1)
|
||||||
publish_date = Optional(datetime, precision=0, default=now)
|
publish_date = Optional(datetime, precision=0, default=now)
|
||||||
|
error_message = Optional(str, nullable=True)
|
||||||
created = Required(datetime, precision=0, default=now)
|
created = Required(datetime, precision=0, default=now)
|
||||||
|
size = Optional(int, nullable=True)
|
||||||
|
suffix = Optional(str, nullable=True)
|
||||||
|
bitrate = Optional(str, nullable=True)
|
||||||
|
content_type = Optional(str, nullable=True)
|
||||||
|
cover_art = Optional(str, nullable=True)
|
||||||
|
genre = Optional(str, nullable=True)
|
||||||
|
year = Optional(int, nullable=True)
|
||||||
|
|
||||||
def soft_delete(self):
|
def soft_delete(self):
|
||||||
self.status = PodcastStatus.deleted.value
|
self.status = PodcastStatus.deleted.value
|
||||||
@ -660,11 +668,16 @@ class PodcastEpisode(db.Entity):
|
|||||||
status=PodcastStatus(self.status).name,
|
status=PodcastStatus(self.status).name,
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.description:
|
|
||||||
info["description"] = self.description,
|
|
||||||
if self.publish_date:
|
if self.publish_date:
|
||||||
info["publishDate"] = self.publish_date.isoformat()
|
info["publishDate"] = self.publish_date.isoformat()
|
||||||
|
|
||||||
|
opt_fields = [ 'description', 'duration', 'size', 'suffix', 'bitrate', 'content_type', 'cover_art', 'genre', 'year' ]
|
||||||
|
for opt in opt_fields:
|
||||||
|
if hasattr(self, opt):
|
||||||
|
val = getattr(self, opt)
|
||||||
|
if val:
|
||||||
|
info[opt] = val
|
||||||
|
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
@ -17,13 +17,21 @@ CREATE TABLE IF NOT EXISTS podcast_episode (
|
|||||||
id BINARY(16) PRIMARY KEY,
|
id BINARY(16) PRIMARY KEY,
|
||||||
stream_url VARCHAR(256) NOT NULL,
|
stream_url VARCHAR(256) NOT NULL,
|
||||||
file_path VARCHAR(256),
|
file_path VARCHAR(256),
|
||||||
channel_id CHAR(36) NOT NULL,
|
channel_id BINARY(16) NOT NULL,
|
||||||
title VARCHAR(256),
|
title VARCHAR(256),
|
||||||
description VARCHAR(256),
|
description VARCHAR(256),
|
||||||
duration VARCHAR(8),
|
duration VARCHAR(8),
|
||||||
status TINYINT NOT NULL,
|
status TINYINT NOT NULL,
|
||||||
publish_date DATETIME,
|
publish_date DATETIME,
|
||||||
|
error_message VARCHAR(256),
|
||||||
created DATETIME NOT NULL,
|
created DATETIME NOT NULL,
|
||||||
|
size INTEGER,
|
||||||
|
suffix VARCHAR(8),
|
||||||
|
bitrate VARCHAR(16),
|
||||||
|
content_type VARCHAR(64),
|
||||||
|
cover_art VARCHAR(256),
|
||||||
|
genre VARCHAR(16),
|
||||||
|
year SMALLINT,
|
||||||
FOREIGN KEY(channel_id) REFERENCES podcast_channel(id),
|
FOREIGN KEY(channel_id) REFERENCES podcast_channel(id),
|
||||||
INDEX index_episode_channel_id_fk (channel_id),
|
INDEX index_episode_channel_id_fk (channel_id),
|
||||||
INDEX index_episode_status (status)
|
INDEX index_episode_status (status)
|
||||||
|
@ -18,13 +18,21 @@ CREATE TABLE IF NOT EXISTS podcast_episode (
|
|||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
stream_url VARCHAR(256) NOT NULL,
|
stream_url VARCHAR(256) NOT NULL,
|
||||||
file_path VARCHAR(256),
|
file_path VARCHAR(256),
|
||||||
channel_id CHAR(36) NOT NULL REFERENCES podcast_channel(id),
|
channel_id UUID NOT NULL REFERENCES podcast_channel(id),
|
||||||
title VARCHAR(256),
|
title VARCHAR(256),
|
||||||
description VARCHAR(256),
|
description VARCHAR(256),
|
||||||
duration VARCHAR(8),
|
duration VARCHAR(8),
|
||||||
status TINYINT NOT NULL,
|
status TINYINT NOT NULL,
|
||||||
publish_date TIMESTAMP,
|
publish_date TIMESTAMP,
|
||||||
created TIMESTAMP NOT NULL
|
error_message VARCHAR(256),
|
||||||
|
created TIMESTAMP NOT NULL,
|
||||||
|
size INTEGER,
|
||||||
|
suffix VARCHAR(8),
|
||||||
|
bitrate VARCHAR(16),
|
||||||
|
content_type VARCHAR(64),
|
||||||
|
cover_art VARCHAR(256),
|
||||||
|
genre VARCHAR(16),
|
||||||
|
year SMALLINT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS index_episode_channel_id_fk ON podcast_channel(id);
|
CREATE INDEX IF NOT EXISTS index_episode_channel_id_fk ON podcast_channel(id);
|
||||||
CREATE INDEX IF NOT EXISTS index_episode_status ON podcast_episode(status);
|
CREATE INDEX IF NOT EXISTS index_episode_status ON podcast_episode(status);
|
||||||
|
@ -24,7 +24,15 @@ CREATE TABLE IF NOT EXISTS podcast_episode (
|
|||||||
duration VARCHAR(8),
|
duration VARCHAR(8),
|
||||||
status TINYINT NOT NULL,
|
status TINYINT NOT NULL,
|
||||||
publish_date DATETIME,
|
publish_date DATETIME,
|
||||||
created DATETIME NOT NULL
|
error_message VARCHAR(256),
|
||||||
|
created DATETIME NOT NULL,
|
||||||
|
size INT,
|
||||||
|
suffix VARCHAR(8),
|
||||||
|
bitrate VARCHAR(16),
|
||||||
|
content_type VARCHAR(64),
|
||||||
|
cover_art VARCHAR(256),
|
||||||
|
genre VARCHAR(16),
|
||||||
|
year SMALLINT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS index_episode_channel_id_fk ON podcast_channel(id);
|
CREATE INDEX IF NOT EXISTS index_episode_channel_id_fk ON podcast_channel(id);
|
||||||
CREATE INDEX IF NOT EXISTS index_episode_status_id_fk ON podcast_episode(status);
|
CREATE INDEX IF NOT EXISTS index_episode_status_id_fk ON podcast_episode(status);
|
||||||
|
@ -176,13 +176,21 @@ CREATE TABLE IF NOT EXISTS podcast_episode (
|
|||||||
id BINARY(16) PRIMARY KEY,
|
id BINARY(16) PRIMARY KEY,
|
||||||
stream_url VARCHAR(256) NOT NULL,
|
stream_url VARCHAR(256) NOT NULL,
|
||||||
file_path VARCHAR(256),
|
file_path VARCHAR(256),
|
||||||
channel_id CHAR(36) NOT NULL,
|
channel_id BINARY(16) NOT NULL,
|
||||||
title VARCHAR(256),
|
title VARCHAR(256),
|
||||||
description VARCHAR(256),
|
description VARCHAR(256),
|
||||||
duration VARCHAR(8),
|
duration VARCHAR(8),
|
||||||
status TINYINT NOT NULL,
|
status TINYINT NOT NULL,
|
||||||
publish_date DATETIME,
|
publish_date DATETIME,
|
||||||
|
error_message VARCHAR(256),
|
||||||
created DATETIME NOT NULL,
|
created DATETIME NOT NULL,
|
||||||
|
size INTEGER,
|
||||||
|
suffix VARCHAR(8),
|
||||||
|
bitrate VARCHAR(16),
|
||||||
|
content_type VARCHAR(64),
|
||||||
|
cover_art VARCHAR(256),
|
||||||
|
genre VARCHAR(16),
|
||||||
|
year SMALLINT,
|
||||||
FOREIGN KEY(channel_id) REFERENCES podcast_channel(id),
|
FOREIGN KEY(channel_id) REFERENCES podcast_channel(id),
|
||||||
INDEX index_episode_channel_id_fk (channel_id),
|
INDEX index_episode_channel_id_fk (channel_id),
|
||||||
INDEX index_episode_status (status)
|
INDEX index_episode_status (status)
|
||||||
|
@ -177,13 +177,21 @@ CREATE TABLE IF NOT EXISTS podcast_episode (
|
|||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
stream_url VARCHAR(256) NOT NULL,
|
stream_url VARCHAR(256) NOT NULL,
|
||||||
file_path VARCHAR(256),
|
file_path VARCHAR(256),
|
||||||
channel_id CHAR(36) NOT NULL REFERENCES podcast_channel(id),
|
channel_id UUID NOT NULL REFERENCES podcast_channel(id),
|
||||||
title VARCHAR(256),
|
title VARCHAR(256),
|
||||||
description VARCHAR(256),
|
description VARCHAR(256),
|
||||||
duration VARCHAR(8),
|
duration VARCHAR(8),
|
||||||
status TINYINT NOT NULL,
|
status TINYINT NOT NULL,
|
||||||
publish_date TIMESTAMP,
|
publish_date TIMESTAMP,
|
||||||
created TIMESTAMP NOT NULL
|
error_message VARCHAR(256),
|
||||||
|
created TIMESTAMP NOT NULL,
|
||||||
|
size INTEGER,
|
||||||
|
suffix VARCHAR(8),
|
||||||
|
bitrate VARCHAR(16),
|
||||||
|
content_type VARCHAR(64),
|
||||||
|
cover_art VARCHAR(256),
|
||||||
|
genre VARCHAR(16),
|
||||||
|
year SMALLINT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS index_episode_channel_id_fk ON podcast_channel(id);
|
CREATE INDEX IF NOT EXISTS index_episode_channel_id_fk ON podcast_channel(id);
|
||||||
CREATE INDEX IF NOT EXISTS index_episode_status ON podcast_episode(status);
|
CREATE INDEX IF NOT EXISTS index_episode_status ON podcast_episode(status);
|
||||||
|
@ -182,10 +182,18 @@ CREATE TABLE IF NOT EXISTS podcast_episode (
|
|||||||
channel_id CHAR(36) NOT NULL REFERENCES podcast_channel,
|
channel_id CHAR(36) NOT NULL REFERENCES podcast_channel,
|
||||||
title VARCHAR(256),
|
title VARCHAR(256),
|
||||||
description VARCHAR(256),
|
description VARCHAR(256),
|
||||||
duration VARCHAR(8),
|
duration VARCHAR(12),
|
||||||
status TINYINT NOT NULL,
|
status TINYINT NOT NULL,
|
||||||
publish_date DATETIME,
|
publish_date DATETIME,
|
||||||
created DATETIME NOT NULL
|
error_message VARCHAR(256),
|
||||||
|
created DATETIME NOT NULL,
|
||||||
|
size INT,
|
||||||
|
suffix VARCHAR(8),
|
||||||
|
bitrate VARCHAR(16),
|
||||||
|
content_type VARCHAR(64),
|
||||||
|
cover_art VARCHAR(256),
|
||||||
|
genre VARCHAR(16),
|
||||||
|
year SMALLINT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS index_episode_channel_id_fk ON podcast_channel(id);
|
CREATE INDEX IF NOT EXISTS index_episode_channel_id_fk ON podcast_channel(id);
|
||||||
CREATE INDEX IF NOT EXISTS index_episode_status_id_fk ON podcast_episode(status);
|
CREATE INDEX IF NOT EXISTS index_episode_status_id_fk ON podcast_episode(status);
|
||||||
|
@ -39,6 +39,7 @@ class PodcastTestCase(ApiTestBase):
|
|||||||
self.assertEqual(channel.description, description)
|
self.assertEqual(channel.description, description)
|
||||||
self.assertEqual(channel.error_message, error_message)
|
self.assertEqual(channel.error_message, error_message)
|
||||||
|
|
||||||
|
@db_session
|
||||||
def test_create_podcast_channel(self):
|
def test_create_podcast_channel(self):
|
||||||
# test for non-admin access
|
# test for non-admin access
|
||||||
self._make_request(
|
self._make_request(
|
||||||
@ -51,19 +52,35 @@ class PodcastTestCase(ApiTestBase):
|
|||||||
self._make_request("createPodcastChannel", error=10)
|
self._make_request("createPodcastChannel", error=10)
|
||||||
self._make_request("createPodcastChannel", {"url": "bad url"}, error=10)
|
self._make_request("createPodcastChannel", {"url": "bad url"}, error=10)
|
||||||
|
|
||||||
# create w/ required fields
|
# create w/o required fields
|
||||||
url = "file://" + os.path.join(os.path.dirname(__file__), "../fixtures/rssfeed.xml")
|
url = "file://" + os.path.join(os.path.dirname(__file__), "../fixtures/rssfeeds/empty.xml")
|
||||||
|
self._make_request("createPodcastChannel", {"url": url}, skip_post=True, error=10)
|
||||||
|
|
||||||
|
# create w/ required fields without episodes
|
||||||
|
url = "file://" + os.path.join(os.path.dirname(__file__), "../fixtures/rssfeeds/waitwait-noeps.xml")
|
||||||
self._make_request("createPodcastChannel", {"url": url}, skip_post=True)
|
self._make_request("createPodcastChannel", {"url": url}, skip_post=True)
|
||||||
|
|
||||||
self.assertDbCountEqual(PodcastChannel, 1)
|
self.assertDbCountEqual(PodcastChannel, 1)
|
||||||
self.assertDbCountEqual(PodcastEpisode, 20)
|
self.assertDbCountEqual(PodcastEpisode, 0)
|
||||||
|
PodcastChannel.select().delete()
|
||||||
|
|
||||||
|
# create w/ required fields with episodes
|
||||||
|
feeds = { "waitwait.xml": 20, "planetmoney.xml": 6 }
|
||||||
|
for feed, count in feeds.items():
|
||||||
|
url = "file://" + os.path.join(os.path.dirname(__file__), "../fixtures/rssfeeds/" + feed)
|
||||||
|
self._make_request("createPodcastChannel", {"url": url}, skip_post=True)
|
||||||
|
|
||||||
|
self.assertDbCountEqual(PodcastChannel, 1)
|
||||||
|
self.assertDbCountEqual(PodcastEpisode, count)
|
||||||
|
|
||||||
with db_session:
|
|
||||||
self.assertPodcastChannelEquals(PodcastChannel.select().first(), url, PodcastStatus.new.value)
|
self.assertPodcastChannelEquals(PodcastChannel.select().first(), url, PodcastStatus.new.value)
|
||||||
for episode in PodcastEpisode.select():
|
for episode in PodcastEpisode.select():
|
||||||
self.assertEqual(episode.status, PodcastStatus.new.value)
|
self.assertEqual(episode.status, PodcastStatus.new.value)
|
||||||
|
|
||||||
|
PodcastChannel.select().delete()
|
||||||
|
PodcastEpisode.select().delete()
|
||||||
|
|
||||||
|
|
||||||
|
@db_session
|
||||||
def test_delete_podcast_channel(self):
|
def test_delete_podcast_channel(self):
|
||||||
# test for non-admin access
|
# test for non-admin access
|
||||||
self._make_request(
|
self._make_request(
|
||||||
@ -78,18 +95,16 @@ class PodcastTestCase(ApiTestBase):
|
|||||||
self._make_request("deletePodcastChannel", {"id": str(uuid.uuid4())}, error=70)
|
self._make_request("deletePodcastChannel", {"id": str(uuid.uuid4())}, error=70)
|
||||||
|
|
||||||
# delete
|
# delete
|
||||||
with db_session:
|
channel = PodcastChannel(
|
||||||
channel = PodcastChannel(
|
url="https://example.local/podcast/delete",
|
||||||
url="https://example.local/podcast/delete",
|
status=PodcastStatus.new.value,
|
||||||
status=PodcastStatus.new.value,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
self._make_request("deletePodcastChannel", {"id": channel.id}, skip_post=True)
|
self._make_request("deletePodcastChannel", {"id": channel.id}, skip_post=True)
|
||||||
|
|
||||||
self.assertDbCountEqual(PodcastChannel, 1)
|
self.assertDbCountEqual(PodcastChannel, 1)
|
||||||
|
|
||||||
with db_session:
|
self.assertEqual(PodcastStatus.deleted.value, PodcastChannel[channel.id].status)
|
||||||
self.assertEqual(PodcastStatus.deleted.value, PodcastChannel[channel.id].status)
|
|
||||||
|
|
||||||
@db_session
|
@db_session
|
||||||
def test_delete_podcast_episode(self):
|
def test_delete_podcast_episode(self):
|
||||||
|
5
tests/fixtures/rssfeeds/empty.xml
vendored
Normal file
5
tests/fixtures/rssfeeds/empty.xml
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<!-- this should have at least 'title' to be valid -->
|
||||||
|
<channel></channel>
|
||||||
|
</rss>
|
134
tests/fixtures/rssfeeds/planetmoney.xml
vendored
Normal file
134
tests/fixtures/rssfeeds/planetmoney.xml
vendored
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss xmlns:npr="https://www.npr.org/rss/" xmlns:nprml="https://api.npr.org/nprml" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Planet Money</title>
|
||||||
|
<link>https://www.npr.org/planetmoney</link>
|
||||||
|
<description><![CDATA[The economy explained. Imagine you could call up a friend and say, "Meet me at the bar and tell me what's going on with the economy." Now imagine that's actually a fun evening.]]></description>
|
||||||
|
<copyright>Copyright 2015-2020 NPR - For Personal Use Only</copyright>
|
||||||
|
<generator>NPR API RSS Generator 0.94</generator>
|
||||||
|
<language>en</language>
|
||||||
|
<itunes:summary><![CDATA[The economy explained. Imagine you could call up a friend and say, "Meet me at the bar and tell me what's going on with the economy." Now imagine that's actually a fun evening.]]></itunes:summary>
|
||||||
|
<itunes:author>NPR</itunes:author>
|
||||||
|
<itunes:block>no</itunes:block>
|
||||||
|
<itunes:owner>
|
||||||
|
<itunes:email>podcasts@npr.org</itunes:email>
|
||||||
|
<itunes:name>NPR</itunes:name>
|
||||||
|
</itunes:owner>
|
||||||
|
<itunes:category text="Business"/>
|
||||||
|
<itunes:category text="News"/>
|
||||||
|
<itunes:image href="https://media.npr.org/assets/img/2018/08/02/npr_planetmoney_podcasttile_sq-7b7fab0b52fd72826936c3dbe51cff94889797a0.jpg?s=1400"/>
|
||||||
|
<itunes:type>episodic</itunes:type>
|
||||||
|
<image>
|
||||||
|
<url>https://media.npr.org/assets/img/2018/08/02/npr_planetmoney_podcasttile_sq-7b7fab0b52fd72826936c3dbe51cff94889797a0.jpg?s=1400</url>
|
||||||
|
<title>Planet Money</title>
|
||||||
|
<link>https://www.npr.org/planetmoney</link>
|
||||||
|
</image>
|
||||||
|
<lastBuildDate>Fri, 14 Aug 2020 15:31:33 -0400</lastBuildDate>
|
||||||
|
<item>
|
||||||
|
<title>Big Rigged</title>
|
||||||
|
<description><![CDATA[Driving a truck used to mean freedom. Now it means a mountain of debt. Subscribe to our weekly newsletter <a href="https://www.npr.org/newsletter/money">here</a>. ]]></description>
|
||||||
|
<pubDate>Fri, 14 Aug 2020 15:31:33 -0400</pubDate>
|
||||||
|
<copyright>Copyright 2015-2020 NPR - For Personal Use Only</copyright>
|
||||||
|
<guid isPermalink="false">003a12a1-5b32-4242-89af-9d2fadd3d851</guid>
|
||||||
|
<link>https://www.npr.org/2020/08/10/901110994/big-rigged</link>
|
||||||
|
<itunes:title>Big Rigged</itunes:title>
|
||||||
|
<itunes:author>NPR</itunes:author>
|
||||||
|
<itunes:summary>Trucking companies have been underpaying workers for years. And now they've found a way to get them to buy the trucks too. </itunes:summary>
|
||||||
|
<itunes:subtitle>Trucking companies have been underpaying workers for years. And now they've found a way to get them to buy the trucks too. </itunes:subtitle>
|
||||||
|
<itunes:image href="https://media.npr.org/assets/img/2020/08/14/gettyimages-535791583_wide-5bbf3c810e95fd1ef2cab2b0a0703af4ffc9c5e5.jpg?s=1400"/>
|
||||||
|
<itunes:duration>1483</itunes:duration>
|
||||||
|
<itunes:explicit>no</itunes:explicit>
|
||||||
|
<itunes:episodeType>full</itunes:episodeType>
|
||||||
|
<content:encoded><![CDATA[Driving a truck used to mean freedom. Now it means a mountain of debt. Subscribe to our weekly newsletter <a href="https://www.npr.org/newsletter/money">here</a>. ]]></content:encoded>
|
||||||
|
<enclosure url="https://play.podtrac.com/npr-510289/edge1.pod.npr.org/anon.npr-mp3/npr/pmoney/2020/08/20200814_pmoney_pmpod1025_1.mp3?awCollectionId=510289&awEpisodeId=901110994&orgId=1&topicId=1017&d=1483&p=510289&story=901110994&t=podcast&e=901110994&size=23676843&ft=pod&f=510289" length="23676843" type="audio/mpeg"/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>SUMMER SCHOOL 6: Taxes & Donald Duck</title>
|
||||||
|
<description><![CDATA[The surprisingly entertaining history of the income tax.]]></description>
|
||||||
|
<pubDate>Wed, 12 Aug 2020 16:21:09 -0400</pubDate>
|
||||||
|
<copyright>Copyright 2015-2020 NPR - For Personal Use Only</copyright>
|
||||||
|
<guid isPermalink="false">77a75311-e38c-482c-bc0e-709510464ea3</guid>
|
||||||
|
<link>https://www.npr.org/2020/08/12/901837703/summer-school-6-taxes-donald-duck</link>
|
||||||
|
<itunes:title>SUMMER SCHOOL 6: Taxes & Donald Duck</itunes:title>
|
||||||
|
<itunes:author>NPR</itunes:author>
|
||||||
|
<itunes:summary><![CDATA[The surprisingly entertaining history of the income tax.]]></itunes:summary>
|
||||||
|
<itunes:image href="https://media.npr.org/assets/img/2020/08/12/6therriousdavis_planetmoney_wide-08bee0e21891ecd46e4a2fc956655f0eb9c5cafd.jpg?s=1400"/>
|
||||||
|
<itunes:duration>1661</itunes:duration>
|
||||||
|
<itunes:explicit>no</itunes:explicit>
|
||||||
|
<itunes:episodeType>full</itunes:episodeType>
|
||||||
|
<content:encoded><![CDATA[The surprisingly entertaining history of the income tax.]]></content:encoded>
|
||||||
|
<enclosure url="https://play.podtrac.com/npr-510289/edge1.pod.npr.org/anon.npr-mp3/npr/pmoney/2020/08/20200812_pmoney_pmpod1023.mp3?awCollectionId=510289&awEpisodeId=901837703&orgId=1&d=1661&p=510289&story=901837703&t=podcast&e=901837703&size=26529957&ft=pod&f=510289" length="26529957" type="audio/mpeg"/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Mask Communication</title>
|
||||||
|
<description><![CDATA[Why won't some people wear masks? Is there anything we can do to convince them? We look to behavioral economics for help. | Subscribe to our weekly newsletter <a href="https://www.npr.org/newsletter/money?utm_source=rss_feed_copy&utm_medium=podcast&utm_term=planet_money">here</a>.]]></description>
|
||||||
|
<pubDate>Fri, 07 Aug 2020 16:58:06 -0400</pubDate>
|
||||||
|
<copyright>Copyright 2015-2020 NPR - For Personal Use Only</copyright>
|
||||||
|
<guid isPermalink="false">4837421e-6eac-48da-91ca-0191045b725c</guid>
|
||||||
|
<link>https://www.npr.org/2020/08/07/900273012/mask-communication</link>
|
||||||
|
<itunes:title>Mask Communication</itunes:title>
|
||||||
|
<itunes:author>NPR</itunes:author>
|
||||||
|
<itunes:summary>Behavioral economists have helped us smoke less and save more. Can they help us understand why some people won't wear masks, and find ways to get them to want to?</itunes:summary>
|
||||||
|
<itunes:subtitle>Behavioral economists have helped us smoke less and save more. Can they help us understand why some people won't wear masks, and find ways to get them to want to?</itunes:subtitle>
|
||||||
|
<itunes:image href="https://media.npr.org/assets/img/2020/08/07/gettyimages-1215037111_wide-f436d18533d519baacfb2add6f4b05cfc7d9b536.jpg?s=1400"/>
|
||||||
|
<itunes:duration>1586</itunes:duration>
|
||||||
|
<itunes:explicit>no</itunes:explicit>
|
||||||
|
<itunes:episodeType>full</itunes:episodeType>
|
||||||
|
<content:encoded><![CDATA[Why won't some people wear masks? Is there anything we can do to convince them? We look to behavioral economics for help. | Subscribe to our weekly newsletter <a href="https://www.npr.org/newsletter/money?utm_source=rss_feed_copy&utm_medium=podcast&utm_term=planet_money">here</a>.]]></content:encoded>
|
||||||
|
<enclosure url="https://play.podtrac.com/npr-510289/edge1.pod.npr.org/anon.npr-podcasts/podcast/npr/pmoney/2020/08/20200807_pmoney_pmpod1023-888aea1d-9e01-4765-a029-0fde7ae692f6.mp3?awCollectionId=510289&awEpisodeId=900273012&aw_0_1st.cv=yes&orgId=1&topicId=1128&aggIds=812054919&d=1586&p=510289&story=900273012&t=podcast&e=900273012&size=25328369&ft=pod&f=510289" length="25328369" type="audio/mpeg"/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>SUMMER SCHOOL 5: Trade & Santa</title>
|
||||||
|
<description><![CDATA[The economics of free trade and what happens when governments get involved.]]></description>
|
||||||
|
<pubDate>Wed, 05 Aug 2020 16:27:58 -0400</pubDate>
|
||||||
|
<copyright>Copyright 2015-2020 NPR - For Personal Use Only</copyright>
|
||||||
|
<guid isPermalink="false">219f4641-19a2-4a36-bd5c-623108875592</guid>
|
||||||
|
<link>https://www.npr.org/2020/08/05/899393029/summer-school-5-trade-santa</link>
|
||||||
|
<itunes:title>SUMMER SCHOOL 5: Trade & Santa</itunes:title>
|
||||||
|
<itunes:author>NPR</itunes:author>
|
||||||
|
<itunes:summary><![CDATA[The economics of free trade and what happens when governments get involved.]]></itunes:summary>
|
||||||
|
<itunes:image href="https://media.npr.org/assets/img/2020/08/05/5therriousdavis_planetmoney-santa_wide-20328bf842207fc48720e009b1b451da24f257b9.jpg?s=1400"/>
|
||||||
|
<itunes:duration>1584</itunes:duration>
|
||||||
|
<itunes:explicit>no</itunes:explicit>
|
||||||
|
<itunes:episodeType>full</itunes:episodeType>
|
||||||
|
<content:encoded><![CDATA[The economics of free trade and what happens when governments get involved.]]></content:encoded>
|
||||||
|
<enclosure url="https://play.podtrac.com/npr-510289/edge1.pod.npr.org/anon.npr-mp3/npr/pmoney/2020/08/20200805_pmoney_pmpod1021.mp3?awCollectionId=510289&awEpisodeId=899393029&orgId=1&d=1584&p=510289&story=899393029&t=podcast&e=899393029&size=25293135&ft=pod&f=510289" length="25293135" type="audio/mpeg"/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>College Fails</title>
|
||||||
|
<description><![CDATA[The pandemic is transforming college from a can't-miss into a can't-attend experience. Can colleges survive? | Subscribe to our weekly newsletter <a href="https://www.npr.org/newsletter/money?utm_source=rss_feed_copy&utm_medium=podcast&utm_term=planet_money">here</a>.]]></description>
|
||||||
|
<pubDate>Fri, 31 Jul 2020 19:32:55 -0400</pubDate>
|
||||||
|
<copyright>Copyright 2015-2020 NPR - For Personal Use Only</copyright>
|
||||||
|
<guid isPermalink="false">01b0506e-cfd5-462d-b07e-05904a8104b1</guid>
|
||||||
|
<link>https://www.npr.org/2020/07/31/897983620/college-fails</link>
|
||||||
|
<itunes:title>College Fails</itunes:title>
|
||||||
|
<itunes:author>NPR</itunes:author>
|
||||||
|
<itunes:summary>With a month to go before the fall semester starts, universities are closing due to coronavirus, and also making hard cuts and tradeoffs around remote learning.</itunes:summary>
|
||||||
|
<itunes:subtitle>With a month to go before the fall semester starts, universities are closing due to coronavirus, and also making hard cuts and tradeoffs around remote learning.</itunes:subtitle>
|
||||||
|
<itunes:image href="https://media.npr.org/assets/img/2020/07/31/rbs-100-rock_wide-c67a4fbc768b75b5a856bde3f16038457918fce4.jpg?s=1400"/>
|
||||||
|
<itunes:duration>1586</itunes:duration>
|
||||||
|
<itunes:explicit>no</itunes:explicit>
|
||||||
|
<itunes:episodeType>full</itunes:episodeType>
|
||||||
|
<content:encoded><![CDATA[The pandemic is transforming college from a can't-miss into a can't-attend experience. Can colleges survive? | Subscribe to our weekly newsletter <a href="https://www.npr.org/newsletter/money?utm_source=rss_feed_copy&utm_medium=podcast&utm_term=planet_money">here</a>.]]></content:encoded>
|
||||||
|
<enclosure url="https://play.podtrac.com/npr-510289/edge1.pod.npr.org/anon.npr-mp3/npr/pmoney/2020/07/20200731_pmoney_pmpod1021.mp3?awCollectionId=510289&awEpisodeId=897983620&orgId=1&topicId=1017&d=1586&p=510289&story=897983620&t=podcast&e=897983620&size=17683719&ft=pod&f=510289" length="17683719" type="audio/mpeg"/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>SUMMER SCHOOL 4: Scarcity & Pistachios</title>
|
||||||
|
<description><![CDATA[Class 4 brings us an economic conundrum: how do you efficiently share a scarce resource? | Subscribe to our weekly newsletter here. ]]></description>
|
||||||
|
<pubDate>Wed, 29 Jul 2020 18:14:21 -0400</pubDate>
|
||||||
|
<copyright>Copyright 2015-2020 NPR - For Personal Use Only</copyright>
|
||||||
|
<guid isPermalink="false">eaff3c77-d26e-44ed-a899-7e8ab3485de8</guid>
|
||||||
|
<link>https://www.npr.org/2020/07/28/896308345/summer-school-4-pistachios-scarcity</link>
|
||||||
|
<itunes:title>SUMMER SCHOOL 4: Scarcity & Pistachios</itunes:title>
|
||||||
|
<itunes:author>NPR</itunes:author>
|
||||||
|
<itunes:summary>Learn about scarce resources and the tragedy of the commons from drought-stricken California.</itunes:summary>
|
||||||
|
<itunes:subtitle>Learn about scarce resources and the tragedy of the commons from drought-stricken California.</itunes:subtitle>
|
||||||
|
<itunes:image href="https://media.npr.org/assets/img/2020/07/29/4therriosdavis_planetmoneywater_wide-5c6856f9d0f4f46f8e7c7d63499c5b722d0071a6.jpg?s=1400"/>
|
||||||
|
<itunes:duration>1711</itunes:duration>
|
||||||
|
<itunes:explicit>no</itunes:explicit>
|
||||||
|
<itunes:episodeType>full</itunes:episodeType>
|
||||||
|
<content:encoded><![CDATA[Class 4 brings us an economic conundrum: how do you efficiently share a scarce resource? | Subscribe to our weekly newsletter here. ]]></content:encoded>
|
||||||
|
<enclosure url="https://play.podtrac.com/npr-510289/edge1.pod.npr.org/anon.npr-podcasts/podcast/npr/pmoney/2020/07/20200729_pmoney_pmpod1019-c01d07ff-e070-43de-9383-e29abae231bb.mp3?awCollectionId=510289&awEpisodeId=896308345&orgId=1&d=1711&p=510289&story=896308345&t=podcast&e=896308345&size=27329135&ft=pod&f=510289" length="27329135" type="audio/mpeg"/>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
30
tests/fixtures/rssfeeds/waitwait-noeps.xml
vendored
Normal file
30
tests/fixtures/rssfeeds/waitwait-noeps.xml
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- copied from https://feeds.npr.org/344098539/podcast.xml -->
|
||||||
|
<rss xmlns:npr="https://www.npr.org/rss/" xmlns:nprml="https://api.npr.org/nprml" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Wait Wait... Don't Tell Me!</title>
|
||||||
|
<link>http://www.npr.org/programs/wait-wait-dont-tell-me/</link>
|
||||||
|
<description><![CDATA[NPR's weekly current events quiz. Have a laugh and test your news knowledge while figuring out what's real and what we've made up.]]></description>
|
||||||
|
<copyright>Copyright 2014-2020 NPR - For Personal Use Only</copyright>
|
||||||
|
<generator>NPR API RSS Generator 0.94</generator>
|
||||||
|
<language>en</language>
|
||||||
|
<itunes:summary><![CDATA[NPR's weekly current events quiz. Have a laugh and test your news knowledge while figuring out what's real and what we've made up.]]></itunes:summary>
|
||||||
|
<itunes:author>NPR</itunes:author>
|
||||||
|
<itunes:block>no</itunes:block>
|
||||||
|
<itunes:owner>
|
||||||
|
<itunes:email>podcasts@npr.org</itunes:email>
|
||||||
|
<itunes:name>NPR</itunes:name>
|
||||||
|
</itunes:owner>
|
||||||
|
<itunes:category text="Comedy"/>
|
||||||
|
<itunes:category text="Leisure">
|
||||||
|
<itunes:category text="Other Games"/>
|
||||||
|
</itunes:category>
|
||||||
|
<itunes:image href="https://media.npr.org/assets/img/2019/11/20/npr_91201389-1-_sq-ca0128ef6da8bbc571cf2bc15e5aecb4d1b33fb4.jpg?s=1400"/>
|
||||||
|
<image>
|
||||||
|
<url>https://media.npr.org/assets/img/2019/11/20/npr_91201389-1-_sq-ca0128ef6da8bbc571cf2bc15e5aecb4d1b33fb4.jpg?s=1400</url>
|
||||||
|
<title>Wait Wait... Don't Tell Me!</title>
|
||||||
|
<link>http://www.npr.org/programs/wait-wait-dont-tell-me/</link>
|
||||||
|
</image>
|
||||||
|
<lastBuildDate>Sat, 08 Aug 2020 12:00:00 -0400</lastBuildDate>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
Loading…
Reference in New Issue
Block a user