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:
parent
1e36d8600b
commit
a937ed8b3e
6 changed files with 83 additions and 9 deletions
18
podcasts/migrations/0027_podcastepisode_link.py
Normal file
18
podcasts/migrations/0027_podcastepisode_link.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
45
podcasts/test_share.py
Normal 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')
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue