Compare commits

..

No commits in common. "ae0785d5ef5cc5504a2d71cf1712642f6ba543b1" and "f9b3a0ce04355015d23c4d6e2dbb3ae9a6af2518" have entirely different histories.

23 changed files with 1589 additions and 6393 deletions

View file

@ -29,12 +29,7 @@ jobs:
/tmp/venv/bin/pip install --no-cache-dir -r requirements.txt
- name: Django system check
# The SQLite file lives in data/ (a volume in production, git-ignored).
# Since the podcast models have JSON fields, the check opens the
# database to ask whether it supports them — so the folder must exist.
run: |
mkdir -p data
/tmp/venv/bin/python manage.py check
run: /tmp/venv/bin/python manage.py check
- name: Run tests
run: /tmp/venv/bin/python manage.py test

View file

@ -1,39 +1,30 @@
"""Podcast chapters: Podlove Simple Chapters from the feed, Podcasting 2.0
JSON chapters fetched from the URL a feed points to, and the CHAP frames of
the ID3v2 tag at the start of the audio file.
"""Podcast chapters: Podlove Simple Chapters from the feed, and Podcasting 2.0
JSON chapters fetched from the URL a feed points to.
Both end up in one shape, a list sorted by start time:
[{"start": 62.5, "title": "…", "url": "https://…", "img": "https://…"}]
url and img only when present.
The JSON file and the audio file live on servers the feed's author chose, so
fetching them is an SSRF surface like the WebDAV import, and it reuses that
module's guards: public addresses only (checked before connecting and again
on the connected socket), redirects followed by hand with each hop
re-checked, and a size cap. Transcripts (transcripts.py) are fetched the same
way, through `fetch_bytes`.
The JSON file lives on a server the feed's author chose, so fetching it is an
SSRF surface like the WebDAV import, and it reuses that module's guards:
public addresses only (checked before connecting and again on the connected
socket), redirects followed by hand with each hop re-checked, and a size cap.
"""
import json
import re
import time
from urllib.parse import urljoin
import requests
from accounts.webdav import WebDAVError, WebDAVInputError, _assert_peer_is_safe, _read_capped, assert_safe_url
from . import id3
MAX_CHAPTERS = 500
MAX_TITLE = 500
MAX_URL = 1000
CHAPTERS_TIMEOUT = 10 # seconds
CHAPTERS_MAX_BYTES = 1024 * 1024
CHAPTERS_MAX_REDIRECTS = 3
ID3_MAX_BYTES = 4 * 1024 * 1024 # of the audio file's start; a larger tag is read this far
ID3_DEADLINE = 30 # seconds for reading it, however slowly it trickles in
ID3_CHUNK = 64 * 1024
_SCHEMES = ('http://', 'https://')
@ -123,14 +114,8 @@ class ChaptersInvalid(Exception):
"""The chapters URL is unusable for good (blocked, missing, not chapters)."""
# Generic names for the two kinds of failure, for other fetches (transcripts).
FetchUnavailable = ChaptersUnavailable
FetchInvalid = ChaptersInvalid
def _open(url, headers):
"""GET `url` with every redirect hop checked; the streamed response of
the final hop, which the caller has to close.
def fetch_json(url):
"""Download and parse the JSON at `url`.
Raises ChaptersInvalid for permanent problems and ChaptersUnavailable for
ones that may go away (network trouble, server errors).
@ -141,7 +126,10 @@ def _open(url, headers):
except WebDAVInputError as e:
raise ChaptersInvalid(str(e))
try:
r = requests.get(url, timeout=CHAPTERS_TIMEOUT, allow_redirects=False, stream=True, headers=headers)
r = requests.get(
url, timeout=CHAPTERS_TIMEOUT, allow_redirects=False, stream=True,
headers={'User-Agent': 'diora podcast chapters', 'Accept': 'application/json'},
)
except requests.RequestException as e:
raise ChaptersUnavailable(e.__class__.__name__)
try:
@ -151,40 +139,23 @@ def _open(url, headers):
if not location:
raise ChaptersInvalid('redirect without a location')
url = urljoin(url, location)
r.close()
continue
if r.status_code >= 500 or r.status_code == 429:
raise ChaptersUnavailable(f'HTTP {r.status_code}')
if r.status_code >= 400:
raise ChaptersInvalid(f'HTTP {r.status_code}')
body = _read_capped(r, CHAPTERS_MAX_BYTES, lambda: ChaptersInvalid('too large'))
except WebDAVInputError as e:
r.close()
raise ChaptersInvalid(str(e))
except Exception:
r.close()
raise
return r
raise ChaptersInvalid('too many redirects')
def fetch_bytes(url, max_bytes, accept='*/*', agent='diora podcast chapters'):
"""The body at `url`, at most `max_bytes` of it (more is ChaptersInvalid)."""
r = _open(url, {'User-Agent': agent, 'Accept': accept})
try:
return _read_capped(r, max_bytes, lambda: ChaptersInvalid('too large'))
except WebDAVError as e: # transfer broke off in _read_capped
raise ChaptersUnavailable(str(e))
finally:
r.close()
def fetch_json(url):
"""Download and parse the JSON at `url`."""
body = fetch_bytes(url, CHAPTERS_MAX_BYTES, accept='application/json')
try:
return json.loads(body)
except ValueError:
raise ChaptersInvalid('not JSON')
raise ChaptersInvalid('too many redirects')
def fetch(url):
@ -193,52 +164,3 @@ def fetch(url):
return from_json(fetch_json(url))
except ValueError:
raise ChaptersInvalid('not a chapters document')
# ---------------------------------------------------------------------------
# ID3v2 chapters (CHAP frames at the start of the audio file)
# ---------------------------------------------------------------------------
def read_id3_tag(url):
"""The ID3v2 tag at the start of the file at `url` (at most ID3_MAX_BYTES
of it), or b'' if the file doesn't start with one.
Asks for just that part with a Range request. Servers that ignore Range
and send the whole file are read only as far as needed, then dropped.
"""
r = _open(url, {
'User-Agent': 'diora podcast chapters',
'Range': f'bytes=0-{ID3_MAX_BYTES - 1}',
'Accept-Encoding': 'identity',
})
data = bytearray()
need = None # bytes of the tag, known once its header is in
deadline = time.monotonic() + ID3_DEADLINE
try:
for chunk in r.iter_content(chunk_size=ID3_CHUNK):
data += chunk
if need is None and len(data) >= id3.HEADER_SIZE:
size = id3.tag_size(bytes(data[:id3.HEADER_SIZE]))
if size is None:
return b''
need = min(size, ID3_MAX_BYTES)
if need is not None and len(data) >= need:
break
if time.monotonic() > deadline:
raise ChaptersUnavailable('too slow')
except requests.RequestException as e:
raise ChaptersUnavailable(e.__class__.__name__)
finally:
r.close()
if need is None:
return b'' # shorter than a tag header
return bytes(data[:need]) # may be less if the file ended early
def fetch_id3(url):
"""Normalised chapters from the ID3v2 tag of the audio file at `url`;
[] if it has none."""
return _finish(
_chapter(start_ms / 1000, title, link)
for start_ms, _end_ms, title, link in id3.chapters(read_id3_tag(url))
)

View file

@ -1,281 +0,0 @@
"""Chapters embedded in the ID3v2 tag at the start of an MP3 file.
Written from the ID3v2.3.0 and ID3v2.4.0 specifications and the ID3v2
Chapter Frame Addendum (CHAP and CTOC frames). Only what chapters need is
read: the tag header, an optional extended header (skipped), and the frames.
Of those, CHAP frames give the chapters, with the title from an embedded TIT2
frame and a link from WXXX or another W*** frame; CTOC frames, when there
are any, give the order (which matters only for chapters starting at the
same time, as callers sort by start). Pictures (APIC) and everything else are
skipped.
Pure parsing, no I/O: the caller hands over the first bytes of the file
(`tag_size` says how many are needed).
"""
import re
import struct
import zlib
HEADER_SIZE = 10
MAX_FRAME_BYTES = 4 * 1024 * 1024 # a decompressed frame can't grow past this
_FRAME_ID = re.compile(rb'[A-Z0-9]{4}\Z')
# Header flags
_UNSYNC = 0x80
_EXTENDED = 0x40
_FOOTER = 0x10
def _synchsafe(raw):
"""Integer from bytes that carry 7 bits each (the high bit is always 0)."""
value = 0
for b in raw:
value = (value << 7) | (b & 0x7F)
return value
def tag_size(header):
"""Bytes the ID3v2 tag at the start of a file takes up, header and footer
included, from the file's first 10 bytes; None if there's no such tag."""
if len(header) < HEADER_SIZE or header[:3] != b'ID3':
return None
major, revision, flags = header[3], header[4], header[5]
if major == 0xFF or revision == 0xFF or any(b & 0x80 for b in header[6:10]):
return None
size = HEADER_SIZE + _synchsafe(header[6:10])
if major >= 4 and flags & _FOOTER:
size += HEADER_SIZE
return size
def _deunsync(data):
"""Undo unsynchronisation: every $FF $00 was an $FF before."""
return data.replace(b'\xff\x00', b'\xff')
# ---------------------------------------------------------------------------
# Frames
# ---------------------------------------------------------------------------
def _plausible_frame_start(buf, at):
"""True if a frame (or padding, or the end of `buf`) can begin at `at`."""
if at == len(buf):
return True
if at > len(buf):
return False
head = buf[at:at + 4]
return bool(_FRAME_ID.match(head)) or not head.strip(b'\x00')
def _v24_frame_size(buf, pos, raw):
"""Size of a v2.4 frame. The spec says synchsafe; some writers (older
iTunes among them) store a plain integer, so where the two differ, take
the one after which the next frame starts."""
plain = int.from_bytes(raw, 'big')
if any(b & 0x80 for b in raw):
return plain # can't be synchsafe
safe = _synchsafe(raw)
if safe == plain or _plausible_frame_start(buf, pos + HEADER_SIZE + safe):
return safe
if _plausible_frame_start(buf, pos + HEADER_SIZE + plain):
return plain
return safe
def _frame_payload(data, fmt, major, unsync_all):
"""The frame's content after its format flags are dealt with, or None if
it can't be read (encrypted, broken compression)."""
if major == 3:
compressed, encrypted = fmt & 0x80, fmt & 0x40
# Added after the frame header, in flag order: decompressed size (4),
# encryption method (1), group id (1).
skip = (4 if compressed else 0) + (1 if encrypted else 0) + (1 if fmt & 0x20 else 0)
unsync = False # v2.3 unsynchronises the whole tag, undone already
else:
compressed, encrypted = fmt & 0x08, fmt & 0x04
# Group id (1), encryption method (1), data length indicator (4).
skip = (1 if fmt & 0x40 else 0) + (1 if encrypted else 0) + (4 if fmt & 0x01 else 0)
unsync = unsync_all or fmt & 0x02
if encrypted:
return None
data = data[skip:]
if unsync:
data = _deunsync(data)
if compressed:
try:
data = zlib.decompressobj().decompress(data, MAX_FRAME_BYTES)
except zlib.error:
return None
return data
def _frames(buf, pos, major, unsync_all=False):
"""(frame id, content) for each complete frame in buf[pos:]."""
while pos + HEADER_SIZE <= len(buf):
frame_id = buf[pos:pos + 4]
if not _FRAME_ID.match(frame_id):
return # padding, or something we can't make sense of
raw_size = buf[pos + 4:pos + 8]
size = _v24_frame_size(buf, pos, raw_size) if major >= 4 else int.from_bytes(raw_size, 'big')
fmt = buf[pos + 9]
start = pos + HEADER_SIZE
if start + size > len(buf):
return # cut off (the caller read only part of a large tag)
payload = _frame_payload(buf[start:start + size], fmt, major, unsync_all)
if payload is not None:
yield frame_id.decode('ascii'), payload
pos = start + size
# ---------------------------------------------------------------------------
# Frame contents
# ---------------------------------------------------------------------------
_CODECS = {0: 'latin-1', 1: 'utf-16', 2: 'utf-16-be', 3: 'utf-8'}
def _decode(raw, encoding):
codec = _CODECS.get(encoding)
if codec is None:
return ''
if encoding in (1, 2) and len(raw) % 2:
raw = raw[:-1]
return raw.decode(codec, errors='replace')
def _text_frame(data):
"""The (first) string of a T*** frame."""
if not data:
return ''
text = _decode(data[1:], data[0])
# v2.4 allows several strings, separated by NUL; the first is the title.
return text.split('\x00')[0].replace('', '').strip()
def _wxxx_frame(data):
"""The URL of a WXXX frame: encoding, description, NUL, URL (latin-1)."""
if not data:
return ''
encoding, rest = data[0], data[1:]
if encoding in (1, 2): # the description ends with a two-byte NUL
i = 0
while i + 1 < len(rest) and not (rest[i] == 0 and rest[i + 1] == 0):
i += 2
url = rest[i + 2:]
else:
i = rest.find(b'\x00')
url = rest[i + 1:] if i >= 0 else b''
return url.split(b'\x00')[0].decode('latin-1').strip()
def _url_frame(data):
"""The URL of any other W*** frame, which is nothing but the URL."""
return data.split(b'\x00')[0].decode('latin-1').strip()
def _chap(data, major):
"""(element id, start ms, end ms, title, url) of a CHAP frame, or None."""
nul = data.find(b'\x00')
if nul < 0 or len(data) < nul + 17:
return None
element_id = data[:nul]
start_ms, end_ms, _start_offset, _end_offset = struct.unpack('>IIII', data[nul + 1:nul + 17])
title = url = ''
# Embedded frames, in the same format as the tag's own. Unsynchronisation
# of the whole tag has already been undone on `data`.
for frame_id, payload in _frames(data, nul + 17, major):
if frame_id == 'TIT2' and not title:
title = _text_frame(payload)
elif frame_id == 'WXXX' and not url:
url = _wxxx_frame(payload)
elif frame_id.startswith('W') and not url:
url = _url_frame(payload)
return element_id, start_ms, end_ms, title, url
def _ctoc(data):
"""(element id, top level?, child element ids) of a CTOC frame, or None."""
nul = data.find(b'\x00')
if nul < 0 or len(data) < nul + 3:
return None
element_id, flags, count = data[:nul], data[nul + 1], data[nul + 2]
children, pos = [], nul + 3
for _ in range(count):
end = data.find(b'\x00', pos)
if end < 0:
break
children.append(data[pos:end])
pos = end + 1
return element_id, bool(flags & 0x02), children
def _toc_order(chaps, tocs):
"""Element ids of the chapters the table of contents lists, in its order
(top-level CTOCs first, nested ones walked depth first); [] if there's no
usable table of contents."""
if not tocs:
return []
nested = {child for _, _, children in tocs for child in children}
roots = [t for t in tocs if t[1]] or [t for t in tocs if t[0] not in nested]
by_id = {t[0]: t for t in tocs}
order, seen = [], set()
def walk(toc):
if toc[0] in seen:
return # a loop
seen.add(toc[0])
for child in toc[2]:
if child in chaps:
if child not in order:
order.append(child)
elif child in by_id:
walk(by_id[child])
for root in roots:
walk(root)
return order
def chapters(data):
"""[(start ms, end ms, title, url)] of the chapters in the ID3v2 tag at
the start of `data`, in table-of-contents order where there is one, else
in tag order.
[] if there is no tag or it has no chapters; a tag cut short yields the
chapters in the part that's there."""
size = tag_size(data)
if size is None:
return []
major, flags = data[3], data[5]
if major not in (3, 4):
return [] # v2.2 has no chapter frames
body = data[HEADER_SIZE:min(len(data), size)]
unsync = bool(flags & _UNSYNC)
if major == 3 and unsync:
body = _deunsync(body)
pos = 0
if flags & _EXTENDED and len(body) >= 4:
if major == 3: # size of what follows the size field
pos = 4 + int.from_bytes(body[:4], 'big')
else: # synchsafe size of the whole extended header
pos = _synchsafe(body[:4])
chaps, order, tocs = {}, [], []
for frame_id, payload in _frames(body, pos, major, unsync_all=(major == 4 and unsync)):
if frame_id == 'CHAP':
chap = _chap(payload, major)
if chap and chap[0] not in chaps:
chaps[chap[0]] = chap
order.append(chap[0])
elif frame_id == 'CTOC':
toc = _ctoc(payload)
if toc:
tocs.append(toc)
# The table of contents' order first; chapters it doesn't list are kept
# (after it) rather than dropped, since writers don't always list all.
toc = _toc_order(chaps, tocs)
listed = set(toc)
order = toc + [e for e in order if e not in listed]
return [chaps[e][1:] for e in order]

