Podcasts: Inbox und Gehört wie in AntennaPod
Some checks failed
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Failing after 28s

- Eingereihte und angefangene Folgen verlassen die Inbox (ausgeblendet,
  nicht gehört), statt dort neben der Warteschlange zu stehen.
- Gehört heißt: höchstens 30 s vor dem Ende, wie in den Apps, statt bei
  90 % (bei 2 h waren das 12 Minuten vor Schluss). Gilt auch für den
  gPodder-Sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-02 08:26:28 +02:00
parent c335ab4094
commit f9b3a0ce04
4 changed files with 63 additions and 9 deletions

View file

@ -8,7 +8,7 @@ from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from podcasts.models import PodcastFeed, PodcastEpisode, EpisodeProgress from podcasts.models import PodcastFeed, PodcastEpisode, EpisodeProgress, counts_as_played
from podcasts.views import _refresh_feed from podcasts.views import _refresh_feed
from .models import GpodderDevice, GpodderSubscriptionChange, GpodderEpisodeAction from .models import GpodderDevice, GpodderSubscriptionChange, GpodderEpisodeAction
@ -235,7 +235,7 @@ def episode_actions(request, username):
if ep: if ep:
pos = int(a['position']) pos = int(a['position'])
dur = ep.duration_seconds or 0 dur = ep.duration_seconds or 0
played = dur > 0 and pos >= dur * 0.9 played = counts_as_played(pos, dur)
EpisodeProgress.objects.update_or_create( EpisodeProgress.objects.update_or_create(
user=request.user, episode=ep, user=request.user, episode=ep,
defaults={'position_seconds': pos, 'played': played} defaults={'position_seconds': pos, 'played': played}

View file

@ -52,6 +52,16 @@ class PodcastEpisode(models.Model):
return self.title return self.title
# An episode counts as played this close to its end (AntennaPod's default),
# the same rule as the apps' "als gehört zählen".
PLAYED_BEFORE_END_SECONDS = 30
def counts_as_played(position_seconds, duration_seconds):
return bool(duration_seconds) and duration_seconds > 0 and \
position_seconds >= max(duration_seconds - PLAYED_BEFORE_END_SECONDS, 1)
class EpisodeProgress(models.Model): class EpisodeProgress(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='episode_progress') user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='episode_progress')
episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='progress') episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='progress')

View file

@ -60,7 +60,7 @@ class QueuePositionTests(TestCase):
def test_after_current_falls_back_to_last_listened(self): def test_after_current_falls_back_to_last_listened(self):
self.add(self.eps[0], self.eps[1], self.eps[2]) self.add(self.eps[0], self.eps[1], self.eps[2])
EpisodeProgress.objects.create(user=self.user, episode=self.eps[2], position_seconds=30) EpisodeProgress.objects.update_or_create(user=self.user, episode=self.eps[2], defaults={'position_seconds': 30})
self.add(self.eps[3], where='after_current') self.add(self.eps[3], where='after_current')
self.assertEqual(self.queue(), [e.id for e in (self.eps[0], self.eps[1], self.eps[2], self.eps[3])]) self.assertEqual(self.queue(), [e.id for e in (self.eps[0], self.eps[1], self.eps[2], self.eps[3])])
@ -104,6 +104,37 @@ class QueuePositionTests(TestCase):
self.assertEqual(bad.status_code, 400) self.assertEqual(bad.status_code, 400)
class InboxRuleTests(TestCase):
"""Queued or started episodes leave the inbox; played means the last 30 s."""
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/f', title='F')
self.eps = [PodcastEpisode.objects.create(feed=feed, guid=f'g{i}', title=f'E{i}', duration_seconds=3600,
audio_url=f'https://example.org/{i}.mp3') for i in range(3)]
def inbox(self):
return {e['id'] for e in self.client.get('/podcasts/inbox/').json()['episodes']}
def post(self, url, body):
return self.client.post(url, json.dumps(body), content_type='application/json')
def test_queued_and_started_leave_the_inbox(self):
self.assertEqual(self.inbox(), {e.id for e in self.eps})
self.post('/podcasts/queue/add/', {'episode_ids': [self.eps[0].id]})
self.post('/podcasts/progress/save/', {'episode_id': self.eps[1].id, 'position_seconds': 12})
self.assertEqual(self.inbox(), {self.eps[2].id})
p = EpisodeProgress.objects.get(user=self.user, episode=self.eps[0])
self.assertFalse(p.played)
def test_played_within_the_last_30_seconds(self):
r = self.post('/podcasts/progress/save/', {'episode_id': self.eps[0].id, 'position_seconds': 3500})
self.assertFalse(r.json()['played']) # 90 % but 100 s left: not yet
r = self.post('/podcasts/progress/save/', {'episode_id': self.eps[0].id, 'position_seconds': 3575})
self.assertTrue(r.json()['played'])
class AutoQueueTests(TestCase): class AutoQueueTests(TestCase):
"""New episodes of an auto_queue feed land where the user's setting says.""" """New episodes of an auto_queue feed land where the user's setting says."""

View file

@ -19,7 +19,7 @@ from django.views.decorators.http import require_http_methods
from . import chapters as chapters_mod from . import chapters as chapters_mod
from . import feedext from . import feedext
from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings, counts_as_played
from .shownotes import extract_timestamps, sanitize_html from .shownotes import extract_timestamps, sanitize_html
feedext.install() feedext.install()
@ -101,9 +101,21 @@ def _enqueue(user, episode_ids, where, current_hint=None):
ignore_conflicts=True, ignore_conflicts=True,
) )
_write_queue_order(user, queued[:at] + new + queued[at:]) _write_queue_order(user, queued[:at] + new + queued[at:])
_leave_inbox(user, new)
return new return new
def _leave_inbox(user, episode_ids):
"""Queued or started episodes are no longer new (AntennaPod's rule): out
of the inbox, without counting as played."""
EpisodeProgress.objects.filter(user=user, episode_id__in=episode_ids, dismissed=False).update(dismissed=True)
have = set(EpisodeProgress.objects.filter(user=user, episode_id__in=episode_ids).values_list('episode_id', flat=True))
EpisodeProgress.objects.bulk_create(
[EpisodeProgress(user=user, episode_id=ep_id, dismissed=True) for ep_id in episode_ids if ep_id not in have],
ignore_conflicts=True,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Duration helper # Duration helper
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -1150,10 +1162,11 @@ def save_progress(request):
episode=episode, episode=episode,
) )
progress.position_seconds = position progress.position_seconds = position
# Started: no longer new.
# Auto-mark played at 90% if position > 0:
if episode.duration_seconds and episode.duration_seconds > 0: progress.dismissed = True
if position >= episode.duration_seconds * 0.9: # Within the last seconds: played (same rule as the apps).
if counts_as_played(position, episode.duration_seconds):
progress.played = True progress.played = True
progress.save() progress.save()