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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-25 13:17:09 +01:00
parent 21f5e33e57
commit a64c801275
11 changed files with 497 additions and 25 deletions

View file

@ -92,7 +92,7 @@ One controlled step out (Apple's episode search and fyyd; no key):
Show search stays on Apple (best coverage).
## Podcast-Radio (planned 2026-10-25)
## Podcast-Radio (since 2026-10-25)
Listening without committing: one button plays the Strom like a station.
@ -100,6 +100,11 @@ Listening without committing: one button plays the Strom like a station.
neither played, nor started (normally), nor done for the radio. So an episode that comes out
while one plays is next (the one playing isn't interrupted); otherwise it goes back in time,
without limit. Auf Abruf podcasts and audiobooks aren't in the Strom, so not in the radio.
- **Mixed**: several episodes 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 (within the
newest 200).
- What comes next is asked anew at every end and skip (so the newest really comes next); the
list fetched before is for "Danach: …" in the player and for going on offline.
- **Leaves no trace**: no progress (no Verlauf, no "Angefangen", no played mark, not counted in
`heard_365`), the Wiedergabeliste and Merkliste unchanged, nothing downloaded (it streams; a
downloaded file plays if there is one), the Strom's marks unchanged.
@ -108,14 +113,23 @@ Listening without committing: one button plays the Strom like a station.
- **Committing**: in the player while the radio plays, + (Merkliste) and "Normal weiterhören"
(first in the Wiedergabeliste at the current place; from then on it plays as usual, with
progress). Playing anything normally switches the radio off (as an audiobook does).
- **Server**: `PodcastRadioHeard(user, episode, at)`, kept apart from progress, pruned after 60
days. `GET /podcasts/radio/?limit=5` → the next episodes by the rule (`{episodes: [...]}`, the
rows of the episode lists); `POST /podcasts/radio/heard/ {"episode_id"}`. Shared by all devices.
- **Clients** (both at once): Android (`PodcastRadio.kt` beside `BookPlay.kt`: the playlist is
[current, next]; RadioService's `save()` / `markPlayed()` / `followQueue()` leave radio items
alone; reloads before each transition and every 10 minutes while playing — the server fetches
feeds every 15 minutes) and the web player (`podcasts-web.js`, so the browser and the desktop
app). Button "📻 Radio" at the top of the Strom; the mini player's ▶ goes on with the radio.
- **Server** (`podcasts/radio.py`): `PodcastRadioHeard(user, episode, at)`, kept apart from
progress and kept for good (pruned, an old episode would come back when the radio goes back
that far). `GET /podcasts/radio/?after=<playing>&skip=<ids>&limit=5` → the next episodes by
the rule (`{episodes: [...]}`, the rows of the episode lists; `after` isn't among them and its
podcast not first; `skip`: ones the device is done with whose /heard/ may still be on its
way); `POST /podcasts/radio/heard/ {"episode_id"}`. Shared by all devices. Filter `untouched`
(episode lists): neither played nor listened into.
- **Player sync**: a report carries `podcast_radio: true` while the radio plays
(`PlayerState.podcast_radio`); taking it over ("Hier abspielen", a transfer) or adopting it goes
on with the radio on that device.
- **Clients**: the web player (`podcasts-web.js`: an entry with `radio: true`, the radio's own
`podradio` in localStorage — next ones, its place, what it was done with lately; host
`podcastRadio()`, `podcastRadioCommit()`, `podcastRadioLater()`; `playerState().radio =
{next}`) and Android (`PodcastRadio.kt` beside `BookPlay.kt`; RadioService's `save()` /
`markPlayed()` / `followQueue()` leave radio items alone). UI (native-podcast.js): 📻 at the top
of the Strom, "Podcast-Radio · <podcast>" in the mini player and the player, there a box with
"Danach: …", + Merkliste and "Normal weiterhören"; ⏭ only skips.
- Later perhaps: a radio of one folder, a longest length.
## Moving over

View file

@ -0,0 +1,33 @@
# Generated by Django 6.1 on 2026-10-25 12:04
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0024_feed_favorite'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='playerstate',
name='podcast_radio',
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name='PodcastRadioHeard',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('at', models.DateTimeField(auto_now_add=True)),
('episode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='radio_heard_by', to='podcasts.podcastepisode')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='podcast_radio_heard', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'episode')},
},
),
]

View file

