diora-web/podcasts/views.py

878 lines
30 KiB
Python
Raw Normal View History

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