From 08a83a8492c8102d452532e9189c3954e130add0 Mon Sep 17 00:00:00 2001 From: Carl Hall Date: Fri, 14 Aug 2020 17:40:19 -0700 Subject: [PATCH] Create and use PodcastStatus enum Add missing foreign keys Require url for podcast channel be unique Add basic url validation Use soft delete for channels and episodes to match Subsonic impl --- setup.py | 1 + supysonic/api/podcast.py | 39 ++- supysonic/db.py | 82 +++-- supysonic/schema/migration/mysql/20200620.sql | 13 +- .../schema/migration/postgres/20200620.sql | 8 +- .../schema/migration/sqlite/20200620.sql | 12 +- supysonic/schema/mysql.sql | 11 +- supysonic/schema/postgres.sql | 8 +- supysonic/schema/sqlite.sql | 12 +- tests/api/test_podcast.py | 82 +++-- tests/fixtures/rssfeed.xml | 330 ++++++++++++++++++ 11 files changed, 510 insertions(+), 88 deletions(-) create mode 100644 tests/fixtures/rssfeed.xml diff --git a/setup.py b/setup.py index 6e58aed..4b85d22 100755 --- a/setup.py +++ b/setup.py @@ -22,6 +22,7 @@ reqs = [ "mutagen>=1.33", "watchdog>=0.8.0", "zipstream", + "feedparser", ] setup( diff --git a/supysonic/api/podcast.py b/supysonic/api/podcast.py index 889e32d..d964d78 100644 --- a/supysonic/api/podcast.py +++ b/supysonic/api/podcast.py @@ -7,9 +7,15 @@ # # Distributed under terms of the GNU AGPLv3 license. -from flask import request +import os +from datetime import datetime +from time import mktime +from urllib.parse import urlparse -from ..db import PodcastChannel, PodcastEpisode +from flask import request +import feedparser + +from ..db import PodcastChannel, PodcastEpisode, PodcastStatus from . import api, get_entity, require_podcast from .exceptions import Forbidden, MissingParameter, NotFound @@ -22,7 +28,9 @@ def get_podcasts(): if channel_id: channels = (get_entity(PodcastChannel),) else: - channels = PodcastChannel.select().sort_by(PodcastChannel.url) + channels = PodcastChannel \ + .select(lambda chan: chan.status != PodcastStatus.deleted.value) \ + .sort_by(PodcastChannel.url) return request.formatter( "podcasts", @@ -34,8 +42,26 @@ def get_podcasts(): @require_podcast def create_podcast_channel(): url = request.values["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 request.formatter.error(10, 'unexepected url') - PodcastChannel(url=url) + feed = feedparser.parse(url) + channel = PodcastChannel( + url=url, + title=feed.feed.title, + ) + + for item in feed.entries: + channel.episodes.create( + title=item.title, + description=item.description, + stream_url=item.links[0].href, + duration=item.links[0].length, + publish_date=datetime.fromtimestamp(mktime(item.published_parsed)), + status=PodcastStatus.new.value, + ) return request.formatter.empty @@ -44,7 +70,7 @@ def create_podcast_channel(): @require_podcast def delete_podcast_channel(): res = get_entity(PodcastChannel) - res.delete() + res.soft_delete() return request.formatter.empty @@ -53,8 +79,7 @@ def delete_podcast_channel(): @require_podcast def delete_podcast_episode(): res = get_entity(PodcastEpisode) - res.delete() + res.soft_delete() return request.formatter.empty - diff --git a/supysonic/db.py b/supysonic/db.py index 789fd45..3a6dd32 100755 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -14,6 +14,7 @@ import pkg_resources import time from datetime import datetime +from enum import Enum, unique from hashlib import sha1 from pony.orm import Database, Required, Optional, Set, PrimaryKey, LongStr from pony.orm import ObjectNotFound, DatabaseError @@ -573,32 +574,54 @@ class RadioStation(db.Entity): return info +@unique +class PodcastStatus(Enum): + new = 1 + downloading = 2 + completed = 3 + error = 4 + deleted = 5 + skipped = 6 + + class PodcastChannel(db.Entity): _table_ = "podcast_channel" id = PrimaryKey(UUID, default=uuid4) - url = Required(str) - title = Optional(str) - description = Optional(str) - cover_art = Optional(str) + url = Required(str, unique=True) + title = Optional(str, nullable=True) + description = Optional(str, nullable=True) + cover_art = Optional(str, nullable=True) original_image_url = Optional(str) - status = Required(str, default="new") - error_message = Optional(str) + # Status params mirror PodcastStatus + status = Required(int, min=1, max=6, default=1) + error_message = Optional(str, nullable=True) created = Required(datetime, precision=0, default=now) - last_fetched = Optional(datetime, precision=0) + last_fetched = Optional(datetime, precision=0, nullable=True) episodes = Set(lambda: PodcastEpisode, lazy=True) + def soft_delete(self): + for ep in self.episodes: + ep.soft_delete() + + self.status = PodcastStatus.deleted.value + self.error_message = None + def as_subsonic_channel(self, include_episodes=False): info = dict( id=self.id, url=self.url, - title=self.title, - description=self.description, - status=self.status, - errorMessage=self.error_message, + status=PodcastStatus(self.status).name, ) + + if self.title: + info["title"] = self.title + if self.description: + info["description"] = self.description + if self.error_message: + info["errorMessage"] = self.error_message + if include_episodes: - self.episodes.load() info["episode"] = [ep.as_subsonic_episode() for ep in self.episodes] return info @@ -608,25 +631,40 @@ class PodcastEpisode(db.Entity): id = PrimaryKey(UUID, default=uuid4) channel = Required(PodcastChannel, column="channel_id") - stream_url = Optional(str) - file_path = Optional(str) - title = Optional(str) - description = Optional(str) - duration = Optional(str) - status = Required(str, default="new") + # Location of the episode. Used to stream and download. + stream_url = Required(str) + # Path of file after it has been downloaded. + file_path = Optional(str, nullable=True) + title = Required(str) + description = Optional(str, nullable=True) + duration = Optional(str, nullable=True) + # Status params mirror PodcastStatus + status = Required(int, min=1, max=6, default=1) publish_date = Optional(datetime, precision=0, default=now) created = Required(datetime, precision=0, default=now) + def soft_delete(self): + self.status = PodcastStatus.deleted.value + + if self.file_path: + if os.path.exists(self.file_path): + os.remove(self.file_path) + self.file_path = None + def as_subsonic_episode(self): info = dict( id=self.id, isDir=False, - streamId="podcast:{}".format(self.id), title=self.title, - description=self.description, - status=self.status, - publishDate=self.publish_date.isoformat(), + streamId="podcast:{}".format(self.id), + status=PodcastStatus(self.status).name, ) + + if self.description: + info["description"] = self.description, + if self.publish_date: + info["publishDate"] = self.publish_date.isoformat() + return info diff --git a/supysonic/schema/migration/mysql/20200620.sql b/supysonic/schema/migration/mysql/20200620.sql index 9eed7c3..b918357 100644 --- a/supysonic/schema/migration/mysql/20200620.sql +++ b/supysonic/schema/migration/mysql/20200620.sql @@ -2,12 +2,12 @@ ALTER TABLE user ADD podcast BOOLEAN DEFAULT false NOT NULL; CREATE TABLE IF NOT EXISTS podcast_channel ( id BINARY(16) PRIMARY KEY, - url VARCHAR(256) NOT NULL, + url VARCHAR(256) UNIQUE NOT NULL, title VARCHAR(256), description VARCHAR(256), cover_art VARCHAR(256), original_image_url VARCHAR(256), - status VARCHAR(16), + status TINYINT NOT NULL, error_message VARCHAR(256), created TIMESTAMP NOT NULL, last_fetched TIMESTAMP @@ -15,15 +15,16 @@ CREATE TABLE IF NOT EXISTS podcast_channel ( CREATE TABLE IF NOT EXISTS podcast_episode ( id BINARY(16) PRIMARY KEY, - stream_url VARCHAR(256), + stream_url VARCHAR(256) NOT NULL, file_path VARCHAR(256), channel_id CHAR(36) NOT NULL, title VARCHAR(256), description VARCHAR(256), duration VARCHAR(8), - status VARCHAR(16) NOT NULL, + status TINYINT NOT NULL, publish_date DATETIME, - created DATETIME NOT NULL + created DATETIME NOT NULL, 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) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/supysonic/schema/migration/postgres/20200620.sql b/supysonic/schema/migration/postgres/20200620.sql index 4265a9b..30ede80 100644 --- a/supysonic/schema/migration/postgres/20200620.sql +++ b/supysonic/schema/migration/postgres/20200620.sql @@ -7,22 +7,24 @@ CREATE TABLE IF NOT EXISTS podcast_channel ( description VARCHAR(256), cover_art VARCHAR(256), original_image_url VARCHAR(256), - status VARCHAR(16), + status TINYINT NOT NULL, error_message VARCHAR(256), created TIMESTAMP NOT NULL, last_fetched TIMESTAMP ); +CREATE INDEX IF NOT EXISTS index_channel_status ON podcast_channel(status); CREATE TABLE IF NOT EXISTS podcast_episode ( id UUID PRIMARY KEY, - stream_url VARCHAR(256), + stream_url VARCHAR(256) NOT NULL, file_path VARCHAR(256), channel_id CHAR(36) NOT NULL REFERENCES podcast_channel(id), title VARCHAR(256), description VARCHAR(256), duration VARCHAR(8), - status VARCHAR(16) NOT NULL, + status TINYINT NOT NULL, publish_date TIMESTAMP, created TIMESTAMP NOT NULL ); 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); diff --git a/supysonic/schema/migration/sqlite/20200620.sql b/supysonic/schema/migration/sqlite/20200620.sql index 827557b..c5d2bb3 100644 --- a/supysonic/schema/migration/sqlite/20200620.sql +++ b/supysonic/schema/migration/sqlite/20200620.sql @@ -2,27 +2,29 @@ ALTER TABLE user ADD podcast BOOLEAN DEFAULT false NOT NULL; CREATE TABLE IF NOT EXISTS podcast_channel ( id CHAR(36) PRIMARY KEY, - url VARCHAR(256) NOT NULL, + url VARCHAR(256) UNIQUE NOT NULL, title VARCHAR(256), description VARCHAR(256), cover_art VARCHAR(256), original_image_url VARCHAR(256), - status VARCHAR(16), + status TINYINT NOT NULL, error_message VARCHAR(256), created DATETIME NOT NULL, last_fetched DATETIME ); +CREATE INDEX IF NOT EXISTS index_channel_status_id_fk ON podcast_channel(status); CREATE TABLE IF NOT EXISTS podcast_episode ( id CHAR(36) PRIMARY KEY, - stream_url VARCHAR(256), + stream_url VARCHAR(256) NOT NULL, file_path VARCHAR(256), - channel_id CHAR(36) NOT NULL, + channel_id CHAR(36) NOT NULL REFERENCES podcast_channel, title VARCHAR(256), description VARCHAR(256), duration VARCHAR(8), - status VARCHAR(16) NOT NULL, + status TINYINT NOT NULL, publish_date DATETIME, created DATETIME NOT NULL ); 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); diff --git a/supysonic/schema/mysql.sql b/supysonic/schema/mysql.sql index 0d209d2..d849395 100644 --- a/supysonic/schema/mysql.sql +++ b/supysonic/schema/mysql.sql @@ -161,12 +161,12 @@ CREATE TABLE IF NOT EXISTS radio_station ( CREATE TABLE IF NOT EXISTS podcast_channel ( id BINARY(16) PRIMARY KEY, - url VARCHAR(256) NOT NULL, + url VARCHAR(256) UNIQUE NOT NULL, title VARCHAR(256), description VARCHAR(256), cover_art VARCHAR(256), original_image_url VARCHAR(256), - status VARCHAR(16), + status TINYINT NOT NULL, error_message VARCHAR(256), created TIMESTAMP NOT NULL, last_fetched TIMESTAMP @@ -174,15 +174,16 @@ CREATE TABLE IF NOT EXISTS podcast_channel ( CREATE TABLE IF NOT EXISTS podcast_episode ( id BINARY(16) PRIMARY KEY, - stream_url VARCHAR(256), + stream_url VARCHAR(256) NOT NULL, file_path VARCHAR(256), channel_id CHAR(36) NOT NULL, title VARCHAR(256), description VARCHAR(256), duration VARCHAR(8), - status VARCHAR(16) NOT NULL, + status TINYINT NOT NULL, publish_date DATETIME, created DATETIME NOT NULL, 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) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/supysonic/schema/postgres.sql b/supysonic/schema/postgres.sql index 46a0530..2312099 100644 --- a/supysonic/schema/postgres.sql +++ b/supysonic/schema/postgres.sql @@ -166,22 +166,24 @@ CREATE TABLE IF NOT EXISTS podcast_channel ( description VARCHAR(256), cover_art VARCHAR(256), original_image_url VARCHAR(256), - status VARCHAR(16), + status TINYINT NOT NULL, error_message VARCHAR(256), created TIMESTAMP NOT NULL, last_fetched TIMESTAMP ); +CREATE INDEX IF NOT EXISTS index_channel_status ON podcast_channel(status); CREATE TABLE IF NOT EXISTS podcast_episode ( id UUID PRIMARY KEY, - stream_url VARCHAR(256), + stream_url VARCHAR(256) NOT NULL, file_path VARCHAR(256), channel_id CHAR(36) NOT NULL REFERENCES podcast_channel(id), title VARCHAR(256), description VARCHAR(256), duration VARCHAR(8), - status VARCHAR(16) NOT NULL, + status TINYINT NOT NULL, publish_date TIMESTAMP, created TIMESTAMP NOT NULL ); 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); diff --git a/supysonic/schema/sqlite.sql b/supysonic/schema/sqlite.sql index d1f8e97..d148cca 100644 --- a/supysonic/schema/sqlite.sql +++ b/supysonic/schema/sqlite.sql @@ -163,27 +163,29 @@ CREATE TABLE IF NOT EXISTS radio_station ( CREATE TABLE IF NOT EXISTS podcast_channel ( id CHAR(36) PRIMARY KEY, - url VARCHAR(256) NOT NULL, + url VARCHAR(256) UNIQUE NOT NULL, title VARCHAR(256), description VARCHAR(256), cover_art VARCHAR(256), original_image_url VARCHAR(256), - status VARCHAR(16), + status TINYINT NOT NULL, error_message VARCHAR(256), created DATETIME NOT NULL, last_fetched DATETIME ); +CREATE INDEX IF NOT EXISTS index_channel_status_id_fk ON podcast_channel(status); CREATE TABLE IF NOT EXISTS podcast_episode ( id CHAR(36) PRIMARY KEY, - stream_url VARCHAR(256), + stream_url VARCHAR(256) NOT NULL, file_path VARCHAR(256), - channel_id CHAR(36) NOT NULL, + channel_id CHAR(36) NOT NULL REFERENCES podcast_channel, title VARCHAR(256), description VARCHAR(256), duration VARCHAR(8), - status VARCHAR(16) NOT NULL, + status TINYINT NOT NULL, publish_date DATETIME, created DATETIME NOT NULL ); 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); diff --git a/tests/api/test_podcast.py b/tests/api/test_podcast.py index 978ab68..6b70849 100644 --- a/tests/api/test_podcast.py +++ b/tests/api/test_podcast.py @@ -8,11 +8,12 @@ # # Distributed under terms of the GNU AGPLv3 license. +import os import uuid from pony.orm import db_session -from supysonic.db import PodcastChannel, PodcastEpisode +from supysonic.db import PodcastChannel, PodcastEpisode, PodcastStatus from unittest import skip @@ -29,11 +30,13 @@ class PodcastTestCase(ApiTestBase): def assertDbCountEqual(self, entity, count): self.assertEqual(entity.select().count(), count) - def assertPodcastChannelEquals(self, channel, url, status, title='', description='', error_message=''): + def assertPodcastChannelEquals(self, channel, url, status, title=None, description=None, error_message=None): self.assertEqual(channel.url, url) - self.assertEqual(channel.title, title) - self.assertEqual(channel.description, description) self.assertEqual(channel.status, status) + if title: + self.assertEqual(channel.title, title) + if description: + self.assertEqual(channel.description, description) self.assertEqual(channel.error_message, error_message) def test_create_podcast_channel(self): @@ -46,18 +49,19 @@ class PodcastTestCase(ApiTestBase): # check params self._make_request("createPodcastChannel", error=10) + self._make_request("createPodcastChannel", {"url": "bad url"}, error=10) # create w/ required fields - url = "https://example.local/podcast_channel/create" + url = "file://" + os.path.join(os.path.dirname(__file__), "../fixtures/rssfeed.xml") + self._make_request("createPodcastChannel", {"url": url}, skip_post=True) - self._make_request("createPodcastChannel", {"url": url}) - - # the correct value is 2 because _make_request uses GET then POST - self.assertDbCountEqual(PodcastChannel, 2) + self.assertDbCountEqual(PodcastChannel, 1) + self.assertDbCountEqual(PodcastEpisode, 20) with db_session: - for channel in PodcastChannel.select(): - self.assertPodcastChannelEquals(channel, url, "new") + self.assertPodcastChannelEquals(PodcastChannel.select().first(), url, PodcastStatus.new.value) + for episode in PodcastEpisode.select(): + self.assertEqual(episode.status, PodcastStatus.new.value) def test_delete_podcast_channel(self): @@ -77,13 +81,17 @@ class PodcastTestCase(ApiTestBase): with db_session: channel = PodcastChannel( url="https://example.local/podcast/delete", - status="new", + status=PodcastStatus.new.value, ) self._make_request("deletePodcastChannel", {"id": channel.id}, skip_post=True) - self.assertDbCountEqual(PodcastChannel, 0) + self.assertDbCountEqual(PodcastChannel, 1) + with db_session: + self.assertEqual(PodcastStatus.deleted.value, PodcastChannel[channel.id].status) + + @db_session def test_delete_podcast_episode(self): # test for non-admin access self._make_request( @@ -98,31 +106,38 @@ class PodcastTestCase(ApiTestBase): self._make_request("deletePodcastEpisode", {"id": str(uuid.uuid4())}, error=70) # delete - with db_session: - channel = PodcastChannel( - url="https://example.local/episode/delete", - status="new", - ) - episode = channel.episodes.create( - description="Test Episode 1", - status="new", - ) - channel.episodes.create( - description="Test Episode 2", - status="new", - ) + channel = PodcastChannel( + url="https://example.local/episode/delete", + status=PodcastStatus.new.value, + ) + episode = channel.episodes.create( + title="Test Episode 1", + stream_url="https://supysonic.local/delete/1", + status=PodcastStatus.new.value, + ) + channel.episodes.create( + title="Test Episode 2", + stream_url="https://supysonic.local/delete/2", + status=PodcastStatus.new.value, + ) # validate starting condition self.assertDbCountEqual(PodcastEpisode, 2) # validate delete of an episode self._make_request("deletePodcastEpisode", {"id": episode.id}, skip_post=True) - self.assertDbCountEqual(PodcastEpisode, 1) + ## marked as deleted + self.assertDbCountEqual(PodcastEpisode, 2) + self.assertEqual(PodcastStatus.deleted.value, PodcastEpisode[episode.id].status) # test for cascading delete on PodcastChannel self._make_request("deletePodcastChannel", {"id": channel.id}, skip_post=True) - self.assertDbCountEqual(PodcastChannel, 0) - self.assertDbCountEqual(PodcastEpisode, 0) + ## counts are the same but the status is now "deleted" + self.assertDbCountEqual(PodcastChannel, 1) + self.assertEqual(PodcastStatus.deleted.value, PodcastChannel[channel.id].status) + self.assertDbCountEqual(PodcastEpisode, 2) + for ep in PodcastEpisode.select(): + self.assertEqual(PodcastStatus.deleted.value, ep.status) def test_get_podcasts(self): test_range = 3 @@ -130,10 +145,13 @@ class PodcastTestCase(ApiTestBase): for x in range(test_range): ch = PodcastChannel( url="https://example.local/podcast-{}".format(x), - status="new", + status=PodcastStatus.new.value, ) for y in range(x + 1): - ch.episodes.create(description="episode {} for channel {}".format(y, x)) + ch.episodes.create( + title="episode {} for channel {}".format(y, x), + stream_url="https://supysonic.local/get/{}/{}".format(x, y), + ) # verify data is stored self.assertDbCountEqual(PodcastChannel, test_range) @@ -147,7 +165,7 @@ class PodcastTestCase(ApiTestBase): for x in range(test_range): channel = channels[x] self.assertTrue(channel.get("url").endswith("podcast-{}".format(x))) - self.assertTrue(channel.get("status").endswith("new")) + self.assertEqual(channel.get("status"), "new") # test for non-admin access rv, channels = self._make_request( diff --git a/tests/fixtures/rssfeed.xml b/tests/fixtures/rssfeed.xml new file mode 100644 index 0000000..9d3efb5 --- /dev/null +++ b/tests/fixtures/rssfeed.xml @@ -0,0 +1,330 @@ + + + + + Wait Wait... Don't Tell Me! + http://www.npr.org/programs/wait-wait-dont-tell-me/ + + Copyright 2014-2020 NPR - For Personal Use Only + NPR API RSS Generator 0.94 + en + + NPR + no + + podcasts@npr.org + NPR + + + + + + + + https://media.npr.org/assets/img/2019/11/20/npr_91201389-1-_sq-ca0128ef6da8bbc571cf2bc15e5aecb4d1b33fb4.jpg?s=1400 + Wait Wait... Don't Tell Me! + http://www.npr.org/programs/wait-wait-dont-tell-me/ + + Sat, 08 Aug 2020 12:00:00 -0400 + + Bryan Cranston + + Sat, 08 Aug 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + 623b04b0-439a-4c8d-b338-2f0ee6464e07 + Bryan Cranston + NPR + + 2868 + no + full + + + + + Ramy Youssef + + Sat, 01 Aug 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + 1dab26dc-a02c-4352-8057-0380a2cce384 + Ramy Youssef + NPR + + 2959 + no + full + + + + + Padma Lakshmi + + Sat, 25 Jul 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + efd39a54-2b6c-4458-8a28-74e27c199fb5 + Padma Lakshmi + NPR + + 2971 + no + full + + + + + Maria Konnikova + + Sat, 18 Jul 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + 578fd11b-0448-406a-a63d-99b536c43318 + Maria Konnikova + NPR + + 2927 + no + full + + + + + Bonus Podcast: Tur-Bill Tax + + Tue, 14 Jul 2020 18:55:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + 9ed4bdef-145b-4e45-809e-e74877842d03 + Bonus Podcast: Tur-Bill Tax + NPR + + 74 + no + full + + + + + Jameela Jamil + + Sat, 11 Jul 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + 41868a31-df3e-48e6-b572-2ff0b0e4ae29 + Jameela Jamil + NPR + + 2872 + no + full + + + + + WWDTM Quarantine Edition + + Sat, 04 Jul 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + c9b64706-efc5-4a8b-93ee-1f4323c8bfca + WWDTM Quarantine Edition + NPR + + 2881 + no + full + + + + + Don Cheadle + + Sat, 27 Jun 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + 4662e484-dac3-4692-8e7a-e2b5ff176979 + Don Cheadle + NPR + + 2990 + no + full + + + + + Dan Riskin + + Sat, 20 Jun 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + bc6029ab-2bec-4fe5-aa4f-d570a29257b7 + Dan Riskin + NPR + + 2933 + no + full + + + + + Ashima Shiraishi + + Sat, 13 Jun 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + 98aaa454-fd63-4edc-8236-c2499f37a5fc + Ashima Shiraishi + NPR + + 2960 + no + full + + + + + Sarah Cooper + + Sat, 06 Jun 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + db687322-1327-4ad2-a1cb-251a988da5fd + Sarah Cooper + NPR + + 2926 + no + full + + + + + Tony Hawk and Jeff Tweedy + + Sat, 30 May 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + 0e0c5ee0-3733-45bc-979f-20b627a95c09 + Tony Hawk and Jeff Tweedy + NPR + + 2907 + no + full + + + + + Christina Koch + + Sat, 23 May 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + b13d7726-d82a-4e00-b23c-d09e3e0d383e + Christina Koch + NPR + + 2954 + no + full + + + + + Adam Rippon + + Sat, 16 May 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + 709d41f4-e5be-45ed-83fe-543282c2051d + Adam Rippon + NPR + + 2947 + no + full + + + + + Bonus Podcast: Bill Kurtis In the Wild + + Tue, 12 May 2020 18:50:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + 3903c8b4-b735-45c6-bf09-2b237b6d5e15 + Bonus Podcast: Bill Kurtis In the Wild + NPR + + 139 + no + full + + + + + Samantha Bee + + Sat, 09 May 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + 29550aa1-e223-4496-b394-bdd685b0c08f + Samantha Bee + NPR + + 2926 + no + full + + + + + Christine Baranski + + Sat, 02 May 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + 1c9b2452-02c9-4897-9f32-01156fc0bf8a + Christine Baranski + NPR + + 2995 + no + full + + + + + Allison Janney + + Sat, 25 Apr 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + 01528da1-fa6f-4177-a19d-9aafb1926a86 + Allison Janney + NPR + + 2992 + no + full + + + + + Tom Hanks At Home + + Sat, 18 Apr 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + 61215c56-640e-4fc0-bef3-d88810aa48b6 + Tom Hanks At Home + NPR + + 2994 + no + full + + + + + Samin Nosrat + + Sat, 11 Apr 2020 12:00:00 -0400 + Copyright 2014-2020 NPR - For Personal Use Only + be3fe3c6-153b-4f9a-8082-56eedf498adc + Samin Nosrat + NPR + + 2933 + no + full + + + + +