@ -290,6 +290,21 @@ class PodcastLater(models.Model):
return f'{self.user} Merkliste: {self.episode}'
class PodcastRadioHeard(models.Model):
"""An episode the Podcast-Radio is done with (docs/podcasts.md): it ran to its end there or
was skipped. Apart from the progress on purpose: it counts as heard nowhere else (no
history, no played mark, not in heard_365). Kept, so the radio never comes back to it."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='podcast_radio_heard')
episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='radio_heard_by')
at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'episode')
def __str__(self):
return f'{self.user} Radio: {self.episode}'
class PodcastSettings(models.Model):
"""A user's podcast preferences. Where episodes go in the queue: when added
by hand (also when played from outside the queue), and when a feed that
@ -401,6 +416,9 @@ class PlayerState(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='player_state')
kind = models.CharField(max_length=10, choices=KINDS, default='', blank=True)
episode = models.ForeignKey(PodcastEpisode, null=True, blank=True, on_delete=models.SET_NULL, related_name='+')
# The episode plays in the Podcast-Radio (docs/podcasts.md): shown so elsewhere, and a
# transfer goes on with the radio there rather than playing it the usual way.
podcast_radio = models.BooleanField(default=False)
station_url = models.CharField(max_length=1000, blank=True)
station_name = models.CharField(max_length=300, blank=True)
station_id = models.IntegerField(null=True, blank=True)

88
podcasts/radio.py Normal file
View file

