Warteschlange - PodcastSettings: wohin neue Folgen kommen, getrennt für Hinzufügen von Hand (auch beim Abspielen einer Folge außerhalb der Warteschlange) und für automatisch eingereihte neue Folgen: ans Ende, an den Anfang oder nach die laufende Folge. Voreinstellung wie bisher (Hand: Ende, automatisch: Anfang). GET/POST /podcasts/settings/. - queue/add nimmt auch mehrere Folgen (episode_ids), eine Position (where) und die laufende Folge (current_episode_id); ohne Angabe gilt die zuletzt angehörte, nicht fertige Folge als laufende. - Positionen werden lückenlos neu durchnummeriert. Feeds - POST /podcasts/feeds/refresh-all/ aktualisiert alle Feeds des Nutzers auf dem Server: Downloads parallel, jede Feed-Adresse nur einmal (auch über Nutzer hinweg), mit Timeout; kürzlich aktualisierte werden übersprungen. Der stündliche Cron-Job nutzt dasselbe. - GET /podcasts/state/ liefert Abos, Warteschlange, Inbox und Einstellungen in einer Antwort (für die Apps). - Web: „↻ All“ ist eine Anfrage statt einer pro Feed; die stündliche Schleife im Browser lädt nur noch die Listen neu, statt jeden Feed selbst anzustoßen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
877 lines
30 KiB
Python
877 lines
30 KiB
Python
import json
|
|
import logging
|
|
import urllib.parse
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from datetime import timedelta
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import feedparser
|
|
import requests
|
|
from django.conf import settings
|
|
from django.db.models import Max, Q
|
|
from django.http import JsonResponse
|
|
from django.utils import timezone
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
from django.views.decorators.http import require_http_methods
|
|
|
|
from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
FEED_TIMEOUT = 20 # seconds per feed download
|
|
FEED_WORKERS = 8 # feeds downloaded at the same time
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Queue positions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
QUEUE_POSITIONS = {key for key, _ in PodcastSettings.POSITIONS}
|
|
|
|
|
|
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 _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."""
|
|
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:])
|
|
return new
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared feed refresh helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _fetch_feed(url):
|
|
"""Download and parse a feed (with a timeout, unlike feedparser's own fetch)."""
|
|
r = requests.get(url, timeout=FEED_TIMEOUT, headers={'User-Agent': 'diora podcast refresh'})
|
|
r.raise_for_status()
|
|
return feedparser.parse(r.content, response_headers={'content-location': r.url, **r.headers})
|
|
|
|
|
|
def _refresh_feeds(feeds, chunk=32):
|
|
"""Refresh many feeds at once: the downloads run in parallel and each
|
|
feed URL is fetched once, even when several users subscribed to it; the
|
|
database writes then happen one by one. Returns (refreshed, failed, new)."""
|
|
feeds = list(feeds)
|
|
refreshed = failed = new = 0
|
|
for start in range(0, len(feeds), chunk):
|
|
part = feeds[start:start + chunk]
|
|
urls = sorted({f.rss_url for f in part})
|
|
results = {}
|
|
|
|
def fetch(url):
|
|
try:
|
|
return url, _fetch_feed(url), None
|
|
except Exception as e: # network, HTTP or parser trouble: report and go on
|
|
return url, None, e
|
|
|
|
with ThreadPoolExecutor(max_workers=min(FEED_WORKERS, len(urls) or 1)) as pool:
|
|
for url, parsed, err in pool.map(fetch, urls):
|
|
results[url] = (parsed, err)
|
|
|
|
for feed in part:
|
|
parsed, err = results[feed.rss_url]
|
|
if err is not None:
|
|
failed += 1
|
|
log.warning('refreshing %s failed: %s', feed.rss_url, err)
|
|
continue
|
|
try:
|
|
new += _refresh_feed(feed, parsed)
|
|
refreshed += 1
|
|
except Exception as e:
|
|
failed += 1
|
|
log.warning('updating %s failed: %s', feed.rss_url, e)
|
|
return refreshed, failed, new
|
|
|
|
|
|
def _refresh_feed(feed_obj, parsed=None):
|
|
"""Parse rss_url and upsert episodes. Returns count of new episodes."""
|
|
if parsed is None:
|
|
parsed = _fetch_feed(feed_obj.rss_url)
|
|
channel = parsed.feed
|
|
|
|
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', '')
|
|
)[:1000]
|
|
|
|
feed_obj.last_refreshed_at = timezone.now()
|
|
feed_obj.save()
|
|
|
|
max_ep = getattr(settings, 'PODCAST_MAX_EPISODES_PER_FEED', 200)
|
|
new_count = 0
|
|
new_episodes_list = []
|
|
|
|
for entry in parsed.entries[:max_ep]:
|
|
# Find audio enclosure
|
|
audio_url = ''
|
|
for enc in entry.get('enclosures', []):
|
|
mime = enc.get('type', '')
|
|
if mime.startswith('audio/') or enc.get('url', '').endswith(('.mp3', '.m4a', '.ogg', '.opus', '.aac')):
|
|
audio_url = enc.get('url', '')
|
|
break
|
|
if not audio_url:
|
|
continue
|
|
|
|
guid = entry.get('id') or entry.get('guid') or audio_url
|
|
guid = str(guid)[:1000]
|
|
title = entry.get('title', 'Untitled')[:500]
|
|
description = entry.get('summary', entry.get('description', ''))
|
|
|
|
duration_raw = entry.get('itunes_duration', 0)
|
|
duration_seconds = _parse_duration(duration_raw)
|
|
|
|
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
|
|
|
|
season_number = None
|
|
try:
|
|
season_number = int(entry.get('itunes_season', ''))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
artwork_url = 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],
|
|
'duration_seconds': duration_seconds,
|
|
'pub_date': pub_date,
|
|
'episode_number': ep_number,
|
|
'season_number': season_number,
|
|
'artwork_url': artwork_url,
|
|
},
|
|
)
|
|
if created:
|
|
new_count += 1
|
|
new_episodes_list.append(episode_obj)
|
|
|
|
if feed_obj.auto_queue and new_episodes_list:
|
|
where = PodcastSettings.for_user(feed_obj.user).auto_queue_position
|
|
# Newest first in front of what's there; oldest first when appended,
|
|
# so the end of the queue plays in the order the episodes came out.
|
|
newest_first = sorted(
|
|
new_episodes_list,
|
|
key=lambda e: e.pub_date or e.discovered_at,
|
|
reverse=True,
|
|
)
|
|
ordered = newest_first if where != PodcastSettings.BACK else newest_first[::-1]
|
|
_enqueue(feed_obj.user, [e.id for e in ordered], where)
|
|
|
|
return new_count
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Search
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def podcast_search(request):
|
|
q = request.GET.get('q', '').strip()
|
|
if not q:
|
|
return JsonResponse({'results': []})
|
|
|
|
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)})
|
|
|
|
|
|
def _feeds_data(user):
|
|
feeds = list(
|
|
user.podcast_feeds
|
|
.annotate(latest_episode_at=Max('episodes__pub_date'))
|
|
.values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author', 'added_at', 'auto_queue', 'latest_episode_at')
|
|
)
|
|
|
|
for f in feeds:
|
|
if f['last_refreshed_at']:
|
|
f['last_refreshed_at'] = f['last_refreshed_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)
|
|
|
|
feed, created = PodcastFeed.objects.get_or_create(
|
|
user=request.user,
|
|
rss_url=rss_url,
|
|
defaults={'title': body.get('title', rss_url)[:300]},
|
|
)
|
|
|
|
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)
|
|
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})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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', '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')
|
|
}
|
|
|
|
# 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['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,
|
|
},
|
|
'episodes': episodes,
|
|
})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
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},
|
|
)
|
|
if created:
|
|
added += 1
|
|
else:
|
|
skipped += 1
|
|
|
|
return JsonResponse({'ok': True, 'added': added, 'skipped': skipped})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
|
|
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/ …)."""
|
|
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 = request.user.podcast_feeds.filter(Q(last_refreshed_at__isnull=True) | Q(last_refreshed_at__lt=cutoff))
|
|
refreshed, failed, new = _refresh_feeds(feeds)
|
|
return JsonResponse({'ok': True, 'refreshed': refreshed, 'failed': failed, 'new_episodes': new})
|
|
|
|
|
|
@csrf_exempt
|
|
def podcast_state(request):
|
|
"""Everything a client shows first, in one response: feeds, queue, the
|
|
first page of the inbox and the settings."""
|
|
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)
|
|
return JsonResponse({
|
|
'feeds': _feeds_data(request.user),
|
|
'queue': _queue_data(request.user),
|
|
'inbox': _inbox_data(request.user, 0, limit),
|
|
'settings': {'enqueue_position': s.enqueue_position, 'auto_queue_position': s.auto_queue_position},
|
|
'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__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)
|
|
|
|
episode_id = body.get('episode_id')
|
|
PodcastQueue.objects.filter(user=request.user, episode_id=episode_id).delete()
|
|
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)
|
|
|
|
return JsonResponse({'ok': True})
|
|
|
|
|
|
@csrf_exempt
|
|
def podcast_settings(request):
|
|
"""GET the user's podcast settings; POST changes some of 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)
|
|
fields = []
|
|
for name in ('enqueue_position', 'auto_queue_position'):
|
|
if name in body:
|
|
if body[name] not in QUEUE_POSITIONS:
|
|
return JsonResponse({'error': f'unknown {name}'}, status=400)
|
|
setattr(s, name, body[name])
|
|
fields.append(name)
|
|
if fields:
|
|
s.save(update_fields=fields)
|
|
elif request.method != 'GET':
|
|
return JsonResponse({'error': 'method not allowed'}, status=405)
|
|
return JsonResponse({
|
|
'enqueue_position': s.enqueue_position,
|
|
'auto_queue_position': s.auto_queue_position,
|
|
'positions': [{'value': k, 'label': v} for k, v in PodcastSettings.POSITIONS],
|
|
})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
|
|
progress, _ = EpisodeProgress.objects.get_or_create(
|
|
user=request.user,
|
|
episode=episode,
|
|
)
|
|
progress.position_seconds = position
|
|
|
|
# Auto-mark played at 90%
|
|
if episode.duration_seconds and episode.duration_seconds > 0:
|
|
if position >= episode.duration_seconds * 0.9:
|
|
progress.played = True
|
|
|
|
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
|
|
progress.save(update_fields=['played', 'updated_at'])
|
|
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)})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_data(user, offset, limit):
|
|
hidden_ids = set(
|
|
EpisodeProgress.objects.filter(
|
|
user=user,
|
|
).filter(
|
|
Q(played=True) | Q(dismissed=True)
|
|
).values_list('episode_id', flat=True)
|
|
)
|
|
|
|
episodes = list(
|
|
PodcastEpisode.objects.filter(feed__user=user)
|
|
.exclude(id__in=hidden_ids)
|
|
.select_related('feed')
|
|
.order_by('-pub_date')
|
|
.values(
|
|
'id', 'title', 'description', 'audio_url', 'duration_seconds',
|
|
'pub_date', 'artwork_url',
|
|
'feed__id', 'feed__title', 'feed__artwork_url',
|
|
)[offset:offset + limit]
|
|
)
|
|
|
|
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
|