diora-web/podcasts/radio.py
mrwnslz a64c801275
Some checks failed
Build and push Docker image / build (push) Failing after 0s
Test / test (push) Failing after 0s
Test / e2e (push) Failing after 0s
Podcast-Radio: die neueste ungehörte Strom-Folge, ohne Spuren (eigene Radio-gehört-Liste, gemischt nach Podcasts, neue Folgen zuerst); Web-Player und Player-Sync; sw v153
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:17:09 +01:00

88 lines
3.7 KiB
Python

"""The Podcast-Radio (docs/podcasts.md): the Strom played like a station, leaving no trace.
Which episode comes next: the newest (publication date) Strom episode that is neither played,
nor listened into, nor done for the radio (PodcastRadioHeard) — so one that comes out while
another plays is next, and otherwise the radio goes back in time. Several of one podcast in a
row are mixed with the others waiting: the next is the newest of another podcast than the one
before, if there is one.
GET /podcasts/radio/?after=<episode id>&skip=<ids>&limit=5 → {episodes: [...]} (episode-list rows)
skip: ones the device is done with whose /heard/ may not have arrived yet
POST /podcasts/radio/heard/ {"episode_id", "after"?} → {ok, episodes} (the next ones)
"""
import json
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from .models import PodcastEpisode, PodcastRadioHeard
from .views import _episode_list_data
LIMIT = 5
MAX_LIMIT = 20
MAX_SKIP = 50
# How far down the newest the mixing looks for another podcast.
WINDOW = 200
def next_episodes(user, after=None, limit=LIMIT, skip=()):
"""The radio's next episodes, in the order to play; `after`: the episode playing (or
played last) — not itself again, and not its podcast next if another is waiting; `skip`:
not these either."""
candidates, _ = _episode_list_data(user, ['stream', 'untouched'], sort='new', limit=WINDOW, radio=True)
skip = set(skip) | ({after} if after else set())
candidates = [e for e in candidates if e['id'] not in skip]
last_feed = None
if after:
last_feed = PodcastEpisode.objects.filter(pk=after, feed__user=user).values_list('feed_id', flat=True).first()
out = []
while candidates and len(out) < limit:
pick = next((e for e in candidates if e['feed__id'] != last_feed), candidates[0])
candidates.remove(pick)
out.append(pick)
last_feed = pick['feed__id']
return out
def _after(value):
if value in (None, ''):
return None
after = int(value)
if after <= 0:
raise ValueError
return after
@csrf_exempt
@require_http_methods(['GET'])
def radio(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
after = _after(request.GET.get('after'))
limit = min(max(int(request.GET.get('limit') or LIMIT), 1), MAX_LIMIT)
skip = [int(i) for i in request.GET.get('skip', '').split(',') if i.strip()][:MAX_SKIP]
except ValueError:
return JsonResponse({'error': 'after, skip and limit must be numbers'}, status=400)
return JsonResponse({'episodes': next_episodes(request.user, after, limit, skip)})
@csrf_exempt
@require_http_methods(['POST'])
def radio_heard(request):
"""The radio is done with an episode (ran to its end, or skipped): it never plays it again.
Nothing else about the episode changes. Answers with the next ones (after it)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
body = json.loads(request.body)
episode_id = int(body['episode_id'])
after = _after(body.get('after'))
except (ValueError, KeyError, TypeError):
return JsonResponse({'error': 'episode_id required'}, status=400)
episode = PodcastEpisode.objects.filter(pk=episode_id, feed__user=request.user).first()
if episode is None:
return JsonResponse({'error': 'unknown episode'}, status=404)
PodcastRadioHeard.objects.get_or_create(user=request.user, episode=episode)
return JsonResponse({'ok': True, 'episodes': next_episodes(request.user, after or episode_id)})