@ -0,0 +1,88 @@
"""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)})

107
podcasts/test_radio.py Normal file
View file

@ -0,0 +1,107 @@
"""The Podcast-Radio (docs/podcasts.md, podcasts/radio.py)."""
import json
from django.contrib.auth.models import User
from django.core.cache import cache
from django.test import TestCase
from .models import EpisodeProgress, PlayerState, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastRadioHeard
from .tests import _at, _progress
class RadioTests(TestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
self.fa = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='Alpha')
self.fb = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='Beta')
self.fc = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/c', title='Abruf',
new_episodes=PodcastFeed.NEW_IGNORE)
self.book = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/h', title='Buch',
kind=PodcastFeed.AUDIOBOOK)
def ep(self, feed, guid, day):
return PodcastEpisode.objects.create(feed=feed, guid=guid, title=guid, pub_date=_at(1, day),
audio_url=f'https://example.org/{guid}.mp3')
def radio(self, **params):
r = self.client.get('/podcasts/radio/', params)
self.assertEqual(r.status_code, 200)
return [e['title'] for e in r.json()['episodes']]
def heard(self, episode, **body):
r = self.client.post('/podcasts/radio/heard/', json.dumps({'episode_id': episode.id, **body}),
content_type='application/json')
self.assertEqual(r.status_code, 200)
return [e['title'] for e in r.json()['episodes']]
def test_newest_untouched_strom_episodes_first(self):
self.ep(self.fa, 'a1', 1)
self.ep(self.fb, 'b2', 2)
played = self.ep(self.fa, 'a3', 3)
started = self.ep(self.fb, 'b4', 4)
self.ep(self.fc, 'c5', 5) # Auf Abruf: not in the Strom
self.ep(self.book, 'h6', 6) # an audiobook's chapter
_progress(self.user, played, _at(2, 1), played=True)
_progress(self.user, started, _at(2, 1), position_seconds=30)
self.assertEqual(self.radio(), ['b2', 'a1'])
def test_one_podcast_is_mixed_with_the_others(self):
for d in (1, 2, 3):
self.ep(self.fb, f'b{d}', d)
for d in (7, 8, 9):
self.ep(self.fa, f'a{d}', d)
self.assertEqual(self.radio(limit=6), ['a9', 'b3', 'a8', 'b2', 'a7', 'b1'])
# After an episode of Alpha: Beta first, and that episode not again.
a9 = PodcastEpisode.objects.get(guid='a9')
self.assertEqual(self.radio(after=a9.id, limit=3), ['b3', 'a8', 'b2'])
# One the device is done with (its /heard/ still on the way) doesn't count for the mixing.
a8 = PodcastEpisode.objects.get(guid='a8')
self.assertEqual(self.radio(after=a9.id, skip=str(a8.id), limit=3), ['b3', 'a7', 'b2'])
# Only one podcast left: its episodes in a row.
PodcastEpisode.objects.filter(feed=self.fb).delete()
self.assertEqual(self.radio(after=a9.id), ['a8', 'a7'])
def test_heard_leaves_no_trace_but_the_radio_moves_on(self):
a1 = self.ep(self.fa, 'a1', 1)
b2 = self.ep(self.fb, 'b2', 2)
self.assertEqual(self.heard(b2), ['a1'])
self.assertEqual(self.radio(), ['a1'])
self.assertFalse(EpisodeProgress.objects.exists())
self.assertFalse(PodcastQueue.objects.exists())
# The Strom and the history don't know about it.
r = self.client.get('/podcasts/episodes/', {'filter': 'history'}).json()
self.assertEqual(r['total'], 0)
r = self.client.get('/podcasts/episodes/', {'filter': 'stream,unplayed'}).json()
self.assertEqual(r['total'], 2)
self.heard(b2) # twice is fine
self.assertEqual(PodcastRadioHeard.objects.count(), 1)
self.heard(a1)
self.assertEqual(self.radio(), [])
def test_others_episodes_and_bad_requests(self):
bob = User.objects.create_user(username='bob', password='pw12345678')
fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/a', title='Alpha')
theirs = self.ep(fbob, 'x', 1)
r = self.client.post('/podcasts/radio/heard/', json.dumps({'episode_id': theirs.id}), content_type='application/json')
self.assertEqual(r.status_code, 404)
r = self.client.post('/podcasts/radio/heard/', '{}', content_type='application/json')
self.assertEqual(r.status_code, 400)
self.assertEqual(self.client.get('/podcasts/radio/', {'after': 'x'}).status_code, 400)
self.assertEqual(self.radio(), [])
self.client.logout()
self.assertEqual(self.client.get('/podcasts/radio/').status_code, 401)
def test_player_state_says_radio(self):
a1 = self.ep(self.fa, 'a1', 1)
body = {'device_id': 'phone', 'device_name': 'Handy', 'kind': 'podcast', 'episode_id': a1.id,
'playing': True, 'position_ms': 1000, 'start': True, 'podcast_radio': True}
r = self.client.post('/podcasts/player/report/', json.dumps(body), content_type='application/json')
self.assertEqual(r.status_code, 200)
self.assertTrue(r.json()['podcast_radio'])
self.assertTrue(PlayerState.objects.get(user=self.user).podcast_radio)
body['podcast_radio'] = False
r = self.client.post('/podcasts/player/report/', json.dumps(body), content_type='application/json')
self.assertFalse(r.json()['podcast_radio'])
self.assertFalse(EpisodeProgress.objects.exists())

View file

@ -1,5 +1,5 @@
from django.urls import path
from . import audiobooks, discover, folders, views
from . import audiobooks, discover, folders, radio, views
urlpatterns = [
path('search/', views.podcast_search, name='podcast_search'),
@ -39,6 +39,8 @@ urlpatterns = [
path('later/remove/', views.later_remove, name='podcast_later_remove'),
path('lists/', views.lists_switch, name='podcast_lists_switch'),
path('stream/seen/', views.stream_seen, name='podcast_stream_seen'),
path('radio/', radio.radio, name='podcast_radio'),
path('radio/heard/', radio.radio_heard, name='podcast_radio_heard'),
# Stöbern (docs/podcasts.md, podcasts/discover.py)
path('discover/search/', discover.search_view, name='podcast_discover_search'),
path('discover/similar/<int:pk>/', discover.similar_view, name='podcast_discover_similar'),

View file

@ -33,7 +33,7 @@ from . import youtube as youtube_mod
from . import folders as folders_mod
from .models import (
EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastLater,
PodcastQueue, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played,
PodcastQueue, PodcastRadioHeard, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played,
)
from .shownotes import extract_timestamps, sanitize_html
@ -1602,6 +1602,8 @@ EPISODE_FILTERS = {
'all': Q(),
'unplayed': Q(played=False),
'in_progress': Q(position_seconds__gt=0, played=False),
# Neither played nor listened into (the Podcast-Radio's, docs/podcasts.md).
'untouched': Q(position_seconds=0, played=False),
'played': Q(played=True),
'favorites': Q(favorite=True),
'history': Q(played=True) | Q(position_seconds__gt=0),
@ -1638,7 +1640,7 @@ def _int_param(request, name, default):
def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, limit=EPISODE_LIST_LIMIT,
sort=None, ids=None, notes=False, folder=None):
sort=None, ids=None, notes=False, folder=None, radio=False):
"""(episodes, total) for the episode list. Two queries, whatever the
page size: the count and the page. The user's progress row comes in by
a join, queue membership by a subquery. No shownotes (up to 50 KB each).
@ -1648,7 +1650,7 @@ def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, li
the newest first, in the history the last listened first. ids: only these
episodes (the ones downloaded on a device). notes: `q` also matches the
shownotes; title matches come first. folder: only the feeds in this folder ('': the feeds
in none)."""
in none). radio: not the ones the Podcast-Radio is done with (PodcastRadioHeard)."""
names = filter_name.split(',') if isinstance(filter_name, str) else list(filter_name)
names = [n for n in names if n and n != 'all']
qs = (
@ -1673,6 +1675,8 @@ def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, li
if (folder in (fs or []) if folder else not fs)])
if ids is not None:
qs = qs.filter(id__in=ids)
if radio:
qs = qs.exclude(Exists(PodcastRadioHeard.objects.filter(user=user, episode=OuterRef('pk'))))
if feed_id is None and ids is None: # audiobooks are listened to in the library
qs = qs.exclude(feed__kind=PodcastFeed.AUDIOBOOK)
order = EPISODE_SORTS[sort or ('played' if 'history' in names else 'new')]
@ -2695,6 +2699,7 @@ def _player_json(user, p, now=None):
'seq': p.seq,
'kind': p.kind,
'episode': episode,
'podcast_radio': bool(episode) and p.podcast_radio,
'station': station,
'position_ms': p.position_ms,
'playing': p.playing,
@ -2798,6 +2803,8 @@ def player_report(request):
p,
kind=kind,
episode_id=episode_id,
# The episode plays in the Podcast-Radio (docs/podcasts.md).
podcast_radio=kind == PlayerState.PODCAST and body.get('podcast_radio') is True,
station_url=station[0], station_name=station[1], station_id=station[2],
position_ms=max(int(position), 0) if kind == PlayerState.PODCAST else 0,
playing=playing and bool(kind),