View file

@ -1,15 +1,16 @@
import sys
from django.core.management.base import BaseCommand
from django.utils import timezone
from podcasts.models import PodcastFeed
from podcasts.views import _in_backoff_q, _refresh_feeds
from podcasts.views import _refresh_feeds
class Command(BaseCommand):
help = 'Refresh podcast feeds (fetch new episodes from RSS)'
def add_arguments(self, parser):
parser.add_argument('--feed-id', type=int, help='Refresh only this feed ID (even when backing off)')
parser.add_argument('--feed-id', type=int, help='Refresh only this feed ID')
parser.add_argument('--limit', type=int, default=0, help='Max number of feeds to refresh')
def handle(self, *args, **options):
@ -17,10 +18,6 @@ class Command(BaseCommand):
if options['feed_id']:
qs = qs.filter(pk=options['feed_id'])
else:
# A feed failing 3+ times in a row is tried every 2, 4, 8, 16,
# at most 24 hours instead of every hour.
qs = qs.exclude(_in_backoff_q(timezone.now()))
if options['limit']:
qs = qs[: options['limit']]

View file

@ -1,74 +0,0 @@
from django.db import migrations, models
def auto_queue_to_new_episodes(apps, schema_editor):
"""Feeds that queued their new episodes keep doing so."""
PodcastFeed = apps.get_model('podcasts', 'PodcastFeed')
PodcastFeed.objects.filter(auto_queue=True).update(new_episodes='queue')
def new_episodes_to_auto_queue(apps, schema_editor):
PodcastFeed = apps.get_model('podcasts', 'PodcastFeed')
PodcastFeed.objects.filter(new_episodes='queue').update(auto_queue=True)
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0006_episodeprogress_favorite'),
]
operations = [
migrations.AddField(
model_name='podcastfeed',
name='exclude_words',
field=models.CharField(blank=True, max_length=500),
),
migrations.AddField(
model_name='podcastfeed',
name='include_words',
field=models.CharField(blank=True, max_length=500),
),
migrations.AddField(
model_name='podcastfeed',
name='min_duration',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='podcastfeed',
name='new_episodes',
field=models.CharField(
choices=[('inbox', 'In „Neu“'), ('queue', 'In die Warteschlange'), ('ignore', 'Nicht anzeigen')],
default='inbox', max_length=10),
),
migrations.AddField(
model_name='podcastfeed',
name='playback_speed',
field=models.FloatField(blank=True, null=True),
),
migrations.AddField(
model_name='podcastfeed',
name='skip_intro',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='podcastfeed',
name='skip_outro',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='podcastfeed',
name='volume',
field=models.FloatField(default=1.0),
),
migrations.AddField(
model_name='podcastsettings',
name='keep_sorted',
field=models.CharField(
blank=True,
choices=[('', 'Aus'), ('new', 'Neueste zuerst'), ('old', 'Älteste zuerst'),
('short', 'Kürzeste zuerst'), ('long', 'Längste zuerst'), ('feed', 'Nach Podcast')],
default='', max_length=10),
),
migrations.RunPython(auto_queue_to_new_episodes, new_episodes_to_auto_queue),
]

View file

@ -1,38 +0,0 @@
# Generated by Django 6.1 on 2026-10-02 12:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0007_feed_settings'),
]
operations = [
migrations.AddField(
model_name='podcastfeed',
name='http_etag',
field=models.CharField(blank=True, max_length=500),
),
migrations.AddField(
model_name='podcastfeed',
name='http_last_modified',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='podcastfeed',
name='last_error',
field=models.CharField(blank=True, max_length=500),
),
migrations.AddField(
model_name='podcastfeed',
name='last_error_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='podcastfeed',
name='error_count',
field=models.PositiveIntegerField(default=0),
),
]

View file

@ -1,25 +0,0 @@
# Generated by Django 6.1 on 2026-10-02 07:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0008_feed_http_cache'),
]
operations = [
migrations.CreateModel(
name='EpisodeTranscript',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sources', models.JSONField(default=list)),
('data', models.JSONField(blank=True, null=True)),
('error', models.CharField(blank=True, max_length=20)),
('fetched_at', models.DateTimeField(auto_now=True)),
('episode', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transcript_cache', to='podcasts.podcastepisode')),
],
),
]

View file

@ -3,18 +3,6 @@ from django.db import models
class PodcastFeed(models.Model):
# What happens to a new episode of this feed (one that passed the filter).
NEW_INBOX = 'inbox'
NEW_QUEUE = 'queue'
NEW_IGNORE = 'ignore'
NEW_EPISODE_ACTIONS = [
(NEW_INBOX, 'In „Neu“'),
(NEW_QUEUE, 'In die Warteschlange'),
(NEW_IGNORE, 'Nicht anzeigen'),
]
# Volume factors: below 1 quieter, above 1 louder (the apps boost those).
VOLUMES = (0.2, 0.5, 1.0, 1.5, 2.0, 2.5)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='podcast_feeds')
rss_url = models.URLField(max_length=1000)
title = models.CharField(max_length=300)
@ -24,30 +12,7 @@ class PodcastFeed(models.Model):
link = models.URLField(max_length=1000, blank=True)
last_refreshed_at = models.DateTimeField(null=True, blank=True)
added_at = models.DateTimeField(auto_now_add=True)
# Always the same as new_episodes == 'queue' (see save()); kept for the
# web UI's toggle and older clients.
auto_queue = models.BooleanField(default=False)
# HTTP validators of the last full (200) response, sent back as
# If-None-Match / If-Modified-Since so an unchanged feed costs a 304.
http_etag = models.CharField(max_length=500, blank=True)
http_last_modified = models.CharField(max_length=100, blank=True)
# The last failed refresh (network, HTTP status, not a feed); cleared on
# success. error_count counts failures in a row and drives the backoff.
last_error = models.CharField(max_length=500, blank=True)
last_error_at = models.DateTimeField(null=True, blank=True)
error_count = models.PositiveIntegerField(default=0)
# Playback, for every device: null = the device's own speed.
playback_speed = models.FloatField(null=True, blank=True)
skip_intro = models.PositiveIntegerField(default=0) # seconds
skip_outro = models.PositiveIntegerField(default=0) # seconds
volume = models.FloatField(default=1.0) # one of VOLUMES
# New episodes: what happens to them, and which ones count at all.
new_episodes = models.CharField(max_length=10, choices=NEW_EPISODE_ACTIONS, default=NEW_INBOX)
# Comma-separated words, case-insensitive, matched against the title.
include_words = models.CharField(max_length=500, blank=True)
exclude_words = models.CharField(max_length=500, blank=True)
min_duration = models.PositiveIntegerField(default=0) # minutes, 0 = any
class Meta:
unique_together = ('user', 'rss_url')
@ -56,58 +21,6 @@ class PodcastFeed(models.Model):
def __str__(self):
return self.title
@classmethod
def from_db(cls, *args, **kwargs):
obj = super().from_db(*args, **kwargs)
obj._remember_sync()
return obj
def refresh_from_db(self, *args, **kwargs):
super().refresh_from_db(*args, **kwargs)
self._remember_sync()
def _remember_sync(self):
self._synced = (self.__dict__.get('auto_queue'), self.__dict__.get('new_episodes'))
def _sync_auto_queue(self):
"""Keep auto_queue == (new_episodes == 'queue'). Whichever of the two
was changed since loading (or, for a new feed, set away from its
default) wins; new_episodes if both were."""
auto_before, new_before = getattr(self, '_synced', (False, self.NEW_INBOX))
if self.new_episodes != new_before or self.auto_queue == auto_before:
self.auto_queue = self.new_episodes == self.NEW_QUEUE
elif self.auto_queue:
self.new_episodes = self.NEW_QUEUE
elif self.new_episodes == self.NEW_QUEUE:
self.new_episodes = self.NEW_INBOX
def save(self, *args, **kwargs):
update_fields = kwargs.get('update_fields')
if update_fields is not None and not {'auto_queue', 'new_episodes'} & set(update_fields):
return super().save(*args, **kwargs)
self._sync_auto_queue()
if update_fields is not None:
kwargs['update_fields'] = set(update_fields) | {'auto_queue', 'new_episodes'}
super().save(*args, **kwargs)
self._remember_sync()
def matches_filter(self, title, duration_seconds):
"""Whether a new episode passes this feed's filter: an include word
(if any are given), no exclude word, and long enough (if known)."""
title = (title or '').casefold()
include = _words(self.include_words)
if include and not any(w in title for w in include):
return False
if any(w in title for w in _words(self.exclude_words)):
return False
if self.min_duration and duration_seconds and duration_seconds < self.min_duration * 60:
return False
return True
def _words(raw):
return [w.strip().casefold() for w in (raw or '').split(',') if w.strip()]
class PodcastEpisode(models.Model):
feed = models.ForeignKey(PodcastFeed, on_delete=models.CASCADE, related_name='episodes')
@ -124,11 +37,9 @@ class PodcastEpisode(models.Model):
# Podcasting 2.0 <podcast:chapters url=…>: a JSON file, loaded on demand.
chapters_url = models.URLField(max_length=1000, blank=True)
# [{start: seconds, title, url?, img?}], from Podlove Simple Chapters in
# the feed, or once fetched: the JSON at chapters_url, else (or if that
# has none) the CHAP frames of the audio file's ID3v2 tag.
# the feed, or the JSON at chapters_url once it has been fetched.
chapters = models.JSONField(default=list, blank=True)
# When chapters were fetched into `chapters` (from chapters_url or the
# audio file); null = not yet.
# When chapters_url was fetched into `chapters`; null = not yet.
chapters_fetched_at = models.DateTimeField(null=True, blank=True)
# [{url, type, language}] from <podcast:transcript>.
transcripts = models.JSONField(default=list, blank=True)
@ -141,27 +52,6 @@ class PodcastEpisode(models.Model):
return self.title
class EpisodeTranscript(models.Model):
"""An episode's transcript, fetched and parsed once (transcripts.py).
A table of its own rather than fields on PodcastEpisode: a parsed
transcript runs to hundreds of KB, and episode lists load whole rows.
"""
episode = models.OneToOneField(PodcastEpisode, on_delete=models.CASCADE, related_name='transcript_cache')
# URLs of the transcripts it was made from, best first. When the feed
# offers different ones, the cache is stale.
sources = models.JSONField(default=list)
# {"segments": [...], "source": {...}}, or null with an error.
data = models.JSONField(null=True, blank=True)
# '' when data is there; else why there's none for good: 'unsupported'
# (no format with timing), 'invalid' (missing, broken, too large).
error = models.CharField(max_length=20, blank=True)
fetched_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f'Transcript of {self.episode}'
# 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
@ -204,8 +94,8 @@ class PodcastQueue(models.Model):
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
queues its new episodes brings some; and whether the queue stays sorted."""
by hand (also when played from outside the queue), and when a feed with
auto_queue brings new ones."""
BACK = 'back'
FRONT = 'front'
AFTER_CURRENT = 'after_current'
@ -215,22 +105,10 @@ class PodcastSettings(models.Model):
(AFTER_CURRENT, 'Nach der laufenden Folge'),
]
# Keep the queue sorted: after every change the whole queue is sorted
# again by this rule ('' = off, the user's own order).
SORT_ORDERS = [
('', 'Aus'),
('new', 'Neueste zuerst'),
('old', 'Älteste zuerst'),
('short', 'Kürzeste zuerst'),
('long', 'Längste zuerst'),
('feed', 'Nach Podcast'),
]
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='podcast_settings')
enqueue_position = models.CharField(max_length=20, choices=POSITIONS, default=BACK)
# front = how auto-queue always worked before this setting existed
auto_queue_position = models.CharField(max_length=20, choices=POSITIONS, default=FRONT)
keep_sorted = models.CharField(max_length=10, choices=SORT_ORDERS, default='', blank=True)
@classmethod
def for_user(cls, user):

