From f9b3a0ce04355015d23c4d6e2dbb3ae9a6af2518 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Fri, 2 Oct 2026 08:26:28 +0200 Subject: [PATCH] =?UTF-8?q?Podcasts:=20Inbox=20und=20Geh=C3=B6rt=20wie=20i?= =?UTF-8?q?n=20AntennaPod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- gpodder/views.py | 4 ++-- podcasts/models.py | 10 ++++++++++ podcasts/tests.py | 33 ++++++++++++++++++++++++++++++++- podcasts/views.py | 25 +++++++++++++++++++------ 4 files changed, 63 insertions(+), 9 deletions(-) diff --git a/gpodder/views.py b/gpodder/views.py index 913d64b..a16eacb 100644 --- a/gpodder/views.py +++ b/gpodder/views.py @@ -8,7 +8,7 @@ from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt 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 .models import GpodderDevice, GpodderSubscriptionChange, GpodderEpisodeAction @@ -235,7 +235,7 @@ def episode_actions(request, username): if ep: pos = int(a['position']) 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( user=request.user, episode=ep, defaults={'position_seconds': pos, 'played': played} diff --git a/podcasts/models.py b/podcasts/models.py index b349b7b..24499b9 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -52,6 +52,16 @@ class PodcastEpisode(models.Model): 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): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='episode_progress') episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='progress') diff --git a/podcasts/tests.py b/podcasts/tests.py index 1e1ba9c..142630d 100644 --- a/podcasts/tests.py +++ b/podcasts/tests.py @@ -60,7 +60,7 @@ class QueuePositionTests(TestCase): def test_after_current_falls_back_to_last_listened(self): 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.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) +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): """New episodes of an auto_queue feed land where the user's setting says.""" diff --git a/podcasts/views.py b/podcasts/views.py index 90b1a80..f38f967 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -19,7 +19,7 @@ from django.views.decorators.http import require_http_methods from . import chapters as chapters_mod 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 feedext.install() @@ -101,9 +101,21 @@ def _enqueue(user, episode_ids, where, current_hint=None): ignore_conflicts=True, ) _write_queue_order(user, queued[:at] + new + queued[at:]) + _leave_inbox(user, 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 # --------------------------------------------------------------------------- @@ -1150,11 +1162,12 @@ def save_progress(request): episode=episode, ) progress.position_seconds = position - - # Auto-mark played at 90% - if episode.duration_seconds and episode.duration_seconds > 0: - if position >= episode.duration_seconds * 0.9: - progress.played = True + # Started: no longer new. + if position > 0: + progress.dismissed = True + # Within the last seconds: played (same rule as the apps). + if counts_as_played(position, episode.duration_seconds): + progress.played = True progress.save() return JsonResponse({'ok': True, 'played': progress.played})