View file

@ -673,8 +673,17 @@
store('book', b);
}
// The Podcast-Radio (docs/podcasts.md): the Strom played like a station. An episode it plays
// is an entry with radio: true; the radio keeps the next ones (from /podcasts/radio/), the place
// of the one it plays and what it was done with lately — here only: nothing else learns of it
// (no progress, no lists). Playing anything else switches it off.
let podRadio = load('podradio', null); // {next: [entry, …], pos: {id, seconds}, done: [id, …]}
if (!podRadio || !Array.isArray(podRadio.next)) podRadio = null;
let radioFetchedAt = 0;
const curId = () => (cur ? cur.episode__id : null);
const curFeed = () => (cur ? cur.episode__feed__id : null);
const onRadio = () => !!(cur && cur.radio && podRadio);
function durMs() {
if (loaded && isFinite(audio.duration) && audio.duration > 0) return audio.duration * 1000;
@ -689,6 +698,10 @@
// Where this episode picks up: its place (the queue knows it best), past the podcast's intro.
function resumeMs() {
if (onRadio()) {
const at = podRadio.pos && podRadio.pos.id === cur.episode__id ? podRadio.pos.seconds * 1000 : 0;
return Math.max(at > 5000 ? at : 0, introMs(curFeed()));
}
const q = cur ? entry(cur.episode__id) : null;
const resume = ((q || cur || {}).position_seconds || 0) * 1000;
return Math.max(resume > 5000 ? resume : 0, introMs(curFeed()));
@ -701,6 +714,7 @@
}
function nextEntry() {
if (onRadio()) return podRadio.next[0] || null; // the radio goes on with what the server said
const inBook = bookIndex(curId());
if (inBook >= 0) return book.chapters[inBook + 1] || null; // a book goes on with its next chapter
const q = queueOf(state);
@ -711,12 +725,18 @@
function saveNow(id, send) {
lastSaved = Date.now();
if (onRadio() && id === curId()) { // the radio's place, for the radio only
podRadio.pos = {id, seconds: Math.floor(posMs() / 1000)};
store('podradio', podRadio);
return;
}
saveProgress(id, Math.floor(posMs() / 1000));
if (send) flush();
}
// An episode is done: played, out of the queue (setting). True when the sleep timer says stop.
function finished(id) {
if (onRadio() && id === curId()) { radioDone(id); return sleep.episodeFinished(); }
const at = bookIndex(id);
if (at >= 0) { book.chapters[at].played = true; store('book', book); }
markPlayedAll([id], true, prefs.remove_played);
@ -830,13 +850,25 @@
const prev = curId();
let stop = false;
if (book && bookIndex(e.episode__id) < 0) setBook(null); // something else plays: the book is left
const fromRadio = onRadio();
if (prev != null && prev !== outroDone && (auto || e.episode__id !== prev)) {
if (auto || almostEnded()) stop = finished(prev);
// The radio's episode: ended or skipped, the radio is done with it; else it keeps its place.
if (fromRadio) {
if (auto || skipped || almostEnded()) radioDone(prev);
if (auto || almostEnded()) stop = sleep.episodeFinished();
else if (!skipped) saveNow(prev, false);
} else if (auto || almostEnded()) stop = finished(prev);
else if (skipped && !keepSkipped() && bookIndex(prev) < 0) { markPlayedAll([prev], true, prefs.remove_played); flush(); }
else saveNow(prev, true);
}
unload();
if (podRadio && !e.radio) setPodRadio(null); // something else plays: the radio is off
cur = Object.assign({}, e);
if (e.radio) {
podRadio.next = podRadio.next.filter(x => x.episode__id !== e.episode__id);
store('podradio', podRadio);
radioFetch();
}
startAt = null;
pausedAt = 0;
buffering = false;
@ -917,6 +949,7 @@
// To the next episode: the one left is skipped (see goTo).
function next() {
if (onRadio()) { radioAdvance(curId(), false); return; }
const n = nextEntry();
if (n) goTo(n, false, wantPlay, true);
}
@ -942,7 +975,7 @@
}
function playEpisode(id) {
if (curId() === id) { resume(); return; }
if (curId() === id) { radioCommit(); resume(); return; } // played by hand: no longer the radio's
const e = entry(id);
if (e) goTo(e, false, true);
}
@ -965,6 +998,7 @@
const dur = durMs();
if (outro <= 0 || dur <= 0 || dur - posMs() > outro) return;
outroDone = id;
if (onRadio()) { radioAdvance(id, true); return; }
const n = nextEntry(); // before finished() takes this one out of the queue
if (finished(id) || !n) pause();
else goTo(n, false, true);
@ -973,6 +1007,7 @@
function onEnded() {
const id = curId();
if (id == null) return;
if (onRadio()) { if (id !== outroDone) radioAdvance(id, true); return; }
const n = nextEntry();
if (n) { goTo(n, true, true); return; }
if (id !== outroDone) finished(id);
@ -1058,6 +1093,7 @@
if (isFinite(audio.duration) && audio.duration > 0) lastDur = audio.duration * 1000;
if (playing && Date.now() - lastSaved > 10000) saveNow(id, true);
if (playing) skipOutro(id);
if (playing && onRadio() && Date.now() - radioFetchedAt > (durMs() - posMs() < 90000 ? 120000 : 600000)) radioFetch();
}
// The sleep timer counts while any of them plays (radio and podcasts share the player; Ambient).
sleep.tick(playing || radioPlaying() || ambientPlaying());
@ -1067,6 +1103,121 @@
if (playing || sleep.leftMs > 0 || otherActive(sync.state)) report();
}, 1000);
// ---- the Podcast-Radio (docs/podcasts.md) -----------------------------------------------------
function setPodRadio(r) {
podRadio = r;
store('podradio', r);
}
const radioEntry = ep => Object.assign({}, asQueueEntry(ep), {radio: true, position_seconds: 0});
// The next ones from the server, after the episode playing (not it, not its podcast next).
function radioFetch() {
radioFetchedAt = Date.now();
const after = onRadio() ? curId() : null;
const q = new URLSearchParams({limit: '5'});
if (after) q.set('after', String(after));
// Done here lately: maybe not on the server yet (the mixing must not count them).
if (podRadio && podRadio.done && podRadio.done.length) q.set('skip', podRadio.done.slice(0, 20).join(','));
return fetch('/podcasts/radio/?' + q, {credentials: 'same-origin', cache: 'no-store'})
.then(r => (r.ok ? r.json() : null))
.catch(() => null)
.then(d => {
if (!d || !podRadio) return null; // couldn't ask (offline): the caller keeps what it knew
const done = podRadio.done || [];
podRadio.next = (d.episodes || []).filter(e => e.id !== curId() && done.indexOf(e.id) < 0).map(radioEntry);
store('podradio', podRadio);
report();
return podRadio.next;
});
}
// Ended or skipped: the radio is done with it (kept here too, until the server has it).
function radioDone(id) {
if (!podRadio) return;
podRadio.done = [id].concat((podRadio.done || []).filter(x => x !== id)).slice(0, 50);
podRadio.next = podRadio.next.filter(x => x.episode__id !== id);
if (podRadio.pos && podRadio.pos.id === id) podRadio.pos = null;
store('podradio', podRadio);
queueOp('/podcasts/radio/heard/', {episode_id: id}, 'radio-heard:' + id);
flush();
}
// The radio moves on from `id` (ended: auto, or skipped): done with it, and what comes next is
// asked anew, so an episode that came out a minute ago goes first; offline, what it knew.
function radioAdvance(id, auto) {
const known = podRadio.next.filter(x => x.episode__id !== id)[0] || null;
const play = auto || wantPlay;
radioDone(id);
outroDone = id; // goTo doesn't count it again
if (auto && sleep.episodeFinished()) { pause(); return; }
radioFetch().then(list => {
if (curId() !== id || !onRadio()) return; // something else plays meanwhile
const n = list ? list[0] : known;
if (n) { goTo(n, false, play); return; }
pause();
toast('Podcast-Radio: gerade nichts Ungehörtes im Strom');
});
}
// 📻: the radio plays (goes on with its episode, or starts with the newest).
function radioStart() {
// Its episode, unless the radio is done with it (it ran out of episodes there).
if (onRadio() && (podRadio.done || []).indexOf(curId()) < 0) { resume(); return; }
setPodRadio({next: [], pos: null, done: (podRadio && podRadio.done) || []});
radioFetch().then(list => {
if (!podRadio) return;
if (!list) { setPodRadio(null); toast('Keine Verbindung zum Server'); return; }
if (!list.length) { setPodRadio(null); toast('Im Strom ist gerade nichts Ungehörtes'); return; }
goTo(list[0], false, true);
});
}
// "Normal weiterhören": the episode leaves the radio, first in the Wiedergabeliste at its place;
// from now on it plays as usual (with progress).
function radioCommit() {
if (!onRadio()) return;
const id = curId();
const e = Object.assign({}, cur, {position_seconds: Math.floor(posMs() / 1000)});
delete e.radio;
setPodRadio(null);
cur = e;
storePlayer();
enqueue([e], 'front', null);
saveProgress(id, e.position_seconds);
flush();
setSession();
report();
syncSoon();
}
// The radio's episode from another device (a transfer, or adopting it): the radio here.
function loadRadioAt(ep, at) {
at = Math.max(0, Math.round(at || 0));
if (!podRadio) setPodRadio({next: [], pos: null, done: []});
if (curId() !== ep.id || !cur.radio) {
if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
unload();
cur = radioEntry(ep);
wantPlay = false;
buffering = false;
error = null;
lastDur = 0;
storePlayer();
} else if (loaded && audio.readyState > 0) {
try { audio.currentTime = at / 1000; } catch (e) {}
}
startAt = loaded ? null : at;
mustReach = null;
pausedAt = 0;
lastPos = at;
podRadio.pos = {id: ep.id, seconds: Math.floor(at / 1000)};
store('podradio', podRadio);
radioFetch();
report();
}
// ---- skip silence ----------------------------------------------------------------------
// A browser can't listen to most podcast servers' audio (no CORS), so the server works out
// once per episode which parts of its pauses go (podcasts/silence.py, the same rules as the
@ -1448,7 +1599,7 @@
}
if (cur) {
return {kind: 'podcast', episode_id: cur.episode__id, position_ms: Math.max(0, Math.round(posMs())),
playing: wantPlay, speed: speedFor(curFeed()), sleep: zz};
playing: wantPlay, speed: speedFor(curFeed()), sleep: zz, podcast_radio: onRadio()};
}
return {kind: '', position_ms: 0, playing: false};
}
@ -1614,6 +1765,7 @@
} else {
if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
unload();
if (podRadio) setPodRadio(null);
cur = Object.assign({}, entry(ep.id) || asQueueEntry(ep));
startAt = at;
wantPlay = false;
@ -1635,7 +1787,7 @@
// (another device played on): never while it plays here, never over a place
// of ours that isn't saved yet, not for a few seconds' difference.
function movePausedTo(id, at) {
if (curId() !== id || wantPlay) return false;
if (curId() !== id || wantPlay || onRadio()) return false;
if (outbox.some(op => op.key === 'progress:' + id)) return false;
if (Math.abs(at - posMs()) <= 5000) return false;
loadAt({id}, at);
@ -1657,6 +1809,9 @@
st = st || sync.state;
if (st && st.kind === 'radio' && st.station) {
playStation(st.station.url, st.station.name || '', st.station.id || null);
} else if (st && st.kind === 'podcast' && st.episode && st.podcast_radio) {
loadRadioAt(st.episode, placeOf(st));
resume();
} else if (st && st.kind === 'podcast' && st.episode) {
loadAt(st.episode, placeOf(st));
resume();
@ -1668,7 +1823,9 @@
// The active device is gone and we play nothing: its item becomes ours, paused.
function adopt(st) {
if (wantPlay || radioPlaying()) return;
if (st.kind === 'podcast' && st.episode) {
if (st.kind === 'podcast' && st.episode && st.podcast_radio) {
loadRadioAt(st.episode, st.position_ms);
} else if (st.kind === 'podcast' && st.episode) {
if (curId() === st.episode.id) movePausedTo(st.episode.id, st.position_ms);
else loadAt(st.episode, st.position_ms);
} else if (st.kind === 'radio' && st.station) {
@ -1792,6 +1949,10 @@
o.dur = Math.round(durMs());
const at = bookIndex(cur.episode__id);
if (at >= 0) o.book = {feed_id: book.feed_id, index: at, chapters: book.chapters.length};
if (onRadio()) {
const n = podRadio.next[0];
o.radio = {next: n ? {title: n.episode__title || '', feed: n.episode__feed__title || ''} : null};
}
o.playing = wantPlay;
o.buffering = buffering && wantPlay;
if (error) o.error = error;
@ -1819,7 +1980,7 @@
try {
ms.metadata = new MediaMetadata({
title: plain(cur.episode__title),
artist: plain(cur.episode__feed__title),
artist: (onRadio() ? 'Podcast-Radio · ' : '') + plain(cur.episode__feed__title),
album: plain(cur.episode__feed__title),
artwork: art ? [{src: art}] : [],
});
@ -1939,6 +2100,11 @@
flush();
},
podcastNext: () => next(),
// The Podcast-Radio (docs/podcasts.md): play it, or take its episode out of it.
podcastRadio: () => radioStart(),
podcastRadioCommit: () => radioCommit(),
// + while the radio plays it: onto the Merkliste (the radio plays on).
podcastRadioLater: sent(() => { if (onRadio()) { const e = Object.assign({}, cur); delete e.radio; laterAdd([e]); } }),
podcastToggle: () => (wantPlay ? pause() : resume()),
podcastPause: () => { if (wantPlay) pause(); },
podcastSeek: ms => seek(Number(ms)),
@ -2076,12 +2242,15 @@
const was = load('player', null);
if (was && was.entry && was.entry.episode__id != null) {
const at = bookIndex(was.entry.episode__id);
cur = entry(was.entry.episode__id) || (at >= 0 ? book.chapters[at] : null) || was.entry;
cur = was.entry.radio && podRadio ? was.entry
: entry(was.entry.episode__id) || (at >= 0 ? book.chapters[at] : null) || was.entry;
if (cur.radio && !podRadio) delete cur.radio;
lastPos = resumeMs();
}
// Keep the place when the page goes away; send what's pending when the connection is back.
const leaving = () => {
if (onRadio()) { if (loaded && audio.readyState > 0) saveNow(cur.episode__id, false); return; }
if (cur && loaded && audio.readyState > 0) {
saveProgress(cur.episode__id, Math.floor(posMs() / 1000));
if (navigator.sendBeacon) {

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v152';
const CACHE = 'diora-v153';
const SHELL = [
'/static/css/app.css',
'/static/js/app/01-core.js',

View file

@ -1,4 +1,4 @@
// Copied from diora-android 6cfec49 2026-10-25: web-native/static/native-podcast.js
// Copied from diora-android 7f37d4e 2026-10-25: web-native/static/native-podcast.js
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
// and copy again. The browser host is static/js/podcasts-web.js.
@ -384,8 +384,10 @@
};
}
// "Kapitel 7/24 · <book>" while a chapter of an audiobook plays (playerState().book).
// "Kapitel 7/24 · <book>" while a chapter of an audiobook plays (playerState().book);
// "Podcast-Radio · <podcast>" while the radio plays (playerState().radio, diora-web docs/podcasts.md).
function bookLine(p) {
if (p && p.radio) return 'Podcast-Radio · ' + plain(p.feed || '');
const b = p && p.book;
if (!b || !b.chapters) return '';
return `Kapitel ${b.index + 1}/${b.chapters}` + (b.title || p.feed ? ' · ' + plain(b.title || p.feed) : '');
@ -3365,6 +3367,8 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
const head = el('div', 'nui-pod-head');
head.append(el('div', 'nui-pod-summary', `${nEpisodes(total)} · ${sortLabel(epSort).toLowerCase()}`),
listTools([
view === 'stream' && N.podcastRadio && button('📻', 'Podcast-Radio: immer die neueste ungehörte Folge – ohne Spuren in Listen und Verlauf',
() => N.podcastRadio(), 'nui-pod-radiobtn'),
view === 'stream' && button('✦', 'Stöbern: suchen, Themen, Personen, Vergessenes, Empfehlungen', openBrowse, 'nui-pod-browsebtn'),
button('⇅', 'Sortieren', () => sortMenuFor(epSort, true, id => setEpisodeList(epFilters, id))),
button('☐', 'Mehrere auswählen', () => startSelecting()),
@ -4715,7 +4719,17 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
closePlayer();
window.__dioraPodcastOpenFeed(id);
});
panel.append(el('div', 'nui-pod-player-title'), feedLine, chapBox);
// The Podcast-Radio: what comes next; + (Merkliste) and "Normal weiterhören" (diora-web docs/podcasts.md).
const radioBox = el('div', 'nui-pod-radiobox');
radioBox.hidden = true;
const radioActs = el('div', 'nui-pod-radioacts');
radioActs.append(
button('+ Merkliste', 'Auf die Merkliste (das Radio spielt weiter)', () => { N.podcastRadioLater(); toast('Auf der Merkliste'); }, 'nui-pod-radiolater'),
button('Normal weiterhören', 'Aus dem Radio nehmen: zuerst in die Wiedergabeliste, ab hier mit Verlauf',
() => { N.podcastRadioCommit(); toast('Läuft jetzt normal – zuerst in der Wiedergabeliste'); }, 'nui-pod-radiocommit'),
);
radioBox.append(el('div', 'nui-pod-radionext'), radioActs);
panel.append(el('div', 'nui-pod-player-title'), feedLine, chapBox, radioBox);
const range = el('input', 'nui-pod-seek');
range.type = 'range';
range.min = 0;
@ -4873,6 +4887,17 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
markChapter(sheet, (player.pos || 0) / 1000);
sheet.querySelector('.nui-pod-player-title').textContent = player.title || '';
sheet.querySelector('.nui-pod-player-feed').textContent = bookLine(player) || player.feed || '';
const radioBox = sheet.querySelector('.nui-pod-radiobox');
if (radioBox) {
radioBox.hidden = !player.radio || !N.podcastRadioCommit;
const n = player.radio && player.radio.next;
radioBox.querySelector('.nui-pod-radionext').textContent = n ? `Danach: ${plain(n.feed)} – ${plain(n.title)}` : '';
}
const nx = sheet.querySelector('.nui-pod-next');
if (nx) {
if (!nx.dataset.title) nx.dataset.title = nx.title;
nx.title = player.radio ? 'Überspringen – das Radio spielt die nächste' : nx.dataset.title;
}
const range = sheet.querySelector('.nui-pod-seek');
const dur = Math.round((player.dur || 0) / 1000);
const pos = Math.round((player.pos || 0) / 1000);
@ -5107,6 +5132,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
function finishAndNext() {
const id = currentId();
if (!id) return;
if (player.radio) { N.podcastNext(); return; } // the radio is done with it, nothing else
const ids = queue().map(q => q.episode__id);
const at = ids.indexOf(id);
const last = at < 0 ? !ids.length : at === ids.length - 1;

View file

@ -1,4 +1,4 @@
/* Copied from diora-android 1c1ec4e 2026-10-19: the podcast parts of web-native/static/native.css
/* Copied from diora-android 7f37d4e 2026-10-25: the podcast parts of web-native/static/native.css
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */
@ -803,6 +803,14 @@ body.eink .nui-pod-facts a { color: #000; text-decoration: underline; }
/* the player's chapters, right under the title */
.nui-pod-chapbox { margin-top: -4px; border: 1px solid var(--border); border-radius: 8px; padding: 2px 4px 2px 10px; }
.nui-pod-chapbox[hidden] { display: none; }
/* the Podcast-Radio (diora-web docs/podcasts.md): what comes next, + and "Normal weiterhören" */
.nui-pod-radiobox { margin-top: -4px; border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px 6px 10px; }
.nui-pod-radiobox[hidden] { display: none; }
.nui-pod-radionext { font-size: 12px; color: var(--nui-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nui-pod-radionext:empty { display: none; }
.nui-pod-radioacts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.nui-pod-radioacts .nui-icon { width: auto !important; padding: 0 12px !important; font-size: 13px !important; border: 1px solid var(--border); border-radius: 16px; }
body.eink .nui-pod-radiobox, body.eink .nui-pod-radioacts .nui-icon { border-color: #000; }
.nui-pod-chaphead { display: flex; align-items: center; gap: 2px; min-height: 40px; }
.nui-pod-chapcount { flex: 1; min-width: 0; font-size: 12px; color: var(--nui-muted); font-variant-numeric: tabular-nums; }
.nui-pod-chapbtn { min-width: 40px !important; min-height: 40px !important; font-size: 16px !important; }