diora-web/podcasts/views.py

2975 lines
122 KiB
Python
Raw Blame History

import hashlib
import json
import logging
import math
import re
import time
import urllib.parse
import uuid
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout, as_completed
from dataclasses import dataclass
from datetime import timedelta, timezone as dt_timezone
import xml.etree.ElementTree as ET
import feedparser
import requests
from django.conf import settings
from django.core.cache import cache
from django.db import IntegrityError, transaction
from django.db.models import (
Case, Count, Exists, F, FilteredRelation, IntegerField, Max, OuterRef, Q, Sum, Value, When, Window,
)
from django.db.models.functions import Coalesce, Lower, RowNumber
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from . import chapters as chapters_mod
from . import silence as silence_mod
from . import sponsor as sponsor_mod
from . import feedext
from . import transcription as transcription_mod
from . import transcripts as transcripts_mod
from . import youtube as youtube_mod
from . import folders as folders_mod
from .models import (
EpisodeArticle, EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastLater,
PodcastQueue, PodcastRadioHeard, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played,
)
from .shownotes import extract_timestamps, sanitize_html
feedext.install()
log = logging.getLogger(__name__)
FEED_TIMEOUT = 20 # seconds per feed download
NEW_ON_SUBSCRIBE = 1 # episodes of a new subscription that count as new
FEED_WORKERS = 16 # feeds downloaded at the same time
REFRESH_WAIT = 40 # seconds a refresh-all (⟳) waits; feeds not done by then wait for the hourly job
MAX_DESCRIPTION = 50_000 # characters of shownotes HTML kept per episode
MAX_TRANSCRIPTS = 10
MAX_DURATION = 7 * 24 * 3600 # seconds: a longer one reported is nonsense
# ---------------------------------------------------------------------------
# Queue positions
# ---------------------------------------------------------------------------
QUEUE_POSITIONS = {key for key, _ in PodcastSettings.POSITIONS}
KEEP_SORTED = {key for key, _ in PodcastSettings.SORT_ORDERS}
def _queue_order(user):
"""Episode ids in the user's queue, first to last."""
return list(
PodcastQueue.objects.filter(user=user)
.order_by('position', 'added_at', 'id')
.values_list('episode_id', flat=True)
)
def _write_queue_order(user, order):
"""Renumber the queue 0..n-1 in the given order."""
rows = {q.episode_id: q for q in PodcastQueue.objects.filter(user=user)}
changed = []
for pos, ep_id in enumerate(order):
row = rows.get(ep_id)
if row is not None and row.position != pos:
row.position = pos
changed.append(row)
if changed:
PodcastQueue.objects.bulk_update(changed, ['position'])
def _current_episode_id(user, queued, hint=None):
"""The episode being listened to: the client's word for it, else the
queued episode most recently listened to and not finished."""
if hint in queued:
return hint
p = (
EpisodeProgress.objects
.filter(user=user, episode_id__in=queued, played=False, position_seconds__gt=0)
.order_by('-updated_at')
.values_list('episode_id', flat=True)
.first()
)
return p
def _not_books(episode_ids):
"""The ids that aren't an audiobook's chapters: those are played in the library, in their
book's order, and never sit in the Wiedergabeliste or the Merkliste (docs/audiobooks.md)."""
books = set(PodcastEpisode.objects.filter(id__in=episode_ids, feed__kind=PodcastFeed.AUDIOBOOK)
.values_list('id', flat=True))
return [i for i in episode_ids if i not in books]
def _enqueue(user, episode_ids, where, current_hint=None):
"""Put episodes that aren't queued yet into the queue at `where`
(back, front or after_current), keeping their given order.
Returns the ids that were added."""
episode_ids = _not_books(episode_ids)
queued = _queue_order(user)
present = set(queued)
new = []
for ep_id in episode_ids:
if ep_id not in present and ep_id not in new:
new.append(ep_id)
if not new:
return []
if where == PodcastSettings.FRONT:
at = 0
elif where == PodcastSettings.AFTER_CURRENT:
current = _current_episode_id(user, queued, current_hint)
at = queued.index(current) + 1 if current in queued else 0
else:
at = len(queued)
PodcastQueue.objects.bulk_create(
[PodcastQueue(user=user, episode_id=ep_id, position=len(queued) + i) for i, ep_id in enumerate(new)],
ignore_conflicts=True,
)
_write_queue_order(user, queued[:at] + new + queued[at:])
_leave_inbox(user, new)
# Wanted again after "Folge löschen": devices keep (or fetch) their download.
EpisodeProgress.objects.filter(user=user, episode_id__in=new, discarded_at__isnull=False).update(discarded_at=None)
_keep_sorted(user)
return new
def _dequeue(user, episode_ids):
"""Take episodes out of the user's queue. The others keep their order
(a queue kept sorted stays sorted: taking some out doesn't change that)."""
PodcastQueue.objects.filter(user=user, episode_id__in=episode_ids).delete()
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."""
EpisodeProgress.objects.filter(user=user, episode_id__in=episode_ids, dismissed=False).update(dismissed=True)
have = set(EpisodeProgress.objects.filter(user=user, episode_id__in=episode_ids).values_list('episode_id', flat=True))
EpisodeProgress.objects.bulk_create(
[EpisodeProgress(user=user, episode_id=ep_id, dismissed=True) for ep_id in episode_ids if ep_id not in have],
ignore_conflicts=True,
)
# ---------------------------------------------------------------------------
# Merkliste (docs/podcasts.md)
# ---------------------------------------------------------------------------
def _later_add(user, episode_ids, auto=False):
"""Put episodes on the user's Merkliste (those not on it yet). Like queueing they are no
longer new; wanted again after "Folge löschen". Returns the ids added."""
have = set(PodcastLater.objects.filter(user=user, episode_id__in=episode_ids).values_list('episode_id', flat=True))
new = [i for i in dict.fromkeys(_not_books(episode_ids)) if i not in have]
if not new:
return []
PodcastLater.objects.bulk_create([PodcastLater(user=user, episode_id=i, auto=auto) for i in new], ignore_conflicts=True)
_leave_inbox(user, new)
EpisodeProgress.objects.filter(user=user, episode_id__in=new, discarded_at__isnull=False).update(discarded_at=None)
return new
def _later_remove(user, episode_ids):
PodcastLater.objects.filter(user=user, episode_id__in=episode_ids).delete()
def _later_data(user):
"""The Merkliste: like the queue's rows (episode__… fields, position_seconds), plus
added_at, auto and in_queue (also in the Wiedergabeliste). Newest episode first; the
apps group it by podcast."""
items = list(
PodcastLater.objects.filter(user=user)
.order_by(F('episode__pub_date').desc(nulls_last=True), '-id')
.values(
'id', 'added_at', 'auto',
'episode__id', 'episode__title', 'episode__audio_url', 'episode__link',
'episode__duration_seconds', 'episode__artwork_url',
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
'episode__pub_date',
)
)
ids = [i['episode__id'] for i in items]
positions = dict(EpisodeProgress.objects.filter(user=user, episode_id__in=ids).values_list('episode_id', 'position_seconds'))
queued = set(PodcastQueue.objects.filter(user=user, episode_id__in=ids).values_list('episode_id', flat=True))
for i in items:
i['position_seconds'] = positions.get(i['episode__id'], 0)
i['in_queue'] = i['episode__id'] in queued
i['added_at'] = i['added_at'].isoformat()
if i['episode__pub_date']:
i['episode__pub_date'] = i['episode__pub_date'].isoformat()
return items
# How often a feed is fetched (docs/podcasts.md): fixed, or by its rhythm — a feed that
# brings an episode every n days is fetched every n hours, within 15 minutes and a day.
REFRESH_HOURS = {'hourly': 1, 'daily': 24, 'weekly': 24 * 7}
REFRESH_AUTO_MIN_HOURS = 0.25
REFRESH_AUTO_MAX_HOURS = 24
def refresh_hours(feed, interval_days):
"""Hours between two fetches of `feed` (a PodcastFeed or a dict with refresh_interval)."""
fixed = REFRESH_HOURS.get(getattr(feed, 'refresh_interval', None) if not isinstance(feed, dict) else feed.get('refresh_interval'))
if fixed:
return fixed
if interval_days is None:
return 1
return min(max(interval_days, REFRESH_AUTO_MIN_HOURS), REFRESH_AUTO_MAX_HOURS)
def refresh_due(feeds, now=None):
"""The ids of the feeds due for a fetch: never fetched, or longer ago than their
refresh_hours (with a few minutes' slack for the 15-minute cron)."""
now = now or timezone.now()
intervals = {}
due = []
for f in feeds:
if f.user_id not in intervals:
intervals[f.user_id] = _intervals(f.user)
if f.last_refreshed_at is None:
due.append(f.id)
continue
hours = refresh_hours(f, intervals[f.user_id].get(f.id))
if now - f.last_refreshed_at >= timedelta(hours=hours) - timedelta(minutes=5):
due.append(f.id)
return due
# ---------------------------------------------------------------------------
# Duration helper
# ---------------------------------------------------------------------------
def _parse_duration(raw):
"""Return duration in seconds from itunes:duration string or int."""
if not raw:
return 0
if isinstance(raw, int):
return raw
raw = str(raw).strip()
parts = raw.split(':')
try:
if len(parts) == 3:
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
if len(parts) == 2:
return int(parts[0]) * 60 + int(parts[1])
return int(raw)
except (ValueError, TypeError):
return 0
# ---------------------------------------------------------------------------
# Shownotes, chapters and transcripts of a feed entry
# ---------------------------------------------------------------------------
def _entry_description(entry):
"""Shownotes HTML: content:encoded when the feed has it (that's where the
full notes usually are), else the description/summary."""
for c in entry.get('content') or []:
value = (c.get('value') or '').strip()
if value:
return value[:MAX_DESCRIPTION]
return (entry.get('summary') or entry.get('description') or '')[:MAX_DESCRIPTION]
def _entry_chapters_url(entry):
url = ((entry.get('podcast_chapters') or {}).get('url') or '').strip()
return url[:1000] if url.lower().startswith(('http://', 'https://')) else ''
def _entry_transcripts(entry):
raw = entry.get('podcast_transcripts')
if raw is None and entry.get('podcast_transcript'):
raw = [entry['podcast_transcript']] # feedparser without feedext
transcripts = []
for t in raw or []:
url = (t.get('url') or '').strip()
if not url.lower().startswith(('http://', 'https://')) or len(url) > 1000:
continue
transcripts.append({
'url': url,
'type': (t.get('type') or '')[:100],
'language': (t.get('language') or '')[:35],
})
if len(transcripts) >= MAX_TRANSCRIPTS:
break
return transcripts
def _apply_extras(episode, description, chapters_url, psc_chapters, transcripts, renamed=None):
"""Copy what the feed says now onto `episode`; True if anything changed.
`renamed`: its title, date and number as the feed has them now (a feed may correct
them; an audiobook's chapter order depends on them).
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 = []
for name, value in (renamed or {}).items():
if getattr(episode, name) != value:
setattr(episode, name, value)
changed.append(name)
if episode.description != description:
episode.description = description
changed.append('description')
if episode.transcripts != transcripts:
episode.transcripts = transcripts
changed.append('transcripts')
if psc_chapters:
chapters, fetched_at = psc_chapters, None
elif chapters_url == episode.chapters_url and episode.chapters_fetched_at:
chapters, fetched_at = episode.chapters, episode.chapters_fetched_at
else:
chapters, fetched_at = [], None
if episode.chapters_url != chapters_url:
episode.chapters_url = chapters_url
changed.append('chapters_url')
if episode.chapters != chapters:
episode.chapters = chapters
changed.append('chapters')
if episode.chapters_fetched_at != fetched_at:
episode.chapters_fetched_at = fetched_at
changed.append('chapters_fetched_at')
return bool(changed)
EXTRA_FIELDS = ['link', 'description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at',
'title', 'pub_date', 'episode_number']
# ---------------------------------------------------------------------------
# Shared feed refresh helper
# ---------------------------------------------------------------------------
class FeedFetchError(Exception):
"""A feed that could not be fetched or is not a feed; str() is the short
message stored in PodcastFeed.last_error ("HTTP 404", "timeout", …)."""
@dataclass
class FeedFetch:
"""What _fetch_feed got. status 200: `parsed` is the feedparser result and
etag/last_modified are the new validators ('' when the server sent none).
status 304: unchanged since the validators that were sent, parsed is None.
final_url is where the content came from after all redirects;
permanent_url is the requested URL after following only the 301/308 hops
at the start of the redirect chain (== the requested URL without one)."""
status: int
parsed: object = None
etag: str = ''
last_modified: str = ''
final_url: str = ''
permanent_url: str = ''
body_hash: str = ''
# What changes in a feed on every request without anything in it changing:
# the build date, comments with a generation time.
_VOLATILE = re.compile(rb'<lastBuildDate>.*?</lastBuildDate>|<!--.*?-->', re.S)
def _body_hash(content):
"""Fingerprint of a feed body, without the parts that change on every request."""
return hashlib.sha256(_VOLATILE.sub(b'', content)).hexdigest()
PERMANENT_REDIRECTS = (301, 308)
YOUTUBE_FEED_TRIES = 12
YOUTUBE_RETRY_STATUS = (404, 500, 502, 503)
YOUTUBE_RETRY_PAUSE = 0.25 # seconds between tries
def _fetch_feed(url, etag='', last_modified='', body_hash='', user_agent='diora podcast refresh'):
"""Download and parse a feed (with a timeout, unlike feedparser's own
fetch). With etag/last_modified it is a conditional GET; with body_hash a
body like the last one counts as unchanged too (a 304), unparsed —
parsing is what takes the time (seconds for a big feed). Returns a
FeedFetch; raises FeedFetchError when there is nothing usable."""
headers = {'User-Agent': user_agent}
if etag:
headers['If-None-Match'] = etag
if last_modified:
headers['If-Modified-Since'] = last_modified
# YouTube's channel feeds answer 404 (or a 5xx) now and then for no reason — in
# October 2026 about every other request — and the next request gets the feed: ask
# again a few times before calling it an error.
tries = YOUTUBE_FEED_TRIES if youtube_mod.is_feed(url) else 1
for attempt in range(tries):
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__})')
if r.status_code not in YOUTUBE_RETRY_STATUS or attempt == tries - 1:
break
time.sleep(YOUTUBE_RETRY_PAUSE)
# 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}')
new_hash = _body_hash(r.content)
if body_hash and new_hash == body_hash:
return FeedFetch(304, None, etag, last_modified, r.url, permanent_url, body_hash)
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, new_hash,
)
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, f.http_body_hash) for f in feeds}
if len(pairs) != 1 or any(f.last_refreshed_at is None for f in feeds):
return {}
etag, last_modified, body_hash = pairs.pop()
out = {'etag': etag, 'last_modified': last_modified} if etag or last_modified else {}
if body_hash:
out['body_hash'] = body_hash
return out
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
# File endings of an enclosure without a usable type.
AUDIO_EXTENSIONS = ('.mp3', '.m4a', '.m4b', '.ogg', '.oga', '.opus', '.aac', '.flac', '.wav')
VIDEO_EXTENSIONS = ('.mp4', '.m4v', '.mov', '.webm')
def _refresh_feeds(feeds, full=False, deadline=None):
"""Refresh many feeds at once: the downloads run in parallel and each
feed URL is fetched once, even when several users subscribed to it; each
download is written as soon as it's there (the writes one by one, while
the others still load). Returns (refreshed, failed, new).
`full`: download every feed again (no 304) and take in episodes the
reader skipped before (see _apply_feed). `deadline` (time.monotonic()):
stop there; feeds not done by then stay as they are."""
feeds = list(feeds)
refreshed = failed = new = 0
by_url = {}
for f in feeds:
by_url.setdefault(f.rss_url, []).append(f)
if not by_url:
return 0, 0, 0
def fetch(url):
try:
return url, _fetch_feed(url, **({} if full else _validators(by_url[url]))), None
except Exception as e: # network, HTTP or parser trouble: report and go on
return url, None, e
pool = ThreadPoolExecutor(max_workers=min(FEED_WORKERS, len(by_url)))
futures = [pool.submit(fetch, url) for url in sorted(by_url)]
try:
wait = None if deadline is None else max(0.0, deadline - time.monotonic())
for future in as_completed(futures, timeout=wait):
url, fetched, err = future.result()
for feed in by_url[url]:
if err is not None:
failed += 1
log.warning('refreshing %s failed: %s', feed.rss_url, err)
_record_error(feed, err)
continue
try:
new += _refresh_feed(feed, fetched, full=full)
refreshed += 1
except Exception as e:
failed += 1
log.warning('updating %s failed: %s', feed.rss_url, e)
except FuturesTimeout:
log.info('refresh stopped at its deadline: %d of %d feeds done', refreshed + failed, len(feeds))
finally:
pool.shutdown(wait=False, cancel_futures=True)
return refreshed, failed, new
def _refresh_feed(feed_obj, fetched=None, full=False):
"""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, full=full)
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 _entry_link(entry):
"""The item's <link>, the episode's own page; '' unless it is an http(s) address."""
link = (entry.get('link') or '').strip()
return link[:1000] if re.match(r'^https?://', link, re.I) else ''
def _entry_media_url(entry):
"""The episode's file: its audio enclosure, else a video one (the players
play only its sound). '' when there is neither."""
video = ''
for enc in entry.get('enclosures', []):
url = enc.get('url', '')
mime = (enc.get('type') or '').lower()
path = urllib.parse.urlsplit(url).path.lower()
if mime.startswith('audio/') or path.endswith(AUDIO_EXTENSIONS):
return url
if not video and (mime.startswith('video/') or path.endswith(VIDEO_EXTENSIONS)):
video = url
# A YouTube feed has no files: the video's page, whose sound the app finds itself.
return video or youtube_mod.video_url(entry)
def _apply_feed(feed_obj, fetched, full=False):
"""Store a fetched feed. `full` (a re-read after the reader learned to
take more episodes, e.g. video ones): episodes it finds that came out
before the last refresh go into the podcast's list, not into "Neu"."""
now = timezone.now()
previous_refresh = feed_obj.last_refreshed_at
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
feed_obj.title = channel.get('title', feed_obj.title or feed_obj.rss_url)[:300]
feed_obj.description = channel.get('subtitle', channel.get('description', ''))[:2000]
feed_obj.author = channel.get('author', channel.get('itunes_author', ''))[:300]
feed_obj.link = channel.get('link', '')[:1000]
# Artwork
image = channel.get('image', {})
feed_obj.artwork_url = (
channel.get('itunes_image', {}).get('href', '')
or image.get('href', '')
or image.get('url', '')
or feed_obj.artwork_url # a YouTube feed has none: the channel's, found when subscribing
)[:1000]
if not feed_obj.artwork_url and youtube_mod.feed_channel_id(channel):
feed_obj.artwork_url = youtube_mod.channel_artwork(youtube_mod.feed_channel_id(channel))[:1000]
feed_obj.last_refreshed_at = now
feed_obj.save()
max_ep = getattr(settings, 'PODCAST_MAX_EPISODES_PER_FEED', 200)
new_count = 0
new_episodes_list = []
entries = parsed.entries[:max_ep]
# Episodes we already have, in one query, so shownotes/chapters/transcripts
# that changed in the feed can be brought up to date (only those rows).
guids = [str(e.get('id') or e.get('guid') or '')[:1000] for e in entries]
existing = {
ep.guid: ep
for ep in feed_obj.episodes.filter(guid__in=[g for g in guids if g]).only('id', 'guid', *EXTRA_FIELDS)
}
changed_episodes = {}
for entry in entries:
audio_url = _entry_media_url(entry)
if not audio_url:
continue
link = _entry_link(entry)
guid = entry.get('id') or entry.get('guid') or audio_url
guid = str(guid)[:1000]
title = entry.get('title', 'Untitled')[:500]
video = youtube_mod.is_video(audio_url)
description = youtube_mod.description_html(entry.get('summary')) if video else _entry_description(entry)
chapters_url = _entry_chapters_url(entry)
psc_chapters = chapters_mod.from_psc(entry)
transcripts = _entry_transcripts(entry)
pub_date = None
if entry.get('published_parsed'):
import datetime as dt
t = entry.published_parsed
pub_date = dt.datetime(*t[:6], tzinfo=dt.timezone.utc)
ep_number = None
try:
ep_number = int(entry.get('itunes_episode', ''))
except (ValueError, TypeError):
pass
known = existing.get(guid)
if known is not None:
renamed = {'title': title, 'episode_number': ep_number, 'link': link}
if pub_date is not None:
renamed['pub_date'] = pub_date
if _apply_extras(known, description, chapters_url, psc_chapters, transcripts, renamed):
changed_episodes[known.id] = known
continue
duration_raw = entry.get('itunes_duration', 0)
duration_seconds = _parse_duration(duration_raw)
season_number = None
try:
season_number = int(entry.get('itunes_season', ''))
except (ValueError, TypeError):
pass
artwork_url = (youtube_mod.entry_artwork(entry) if video else entry.get('itunes_image', {}).get('href', ''))[:1000]
episode_obj, created = PodcastEpisode.objects.get_or_create(
feed=feed_obj,
guid=guid,
defaults={
'title': title,
'description': description,
'audio_url': audio_url[:1000],
'link': link,
'duration_seconds': duration_seconds,
'pub_date': pub_date,
'episode_number': ep_number,
'season_number': season_number,
'artwork_url': artwork_url,
'chapters_url': chapters_url,
'chapters': psc_chapters,
'transcripts': transcripts,
},
)
existing[guid] = episode_obj # the same guid twice in one feed
if created:
new_count += 1
new_episodes_list.append(episode_obj)
elif _apply_extras(episode_obj, description, chapters_url, psc_chapters, transcripts):
changed_episodes[episode_obj.id] = episode_obj
if changed_episodes:
PodcastEpisode.objects.bulk_update(list(changed_episodes.values()), EXTRA_FIELDS, batch_size=100)
if full and not first_fetch and new_episodes_list:
# Found only now, but out before the last refresh: not news.
late = [e for e in new_episodes_list if e.pub_date is None or e.pub_date < previous_refresh]
if late:
EpisodeProgress.objects.bulk_create(
[EpisodeProgress(user=feed_obj.user, episode=e, dismissed=True) for e in late],
ignore_conflicts=True,
)
gone = {e.id for e in late}
new_episodes_list = [e for e in new_episodes_list if e.id not in gone]
if first_fetch and len(new_episodes_list) > NEW_ON_SUBSCRIBE:
# Only the newest episodes count as new; the rest stay in the
# podcast's episode list, out of the inbox (dismissed, not played).
newest_first = sorted(new_episodes_list, key=lambda e: e.pub_date or e.discovered_at, reverse=True)
older = newest_first[NEW_ON_SUBSCRIBE:]
EpisodeProgress.objects.bulk_create(
[EpisodeProgress(user=feed_obj.user, episode=e, dismissed=True) for e in older],
ignore_conflicts=True,
)
new_episodes_list = newest_first[:NEW_ON_SUBSCRIBE]
# The feed's own rules for its new episodes: those its filter turns down,
# and all of them for 'ignore', go straight out of the inbox (not played);
# 'queue' puts them in the queue (which also takes them out of the inbox);
# 'inbox' leaves them there.
if new_episodes_list:
unwanted = [e for e in new_episodes_list if not feed_obj.matches_filter(e.title, e.duration_seconds)]
if feed_obj.new_episodes == PodcastFeed.NEW_IGNORE or feed_obj.kind == PodcastFeed.AUDIOBOOK:
unwanted = new_episodes_list # an audiobook's new chapters wait in the library
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]
stamm = (PodcastFeed.NEW_QUEUE, PodcastFeed.NEW_PLAY)
if feed_obj.new_episodes in stamm and new_episodes_list and feed_obj.kind != PodcastFeed.AUDIOBOOK:
s = PodcastSettings.for_user(feed_obj.user)
newest_first = sorted(
new_episodes_list,
key=lambda e: e.pub_date or e.discovered_at,
reverse=True,
)
if s.lists_v2 and feed_obj.new_episodes == PodcastFeed.NEW_QUEUE:
# Stamm: into the Merkliste (docs/podcasts.md).
_later_add(feed_obj.user, [e.id for e in newest_first], auto=True)
else:
where = s.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.
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
feed_obj.http_body_hash = fetched.body_hash
_clear_error(feed_obj)
feed_obj.save(update_fields=['http_etag', 'http_last_modified', 'http_body_hash', *ERROR_FIELDS])
_move_to_permanent_url(feed_obj, fetched.permanent_url)
return new_count
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
def podcast_search(request):
q = request.GET.get('q', '').strip()
if not q:
return JsonResponse({'results': []})
if youtube_mod.is_youtube(q):
try:
found = youtube_mod.resolve(q)
except youtube_mod.NotAChannel:
return JsonResponse({'results': []})
return JsonResponse({'results': [{
'id': found['feed'], 'title': found['title'] or 'YouTube', 'author': 'YouTube',
'artwork_url': found['artwork'], 'rss_url': found['feed'], 'genre': 'YouTube',
}]})
try:
url = f'https://itunes.apple.com/search?term={urllib.parse.quote(q)}&media=podcast&limit=20'
resp = requests.get(url, timeout=getattr(settings, 'ITUNES_TIMEOUT', 6))
resp.raise_for_status()
raw = resp.json().get('results', [])
except Exception as e:
return JsonResponse({'error': str(e)}, status=502)
results = []
for r in raw:
results.append({
'id': r.get('collectionId'),
'title': r.get('collectionName', ''),
'author': r.get('artistName', ''),
'artwork_url': r.get('artworkUrl100', ''),
'rss_url': r.get('feedUrl', ''),
'genre': r.get('primaryGenreName', ''),
})
return JsonResponse({'results': results})
# ---------------------------------------------------------------------------
# Feed list
# ---------------------------------------------------------------------------
@csrf_exempt
def feed_list(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
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', 'auto_download',
'notify', 'episode_sort', 'episode_filter', 'skip_silence', 'kind', 'folder', 'folders',
'refresh_interval', 'favorite',
)
def _feed_settings(feed):
return {'feed_id': feed.id, **{name: getattr(feed, name) for name in FEED_SETTING_FIELDS}}
INTERVAL_EPISODES = 10 # a feed's newest episodes that tell how often it comes
HEARD_MIN_SECONDS = 60 # listened into this far, an episode counts as heard (with played)
INTERVAL_SINGLE_DAYS = 30
def _intervals(user):
"""How often each feed brings an episode, in days: the mean gap between its newest
INTERVAL_EPISODES. One episode only (or all within an hour): its age, at least INTERVAL_SINGLE_DAYS. The lists
put the seldom ones first within a day or week (after Kino's feed; feeds.js does the
same for blogs). One query (the newest episodes per feed by a window), kept an hour."""
key = f'podcasts:intervals:{user.id}'
got = cache.get(key)
if got is not None:
return got
rows = (
PodcastEpisode.objects.filter(feed__user=user, pub_date__isnull=False)
.annotate(n=Window(RowNumber(), partition_by=[F('feed_id')], order_by=F('pub_date').desc()))
.filter(n__lte=INTERVAL_EPISODES)
.values_list('feed_id', 'pub_date')
)
dates = {}
for feed_id, when in rows:
dates.setdefault(feed_id, []).append(when)
now = timezone.now()
out = {}
for feed_id, ds in dates.items():
span = (max(ds) - min(ds)).total_seconds() / 86400
if len(ds) == 1 or span < 1 / 24:
# One episode, or all within an hour (an archive, a course put up at once): no rhythm
# to go by. Hourly news still has one.
days = max((now - max(ds)).total_seconds() / 86400, INTERVAL_SINGLE_DAYS)
else:
days = span / (len(ds) - 1)
out[feed_id] = round(days, 2)
cache.set(key, out, 3600)
return out
def _feeds_data(user):
"""The user's feeds with their settings and, per feed, how many episodes
it has, how many of them are unplayed and how many new (as in the inbox:
neither played nor dismissed), and when the user last listened to one.
Two queries, however many feeds: the feeds with their episode count, and
the user's progress rows counted per feed (and, once an hour, how often each
feed comes: _intervals)."""
feeds = list(
user.podcast_feeds
.annotate(latest_episode_at=Max('episodes__pub_date'), episode_count=Count('episodes'))
.values('id', 'title', 'artwork_url', 'rss_url', 'link', 'last_refreshed_at', 'author', 'added_at',
'latest_episode_at', 'episode_count', 'last_error', 'last_error_at', 'error_count',
*FEED_SETTING_FIELDS)
)
listened = {
row['episode__feed_id']: row
for row in EpisodeProgress.objects.filter(user=user, episode__feed__user=user)
.order_by()
.values('episode__feed_id')
.annotate(
played_count=Count('id', filter=Q(played=True)),
hidden_count=Count('id', filter=Q(played=True) | Q(dismissed=True)),
last_played_at=Max('updated_at', filter=Q(played=True) | Q(position_seconds__gt=0)),
heard_365=Count('id', filter=(Q(played=True) | Q(position_seconds__gt=HEARD_MIN_SECONDS))
& Q(updated_at__gte=timezone.now() - timedelta(days=365))),
)
}
intervals = _intervals(user)
for f in feeds:
f['feed_id'] = f['id'] # as in feeds/<id>/settings/
f['interval_days'] = intervals.get(f['id'])
mine = listened.get(f['id'], {})
f['unplayed_count'] = max(f['episode_count'] - mine.get('played_count', 0), 0)
f['new_count'] = max(f['episode_count'] - mine.get('hidden_count', 0), 0)
# Episodes heard in the last 12 months: the Strom puts the most listened first,
# any heard one before every never-heard one (docs/podcasts.md).
f['heard_365'] = mine.get('heard_365', 0)
last = mine.get('last_played_at')
f['last_played_at'] = last.isoformat() if last else None
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']:
f['latest_episode_at'] = f['latest_episode_at'].isoformat()
return feeds
# ---------------------------------------------------------------------------
# Add feed
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def add_feed(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
rss_url = body.get('rss_url', '').strip()
if not rss_url:
return JsonResponse({'error': 'rss_url required'}, status=400)
title = body.get('title', rss_url)
artwork = ''
if youtube_mod.is_youtube(rss_url) and not youtube_mod.is_feed(rss_url):
# A channel, playlist or video address: its channel's feed (the server reads only that).
try:
found = youtube_mod.resolve(rss_url)
except youtube_mod.NotAChannel as e:
return JsonResponse({'error': f'No YouTube channel there ({e})'}, status=400)
rss_url, artwork = found['feed'], found['artwork']
title = found['title'] or title
feed, created = PodcastFeed.objects.get_or_create(
user=request.user,
rss_url=rss_url,
defaults={'title': title[:300], 'artwork_url': artwork[:1000],
**PodcastSettings.new_feed_defaults(request.user)},
)
new_episodes = 0
if created:
try:
new_episodes = _refresh_feed(feed)
except Exception as e:
feed.delete()
return JsonResponse({'error': f'Failed to parse feed: {e}'}, status=400)
return JsonResponse({
'ok': True,
'created': created,
'feed_id': feed.id,
'title': feed.title,
'artwork_url': feed.artwork_url,
'new_episodes': new_episodes,
})
# ---------------------------------------------------------------------------
# Remove feed
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def remove_feed(request, pk):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
feed = PodcastFeed.objects.get(pk=pk, user=request.user)
feed.delete()
return JsonResponse({'ok': True})
except PodcastFeed.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
# ---------------------------------------------------------------------------
# Set auto-queue
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def set_auto_queue(request, pk):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
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)
# 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 MIN_SPEED <= value <= MAX_SPEED:
raise ValueError
return float(value)
def _episode_sort(value):
if value in (None, ''):
return ''
if value not in PodcastFeed.EPISODE_SORTS:
raise ValueError
return value
def _episode_filter(value):
"""A list of filter ids (each once, in the given order); [] = all episodes."""
if value is None:
return []
if not isinstance(value, list) or any(v not in PodcastFeed.EPISODE_FILTERS for v in value):
raise ValueError
return list(dict.fromkeys(value))
def _optional_boolean(value):
if value is not None and not isinstance(value, bool):
raise ValueError
return value
def _volume(value):
if not _number(value) or float(value) not in PodcastFeed.VOLUMES:
raise ValueError
return float(value)
def _kind(value):
if value not in {key for key, _ in PodcastFeed.KINDS}:
raise ValueError
return value
def _new_episodes(value):
if value not in {key for key, _ in PodcastFeed.NEW_EPISODE_ACTIONS}:
raise ValueError
return value
def _refresh_interval(value):
if value not in {key for key, _ in PodcastFeed.REFRESH_INTERVALS}:
raise ValueError
return value
def _boolean(value):
if not isinstance(value, bool):
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),
'auto_download': _boolean,
'notify': _boolean,
'episode_sort': _episode_sort,
'episode_filter': _episode_filter,
'kind': _kind,
'folder': lambda v: folders_mod.clean_name(v),
'folders': folders_mod.clean_list,
'skip_silence': _optional_boolean,
'refresh_interval': lambda v: _refresh_interval(v),
'favorite': _boolean,
}
@csrf_exempt
@require_http_methods(['POST'])
def move_feed(request, pk):
"""Give a subscription a new address, keeping it: its settings, episodes, places,
played and favourite marks, queue entries. For a podcast that moved, or one that was
a YouTube channel fed through some other server (a podsync-like one): moved to the
channel itself (a YouTube address of any kind), each episode whose file was named
after its video becomes that video (docs/youtube.md), so the channel's feed finds it
again instead of adding it twice. {"ok", "rss_url", "videos": episodes made videos,
"new_episodes"}."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
feed = PodcastFeed.objects.filter(pk=pk, user=request.user).first()
if feed is None:
return JsonResponse({'error': 'not found'}, status=404)
rss_url = str(body.get('rss_url') or '').strip()
if not rss_url.startswith(('http://', 'https://')) or len(rss_url) > 1000:
return JsonResponse({'error': 'rss_url required'}, status=400)
artwork = ''
if youtube_mod.is_youtube(rss_url) and not youtube_mod.is_feed(rss_url):
try:
found = youtube_mod.resolve(rss_url)
except youtube_mod.NotAChannel as e:
return JsonResponse({'error': f'No YouTube channel there ({e})'}, status=400)
rss_url, artwork = found['feed'], found['artwork']
if rss_url != feed.rss_url and PodcastFeed.objects.filter(user=request.user, rss_url=rss_url).exists():
return JsonResponse({'error': 'already subscribed at that address'}, status=409)
videos = 0
with transaction.atomic():
if youtube_mod.is_feed(rss_url):
taken = set(feed.episodes.values_list('guid', flat=True))
changed = []
for ep in feed.episodes.only('id', 'guid', 'audio_url'):
vid = youtube_mod.video_id_in(ep.audio_url)
guid = f'yt:video:{vid}' if vid else ''
if not vid or (guid in taken and ep.guid != guid):
continue # not named after a video, or that video is here already
taken.add(guid)
ep.guid, ep.audio_url = guid, youtube_mod.WATCH + vid
changed.append(ep)
PodcastEpisode.objects.bulk_update(changed, ['guid', 'audio_url'], batch_size=200)
videos = len(changed)
feed.rss_url = rss_url
if artwork:
feed.artwork_url = artwork[:1000]
# Whatever the old address answered says nothing about the new one.
feed.http_etag = feed.http_last_modified = feed.http_body_hash = ''
feed.save()
try:
# Videos the old address didn't carry that came out before its last refresh aren't news.
new_episodes = _refresh_feed(feed, full=True)
except Exception as e: # moved all the same: the next refresh tries again
log.warning('feed %s moved, first refresh failed: %s', feed.pk, e)
new_episodes = 0
return JsonResponse({'ok': True, 'rss_url': feed.rss_url, 'videos': videos, 'new_episodes': new_episodes})
@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)
# Folders (docs/library.md): a list; `folder` alone (older clients) means that one only.
if 'folders' in changes or 'folder' in changes:
names = changes.pop('folders', None)
if names is None:
one = changes.pop('folder')
names = [one] if one else []
changes.pop('folder', None)
folders_mod.set_folders(feed, names)
changes['folders'], changes['folder'] = feed.folders, feed.folder
became_book = changes.get('kind') == PodcastFeed.AUDIOBOOK and feed.kind != PodcastFeed.AUDIOBOOK
if changes:
for name, value in changes.items():
setattr(feed, name, value)
feed.save(update_fields=list(changes))
if became_book: # an audiobook's chapters aren't news (docs/audiobooks.md)
_leave_inbox(request.user, list(feed.episodes.values_list('id', flat=True)))
# Its order depends on titles and dates: the next refresh reads the feed whole.
PodcastFeed.objects.filter(pk=feed.pk).update(http_etag='', http_last_modified='', http_body_hash='')
return JsonResponse(_feed_settings(feed))
# ---------------------------------------------------------------------------
# Feed episodes
# ---------------------------------------------------------------------------
@csrf_exempt
def feed_episodes(request, pk):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
feed = PodcastFeed.objects.get(pk=pk, user=request.user)
except PodcastFeed.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
episodes = list(
feed.episodes.values(
'id', 'title', 'description', 'audio_url', 'link', 'duration_seconds',
'pub_date', 'artwork_url', 'episode_number', 'season_number',
)
)
# Batch-fetch progress
progress_map = {
ep['episode_id']: ep
for ep in EpisodeProgress.objects.filter(
user=request.user,
episode__feed=feed,
).values('episode_id', 'position_seconds', 'played', 'favorite')
}
# Batch-fetch queue membership
queued_ids = set(
PodcastQueue.objects.filter(
user=request.user,
episode__feed=feed,
).values_list('episode_id', flat=True)
)
for ep in episodes:
if ep['pub_date']:
ep['pub_date'] = ep['pub_date'].isoformat()
prog = progress_map.get(ep['id'], {})
ep['position_seconds'] = prog.get('position_seconds', 0)
ep['played'] = prog.get('played', False)
ep['favorite'] = prog.get('favorite', False)
ep['in_queue'] = ep['id'] in queued_ids
return JsonResponse({
'feed': {
'id': feed.id,
'title': feed.title,
'artwork_url': feed.artwork_url,
'author': feed.author,
'auto_queue': feed.auto_queue,
# For the podcast's info page.
'description': feed.description,
'link': feed.link,
'rss_url': feed.rss_url,
'last_refreshed_at': feed.last_refreshed_at.isoformat() if feed.last_refreshed_at else None,
},
'episodes': episodes,
})
# ---------------------------------------------------------------------------
# One episode in detail: shownotes, chapters, transcripts
# ---------------------------------------------------------------------------
def _load_chapters(episode):
"""Fetch the chapters the feed doesn't carry inline, once, and cache them
on `episode`: the JSON at chapters_url, else (no URL, or nothing there)
the CHAP frames in the ID3v2 tag at the start of the audio file.
Problems that may go away (timeouts, server errors) aren't cached, so the
next request tries again; anything else is remembered as "no chapters".
"""
url = episode.chapters_url
chapters = []
try:
if url:
try:
chapters = chapters_mod.fetch(url)
except chapters_mod.ChaptersInvalid as e:
log.info('chapters of episode %s unusable: %s', episode.id, e)
if not chapters and episode.audio_url and not youtube_mod.is_video(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 []
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(
chapters=chapters, chapters_fetched_at=now,
)
episode.chapters, episode.chapters_fetched_at = chapters, now
return chapters
@csrf_exempt
@require_http_methods(['GET'])
def episode_detail(request, pk):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
ep = PodcastEpisode.objects.select_related('feed').get(pk=pk, feed__user=request.user)
except PodcastEpisode.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
chapters = ep.chapters
if not chapters and ep.chapters_fetched_at is None:
chapters = _load_chapters(ep)
progress = EpisodeProgress.objects.filter(user=request.user, episode=ep).first()
job = transcription_mod.job_of(ep)
origin = _transcript_origin(ep, job)
article = EpisodeArticle.objects.filter(episode=ep).first()
return JsonResponse({
'id': ep.id,
'title': ep.title,
'description': sanitize_html(ep.description),
'timestamps': extract_timestamps(ep.description, ep.duration_seconds),
'chapters': chapters,
'transcripts': ep.transcripts,
'has_transcript': origin is not None,
'transcript_origin': origin,
'article_item_id': article.item_id if article else None,
'transcription': transcription_mod.job_state(job) if job else None,
'feed': {'id': ep.feed.id, 'title': ep.feed.title, 'artwork_url': ep.feed.artwork_url},
'audio_url': ep.audio_url,
'link': ep.link,
'artwork_url': ep.artwork_url,
'duration_seconds': ep.duration_seconds,
'pub_date': ep.pub_date.isoformat() if ep.pub_date else None,
'episode_number': ep.episode_number,
'season_number': ep.season_number,
'position_seconds': progress.position_seconds if progress else 0,
'played': progress.played if progress else False,
'favorite': progress.favorite if progress else False,
'last_played_at': progress.updated_at.isoformat() if progress else None,
'in_queue': PodcastQueue.objects.filter(user=request.user, episode=ep).exists(),
})
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])
def _transcript_origin(ep, job=None):
""""feed" when the feed has a usable transcript, else "whisper" when we made one, else None."""
if _has_transcript(ep):
return 'feed'
return 'whisper' if job is not None and job.state == 'done' and job.data else None
@csrf_exempt
@require_http_methods(['GET'])
def episode_silences(request, pk):
"""What to cut out of the episode's pauses for skipping silence in the
browser: {"ready": true, "v": 2, "spans": [[from, to], …]} once found (the
parts to leave out, podcasts/silence.py); the first request starts the search
on the server ({"ready": false}, ask again later)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
ep = PodcastEpisode.objects.filter(pk=pk, feed__user=request.user).only('id', 'silences', 'audio_url').first()
if ep is None:
return JsonResponse({'error': 'not found'}, status=404)
if youtube_mod.is_video(ep.audio_url): # the server never fetches a video's sound
return JsonResponse({'ready': True, 'v': silence_mod.VERSION, 'spans': [], 'error': 'youtube'})
if not silence_mod.current(ep.silences): # none yet, or found by older rules
silence_mod.ensure(ep.pk)
return JsonResponse({'ready': False})
return JsonResponse({'ready': True, 'v': silence_mod.VERSION, 'spans': ep.silences.get('spans', []),
**({'error': ep.silences['error']} if ep.silences.get('error') else {})})
@csrf_exempt
@require_http_methods(['GET'])
def episode_sponsor(request, pk):
"""What to leave out of the episode's sound: the ad and the outro, from SponsorBlock
(sponsor.py). {"ready": true, "v": 1, "spans": [[from, to], …]} — asked for on the
first request and then kept on the episode; empty for an episode that isn't a video,
and an "error" beside empty spans when SponsorBlock couldn't be asked."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
ep = PodcastEpisode.objects.filter(pk=pk, feed__user=request.user).only(
'id', 'sponsor', 'audio_url').first()
if ep is None:
return JsonResponse({'error': 'not found'}, status=404)
return JsonResponse(sponsor_mod.spans_of(ep))
@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. When the feed has none we can use, the one Whisper made (a finished
TranscriptionJob, docs/transcription.md). 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)
def fallback(error, status=404):
job = transcription_mod.job_of(ep)
if job is not None and job.state == 'done' and job.data:
return JsonResponse(job.data)
return JsonResponse({'error': error}, status=status)
if not ep.transcripts:
return fallback('no transcript')
sources = _transcript_sources(ep)
if not sources:
return fallback('unsupported')
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 fallback('unavailable', 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 fallback(error)
return JsonResponse(data)
# ---------------------------------------------------------------------------
# Favorites
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def set_favorite(request, pk):
"""Star or unstar an episode: {"favorite": true|false}, no value toggles."""
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:
episode = PodcastEpisode.objects.get(pk=pk, feed__user=request.user)
except PodcastEpisode.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
progress, _ = EpisodeProgress.objects.get_or_create(user=request.user, episode=episode)
wanted = body.get('favorite')
favorite = (not progress.favorite) if wanted is None else bool(wanted)
if favorite != progress.favorite:
# update() leaves updated_at alone: a star isn't listening, and the
# history and the statistics go by updated_at.
EpisodeProgress.objects.filter(pk=progress.pk).update(favorite=favorite)
return JsonResponse({'ok': True, 'favorite': favorite})
@csrf_exempt
@require_http_methods(['POST'])
def set_favorites(request):
"""Star or unstar several episodes at once (choosing several in a list):
{"episode_ids": [...], "favorite": true}. Like set_favorite, it leaves
the listening time (updated_at) of existing rows alone."""
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)
episode_ids = body.get('episode_ids')
favorite = body.get('favorite')
if not isinstance(episode_ids, list) or not episode_ids or not isinstance(favorite, bool):
return JsonResponse({'error': 'episode_ids (a list) and favorite (true/false) required'}, status=400)
try:
wanted = {int(i) for i in episode_ids[:EPISODE_IDS_MAX]}
except (TypeError, ValueError):
return JsonResponse({'error': 'episode_ids must be numbers'}, status=400)
valid = set(
PodcastEpisode.objects.filter(id__in=wanted, feed__user=request.user).values_list('id', flat=True)
)
with transaction.atomic():
rows = EpisodeProgress.objects.filter(user=request.user, episode_id__in=valid)
rows.exclude(favorite=favorite).update(favorite=favorite)
if favorite:
have = set(rows.values_list('episode_id', flat=True))
EpisodeProgress.objects.bulk_create(
[EpisodeProgress(user=request.user, episode_id=i, favorite=True) for i in sorted(valid - have)],
ignore_conflicts=True,
)
return JsonResponse({'ok': True, 'favorite': favorite, 'count': len(valid)})
# ---------------------------------------------------------------------------
# Episodes of all feeds: filters, history, search
# ---------------------------------------------------------------------------
EPISODE_FILTERS = {
'all': Q(),
'unplayed': Q(played=False),
'in_progress': Q(position_seconds__gt=0, played=False),
# Neither played nor listened into (the Podcast-Radio's, docs/podcasts.md).
'untouched': Q(position_seconds=0, played=False),
'played': Q(played=True),
'favorites': Q(favorite=True),
'history': Q(played=True) | Q(position_seconds__gt=0),
'queued': Q(in_queue=True),
'later': Q(in_later=True),
# The Strom (docs/podcasts.md): every subscription but the "Auf Abruf" ones.
'stream': ~Q(feed__new_episodes=PodcastFeed.NEW_IGNORE),
# Episodes of the favourite subscriptions.
'fave_feeds': Q(feed__favorite=True),
}
# Episodes of unknown length (0) come last when sorting by length.
_UNKNOWN_LENGTH_LAST = Case(When(duration_seconds__gt=0, then=Value(0)), default=Value(1), output_field=IntegerField())
EPISODE_SORTS = {
'new': (F('pub_date').desc(nulls_last=True), '-id'),
'old': (F('pub_date').asc(nulls_last=True), 'id'),
'long': (_UNKNOWN_LENGTH_LAST, '-duration_seconds', F('pub_date').desc(nulls_last=True), '-id'),
'short': (_UNKNOWN_LENGTH_LAST, 'duration_seconds', F('pub_date').desc(nulls_last=True), '-id'),
'title': (Lower('title'), 'id'),
'played': (F('last_played_at').desc(nulls_last=True), '-id'),
}
EPISODE_IDS_MAX = 1000
EPISODE_LIST_LIMIT = 100
EPISODE_LIST_MAX = 500
EPISODE_LIST_FIELDS = (
'id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
'feed__id', 'feed__title', 'feed__artwork_url',
'position_seconds', 'played', 'favorite', 'in_queue', 'in_later', 'last_played_at', 'discovered_at',
)
def _int_param(request, name, default):
raw = request.GET.get(name, '').strip()
return default if raw == '' else int(raw)
def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, limit=EPISODE_LIST_LIMIT,
sort=None, ids=None, notes=False, folder=None, radio=False):
"""(episodes, total) for the episode list. Two queries, whatever the
page size: the count and the page. The user's progress row comes in by
a join, queue membership by a subquery. No shownotes (up to 50 KB each).
filter_name: one of EPISODE_FILTERS, or several (a list, or joined by
commas), which all have to match. sort: one of EPISODE_SORTS; by default
the newest first, in the history the last listened first. ids: only these
episodes (the ones downloaded on a device). notes: `q` also matches the
shownotes; title matches come first. folder: only the feeds in this folder ('': the feeds
in none). radio: not the ones the Podcast-Radio is done with (PodcastRadioHeard)."""
names = filter_name.split(',') if isinstance(filter_name, str) else list(filter_name)
names = [n for n in names if n and n != 'all']
qs = (
PodcastEpisode.objects.filter(feed__user=user)
.annotate(mine=FilteredRelation('progress', condition=Q(progress__user=user)))
.annotate(
position_seconds=Coalesce('mine__position_seconds', Value(0)),
played=Coalesce('mine__played', Value(False)),
favorite=Coalesce('mine__favorite', Value(False)),
last_played_at=F('mine__updated_at'),
in_queue=Exists(PodcastQueue.objects.filter(user=user, episode=OuterRef('pk'))),
in_later=Exists(PodcastLater.objects.filter(user=user, episode=OuterRef('pk'))),
)
)
for name in names:
qs = qs.filter(EPISODE_FILTERS[name])
if feed_id is not None:
qs = qs.filter(feed_id=feed_id)
if folder is not None:
qs = qs.filter(feed_id__in=[
fid for fid, fs in PodcastFeed.objects.filter(user=user).values_list('id', 'folders')
if (folder in (fs or []) if folder else not fs)])
if ids is not None:
qs = qs.filter(id__in=ids)
if radio:
qs = qs.exclude(Exists(PodcastRadioHeard.objects.filter(user=user, episode=OuterRef('pk'))))
if feed_id is None and ids is None: # audiobooks are listened to in the library
qs = qs.exclude(feed__kind=PodcastFeed.AUDIOBOOK)
order = EPISODE_SORTS[sort or ('played' if 'history' in names else 'new')]
if q and notes:
qs = qs.filter(Q(title__icontains=q) | Q(description__icontains=q)).annotate(
title_hit=Case(When(title__icontains=q, then=Value(0)), default=Value(1), output_field=IntegerField()),
)
order = ('title_hit', *order)
elif q:
qs = qs.filter(title__icontains=q)
qs = qs.order_by(*order)
total = qs.count()
episodes = list(qs.values(*EPISODE_LIST_FIELDS)[offset:offset + limit]) if limit else []
for ep in episodes:
ep['pub_date'] = ep['pub_date'].isoformat() if ep['pub_date'] else None
ep['last_played_at'] = ep['last_played_at'].isoformat() if ep['last_played_at'] else None
ep['played'] = bool(ep['played'])
ep['favorite'] = bool(ep['favorite'])
ep['in_queue'] = bool(ep['in_queue'])
ep['in_later'] = bool(ep['in_later'])
ep['discovered_at'] = ep['discovered_at'].isoformat() if ep['discovered_at'] else None
return episodes, total
@csrf_exempt
@require_http_methods(['GET'])
def episode_list(request):
"""Episodes of all the user's feeds, filtered (all, unplayed, in_progress,
played, favorites, history, queued; several joined by commas all have to
match), optionally of one feed, of some episodes (`ids`, comma-separated)
or matching `q` (in the title; with notes=1 also in the shownotes), of the
feeds in `folder` (empty: in none), sorted by `sort` (new, old, long, short,
title, played)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
names = [n.strip() for n in request.GET.get('filter', '').split(',') if n.strip()]
if any(n not in EPISODE_FILTERS for n in names):
return JsonResponse({'error': 'unknown filter'}, status=400)
sort = request.GET.get('sort', '').strip() or None
if sort is not None and sort not in EPISODE_SORTS:
return JsonResponse({'error': 'unknown sort'}, status=400)
try:
offset = max(_int_param(request, 'offset', 0), 0)
limit = min(max(_int_param(request, 'limit', EPISODE_LIST_LIMIT), 0), EPISODE_LIST_MAX)
feed_id = _int_param(request, 'feed_id', None)
raw_ids = request.GET.get('ids')
ids = None if raw_ids is None else [int(i) for i in raw_ids.split(',') if i.strip()][:EPISODE_IDS_MAX]
except ValueError:
return JsonResponse({'error': 'offset, limit, feed_id and ids must be numbers'}, status=400)
episodes, total = _episode_list_data(
request.user, names, feed_id, request.GET.get('q', '').strip(), offset, limit,
sort=sort, ids=ids, notes=request.GET.get('notes') == '1',
folder=folders_mod.clean_name(request.GET['folder']) if 'folder' in request.GET else None,
)
return JsonResponse({'episodes': episodes, 'offset': offset, 'limit': limit, 'total': total})
# ---------------------------------------------------------------------------
# Listening statistics
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['GET'])
def podcast_stats(request):
"""How much the user listened, in total and per feed; `days` limits it to
progress rows updated within the last N days."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
rows = EpisodeProgress.objects.filter(user=request.user, episode__feed__user=request.user)
since = None
raw_days = request.GET.get('days', '').strip()
if raw_days:
try:
days = int(raw_days)
except ValueError:
return JsonResponse({'error': 'days must be a number'}, status=400)
if days < 0:
return JsonResponse({'error': 'days must not be negative'}, status=400)
since = timezone.now() - timedelta(days=days)
rows = rows.filter(updated_at__gte=since)
# A played episode counts with its full length (or as far as it got, if
# the feed gave no length); anything else as far as it got.
listened = Case(
When(played=True, episode__duration_seconds__gt=0, then=F('episode__duration_seconds')),
default=F('position_seconds'),
output_field=IntegerField(),
)
per_feed = (
rows.order_by()
.values('episode__feed_id', 'episode__feed__title', 'episode__feed__artwork_url')
.annotate(
seconds=Sum(listened),
played_count=Count('id', filter=Q(played=True)),
started_count=Count('id', filter=Q(played=False, position_seconds__gt=0)),
)
)
total_seconds = episodes_played = episodes_started = 0
feeds = []
for row in per_feed:
seconds = row['seconds'] or 0
total_seconds += seconds
episodes_played += row['played_count']
episodes_started += row['started_count']
if seconds > 0:
feeds.append({
'feed_id': row['episode__feed_id'],
'title': row['episode__feed__title'],
'artwork_url': row['episode__feed__artwork_url'],
'seconds': seconds,
'episodes_played': row['played_count'],
})
feeds.sort(key=lambda f: (-f['seconds'], f['title'].lower(), f['feed_id']))
return JsonResponse({
'total_seconds': total_seconds,
'episodes_played': episodes_played,
'episodes_started': episodes_started,
'since': since.isoformat() if since else None,
'feeds': feeds,
})
# ---------------------------------------------------------------------------
# Import OPML
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def import_opml(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
f = request.FILES.get('file')
if not f:
return JsonResponse({'error': 'no file uploaded'}, status=400)
try:
content = f.read().decode('utf-8', errors='replace')
root = ET.fromstring(content)
except Exception as e:
return JsonResponse({'error': f'invalid OPML: {e}'}, status=400)
added = 0
skipped = 0
new_feed = PodcastSettings.new_feed_defaults(request.user)
for outline in root.iter('outline'):
rss_url = outline.get('xmlUrl', '').strip()
if not rss_url:
continue
title = outline.get('title', outline.get('text', rss_url))[:300]
_, created = PodcastFeed.objects.get_or_create(
user=request.user,
rss_url=rss_url,
defaults={'title': title, **new_feed},
)
if created:
added += 1
else:
skipped += 1
return JsonResponse({'ok': True, 'added': added, 'skipped': skipped})
# ---------------------------------------------------------------------------
# Export OPML
# ---------------------------------------------------------------------------
def _xml_text(s):
"""Drop characters XML 1.0 can't carry at all (escaping is ElementTree's job)."""
return ''.join(c for c in s if c in '\t\n\r' or (' ' <= c <= '퟿') or ('' <= c <= '<EFBFBD>')
or ord(c) >= 0x10000)
@csrf_exempt
@require_http_methods(['GET'])
def export_opml(request):
"""The user's subscriptions as an OPML 2.0 file, for other podcast apps
(and for import_opml)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
root = ET.Element('opml', version='2.0')
head = ET.SubElement(root, 'head')
ET.SubElement(head, 'title').text = 'diora Podcasts'
body = ET.SubElement(root, 'body')
for feed in request.user.podcast_feeds.order_by('title', 'id').only('title', 'rss_url', 'link'):
title = _xml_text(feed.title or feed.rss_url)
ET.SubElement(body, 'outline', {
'type': 'rss',
'text': title,
'title': title,
'xmlUrl': _xml_text(feed.rss_url),
'htmlUrl': _xml_text(feed.link),
})
ET.indent(root)
xml = '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding='unicode') + '\n'
response = HttpResponse(xml.encode('utf-8'), content_type='text/x-opml; charset=utf-8')
response['Content-Disposition'] = 'attachment; filename="diora-podcasts.opml"'
return response
# ---------------------------------------------------------------------------
# Refresh feed now
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def refresh_feed_now(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
feed_id = body.get('feed_id')
if not feed_id:
return JsonResponse({'error': 'feed_id required'}, status=400)
try:
feed = PodcastFeed.objects.get(pk=feed_id, user=request.user)
except PodcastFeed.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
# Asked for by hand: read it whole even if it looks unchanged (validators, body hash),
# so corrections to episodes already known come in too.
feed.http_etag = feed.http_last_modified = feed.http_body_hash = ''
try:
new_episodes = _refresh_feed(feed)
except Exception as e:
return JsonResponse({'error': str(e)}, status=502)
return JsonResponse({'ok': True, 'new_episodes': new_episodes})
@csrf_exempt
@require_http_methods(['POST'])
def refresh_all_feeds(request):
"""Refresh all of the user's feeds on the server in one go. Feeds the
hourly job (or an earlier call) refreshed within `max_age` seconds are
skipped. Clients then fetch the result once (state/, inbox/ …).
The user asked, so feeds the hourly job backs off from are tried too.
It answers within REFRESH_WAIT seconds: `left` feeds weren't done by
then (done: false) and wait for the next call or the hourly job."""
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)
try:
max_age = max(int(body.get('max_age', 300)), 0)
except (TypeError, ValueError):
return JsonResponse({'error': 'max_age must be a number'}, status=400)
cutoff = timezone.now() - timedelta(seconds=max_age)
feeds = list(request.user.podcast_feeds.filter(Q(last_refreshed_at__isnull=True) | Q(last_refreshed_at__lt=cutoff)))
refreshed, failed, new = _refresh_feeds(feeds, deadline=time.monotonic() + REFRESH_WAIT)
left = len(feeds) - refreshed - failed
return JsonResponse({'ok': True, 'refreshed': refreshed, 'failed': failed, 'new_episodes': new,
'done': left == 0, 'left': left})
@csrf_exempt
def podcast_state(request):
"""Everything a client shows first, in one response: feeds, queue, the
first page of the inbox (inbox_limit of inbox_total new episodes), the
settings, how many AntennaPod downloads wait to be offered
(import-downloads/) and the episodes discarded lately ("Folge löschen":
a device deletes its older download of them)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
limit = min(max(int(request.GET.get('inbox_limit', 100)), 0), 1000)
except ValueError:
return JsonResponse({'error': 'inbox_limit must be a number'}, status=400)
s = PodcastSettings.for_user(request.user)
downloads = _import_downloads(request.user, s.import_downloads)
return JsonResponse({
'feeds': _feeds_data(request.user),
'queue': _queue_data(request.user),
'inbox': _inbox_data(request.user, 0, limit, with_description=False),
'inbox_total': _inbox_episodes(request.user).count(),
'inbox_limit': limit,
'settings': _settings_data(s),
# docs/podcasts.md: the Merkliste, whether the two lists are on, the Strom's mark.
'later': _later_data(request.user),
'lists_v2': s.lists_v2,
'stream_seen_at': s.stream_seen_at.isoformat() if s.stream_seen_at else None,
'import_downloads': {'count': len(downloads), 'bytes': sum(e['size'] for e in downloads)},
'discarded': _discarded_data(request.user),
'server_time': timezone.now().isoformat(),
})
# ---------------------------------------------------------------------------
# Queue
# ---------------------------------------------------------------------------
@csrf_exempt
def queue_get(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
return JsonResponse({'queue': _queue_data(request.user)})
def _queue_data(user):
items = list(
PodcastQueue.objects
.filter(user=user)
.order_by('position', 'added_at', 'id')
.select_related('episode__feed')
.values(
'id', 'position',
'episode__id', 'episode__title', 'episode__audio_url', 'episode__link',
'episode__duration_seconds', 'episode__artwork_url',
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
'episode__pub_date',
)
)
ep_ids = [item['episode__id'] for item in items]
progress_map = {
p['episode_id']: p['position_seconds']
for p in EpisodeProgress.objects.filter(
user=user, episode_id__in=ep_ids,
).values('episode_id', 'position_seconds')
}
for item in items:
item['position_seconds'] = progress_map.get(item['episode__id'], 0)
if item['episode__pub_date']:
item['episode__pub_date'] = item['episode__pub_date'].isoformat()
return items
@csrf_exempt
@require_http_methods(['POST'])
def queue_add(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
# One episode (episode_id) or several in order (episode_ids). Where they
# go: `where` if given, else the user's setting. `current_episode_id` is
# what the client is playing, for after_current.
ids = body.get('episode_ids')
if ids is None:
ids = [body.get('episode_id')]
if not isinstance(ids, list):
return JsonResponse({'error': 'episode_ids must be a list'}, status=400)
where = body.get('where') or PodcastSettings.for_user(request.user).enqueue_position
if where not in QUEUE_POSITIONS:
return JsonResponse({'error': 'unknown position'}, status=400)
found = set(
PodcastEpisode.objects.filter(pk__in=[i for i in ids if isinstance(i, int)], feed__user=request.user)
.values_list('id', flat=True)
)
if not found:
return JsonResponse({'error': 'not found'}, status=404)
added = _enqueue(
request.user, [i for i in ids if i in found], where,
current_hint=body.get('current_episode_id'),
)
return JsonResponse({'ok': True, 'created': bool(added), 'added': added})
@csrf_exempt
@require_http_methods(['POST'])
def queue_remove(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
_dequeue(request.user, [body.get('episode_id')])
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['POST'])
def queue_reorder(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
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})
def _episode_ids(request):
"""The user's episodes among body['episode_ids'] (in order), or a JsonResponse error."""
try:
body = json.loads(request.body or b'{}')
except (json.JSONDecodeError, ValueError):
return None, JsonResponse({'error': 'invalid JSON'}, status=400)
ids = body.get('episode_ids') if isinstance(body, dict) else None
if not isinstance(ids, list) or not all(isinstance(i, int) and not isinstance(i, bool) for i in ids):
return None, JsonResponse({'error': 'episode_ids (a list of numbers) required'}, status=400)
found = set(PodcastEpisode.objects.filter(pk__in=ids[:EPISODE_IDS_MAX], feed__user=request.user)
.values_list('id', flat=True))
return [i for i in ids if i in found], None
@csrf_exempt
@require_http_methods(['POST'])
def later_add(request):
"""{"episode_ids": [...]}: onto the Merkliste (docs/podcasts.md)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
ids, err = _episode_ids(request)
if err:
return err
return JsonResponse({'ok': True, 'added': _later_add(request.user, ids)})
@csrf_exempt
@require_http_methods(['POST'])
def later_remove(request):
"""{"episode_ids": [...]}: off the Merkliste (the Wiedergabeliste is left alone)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
ids, err = _episode_ids(request)
if err:
return err
_later_remove(request.user, ids)
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['POST'])
def lists_switch(request):
"""{"on": true}: the two lists of docs/podcasts.md — what is in the queue now becomes
the Merkliste, the Wiedergabeliste starts empty. {"on": false}: back, the Merkliste
joins the end of the queue."""
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)
on = bool(body.get('on')) if isinstance(body, dict) else False
user = request.user
with transaction.atomic():
s = PodcastSettings.for_user(user)
if on and not s.lists_v2:
_later_add(user, _queue_order(user))
PodcastQueue.objects.filter(user=user).delete()
elif not on and s.lists_v2:
later = list(PodcastLater.objects.filter(user=user)
.order_by(F('episode__pub_date').asc(nulls_last=True)).values_list('episode_id', flat=True))
_enqueue(user, later, PodcastSettings.BACK)
PodcastLater.objects.filter(user=user).delete()
s.lists_v2 = on
s.save(update_fields=['lists_v2'])
return JsonResponse({'ok': True, 'lists_v2': on, 'queue': len(_queue_order(user)),
'later': PodcastLater.objects.filter(user=user).count()})
@csrf_exempt
@require_http_methods(['POST'])
def stream_seen(request):
"""The user looked at the Strom now: returns the time before (episodes found after it
carry the mark) and keeps now."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
s = PodcastSettings.for_user(request.user)
before = s.stream_seen_at
s.stream_seen_at = timezone.now()
s.save(update_fields=['stream_seen_at'])
return JsonResponse({'ok': True, 'before': before.isoformat() if before else None,
'now': s.stream_seen_at.isoformat()})
def _choice(choices, what):
def parse(value):
if value not in choices:
raise LookupError(f'unknown {what}')
return value
return parse
def _skip_seconds(value):
if value is None:
return None
if not _number(value) or value != int(value) or int(value) not in PodcastSettings.SKIP_CHOICES:
raise ValueError
return int(value)
def _smart_played(value):
return None if value is None else _whole(value, 0, 3600)
def _speed_presets(value):
"""Up to MAX_PRESETS speeds (MIN_SPEED to MAX_SPEED), rounded to two
places, each once, ascending; null = the apps' default list."""
if value is None:
return None
if not isinstance(value, list) or not 1 <= len(value) <= PodcastSettings.MAX_PRESETS:
raise ValueError
out = set()
for v in value:
if not _number(v) or not math.isfinite(v) or not MIN_SPEED <= v <= MAX_SPEED:
raise ValueError
out.add(round(float(v), 2))
return sorted(out)
# POST settings/: each field's parser; a LookupError's text is the error.
SETTING_PARSERS = {
'enqueue_position': _choice(QUEUE_POSITIONS, 'enqueue_position'),
'auto_queue_position': _choice(QUEUE_POSITIONS, 'auto_queue_position'),
'keep_sorted': _choice(KEEP_SORTED, 'keep_sorted'),
'auto_download_default': _boolean,
'skip_back': _skip_seconds,
'skip_forward': _skip_seconds,
'smart_played': _smart_played,
'speed_presets': _speed_presets,
'keep_skipped': _boolean,
'delete_dequeue': _boolean,
'mobile_downloads': _boolean,
'mobile_streaming': _choice({key for key, _ in PodcastSettings.STREAMING}, 'mobile_streaming'),
'times_by_speed': _boolean,
}
def _settings_data(s):
return {
'enqueue_position': s.enqueue_position, 'auto_queue_position': s.auto_queue_position,
'keep_sorted': s.keep_sorted, 'auto_download_default': s.auto_download_default,
**{name: getattr(s, name) for name in PodcastSettings.PLAYBACK_FIELDS},
}
def _parse_settings(body, names):
"""{field: value} for those of `names` in body; raises (field, error text)."""
out = {}
for name in names:
if name not in body:
continue
try:
out[name] = SETTING_PARSERS[name](body[name])
except LookupError as e:
raise ValueError(name, str(e))
except (ValueError, TypeError, OverflowError):
raise ValueError(name, f'invalid {name}')
return out
@csrf_exempt
def podcast_settings(request):
"""GET the user's podcast settings; POST changes some of them (any of
SETTING_PARSERS; nothing changes if one value is invalid).
POST {"initial": {...}} sets the playback settings that are still unset
(null) and leaves the others: how an app hands over its own values once,
the first time it finds the account without them."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
s = PodcastSettings.for_user(request.user)
if request.method == 'POST':
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
if not isinstance(body, dict):
return JsonResponse({'error': 'invalid JSON'}, status=400)
initial = body.get('initial') or {}
if not isinstance(initial, dict):
return JsonResponse({'error': 'invalid initial', 'field': 'initial'}, status=400)
try:
changes = _parse_settings(body, SETTING_PARSERS)
first = _parse_settings(initial, PodcastSettings.UNSET_FIELDS)
except ValueError as e:
name, text = e.args
return JsonResponse({'error': text, 'field': name}, status=400)
if first:
# Only where nothing is set yet, decided by the database (two
# devices handing theirs over at once: the first one wins).
with transaction.atomic():
s = PodcastSettings.objects.select_for_update().get(pk=s.pk)
fields = [name for name, value in first.items() if getattr(s, name) is None and value is not None]
for name in fields:
setattr(s, name, first[name])
if fields:
s.save(update_fields=fields)
if changes:
for name, value in changes.items():
setattr(s, name, value)
s.save(update_fields=list(changes))
if 'keep_sorted' in changes:
_keep_sorted(request.user, s.keep_sorted) # sorted right away
elif request.method != 'GET':
return JsonResponse({'error': 'method not allowed'}, status=405)
return JsonResponse({
**_settings_data(s),
'positions': [{'value': k, 'label': v} for k, v in PodcastSettings.POSITIONS],
'skip_choices': list(PodcastSettings.SKIP_CHOICES),
'speed_range': [MIN_SPEED, MAX_SPEED],
})
# ---------------------------------------------------------------------------
# Progress
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def save_progress(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
episode_id = body.get('episode_id')
position = int(body.get('position_seconds', 0))
try:
episode = PodcastEpisode.objects.get(pk=episode_id, feed__user=request.user)
except PodcastEpisode.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
# A feed without durations (YouTube): the one the player found, once.
duration = body.get('duration_seconds')
if not episode.duration_seconds and _number(duration) and math.isfinite(duration) and 1 <= duration <= MAX_DURATION:
episode.duration_seconds = int(duration)
PodcastEpisode.objects.filter(pk=episode.pk, duration_seconds=0).update(duration_seconds=int(duration))
progress, _ = EpisodeProgress.objects.get_or_create(
user=request.user,
episode=episode,
)
progress.position_seconds = position
# Started: no longer new.
if position > 0:
progress.dismissed = True
# Within the last seconds: played (same rule as the apps).
if counts_as_played(position, episode.duration_seconds):
progress.played = True
_later_remove(request.user, [episode.id]) # heard: off the Merkliste
progress.save()
return JsonResponse({'ok': True, 'played': progress.played})
@csrf_exempt
@require_http_methods(['POST'])
def mark_played(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
episode_id = body.get('episode_id')
played = bool(body.get('played', True))
try:
episode = PodcastEpisode.objects.get(pk=episode_id, feed__user=request.user)
except PodcastEpisode.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
progress, _ = EpisodeProgress.objects.get_or_create(
user=request.user,
episode=episode,
)
progress.played = played
fields = ['played', 'updated_at']
if not played and counts_as_played(progress.position_seconds, episode.duration_seconds):
progress.position_seconds = 0 # heard to the end, now "not heard": from the start again
fields.append('position_seconds')
progress.save(update_fields=fields)
if played:
_later_remove(request.user, [episode.id])
return JsonResponse({'ok': True, 'played': played})
# ---------------------------------------------------------------------------
# Dismiss episodes (hide from inbox without marking played)
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def dismiss_episodes(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
episode_ids = body.get('episode_ids', [])
dismissed = bool(body.get('dismissed', True))
if not isinstance(episode_ids, list) or not episode_ids:
return JsonResponse({'error': 'episode_ids required'}, status=400)
# Only allow dismissing episodes that belong to this user
valid_ids = list(
PodcastEpisode.objects.filter(
id__in=episode_ids, feed__user=request.user
).values_list('id', flat=True)
)
for ep_id in valid_ids:
progress, _ = EpisodeProgress.objects.get_or_create(
user=request.user,
episode_id=ep_id,
)
progress.dismissed = dismissed
progress.save(update_fields=['dismissed', 'updated_at'])
return JsonResponse({'ok': True, 'count': len(valid_ids)})
# ---------------------------------------------------------------------------
# "Folge löschen": out of the queue and "Neu", its downloads gone everywhere
# ---------------------------------------------------------------------------
DISCARDED_DAYS = 30 # how long state/ lists a discard for devices still to delete their copy
def _discarded_data(user, now=None):
"""[{"id": episode id, "at": ISO ms}] of the episodes discarded lately:
a device deletes its download of one if it finished before `at`."""
since = (now or timezone.now()) - timedelta(days=DISCARDED_DAYS)
rows = (EpisodeProgress.objects.filter(user=user, discarded_at__gte=since)
.order_by('discarded_at').values_list('episode_id', 'discarded_at'))
return [{'id': ep_id, 'at': _iso_ms(at)} for ep_id, at in rows]
@csrf_exempt
@require_http_methods(['POST'])
def discard_episodes(request):
"""{"episode_ids": [...]}: the episodes leave the queue and "Neu" (like
dismiss: not played) and every device deletes its download of them (they
see them under "discarded" in state/). Played, the saved place and the
star stay as they are; the listening time (updated_at) too. Episodes that
aren't the user's are left out (count)."""
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)
episode_ids = body.get('episode_ids')
if not isinstance(episode_ids, list) or not episode_ids:
return JsonResponse({'error': 'episode_ids (a list) required'}, status=400)
if not all(isinstance(i, int) and not isinstance(i, bool) for i in episode_ids):
return JsonResponse({'error': 'episode_ids must be numbers'}, status=400)
wanted = set(episode_ids[:EPISODE_IDS_MAX])
valid = sorted(
PodcastEpisode.objects.filter(id__in=wanted, feed__user=request.user).values_list('id', flat=True)
)
now = timezone.now()
with transaction.atomic():
_dequeue(request.user, valid)
_later_remove(request.user, valid)
# update() leaves updated_at alone: throwing an episode away isn't listening to it.
rows = EpisodeProgress.objects.filter(user=request.user, episode_id__in=valid)
rows.update(dismissed=True, discarded_at=now)
have = set(rows.values_list('episode_id', flat=True))
EpisodeProgress.objects.bulk_create(
[EpisodeProgress(user=request.user, episode_id=i, dismissed=True, discarded_at=now)
for i in valid if i not in have],
ignore_conflicts=True,
)
return JsonResponse({'ok': True, 'count': len(valid), 'at': _iso_ms(now)})
# ---------------------------------------------------------------------------
# Inbox
# ---------------------------------------------------------------------------
@csrf_exempt
def inbox(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
limit = min(int(request.GET.get('limit', getattr(settings, 'PODCAST_INBOX_PAGE_SIZE', 200))), 1000)
offset = max(int(request.GET.get('offset', 0)), 0)
return JsonResponse({'episodes': _inbox_data(request.user, offset, limit), 'offset': offset, 'limit': limit})
def _inbox_fields(with_description=True):
fields = ['id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
'feed__id', 'feed__title', 'feed__artwork_url']
if with_description:
fields.insert(2, 'description')
return fields
def _add_user_state(user, episodes):
"""Complete inbox-style episode dicts (_inbox_fields) with the user's
position and whether they are queued."""
progress_map = {
ep['episode_id']: ep
for ep in EpisodeProgress.objects.filter(
user=user,
episode_id__in=[e['id'] for e in episodes],
).values('episode_id', 'position_seconds')
}
queued_ids = set(
PodcastQueue.objects.filter(
user=user,
episode_id__in=[e['id'] for e in episodes],
).values_list('episode_id', flat=True)
)
for ep in episodes:
if ep['pub_date']:
ep['pub_date'] = ep['pub_date'].isoformat()
prog = progress_map.get(ep['id'], {})
ep['position_seconds'] = prog.get('position_seconds', 0)
ep['in_queue'] = ep['id'] in queued_ids
return episodes
def _inbox_episodes(user):
"""The inbox: the user's episodes neither played nor dismissed."""
hidden = EpisodeProgress.objects.filter(user=user).filter(
Q(played=True) | Q(dismissed=True)
).values('episode_id')
return PodcastEpisode.objects.filter(feed__user=user).exclude(id__in=hidden)
def _inbox_data(user, offset, limit, with_description=True):
"""Inbox episodes. Shownotes can be large (up to 50 KB each); the apps'
state/ leaves them out and loads one episode's at a time."""
episodes = list(
_inbox_episodes(user)
.select_related('feed')
.order_by('-pub_date')
.values(*_inbox_fields(with_description))[offset:offset + limit]
)
return _add_user_state(user, episodes)
# ---------------------------------------------------------------------------
# Episodes downloaded in AntennaPod (antennapod.py): offered once for download
# ---------------------------------------------------------------------------
def _import_downloads(user, entries, fields=('id',)):
"""The entries of PodcastSettings.import_downloads still worth offering
(the episode is still there, the user's, with an audio URL), in their
order: dicts of `fields` plus 'size' (bytes, 0 = unknown)."""
sizes = {}
for e in entries or []:
if isinstance(e, dict) and isinstance(e.get('episode_id'), int):
size = e.get('size')
sizes.setdefault(e['episode_id'], size if isinstance(size, int) and size > 0 else 0)
ids = list(sizes)
found = {}
for i in range(0, len(ids), 500):
for ep in (PodcastEpisode.objects.filter(id__in=ids[i:i + 500], feed__user=user)
.exclude(audio_url='').values(*fields)):
found[ep['id']] = ep
return [{**found[ep_id], 'size': sizes[ep_id]} for ep_id in ids if ep_id in found]
@csrf_exempt
@require_http_methods(['GET'])
def import_downloads(request):
"""The episodes that were on the phone in AntennaPod, inbox-style plus
their size; the app offers once to download them again."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
entries = PodcastSettings.objects.filter(user=request.user).values_list('import_downloads', flat=True).first()
episodes = _add_user_state(request.user, _import_downloads(request.user, entries, _inbox_fields(False)))
return JsonResponse({'episodes': episodes, 'bytes': sum(e['size'] for e in episodes)})
@csrf_exempt
@require_http_methods(['POST'])
def clear_import_downloads(request):
"""Offered (downloaded or declined): the list is done with."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
PodcastSettings.objects.filter(user=request.user).update(import_downloads=[])
return JsonResponse({'ok': True})
# ---------------------------------------------------------------------------
# Player sync (like Spotify Connect): one player state per user. The device
# that starts playing becomes the active one and stays it, also while paused,
# as long as it is online. The others hear it through the stream, stop their
# own playback and show it as playing (or paused) there, with remote controls
# (command/), "play here" (a report of their own) and moving it to another
# device (command transfer). Presence: the stream and every report or command
# keep the device's last_seen fresh.
# ---------------------------------------------------------------------------
PLAYER_ONLINE_SECONDS = 45 # not seen for longer: the device is gone
PLAYER_ACTIONS = ('play', 'pause', 'seek', 'skip', 'next', 'stop', 'transfer', 'sleep')
SLEEP_MAX_MINUTES = 600 # a sleep timer: 1 min to 10 h, or after 1 to SLEEP_MAX_EPISODES
SLEEP_MAX_EPISODES = 99
PLAYER_DEVICES_SHOWN = 10 # online ones, then the last ones seen in DEVICES_DAYS
PLAYER_DEVICES_DAYS = 7
STREAM_SECONDS = 300 # one stream connection; clients reconnect with their seq
STREAM_POLL_SECONDS = 1 # how often the stream looks for a new seq
STREAM_PING_SECONDS = 15 # a comment to keep the connection, and the device's presence
STREAM_PRESENCE_SECONDS = 5 # how often the stream looks for devices that went away
def _iso_ms(dt):
"""2026-10-02T12:00:00.123Z"""
if dt is None:
return None
dt = dt.astimezone(dt_timezone.utc)
return dt.strftime('%Y-%m-%dT%H:%M:%S.') + f'{dt.microsecond // 1000:03d}Z'
def _finite(value):
return _number(value) and math.isfinite(value)
def _online_since(now):
return now - timedelta(seconds=PLAYER_ONLINE_SECONDS)
def _online_ids(user, now):
return sorted(PlayerDevice.objects.filter(user=user, last_seen__gte=_online_since(now))
.values_list('device_id', flat=True))
def _seen(user, device_id, device_name='', kind=None, now=None):
"""The device is here (stream, ping, report, command)."""
if not device_id:
return
now = now or timezone.now()
fields = {'last_seen': now}
if device_name:
fields['device_name'] = device_name[:100]
if kind in {k for k, _ in PlayerDevice.KINDS if k}:
fields['kind'] = kind
# Single statements, no read-then-write transaction (update_or_create):
# under SQLite two of those at the same time end in "database is locked".
rows = PlayerDevice.objects.filter(user=user, device_id=device_id[:64])
if not rows.update(**fields):
PlayerDevice.objects.bulk_create([PlayerDevice(user=user, device_id=device_id[:64], **fields)],
ignore_conflicts=True)
rows.update(**fields) # someone else was quicker
def _check_online(user, now=None):
"""A device came or went: seq + 1, so the others see it. Returns the state."""
now = now or timezone.now()
online = _online_ids(user, now)
p = PlayerState.for_user(user)
if p.online_devices == online:
return p
return _change(p, online_devices=online)
def _change(p, **fields):
"""Write fields of the state and count the change: seq + 1 in the same
UPDATE, so that it only ever goes up, also with requests at the same
time. Returns the state as it is now."""
PlayerState.objects.filter(pk=p.pk).update(seq=F('seq') + 1, **fields)
p.refresh_from_db()
return p
def _is_online(user, device_id, now):
return bool(device_id) and PlayerDevice.objects.filter(
user=user, device_id=device_id, last_seen__gte=_online_since(now)).exists()
def _devices_json(user, p, now):
"""Online devices first (by name), then the last ones seen lately."""
since = _online_since(now)
rows = list(PlayerDevice.objects.filter(user=user, last_seen__gte=now - timedelta(days=PLAYER_DEVICES_DAYS))
.order_by('-last_seen').values('device_id', 'device_name', 'last_seen'))
online = sorted((r for r in rows if r['last_seen'] >= since), key=lambda r: (r['device_name'].casefold(), r['device_id']))
offline = [r for r in rows if r['last_seen'] < since]
return [
{'id': r['device_id'], 'name': r['device_name'], 'online': r['last_seen'] >= since,
'active': r['device_id'] == p.device_id}
for r in (online + offline)[:PLAYER_DEVICES_SHOWN]
]
def _player_json(user, p, now=None):
now = now or timezone.now()
episode = None
if p.kind == PlayerState.PODCAST and p.episode_id:
rows = list(PodcastEpisode.objects.filter(pk=p.episode_id).values(*_inbox_fields(False)))
episode = _add_user_state(user, rows)[0] if rows else None
station = None
if p.kind == PlayerState.RADIO:
station = {'url': p.station_url, 'name': p.station_name, 'id': p.station_id}
devices = _devices_json(user, p, now)
return {
'seq': p.seq,
'kind': p.kind,
'episode': episode,
'podcast_radio': bool(episode) and p.podcast_radio,
'station': station,
'position_ms': p.position_ms,
'playing': p.playing,
'speed': p.speed,
'device_id': p.device_id,
'device_name': p.device_name,
'updated_at': _iso_ms(p.updated_at),
'server_time': _iso_ms(now),
'command': p.command,
'sleep': {'left_ms': p.sleep_left_ms, 'episodes': p.sleep_episodes},
'devices': devices,
'active_online': _is_online(user, p.device_id, now),
}
def _player_body(request):
"""The JSON body and the sending device, or an error response."""
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return None, JsonResponse({'error': 'invalid JSON'}, status=400)
if not isinstance(body, dict):
return None, JsonResponse({'error': 'invalid JSON'}, status=400)
device_id = body.get('device_id')
if not isinstance(device_id, str) or not device_id.strip() or len(device_id) > 64:
return None, JsonResponse({'error': 'device_id required'}, status=400)
body['device_name'] = str(body.get('device_name') or '')[:100]
return body, None
@csrf_exempt
@require_http_methods(['GET'])
def player_state(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
return JsonResponse(_player_json(request.user, PlayerState.for_user(request.user)))
@csrf_exempt
@require_http_methods(['POST'])
def player_report(request):
"""A device says what it plays. Accepted from the active device; from
another one when playback was just started there (start: by the user, a
command, a transfer — it becomes the active device), or when the active
device is gone (offline, or there is none): then the newest report wins.
Otherwise ignored, such as a periodic "playing" report of a device that
was just taken over. The answer is the state either way."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
body, error = _player_body(request)
if error:
return error
kind = body.get('kind') or ''
if kind not in {k for k, _ in PlayerState.KINDS}:
return JsonResponse({'error': 'unknown kind'}, status=400)
playing = body.get('playing')
if not isinstance(playing, bool):
return JsonResponse({'error': 'playing must be true or false'}, status=400)
position = body.get('position_ms', 0)
if not _finite(position):
return JsonResponse({'error': 'position_ms must be a number'}, status=400)
speed = body.get('speed')
if speed is None:
speed = 1
if not _finite(speed) or not 0.1 <= speed <= 10:
return JsonResponse({'error': 'invalid speed'}, status=400)
episode_id = None
station = ('', '', None)
if kind == PlayerState.PODCAST:
episode_id = body.get('episode_id')
if not _number(episode_id) or episode_id != int(episode_id) or not \
PodcastEpisode.objects.filter(pk=int(episode_id), feed__user=request.user).exists():
return JsonResponse({'error': 'unknown episode'}, status=400)
episode_id = int(episode_id)
elif kind == PlayerState.RADIO:
st = body.get('station')
if not isinstance(st, dict) or not isinstance(st.get('url'), str) or not st['url'].strip():
return JsonResponse({'error': 'station.url required'}, status=400)
sid = st.get('id')
station = (st['url'].strip()[:1000], str(st.get('name') or '')[:300],
int(sid) if _number(sid) and sid == int(sid) else None)
# The sleep timer, if the device has one (older clients don't say: none).
sleep = body.get('sleep') if isinstance(body.get('sleep'), dict) else {}
sleep_left = sleep.get('left_ms', 0)
sleep_left = min(int(sleep_left), SLEEP_MAX_MINUTES * 60_000) if _finite(sleep_left) and sleep_left > 0 else 0
sleep_episodes = sleep.get('episodes', 0)
sleep_episodes = min(int(sleep_episodes), SLEEP_MAX_EPISODES) if _finite(sleep_episodes) and sleep_episodes > 0 else 0
device_id = body['device_id']
done = body.get('done_command')
start = body.get('start') is True
now = timezone.now()
_seen(request.user, device_id, body['device_name'], now=now)
p = PlayerState.for_user(request.user)
done = bool(done) and bool(p.command) and done == p.command.get('id')
if device_id == p.device_id or (playing and start) or not _is_online(request.user, p.device_id, now):
p = _change(
p,
kind=kind,
episode_id=episode_id,
# The episode plays in the Podcast-Radio (docs/podcasts.md).
podcast_radio=kind == PlayerState.PODCAST and body.get('podcast_radio') is True,
station_url=station[0], station_name=station[1], station_id=station[2],
position_ms=max(int(position), 0) if kind == PlayerState.PODCAST else 0,
playing=playing and bool(kind),
speed=float(speed),
sleep_left_ms=sleep_left,
sleep_episodes=sleep_episodes,
device_id=device_id,
device_name=body['device_name'],
updated_at=now,
# Done (also a transfer, done in the report that makes its target
# the active device), or meant for the device before.
command=None if done or device_id != p.device_id else p.command,
)
elif done:
# A device that isn't the active one did its command (a transfer
# that couldn't play): done all the same.
p = _change(p, command=None)
p = _check_online(request.user, now)
return JsonResponse(_player_json(request.user, p, now))
@csrf_exempt
@require_http_methods(['POST'])
def player_command(request):
"""Remote control: a command for a device (to_device_id, else the active
one), which carries it out and reports it done (done_command). Only to a
device that is online. transfer: the device plays the shared item from
its place and so becomes the active one. sleep: its sleep timer, value =
minutes of playing time, -n = after n episodes, 0 = off."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
body, error = _player_body(request)
if error:
return error
action = body.get('action')
if action not in PLAYER_ACTIONS:
return JsonResponse({'error': 'unknown action'}, status=400)
value = None
if action in ('seek', 'skip'):
value = body.get('value')
if not _finite(value):
return JsonResponse({'error': 'value must be a number'}, status=400)
value = max(int(value), 0) if action == 'seek' else int(value)
elif action == 'sleep':
value = body.get('value')
if not _finite(value) or not -SLEEP_MAX_EPISODES <= int(value) <= SLEEP_MAX_MINUTES:
return JsonResponse({'error': f'value must be minutes (up to {SLEEP_MAX_MINUTES}), '
f'-episodes or 0'}, status=400)
value = int(value)
to = body.get('to_device_id')
if to is not None and (not isinstance(to, str) or len(to) > 64):
return JsonResponse({'error': 'invalid to_device_id'}, status=400)
now = timezone.now()
_seen(request.user, body['device_id'], body['device_name'], now=now)
p = _check_online(request.user, now)
to = to or p.device_id
if not to or not _is_online(request.user, to, now):
return JsonResponse({'error': 'device not online'}, status=409)
if action == 'transfer' and not p.kind:
return JsonResponse({'error': 'nothing to transfer'}, status=409)
p = _change(p, command={
'id': uuid.uuid4().hex,
'action': action,
'value': value,
'from_device_id': body['device_id'],
'from_device_name': body['device_name'],
'to_device_id': to,
'at': _iso_ms(now),
})
return JsonResponse(_player_json(request.user, p, now))
@csrf_exempt
@require_http_methods(['GET'])
def player_stream(request):
"""Server-sent events: the state at once if it is newer than ?since=<seq>,
then on every change; a ping every 15 s, and the end after a few minutes
(the client reconnects with the last seq it has). With device_id (and
device_name, device_kind app|web) the device counts as online meanwhile."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
since = int(request.GET.get('since', -1))
except ValueError:
return JsonResponse({'error': 'since must be a number'}, status=400)
user = request.user
device_id = request.GET.get('device_id', '')[:64]
_seen(user, device_id, request.GET.get('device_name', ''), request.GET.get('device_kind'))
_check_online(user)
def events():
last = since
first = True
start = pinged = checked = time.monotonic()
while True:
seq = PlayerState.objects.filter(user=user).values_list('seq', flat=True).first()
if seq is not None and (seq > last if first else seq != last):
p = PlayerState.objects.filter(user=user).first()
last = p.seq
yield f'data: {json.dumps(_player_json(user, p))}\n\n'
elif first:
yield ': ping\n\n' # the headers go out now: the client knows it's connected
first = False
now = time.monotonic()
if now - start >= STREAM_SECONDS:
return
if now - pinged >= STREAM_PING_SECONDS:
pinged = now
_seen(user, device_id)
yield ': ping\n\n'
if now - checked >= STREAM_PRESENCE_SECONDS:
checked = now
_check_online(user)
time.sleep(STREAM_POLL_SECONDS)
response = StreamingHttpResponse(events(), content_type='text/event-stream')
response['Cache-Control'] = 'no-cache'
response['X-Accel-Buffering'] = 'no'
return response