Compare commits
9 commits
f9b3a0ce04
...
ae0785d5ef
| Author | SHA1 | Date | |
|---|---|---|---|
| ae0785d5ef | |||
| 3f7eec9c41 | |||
| 76a164709d | |||
| 5f7d4066c7 | |||
| 578b0e478b | |||
| 38d7f6c643 | |||
| 9b0848c30c | |||
| 7fdcbb4a3d | |||
| bbe5f6c5c2 |
23 changed files with 6394 additions and 1590 deletions
|
|
@ -29,7 +29,12 @@ jobs:
|
|||
/tmp/venv/bin/pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
- name: Django system check
|
||||
run: /tmp/venv/bin/python manage.py 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
|
||||
|
||||
- name: Run tests
|
||||
run: /tmp/venv/bin/python manage.py test
|
||||
|
|
|
|||
|
|
@ -1,30 +1,39 @@
|
|||
"""Podcast chapters: Podlove Simple Chapters from the feed, and Podcasting 2.0
|
||||
JSON chapters fetched from the URL a feed points to.
|
||||
"""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.
|
||||
|
||||
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 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.
|
||||
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`.
|
||||
"""
|
||||
|
||||
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://')
|
||||
|
||||
|
|
@ -114,8 +123,14 @@ class ChaptersInvalid(Exception):
|
|||
"""The chapters URL is unusable for good (blocked, missing, not chapters)."""
|
||||
|
||||
|
||||
def fetch_json(url):
|
||||
"""Download and parse the JSON at `url`.
|
||||
# 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.
|
||||
|
||||
Raises ChaptersInvalid for permanent problems and ChaptersUnavailable for
|
||||
ones that may go away (network trouble, server errors).
|
||||
|
|
@ -126,10 +141,7 @@ def fetch_json(url):
|
|||
except WebDAVInputError as e:
|
||||
raise ChaptersInvalid(str(e))
|
||||
try:
|
||||
r = requests.get(
|
||||
url, timeout=CHAPTERS_TIMEOUT, allow_redirects=False, stream=True,
|
||||
headers={'User-Agent': 'diora podcast chapters', 'Accept': 'application/json'},
|
||||
)
|
||||
r = requests.get(url, timeout=CHAPTERS_TIMEOUT, allow_redirects=False, stream=True, headers=headers)
|
||||
except requests.RequestException as e:
|
||||
raise ChaptersUnavailable(e.__class__.__name__)
|
||||
try:
|
||||
|
|
@ -139,28 +151,94 @@ def fetch_json(url):
|
|||
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:
|
||||
raise ChaptersInvalid(str(e))
|
||||
except WebDAVError as e: # transfer broke off in _read_capped
|
||||
raise ChaptersUnavailable(str(e))
|
||||
finally:
|
||||
r.close()
|
||||
try:
|
||||
return json.loads(body)
|
||||
except ValueError:
|
||||
raise ChaptersInvalid('not JSON')
|
||||
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')
|
||||
|
||||
|
||||
def fetch(url):
|
||||
"""Normalised chapters from a Podcasting 2.0 chapters URL."""
|
||||
try:
|
||||
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))
|
||||
)
|
||||
|
|
|
|||
281
podcasts/id3.py
Normal file
281
podcasts/id3.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
"""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]
|
||||
|
|
@ -1,16 +1,15 @@
|
|||
import sys
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from podcasts.models import PodcastFeed
|
||||
from podcasts.views import _refresh_feeds
|
||||
from podcasts.views import _in_backoff_q, _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')
|
||||
parser.add_argument('--feed-id', type=int, help='Refresh only this feed ID (even when backing off)')
|
||||
parser.add_argument('--limit', type=int, default=0, help='Max number of feeds to refresh')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
|
|
@ -18,6 +17,10 @@ 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']]
|
||||
|
|
|
|||
74
podcasts/migrations/0007_feed_settings.py
Normal file
74
podcasts/migrations/0007_feed_settings.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
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),
|
||||
]
|
||||
38
podcasts/migrations/0008_feed_http_cache.py
Normal file
38
podcasts/migrations/0008_feed_http_cache.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
||||
25
podcasts/migrations/0009_episode_transcript_cache.py
Normal file
25
podcasts/migrations/0009_episode_transcript_cache.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -3,6 +3,18 @@ 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)
|
||||
|
|
@ -12,7 +24,30 @@ 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')
|
||||
|
|
@ -21,6 +56,58 @@ 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')
|
||||
|
|
@ -37,9 +124,11 @@ 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 the JSON at chapters_url once it has been fetched.
|
||||
# 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.
|
||||
chapters = models.JSONField(default=list, blank=True)
|
||||
# When chapters_url was fetched into `chapters`; null = not yet.
|
||||
# When chapters were fetched into `chapters` (from chapters_url or the
|
||||
# audio file); 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)
|
||||
|
|
@ -52,6 +141,27 @@ 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
|
||||
|
|
@ -94,8 +204,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 with
|
||||
auto_queue brings new ones."""
|
||||
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."""
|
||||
BACK = 'back'
|
||||
FRONT = 'front'
|
||||
AFTER_CURRENT = 'after_current'
|
||||
|
|
@ -105,10 +215,22 @@ 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):
|
||||
|
|
|
|||
1186
podcasts/tests.py
1186
podcasts/tests.py
File diff suppressed because it is too large
Load diff
282
podcasts/transcripts.py
Normal file
282
podcasts/transcripts.py
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
"""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')
|
||||
|
|
@ -12,9 +12,11 @@ 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'),
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ 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,
|
||||
)
|
||||
|
|
@ -19,7 +21,10 @@ from django.views.decorators.http import require_http_methods
|
|||
|
||||
from . import chapters as chapters_mod
|
||||
from . import feedext
|
||||
from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings, counts_as_played
|
||||
from . import transcripts as transcripts_mod
|
||||
from .models import (
|
||||
EpisodeProgress, EpisodeTranscript, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings, counts_as_played,
|
||||
)
|
||||
from .shownotes import extract_timestamps, sanitize_html
|
||||
|
||||
feedext.install()
|
||||
|
|
@ -38,6 +43,7 @@ MAX_TRANSCRIPTS = 10
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
QUEUE_POSITIONS = {key for key, _ in PodcastSettings.POSITIONS}
|
||||
KEEP_SORTED = {key for key, _ in PodcastSettings.SORT_ORDERS}
|
||||
|
||||
|
||||
def _queue_order(user):
|
||||
|
|
@ -102,9 +108,58 @@ 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."""
|
||||
|
|
@ -179,8 +234,10 @@ 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, chapters fetched from
|
||||
chapters_url stay cached for as long as the URL stays the same.
|
||||
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.
|
||||
"""
|
||||
changed = []
|
||||
if episode.description != description:
|
||||
|
|
@ -190,8 +247,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, episode.chapters_fetched_at
|
||||
elif chapters_url and chapters_url == episode.chapters_url and episode.chapters_fetched_at:
|
||||
chapters, fetched_at = psc_chapters, None
|
||||
elif chapters_url == episode.chapters_url and episode.chapters_fetched_at:
|
||||
chapters, fetched_at = episode.chapters, episode.chapters_fetched_at
|
||||
else:
|
||||
chapters, fetched_at = [], None
|
||||
|
|
@ -214,11 +271,141 @@ EXTRA_FIELDS = ['description', 'transcripts', 'chapters_url', 'chapters', 'chapt
|
|||
# Shared feed refresh helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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})
|
||||
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 _refresh_feeds(feeds, chunk=32):
|
||||
|
|
@ -229,27 +416,31 @@ def _refresh_feeds(feeds, chunk=32):
|
|||
refreshed = failed = new = 0
|
||||
for start in range(0, len(feeds), chunk):
|
||||
part = feeds[start:start + chunk]
|
||||
urls = sorted({f.rss_url for f in part})
|
||||
by_url = {}
|
||||
for f in part:
|
||||
by_url.setdefault(f.rss_url, []).append(f)
|
||||
results = {}
|
||||
|
||||
def fetch(url):
|
||||
try:
|
||||
return url, _fetch_feed(url), None
|
||||
return url, _fetch_feed(url, **_validators(by_url[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, parsed, err in pool.map(fetch, urls):
|
||||
results[url] = (parsed, err)
|
||||
for url, fetched, err in pool.map(fetch, urls):
|
||||
results[url] = (fetched, err)
|
||||
|
||||
for feed in part:
|
||||
parsed, err = results[feed.rss_url]
|
||||
fetched, 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, parsed)
|
||||
new += _refresh_feed(feed, fetched)
|
||||
refreshed += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
|
|
@ -257,10 +448,47 @@ def _refresh_feeds(feeds, chunk=32):
|
|||
return refreshed, failed, new
|
||||
|
||||
|
||||
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)
|
||||
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
|
||||
channel = parsed.feed
|
||||
# The first fetch of a new subscription brings the whole back catalogue.
|
||||
first_fetch = feed_obj.last_refreshed_at is None
|
||||
|
|
@ -278,7 +506,7 @@ def _refresh_feed(feed_obj, parsed=None):
|
|||
or image.get('url', '')
|
||||
)[:1000]
|
||||
|
||||
feed_obj.last_refreshed_at = timezone.now()
|
||||
feed_obj.last_refreshed_at = now
|
||||
feed_obj.save()
|
||||
|
||||
max_ep = getattr(settings, 'PODCAST_MAX_EPISODES_PER_FEED', 200)
|
||||
|
|
@ -381,7 +609,20 @@ def _refresh_feed(feed_obj, parsed=None):
|
|||
)
|
||||
new_episodes_list = newest_first[:NEW_ON_SUBSCRIBE]
|
||||
|
||||
if feed_obj.auto_queue and new_episodes_list:
|
||||
# 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:
|
||||
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.
|
||||
|
|
@ -393,6 +634,13 @@ def _refresh_feed(feed_obj, parsed=None):
|
|||
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
|
||||
|
||||
|
||||
|
|
@ -439,16 +687,30 @@ 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', 'auto_queue', 'latest_episode_at')
|
||||
.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)
|
||||
)
|
||||
|
||||
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']:
|
||||
|
|
@ -534,11 +796,99 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -602,20 +952,29 @@ def feed_episodes(request, pk):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_chapters(episode):
|
||||
"""Fetch the JSON chapters of `episode` once and cache them on it.
|
||||
"""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.
|
||||
|
||||
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:
|
||||
chapters = chapters_mod.fetch(url)
|
||||
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(
|
||||
|
|
@ -636,7 +995,7 @@ def episode_detail(request, pk):
|
|||
return JsonResponse({'error': 'not found'}, status=404)
|
||||
|
||||
chapters = ep.chapters
|
||||
if not chapters and ep.chapters_url and ep.chapters_fetched_at is None:
|
||||
if not chapters and ep.chapters_fetched_at is None:
|
||||
chapters = _load_chapters(ep)
|
||||
|
||||
progress = EpisodeProgress.objects.filter(user=request.user, episode=ep).first()
|
||||
|
|
@ -647,6 +1006,7 @@ 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,
|
||||
|
|
@ -662,6 +1022,65 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -957,7 +1376,8 @@ 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/ …)."""
|
||||
skipped. Clients then fetch the result once (state/, inbox/ …).
|
||||
The user asked, so feeds the hourly job backs off from are tried too."""
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||
try:
|
||||
|
|
@ -989,7 +1409,8 @@ 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},
|
||||
'settings': {'enqueue_position': s.enqueue_position, 'auto_queue_position': s.auto_queue_position,
|
||||
'keep_sorted': s.keep_sorted},
|
||||
'server_time': timezone.now().isoformat(),
|
||||
})
|
||||
|
||||
|
|
@ -1101,6 +1522,8 @@ 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})
|
||||
|
||||
|
|
@ -1123,13 +1546,21 @@ 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],
|
||||
})
|
||||
|
||||
|
|
|
|||
40
radio/tests.py
Normal file
40
radio/tests.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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)
|
||||
|
|
@ -63,13 +63,6 @@ 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:
|
||||
|
|
@ -91,7 +84,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -1251,241 +1251,6 @@ 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 {
|
||||
|
|
@ -1588,90 +1353,6 @@ 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;
|
||||
|
|
@ -1682,11 +1363,6 @@ 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; }
|
||||
|
|
|
|||
1073
static/js/app.js
1073
static/js/app.js
File diff suppressed because it is too large
Load diff
1069
static/js/podcasts-web.js
Normal file
1069
static/js/podcasts-web.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,11 +2,14 @@
|
|||
* diora service worker — caches the app shell for offline use.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v51';
|
||||
const PODCAST_CACHE = 'diora-podcast-v1';
|
||||
const CACHE = 'diora-v52';
|
||||
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',
|
||||
|
|
@ -25,12 +28,13 @@ self.addEventListener('install', function (event) {
|
|||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate: remove stale caches from previous versions
|
||||
// Activate: remove stale caches from previous versions (also the old
|
||||
// 'diora-podcast-v1' of episodes the web app used to download)
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(
|
||||
caches.keys().then(function (keys) {
|
||||
return Promise.all(
|
||||
keys.filter(function (key) { return key !== CACHE && key !== PODCAST_CACHE; })
|
||||
keys.filter(function (key) { return key !== CACHE; })
|
||||
.map(function (key) { return caches.delete(key); })
|
||||
);
|
||||
}).then(function () {
|
||||
|
|
@ -68,21 +72,6 @@ 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) {
|
||||
|
|
|
|||
1901
static/podcasts/native-podcast.js
Normal file
1901
static/podcasts/native-podcast.js
Normal file
File diff suppressed because it is too large
Load diff
529
static/podcasts/podcast-ui.css
Normal file
529
static/podcasts/podcast-ui.css
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
/* 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; }
|
||||
189
static/podcasts/podcasts-web.css
Normal file
189
static/podcasts/podcasts-web.css
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/* 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); }
|
||||
|
|
@ -22,23 +22,6 @@
|
|||
<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">« 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 »</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>
|
||||
|
|
@ -228,82 +211,8 @@
|
|||
<!-- ===== PODCASTS TAB ===== -->
|
||||
<section class="tab-panel" id="tab-podcasts" style="display:none;">
|
||||
{% if user.is_authenticated %}
|
||||
<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>
|
||||
|
||||
{# Filled by static/js/podcasts-web.js with the app's podcast page (static/podcasts/). #}
|
||||
<p class="muted dpw-loading">Podcasts werden geladen …</p>
|
||||
{% else %}
|
||||
<p class="auth-prompt">
|
||||
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
|
||||
|
|
@ -415,9 +324,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 %}
|
||||
|
|
|
|||
78
tools/sync_podcast_ui.py
Normal file
78
tools/sync_podcast_ui.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""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()
|
||||
Loading…
Add table
Reference in a new issue