From a937ed8b3e597ca484d0472e1c55c4f9ff952850 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Mon, 9 Nov 2026 09:32:13 +0100 Subject: [PATCH] Folgen haben einen Link: PodcastEpisode.link aus dem des Feed-Eintrags (neu und beim Abruf nachgetragen), in episode_detail und allen Folgenlisten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grundlage fürs Teilen einer Folge; Migration 0027. Co-Authored-By: Claude Sonnet 5.5 --- .../migrations/0027_podcastepisode_link.py | 18 ++++++++ podcasts/models.py | 2 + podcasts/test_player.py | 2 +- podcasts/test_share.py | 45 +++++++++++++++++++ podcasts/tests.py | 2 +- podcasts/views.py | 23 +++++++--- 6 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 podcasts/migrations/0027_podcastepisode_link.py create mode 100644 podcasts/test_share.py 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')