File diff suppressed because it is too large Load diff

View file

@ -1,282 +0,0 @@
"""Podcast transcripts (<podcast:transcript>): pick the best one an episode
offers, fetch it with the same guards as the chapters, and turn it into
readable segments:
{"segments": [{"start": 1.5, "end": 9.25, "speaker": "Anna" | None, "text": "…"}],
"source": {"url": "…", "type": "text/vtt", "language": "de"}}
Formats: Podcasting 2.0 JSON, WebVTT and SubRip (SRT). HTML transcripts have
no timing and aren't supported. Cues are often a few words each; consecutive
ones of the same speaker are merged up to a sentence end or about 30 s.
"""
import html
import json
import re
from . import chapters as fetching
MAX_BYTES = 2 * 1024 * 1024
MAX_SEGMENTS = 20_000
MAX_TEXT = 5_000 # characters of one segment
MAX_SPEAKER = 200
# Merging
MERGE_MAX_SECONDS = 30 # a segment never spans more than this
MERGE_MIN_SECONDS = 5 # ... and ends at a sentence end once it's this long
MERGE_MAX_GAP = 5 # silence that always starts a new segment
JSON, VTT, SRT, HTML = 'json', 'vtt', 'srt', 'html'
_KIND_RANK = {JSON: 0, VTT: 1, SRT: 2}
_ACCEPT = {JSON: 'application/json', VTT: 'text/vtt', SRT: 'application/x-subrip'}
class Unsupported(Exception):
"""The transcript is in a format without timing (HTML) or an unknown one."""
def kind(transcript):
"""json, vtt, srt, html or '' (unknown) from the type, else the URL."""
t = (transcript.get('type') or '').lower().split(';')[0].strip()
if 'json' in t:
return JSON
if 'vtt' in t:
return VTT
if 'srt' in t or 'subrip' in t:
return SRT
if 'html' in t:
return HTML
path = (transcript.get('url') or '').lower().split('?')[0].split('#')[0]
for ext, k in (('.json', JSON), ('.vtt', VTT), ('.srt', SRT), ('.html', HTML), ('.htm', HTML)):
if path.endswith(ext):
return k
return ''
def _language_rank(language):
lang = (language or '').lower().split('-')[0].split('_')[0]
if lang in ('de', ''): # no language given: most likely the episode's own
return 0
if lang == 'en':
return 1
return 2
def candidates(transcripts):
"""The episode's transcripts in a format we read, best first: German (or
no language given) before English before others, then JSON before VTT
before SRT."""
usable = [(i, t) for i, t in enumerate(transcripts or []) if kind(t) in _KIND_RANK]
usable.sort(key=lambda it: (_language_rank(it[1].get('language')), _KIND_RANK[kind(it[1])], it[0]))
return [t for _, t in usable]
# ---------------------------------------------------------------------------
# Parsers: each returns fragments [(start, end, speaker, text)]
# ---------------------------------------------------------------------------
def _decode(body):
if body.startswith((b'\xff\xfe', b'\xfe\xff')):
return body.decode('utf-16', errors='replace')
try:
return body.decode('utf-8-sig')
except UnicodeDecodeError:
return body.decode('cp1252', errors='replace')
def _clean(text):
text = re.sub(r'<[^>]*>', ' ', text)
return ' '.join(html.unescape(text).split())
def _seconds(value):
try:
value = float(value)
except (TypeError, ValueError):
return None
if value != value or value < 0 or value == float('inf'):
return None
return value
def parse_json(text):
"""Podcasting 2.0 JSON: {"segments": [{"startTime", "endTime", "speaker", "body"}]}."""
try:
data = json.loads(text)
except ValueError:
raise fetching.FetchInvalid('not JSON')
segments = data.get('segments') if isinstance(data, dict) else None
if not isinstance(segments, list):
raise fetching.FetchInvalid('no segments list')
out = []
for s in segments:
if not isinstance(s, dict):
continue
start = _seconds(s.get('startTime'))
if start is None:
continue
end = _seconds(s.get('endTime'))
speaker = s.get('speaker')
speaker = ' '.join(speaker.split()) if isinstance(speaker, str) else None
body = s.get('body')
out.append((start, end, speaker or None, _clean(body) if isinstance(body, str) else ''))
return out
# 00:01:02.500 / 01:02.500 (VTT), 00:01:02,500 (SRT)
_TIME = r'(?:(\d+):)?(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?'
_TIMING = re.compile(r'^\s*' + _TIME + r'\s*-->\s*' + _TIME + r'(?:\s|$)')
_VOICE = re.compile(r'<v(?:\.[^\s>]*)?(?:\s+([^>]*))?>', re.IGNORECASE)
def _time(hours, minutes, seconds, fraction):
return int(hours or 0) * 3600 + int(minutes) * 60 + int(seconds) + (int(fraction.ljust(3, '0')) / 1000 if fraction else 0)
def _cue_fragments(start, end, lines):
"""Split a cue's text at its voice tags (<v Anna>…) into fragments."""
text = '\n'.join(lines)
out = []
speaker, pos = None, 0
for m in _VOICE.finditer(text):
out.append((start, end, speaker, _clean(text[pos:m.start()])))
speaker = ' '.join((m.group(1) or '').split())[:MAX_SPEAKER] or None
pos = m.end()
out.append((start, end, speaker, _clean(text[pos:])))
return [f for f in out if f[3]]
def parse_cues(text):
"""WebVTT or SRT. Lines before a cue's timing line (VTT cue ids, SRT
numbers) are dropped, as is everything outside cues (the WEBVTT header,
NOTE, STYLE and REGION blocks)."""
cues, current = [], None
for line in text.replace('\r\n', '\n').replace('\r', '\n').split('\n'):
m = _TIMING.match(line)
if m:
if current is not None and current[2] and current[2][-1].strip().isdigit():
current[2].pop() # an SRT number with no blank line before it
g = m.groups()
current = (_time(*g[:4]), _time(*g[4:]), [])
cues.append(current)
elif not line.strip():
current = None
elif current is not None:
current[2].append(line)
out = []
for start, end, lines in cues:
out.extend(_cue_fragments(start, end, lines))
return out
def parse(body, declared_kind):
"""Fragments from a downloaded transcript; the content decides the format
when it's obvious, the declared type otherwise."""
text = _decode(body)
head = text.lstrip()[:200].lower()
if head.startswith(('<!doctype', '<html', '<?xml', '<body', '<p', '<div')):
raise Unsupported('HTML')
if head.startswith(('{', '[')):
return parse_json(text)
if head.startswith('webvtt') or declared_kind in (VTT, SRT):
return parse_cues(text)
if declared_kind == JSON:
return parse_json(text)
raise Unsupported('unknown format')
# ---------------------------------------------------------------------------
# Merging
# ---------------------------------------------------------------------------
_SENTENCE_END = re.compile(r'[.!?…]["\'”’»)\]]*$')
def merge(fragments):
"""Readable segments from cue fragments: consecutive ones of the same
speaker are joined until a sentence ends (once the segment is at least
MERGE_MIN_SECONDS long), MERGE_MAX_SECONDS are reached, or there's a
pause of more than MERGE_MAX_GAP."""
frags = sorted((f for f in fragments if f[3]), key=lambda f: f[0])[:MAX_SEGMENTS * 10]
# Fragments without a usable end run until the next one starts.
fixed = []
for i, (start, end, speaker, text) in enumerate(frags):
if end is None or end <= start:
nxt = frags[i + 1][0] if i + 1 < len(frags) else start
end = max(nxt, start)
fixed.append((start, end, speaker, text))
segments = []
for start, end, speaker, text in fixed:
last = segments[-1] if segments else None
if (
last is not None
and last['speaker'] == speaker
and start - last['end'] <= MERGE_MAX_GAP
and end - last['start'] <= MERGE_MAX_SECONDS
and len(last['text']) + len(text) < MAX_TEXT
and not (_SENTENCE_END.search(last['text']) and last['end'] - last['start'] >= MERGE_MIN_SECONDS)
):
last['text'] += ' ' + text
last['end'] = max(last['end'], end)
continue
if len(segments) >= MAX_SEGMENTS:
break
segments.append({'start': start, 'end': end, 'speaker': speaker, 'text': text[:MAX_TEXT]})
for s in segments:
s['start'], s['end'] = round(s['start'], 3), round(s['end'], 3)
return segments
# ---------------------------------------------------------------------------
# Fetching
# ---------------------------------------------------------------------------
def fetch(transcript):
"""The parsed transcript {"segments", "source"} from one of the episode's
<podcast:transcript> entries. Raises Unsupported, FetchInvalid or
FetchUnavailable."""
k = kind(transcript)
if k not in _KIND_RANK:
raise Unsupported(transcript.get('type') or 'unknown type')
body = fetching.fetch_bytes(
transcript['url'], MAX_BYTES, accept=f'{_ACCEPT[k]}, */*;q=0.1', agent='diora podcast transcripts',
)
segments = merge(parse(body, k))
if not segments:
raise fetching.FetchInvalid('no segments')
return {
'segments': segments,
'source': {
'url': transcript['url'],
'type': transcript.get('type') or '',
'language': transcript.get('language') or '',
},
}
def load(transcripts):
"""(parsed transcript, final) from the best candidate that works.
final is False when a better candidate failed for now only, so the
result shouldn't be kept for good. Raises FetchUnavailable when nothing
worked and something may work later, Unsupported when no candidate is in
a usable format, FetchInvalid otherwise.
"""
retry_later = False
unsupported_only = True
for t in candidates(transcripts):
try:
return fetch(t), not retry_later
except fetching.FetchUnavailable:
retry_later = True
unsupported_only = False
except Unsupported:
pass
except fetching.FetchInvalid:
unsupported_only = False
if retry_later:
raise fetching.FetchUnavailable('try again later')
if unsupported_only:
raise Unsupported('no usable transcript')
raise fetching.FetchInvalid('no usable transcript')

View file

