summaryrefslogtreecommitdiff
path: root/app/podcasts
diff options
context:
space:
mode:
Diffstat (limited to 'app/podcasts')
-rw-r--r--app/podcasts/admin.py10
-rw-r--r--app/podcasts/feeds.py229
-rw-r--r--app/podcasts/migrations/0001_initial.py60
-rw-r--r--app/podcasts/migrations/__init__.py0
-rw-r--r--app/podcasts/models.py132
-rw-r--r--app/podcasts/templates/podcasts/detail.html108
-rw-r--r--app/podcasts/templates/podcasts/list.html31
-rw-r--r--app/podcasts/urls.py19
-rw-r--r--app/podcasts/urls_feeds.py17
-rw-r--r--app/podcasts/views.py29
10 files changed, 635 insertions, 0 deletions
diff --git a/app/podcasts/admin.py b/app/podcasts/admin.py
new file mode 100644
index 0000000..b6adfc6
--- /dev/null
+++ b/app/podcasts/admin.py
@@ -0,0 +1,10 @@
+from django.contrib import admin
+from .models import Podcast
+
+@admin.register(Podcast)
+class PodcastAdmin(admin.ModelAdmin):
+ list_display = ('title',)
+ search_fields = ['title', 'body_markdown']
+
+ class Media:
+ js = ('next-prev-links.js',)
diff --git a/app/podcasts/feeds.py b/app/podcasts/feeds.py
new file mode 100644
index 0000000..24d47bd
--- /dev/null
+++ b/app/podcasts/feeds.py
@@ -0,0 +1,229 @@
+import datetime
+from podcasts.models import Episode, Podcast
+from django.contrib.syndication.views import Feed
+from django.contrib.sites.shortcuts import get_current_site
+from django.views.generic.base import RedirectView
+from django.utils.feedgenerator import rfc2822_date, Rss201rev2Feed, Atom1Feed
+from django.shortcuts import get_object_or_404
+
+from .models import MIME_CHOICES
+
+class ITunesElements(object):
+
+ def add_root_elements(self, handler):
+ """ Add additional elements to the podcast object"""
+ super(ITunesElements, self).add_root_elements(handler)
+
+ podcast = self.feed["podcast"]
+
+ if podcast.featured_image:
+ # grab thumbs here
+ #itunes_sm_url = thumbnailer.get_thumbnail(aliases["itunes_sm"]).url
+ #itunes_lg_url = thumbnailer.get_thumbnail(aliases["itunes_lg"]).url
+ if itunes_sm_url and itunes_lg_url:
+ handler.addQuickElement("itunes:image", attrs={"href": itunes_lg_url})
+ handler.startElement("image", {})
+ handler.addQuickElement("url", itunes_sm_url)
+ handler.addQuickElement("title", self.feed["title"])
+ handler.addQuickElement("link", self.feed["link"])
+ handler.endElement("image")
+
+ handler.addQuickElement("guid", str(podcast.uuid), attrs={"isPermaLink": "false"})
+ handler.addQuickElement("itunes:subtitle", self.feed["subtitle"])
+ handler.addQuickElement("itunes:author", podcast.publisher)
+ handler.startElement("itunes:owner", {})
+ handler.addQuickElement("itunes:name", podcast.publisher)
+ handler.addQuickElement("itunes:email", podcast.publisher_email)
+ handler.endElement("itunes:owner")
+ handler.addQuickElement("itunes:category", attrs={"text": self.feed["categories"][0]})
+ handler.addQuickElement("itunes:summary", podcast.description)
+ handler.addQuickElement("itunes:explicit", "no")
+ handler.addQuickElement("keywords", podcast.keywords)
+ try:
+ handler.addQuickElement("lastBuildDate",
+ rfc2822_date(podcast.episode_set.filter(status=1)[1].pub_date))
+ except IndexError:
+ pass
+ handler.addQuickElement("generator", "Luxagraf's Django Web Framework")
+ handler.addQuickElement("docs", "http://blogs.law.harvard.edu/tech/rss")
+
+ def add_item_elements(self, handler, item):
+ """ Add additional elements to the episode object"""
+ super(ITunesElements, self).add_item_elements(handler, item)
+
+ podcast = item["podcast"]
+ episode = item["episode"]
+ if episode.featured_image:
+ #grab episode thumbs
+ #itunes_sm_url = None
+ #itunes_lg_url = None
+ if itunes_sm_url and itunes_lg_url:
+ handler.addQuickElement("itunes:image", attrs={"href": itunes_lg_url})
+ handler.startElement("image", {})
+ handler.addQuickElement("url", itunes_sm_url)
+ handler.addQuickElement("title", episode.title)
+ handler.addQuickElement("link", episode.get_absolute_url())
+ handler.endElement("image")
+
+ handler.addQuickElement("guid", str(episode.uuid), attrs={"isPermaLink": "false"})
+ handler.addQuickElement("copyright", "{0} {1}".format(podcast.license,
+ datetime.date.today().year))
+ handler.addQuickElement("itunes:author", episode.podcast.publisher)
+ handler.addQuickElement("itunes:subtitle", episode.subtitle)
+ handler.addQuickElement("itunes:summary", episode.description)
+ handler.addQuickElement("itunes:duration", "%02d:%02d:%02d" % (episode.hours,
+ episode.minutes,
+ episode.seconds))
+ handler.addQuickElement("itunes:keywords", episode.keywords)
+ handler.addQuickElement("itunes:explicit", "no")
+ if episode.block:
+ handler.addQuickElement("itunes:block", "yes")
+
+ def namespace_attributes(self):
+ return {"xmlns:itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd"}
+
+
+class AtomITunesFeedGenerator(ITunesElements, Atom1Feed):
+ def root_attributes(self):
+ atom_attrs = super(AtomITunesFeedGenerator, self).root_attributes()
+ atom_attrs.update(self.namespace_attributes())
+ return atom_attrs
+
+
+class RssITunesFeedGenerator(ITunesElements, Rss201rev2Feed):
+ def rss_attributes(self):
+ rss_attrs = super(RssITunesFeedGenerator, self).rss_attributes()
+ rss_attrs.update(self.namespace_attributes())
+ return rss_attrs
+
+
+class ShowFeed(Feed):
+ """
+ A feed of podcasts for iTunes and other compatible podcatchers.
+ """
+ def title(self, podcast):
+ return podcast.title
+
+ def link(self, podcast):
+ return podcast.get_absolute_url()
+
+ def categories(self, podcast):
+ return ("Music",)
+
+ def feed_copyright(self, podcast):
+ return "{0} {1}".format(podcast.license, datetime.date.today().year)
+
+ def ttl(self, podcast):
+ return podcast.ttl
+
+ def items(self, podcast):
+ return podcast.episode_set.filter(status=1)[:300]
+
+ def get_object(self, request, *args, **kwargs):
+ self.mime = [mc[0] for mc in MIME_CHOICES if mc[0] == kwargs["mime_type"]][0]
+ site = get_current_site(request)
+ self.podcast = get_object_or_404(Podcast, slug=kwargs["show_slug"])
+ return self.podcast
+
+ def item_title(self, episode):
+ return episode.title
+
+ def item_description(self, episode):
+ "renders summary for atom"
+ return episode.description
+
+ def item_link(self, episode):
+ return reverse("podcasting_episode_detail",
+ kwargs={"podcast_slug": self.podcast.slug, "slug": episode.slug})
+
+ # def item_author_link(self, episode):
+ # return "todo" #this one doesn't add anything in atom or rss
+ #
+ # def item_author_email(self, episode):
+ # return "todo" #this one doesn't add anything in atom or rss
+
+ def item_pubdate(self, episode):
+ return episode.pub_date
+
+ def item_categories(self, episode):
+ return self.categories(self.podcast)
+
+ def item_enclosure_url(self, episode):
+ try:
+ e = episode.enclosure_set.get(mime=self.mime)
+ return e.url
+ except Enclosure.DoesNotExist:
+ pass
+
+ def item_enclosure_length(self, episode):
+ try:
+ e = episode.enclosure_set.get(mime=self.mime)
+ return e.size
+ except Enclosure.DoesNotExist:
+ pass
+
+ def item_enclosure_mime_type(self, episode):
+ try:
+ e = episode.enclosure_set.get(mime=self.mime)
+ return e.get_mime_display()
+ except Enclosure.DoesNotExist:
+ pass
+
+ def item_keywords(self, episode):
+ return episode.keywords
+
+ def feed_extra_kwargs(self, obj):
+ extra = {}
+ extra["podcast"] = self.podcast
+ return extra
+
+ def item_extra_kwargs(self, item):
+ extra = {}
+ extra["podcast"] = self.podcast
+ extra["episode"] = item
+ return extra
+
+
+class AtomShowFeed(ShowFeed):
+ feed_type = AtomITunesFeedGenerator
+
+ def subtitle(self, show):
+ return show.subtitle
+
+ def author_name(self, show):
+ return show.publisher
+
+ def author_email(self, show):
+ return show.publisher_email
+
+ def author_link(self, show):
+ return show.get_absolute_url()
+
+
+class RssShowFeed(ShowFeed):
+ feed_type = RssITunesFeedGenerator
+
+ def item_guid(self, episode):
+ "ITunesElements can't add isPermaLink attr unless None is returned here."
+ return None
+
+ def description(self, show):
+ return show.description
+
+
+class AtomRedirectView(RedirectView):
+ permanent = False
+
+ def get_redirect_url(self, show_slug, mime_type):
+ return reverse(
+ "podcasts_show_feed_atom",
+ kwargs={"show_slug": show_slug, "mime_type": mime_type})
+
+
+class RssRedirectView(RedirectView):
+ permanent = False
+
+ def get_redirect_url(self, show_slug, mime_type):
+ return reverse(
+ "podcasts_show_feed_rss",
+ kwargs={"show_slug": show_slug, "mime_type": mime_type})
diff --git a/app/podcasts/migrations/0001_initial.py b/app/podcasts/migrations/0001_initial.py
new file mode 100644
index 0000000..644e7dc
--- /dev/null
+++ b/app/podcasts/migrations/0001_initial.py
@@ -0,0 +1,60 @@
+# Generated by Django 4.1.3 on 2022-12-02 20:09
+
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('media', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Podcast',
+ fields=[
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('title', models.CharField(max_length=255)),
+ ('subtitle', models.CharField(blank=True, max_length=255, null=True)),
+ ('slug', models.SlugField()),
+ ('publisher', models.CharField(max_length=255)),
+ ('publisher_email', models.CharField(max_length=255)),
+ ('description', models.TextField()),
+ ('keywords', models.CharField(blank=True, help_text='A comma-delimited list of words for searches, up to 12;', max_length=255)),
+ ('license', models.TextField(blank=True, null=True)),
+ ('featured_image', models.ForeignKey(blank=True, help_text='square JPEG (.jpg) or PNG (.png) image at a size of 1400x1400 pixels.', null=True, on_delete=django.db.models.deletion.CASCADE, to='media.luximage')),
+ ],
+ options={
+ 'verbose_name': 'Podcast',
+ 'verbose_name_plural': 'Podcasts',
+ 'ordering': ('title', 'slug'),
+ },
+ ),
+ migrations.CreateModel(
+ name='Episode',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('title', models.CharField(max_length=255)),
+ ('subtitle', models.CharField(blank=True, max_length=255, null=True)),
+ ('date_created', models.DateTimeField(auto_now_add=True)),
+ ('date_updated', models.DateTimeField(auto_now=True)),
+ ('pub_date', models.DateTimeField(blank=True, null=True)),
+ ('enable_comments', models.BooleanField(default=True)),
+ ('slug', models.SlugField()),
+ ('status', models.IntegerField(choices=[(0, 'Draft'), (1, 'Published')], default=0)),
+ ('description', models.TextField()),
+ ('keywords', models.CharField(blank=True, help_text='A comma-delimited list of words for searches, up to 12;', max_length=255)),
+ ('featured_image', models.ForeignKey(blank=True, help_text='square JPEG (.jpg) or PNG (.png) image at a size of 1400x1400 pixels.', null=True, on_delete=django.db.models.deletion.CASCADE, to='media.luximage')),
+ ('podcast', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='podcasts.podcast')),
+ ],
+ options={
+ 'verbose_name': 'Episode',
+ 'verbose_name_plural': 'Episodes',
+ 'ordering': ('-pub_date', 'slug'),
+ },
+ ),
+ ]
diff --git a/app/podcasts/migrations/__init__.py b/app/podcasts/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/app/podcasts/migrations/__init__.py
diff --git a/app/podcasts/models.py b/app/podcasts/models.py
new file mode 100644
index 0000000..b574986
--- /dev/null
+++ b/app/podcasts/models.py
@@ -0,0 +1,132 @@
+import os
+import uuid
+from django.db import models
+from django.urls import reverse
+from django.template.defaultfilters import slugify
+from django.conf import settings
+
+# optional external dependencies
+try:
+ from licenses.models import License
+except:
+ License = None
+
+from taggit.managers import TaggableManager
+from mutagen.mp3 import MP3
+
+from media.models import LuxAudio, LuxImage
+
+def get_show_upload_folder(instance, pathname):
+ "A standardized pathname for uploaded files and images."
+ root, ext = os.path.splitext(pathname)
+ return "{0}/podcasts/{1}/{2}{3}".format(
+ settings.PODCASTING_IMG_PATH, instance.slug, slugify(root), ext
+ )
+
+
+def get_episode_upload_folder(instance, pathname):
+ "A standardized pathname for uploaded files and images."
+ root, ext = os.path.splitext(pathname)
+ if instance.shows.count() == 1:
+ return "{0}/podcasts/{1}/episodes/{2}{3}".format(
+ settings.PODCASTING_IMG_PATH, instance.shows.all()[0].slug, slugify(root), ext
+ )
+ else:
+ return "{0}/podcasts/episodes/{1}/{2}{3}".format(
+ settings.PODCASTING_IMG_PATH, instance.slug, slugify(root), ext
+ )
+
+MIME_CHOICES = (
+ ("mp3", "audio/mpeg"),
+ ("mp4", "audio/mp4"),
+ ("ogg", "audio/ogg"),
+)
+
+
+#audio = MP3("example.mp3")
+#print(audio.info.length)
+
+class Podcast(models.Model):
+ uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ title = models.CharField(max_length=255)
+ subtitle = models.CharField(max_length=255, blank=True, null=True)
+ slug = models.SlugField()
+ publisher = models.CharField(max_length=255)
+ publisher_email = models.CharField(max_length=255)
+ description = models.TextField()
+ keywords = models.CharField(max_length=255, blank=True, help_text="A comma-delimited list of words for searches, up to 12;")
+ license = models.TextField(blank=True, null=True)
+ featured_image = models.ForeignKey(LuxImage, on_delete=models.CASCADE, null=True, blank=True, help_text=("square JPEG (.jpg) or PNG (.png) image at a size of 1400x1400 pixels."))
+
+
+ class Meta:
+ verbose_name = "Podcast"
+ verbose_name_plural = "Podcasts"
+ ordering = ("title", "slug")
+
+ def __str__(self):
+ return self.title
+
+ def get_absolute_url(self):
+ return reverse("podcasts:list", kwargs={"slug": self.slug})
+
+ def ttl(self):
+ return "1440"
+
+
+class Episode(models.Model):
+ """
+ An individual podcast episode and it's unique attributes.
+ """
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ title = models.CharField(max_length=255)
+ subtitle = models.CharField(max_length=255, blank=True, null=True)
+ date_created = models.DateTimeField(auto_now_add=True, editable=False)
+ date_updated = models.DateTimeField(auto_now=True, editable=False)
+ pub_date = models.DateTimeField(null=True, blank=True)
+ podcast = models.ForeignKey(Podcast, on_delete=models.PROTECT)
+ enable_comments = models.BooleanField(default=True)
+ slug = models.SlugField()
+ PUB_STATUS = (
+ (0, 'Draft'),
+ (1, 'Published'),
+ )
+ status = models.IntegerField(choices=PUB_STATUS, default=0)
+ description = models.TextField()
+ featured_image = models.ForeignKey(LuxImage, on_delete=models.CASCADE, null=True, blank=True, help_text=("square JPEG (.jpg) or PNG (.png) image at a size of 1400x1400 pixels."))
+ # iTunes specific fields
+ keywords = models.CharField(max_length=255, blank=True, help_text="A comma-delimited list of words for searches, up to 12;")
+
+ class Meta:
+ verbose_name = "Episode"
+ verbose_name_plural = "Episodes"
+ ordering = ("-pub_date", "slug")
+
+ def __str__(self):
+ return self.title
+
+ def get_absolute_url(self):
+ return reverse("podcasting_episode_detail", kwargs={"show_slug": self.shows.all()[0].slug, "slug": self.slug})
+
+ def get_next(self):
+ next = self.__class__.objects.filter(published__gt=self.published)
+ try:
+ return next[0]
+ except IndexError:
+ return False
+
+ def get_prev(self):
+ prev = self.__class__.objects.filter(published__lt=self.published).order_by("-published")
+ try:
+ return prev[0]
+ except IndexError:
+ return False
+
+ def get_explicit_display(self):
+ return "no"
+
+ def seconds_total(self):
+ try:
+ return self.minutes * 60 + self.seconds
+ except:
+ return 0
diff --git a/app/podcasts/templates/podcasts/detail.html b/app/podcasts/templates/podcasts/detail.html
new file mode 100644
index 0000000..361d822
--- /dev/null
+++ b/app/podcasts/templates/podcasts/detail.html
@@ -0,0 +1,108 @@
+{% extends 'base.html' %}
+{% load typogrify_tags %}
+{% load comments %}
+{% block pagetitle %}{{object.title|striptags}} - by Scott Gilbertson{% endblock %}
+{% block metadescription %}{% autoescape on %}{{object.meta_description|striptags|safe}}{% endautoescape %}{% endblock %}
+{%block extrahead%}
+ <link rel="stylesheet" href="/media/src/solarized.css" type="text/css" media="screen"/>
+{%endblock%}
+
+{% block bodyid %}class="detail single"{% endblock %}
+{% block breadcrumbs %}{% include "lib/breadcrumbs.html" with breadcrumbs=breadcrumbs %}{% endblock %}
+{% block primary %}<main role="main">
+ <article class="h-entry hentry entry-content content" itemscope itemType="http://schema.org/BlogPosting">
+ <header id="header" class="post-header">
+ <h1 class="p-name post-title" itemprop="headline">{{object.title|smartypants|safe}}</h1>
+ <h2 class="post-subtitle">{% if object.subtitle %}{{object.subtitle|smartypants|safe}}{%else%}{{object.meta_description|safe|smartypants|widont}}{%endif%}</h2>
+ <div class="post-dateline">
+ {% if object.originally_published_by %}<h4 class="post-source">Originally Published By: <a href="{{object.originally_published_by_url}}" title="View {{object.title}} on {{object.originally_published_by}}">{{object.originally_published_by}}</a></h4>{%endif%}
+ <time class="dt-published published dt-updated post-date" datetime="{{object.pub_date|date:'c'}}" itemprop="datePublished">{{object.pub_date|date:"F"}} <span>{{object.pub_date|date:"j, Y"}}</span></time>
+ <span class="hide" itemprop="author" itemscope itemtype="http://schema.org/Person">by <a class="p-author h-card" href="/about"><span itemprop="name">Scott Gilbertson</span></a></span>
+ </div>
+ </header>
+
+
+ <div id="article" class="e-content entry-content post-article post-body-{% with object.template_name as t %}{%if t == 0 or t == 2 %}single{%endif%}{%if t == 1 or t == 3 %}double{%endif%}{%endwith%}" itemprop="articleBody">
+ {{object.body_html|safe|smartypants|widont}}
+ </div>
+ </article>
+ <div class="entry-footer">
+ <aside class="narrow donate">
+ <h3>Support</h3>
+ <p>Want to help support Lulu and Birdie? You can <a href="/bookshop/">buy the book</a>, or you can donate a few dollars.</p>
+ <div class="donate-btn">
+ <form action="https://www.paypal.com/cgi-bin/webscr" method="post" target="_top">
+ <input type="hidden" name="cmd" value="_s-xclick">
+ <input type="hidden" name="hosted_button_id" value="HYJFZQSBGJ8QQ">
+ <input type="submit" name="submit" title="Donate to luxagraf via PayPal">
+ </form>
+ </div>
+ <div class="donate-btn">
+ <a class="liberapay-btn" href="https://liberapay.com/luxagraf/donate"><span>Donate</span></a>
+ </div>
+ </aside>
+ {% comment %}<aside class="narrow join">
+ <h3>Subscribe</h3>
+ <p>You're reading <code>/src/</code>, a collection of infrequent postings about open source software, linux tools, and other nerdry. If you'd like to join us, drop your email in the form below: </p>
+ <iframe target='_parent' style="border:none !important; background:white; width:100% !important;" title="embedded form for subscribing the the src newsletter" src="{% url 'lttr:subscribe' slug='range' %}"></iframe>
+ <p>Unsubscribing is easy. It's <a href="/src/building-your-own-mailing-list-software">self-hosted</a> and <a href="/privacy" title="My privacy policy">respects your privacy</a>. If you don't want an email, there's also <a href="/src/feed.xml">an RSS feed</a>, and it's all archived <a href="/src/">here</a>.</p>
+ </aside>{% endcomment%}
+ </div>
+ {% with object.get_next_published as next %}
+ {% with object.get_previous_published as prev %}
+ <nav class="page-navigation">
+ <div>{% if prev%}
+ <span class="label">Previous:</span>
+ <a href="{{ prev.get_absolute_url }}" rel="prev" title=" {{prev.title}}">{{prev.title|safe}}</a>
+ </div>{%endif%}{% if next%}
+ <div>
+ <span class="label">Next:</span>
+ <a href="{{ next.get_absolute_url }}" rel="next" title=" {{next.title}}">{{next.title|safe}}</a>
+ </div>{%endif%}
+ </nav>{%endwith%}{%endwith%}
+ {% if object.related.all %}<div class="article-afterward related">
+ <div class="related-bottom">
+ <h6 class="hedtinycaps">You might also enjoy</h6>
+ <div class="archive-grid-quad">{% for object in related %}
+ <div class="archive-grid-card archive-grid-card-simple">
+ <a href="{{object.get_absolute_url}}" title="{{object.title}}">
+ <div class="card-image-tiny">
+ {% if object.featured_image %}
+ {% include "lib/img_archive.html" with image=object.featured_image nolightbox=True %}
+ {%endif%}
+ </div>
+ <h4 class="p-name card-hed" itemprop="headline">{% if object.title %}{{object.title|safe|smartypants|widont}}{% else %}{{object.common_name}}{%endif%}</h4>
+ <p class="p-author author hide" itemprop="author"><span class="byline-author" itemscope itemtype="http://schema.org/Person"><span itemprop="name">Scott Gilbertson</span></span></p>
+ <span class="card-smcaps">
+ {% if object.location %}<span class="p-location h-adr adr card-location" itemprop="contentLocation" itemscope itemtype="http://schema.org/Place">
+ {% if object.location.country_name == "United States" %}{{object.location.state_name}}{%else%}{{object.location.country_name}}{%endif%}
+ </span>{%endif%}
+ {% if object.location and object.model_name.model != 'page' %}&ndash;{%endif%}
+ {% if object.model_name.model != 'page' %}<time class="dt-published published dt-updated" datetime="{{object.pub_date|date:'c'}}"><span>{{object.pub_date|date:" Y"}}</span></time>{%endif%}
+ </span>
+ </a>
+ </div>
+ {% endfor %}</div>
+ </div>
+ </div>{%endif%}
+ </main>
+ {% if object.enable_comments %}
+{% get_comment_count for object as comment_count %}
+{%if comment_count > 0 %}
+<div class="comment-wrapper">
+<p class="comments-header">{{comment_count}} Comment{{ comment_count|pluralize }}</p>
+{% render_comment_list for object %}
+{%endif%}
+<div class="comment-form-wrapper {%if comment_count > 0%}comment-form-border{%endif%}">
+{% render_comment_form for object %}
+</div>
+{% else %}
+<p class="comments--header" style="text-align: center">Sorry, comments have been disabled for this post.</p>
+{%endif%}
+</div>
+</main>
+{% endblock %}
+{% block js %}
+<script src="/media/js/leaflet-master/leaflet-mod.js"></script>
+<script src="/media/js/detail.min.js"></script>
+{%endblock%}
diff --git a/app/podcasts/templates/podcasts/list.html b/app/podcasts/templates/podcasts/list.html
new file mode 100644
index 0000000..89b7ea8
--- /dev/null
+++ b/app/podcasts/templates/podcasts/list.html
@@ -0,0 +1,31 @@
+{% extends 'base.html' %}
+{% load typogrify_tags %}
+{% load pagination_tags %}
+{% load comments %}
+
+{% block pagetitle %}The Lulu and Birdie Podcast{% endblock %}
+{% block metadescription %}The Adventures of Lulu, Birdie, and Henry in podcast form - by Scott Gilbertson.{% endblock %}
+{% block breadcrumbs %}{% include "lib/breadcrumbs.html" with breadcrumbs=breadcrumbs %}{% endblock %}
+{% block primary %}<main role="main" class="archive-wrapper">
+ <div class="archive-intro">
+ <h1 class="archive-hed">{{podcast.title}}</h1>
+ {% if object.subtitle %}<h2 class="list-subhed">{{podcast.subtitle}}</h2>{% endif %}
+ </div>
+
+ <h1 class="archive-sans">Episodes</h1>{% autopaginate object_list 24 %}
+ <ul class="archive-list">{% for object in object_list %}
+ <li class="h-entry hentry archive-list-card archive-list-card-sm" itemscope itemType="http://schema.org/Article">
+ <span class="date dt-published card-smcaps">{{object.pub_date|date:"F Y"}}</span>
+ <a href="{{object.get_absolute_url}}">
+ <h2 class="card-hed">{{object.title|safe|smartypants|widont}}</h2>
+ <p class="p-summary card-lede">{% if object.subtitle %}{{object.subtitle}}{%else%}{{object.meta_description|safe|smartypants|widont}}{%endif%}</p>
+ </a>
+ </li>
+ {%endfor%}</ul>
+ <a href="{% url 'podcasts_show_feed_atom' podcast.slug 'mp3' %}" >MP3</a>
+ <a href="{% url 'podcasts_show_feed_atom' podcast.slug 'mp4' %}" >MP4</a>
+ <a href="{% url 'podcasts_show_feed_rss' podcast.slug 'mp3' %}" >OGG</a>
+
+
+ </main>
+{%endblock%}
diff --git a/app/podcasts/urls.py b/app/podcasts/urls.py
new file mode 100644
index 0000000..622b203
--- /dev/null
+++ b/app/podcasts/urls.py
@@ -0,0 +1,19 @@
+from django.urls import path, re_path
+
+from . import views
+
+app_name = "podcasts"
+
+urlpatterns = [
+ re_path(
+ r'<str:slug>/<int:page>',
+ views.PodcastListView.as_view(),
+ name="list"
+ ),
+ path(
+ r'<str:slug>/',
+ views.PodcastListView.as_view(),
+ {'page':1},
+ name="list"
+ ),
+]
diff --git a/app/podcasts/urls_feeds.py b/app/podcasts/urls_feeds.py
new file mode 100644
index 0000000..07e02ae
--- /dev/null
+++ b/app/podcasts/urls_feeds.py
@@ -0,0 +1,17 @@
+from django.urls import include, re_path
+
+from .feeds import RssShowFeed, AtomShowFeed, AtomRedirectView, RssRedirectView
+from .models import MIME_CHOICES
+
+
+MIMES = "|".join([enclosure[0] for enclosure in MIME_CHOICES])
+
+
+urlpatterns = [
+ # Episode list feed by podcast (RSS 2.0 and iTunes)
+ re_path(r"^(?P<show_slug>[-\w]+)/(?P<mime_type>{mimes})/rss/$".format(mimes=MIMES),
+ RssShowFeed(), name="podcasts_show_feed_rss"),
+ # Episode list feed by show (Atom)
+ re_path(r"^(?P<show_slug>[-\w]+)/(?P<mime_type>{mimes})/atom/$".format(mimes=MIMES),
+ AtomShowFeed(), name="podcasts_show_feed_atom"),
+]
diff --git a/app/podcasts/views.py b/app/podcasts/views.py
new file mode 100644
index 0000000..a8edbfa
--- /dev/null
+++ b/app/podcasts/views.py
@@ -0,0 +1,29 @@
+from django.views.generic import ListView
+from django.views.generic.detail import DetailView
+from django.views.generic.dates import DateDetailView
+from django.urls import reverse
+from django.contrib.syndication.views import Feed
+from django.apps import apps
+from django.shortcuts import get_object_or_404
+from django.conf import settings
+from django.db.models import Q
+
+from utils.views import PaginatedListView
+
+from .models import Episode, Podcast
+
+
+class PodcastListView(PaginatedListView):
+ """
+ Return a list of Episodes in reverse chronological order
+ """
+ model = Podcast
+ template_name = "podcasts/list.html"
+ queryset = Episode.objects.filter(podcast=1).filter(status__exact=1).order_by('-pub_date')
+
+ def get_context_data(self, **kwargs):
+ context = super(PodcastListView, self).get_context_data(**kwargs)
+ context['breadcrumbs'] = ['podcast',]
+ context['podcast'] = Podcast.objects.get(title="The Lulu & Birdie Podcast")
+ return context
+