diff --git a/podcasts/migrations/0027_podcastepisode_link.py b/podcasts/migrations/0027_podcastepisode_link.py
new file mode 100644
index 0000000..25ed11f
--- /dev/null
+++ b/podcasts/migrations/0027_podcastepisode_link.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.1 on 2026-11-09 08:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('podcasts', '0026_podcastepisode_sponsor'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='podcastepisode',
+ name='link',
+ field=models.URLField(blank=True, max_length=1000),
+ ),
+ ]
diff --git a/podcasts/models.py b/podcasts/models.py
index c24859c..781c862 100644
--- a/podcasts/models.py
+++ b/podcasts/models.py
@@ -164,6 +164,8 @@ class PodcastEpisode(models.Model):
title = models.CharField(max_length=500)
description = models.TextField(blank=True)
audio_url = models.URLField(max_length=1000)
+ # The episode's page, from the feed item's (for sharing); '' when it has none.
+ link = models.URLField(max_length=1000, blank=True)
duration_seconds = models.IntegerField(default=0)
pub_date = models.DateTimeField(null=True, blank=True)
episode_number = models.IntegerField(null=True, blank=True)
diff --git a/podcasts/test_player.py b/podcasts/test_player.py
index e7c8214..175bab1 100644
--- a/podcasts/test_player.py
+++ b/podcasts/test_player.py
@@ -89,7 +89,7 @@ class PlayerSyncTests(TestCase):
self.assertEqual(d['seq'], 2) # the report, and device a coming online
self.assertEqual((d['kind'], d['playing'], d['position_ms'], d['speed']), ('podcast', True, 12345, 1.5))
self.assertEqual((d['device_id'], d['device_name']), ('a', 'A'))
- self.assertEqual(set(d['episode']), {'id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
+ self.assertEqual(set(d['episode']), {'id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
'feed__id', 'feed__title', 'feed__artwork_url', 'position_seconds',
'in_queue'})
self.assertEqual((d['episode']['id'], d['episode']['in_queue']), (self.ep.id, True))
diff --git a/podcasts/test_share.py b/podcasts/test_share.py
new file mode 100644
index 0000000..207fc18
--- /dev/null
+++ b/podcasts/test_share.py
@@ -0,0 +1,45 @@
+import feedparser
+from django.contrib.auth.models import User
+from django.test import TestCase
+
+from .models import PodcastEpisode, PodcastFeed
+from .views import FeedFetch, _apply_feed, _entry_link
+
+
+def _feed(link):
+ item_link = f'{link}' if link is not None else ''
+ return feedparser.parse(
+ '99 ZU EINS'
+ '- Folge 724g724' + item_link +
+ '
'
+ ''
+ )
+
+
+class EpisodeLinkTests(TestCase):
+ def setUp(self):
+ self.user = User.objects.create_user(username='alice', password='pw12345678')
+ self.client.force_login(self.user)
+ self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/rss', title='99 ZU EINS')
+
+ def fetch(self, link):
+ _apply_feed(self.feed, FeedFetch(200, _feed(link)))
+ return PodcastEpisode.objects.get(feed=self.feed)
+
+ def test_entry_link_takes_only_web_addresses(self):
+ self.assertEqual(_entry_link({'link': ' https://x.fireside.fm/724 '}), 'https://x.fireside.fm/724')
+ self.assertEqual(_entry_link({'link': 'javascript:alert(1)'}), '')
+ self.assertEqual(_entry_link({}), '')
+
+ def test_a_new_episode_gets_the_items_link(self):
+ self.assertEqual(self.fetch('https://99zueins.fireside.fm/724').link, 'https://99zueins.fireside.fm/724')
+
+ def test_a_refresh_fills_the_link_of_an_episode_that_has_none(self):
+ self.assertEqual(self.fetch(None).link, '')
+ self.assertEqual(self.fetch('https://99zueins.fireside.fm/724').link, 'https://99zueins.fireside.fm/724')
+
+ def test_episode_detail_returns_the_link(self):
+ ep = self.fetch('https://99zueins.fireside.fm/724')
+ r = self.client.get(f'/podcasts/episodes/{ep.id}/')
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.json()['link'], 'https://99zueins.fireside.fm/724')
diff --git a/podcasts/tests.py b/podcasts/tests.py
index 4265b61..4808067 100644
--- a/podcasts/tests.py
+++ b/podcasts/tests.py
@@ -837,7 +837,7 @@ class EpisodeListTests(TestCase):
a2 = dict(eps[self.a2.id])
self.assertTrue(a2.pop('discovered_at')) # when diora found it (the Strom's mark)
self.assertEqual(a2, {
- 'id': self.a2.id, 'title': 'A2', 'audio_url': 'https://example.org/a2.mp3', 'duration_seconds': 0,
+ 'id': self.a2.id, 'title': 'A2', 'audio_url': 'https://example.org/a2.mp3', 'link': '', 'duration_seconds': 0,
'pub_date': '2026-01-02T09:00:00+00:00', 'artwork_url': '',
'feed__id': self.fa.id, 'feed__title': 'Alpha', 'feed__artwork_url': 'https://example.org/a.jpg',
'position_seconds': 100, 'played': False, 'favorite': False, 'in_queue': True, 'in_later': False,
diff --git a/podcasts/views.py b/podcasts/views.py
index b856077..60a5549 100644
--- a/podcasts/views.py
+++ b/podcasts/views.py
@@ -231,7 +231,7 @@ def _later_data(user):
.order_by(F('episode__pub_date').desc(nulls_last=True), '-id')
.values(
'id', 'added_at', 'auto',
- 'episode__id', 'episode__title', 'episode__audio_url',
+ 'episode__id', 'episode__title', 'episode__audio_url', 'episode__link',
'episode__duration_seconds', 'episode__artwork_url',
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
'episode__pub_date',
@@ -383,7 +383,7 @@ def _apply_extras(episode, description, chapters_url, psc_chapters, transcripts,
return bool(changed)
-EXTRA_FIELDS = ['description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at',
+EXTRA_FIELDS = ['link', 'description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at',
'title', 'pub_date', 'episode_number']
@@ -642,6 +642,12 @@ def _move_to_permanent_url(feed_obj, new_url):
feed_obj.rss_url = new_url
+def _entry_link(entry):
+ """The item's , the episode's own page; '' unless it is an http(s) address."""
+ link = (entry.get('link') or '').strip()
+ return link[:1000] if re.match(r'^https?://', link, re.I) else ''
+
+
def _entry_media_url(entry):
"""The episode's file: its audio enclosure, else a video one (the players
play only its sound). '' when there is neither."""
@@ -713,6 +719,7 @@ def _apply_feed(feed_obj, fetched, full=False):
audio_url = _entry_media_url(entry)
if not audio_url:
continue
+ link = _entry_link(entry)
guid = entry.get('id') or entry.get('guid') or audio_url
guid = str(guid)[:1000]
@@ -737,7 +744,7 @@ def _apply_feed(feed_obj, fetched, full=False):
known = existing.get(guid)
if known is not None:
- renamed = {'title': title, 'episode_number': ep_number}
+ renamed = {'title': title, 'episode_number': ep_number, 'link': link}
if pub_date is not None:
renamed['pub_date'] = pub_date
if _apply_extras(known, description, chapters_url, psc_chapters, transcripts, renamed):
@@ -762,6 +769,7 @@ def _apply_feed(feed_obj, fetched, full=False):
'title': title,
'description': description,
'audio_url': audio_url[:1000],
+ 'link': link,
'duration_seconds': duration_seconds,
'pub_date': pub_date,
'episode_number': ep_number,
@@ -1324,7 +1332,7 @@ def feed_episodes(request, pk):
episodes = list(
feed.episodes.values(
- 'id', 'title', 'description', 'audio_url', 'duration_seconds',
+ 'id', 'title', 'description', 'audio_url', 'link', 'duration_seconds',
'pub_date', 'artwork_url', 'episode_number', 'season_number',
)
)
@@ -1434,6 +1442,7 @@ def episode_detail(request, pk):
'has_transcript': _has_transcript(ep),
'feed': {'id': ep.feed.id, 'title': ep.feed.title, 'artwork_url': ep.feed.artwork_url},
'audio_url': ep.audio_url,
+ 'link': ep.link,
'artwork_url': ep.artwork_url,
'duration_seconds': ep.duration_seconds,
'pub_date': ep.pub_date.isoformat() if ep.pub_date else None,
@@ -1645,7 +1654,7 @@ EPISODE_IDS_MAX = 1000
EPISODE_LIST_LIMIT = 100
EPISODE_LIST_MAX = 500
EPISODE_LIST_FIELDS = (
- 'id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
+ 'id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
'feed__id', 'feed__title', 'feed__artwork_url',
'position_seconds', 'played', 'favorite', 'in_queue', 'in_later', 'last_played_at', 'discovered_at',
)
@@ -2010,7 +2019,7 @@ def _queue_data(user):
.select_related('episode__feed')
.values(
'id', 'position',
- 'episode__id', 'episode__title', 'episode__audio_url',
+ 'episode__id', 'episode__title', 'episode__audio_url', 'episode__link',
'episode__duration_seconds', 'episode__artwork_url',
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
'episode__pub_date',
@@ -2506,7 +2515,7 @@ def inbox(request):
def _inbox_fields(with_description=True):
- fields = ['id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
+ fields = ['id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
'feed__id', 'feed__title', 'feed__artwork_url']
if with_description:
fields.insert(2, 'description')