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 + + + + +