@ -12,11 +12,9 @@ urlpatterns = [
path('state/', views.podcast_state, name='podcast_state'),
path('feeds/<int:pk>/remove/', views.remove_feed, name='podcast_remove_feed'),
path('feeds/<int:pk>/set-auto-queue/', views.set_auto_queue, name='podcast_set_auto_queue'),
path('feeds/<int:pk>/settings/', views.feed_settings, name='podcast_feed_settings'),
path('feeds/<int:pk>/episodes/', views.feed_episodes, name='podcast_feed_episodes'),
path('episodes/', views.episode_list, name='podcast_episode_list'),
path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'),
path('episodes/<int:pk>/transcript/', views.episode_transcript, name='podcast_episode_transcript'),
path('episodes/<int:pk>/favorite/', views.set_favorite, name='podcast_set_favorite'),
path('stats/', views.podcast_stats, name='podcast_stats'),
path('queue/', views.queue_get, name='podcast_queue_get'),

View file

@ -2,14 +2,12 @@ import json
import logging
import urllib.parse
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import timedelta
import xml.etree.ElementTree as ET
import feedparser
import requests
from django.conf import settings
from django.db import IntegrityError, transaction
from django.db.models import (
Case, Count, Exists, F, FilteredRelation, IntegerField, Max, OuterRef, Q, Sum, Value, When,
)
@ -21,10 +19,7 @@ from django.views.decorators.http import require_http_methods
from . import chapters as chapters_mod
from . import feedext
from . import transcripts as transcripts_mod
from .models import (
EpisodeProgress, EpisodeTranscript, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings, counts_as_played,
)
from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings, counts_as_played
from .shownotes import extract_timestamps, sanitize_html
feedext.install()
@ -43,7 +38,6 @@ MAX_TRANSCRIPTS = 10
# ---------------------------------------------------------------------------
QUEUE_POSITIONS = {key for key, _ in PodcastSettings.POSITIONS}
KEEP_SORTED = {key for key, _ in PodcastSettings.SORT_ORDERS}
def _queue_order(user):
@ -108,58 +102,9 @@ def _enqueue(user, episode_ids, where, current_hint=None):
)
_write_queue_order(user, queued[:at] + new + queued[at:])
_leave_inbox(user, new)
_keep_sorted(user)
return new
def _sort_key(rule):
"""Sort key for one queue row ({pub_date, duration, position, feed_title})
by a PodcastSettings.SORT_ORDERS rule. Ties keep their current order
(the sort is stable)."""
def ts(row):
return row['pub_date'].timestamp() if row['pub_date'] else None
def remaining(row):
return max((row['duration'] or 0) - (row['position'] or 0), 0)
if rule == 'new': # no date: last
return lambda r: (ts(r) is None, -(ts(r) or 0))
if rule == 'old': # no date: last as well
return lambda r: (ts(r) is None, ts(r) or 0)
if rule == 'short':
return remaining
if rule == 'long':
return lambda r: -remaining(r)
if rule == 'feed': # by podcast title, then newest first
return lambda r: ((r['feed_title'] or '').casefold(), ts(r) is None, -(ts(r) or 0))
return None
def _keep_sorted(user, rule=None):
"""Sort the user's whole queue by their keep_sorted rule (or `rule`), if
there is one. Returns True if it is kept sorted."""
if rule is None:
rule = PodcastSettings.objects.filter(user=user).values_list('keep_sorted', flat=True).first() or ''
key = _sort_key(rule)
if key is None:
return False
rows = list(
PodcastQueue.objects.filter(user=user)
.order_by('position', 'added_at', 'id')
.values('episode_id', pub_date=F('episode__pub_date'), duration=F('episode__duration_seconds'),
feed_title=F('episode__feed__title'))
)
positions = dict(
EpisodeProgress.objects.filter(user=user, episode_id__in=[r['episode_id'] for r in rows])
.values_list('episode_id', 'position_seconds')
)
for r in rows:
r['position'] = positions.get(r['episode_id'], 0)
rows.sort(key=key)
_write_queue_order(user, [r['episode_id'] for r in rows])
return True
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."""
@ -234,10 +179,8 @@ def _entry_transcripts(entry):
def _apply_extras(episode, description, chapters_url, psc_chapters, transcripts):
"""Copy what the feed says now onto `episode`; True if anything changed.
Inline (Podlove) chapters win. Without them, fetched chapters (from
chapters_url, or from the audio file when there's no URL) stay cached for
as long as the URL stays the same; inline chapters going away means a
fresh fetch.
Inline (Podlove) chapters win. Without them, chapters fetched from
chapters_url stay cached for as long as the URL stays the same.
"""
changed = []
if episode.description != description:
@ -247,8 +190,8 @@ def _apply_extras(episode, description, chapters_url, psc_chapters, transcripts)
episode.transcripts = transcripts
changed.append('transcripts')
if psc_chapters:
chapters, fetched_at = psc_chapters, None
elif chapters_url == episode.chapters_url and episode.chapters_fetched_at:
chapters, fetched_at = psc_chapters, episode.chapters_fetched_at
elif chapters_url and chapters_url == episode.chapters_url and episode.chapters_fetched_at:
chapters, fetched_at = episode.chapters, episode.chapters_fetched_at
else:
chapters, fetched_at = [], None
@ -271,141 +214,11 @@ EXTRA_FIELDS = ['description', 'transcripts', 'chapters_url', 'chapters', 'chapt
# Shared feed refresh helper
# ---------------------------------------------------------------------------
class FeedFetchError(Exception):
"""A feed that could not be fetched or is not a feed; str() is the short
message stored in PodcastFeed.last_error ("HTTP 404", "timeout", …)."""
@dataclass
class FeedFetch:
"""What _fetch_feed got. status 200: `parsed` is the feedparser result and
etag/last_modified are the new validators ('' when the server sent none).
status 304: unchanged since the validators that were sent, parsed is None.
final_url is where the content came from after all redirects;
permanent_url is the requested URL after following only the 301/308 hops
at the start of the redirect chain (== the requested URL without one)."""
status: int
parsed: object = None
etag: str = ''
last_modified: str = ''
final_url: str = ''
permanent_url: str = ''
PERMANENT_REDIRECTS = (301, 308)
def _fetch_feed(url, etag='', last_modified=''):
"""Download and parse a feed (with a timeout, unlike feedparser's own
fetch). With etag/last_modified it is a conditional GET. Returns a
FeedFetch; raises FeedFetchError when there is nothing usable."""
headers = {'User-Agent': 'diora podcast refresh'}
if etag:
headers['If-None-Match'] = etag
if last_modified:
headers['If-Modified-Since'] = last_modified
try:
r = requests.get(url, timeout=FEED_TIMEOUT, headers=headers)
except requests.Timeout:
raise FeedFetchError('timeout')
except requests.TooManyRedirects:
raise FeedFetchError('too many redirects')
except requests.ConnectionError:
raise FeedFetchError('connection failed')
except requests.RequestException as e:
raise FeedFetchError(f'request failed ({type(e).__name__})')
# Each hop's target is the next hop's URL (the last one's is r.url).
permanent_url = url
targets = [h.url for h in r.history[1:]] + [r.url]
for hop, target in zip(r.history, targets):
if hop.status_code not in PERMANENT_REDIRECTS:
break
permanent_url = target
if r.status_code == 304:
if not (etag or last_modified):
raise FeedFetchError('HTTP 304') # nothing to be unchanged from
return FeedFetch(304, None, etag, last_modified, r.url, permanent_url)
if r.status_code >= 400:
raise FeedFetchError(f'HTTP {r.status_code}')
parsed = feedparser.parse(r.content, response_headers={'content-location': r.url, **r.headers})
if parsed.get('bozo') and not parsed.entries and not parsed.feed.get('title'):
raise FeedFetchError('not a feed')
new_etag = r.headers.get('ETag', '')
new_modified = r.headers.get('Last-Modified', '')
return FeedFetch(
r.status_code, parsed,
# A validator too long to store is dropped (a truncated one never matches).
new_etag if len(new_etag) <= 500 else '',
new_modified if len(new_modified) <= 100 else '',
r.url, permanent_url,
)
def _validators(feeds):
"""If-None-Match / If-Modified-Since for one download that serves all
`feeds` (several users' subscriptions of the same URL): only when every
one of them was fetched before and stored the same validators, because a
304 then skips all of them. Otherwise {} (a full response)."""
pairs = {(f.http_etag, f.http_last_modified) for f in feeds}
if len(pairs) != 1 or any(f.last_refreshed_at is None for f in feeds):
return {}
etag, last_modified = pairs.pop()
if not (etag or last_modified):
return {}
return {'etag': etag, 'last_modified': last_modified}
def _error_text(e):
if isinstance(e, FeedFetchError):
return str(e)[:500]
return (str(e) or type(e).__name__)[:500]
def _record_error(feed_obj, e):
feed_obj.last_error = _error_text(e)
feed_obj.last_error_at = timezone.now()
feed_obj.error_count += 1
try:
feed_obj.save(update_fields=['last_error', 'last_error_at', 'error_count'])
except Exception:
log.exception('could not store the refresh error of feed %s', feed_obj.pk)
ERROR_FIELDS = ['last_error', 'last_error_at', 'error_count']
def _clear_error(feed_obj):
feed_obj.last_error = ''
feed_obj.last_error_at = None
feed_obj.error_count = 0
BACKOFF_AFTER = 3 # failures in a row before the hourly job backs off
BACKOFF_MAX_HOURS = 24
def _backoff_hours(error_count):
"""Hours between hourly-job attempts for a feed failing error_count
times in a row: 2, 4, 8, 16, 24 … from the third failure on."""
if error_count < BACKOFF_AFTER:
return 0
return min(2 ** (error_count - 2), BACKOFF_MAX_HOURS)
def _in_backoff_q(now=None):
"""Q for feeds that failed too often to be tried again yet."""
now = now or timezone.now()
n = BACKOFF_AFTER
while _backoff_hours(n) < BACKOFF_MAX_HOURS:
n += 1
# From n failures on the wait is capped; below, one term per count.
q = Q(error_count__gte=n, last_error_at__gt=now - timedelta(hours=BACKOFF_MAX_HOURS))
for count in range(BACKOFF_AFTER, n):
q |= Q(error_count=count, last_error_at__gt=now - timedelta(hours=_backoff_hours(count)))
return q
def _fetch_feed(url):
"""Download and parse a feed (with a timeout, unlike feedparser's own fetch)."""
r = requests.get(url, timeout=FEED_TIMEOUT, headers={'User-Agent': 'diora podcast refresh'})
r.raise_for_status()
return feedparser.parse(r.content, response_headers={'content-location': r.url, **r.headers})
def _refresh_feeds(feeds, chunk=32):
@ -416,31 +229,27 @@ def _refresh_feeds(feeds, chunk=32):
refreshed = failed = new = 0
for start in range(0, len(feeds), chunk):
part = feeds[start:start + chunk]
by_url = {}
for f in part:
by_url.setdefault(f.rss_url, []).append(f)
urls = sorted({f.rss_url for f in part})
results = {}
def fetch(url):
try:
return url, _fetch_feed(url, **_validators(by_url[url])), None
return url, _fetch_feed(url), None
except Exception as e: # network, HTTP or parser trouble: report and go on
return url, None, e
urls = sorted(by_url)
with ThreadPoolExecutor(max_workers=min(FEED_WORKERS, len(urls) or 1)) as pool:
for url, fetched, err in pool.map(fetch, urls):
results[url] = (fetched, err)
for url, parsed, err in pool.map(fetch, urls):
results[url] = (parsed, err)
for feed in part:
fetched, err = results[feed.rss_url]
parsed, err = results[feed.rss_url]
if err is not None:
failed += 1
log.warning('refreshing %s failed: %s', feed.rss_url, err)
_record_error(feed, err)
continue
try:
new += _refresh_feed(feed, fetched)
new += _refresh_feed(feed, parsed)
refreshed += 1
except Exception as e:
failed += 1
@ -448,47 +257,10 @@ def _refresh_feeds(feeds, chunk=32):
return refreshed, failed, new
def _refresh_feed(feed_obj, fetched=None):
"""Fetch rss_url (unless `fetched`, a FeedFetch, is given) and upsert
episodes. Returns count of new episodes. A failure is stored on the feed
(last_error, error_count) and raised again."""
if fetched is None:
try:
fetched = _fetch_feed(feed_obj.rss_url, **_validators([feed_obj]))
except Exception as e:
_record_error(feed_obj, e)
raise
try:
return _apply_feed(feed_obj, fetched)
except Exception as e:
_record_error(feed_obj, e)
raise
def _move_to_permanent_url(feed_obj, new_url):
"""The feed answered with a permanent redirect: subscribe to the new URL,
unless the user already has that one too (then nothing changes)."""
if not new_url or new_url == feed_obj.rss_url or len(new_url) > 1000:
return
try:
with transaction.atomic():
PodcastFeed.objects.filter(pk=feed_obj.pk).update(rss_url=new_url)
except IntegrityError: # the user's other subscription has it
return
log.info('feed %s moved permanently: %s -> %s', feed_obj.pk, feed_obj.rss_url, new_url)
feed_obj.rss_url = new_url
def _apply_feed(feed_obj, fetched):
now = timezone.now()
if fetched.status == 304:
# Unchanged since the last full download: nothing to parse or write.
feed_obj.last_refreshed_at = now
_clear_error(feed_obj)
feed_obj.save(update_fields=['last_refreshed_at', *ERROR_FIELDS])
return 0
parsed = fetched.parsed
def _refresh_feed(feed_obj, parsed=None):
"""Parse rss_url and upsert episodes. Returns count of new episodes."""
if parsed is None:
parsed = _fetch_feed(feed_obj.rss_url)
channel = parsed.feed
# The first fetch of a new subscription brings the whole back catalogue.
first_fetch = feed_obj.last_refreshed_at is None
@ -506,7 +278,7 @@ def _apply_feed(feed_obj, fetched):
or image.get('url', '')
)[:1000]
feed_obj.last_refreshed_at = now
feed_obj.last_refreshed_at = timezone.now()
feed_obj.save()
max_ep = getattr(settings, 'PODCAST_MAX_EPISODES_PER_FEED', 200)
@ -609,20 +381,7 @@ def _apply_feed(feed_obj, fetched):
)
new_episodes_list = newest_first[:NEW_ON_SUBSCRIBE]
# The feed's own rules for its new episodes: those its filter turns down,
# and all of them for 'ignore', go straight out of the inbox (not played);
# 'queue' puts them in the queue (which also takes them out of the inbox);
# 'inbox' leaves them there.
if new_episodes_list:
unwanted = [e for e in new_episodes_list if not feed_obj.matches_filter(e.title, e.duration_seconds)]
if feed_obj.new_episodes == PodcastFeed.NEW_IGNORE:
unwanted = new_episodes_list
if unwanted:
gone = {e.id for e in unwanted}
_leave_inbox(feed_obj.user, list(gone))
new_episodes_list = [e for e in new_episodes_list if e.id not in gone]
if feed_obj.new_episodes == PodcastFeed.NEW_QUEUE and new_episodes_list:
if feed_obj.auto_queue and new_episodes_list:
where = PodcastSettings.for_user(feed_obj.user).auto_queue_position
# Newest first in front of what's there; oldest first when appended,
# so the end of the queue plays in the order the episodes came out.
@ -634,13 +393,6 @@ def _apply_feed(feed_obj, fetched):
ordered = newest_first if where != PodcastSettings.BACK else newest_first[::-1]
_enqueue(feed_obj.user, [e.id for e in ordered], where)
# Only now, with every episode written: the validators (a 304 next time
# skips all of the above) and a clean error state.
feed_obj.http_etag = fetched.etag
feed_obj.http_last_modified = fetched.last_modified
_clear_error(feed_obj)
feed_obj.save(update_fields=['http_etag', 'http_last_modified', *ERROR_FIELDS])
_move_to_permanent_url(feed_obj, fetched.permanent_url)
return new_count
@ -687,30 +439,16 @@ def feed_list(request):
return JsonResponse({'feeds': _feeds_data(request.user)})
FEED_SETTING_FIELDS = (
'playback_speed', 'skip_intro', 'skip_outro', 'volume',
'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_queue',
)
def _feed_settings(feed):
return {'feed_id': feed.id, **{name: getattr(feed, name) for name in FEED_SETTING_FIELDS}}
def _feeds_data(user):
feeds = list(
user.podcast_feeds
.annotate(latest_episode_at=Max('episodes__pub_date'))
.values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author', 'added_at',
'latest_episode_at', 'last_error', 'last_error_at', 'error_count', *FEED_SETTING_FIELDS)
.values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author', 'added_at', 'auto_queue', 'latest_episode_at')
)
for f in feeds:
f['feed_id'] = f['id'] # as in feeds/<id>/settings/
if f['last_refreshed_at']:
f['last_refreshed_at'] = f['last_refreshed_at'].isoformat()
if f['last_error_at']:
f['last_error_at'] = f['last_error_at'].isoformat()
if f['added_at']:
f['added_at'] = f['added_at'].isoformat()
if f['latest_episode_at']:
@ -796,99 +534,11 @@ def set_auto_queue(request, pk):
feed = PodcastFeed.objects.get(pk=pk, user=request.user)
except PodcastFeed.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
# On: new episodes go to the queue; off: back to the inbox (a feed set
# to 'ignore' stays so). PodcastFeed.save() sets new_episodes to match.
feed.auto_queue = bool(body.get('auto_queue', not feed.auto_queue))
feed.save(update_fields=['auto_queue'])
return JsonResponse({'ok': True, 'auto_queue': feed.auto_queue})
# ---------------------------------------------------------------------------
# Per-feed settings
# ---------------------------------------------------------------------------
def _number(value):
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _whole(value, low, high):
if not _number(value) or value != int(value) or not low <= value <= high:
raise ValueError
return int(value)
def _speed(value):
if value is None:
return None
if not _number(value) or not 0.5 <= value <= 3.0:
raise ValueError
return float(value)
def _volume(value):
if not _number(value) or float(value) not in PodcastFeed.VOLUMES:
raise ValueError
return float(value)
def _new_episodes(value):
if value not in {key for key, _ in PodcastFeed.NEW_EPISODE_ACTIONS}:
raise ValueError
return value
def _word_list(value):
if value is None:
return ''
if not isinstance(value, str) or len(value.strip()) > 500:
raise ValueError
return value.strip()
FEED_SETTING_PARSERS = {
'playback_speed': _speed,
'skip_intro': lambda v: _whole(v, 0, 600),
'skip_outro': lambda v: _whole(v, 0, 600),
'volume': _volume,
'new_episodes': _new_episodes,
'include_words': _word_list,
'exclude_words': _word_list,
'min_duration': lambda v: _whole(v, 0, 600),
}
@csrf_exempt
@require_http_methods(['POST'])
def feed_settings(request, pk):
"""Change some of one feed's settings (any of FEED_SETTING_PARSERS);
returns all of them. Nothing is changed if one value is invalid."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
body = json.loads(request.body or b'{}')
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
if not isinstance(body, dict):
return JsonResponse({'error': 'invalid JSON'}, status=400)
try:
feed = PodcastFeed.objects.get(pk=pk, user=request.user)
except PodcastFeed.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
changes = {}
for name, parse in FEED_SETTING_PARSERS.items():
if name in body:
try:
changes[name] = parse(body[name])
except (ValueError, TypeError, OverflowError):
return JsonResponse({'error': f'invalid {name}', 'field': name}, status=400)
if changes:
for name, value in changes.items():
setattr(feed, name, value)
feed.save(update_fields=list(changes))
return JsonResponse(_feed_settings(feed))
# ---------------------------------------------------------------------------
# Feed episodes
# ---------------------------------------------------------------------------
@ -952,29 +602,20 @@ def feed_episodes(request, pk):
# ---------------------------------------------------------------------------
def _load_chapters(episode):
"""Fetch the chapters the feed doesn't carry inline, once, and cache them
on `episode`: the JSON at chapters_url, else (no URL, or nothing there)
the CHAP frames in the ID3v2 tag at the start of the audio file.
"""Fetch the JSON chapters of `episode` once and cache them on it.
Problems that may go away (timeouts, server errors) aren't cached, so the
next request tries again; anything else is remembered as "no chapters".
"""
url = episode.chapters_url
chapters = []
try:
if url:
try:
chapters = chapters_mod.fetch(url)
except chapters_mod.ChaptersInvalid as e:
log.info('chapters of episode %s unusable: %s', episode.id, e)
if not chapters and episode.audio_url:
try:
chapters = chapters_mod.fetch_id3(episode.audio_url)
except chapters_mod.ChaptersInvalid as e:
log.info('ID3 chapters of episode %s unusable: %s', episode.id, e)
except chapters_mod.ChaptersUnavailable as e:
log.info('chapters of episode %s not available: %s', episode.id, e)
return []
except chapters_mod.ChaptersInvalid as e:
log.info('chapters of episode %s unusable: %s', episode.id, e)
chapters = []
now = timezone.now()
# Only if a refresh hasn't changed the URL in the meantime.
PodcastEpisode.objects.filter(pk=episode.pk, chapters_url=url).update(
@ -995,7 +636,7 @@ def episode_detail(request, pk):
return JsonResponse({'error': 'not found'}, status=404)
chapters = ep.chapters
if not chapters and ep.chapters_fetched_at is None:
if not chapters and ep.chapters_url and ep.chapters_fetched_at is None:
chapters = _load_chapters(ep)
progress = EpisodeProgress.objects.filter(user=request.user, episode=ep).first()
@ -1006,7 +647,6 @@ def episode_detail(request, pk):
'timestamps': extract_timestamps(ep.description, ep.duration_seconds),
'chapters': chapters,
'transcripts': ep.transcripts,
'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,
'artwork_url': ep.artwork_url,
@ -1022,65 +662,6 @@ def episode_detail(request, pk):
})
def _transcript_sources(ep):
return [t['url'] for t in transcripts_mod.candidates(ep.transcripts)]
def _has_transcript(ep):
"""True if the episode offers a transcript we can read, and it hasn't
turned out to be unusable."""
sources = _transcript_sources(ep)
if not sources:
return False
cached = EpisodeTranscript.objects.filter(episode=ep).values_list('sources', 'error').first()
return not (cached and cached[0] == sources and cached[1])
@csrf_exempt
@require_http_methods(['GET'])
def episode_transcript(request, pk):
"""The episode's transcript as {"segments": [{start, end, speaker, text}],
"source": {url, type, language}}, fetched and parsed on first use, then
kept. 404 {"error": "no transcript" | "unsupported" | "invalid"} when
there's none to show; 502 {"error": "unavailable"} when it couldn't be
loaded right now."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
ep = PodcastEpisode.objects.get(pk=pk, feed__user=request.user)
except PodcastEpisode.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
if not ep.transcripts:
return JsonResponse({'error': 'no transcript'}, status=404)
sources = _transcript_sources(ep)
if not sources:
return JsonResponse({'error': 'unsupported'}, status=404)
cached = EpisodeTranscript.objects.filter(episode=ep).first()
if cached is not None and cached.sources == sources:
data, error = cached.data, cached.error
else:
try:
data, final = transcripts_mod.load(ep.transcripts)
error = ''
except chapters_mod.FetchUnavailable as e:
log.info('transcript of episode %s not available: %s', ep.id, e)
return JsonResponse({'error': 'unavailable'}, status=502)
except transcripts_mod.Unsupported as e:
log.info('transcript of episode %s unsupported: %s', ep.id, e)
data, error, final = None, 'unsupported', True
except chapters_mod.FetchInvalid as e:
log.info('transcript of episode %s unusable: %s', ep.id, e)
data, error, final = None, 'invalid', True
if final:
EpisodeTranscript.objects.update_or_create(
episode=ep, defaults={'sources': sources, 'data': data, 'error': error},
)
if error:
return JsonResponse({'error': error}, status=404)
return JsonResponse(data)
# ---------------------------------------------------------------------------
# Favorites
# ---------------------------------------------------------------------------
@ -1376,8 +957,7 @@ def refresh_feed_now(request):
def refresh_all_feeds(request):
"""Refresh all of the user's feeds on the server in one go. Feeds the
hourly job (or an earlier call) refreshed within `max_age` seconds are
skipped. Clients then fetch the result once (state/, inbox/ …).
The user asked, so feeds the hourly job backs off from are tried too."""
skipped. Clients then fetch the result once (state/, inbox/ …)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
@ -1409,8 +989,7 @@ def podcast_state(request):
'feeds': _feeds_data(request.user),
'queue': _queue_data(request.user),
'inbox': _inbox_data(request.user, 0, limit, with_description=False),
'settings': {'enqueue_position': s.enqueue_position, 'auto_queue_position': s.auto_queue_position,
'keep_sorted': s.keep_sorted},
'settings': {'enqueue_position': s.enqueue_position, 'auto_queue_position': s.auto_queue_position},
'server_time': timezone.now().isoformat(),
})
@ -1522,8 +1101,6 @@ def queue_reorder(request):
order = body.get('order', []) # list of episode ids
for pos, ep_id in enumerate(order):
PodcastQueue.objects.filter(user=request.user, episode_id=ep_id).update(position=pos)
# A queue that is kept sorted goes straight back into its order.
_keep_sorted(request.user)
return JsonResponse({'ok': True})
@ -1546,21 +1123,13 @@ def podcast_settings(request):
return JsonResponse({'error': f'unknown {name}'}, status=400)
setattr(s, name, body[name])
fields.append(name)
if 'keep_sorted' in body:
if body['keep_sorted'] not in KEEP_SORTED:
return JsonResponse({'error': 'unknown keep_sorted'}, status=400)
s.keep_sorted = body['keep_sorted']
fields.append('keep_sorted')
if fields:
s.save(update_fields=fields)
if 'keep_sorted' in fields:
_keep_sorted(request.user, s.keep_sorted) # sorted right away
elif request.method != 'GET':
return JsonResponse({'error': 'method not allowed'}, status=405)
return JsonResponse({
'enqueue_position': s.enqueue_position,
'auto_queue_position': s.auto_queue_position,
'keep_sorted': s.keep_sorted,
'positions': [{'value': k, 'label': v} for k, v in PodcastSettings.POSITIONS],
})

View file

@ -1,40 +0,0 @@
from pathlib import Path
from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase
class PlayerPagePodcastsTests(TestCase):
"""The podcast tab is the app's podcast page (static/podcasts/, copied from
diora-android) on a browser host (static/js/podcasts-web.js)."""
def test_logged_in_page_loads_the_podcast_host(self):
user = User.objects.create_user('pod', password='pw-for-tests-only')
self.client.force_login(user)
html = self.client.get('/').content.decode()
self.assertIn('id="tab-podcasts"', html)
self.assertIn('<script src="/static/js/podcasts-web.js"></script>', html)
self.assertIn('dpw-loading', html)
# The old podcast tab and its player bar are gone.
self.assertNotIn('podcast-toolbar', html)
self.assertNotIn('podcast-seek-bar', html)
self.assertNotIn('INITIAL_PODCAST_FEEDS', html)
def test_logged_out_page_asks_to_log_in(self):
html = self.client.get('/').content.decode()
self.assertIn('to subscribe to podcasts.', html)
self.assertNotIn('dpw-loading', html)
def test_podcast_ui_files_are_there(self):
static = Path(settings.BASE_DIR) / 'static'
host = (static / 'js' / 'podcasts-web.js').read_text(encoding='utf-8')
for name in ('native-podcast.js', 'podcast-ui.css', 'podcasts-web.css'):
path = static / 'podcasts' / name
self.assertTrue(path.exists(), name)
if name != 'podcasts-web.css':
self.assertIn('Copied from diora-android', path.read_text(encoding='utf-8')[:300])
self.assertIn('/static/podcasts/' + name, host)
sw = (static / 'js' / 'sw.js').read_text(encoding='utf-8')
for path in ('/static/js/podcasts-web.js', '/static/podcasts/native-podcast.js'):
self.assertIn(path, sw)

View file

@ -63,6 +63,13 @@ def index(request):
)
)
initial_podcast_feeds = []
if request.user.is_authenticated:
from podcasts.models import PodcastFeed
initial_podcast_feeds = list(
request.user.podcast_feeds.values('id', 'title', 'artwork_url', 'rss_url')
)
focus_station = None # null in JS means "never configured, use default"
encrypted_bg = {}
if request.user.is_authenticated:
@ -84,6 +91,7 @@ def index(request):
'amazon_enabled': settings.AMAZON_AFFILIATE_ENABLED,
'featured_stations': featured,
'featured_stations_json': json.dumps(featured, cls=DjangoJSONEncoder),
'initial_podcast_feeds': json.dumps(initial_podcast_feeds, cls=DjangoJSONEncoder),
'focus_station': focus_station,
'focus_station_json': json.dumps(focus_station, cls=DjangoJSONEncoder),
'encrypted_bg': encrypted_bg,

View file

@ -1251,6 +1251,241 @@ body.dnd-mode .timer-display {
.volume-num::-webkit-outer-spin-button { display: none; }
.volume-num { -moz-appearance: textfield; }
/* ===== PODCAST COMPONENTS ===== */
.podcast-seek-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 0 12px;
flex: 1;
min-width: 0;
position: relative;
}
.skip-btn {
font-size: 0.72rem;
opacity: 0.8;
white-space: nowrap;
padding: 2px 4px;
}
.skip-btn:hover { opacity: 1; }
.speed-btns {
display: flex;
gap: 2px;
margin-left: 4px;
flex-shrink: 0;
}
.speed-btn {
background: none;
border: 1px solid var(--border, #444);
color: var(--fg, #fff);
border-radius: 3px;
font-size: 0.68rem;
padding: 1px 4px;
cursor: pointer;
opacity: 0.6;
white-space: nowrap;
}
.speed-btn:hover { opacity: 1; }
.speed-btn.active {
opacity: 1;
border-color: var(--accent, #e63946);
color: var(--accent, #e63946);
}
.seek-slider {
flex: 1;
accent-color: var(--accent, #e63946);
cursor: pointer;
height: 4px;
}
.seek-time {
font-size: 0.75rem;
color: var(--text-muted, #888);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.podcast-toolbar {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 0 12px;
border-bottom: 1px solid var(--border, #333);
margin-bottom: 10px;
align-items: center;
}
.podcast-pane { }
.podcast-feed-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.podcast-feed-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
border-bottom: 1px solid var(--border, #222);
}
.podcast-feed-info {
flex: 1;
min-width: 0;
}
.podcast-feed-title {
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.podcast-feed-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.podcast-thumb {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.podcast-thumb-lg {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
.podcast-thumb-placeholder {
width: 48px;
height: 48px;
background: var(--surface, #1e1e2e);
border-radius: 4px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.podcast-unplayed-badge {
background: var(--accent, #e63946);
color: #fff;
border-radius: 99px;
padding: 1px 7px;
font-size: 0.7rem;
margin-left: 6px;
}
.episode-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.episode-item {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
border-bottom: 1px solid var(--border, #222);
}
.episode-item.episode-played {
opacity: 0.5;
}
.episode-info {
flex: 1;
min-width: 0;
}
.episode-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.9rem;
}
.episode-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.podcast-search-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
}
.podcast-search-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
border-bottom: 1px solid var(--border, #222);
}
.podcast-search-info {
flex: 1;
min-width: 0;
}
.podcast-queue-ol {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.podcast-feed-header {
margin-bottom: 12px;
}
.podcast-feed-header-inner {
display: flex;
align-items: center;
gap: 14px;
}
.feed-refresh-btn { margin-left: auto; flex-shrink: 0; }
/* Clickable episode title (episode list) */
.ep-clickable {
cursor: pointer;
}
.ep-clickable:hover {
color: var(--accent, #e63946);
text-decoration: underline;
}
/* Clickable episode title / feed name in the now-playing bar */
.podcast-track-link,
.podcast-station-link {
cursor: pointer;
}
.podcast-track-link:hover,
.podcast-station-link:hover {
color: var(--accent, #e63946);
text-decoration: underline;
}
/* ===== SIDEBAR ===== */
.sidebar-overlay {
@ -1353,6 +1588,90 @@ body.dnd-mode .timer-display {
margin: 12px 0 4px;
}
/* Inbox toolbar + checkboxes */
.inbox-toolbar {
display: flex; align-items: center; gap: 8px;
padding: 0 0 8px; flex-wrap: wrap; min-height: 32px;
}
.inbox-select-all-label {
display: flex; align-items: center; gap: 4px;
font-size: 0.82rem; color: var(--text-muted, #888); cursor: pointer;
white-space: nowrap;
}
.inbox-bulk-actions {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
}
.inbox-load-more {
display: flex; align-items: center; gap: 6px; margin-left: auto;
}
.inbox-checkbox-label {
display: flex; align-items: center; flex-shrink: 0;
padding-right: 4px; cursor: pointer;
}
.inbox-checkbox-label input[type="checkbox"] {
width: 15px; height: 15px; cursor: pointer; accent-color: var(--accent, #e63946);
}
.episode-item.inbox-selected { background: var(--surface-alt, #2a2a3e); }
/* Sleep timer */
.sleep-timer-btn {
font-size: 0.68rem; padding: 1px 5px; white-space: nowrap;
border: 1px solid var(--border, #444); border-radius: 3px; opacity: 0.7;
}
.sleep-timer-btn:hover { opacity: 1; }
.sleep-timer-menu {
position: absolute; background: var(--surface, #1e1e2e);
border: 1px solid var(--border, #444); border-radius: 6px; padding: 4px;
display: flex; flex-direction: column; gap: 2px; z-index: 200;
bottom: calc(100% + 4px); right: 0;
}
.sleep-timer-option {
background: none; border: none; color: var(--fg, #fff);
padding: 5px 12px; text-align: left; cursor: pointer;
font-size: 0.85rem; border-radius: 4px;
}
.sleep-timer-option:hover { background: var(--accent, #e63946); color: #fff; }
/* Episode filter */
.episode-search-bar { padding: 6px 0 8px; }
.episode-search-bar .search-input { width: 100%; box-sizing: border-box; }
/* Episode meta line */
.episode-meta {
display: flex; gap: 8px; font-size: 0.78rem;
color: var(--text-muted, #888); margin-top: 2px; flex-wrap: wrap;
}
.episode-date { font-weight: 500; color: var(--fg, #ccc); }
.episode-feed-link { cursor: pointer; }
.episode-feed-link:hover { text-decoration: underline; color: var(--accent, #e63946); }
.episode-dur { color: var(--text-muted, #888); }
/* Episode progress bar */
.episode-progress-bar {
height: 3px; background: var(--border, #333);
border-radius: 2px; margin-top: 5px; overflow: hidden;
}
.episode-progress-fill {
height: 100%; background: var(--accent, #e63946); border-radius: 2px;
}
/* Queue drag-and-drop */
.drag-handle { cursor: grab; color: var(--text-muted, #666); padding: 0 6px; user-select: none; }
.drag-handle:active { cursor: grabbing; }
#podcast-queue-ol li.dragging { opacity: 0.4; border: 1px dashed var(--accent, #e63946); }
/* Feed list toolbar */
.feed-list-toolbar {
display: flex; gap: 8px; align-items: center;
padding: 0 0 10px; flex-wrap: wrap;
}
.feed-list-toolbar .search-input { flex: 1; min-width: 140px; }
.feed-sort-select {
background: var(--surface, #1e1e2e); color: var(--fg, #fff);
border: 1px solid var(--border, #444); border-radius: 4px;
padding: 4px 6px; font-size: 0.82rem; cursor: pointer;
}
.tts-lang-select {
background: var(--surface, #1e1e2e); color: var(--fg, #fff);
border: 1px solid var(--border, #444); border-radius: 4px;
@ -1363,6 +1682,11 @@ body.dnd-mode .timer-display {
@media (max-width: 600px) {
.sidebar { width: 100vw; }
.podcast-seek-bar { padding: 0 6px; }
.podcast-thumb { width: 40px; height: 40px; }
.podcast-thumb-lg { width: 60px; height: 60px; }
.podcast-feed-actions { flex-direction: column; }
.episode-actions { flex-direction: column; }
/* Reader auf Mobile: Header kompakter, Fortschrittsfeld versteckt */
.reader-header { padding: 6px 8px; gap: 4px; }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -2,14 +2,11 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v52';
const CACHE = 'diora-v51';
const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [
'/static/css/app.css',
'/static/js/app.js',
'/static/js/podcasts-web.js',
'/static/podcasts/native-podcast.js',
'/static/podcasts/podcast-ui.css',
'/static/podcasts/podcasts-web.css',
'/static/manifest.json',
'/static/fonts/SourceSerif4-normal-latin.woff2',
'/static/fonts/SourceSerif4-normal-latin-ext.woff2',
@ -28,13 +25,12 @@ self.addEventListener('install', function (event) {
self.skipWaiting();
});
// Activate: remove stale caches from previous versions (also the old
// 'diora-podcast-v1' of episodes the web app used to download)
// Activate: remove stale caches from previous versions
self.addEventListener('activate', function (event) {
event.waitUntil(
caches.keys().then(function (keys) {
return Promise.all(
keys.filter(function (key) { return key !== CACHE; })
keys.filter(function (key) { return key !== CACHE && key !== PODCAST_CACHE; })
.map(function (key) { return caches.delete(key); })
);
}).then(function () {
@ -72,6 +68,21 @@ self.addEventListener('fetch', function (event) {
return;
}
// Podcast audio: serve from podcast cache first, then network
if (event.request.destination === 'audio') {
event.respondWith(
caches.open(PODCAST_CACHE).then(function (cache) {
return cache.match(event.request).then(function (cached) {
if (cached) return cached;
return fetch(event.request).then(function (response) {
return response;
});
});
})
);
return;
}
// Cache-first for pre-defined shell assets
const isShell = SHELL.some(function (s) { return url.pathname === s; });
if (isShell) {

File diff suppressed because it is too large Load diff

View file

@ -1,529 +0,0 @@
/* Copied from diora-android 62f3a69 2026-10-02: 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. */
/* ---- bottom sheet ---------------------------------------------------------------- */
#nui-sheet {
position: fixed;
inset: 0;
z-index: 900;
}
.nui-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
}
.nui-panel {
position: absolute;
left: 0;
right: 0;
bottom: 0;
max-height: 80vh;
overflow-y: auto;
background: var(--nui-panel);
border-top: 1px solid var(--border);
border-radius: 12px 12px 0 0;
padding: 8px 0 12px;
animation: nui-up 0.18s ease-out;
}
@keyframes nui-up {
from { transform: translateY(40px); opacity: 0; }
to { transform: none; opacity: 1; }
}
.nui-sheet-title {
padding: 10px 20px 8px;
font-size: 12px;
font-weight: 400;
color: var(--nui-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nui-row,
.nui-row-main {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
min-height: 52px;
padding: 6px 20px;
background: none;
border: none;
color: var(--fg);
font: inherit;
font-size: 15px;
text-align: left;
cursor: pointer;
}
.nui-row:active,
.nui-row-main:active {
background: #1d1d1d;
}
.nui-row-icon {
width: 24px;
text-align: center;
flex-shrink: 0;
font-size: 18px;
}
.nui-row-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.nui-row-hint {
font-size: 12px;
font-weight: 400;
color: var(--nui-muted);
}
.nui-danger { color: var(--accent); }
.nui-active .nui-row-label { color: var(--accent); }
.nui-row-split {
padding: 0 16px 0 0;
}
.nui-row-split .nui-row-main {
flex: 1;
width: auto;
}
.nui-seg {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
}
.nui-seg button {
min-width: 44px;
min-height: 34px;
background: none;
border: none;
color: var(--nui-muted);
font: inherit;
}
.nui-seg button.nui-seg-on {
background: var(--fg);
color: var(--bg);
}
/* ---- podcasts (phones only: html.nui-podcasts, see native-podcast.js) ------------ */
html.nui-podcasts #tab-podcasts > :not(#nui-pod) { display: none !important; }
html.nui-pod-playing #nui-mini { display: none !important; }
html.nui-radio.nui-pod-mini-open body {
padding-bottom: calc(124px + env(safe-area-inset-bottom, 0px));
}
#nui-pod-bar {
display: flex;
align-items: center;
gap: 2px;
margin: 0 -4px 8px 0;
}
#nui-pod-bar .native-brand { font-size: 22px; font-weight: 700; letter-spacing: 0.04em; }
.nui-pod-status { font-size: 12px; font-weight: 400; color: var(--nui-muted); }
.nui-pod-tabs {
display: flex;
margin: 0 0 8px;
border-bottom: 1px solid var(--border);
overflow-x: auto;
scrollbar-width: none;
}
.nui-pod-tab {
flex: 1 0 auto;
min-height: var(--nui-tap);
padding: 0 10px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--nui-muted);
font: inherit;
font-size: 14px;
white-space: nowrap;
}
.nui-pod-tab.active { color: inherit; font-weight: 700; border-bottom-color: var(--accent); }
.nui-pod-count {
min-width: 20px;
padding: 1px 6px;
border-radius: 10px;
background: #2a2a2a;
font-size: 11px;
font-weight: 700;
text-align: center;
}
.nui-pod-tab.active .nui-pod-count { background: var(--accent); color: #fff; }
.nui-pod-summary { padding: 6px 2px 10px; font-size: 12px; color: var(--nui-muted); }
.nui-pod-empty { padding: 32px 12px; color: var(--nui-muted); text-align: center; line-height: 1.5; }
.nui-pod-list { display: flex; flex-direction: column; }
.nui-pod-row {
display: flex;
align-items: center;
gap: 12px;
min-height: 72px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
background: var(--bg);
-webkit-tap-highlight-color: transparent;
}
.nui-pod-row:active { background: #141414; }
.nui-pod-row.nui-pod-played .nui-pod-title { color: var(--nui-muted); font-weight: 400; }
.nui-pod-row.nui-pod-current .nui-pod-title { color: var(--accent); }
.nui-pod-row.nui-pod-current .nui-pod-title::before { content: '▶ '; }
.nui-pod-art {
flex: none;
width: 52px;
height: 52px;
border-radius: 4px;
overflow: hidden;
background: #1c1c1c;
}
.nui-pod-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.nui-pod-art-big { width: 72px; height: 72px; }
.nui-pod-art-mini { width: 44px; height: 44px; margin-left: 10px; }
.nui-pod-art-player { width: min(72vw, 320px); height: min(72vw, 320px); border-radius: 8px; align-self: center; }
.nui-pod-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.nui-pod-title {
font-size: 15px;
font-weight: 700;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.nui-pod-meta {
font-size: 12px;
font-weight: 400;
color: var(--nui-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nui-pod-progress { height: 3px; margin-top: 4px; border-radius: 2px; background: #2a2a2a; overflow: hidden; max-width: 160px; }
.nui-pod-progress > span { display: block; height: 100%; background: var(--accent); }
.nui-pod-badge {
flex: none;
padding: 3px 7px;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 11px;
color: var(--nui-muted);
}
.nui-pod-queued { color: var(--green); }
.nui-pod-handle {
flex: none;
width: var(--nui-tap);
height: var(--nui-tap);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: var(--nui-muted);
touch-action: none;
cursor: grab;
}
.nui-pod-queue .nui-pod-row { transition: transform 0.15s ease; }
.nui-pod-queue .nui-pod-row.nui-pod-dragging {
transition: none;
position: relative;
z-index: 2;
background: #1a1a1a;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.6);
}
.nui-pod-feedhead { display: flex; align-items: center; gap: 10px; margin: 4px 0 10px; }
.nui-pod-switch {
display: grid;
grid-template-columns: auto 1fr;
column-gap: 10px;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--border);
font-size: 14px;
}
.nui-pod-switch input { width: 20px; height: 20px; accent-color: var(--accent); }
.nui-pod-switch .nui-pod-meta { grid-column: 2; }
.nui-pod-search { display: flex; gap: 8px; margin: 4px 0 10px; }
.nui-pod-search input { flex: 1; min-width: 0; min-height: var(--nui-tap); font-size: 16px; }
.nui-pod-search .btn { min-height: var(--nui-tap); }
/* mini player in the bottom bar */
#nui-pod-mini {
position: relative;
display: flex;
align-items: center;
gap: 2px;
height: 64px;
padding: 0 4px 0 0;
border-bottom: 1px solid var(--border);
}
#nui-pod-mini[hidden] { display: none; }
.nui-pod-mini-bar { position: absolute; left: 0; right: 0; top: -1px; height: 2px; background: #2a2a2a; }
.nui-pod-mini-bar span { display: block; height: 100%; background: var(--accent); }
.nui-pod-skip { font-size: 20px; }
/* full player */
#nui-pod-player {
position: fixed;
inset: 0;
z-index: 880;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: flex-end;
}
.nui-pod-player-panel {
width: 100%;
max-height: 100%;
overflow-y: auto;
background: #0b0b0b;
border-top: 1px solid var(--border);
border-radius: 14px 14px 0 0;
padding: 8px 20px calc(24px + env(safe-area-inset-bottom, 0px));
display: flex;
flex-direction: column;
gap: 12px;
}
.nui-pod-player-top { display: flex; justify-content: center; }
.nui-pod-player-top .nui-icon { font-size: 26px; color: var(--nui-muted); }
.nui-pod-player-title { font-size: 18px; font-weight: 700; line-height: 1.3; text-align: center; }
.nui-pod-player-feed { margin-top: -6px; font-size: 13px; color: var(--nui-muted); text-align: center; }
.nui-pod-seek { width: 100%; accent-color: var(--accent); height: 28px; }
.nui-pod-times { display: flex; justify-content: space-between; margin-top: -8px; font-size: 12px; color: var(--nui-muted); font-variant-numeric: tabular-nums; }
.nui-pod-controls { display: flex; align-items: center; justify-content: center; gap: 28px; }
.nui-pod-controls .nui-icon { font-size: 18px; font-weight: 700; }
.nui-pod-back::before { content: '↺'; font-size: 26px; margin-right: 2px; }
.nui-pod-fwd::after { content: '↻'; font-size: 26px; margin-left: 2px; }
.nui-pod-play {
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--accent) !important;
color: #fff !important;
font-size: 26px !important;
}
.nui-pod-extra { display: flex; justify-content: space-around; }
.nui-pod-speed { font-size: 15px !important; font-weight: 700; min-width: 64px; }
/* e-reader mode on a phone: black on white */
body.eink .nui-pod-row { background: #fff; }
body.eink .nui-pod-row:active { background: #ddd; }
body.eink .nui-pod-count { background: #ddd; color: #000; }
body.eink .nui-pod-tab.active { border-bottom-color: #000; color: #000; }
body.eink .nui-pod-tab.active .nui-pod-count { background: #000; color: #fff; }
body.eink .nui-pod-progress { background: #ccc; }
body.eink .nui-pod-progress > span,
body.eink .nui-pod-mini-bar span { background: #000; }
body.eink .nui-pod-row.nui-pod-current .nui-pod-title { color: #000; }
body.eink .nui-pod-player-panel { background: #fff; border-top: 2px solid #000; }
body.eink .nui-pod-play { background: #000 !important; }
body.eink .nui-pod-art { background: #eee; }
/* podcast queue tools: sort, choose several, lock */
.nui-pod-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.nui-pod-head .nui-pod-summary { padding: 6px 2px; }
.nui-pod-tools { display: flex; align-items: center; margin-right: -6px; }
.nui-pod-tools .nui-icon { font-size: 18px; color: var(--nui-muted); }
.nui-pod-lock.on { color: var(--accent) !important; }
.nui-pod-selbar {
position: sticky;
top: 0;
z-index: 5;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 6px;
margin: 0 -12px 4px;
padding: 6px 12px 8px;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.nui-pod-selcount { flex: 1; min-width: 0; font-size: 14px; font-weight: 700; }
.nui-pod-selactions { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 6px; }
.nui-pod-selactions .btn { min-height: 38px; flex: 1 0 auto; }
.nui-pod-selactions .btn:disabled { opacity: 0.4; }
.nui-pod-check { flex: none; width: 28px; font-size: 22px; color: var(--nui-muted); text-align: center; }
.nui-pod-check.on { color: var(--accent); }
.nui-pod-row.nui-pod-selecting { user-select: none; -webkit-user-select: none; }
body.eink .nui-pod-check.on,
body.eink .nui-pod-lock.on { color: #000 !important; }
body.eink .nui-pod-selbar { background: #fff; }
/* podcast playback: per-podcast chips, sleep timer */
.nui-pod-feedprefs { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 0; border-bottom: 1px solid var(--border); }
.nui-pod-chip {
display: inline-flex;
align-items: baseline;
gap: 6px;
min-height: 36px;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 18px;
background: none;
color: var(--nui-muted);
font: inherit;
font-size: 13px;
}
.nui-pod-chip strong { color: inherit; font-weight: 700; }
.nui-pod-chip.on { border-color: var(--accent); color: inherit; }
.nui-pod-chip.on strong { color: var(--accent); }
.nui-pod-sleep { font-size: 15px !important; white-space: nowrap; }
.nui-pod-sleep.on { color: var(--accent) !important; font-weight: 700; }
/* unsubscribing: a quiet link, asked once more in place */
.nui-pod-unsub { display: flex; justify-content: flex-end; padding: 6px 0; border-bottom: 1px solid var(--border); }
.nui-pod-unsub-btn {
min-height: var(--nui-tap, 44px);
padding: 0 4px;
border: none;
background: none;
color: var(--nui-muted);
font: inherit;
font-size: 13px;
}
.nui-pod-unsub-ask { display: block; padding: 12px 0; }
.nui-pod-unsub-ask .nui-pod-title { display: block; overflow: visible; }
.nui-pod-unsub-ask .nui-pod-meta { margin-top: 4px; line-height: 1.4; white-space: normal; overflow: visible; }
.nui-pod-unsub-actions { display: flex; gap: 8px; margin-top: 10px; }
.nui-pod-unsub-actions .btn { min-height: var(--nui-tap, 44px); }
.nui-pod-unsub-yes { color: var(--accent); border-color: var(--accent); font-weight: 700; }
body.eink .nui-pod-unsub-yes { color: #000; border-color: #000; }
body.eink .nui-pod-chip.on { border-color: #000; }
body.eink .nui-pod-chip.on strong,
body.eink .nui-pod-sleep.on { color: #000 !important; }
/* podcast chapters and shownotes */
.nui-pod-notesbox { margin-top: 6px; border-top: 1px solid var(--border); padding-top: 6px; min-height: 40px; }
.nui-pod-subtabs { display: flex; gap: 4px; margin-bottom: 6px; }
.nui-pod-subtab {
min-height: 36px;
padding: 0 12px;
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--nui-muted);
font: inherit;
font-size: 14px;
}
.nui-pod-subtab.active { color: inherit; font-weight: 700; border-bottom-color: var(--accent); }
.nui-pod-chapters { list-style: none; margin: 0; padding: 0; }
.nui-pod-chapter {
display: grid;
grid-template-columns: 4.5em 1fr;
gap: 10px;
align-items: baseline;
padding: 10px 4px;
border-bottom: 1px solid var(--border);
font-size: 14px;
cursor: pointer;
}
.nui-pod-chapter-t { color: var(--nui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.nui-pod-chapter.current { color: var(--accent); font-weight: 700; }
.nui-pod-chapter.current .nui-pod-chapter-t { color: var(--accent); }
.nui-pod-notes { font-size: 14px; line-height: 1.55; overflow-wrap: anywhere; }
.nui-pod-notes img { max-width: 100%; height: auto; }
.nui-pod-notes a { color: var(--accent); }
.nui-pod-notes .nui-pod-stamp { font-weight: 700; text-decoration: none; border-bottom: 1px dotted currentColor; }
.nui-pod-notes table { display: block; overflow-x: auto; }
.nui-pod-notes-sheet .nui-pod-player-panel { max-height: 88%; }
.nui-pod-notes-play { align-self: center; min-height: var(--nui-tap); }
body.eink .nui-pod-chapter.current,
body.eink .nui-pod-chapter.current .nui-pod-chapter-t,
body.eink .nui-pod-notes a { color: #000; }
body.eink .nui-pod-subtab.active { border-bottom-color: #000; color: #000; }
/* ---- podcast downloads (Downloads.kt; the "downloads" block of native-podcast.js) ---- */
.nui-pod-dl { min-width: 36px !important; margin-left: -6px; font-size: 22px; color: var(--nui-muted); }
.nui-pod-dl-failed { color: var(--yellow); }
/* ✓ in a ring: set apart from the plain green ✓ of "in der Warteschlange" */
.nui-pod-dl.nui-pod-dl-done {
position: relative;
font-size: 12px;
font-weight: 700;
color: var(--green);
}
.nui-pod-dl-done::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
left: 50%;
top: 50%;
margin: -11px 0 0 -11px;
border: 1px solid var(--green);
border-radius: 50%;
}
.nui-pod-dl-ring > span {
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 700;
color: var(--nui-muted);
background:
radial-gradient(closest-side, var(--bg) 78%, transparent 80% 100%),
conic-gradient(var(--accent) var(--p, 0%), #2a2a2a 0);
}
body.eink .nui-pod-dl-done { color: #000; }
body.eink .nui-pod-dl-done::after { border-color: #000; }
body.eink .nui-pod-dl-ring > span {
color: #000;
background:
radial-gradient(closest-side, #fff 78%, transparent 80% 100%),
conic-gradient(#000 var(--p, 0%), #ccc 0);
}
/* tab row scrolls sideways without a visible bar; the lock emoji in the app's greys */
.nui-pod-tabs::-webkit-scrollbar { display: none; }
.nui-pod-lock { filter: grayscale(1); opacity: 0.6; }
.nui-pod-lock.on { opacity: 1; filter: grayscale(1) brightness(1.4); }
/* episode filters: one row that scrolls sideways */
.nui-pod-filters { display: flex; gap: 6px; overflow-x: auto; padding: 6px 0 10px; scrollbar-width: none; }
.nui-pod-filters::-webkit-scrollbar { display: none; }
.nui-pod-filters .nui-pod-chip { flex: none; }
.nui-pod-more { display: block; margin: 12px auto; min-height: var(--nui-tap); }
.nui-pod-star { color: var(--yellow); }
.nui-pod-fav.on { color: var(--yellow) !important; }
.nui-pod-stat-total { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 12px 0 18px; }
.nui-pod-stat-total strong { font-size: 30px; }
.nui-pod-stat-total span { font-size: 13px; color: var(--nui-muted); }
.nui-pod-stat-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); }
.nui-pod-stat-bar { max-width: none; height: 5px; }
body.eink .nui-pod-star, body.eink .nui-pod-fav.on { color: #000 !important; }
/* feed errors, chapter navigation, transcript */
.nui-pod-feederr { font-size: 12px; color: var(--yellow); }
.nui-pod-feederr-box { padding: 8px 10px; margin: 4px 0 8px; border: 1px solid var(--yellow); border-radius: 6px; line-height: 1.4; }
.nui-pod-chapnav { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; }
.nui-pod-chapnow { flex: 1; min-width: 0; text-align: center; font-size: 13px; color: var(--nui-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nui-pod-transcript { max-height: 60vh; overflow-y: auto; padding-right: 4px; }
.nui-pod-seg { margin: 0; padding: 6px 4px; font-size: 14px; line-height: 1.5; color: var(--nui-muted); border-radius: 4px; cursor: pointer; }
.nui-pod-seg.current { color: inherit; background: #1a1a1a; }
.nui-pod-speaker { color: inherit; }
body.eink .nui-pod-feederr { color: #000; }
body.eink .nui-pod-feederr-box { border-color: #000; }
body.eink .nui-pod-seg { color: #444; }
body.eink .nui-pod-seg.current { color: #000; background: #e6e6e6; }

View file

@ -1,189 +0,0 @@
/* Podcasts in the browser (static/js/podcasts-web.js): the app's podcast page
— its styles are podcast-ui.css, copied from diora-android's native.css by
tools/sync_podcast_ui.py — set into the web layout. The app's look stays
(black, monospace, red accent); on wide screens the podcast page, its mini
player and its sheets keep to one centred column. Only loaded in browsers;
everything is scoped to html.dpw. */
html.dpw {
--nui-tap: 44px;
--nui-muted: #9a9a9a;
--nui-panel: #111;
--dpw-surface: #0b0b0b;
--dpw-hover: #111;
--dpw-col: 760px;
}
/* The placeholder shown until the page is there. */
html.nui-podcasts #tab-podcasts > :not(#nui-pod) { display: none !important; }
.dpw-loading { padding: 2rem 0; text-align: center; }
html.dpw #nui-pod {
max-width: var(--dpw-col);
margin: 0 auto;
}
/* ---- what the app has in its other native styles ---------------------------------- */
html.dpw .nui-icon {
min-width: var(--nui-tap);
min-height: var(--nui-tap);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 20px;
padding: 0;
}
html.dpw .nui-icon:hover { color: var(--fg); }
html.dpw .nui-bar-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
/* ---- the dock: mini player right above the radio bar ----------------------------- */
html.dpw #nui-dock {
position: fixed;
left: 0;
right: 0;
bottom: var(--dpw-bar-h, 72px);
z-index: 190; /* under the reader (200), the sidebar and the sheets */
max-width: var(--dpw-col);
margin: 0 auto;
}
html.dpw body.dnd-mode #nui-dock { display: none; }
html.dpw #nui-pod-mini {
background: var(--dpw-surface);
border: 1px solid var(--border);
border-bottom: none;
border-radius: 10px 10px 0 0;
overflow: hidden;
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.5);
}
html.dpw .nui-pod-mini-bar { top: 0; }
html.dpw.nui-pod-mini-open .main-content {
padding-bottom: calc(var(--dpw-bar-h, 72px) + 64px + 2rem);
}
html.dpw .nui-mini-text {
flex: 1;
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: 2px;
padding: 0 14px;
background: none;
border: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
html.dpw .nui-mini-station,
html.dpw .nui-mini-track {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
html.dpw .nui-mini-station { font-size: 15px; font-weight: 700; }
html.dpw .nui-mini-track { font-size: 12px; font-weight: 400; color: var(--nui-muted); }
html.dpw .nui-mini-track:empty { display: none; }
html.dpw .nui-mini-toggle { font-size: 22px; color: var(--accent); }
#nui-radio-toast {
position: fixed;
left: 50%;
bottom: calc(var(--dpw-bar-h, 72px) + 80px);
transform: translateX(-50%);
z-index: 950;
max-width: calc(100% - 32px);
padding: 10px 16px;
border-radius: 6px;
background: #222;
color: #fff;
font-size: 14px;
text-shadow: none;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
#nui-radio-toast.nui-show { opacity: 1; }
/* ---- a mouse: hover states the phone doesn't need --------------------------------- */
@media (hover: hover) {
html.dpw #nui-pod .nui-pod-row { cursor: pointer; }
html.dpw #nui-pod .nui-pod-row:hover { background: var(--dpw-hover); }
html.dpw .nui-row:hover { background: #1a1a1a; }
html.dpw .nui-pod-chip:hover,
html.dpw .nui-pod-tab:hover,
html.dpw .nui-pod-subtab:hover { color: var(--fg); }
html.dpw .nui-pod-chapter:hover,
html.dpw .nui-pod-seg:hover { background: #141414; }
}
html.dpw .nui-row,
html.dpw .nui-pod-tab,
html.dpw .nui-pod-chip,
html.dpw .nui-pod-subtab { cursor: pointer; }
/* ---- wide screens: sheets and the player in the column ------------------------------ */
@media (min-width: 761px) {
html.dpw .nui-panel {
margin: 0 auto;
max-width: 560px;
border: 1px solid var(--border);
border-bottom: none;
}
html.dpw #nui-pod-player { justify-content: center; }
html.dpw .nui-pod-player-panel {
max-width: var(--dpw-col);
max-height: 92vh;
border: 1px solid var(--border);
border-bottom: none;
}
html.dpw .nui-pod-art-player { width: 240px; height: 240px; }
html.dpw .nui-pod-selbar { margin: 0 0 4px; padding: 6px 0 8px; }
}
/* Phones: the dock spans the screen like in the app. */
@media (max-width: 760px) {
html.dpw #nui-pod-mini { border-left: none; border-right: none; border-radius: 0; box-shadow: none; }
html.dpw .nui-pod-selbar { margin: 0 -0.75rem 4px; padding: 6px 0.75rem 8px; }
}
/* ---- the web's light scheme (◑, body.bright-bg) ------------------------------------- */
html.dpw body.bright-bg {
--nui-muted: #555;
--nui-panel: #f4f4f4;
--dpw-surface: #f7f7f7;
--dpw-hover: #f0f0f0;
}
/* app.css resolves --text-muted (the colour of .btn-icon) on :root, so it stays
white under body.bright-bg; inside the podcast parts it follows --fg. */
html.dpw body.bright-bg #nui-pod,
html.dpw body.bright-bg #nui-dock,
html.dpw body.bright-bg #nui-sheet,
html.dpw body.bright-bg #nui-pod-player { --text-muted: var(--fg); --text: var(--fg); }
html.dpw body.bright-bg .nui-pod-player-panel { background: var(--dpw-surface); }
html.dpw body.bright-bg .nui-pod-row:active,
html.dpw body.bright-bg .nui-row:active,
html.dpw body.bright-bg .nui-row:hover,
html.dpw body.bright-bg .nui-pod-chapter:hover,
html.dpw body.bright-bg .nui-pod-seg:hover,
html.dpw body.bright-bg .nui-pod-seg.current,
html.dpw body.bright-bg .nui-pod-queue .nui-pod-row.nui-pod-dragging { background: #e8e8e8; }
html.dpw body.bright-bg .nui-pod-art { background: #e4e4e4; }
html.dpw body.bright-bg .nui-pod-count,
html.dpw body.bright-bg .nui-pod-progress,
html.dpw body.bright-bg .nui-pod-mini-bar { background: #d4d4d4; }
html.dpw body.bright-bg .nui-pod-tab.active .nui-pod-count { background: var(--accent); color: #fff; }
html.dpw body.bright-bg #nui-pod-mini { box-shadow: 0 -4px 14px rgba(0, 0, 0, 0.12); }

View file

@ -22,6 +22,23 @@
<button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)">⊙</button>
<button class="btn-icon" id="focus-station-btn" onclick="openRadioSidebar()" title="Radio">◉</button>
</div>
<div class="podcast-seek-bar" id="podcast-seek-bar" style="display:none;">
<button class="btn-icon skip-btn" onclick="skipBack()" title="Back 15s">«&thinsp;15</button>
<span class="seek-time" id="seek-current">0:00</span>
<input type="range" id="seek-slider" class="seek-slider" min="0" max="100" value="0">
<span class="seek-time" id="seek-duration">0:00</span>
<button class="btn-icon skip-btn" onclick="skipForward()" title="Forward 30s">30&thinsp;»</button>
<div class="speed-btns" id="speed-btns">
<button class="speed-btn" onclick="setPlaybackRate(0.75)">¾×</button>
<button class="speed-btn active" onclick="setPlaybackRate(1)">1×</button>
<button class="speed-btn" onclick="setPlaybackRate(1.25)">1¼×</button>
<button class="speed-btn" onclick="setPlaybackRate(1.5)">1½×</button>
<button class="speed-btn" onclick="setPlaybackRate(1.75)">1¾×</button>
<button class="speed-btn" onclick="setPlaybackRate(2)">2×</button>
<button class="speed-btn" onclick="setPlaybackRate(2.5)">2½×</button>
</div>
<button class="btn-icon sleep-timer-btn" id="sleep-timer-btn" onclick="openSleepTimerMenu()" title="Sleep timer">Sleep</button>
</div>
<div class="timer-widget" id="timer-widget">
<span class="timer-phase" id="timer-phase-label">focus</span>
<span class="timer-display" id="timer-display">25:00</span>
@ -211,8 +228,82 @@
<!-- ===== PODCASTS TAB ===== -->
<section class="tab-panel" id="tab-podcasts" style="display:none;">
{% if user.is_authenticated %}
{# Filled by static/js/podcasts-web.js with the app's podcast page (static/podcasts/). #}
<p class="muted dpw-loading">Podcasts werden geladen …</p>
<div class="podcast-toolbar">
<button class="btn btn-sm" onclick="showPodcastView('feeds')">Feeds</button>
<button class="btn btn-sm" onclick="showPodcastView('inbox')">Inbox</button>
<button class="btn btn-sm" onclick="showPodcastView('queue')">Queue</button>
<button class="btn btn-sm" onclick="podcastSearchOpen()">+ Search</button>
<button class="btn btn-sm" id="refresh-all-btn" onclick="refreshAllFeedsBtn(this)" title="Refresh all feeds">↻ All</button>
<label class="btn btn-sm" for="opml-file-input">Import OPML</label>
<input type="file" id="opml-file-input" accept=".opml,.xml" style="display:none;" onchange="importOPML(this)">
<span id="opml-status" class="muted"></span>
</div>
<!-- Search pane -->
<div class="podcast-pane" id="podcast-search-pane" style="display:none;">
<div class="search-bar">
<input type="text" id="podcast-search-input" class="search-input" placeholder="Search podcasts…"
onkeydown="if(event.key==='Enter') doPodcastSearch()">
<button class="btn" onclick="doPodcastSearch()">Search</button>
</div>
<div id="podcast-search-status" class="status-msg"></div>
<div id="podcast-search-list" class="podcast-search-list"></div>
</div>
<!-- Feeds pane -->
<div class="podcast-pane" id="podcast-feeds-pane">
<div class="feed-list-toolbar">
<input type="text" id="feed-filter-input" class="search-input" placeholder="Search subscriptions…"
oninput="filterFeeds(this.value)">
<select id="feed-sort-select" onchange="sortFeeds(this.value)" class="feed-sort-select">
<option value="alpha">A–Z</option>
<option value="alpha-desc">Z–A</option>
<option value="added">Recently added</option>
<option value="latest_episode">Most recent episode</option>
</select>
</div>
<div id="podcast-feed-list" class="podcast-feed-list">
<p class="muted">Loading…</p>
</div>
</div>
<!-- Inbox pane -->
<div class="podcast-pane" id="podcast-inbox-pane" style="display:none;">
<div class="inbox-toolbar">
<label class="inbox-select-all-label">
<input type="checkbox" id="inbox-select-all" onchange="inboxSelectAll(this.checked)">
<span>All</span>
</label>
<div class="inbox-bulk-actions" id="inbox-bulk-actions" style="display:none;">
<span id="inbox-selection-count" class="muted"></span>
<button class="btn btn-sm" onclick="inboxBulkQueueAdd()">+Queue</button>
<button class="btn btn-sm" onclick="inboxBulkMarkPlayed()">✓ Played</button>
<button class="btn btn-sm" onclick="inboxBulkDownload()">⬇ Download</button>
<button class="btn btn-sm btn-danger" onclick="inboxBulkDismiss()">✕ Dismiss</button>
</div>
</div>
<div id="podcast-inbox-list" class="episode-list"></div>
<div class="inbox-load-more" id="inbox-load-more-bar" style="display:none;">
<button class="btn btn-sm" onclick="inboxLoadMore()">Load more</button>
<span id="inbox-count-label" class="muted"></span>
</div>
</div>
<!-- Episodes pane -->
<div class="podcast-pane" id="podcast-episodes-pane" style="display:none;">
<div class="episode-search-bar" id="episode-search-bar" style="display:none;">
<input type="text" id="episode-filter-input" class="search-input"
placeholder="Filter episodes…" oninput="filterEpisodes(this.value)">
</div>
<div id="podcast-feed-header" class="podcast-feed-header"></div>
<div id="podcast-episode-list" class="episode-list"></div>
</div>
<!-- Queue pane -->
<div class="podcast-pane" id="podcast-queue-pane" style="display:none;">
<ol id="podcast-queue-ol" class="podcast-queue-ol"></ol>
</div>
{% else %}
<p class="auth-prompt">
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
@ -324,9 +415,9 @@
const INITIAL_SAVED = {{ saved_stations_json|safe }};
const INITIAL_FEATURED = {{ featured_stations_json|safe }};
const IS_AUTHENTICATED = {{ user.is_authenticated|yesno:"true,false" }};
const INITIAL_PODCAST_FEEDS = {{ initial_podcast_feeds|safe }};
window.USER_ID = {{ user.id|default:"null" }};
let USER_FOCUS_STATION = {{ focus_station_json|safe }};
</script>
<script src="/static/js/app.js"></script>
<script src="/static/js/podcasts-web.js"></script>
{% endblock %}

View file

@ -1,78 +0,0 @@
"""Copy the phone app's podcast page into the web app.
The browser shows the same podcast UI as the Android app: diora-android's
web-native/static/native-podcast.js, unchanged, on top of a browser host
(static/js/podcasts-web.js). This copies that file and the podcast parts of
native.css (the bottom sheet and everything from the podcast section on) to
static/podcasts/, with the source commit in a header comment.
Usage: python tools/sync_podcast_ui.py [path/to/diora-android]
(default: ../diora-android next to this checkout)
Afterwards bump CACHE in static/js/sw.js so browsers fetch the new files.
"""
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEST = ROOT / 'static' / 'podcasts'
# Sections of native.css the podcast page needs: (start marker, end marker or None = to the end).
CSS_SECTIONS = [
('/* ---- bottom sheet', '/* ---- reader'),
('/* ---- podcasts (phones only', None),
]
def source_line(android, rel):
"""'diora-android <commit> <date>' of the file, plus a note if it has uncommitted changes."""
def git(*args):
return subprocess.run(['git', '-C', str(android), *args], capture_output=True, text=True).stdout.strip()
line = 'diora-android ' + (git('log', '-1', '--format=%h %cs', '--', rel) or 'unknown commit')
if git('status', '--short', '--', rel):
line += ' + uncommitted changes'
return line
def cut(text, start, end):
i = text.find(start)
if i < 0:
sys.exit(f'sync_podcast_ui: {start!r} not found in native.css')
j = text.find(end, i) if end else len(text)
if j < 0:
sys.exit(f'sync_podcast_ui: {end!r} not found after {start!r} in native.css')
return text[i:j].rstrip() + '\n'
def main():
android = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT.parent / 'diora-android'
static = android / 'web-native' / 'static'
if not (static / 'native-podcast.js').exists():
sys.exit(f'sync_podcast_ui: no diora-android checkout at {android}')
DEST.mkdir(parents=True, exist_ok=True)
js_src = source_line(android, 'web-native/static/native-podcast.js')
js = (static / 'native-podcast.js').read_text(encoding='utf-8')
(DEST / 'native-podcast.js').write_text(
f'// Copied from {js_src}: web-native/static/native-podcast.js\n'
'// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android\n'
'// and copy again. The browser host is static/js/podcasts-web.js.\n\n' + js,
encoding='utf-8', newline='\n')
css_src = source_line(android, 'web-native/static/native.css')
css = (static / 'native.css').read_text(encoding='utf-8')
parts = [cut(css, start, end) for start, end in CSS_SECTIONS]
(DEST / 'podcast-ui.css').write_text(
f'/* Copied from {css_src}: the podcast parts of web-native/static/native.css\n'
' (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.\n'
' The web\'s own additions are in podcasts-web.css. */\n\n' + '\n'.join(parts),
encoding='utf-8', newline='\n')
print(f'native-podcast.js: {js_src}')
print(f'podcast-ui.css: {css_src}')
print('Now bump CACHE in static/js/sw.js.')
if __name__ == '__main__':
main()