Folgen haben einen Link: PodcastEpisode.link aus dem <link> des Feed-Eintrags (neu und beim Abruf nachgetragen), in episode_detail und allen Folgenlisten

Grundlage fürs Teilen einer Folge; Migration 0027.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-11-09 09:32:13 +01:00
parent 1e36d8600b
commit a937ed8b3e
6 changed files with 83 additions and 9 deletions

View file

@ -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),
),
]

View file

@ -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 <link> (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)

View file

@ -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))

45
podcasts/test_share.py Normal file
View file

@ -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>{link}</link>' if link is not None else ''
return feedparser.parse(
'<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>99 ZU EINS</title>'
'<item><title>Folge 724</title><guid>g724</guid>' + item_link +
'<enclosure url="https://cdn.example/724.mp3" type="audio/mpeg" length="1"/></item>'
'</channel></rss>'
)
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')

View file

@ -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,

View file

@ -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 <link>, 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')